Add isolated burst and pollution impact evidence, parallel work-order workflows, persisted notifications and graph updates. Checkpoint current code. Known follow-ups remain for large pollution result previews and graceful geometry failure.
60 lines
2.6 KiB
Python
60 lines
2.6 KiB
Python
"""Clock-phase normal envelopes frozen before replay; never inspect future readings."""
|
|
from datetime import datetime
|
|
from math import isfinite
|
|
|
|
CALIBRATION_VERSION = "scada-monitor-calibration@2"
|
|
DIAGNOSTIC_POLICY = "normal-phase-envelope@1"
|
|
|
|
|
|
def weighted_diagnosis(document: dict) -> bool:
|
|
return (document.get("monitoring_config") or {}).get("decision_policy") == "weighted"
|
|
|
|
|
|
def current_calibration(document: dict, start: datetime) -> dict | None:
|
|
calibration = document.get("monitoring_calibration") or {}
|
|
if (calibration.get("version") == CALIBRATION_VERSION
|
|
and calibration.get("diagnostic_policy") == DIAGNOSTIC_POLICY
|
|
and calibration.get("knowledge_cutoff") == start.isoformat()):
|
|
return calibration
|
|
return None
|
|
|
|
|
|
def pollution_signal(document: dict, event: dict) -> bool:
|
|
"""Water-quality measurements have their own incident investigation workflow."""
|
|
if event.get("category") == "data_quality":
|
|
return False
|
|
devices = {item["device_id"]: item.get("device_type") for item in document.get("devices", [])}
|
|
ids = event.get("device_ids") or next((item.get("device_ids", [])
|
|
for item in (document.get("config") or {}).get("rules", [])
|
|
if item["rule_id"] == event.get("rule_id")), [])
|
|
return bool(ids) and all(devices.get(device) in {"quality", "water_quality"} for device in ids)
|
|
|
|
|
|
def diagnostic_signal(document: dict, event: dict) -> bool:
|
|
"""Never revive a persisted fixed-percentage event as new diagnosis evidence."""
|
|
if event.get("category") == "data_quality" or pollution_signal(document, event):
|
|
return False
|
|
if not weighted_diagnosis(document):
|
|
return True
|
|
rules = (document.get("config") or {}).get("rules", [])
|
|
rule = next((item for item in rules if item["rule_id"] == event["rule_id"]), None)
|
|
return bool(rule and (rule["kind"] == "bounds" or event.get("diagnostic_policy") == DIAGNOSTIC_POLICY))
|
|
|
|
|
|
def normal_profile_bounds(profile: dict | None, at: datetime) -> tuple[float, float] | None:
|
|
if not profile:
|
|
return None
|
|
interval = profile.get("interval_seconds", 0)
|
|
if not profile.get("phase_origin"):
|
|
return None
|
|
origin = datetime.fromisoformat(profile["phase_origin"])
|
|
lower, upper = profile.get("lower", []), profile.get("upper", [])
|
|
offset = (at - origin).total_seconds()
|
|
if interval <= 0 or offset < 0 or offset % interval or not lower or len(lower) != len(upper):
|
|
return None
|
|
slot = int(offset // interval) % len(lower)
|
|
low, high = lower[slot], upper[slot]
|
|
if low is None or high is None or not isfinite(low) or not isfinite(high):
|
|
return None
|
|
return float(low), float(high)
|