Files
AareCommon/src/aarecommon/recurrence_watcher.py
T
perl_d 5dc939a5ab
CI / lint (push) Skipped
CI / test (3.11) (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / lint (pull_request) Failing after 28s
CI / test (3.11) (pull_request) Skipped
CI / test (3.12) (pull_request) Skipped
CI / test (3.13) (pull_request) Skipped
ci: add more linting steps
2026-08-06 17:41:17 +02:00

127 lines
3.9 KiB
Python

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)