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:
34
src/cosma_log_analyzer/rules/__init__.py
Normal file
34
src/cosma_log_analyzer/rules/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
47
src/cosma_log_analyzer/rules/base.py
Normal file
47
src/cosma_log_analyzer/rules/base.py
Normal 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,
|
||||||
|
)
|
||||||
62
src/cosma_log_analyzer/rules/battery_low.py
Normal file
62
src/cosma_log_analyzer/rules/battery_low.py
Normal 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
|
||||||
52
src/cosma_log_analyzer/rules/imu_outliers.py
Normal file
52
src/cosma_log_analyzer/rules/imu_outliers.py
Normal 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
|
||||||
50
src/cosma_log_analyzer/rules/usbl_distance_spike.py
Normal file
50
src/cosma_log_analyzer/rules/usbl_distance_spike.py
Normal 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
|
||||||
52
src/cosma_log_analyzer/rules/usbl_snr_low.py
Normal file
52
src/cosma_log_analyzer/rules/usbl_snr_low.py
Normal 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
|
||||||
43
src/cosma_log_analyzer/rules/watchdog_imu.py
Normal file
43
src/cosma_log_analyzer/rules/watchdog_imu.py
Normal 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
|
||||||
Reference in New Issue
Block a user