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