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
@@ -0,0 +1,84 @@
from datetime import datetime, timedelta
from math import sin
import pytest
from pydantic import ValidationError
from app.domain.schemas.project_settings import ScadaMonitoringSettings, ScadaMonitoringWeights
from app.services.scada_monitoring_calibration import MonitoringCalibrationBuilder, adaptive_score_threshold
from app.services import scada_replay_monitoring as monitoring
from test_scada_replay_monitoring import TARGET, PRESSURE_IDS, FLOW_IDS, _document
from app.domain.schemas.scada_replay import ScadaReplayCreate
from app.services.scada_normal_profile import CALIBRATION_VERSION, DIAGNOSTIC_POLICY, normal_profile_bounds
def setup():
document = _document()
document["config"]["start_time"] = TARGET.isoformat()
document["monitoring_config"] = ScadaMonitoringSettings(
pressure_device_ids=PRESSURE_IDS, outlet_flow_device_ids=FLOW_IDS,
).model_dump(mode="json")
config = ScadaReplayCreate.model_validate(document["config"])
start = TARGET - timedelta(days=15)
rows = [{"device_id": device, "time": (start + timedelta(minutes=15 * i)).isoformat(),
"cleaned_value": 100 + 5 * sin(i / 96 * 6.283) + 0.1 * sin(i * 1.7), "quality": "valid"}
for i in range(15 * 96) for device in PRESSURE_IDS + FLOW_IDS]
return document, config, rows
def test_streamed_thresholds_are_frozen_from_complete_pre_replay_history(monkeypatch):
document, config, rows = setup()
calls = []
def detect(**kwargs):
frame = kwargs["observed_pressure_data"]
assert frame.index.max().to_pydatetime() < config.start_time
assert frame.shape == (1440, 5)
calls.append(frame)
return {"rows": [{"Score": value} for value in [-0.03, -0.01, 0.01, 0.02, 0.03] * 3]}
monkeypatch.setattr(monitoring, "run_burst_detection", detect)
builder = MonitoringCalibrationBuilder(document, config)
for offset in range(0, len(rows), 997):
builder.add(rows[offset:offset + 997])
result = builder.fit()
assert result["status"] == "ready"
assert result["version"] == CALIBRATION_VERSION
assert result["diagnostic_policy"] == DIAGNOSTIC_POLICY
assert result["knowledge_cutoff"] == config.start_time.isoformat()
assert result["burst_detection"]["threshold"] < -0.04
assert result["pressure"][PRESSURE_IDS[0]]["threshold_percent"] == 3.0
assert result["outlet_flow"][FLOW_IDS[0]]["threshold_percent"] == 5.0
assert len(calls) == 1
assert sum(values.nbytes for values in builder.values.values()) == 1440 * 6 * 8
for row in rows:
bounds = normal_profile_bounds(result["diagnostic"][row["device_id"]], datetime.fromisoformat(row["time"]))
assert bounds[0] <= row["cleaned_value"] <= bounds[1]
def test_history_gap_and_future_samples_cannot_be_hidden(monkeypatch):
document, config, rows = setup()
monkeypatch.setattr(monitoring, "run_burst_detection", lambda **_: pytest.fail("incomplete history entered detection"))
builder = MonitoringCalibrationBuilder(document, config)
builder.add(rows[1:])
assert builder.fit()["status"] == "insufficient_data"
with pytest.raises(ValueError, match="之后"):
builder.add([{**rows[0], "time": config.start_time.isoformat()}])
def test_score_cutoff_follows_normal_noise_instead_of_target_scores():
quiet = adaptive_score_threshold([0.01, 0.02, 0.03, 0.02, 0.01])
noisy = adaptive_score_threshold([-0.07, -0.03, 0.01, 0.04, 0.02])
assert noisy["threshold"] < quiet["threshold"]
assert -0.2 < noisy["threshold"]
assert adaptive_score_threshold([0.01])["status"] == "insufficient_data"
@pytest.mark.parametrize("values", [
{"burst_detection": 0.5, "pressure": 0.4, "outlet_flow": 0.5},
{"burst_detection": -0.1, "pressure": 1, "outlet_flow": 0.1},
{"outlet_flow": float("nan")},
])
def test_invalid_weights_are_rejected(values):
with pytest.raises(ValidationError):
ScadaMonitoringWeights(**values)