from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from typing import TypeAlias from aarecommon.errors import exception_handler from aarecommon.errors.exception_handler import ( AareException, LoopCenteringFailed, SmargonException, TellException, TransformationInvalidException, ) ExceptionType: TypeAlias = type[AareException] @dataclass(frozen=True) class WatcherTrip: name: str threshold: int streak: int exception_class: str def message(self) -> str: return ( f"{self.streak} consecutive {self.name} errors across samples " f"({self.exception_class}) — automation halted" ) class RecurrenceWatcher: def __init__(self, *, name: str, observes: ExceptionType, threshold: int): self.name = name self._observes = observes self.threshold = max(1, int(threshold)) self._streak = 0 @property def streak(self) -> int: return self._streak def reset(self) -> None: self._streak = 0 def observe(self, exception_class: type | None) -> bool: if exception_class is None: self._streak = 0 return False if not isinstance(exception_class, type) or not issubclass(exception_class, self._observes): return False self._streak += 1 return self._streak >= self.threshold def maybe_trip(self, exception_class: type | None) -> WatcherTrip | None: if not self.observe(exception_class): return None class_name = exception_class.__name__ if isinstance(exception_class, type) else "Unknown" return WatcherTrip( name=self.name, threshold=self.threshold, streak=self._streak, exception_class=class_name, ) DEFAULT_WATCHERS: tuple[tuple[str, ExceptionType, int], ...] = ( ("tell", TellException, 5), ("alc", LoopCenteringFailed, 3), ("transformation", TransformationInvalidException, 3), ("smargon", SmargonException, 3), ) def create_default_watchers(overrides: dict[str, int] | None = None) -> list[RecurrenceWatcher]: thresholds = dict(overrides or {}) return [ RecurrenceWatcher(name=name, observes=observes, threshold=thresholds.get(name, threshold)) for name, observes, threshold in DEFAULT_WATCHERS ] def redis_key_to_env_var(key: str) -> str: """Adapter from canonical Redis key shape to an env-var-safe name. ``aare:watchers:{bl}:{watcher}:threshold`` → ``AARE_WATCHERS_{BL}_{WATCHER}_THRESHOLD``. Used by the GUI today since it has no Redis client; the loader's ``get_value`` argument keeps the Redis key shape authoritative so the DAQ side can later swap in ``cfg.get`` without churn (plan §7).""" return key.replace(":", "_").upper() def load_watcher_threshold_overrides( *, beamline: str, watcher_names: list[str], get_value: Callable[[str], str | bytes | None] ) -> dict[str, int]: overrides: dict[str, int] = {} for watcher_name in watcher_names: key = f"aare:watchers:{beamline}:{watcher_name}:threshold" raw_value = get_value(key) if raw_value is None: continue if isinstance(raw_value, bytes): raw_value = raw_value.decode("utf-8", errors="ignore") try: parsed = int(str(raw_value).strip()) except (TypeError, ValueError): continue if parsed > 0: overrides[watcher_name] = parsed return overrides _EXCEPTION_CLASS_BY_NAME: dict[str, type] = { name: klass for name, klass in vars(exception_handler).items() if isinstance(klass, type) and issubclass(klass, AareException) } def resolve_exception_class(exception_class_name: str | None) -> type | None: if not exception_class_name: return None return _EXCEPTION_CLASS_BY_NAME.get(exception_class_name)