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
@@ -1,8 +1,12 @@
from datetime import datetime, timedelta, timezone
from uuid import uuid4
import pytest
from app.domain.schemas.scada_replay import ScadaReplayCreate
from app.services.scada_replay_processing import prepare_cleaning, detect_frame
from app.services.scada_normal_profile import (
CALIBRATION_VERSION, DIAGNOSTIC_POLICY, diagnostic_signal,
)
START = datetime(2026, 10, 8, tzinfo=timezone.utc)
@@ -64,3 +68,60 @@ def test_empty_clock_ticks_do_not_break_consecutive_observations():
doc, _ = detect_frame(doc, [], cfg, START + timedelta(minutes=1))
_, events = detect_frame(doc, prepare_cleaning(rows(20, 15), cfg), cfg, START + timedelta(minutes=15))
assert events[0]["change"] == "raised"
def weighted_document(low=97., high=103.):
return {"replay_id": "run", "histories": {"P1": [100.] * 4},
"monitoring_config": {"decision_policy": "weighted"},
"config": config().model_dump(mode="json"),
"monitoring_calibration": {
"version": CALIBRATION_VERSION, "diagnostic_policy": DIAGNOSTIC_POLICY,
"knowledge_cutoff": START.isoformat(),
"diagnostic": {"P1": {"phase_origin": START.isoformat(), "interval_seconds": 900,
"lower": [low], "upper": [high]}},
}}
def test_weighted_diagnosis_ignores_normal_diurnal_large_relative_changes():
cfg = config(rules=[{"rule_id": "flow", "device_ids": ["P1"], "kind": "relative_change",
"direction": "both", "threshold": 30, "unit": "m3/h"}])
document = weighted_document(25., 35.)
document["histories"]["P1"] = [10.] * 4
_, events = detect_frame(document, prepare_cleaning(rows(30), cfg), cfg, START)
assert events == [] # +200% is normal at this daily sampling phase.
_, events = detect_frame(document, prepare_cleaning(rows(36), cfg), cfg, START)
assert events[0]["change"] == "raised"
@pytest.mark.parametrize("baseline,value,low,high", [
(100., 95., 97., 103.), (0., -5., -1., 1.),
])
def test_weighted_single_indicator_uses_history_envelope_without_thirty_percent_gate(baseline, value, low, high):
cfg = config()
document = weighted_document(low, high)
document["histories"]["P1"] = [baseline] * 4
_, events = detect_frame(document, prepare_cleaning(rows(value), cfg), cfg, START)
assert len(events) == 1
assert events[0]["diagnostic_policy"] == DIAGNOSTIC_POLICY
assert diagnostic_signal(document, events[0])
assert "阈值 30" not in events[0]["detail"]
@pytest.mark.parametrize("missing", ["version", "diagnostic", "knowledge_cutoff"])
def test_weighted_missing_calibration_does_not_fall_back_to_thirty_percent(missing):
cfg = config()
document = weighted_document()
document["monitoring_calibration"].pop(missing)
_, events = detect_frame(document, prepare_cleaning(rows(1), cfg), cfg, START)
assert events == []
def test_old_active_relative_state_cannot_block_or_supply_new_diagnosis():
cfg = config()
document = weighted_document()
old = {"event_id": "old", "rule_id": "pressure", "device_ids": ["P1"], "status": "active"}
document["rule_states"] = {"pressure": {"event": old, "hits": 1, "recoveries": 0}}
result, events = detect_frame(document, prepare_cleaning(rows(95), cfg), cfg, START)
assert not diagnostic_signal(document, old)
assert result["rule_states"]["pressure"]["event"] == old
assert events[0]["diagnostic_policy"] == DIAGNOSTIC_POLICY