Files
next-tjwater-server/tests/unit/test_scada_replay_monitoring.py
T

311 lines
14 KiB
Python

from datetime import datetime, timedelta, timezone
from uuid import uuid4
import pytest
from app.domain.schemas.scada_replay import ScadaReplayCreate
from app.domain.schemas.project_settings import ScadaMonitoringSettings
from app.services import scada_replay_monitoring
from app.services.scada_normal_profile import CALIBRATION_VERSION, DIAGNOSTIC_POLICY
TARGET = datetime(2026, 10, 16, tzinfo=timezone.utc)
PRESSURE_IDS = [f"P{index}" for index in range(1, 6)]
FLOW_IDS = ["F1"]
def _document():
config = ScadaReplayCreate.model_validate({
"request_id": str(uuid4()),
"start_time": TARGET - timedelta(days=15),
"end_time": TARGET + timedelta(hours=1),
"device_ids": PRESSURE_IDS + FLOW_IDS,
"rules": [
{"rule_id": "pressure_drop", "device_ids": PRESSURE_IDS,
"kind": "relative_change", "direction": "decrease", "threshold": 20, "unit": "m"},
{"rule_id": "outlet_flow", "device_ids": FLOW_IDS,
"kind": "relative_change", "direction": "increase", "threshold": 20, "unit": "m3/h"},
],
})
return {
"replay_id": str(uuid4()),
"config": config.model_dump(mode="json"),
"monitoring_interval_seconds": 900,
"monitoring_config": {
"rule_version": "burst-monitor-v1",
"pressure_device_ids": PRESSURE_IDS,
"outlet_flow_device_ids": FLOW_IDS,
},
}
def _rows():
rows = []
count = 15 * 96
start = TARGET - timedelta(minutes=15 * (count - 1))
for index in range(count):
at = start + timedelta(minutes=15 * index)
for device_id in PRESSURE_IDS:
rows.append({"time": at.isoformat(), "device_id": device_id,
"cleaned_value": 70 if at == TARGET else 100,
"quality": "valid"})
rows.append({"time": at.isoformat(), "device_id": "F1",
"cleaned_value": 150 if at == TARGET else 100,
"quality": "valid"})
return rows
def test_multiple_indicators_create_suspected_burst_result(monkeypatch):
monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", lambda **_: {
"rows": [{"Score": -0.05}],
"summary": {"latest_sensor_rankings": []},
})
result = scada_replay_monitoring.evaluate_monitoring_period(
_document(), _rows(),
period_start=TARGET - timedelta(minutes=15),
period_end=TARGET,
batch_sequence=1440,
)
assert result["status"] == "suspected_burst"
assert result["pressure"]["hit_device_ids"] == PRESSURE_IDS
assert result["outlet_flow"]["hit_device_ids"] == FLOW_IDS
assert result["burst_detection"]["hit"] is True
pressure = result["pressure"]["devices"][0]
assert pressure["value_change"] == -30
assert pressure["unit"] == "m"
assert pressure["baseline_method"] == "recent_samples_median"
assert pressure["baseline_sample_count"] == 4
assert pressure["baseline_end"] == result["period_start"]
assert result["outlet_flow"]["devices"][0]["value_change"] == 50
assert result["burst_detection"]["score_interpretation"] == "lower_is_more_anomalous_not_probability"
def test_single_indicator_stays_attention(monkeypatch):
monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", lambda **_: {
"rows": [{"Score": 0.1}],
"summary": {"latest_sensor_rankings": []},
})
rows = _rows()
for row in rows:
if row["device_id"] == "F1" and row["time"] == TARGET.isoformat():
row["cleaned_value"] = 100
result = scada_replay_monitoring.evaluate_monitoring_period(
_document(), rows,
period_start=TARGET - timedelta(minutes=15),
period_end=TARGET,
batch_sequence=1440,
)
assert result["status"] == "attention"
assert result["pressure"]["hit"] is True
assert result["outlet_flow"]["hit"] is False
assert result["burst_detection"]["hit"] is False
def test_missing_project_roles_is_data_gap_not_normal():
document = _document()
document["monitoring_config"] = None
result = scada_replay_monitoring.evaluate_monitoring_period(
document, [],
period_start=TARGET - timedelta(minutes=15),
period_end=TARGET,
batch_sequence=1,
)
assert result["status"] == "insufficient_data"
assert result["burst_detection"]["status"] == "not_run"
def _weighted_document():
document = _document()
document["config"]["start_time"] = (TARGET - timedelta(minutes=30)).isoformat()
document["monitoring_config"] = ScadaMonitoringSettings(
pressure_device_ids=PRESSURE_IDS, outlet_flow_device_ids=FLOW_IDS,
).model_dump(mode="json")
document["monitoring_calibration"] = {
"version": CALIBRATION_VERSION, "diagnostic_policy": DIAGNOSTIC_POLICY,
"status": "ready", "knowledge_cutoff": (TARGET - timedelta(minutes=30)).isoformat(),
"pressure": {device: {"threshold_percent": 10, "method": "frozen_normal_history"} for device in PRESSURE_IDS},
"outlet_flow": {"F1": {"threshold_percent": 5, "method": "frozen_normal_history"}},
"burst_detection": {"threshold": -0.08},
}
profile = {"phase_origin": document["config"]["start_time"], "interval_seconds": 900,
"lower": [99.], "upper": [101.]}
calibration = document["monitoring_calibration"]
calibration["diagnostic"] = {device: profile for device in PRESSURE_IDS + FLOW_IDS}
for kind in ("pressure", "outlet_flow"):
for item in calibration[kind].values():
item["normal_profile"] = profile
return document
@pytest.mark.parametrize("flow,expected_score", [("increase", 1.0), ("flat", 0.9), ("missing", 0.9)])
def test_pressure_and_detection_can_trigger_with_low_or_unavailable_flow_weight(monkeypatch, flow, expected_score):
monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", lambda **_: {
"rows": [{"Score": -0.2}], "summary": {},
})
rows = _rows()
for row in rows:
if flow == "flat" and row["device_id"] == "F1":
row["cleaned_value"] = 100
if flow == "missing":
rows = [row for row in rows if not (row["device_id"] == "F1"
and datetime.fromisoformat(row["time"]) >= TARGET - timedelta(minutes=15))]
result = scada_replay_monitoring.evaluate_monitoring_period(
_weighted_document(), rows, period_start=TARGET - timedelta(minutes=15),
period_end=TARGET, batch_sequence=1440,
)
assert result["status"] == "suspected_burst"
assert result["weighted"]["score"] == expected_score
assert result["weighted"]["weights"]["outlet_flow"] == 0.1
assert result["pressure"]["devices"][0]["threshold_percent"] == 10
assert result["burst_detection"]["score_threshold"] == -0.08
if flow == "missing":
assert result["weighted"]["unavailable_indicators"] == ["outlet_flow"]
@pytest.mark.parametrize("indicator", ["pressure", "outlet_flow", "burst_detection"])
def test_a_single_indicator_cannot_create_a_weighted_burst_alert(monkeypatch, indicator):
monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", lambda **_: {
"rows": [{"Score": -0.2 if indicator == "burst_detection" else 0.02}], "summary": {},
})
rows = _rows()
for row in rows:
if (row["device_id"] in PRESSURE_IDS and indicator != "pressure"
or row["device_id"] == "F1" and indicator != "outlet_flow"):
row["cleaned_value"] = 100
result = scada_replay_monitoring.evaluate_monitoring_period(
_weighted_document(), rows, period_start=TARGET - timedelta(minutes=15),
period_end=TARGET, batch_sequence=1440,
)
assert result["status"] == "attention"
assert result["weighted"]["score"] < 0.8
def test_weighted_policy_requires_a_persisted_causal_calibration():
document = _weighted_document()
document.pop("monitoring_calibration")
result = scada_replay_monitoring.evaluate_monitoring_period(
document, _rows(), period_start=TARGET - timedelta(minutes=15), period_end=TARGET, batch_sequence=1,
)
assert result["status"] == "insufficient_data"
def test_calibration_from_another_or_future_cutoff_is_rejected():
document = _weighted_document()
document["monitoring_calibration"]["knowledge_cutoff"] = (TARGET + timedelta(minutes=15)).isoformat()
result = scada_replay_monitoring.evaluate_monitoring_period(
document, _rows(), period_start=TARGET - timedelta(minutes=15), period_end=TARGET, batch_sequence=1,
)
assert result["status"] == "insufficient_data"
def test_ready_flow_anomaly_survives_missing_pressure_calibration(monkeypatch):
document = _weighted_document()
document["monitoring_calibration"].update(status="insufficient_data", pressure={})
document["monitoring_calibration"].pop("burst_detection")
def unexpected_detector(**_):
raise AssertionError("Unavailable pressure calibration must not run the detector")
monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", unexpected_detector)
result = scada_replay_monitoring.evaluate_monitoring_period(
document, _rows(), period_start=TARGET - timedelta(minutes=15), period_end=TARGET, batch_sequence=1,
)
assert result["status"] == "insufficient_data"
assert result["outlet_flow"]["hit"] is True
assert result["weighted"]["score"] == 0.1
assert result["burst_detection"]["status"] == "baseline_not_ready"
def test_relative_baseline_matches_calibration_immediately_previous_samples():
config = ScadaReplayCreate.model_validate(_document()["config"])
rows = [{"device_id": "P1", "time": (TARGET - timedelta(minutes=15 * (5 - i))).isoformat(),
"cleaned_value": value, "quality": "valid"}
for i, value in enumerate((120., 115., 110., 105., 100., 95.))]
result = scada_replay_monitoring._relative_indicator(
rows, config, ["P1"], TARGET - timedelta(minutes=15), TARGET, direction="decrease",
)
assert result["devices"][0]["baseline"] == 107.5
assert result["devices"][0]["baseline_end"] == (TARGET - timedelta(minutes=15)).isoformat()
def test_period_start_reading_is_not_a_new_observation():
config = ScadaReplayCreate.model_validate(_document()["config"])
rows = [row for row in _rows() if row["time"] < TARGET.isoformat()]
result = scada_replay_monitoring._relative_indicator(
rows, config, ["P1"], TARGET - timedelta(minutes=15), TARGET, direction="decrease",
)
assert result["status"] == "insufficient_data"
assert result["gaps"][0]["reason"] == "period_reading_missing"
def test_detector_cannot_reuse_the_previous_period_score(monkeypatch):
monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection",
lambda **_: pytest.fail("stale target reached detector"))
rows = [row for row in _rows() if row["time"] < TARGET.isoformat()]
result = scada_replay_monitoring._burst_detection(rows, PRESSURE_IDS, 900, TARGET)
assert result["status"] == "current_reading_missing"
assert result["hit"] is False
def test_old_calibration_version_requires_a_new_replay():
document = _weighted_document()
document["monitoring_calibration"]["version"] = "scada-monitor-calibration@1"
result = scada_replay_monitoring.evaluate_monitoring_period(
document, _rows(), period_start=TARGET - timedelta(minutes=15), period_end=TARGET, batch_sequence=1,
)
assert result["status"] == "insufficient_data"
assert "新建" in result["detail"]
assert not any(result[key]["hit"] for key in ("pressure", "outlet_flow", "burst_detection"))
def test_normal_diurnal_change_then_t_and_t_plus_fifteen_share_one_incident(monkeypatch):
from app.services.scada_replay_processing import detect_frame
from app.services.scada_monitoring_incidents import update_incident
document = _weighted_document()
config = ScadaReplayCreate.model_validate(document["config"])
profile = {"phase_origin": config.start_time.isoformat(), "interval_seconds": 900,
"lower": [99., 199., 199., 199.], "upper": [101., 201., 201., 201.]}
document["monitoring_calibration"]["diagnostic"]["F1"] = profile
document["monitoring_calibration"]["outlet_flow"]["F1"]["normal_profile"] = profile
monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", lambda **kwargs: {
"rows": [{"Score": -0.2 if kwargs["observed_pressure_data"].iloc[-1].min() < 90 else 0.02}],
"summary": {},
})
rows = _rows()
rows += [{"time": (TARGET - timedelta(days=15)).isoformat(), "device_id": device,
"cleaned_value": 100., "quality": "valid"} for device in PRESSURE_IDS + FLOW_IDS]
for row in rows:
if row["device_id"] == "F1" and _time_for_test(row) >= TARGET - timedelta(minutes=15):
row["cleaned_value"] = 200.
rows += [{"time": (TARGET + timedelta(minutes=15)).isoformat(), "device_id": device,
"cleaned_value": 200. if device == "F1" else 70., "quality": "valid"}
for device in PRESSURE_IDS + FLOW_IDS]
document["histories"] = {device: [100.] * 4 for device in PRESSURE_IDS + FLOW_IDS}
actions, statuses = [], []
for sequence, offset in enumerate((-15, 0, 15), start=1):
at = TARGET + timedelta(minutes=offset)
frame = [row for row in rows if _time_for_test(row) == at]
document, changes = detect_frame(document, frame, config, at)
result = scada_replay_monitoring.evaluate_monitoring_period(
document, [row for row in rows if _time_for_test(row) <= at],
period_start=at - timedelta(minutes=15), period_end=at, batch_sequence=sequence,
)
result["anomaly_signals"] = [state["event"] for state in document["rule_states"].values()
if state.get("event")]
statuses.append(result["status"])
incident, action = update_incident(document, result, sequence)
actions.append(action)
if incident and not incident.get("task_id"):
incident["task_id"] = str(uuid4())
if offset == -15:
assert changes == [] # Normal doubling of flow does not launch diagnosis.
assert statuses == ["normal", "suspected_burst", "suspected_burst"]
assert actions == [None, "raised", "ongoing"]
def _time_for_test(row):
return datetime.fromisoformat(row["time"])