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"])