Skip to content

Commit 7eff493

Browse files
RFingAdamclaude
andcommitted
feat: cal-drift alerting, recert reminder, scheduled monitor (v5.2 W3)
- evaluate_drift_alert + cal_drift_alert: classify a drift comparison as OK/WARN/ALERT against warn/alert dB thresholds (worst per-pol mean & max abs), and flag setup_group mismatch. Closes #4. - gain_standard_recert_status + cal_drift_recert_check: gain-standard recertification status (ok/due/unknown) from cal date + recert interval + last recert. Closes #3. - cal_drift_monitor: cron-friendly — compare the most-recent recorded run to a baseline and alert; intended to be scheduled. Closes #33. Pure functions are unit-tested directly; the MCP tools never raise. +3 tools (50 total); +8 tests; full suite 391 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 05ce769 commit 7eff493

3 files changed

Lines changed: 290 additions & 0 deletions

File tree

plot_antenna/cal_drift.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -837,6 +837,93 @@ def compute_drift(baseline_run_id: str, current_run_id: str) -> DriftResult:
837837
)
838838

839839

840+
def evaluate_drift_alert(
841+
result: "DriftResult", warn_db: float = 0.5, alert_db: float = 1.0
842+
) -> dict:
843+
"""Classify a DriftResult against warn/alert thresholds (issue #4).
844+
845+
Looks at the worst per-polarization mean drift and max absolute drift and
846+
returns a level — "OK", "WARN", or "ALERT" — with the triggering reasons.
847+
Pure function (no IO); thresholds default to the module's soft/hard outlier
848+
levels.
849+
850+
Returns:
851+
{level, worst_mean_abs_db, worst_max_abs_db, warn_db, alert_db, reasons}
852+
"""
853+
stats = result.stats
854+
worst_mean = max(abs(stats["H"]["mean"]), abs(stats["V"]["mean"]))
855+
worst_max = max(stats["H"]["max_abs"], stats["V"]["max_abs"])
856+
reasons = []
857+
level = "OK"
858+
if worst_max >= alert_db or worst_mean >= alert_db:
859+
level = "ALERT"
860+
reasons.append(f"drift >= alert threshold {alert_db} dB")
861+
elif worst_max >= warn_db or worst_mean >= warn_db:
862+
level = "WARN"
863+
reasons.append(f"drift >= warn threshold {warn_db} dB")
864+
# Setup-group mismatch is always worth flagging.
865+
sg = result.consistency.get("setup_group")
866+
if sg and isinstance(sg, (list, tuple)) and sg[0] == "mismatch":
867+
if level == "OK":
868+
level = "WARN"
869+
reasons.append(f"setup_group mismatch ({sg[1]!r} vs {sg[2]!r})")
870+
return {
871+
"level": level,
872+
"worst_mean_abs_db": round(worst_mean, 4),
873+
"worst_max_abs_db": round(worst_max, 4),
874+
"warn_db": warn_db,
875+
"alert_db": alert_db,
876+
"reasons": reasons,
877+
}
878+
879+
880+
def gain_standard_recert_status(
881+
cal_date: str, interval_months: int = 12, last_recert_date: Optional[str] = None
882+
) -> dict:
883+
"""Gain-standard recertification status for a cal run (issue #3).
884+
885+
Given the calibration date and the recert interval, report whether the
886+
gain standard's certification was current as of the cal (relative to its
887+
last recert). Pure date math. Dates are "YYYY-MM-DD".
888+
889+
Returns:
890+
{status: "ok"|"due"|"unknown", months_since_recert, interval_months,
891+
due_date, cal_date, last_recert_date}
892+
"""
893+
from datetime import date
894+
895+
out = {
896+
"status": "unknown",
897+
"months_since_recert": None,
898+
"interval_months": interval_months,
899+
"due_date": None,
900+
"cal_date": cal_date,
901+
"last_recert_date": last_recert_date,
902+
}
903+
if not last_recert_date:
904+
return out # no recert reference -> unknown
905+
906+
def _parse(s):
907+
return date.fromisoformat(s)
908+
909+
try:
910+
cal = _parse(cal_date)
911+
recert = _parse(last_recert_date)
912+
except (ValueError, TypeError):
913+
return out
914+
915+
months = (cal.year - recert.year) * 12 + (cal.month - recert.month)
916+
# due date = recert + interval (approx by months)
917+
due_year = recert.year + (recert.month - 1 + interval_months) // 12
918+
due_month = (recert.month - 1 + interval_months) % 12 + 1
919+
due_day = min(recert.day, 28)
920+
due = date(due_year, due_month, due_day)
921+
out["months_since_recert"] = months
922+
out["due_date"] = due.isoformat()
923+
out["status"] = "due" if cal > due else "ok"
924+
return out
925+
926+
840927
# ──────────────────────────────────────────────────────────────────────────
841928
# Plotting + reporting
842929
# ──────────────────────────────────────────────────────────────────────────

rflect-mcp/tools/cal_drift_tools.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,105 @@ def cal_drift_set_notes(run_id: str, notes: str) -> bool:
151151
return cal_drift.update_notes(run_id, notes)
152152

153153

154+
def cal_drift_alert(
155+
baseline_run_id: str,
156+
current_run_id: str,
157+
warn_db: float = 0.5,
158+
alert_db: float = 1.0,
159+
) -> Dict[str, Any]:
160+
"""Compare two cal runs and classify the drift as OK / WARN / ALERT (#4).
161+
162+
Computes the drift between baseline and current and applies warn/alert
163+
thresholds to the worst per-polarization mean and max-absolute drift; also
164+
flags a setup_group mismatch.
165+
166+
Args:
167+
baseline_run_id, current_run_id: run_ids from cal_drift_list_runs.
168+
warn_db: drift at/above this -> WARN (default 0.5 dB).
169+
alert_db: drift at/above this -> ALERT (default 1.0 dB).
170+
171+
Returns:
172+
Dict: level, worst_mean_abs_db, worst_max_abs_db, reasons, warn_db,
173+
alert_db. On failure: {error}. Never raises.
174+
"""
175+
try:
176+
result = cal_drift.compute_drift(baseline_run_id, current_run_id)
177+
except Exception as exc:
178+
return {"error": f"cal_drift_alert failed: {exc}"}
179+
out = cal_drift.evaluate_drift_alert(result, warn_db=warn_db, alert_db=alert_db)
180+
out["baseline_run_id"] = baseline_run_id
181+
out["current_run_id"] = current_run_id
182+
return out
183+
184+
185+
def cal_drift_monitor(
186+
baseline_run_id: str,
187+
antenna: Optional[str] = None,
188+
band: Optional[str] = None,
189+
warn_db: float = 0.5,
190+
alert_db: float = 1.0,
191+
) -> Dict[str, Any]:
192+
"""Compare the most-recent cal run to a baseline and alert (#33).
193+
194+
Cron-friendly: finds the latest recorded run (optionally filtered by
195+
antenna/band), compares it to the baseline, and returns the drift alert.
196+
Intended to be scheduled so chamber drift is caught automatically.
197+
198+
Args:
199+
baseline_run_id: the reference run to compare against.
200+
antenna, band: optional filters to select the latest run.
201+
warn_db, alert_db: alert thresholds (dB).
202+
203+
Returns:
204+
Dict: latest_run_id, plus the cal_drift_alert fields. On failure:
205+
{error}. Never raises.
206+
"""
207+
try:
208+
df = cal_drift.list_runs(antenna=antenna, band=band)
209+
if df is None or len(df) == 0:
210+
return {"error": "no_recorded_runs", "warnings": ["nothing to monitor"]}
211+
# Latest by date then time if present.
212+
sort_cols = [c for c in ("date", "time") if c in df.columns]
213+
latest = df.sort_values(sort_cols).iloc[-1] if sort_cols else df.iloc[-1]
214+
latest_id = str(latest["run_id"])
215+
except Exception as exc:
216+
return {"error": f"cal_drift_monitor failed: {exc}"}
217+
if latest_id == baseline_run_id:
218+
return {
219+
"latest_run_id": latest_id,
220+
"level": "OK",
221+
"reasons": ["latest run is the baseline; nothing newer to compare"],
222+
}
223+
out = cal_drift_alert(baseline_run_id, latest_id, warn_db=warn_db, alert_db=alert_db)
224+
out["latest_run_id"] = latest_id
225+
return out
226+
227+
228+
def cal_drift_recert_check(
229+
cal_date: str,
230+
interval_months: int = 12,
231+
last_recert_date: Optional[str] = None,
232+
) -> Dict[str, Any]:
233+
"""Gain-standard recertification status for a cal run (#3).
234+
235+
Reports whether the gain standard's certification was current as of the
236+
cal date, given its last recert and the recert interval.
237+
238+
Args:
239+
cal_date: calibration date "YYYY-MM-DD".
240+
interval_months: recert validity interval (default 12).
241+
last_recert_date: last recertification date "YYYY-MM-DD" (required for
242+
a definite verdict; otherwise status is "unknown").
243+
244+
Returns:
245+
Dict: status ("ok"|"due"|"unknown"), months_since_recert, due_date,
246+
interval_months, cal_date, last_recert_date. Never raises.
247+
"""
248+
return cal_drift.gain_standard_recert_status(
249+
cal_date, interval_months=interval_months, last_recert_date=last_recert_date
250+
)
251+
252+
154253
def register_cal_drift_tools(mcp):
155254
"""Register calibration-drift tools with the MCP server."""
156255
mcp.tool()(cal_drift_ingest)
@@ -161,3 +260,6 @@ def register_cal_drift_tools(mcp):
161260
mcp.tool()(cal_drift_set_history_dir)
162261
mcp.tool()(cal_drift_set_setup_group)
163262
mcp.tool()(cal_drift_set_notes)
263+
mcp.tool()(cal_drift_alert)
264+
mcp.tool()(cal_drift_monitor)
265+
mcp.tool()(cal_drift_recert_check)

tests/test_cal_drift_v52.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""v5.2 cal-drift extensions: drift alert (#4), recert reminder (#3),
2+
monitor (#33)."""
3+
4+
from __future__ import annotations
5+
6+
import os
7+
import sys
8+
9+
import pytest
10+
11+
import matplotlib
12+
13+
matplotlib.use("Agg")
14+
15+
from plot_antenna.cal_drift import (
16+
DriftResult,
17+
evaluate_drift_alert,
18+
gain_standard_recert_status,
19+
)
20+
21+
22+
def _dr(h_mean, h_max, v_mean=0.0, v_max=0.0, consistency=None):
23+
return DriftResult(
24+
deltas=None,
25+
stats={
26+
"H": {"mean": h_mean, "max_abs": h_max},
27+
"V": {"mean": v_mean, "max_abs": v_max},
28+
},
29+
consistency=consistency or {},
30+
missing_audit={},
31+
baseline=None,
32+
current=None,
33+
)
34+
35+
36+
# ----------------------------- #4 drift alert -----------------------------
37+
38+
39+
def test_alert_levels():
40+
assert evaluate_drift_alert(_dr(0.2, 0.3))["level"] == "OK"
41+
assert evaluate_drift_alert(_dr(0.2, 0.7))["level"] == "WARN" # max 0.7 >= 0.5
42+
assert evaluate_drift_alert(_dr(0.2, 1.3))["level"] == "ALERT" # max 1.3 >= 1.0
43+
assert evaluate_drift_alert(_dr(1.2, 0.3))["level"] == "ALERT" # mean 1.2 >= 1.0
44+
45+
46+
def test_alert_setup_group_mismatch_escalates_ok_to_warn():
47+
out = evaluate_drift_alert(_dr(0.1, 0.2, consistency={"setup_group": ("mismatch", "a", "b")}))
48+
assert out["level"] == "WARN"
49+
assert any("setup_group mismatch" in r for r in out["reasons"])
50+
51+
52+
def test_alert_custom_thresholds():
53+
# With a tighter warn threshold, a 0.3 dB drift trips WARN.
54+
out = evaluate_drift_alert(_dr(0.0, 0.3), warn_db=0.2, alert_db=0.5)
55+
assert out["level"] == "WARN"
56+
57+
58+
# ----------------------------- #3 recert reminder -----------------------------
59+
60+
61+
def test_recert_ok_within_interval():
62+
out = gain_standard_recert_status(
63+
"2026-03-01", interval_months=12, last_recert_date="2025-06-01"
64+
)
65+
assert out["status"] == "ok"
66+
assert out["due_date"] == "2026-06-01"
67+
68+
69+
def test_recert_due_past_interval():
70+
out = gain_standard_recert_status(
71+
"2026-09-01", interval_months=12, last_recert_date="2025-06-01"
72+
)
73+
assert out["status"] == "due"
74+
75+
76+
def test_recert_unknown_without_reference():
77+
assert gain_standard_recert_status("2026-03-01")["status"] == "unknown"
78+
79+
80+
def test_recert_bad_date_is_unknown():
81+
assert gain_standard_recert_status("not-a-date", 12, "2025-06-01")["status"] == "unknown"
82+
83+
84+
# ----------------------------- #4/#33 MCP never-raise -----------------------------
85+
86+
87+
def test_mcp_alert_and_monitor_never_raise_on_missing_runs():
88+
pytest.importorskip("mcp", reason="mcp package not installed")
89+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), "rflect-mcp"))
90+
from mcp.server.fastmcp import FastMCP
91+
from tools.cal_drift_tools import register_cal_drift_tools
92+
93+
mcp = FastMCP("cd-v52")
94+
register_cal_drift_tools(mcp)
95+
reg = mcp._tool_manager._tools
96+
97+
alert = reg["cal_drift_alert"].fn("nope-a", "nope-b")
98+
assert isinstance(alert, dict) and "error" in alert
99+
100+
recert = reg["cal_drift_recert_check"].fn("2026-03-01", 12, "2025-06-01")
101+
assert recert["status"] == "ok"

0 commit comments

Comments
 (0)