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 tests.unit.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)