128 lines
6.0 KiB
Python
128 lines
6.0 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
from uuid import uuid4
|
|
import pytest
|
|
|
|
from app.domain.schemas.scada_replay import ScadaReplayCreate
|
|
from app.services.scada_replay_processing import prepare_cleaning, detect_frame
|
|
from app.services.scada_normal_profile import (
|
|
CALIBRATION_VERSION, DIAGNOSTIC_POLICY, diagnostic_signal,
|
|
)
|
|
|
|
START = datetime(2026, 10, 8, tzinfo=timezone.utc)
|
|
|
|
|
|
def config(**changes):
|
|
return ScadaReplayCreate.model_validate({
|
|
"request_id": str(uuid4()), "start_time": START, "end_time": START + timedelta(hours=2),
|
|
"device_ids": ["P1"], "rules": [{"rule_id": "pressure", "device_ids": ["P1"],
|
|
"kind": "relative_change", "direction": "decrease", "threshold": 30, "unit": "m"}],
|
|
**changes,
|
|
})
|
|
|
|
|
|
def rows(value, minute=0):
|
|
return [{"time": START + timedelta(minutes=minute), "device_id": "P1", "monitored_value": value, "source_hash": "h"}]
|
|
|
|
|
|
def test_quality_does_not_repair_a_real_pressure_drop_or_reverse_flow():
|
|
for value in (0, -5, 1):
|
|
result = prepare_cleaning(rows(value), config())
|
|
assert result[0]["quality"] == "valid"
|
|
assert result[0]["cleaned_value"] == value
|
|
invalid = prepare_cleaning(rows(-5), config(quality_ranges={"P1": {"lower": 0}}))
|
|
assert invalid[0]["quality"] == "invalid"
|
|
assert invalid[0]["monitored_value"] == -5
|
|
assert invalid[0]["cleaned_value"] is None
|
|
|
|
|
|
def test_persistent_drop_is_one_episode_and_recovery_then_drop_is_new_episode():
|
|
cfg = config()
|
|
doc = {"replay_id": "run", "histories": {"P1": [10, 10, 10, 10]}}
|
|
all_events = []
|
|
for minute, value in enumerate((1, 1, None, 10, 10, 1)):
|
|
doc, events = detect_frame(doc, prepare_cleaning(rows(value, minute), cfg), cfg, START + timedelta(minutes=minute))
|
|
all_events.extend(events)
|
|
business = [event for event in all_events if event.get("category") != "data_quality"]
|
|
quality = [event for event in all_events if event.get("category") == "data_quality"]
|
|
assert [event["change"] for event in business] == ["raised", "resolved", "raised"]
|
|
assert [event["change"] for event in quality] == ["raised", "resolved"]
|
|
assert business[0]["event_id"] != business[2]["event_id"]
|
|
assert doc["anomaly_count"] == 3
|
|
assert doc["processed_count"] == 6
|
|
|
|
|
|
def test_water_quality_zero_baseline_uses_explicit_bounds():
|
|
cfg = config(rules=[{"rule_id": "tracer", "device_ids": ["P1"], "kind": "bounds", "upper": 0.1, "unit": "m"}])
|
|
doc = {"replay_id": "run"}
|
|
doc, events = detect_frame(doc, prepare_cleaning(rows(0), cfg), cfg, START)
|
|
assert events == []
|
|
_, events = detect_frame(doc, prepare_cleaning(rows(0.4, 15), cfg), cfg, START + timedelta(minutes=15))
|
|
assert events[0]["change"] == "raised"
|
|
|
|
|
|
def test_empty_clock_ticks_do_not_break_consecutive_observations():
|
|
cfg = config(rules=[{"rule_id": "p", "device_ids": ["P1"], "kind": "bounds", "upper": 10,
|
|
"unit": "m", "consecutive_samples": 2}])
|
|
doc, events = detect_frame({"replay_id": "run"}, prepare_cleaning(rows(20), cfg), cfg, START)
|
|
assert events == []
|
|
doc, _ = detect_frame(doc, [], cfg, START + timedelta(minutes=1))
|
|
_, events = detect_frame(doc, prepare_cleaning(rows(20, 15), cfg), cfg, START + timedelta(minutes=15))
|
|
assert events[0]["change"] == "raised"
|
|
|
|
|
|
def weighted_document(low=97., high=103.):
|
|
return {"replay_id": "run", "histories": {"P1": [100.] * 4},
|
|
"monitoring_config": {"decision_policy": "weighted"},
|
|
"config": config().model_dump(mode="json"),
|
|
"monitoring_calibration": {
|
|
"version": CALIBRATION_VERSION, "diagnostic_policy": DIAGNOSTIC_POLICY,
|
|
"knowledge_cutoff": START.isoformat(),
|
|
"diagnostic": {"P1": {"phase_origin": START.isoformat(), "interval_seconds": 900,
|
|
"lower": [low], "upper": [high]}},
|
|
}}
|
|
|
|
|
|
def test_weighted_diagnosis_ignores_normal_diurnal_large_relative_changes():
|
|
cfg = config(rules=[{"rule_id": "flow", "device_ids": ["P1"], "kind": "relative_change",
|
|
"direction": "both", "threshold": 30, "unit": "m3/h"}])
|
|
document = weighted_document(25., 35.)
|
|
document["histories"]["P1"] = [10.] * 4
|
|
_, events = detect_frame(document, prepare_cleaning(rows(30), cfg), cfg, START)
|
|
assert events == [] # +200% is normal at this daily sampling phase.
|
|
_, events = detect_frame(document, prepare_cleaning(rows(36), cfg), cfg, START)
|
|
assert events[0]["change"] == "raised"
|
|
|
|
|
|
@pytest.mark.parametrize("baseline,value,low,high", [
|
|
(100., 95., 97., 103.), (0., -5., -1., 1.),
|
|
])
|
|
def test_weighted_single_indicator_uses_history_envelope_without_thirty_percent_gate(baseline, value, low, high):
|
|
cfg = config()
|
|
document = weighted_document(low, high)
|
|
document["histories"]["P1"] = [baseline] * 4
|
|
_, events = detect_frame(document, prepare_cleaning(rows(value), cfg), cfg, START)
|
|
assert len(events) == 1
|
|
assert events[0]["diagnostic_policy"] == DIAGNOSTIC_POLICY
|
|
assert diagnostic_signal(document, events[0])
|
|
assert "阈值 30" not in events[0]["detail"]
|
|
|
|
|
|
@pytest.mark.parametrize("missing", ["version", "diagnostic", "knowledge_cutoff"])
|
|
def test_weighted_missing_calibration_does_not_fall_back_to_thirty_percent(missing):
|
|
cfg = config()
|
|
document = weighted_document()
|
|
document["monitoring_calibration"].pop(missing)
|
|
_, events = detect_frame(document, prepare_cleaning(rows(1), cfg), cfg, START)
|
|
assert events == []
|
|
|
|
|
|
def test_old_active_relative_state_cannot_block_or_supply_new_diagnosis():
|
|
cfg = config()
|
|
document = weighted_document()
|
|
old = {"event_id": "old", "rule_id": "pressure", "device_ids": ["P1"], "status": "active"}
|
|
document["rule_states"] = {"pressure": {"event": old, "hits": 1, "recoveries": 0}}
|
|
result, events = detect_frame(document, prepare_cleaning(rows(95), cfg), cfg, START)
|
|
assert not diagnostic_signal(document, old)
|
|
assert result["rule_states"]["pressure"]["event"] == old
|
|
assert events[0]["diagnostic_policy"] == DIAGNOSTIC_POLICY
|