85 lines
3.9 KiB
Python
85 lines
3.9 KiB
Python
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)
|