feat: add 5 deterministic rules (IMU outliers/watchdog, USBL SNR/spike, battery_low)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
floppyrj45
2026-04-19 15:20:17 +00:00
parent 67b2121add
commit 668d84c187
7 changed files with 340 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
"""Deterministic rules v0.
Adding a new rule = subclass Rule, register it in `ALL_RULES`, add a test.
"""
from __future__ import annotations
from .base import Rule
from .battery_low import BatteryLowRule
from .imu_outliers import ImuOutliersRule
from .usbl_distance_spike import UsblDistanceSpikeRule
from .usbl_snr_low import UsblSnrLowRule
from .watchdog_imu import WatchdogImuRule
def all_rules() -> list[Rule]:
"""Default rule set, instantiated with env/default thresholds."""
return [
ImuOutliersRule(),
WatchdogImuRule(),
UsblSnrLowRule(),
UsblDistanceSpikeRule(),
BatteryLowRule(),
]
__all__ = [
"Rule",
"ImuOutliersRule",
"WatchdogImuRule",
"UsblSnrLowRule",
"UsblDistanceSpikeRule",
"BatteryLowRule",
"all_rules",
]

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from abc import ABC, abstractmethod
import pandas as pd
from ..models import Anomaly
class Rule(ABC):
"""Base class for detection rules.
Subclasses declare class attributes `name`, `topic`, `severity`, then
implement `detect(df)` which receives the topic-specific DataFrame and
returns a list of Anomaly instances.
"""
name: str = "rule"
topic: str = ""
severity: str = "warn"
def __init__(self, subject: str = "AUV000") -> None:
self.subject = subject
def bind(self, subject: str) -> "Rule":
self.subject = subject
return self
@abstractmethod
def detect(self, df: pd.DataFrame) -> list[Anomaly]:
...
def _make(
self,
ts: float,
value: float | None,
context: dict,
) -> Anomaly:
return Anomaly(
rule=self.name,
severity=self.severity,
timestamp=float(ts),
subject=self.subject,
topic=self.topic,
value=value,
context=context,
)

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
import os
import pandas as pd
from ..ingest import TOPIC_BATTERY
from ..models import Anomaly
from .base import Rule
class BatteryLowRule(Rule):
"""Fire when voltage < threshold for more than `min_duration_s`."""
name = "battery_low"
topic = TOPIC_BATTERY
severity = "critical"
def __init__(
self,
subject: str = "AUV000",
min_voltage_v: float | None = None,
min_duration_s: float = 5.0,
) -> None:
super().__init__(subject)
if min_voltage_v is None:
min_voltage_v = float(os.environ.get("BATTERY_LOW_V", 13.5))
self.min_voltage_v = min_voltage_v
self.min_duration_s = min_duration_s
def detect(self, df: pd.DataFrame) -> list[Anomaly]:
if df.empty:
return []
below = df["voltage_v"] < self.min_voltage_v
if not below.any():
return []
run_id = (~below).cumsum()
anomalies: list[Anomaly] = []
for rid, group in df[below].groupby(run_id[below]):
duration = float(group["ts"].iloc[-1] - group["ts"].iloc[0])
if duration < self.min_duration_s:
continue
fire_row = None
for _, row in group.iterrows():
if row["ts"] - group["ts"].iloc[0] >= self.min_duration_s:
fire_row = row
break
if fire_row is None:
fire_row = group.iloc[-1]
anomalies.append(
self._make(
ts=float(fire_row["ts"]),
value=float(fire_row["voltage_v"]),
context={
"min_voltage_v": self.min_voltage_v,
"min_duration_s": self.min_duration_s,
"run_start_ts": float(group["ts"].iloc[0]),
"below_duration_s": duration,
},
)
)
return anomalies

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
import numpy as np
import pandas as pd
from ..ingest import TOPIC_IMU
from ..models import Anomaly
from .base import Rule
class ImuOutliersRule(Rule):
"""Fire when instant accel magnitude deviates > z_thresh rolling sigmas."""
name = "imu_outliers"
topic = TOPIC_IMU
severity = "warn"
def __init__(
self,
subject: str = "AUV000",
window_s: float = 10.0,
z_thresh: float = 3.0,
) -> None:
super().__init__(subject)
self.window_s = window_s
self.z_thresh = z_thresh
def detect(self, df: pd.DataFrame) -> list[Anomaly]:
if df.empty or len(df) < 10:
return []
mag = np.sqrt(df["ax"] ** 2 + df["ay"] ** 2 + df["az"] ** 2)
dt = float(df["ts"].diff().median() or 0.02)
window = max(5, int(self.window_s / dt))
baseline_mean = mag.rolling(window, min_periods=window // 2).mean()
baseline_std = mag.rolling(window, min_periods=window // 2).std()
z = (mag - baseline_mean) / baseline_std.replace(0, np.nan)
mask = z.abs() > self.z_thresh
anomalies: list[Anomaly] = []
for idx in df.index[mask.fillna(False)]:
anomalies.append(
self._make(
ts=float(df.at[idx, "ts"]),
value=float(mag.iloc[idx]),
context={
"window_s": self.window_s,
"z_score": float(z.iloc[idx]),
"baseline_mean": float(baseline_mean.iloc[idx]),
"baseline_std": float(baseline_std.iloc[idx]),
},
)
)
return anomalies

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
import os
import pandas as pd
from ..ingest import TOPIC_USBL
from ..models import Anomaly
from .base import Rule
class UsblDistanceSpikeRule(Rule):
"""Fire when |Δdistance| > spike_m within less than max_dt_s."""
name = "usbl_distance_spike"
topic = TOPIC_USBL
severity = "warn"
def __init__(
self,
subject: str = "AUV000",
spike_m: float | None = None,
max_dt_s: float = 1.0,
) -> None:
super().__init__(subject)
if spike_m is None:
spike_m = float(os.environ.get("USBL_DIST_SPIKE_M", 50.0))
self.spike_m = spike_m
self.max_dt_s = max_dt_s
def detect(self, df: pd.DataFrame) -> list[Anomaly]:
if df.empty or len(df) < 2:
return []
dt = df["ts"].diff()
ddist = df["distance_m"].diff().abs()
mask = (ddist > self.spike_m) & (dt < self.max_dt_s)
anomalies: list[Anomaly] = []
for idx in df.index[mask.fillna(False)]:
anomalies.append(
self._make(
ts=float(df.at[idx, "ts"]),
value=float(ddist.iloc[idx]),
context={
"delta_m": float(ddist.iloc[idx]),
"dt_s": float(dt.iloc[idx]),
"spike_m": self.spike_m,
},
)
)
return anomalies

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
import os
import pandas as pd
from ..ingest import TOPIC_USBL
from ..models import Anomaly
from .base import Rule
class UsblSnrLowRule(Rule):
"""Fire when SNR stays below threshold for `consec` consecutive samples."""
name = "usbl_snr_low"
topic = TOPIC_USBL
severity = "warn"
def __init__(
self,
subject: str = "AUV000",
min_snr_db: float | None = None,
consec: int = 3,
) -> None:
super().__init__(subject)
if min_snr_db is None:
min_snr_db = float(os.environ.get("USBL_SNR_LOW", 5.0))
self.min_snr_db = min_snr_db
self.consec = consec
def detect(self, df: pd.DataFrame) -> list[Anomaly]:
if df.empty or len(df) < self.consec:
return []
low = df["snr_db"] < self.min_snr_db
run = low.astype(int).groupby((~low).cumsum()).cumsum()
anomalies: list[Anomaly] = []
fired_run = -1
runs_id = (~low).cumsum()
for idx in df.index:
if run.iloc[idx] >= self.consec and runs_id.iloc[idx] != fired_run:
fired_run = int(runs_id.iloc[idx])
anomalies.append(
self._make(
ts=float(df.at[idx, "ts"]),
value=float(df.at[idx, "snr_db"]),
context={
"min_snr_db": self.min_snr_db,
"consec": self.consec,
},
)
)
return anomalies

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import os
import pandas as pd
from ..ingest import TOPIC_IMU
from ..models import Anomaly
from .base import Rule
class WatchdogImuRule(Rule):
"""Fire when the gap between two IMU messages exceeds `max_gap_s`."""
name = "watchdog_imu"
topic = TOPIC_IMU
severity = "critical"
def __init__(self, subject: str = "AUV000", max_gap_s: float | None = None) -> None:
super().__init__(subject)
if max_gap_s is None:
max_gap_s = float(os.environ.get("WATCHDOG_IMU_S", 2.0))
self.max_gap_s = max_gap_s
def detect(self, df: pd.DataFrame) -> list[Anomaly]:
if df.empty or len(df) < 2:
return []
gaps = df["ts"].diff()
mask = gaps > self.max_gap_s
anomalies: list[Anomaly] = []
for idx in df.index[mask.fillna(False)]:
anomalies.append(
self._make(
ts=float(df.at[idx, "ts"]),
value=float(gaps.iloc[idx]),
context={
"gap_s": float(gaps.iloc[idx]),
"max_gap_s": self.max_gap_s,
"prev_ts": float(df.at[idx - 1, "ts"]),
},
)
)
return anomalies