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,68 @@
from datetime import datetime, timedelta, timezone
import pytest
from scripts.benchmark.pressure_thresholds import evaluate_pressure_thresholds, learn_pressure_thresholds
CUTOFF = datetime(2026, 10, 8, 12, tzinfo=timezone.utc)
def history(device, noisy=False):
start = CUTOFF - timedelta(days=15)
return [{"device_id": device, "time": start + timedelta(minutes=15 * index),
"monitored_value": 30 + (index % 96) / 20 + ((index // 96 % 3) - 1) * (4 if noisy else 0.1)}
for index in range(1440)]
def test_thresholds_follow_device_noise_after_removing_daily_pattern():
result = learn_pressure_thresholds(history("quiet") + history("noisy", True), ["quiet", "noisy"], CUTOFF)
assert result["status"] == "ready"
assert result["devices"]["quiet"]["relative_drop_percent"] == 3
assert result["devices"]["noisy"]["relative_drop_percent"] > 30
assert result["devices"]["quiet"]["absolute_drop_m"] == 0.5
def test_short_warmup_is_not_treated_as_a_fitted_adaptive_rule():
rows = history("P")[-4:]
result = learn_pressure_thresholds(rows, ["P"], CUTOFF)
assert result["status"] == "history_not_ready"
assert result["required_samples_per_device"] == 1440
assert result["available_samples"] == {"P": 4}
def test_event_or_future_reading_is_rejected():
with pytest.raises(ValueError, match="future"):
learn_pressure_thresholds(history("P") + [{"device_id": "P", "time": CUTOFF, "monitored_value": 10}], ["P"], CUTOFF)
def test_missing_grid_point_prevents_fitting():
assert learn_pressure_thresholds(history("P")[:-1], ["P"], CUTOFF)["status"] == "history_not_ready"
@pytest.mark.parametrize("value", [0, -1, float("nan"), float("inf")])
def test_invalid_pressure_samples_are_rejected(value):
rows = history("P")
rows[0]["monitored_value"] = value
with pytest.raises(ValueError, match="positive finite"):
learn_pressure_thresholds(rows, ["P"], CUTOFF)
def test_normal_daily_pattern_is_not_confused_with_a_burst():
fitted = learn_pressure_thresholds(history("P"), ["P"], CUTOFF)
normal = [{"device_id": "P", "time": CUTOFF + timedelta(minutes=15 * i),
"monitored_value": 30 + i / 20} for i in range(20)]
assert not evaluate_pressure_thresholds(normal, fitted)["raised"]
burst = [{**row, "monitored_value": row["monitored_value"] - 3} for row in normal]
event = evaluate_pressure_thresholds(burst, fitted)
assert event["raised"]
assert event["signals"][0]["first_signal_at"] == (CUTOFF + timedelta(minutes=15)).isoformat()
def test_absolute_floor_and_consecutive_sample_gap_are_enforced():
baseline = [{**row, "monitored_value": 5} for row in history("P")]
fitted = learn_pressure_thresholds(baseline, ["P"], CUTOFF)
small = [{"device_id": "P", "time": CUTOFF + timedelta(minutes=15 * i), "monitored_value": 4.8} for i in range(2)]
assert not evaluate_pressure_thresholds(small, fitted)["raised"]
separated = [{"device_id": "P", "time": CUTOFF + timedelta(minutes=30 * i), "monitored_value": 4} for i in range(2)]
assert not evaluate_pressure_thresholds(separated, fitted)["raised"]