feat(scada): add calibrated monitoring and incident recovery
This commit is contained in:
@@ -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"}
|
||||
Reference in New Issue
Block a user