from __future__ import annotations from collections import deque from dataclasses import dataclass from typing import Any, Deque, Dict, Optional def _safe_float(v: Any, default: float) -> float: try: return float(v) except Exception: return float(default) def _safe_int(v: Any, default: int) -> int: try: return int(v) except Exception: return int(default) def _as_bool(v: Any, default: bool) -> bool: if v is None: return default if isinstance(v, bool): return v if isinstance(v, (int, float)): return bool(v) return str(v).strip().lower() in ("1", "true", "yes", "on") @dataclass class LocalizationStateConfig: enabled: bool = True window_size: int = 12 degraded_fitness: float = 0.20 lost_fitness: float = 0.10 degraded_rmse: float = 0.45 lost_rmse: float = 0.80 max_bad_before_lost: int = 4 min_good_to_recover: int = 3 recovery_cooldown_sec: float = 2.0 @staticmethod def from_dict(d: Dict[str, Any] | None) -> "LocalizationStateConfig": src = d or {} return LocalizationStateConfig( enabled=_as_bool(src.get("enabled", True), True), window_size=max(3, _safe_int(src.get("window_size", 12), 12)), degraded_fitness=max(0.0, min(1.0, _safe_float(src.get("degraded_fitness", 0.20), 0.20))), lost_fitness=max(0.0, min(1.0, _safe_float(src.get("lost_fitness", 0.10), 0.10))), degraded_rmse=max(0.01, _safe_float(src.get("degraded_rmse", 0.45), 0.45)), lost_rmse=max(0.02, _safe_float(src.get("lost_rmse", 0.80), 0.80)), max_bad_before_lost=max(2, _safe_int(src.get("max_bad_before_lost", 4), 4)), min_good_to_recover=max(1, _safe_int(src.get("min_good_to_recover", 3), 3)), recovery_cooldown_sec=max(0.1, _safe_float(src.get("recovery_cooldown_sec", 2.0), 2.0)), ) class LocalizationStateMachine: def __init__(self, cfg: LocalizationStateConfig): self.cfg = cfg self.state = "TRACKING" self._history: Deque[bool] = deque(maxlen=int(self.cfg.window_size)) self._consecutive_bad = 0 self._consecutive_good = 0 self._last_update_t = 0.0 self._last_recovery_t = 0.0 def reset(self) -> None: self.state = "TRACKING" self._history.clear() self._consecutive_bad = 0 self._consecutive_good = 0 self._last_update_t = 0.0 self._last_recovery_t = 0.0 def set_enabled(self, enabled: bool) -> None: self.cfg.enabled = bool(enabled) if not self.cfg.enabled: self.reset() self.state = "TRACKING" def enter_recovery(self, now: float) -> None: if not self.cfg.enabled: return self.state = "RECOVERY" self._last_recovery_t = float(now) def _is_good(self, result: Optional[Dict[str, Any]]) -> bool: if result is None: return False if bool(result.get("accepted", False)): return True fit = float(result.get("fitness", 0.0)) rmse = float(result.get("rmse", 9e9)) if fit >= float(self.cfg.degraded_fitness) and rmse <= float(self.cfg.degraded_rmse): return True return False def _is_lost_like(self, result: Optional[Dict[str, Any]]) -> bool: if result is None: return True fit = float(result.get("fitness", 0.0)) rmse = float(result.get("rmse", 9e9)) return (fit < float(self.cfg.lost_fitness)) or (rmse > float(self.cfg.lost_rmse)) def update(self, result: Optional[Dict[str, Any]], now: float) -> str: if not self.cfg.enabled: self.state = "TRACKING" return self.state good = self._is_good(result) self._history.append(good) self._last_update_t = float(now) if self.state == "RECOVERY": if good: self._consecutive_good += 1 self._consecutive_bad = 0 if self._consecutive_good >= int(self.cfg.min_good_to_recover): self.state = "TRACKING" else: self._consecutive_bad += 1 self._consecutive_good = 0 self.state = "LOST" return self.state if good: self._consecutive_good += 1 self._consecutive_bad = 0 if self.state in ("DEGRADED", "LOST") and self._consecutive_good >= int(self.cfg.min_good_to_recover): self.state = "TRACKING" else: self._consecutive_bad += 1 self._consecutive_good = 0 if self._is_lost_like(result) and self._consecutive_bad >= int(self.cfg.max_bad_before_lost): self.state = "LOST" elif self.state == "TRACKING": self.state = "DEGRADED" elif self.state == "LOST": self.state = "LOST" else: self.state = "DEGRADED" return self.state def should_recover(self, now: float) -> bool: if self.state not in ("LOST", "RECOVERY"): return False return (float(now) - float(self._last_recovery_t)) >= float(self.cfg.recovery_cooldown_sec) def mark_recovery_attempt(self, now: float) -> None: self._last_recovery_t = float(now) def snapshot(self) -> Dict[str, Any]: good_ratio = 0.0 if len(self._history) > 0: good_ratio = float(sum(1 for x in self._history if x)) / float(len(self._history)) return { "enabled": bool(self.cfg.enabled), "state": self.state, "history_len": int(len(self._history)), "good_ratio": float(good_ratio), "consecutive_bad": int(self._consecutive_bad), "consecutive_good": int(self._consecutive_good), }