feat(scada): add calibrated monitoring and incident recovery
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.operational_recovery import accept_completion, evaluate_recovery, track_work_order
|
||||
from app.services.operational_tasks import OperationalTaskService
|
||||
|
||||
|
||||
INCIDENT = datetime(2026, 7, 16, 0, tzinfo=timezone.utc)
|
||||
RETURNED = INCIDENT + timedelta(hours=1)
|
||||
|
||||
|
||||
def observations():
|
||||
rows = []
|
||||
for sample in (1, 2, 3):
|
||||
at = RETURNED + timedelta(minutes=15 * sample)
|
||||
for device in ("P1", "F1"):
|
||||
for day in range(1, 16):
|
||||
rows.append({"time": at - timedelta(days=day), "device_id": device, "monitored_value": 100.})
|
||||
rows.append({"time": at, "device_id": device, "monitored_value": 100.})
|
||||
return rows
|
||||
|
||||
|
||||
def evaluate(rows):
|
||||
return evaluate_recovery(rows, device_ids=["P1", "F1"], returned_at=RETURNED,
|
||||
incident_at=INCIDENT, interval_seconds=900)
|
||||
|
||||
|
||||
def test_recovery_uses_post_receipt_samples_and_pre_incident_same_phase_reference():
|
||||
result = evaluate(observations())
|
||||
assert result["status"] == "passed"
|
||||
assert [item["at"] for item in result["samples"]] == [
|
||||
(RETURNED + timedelta(minutes=30)).isoformat(), (RETURNED + timedelta(minutes=45)).isoformat()]
|
||||
assert all(check["normal_day_count"] == 15 for sample in result["samples"] for check in sample["devices"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("problem", ["missing_device", "low_pressure", "high_flow", "nan", "bad_quality", "missing_baseline", "gap"])
|
||||
def test_incomplete_or_abnormal_data_never_proves_recovery(problem):
|
||||
rows = observations()
|
||||
current = [row for row in rows if row["time"] > RETURNED]
|
||||
for row in current:
|
||||
if problem == "missing_device" and row["device_id"] == "P1":
|
||||
rows.remove(row)
|
||||
elif problem in {"low_pressure", "high_flow", "nan"}:
|
||||
row["monitored_value"] = {"low_pressure": 60., "high_flow": 130., "nan": float("nan")}[problem]
|
||||
elif problem == "bad_quality":
|
||||
row["quality"] = "invalid"
|
||||
elif problem == "gap":
|
||||
row["time"] += timedelta(minutes=15 * current.index(row))
|
||||
if problem == "missing_baseline":
|
||||
rows = [row for row in rows if row["time"] >= INCIDENT - timedelta(days=14)]
|
||||
assert evaluate(rows)["status"] == "waiting"
|
||||
|
||||
|
||||
def test_samples_at_or_before_return_time_cannot_close_incident():
|
||||
rows = [row for row in observations() if row["time"] < INCIDENT]
|
||||
rows.extend({"time": RETURNED, "device_id": device, "monitored_value": 100.} for device in ("P1", "F1"))
|
||||
assert evaluate(rows)["status"] == "waiting"
|
||||
|
||||
|
||||
def test_faster_sampling_counts_monitoring_periods_and_rejects_later_relapse():
|
||||
rows = []
|
||||
for minute in range(5, 36, 5):
|
||||
at = RETURNED + timedelta(minutes=minute)
|
||||
for device in ("P1", "F1"):
|
||||
rows.extend({"time": at - timedelta(days=day), "device_id": device, "monitored_value": 100.}
|
||||
for day in range(1, 16))
|
||||
rows.append({"time": at, "device_id": device, "monitored_value": 100.})
|
||||
assert evaluate(rows)["status"] == "passed"
|
||||
next(row for row in rows if row["time"] == RETURNED + timedelta(minutes=25) and row["device_id"] == "P1")["monitored_value"] = 50.
|
||||
assert evaluate(rows)["status"] == "waiting"
|
||||
|
||||
|
||||
def test_dispatch_waits_for_all_orders_and_accepts_corrected_field_completion_time():
|
||||
task = {"workflow": "burst_report"}
|
||||
for order_id in ("one", "two"):
|
||||
track_work_order(task, {"kind": "create_work_order", "action_id": str(uuid4()), "execution_status": "succeeded",
|
||||
"receipt": {"adapter": "peripheral_sample", "receipt_id": order_id}})
|
||||
assert OperationalTaskService._investigation_status([], task) == "waiting_human"
|
||||
assert not accept_completion(task, {"source_event_id": "peripheral_sample:unrelated"})
|
||||
feedback = {"source_event_id": "peripheral_sample:one", "status": "completed", "source_revision": 2,
|
||||
"occurred_at": RETURNED.isoformat()}
|
||||
assert accept_completion(task, feedback)
|
||||
assert task["handling_context"]["phase"] == "waiting_work_order"
|
||||
accept_completion(task, {**feedback, "source_event_id": "peripheral_sample:two"})
|
||||
assert task["handling_context"]["phase"] == "waiting_scada"
|
||||
task["handling_context"]["phase"] = "verification_ready"
|
||||
task["handling_context"].update(verification_evidence_id="previous-proof", last_check={"status": "passed"})
|
||||
accept_completion(task, {**feedback, "source_revision": 3})
|
||||
assert task["handling_context"]["phase"] == "verification_ready"
|
||||
assert OperationalTaskService._investigation_status([], task) == "completed"
|
||||
corrected = RETURNED + timedelta(hours=1)
|
||||
accept_completion(task, {**feedback, "source_revision": 4, "occurred_at": corrected.isoformat()})
|
||||
assert task["handling_context"]["returned_at"] == corrected.isoformat()
|
||||
assert task["handling_context"]["phase"] == "waiting_scada"
|
||||
assert "verification_evidence_id" not in task["handling_context"] and "last_check" not in task["handling_context"]
|
||||
|
||||
|
||||
def test_field_feedback_time_requires_a_real_note_and_explicit_timezone():
|
||||
from pydantic import ValidationError
|
||||
from samples.peripheral.schemas import UpdateWorkOrder
|
||||
body = {"request_id": uuid4(), "expected_revision": 1, "status": "completed",
|
||||
"progress_note": "演示抢修完成并恢复供水", "occurred_at": RETURNED.isoformat()}
|
||||
assert UpdateWorkOrder.model_validate(body).occurred_at == RETURNED
|
||||
with pytest.raises(ValidationError):
|
||||
UpdateWorkOrder.model_validate({**body, "progress_note": None})
|
||||
with pytest.raises(ValidationError):
|
||||
UpdateWorkOrder.model_validate({**body, "occurred_at": "2026-07-16T09:00:00"})
|
||||
Reference in New Issue
Block a user