feat(scada): add calibrated monitoring and incident recovery

This commit is contained in:
2026-11-07 19:44:58 +08:00
parent 26fa5d1909
commit ff8aba4964
109 changed files with 9165 additions and 629 deletions
+99
View File
@@ -0,0 +1,99 @@
"""Learn pressure thresholds from pre-event normal history, never burst truth."""
from __future__ import annotations
import math
from datetime import datetime, timedelta
from statistics import median
def learn_pressure_thresholds(
rows: list[dict], device_ids: list[str], cutoff: datetime, *,
history_days: int = 15, interval_seconds: int = 900,
relative_floor_percent: float = 3.0, noise_multiplier: float = 3.0,
absolute_floor_m: float = 0.5,
) -> dict:
"""Estimate noise after removing each daily slot's median operating pattern."""
if (cutoff.tzinfo is None or history_days < 1 or interval_seconds <= 0
or 86400 % interval_seconds or not device_ids or len(set(device_ids)) != len(device_ids)):
raise ValueError("an aware cutoff and whole-day sampling grid are required")
if not all(math.isfinite(value) and value > 0 for value in (relative_floor_percent, noise_multiplier, absolute_floor_m)):
raise ValueError("threshold floors and noise multiplier must be positive")
count = history_days * 86400 // interval_seconds
start = cutoff - timedelta(days=history_days)
expected = {start + timedelta(seconds=interval_seconds * index) for index in range(count)}
data = {device: {} for device in device_ids}
for row in rows:
device = row["device_id"]
if device not in data:
continue
at = row["time"]
if at.tzinfo is None or at >= cutoff:
raise ValueError("adaptive fitting must not consume event or future observations")
if at < start:
continue
value = row["monitored_value"]
if at not in expected or at in data[device] or value is None or not math.isfinite(float(value)) or float(value) <= 0:
raise ValueError("adaptive fitting requires distinct positive finite pressure samples on the grid")
data[device][at] = float(value)
insufficient = {device: len(values) for device, values in data.items() if set(values) != expected}
if insufficient:
return {"status": "history_not_ready", "required_samples_per_device": count,
"available_samples": insufficient, "knowledge_cutoff": cutoff.isoformat(), "devices": {}}
fitted = {}
for device, values in data.items():
slots = {}
for at, value in values.items():
slot = int((at - start).total_seconds()) // interval_seconds % (86400 // interval_seconds)
slots.setdefault(slot, []).append(value)
references = {slot: median(group) for slot, group in slots.items()}
residuals = []
for at, value in values.items():
slot = int((at - start).total_seconds()) // interval_seconds % (86400 // interval_seconds)
residuals.append(100 * (value / references[slot] - 1))
center = median(residuals)
robust_sigma = 1.4826 * median(abs(value - center) for value in residuals)
fitted[device] = {"relative_drop_percent": max(relative_floor_percent, noise_multiplier * robust_sigma),
"absolute_drop_m": absolute_floor_m, "robust_noise_percent": robust_sigma,
"reference_pressure_m_by_slot": {str(slot): value for slot, value in references.items()}}
return {"status": "ready", "required_samples_per_device": count,
"knowledge_cutoff": cutoff.isoformat(), "devices": fitted,
"reference_grid_start": start.isoformat(), "interval_seconds": interval_seconds,
"method": "daily-slot median removal; 1.4826*MAD; thresholds frozen before event"}
def evaluate_pressure_thresholds(rows: list[dict], fitted: dict, *, consecutive_samples: int = 2) -> dict:
"""Experimental adaptive evaluator; this is not the production replay worker."""
if fitted.get("status") != "ready" or consecutive_samples < 1:
raise ValueError("a fitted rule and positive consecutive sample count are required")
start = datetime.fromisoformat(fitted["reference_grid_start"])
cutoff = datetime.fromisoformat(fitted["knowledge_cutoff"])
step = fitted["interval_seconds"]
states = {}
signals = []
for row in sorted(rows, key=lambda item: (item["time"], item["device_id"])):
device = row["device_id"]
if device not in fitted["devices"]:
continue
at, value = row["time"], row["monitored_value"]
if at.tzinfo is None or at < cutoff or (at - start).total_seconds() % step:
raise ValueError("evaluation samples must follow the fit cutoff on the sampling grid")
previous, hits, signaled = states.get(device, (None, 0, False))
if previous is not None and at <= previous:
raise ValueError("evaluation samples must be distinct")
if previous is None or at - previous != timedelta(seconds=step):
hits = 0
rule = fitted["devices"][device]
slot = int((at - start).total_seconds()) // step % (86400 // step)
expected = rule["reference_pressure_m_by_slot"][str(slot)]
valid = value is not None and math.isfinite(float(value)) and float(value) > 0
drop = expected - float(value) if valid else 0
hit = valid and drop >= rule["absolute_drop_m"] and 100 * drop / expected >= rule["relative_drop_percent"]
hits = hits + 1 if hit else 0
if hits >= consecutive_samples and not signaled:
signals.append({"device_id": device, "first_signal_at": at.isoformat(),
"reference_pressure_m": expected, "absolute_drop_m": drop,
"relative_drop_percent": 100 * drop / expected})
signaled = True
states[device] = (at, hits, signaled)
return {"raised": bool(signals), "signals": signals,
"scope": "experimental adaptive pressure rule only; not a worker or Agent result"}