feat(scada): add calibrated monitoring and incident recovery

This commit is contained in:
2026-11-07 19:44:58 +08:00
parent 26fa5d1909
commit ff8aba4964
109 changed files with 9165 additions and 629 deletions
+55 -1
View File
@@ -12,7 +12,7 @@ from app.mcp.analysis_models import BurstLocationInput
from app.mcp.catalog import TOOLS
from app.mcp.models import BoundContext
from app.mcp import query_service
from app.services.scada_replay_queries import replay_burst_location_inputs
from app.services.scada_replay_queries import replay_burst_location_inputs, replay_snapshot_rows
START = datetime(2026, 10, 8, tzinfo=timezone.utc)
@@ -85,6 +85,60 @@ def test_replay_location_builds_series_only_from_frozen_outputs():
assert connection.statements[1][1][2] == 9
def test_replay_queries_frozen_history_with_project_replay_and_batch_bounds():
project_id, replay_id = uuid4(), str(uuid4())
at = START - timedelta(days=7)
run = {"start_time": START.isoformat(), "config": {"device_ids": ["P1"]},
"baseline": {"start_time": (START - timedelta(days=15)).isoformat()}}
reading = {"time": at.isoformat(), "device_id": "P1", "monitored_value": 23.,
"cleaned_value": 23., "quality": "valid", "reason": "冻结历史基线"}
connection = Connection([[{"document": run}], [{"reading": reading}]])
context = {"replay_id": replay_id, "knowledge_cutoff": START.isoformat(), "batch_sequence": 9}
result = asyncio.run(replay_snapshot_rows(connection, project_id, context, ["P1"], at, at, 10))
assert result == [reading]
statement, params = connection.statements[1]
assert "b.stage='detected'" in statement and "b.sequence<=%s" in statement
assert params[:4] == (str(project_id), replay_id, 9, ["P1"])
assert params[4:6] == (at, at)
@pytest.mark.parametrize("defect,status", [("old_replay", 422), ("too_early", 422), ("future", 422), ("foreign_device", 403)])
def test_replay_history_rejects_missing_freeze_or_scope_escape(defect, status):
at = START - timedelta(days=7)
run = {"start_time": START.isoformat(), "config": {"device_ids": ["P1"]},
"baseline": {"start_time": (START - timedelta(days=15)).isoformat()}}
if defect == "old_replay": run.pop("baseline")
if defect == "too_early": at = START - timedelta(days=16)
if defect == "future": at = START + timedelta(minutes=1)
connection = Connection([[{"document": run}]])
context = {"replay_id": str(uuid4()), "knowledge_cutoff": START.isoformat(), "batch_sequence": 9}
with pytest.raises(HTTPException) as caught:
asyncio.run(replay_snapshot_rows(connection, uuid4(), context,
["P2" if defect == "foreign_device" else "P1"], at, at, 10))
assert caught.value.status_code == status
assert len(connection.statements) <= 1
def test_location_accepts_frozen_pre_replay_normal_window():
replay_id = str(uuid4())
normal = START - timedelta(days=7)
run = {"start_time": START.isoformat(),
"baseline": {"start_time": (START - timedelta(days=15)).isoformat()},
"devices": [{"device_id": "P1", "node_id": "J1"}, {"device_id": "F1", "link_id": "L1"}],
"monitoring_config": {"pressure_device_ids": ["P1"], "outlet_flow_device_ids": ["F1"]}}
readings = [{"reading": {"time": at.isoformat(), "device_id": device,
"cleaned_value": value, "quality": "valid"}}
for at, device, value in [(normal, "P1", 100), (normal, "F1", 50),
(START + timedelta(minutes=15), "P1", 70),
(START + timedelta(minutes=15), "F1", 80)]]
connection = Connection([[{"document": run}], readings])
context = {"replay_id": replay_id, "knowledge_cutoff": (START + timedelta(hours=1)).isoformat(), "batch_sequence": 9}
result = asyncio.run(replay_burst_location_inputs(connection, uuid4(), context,
_args(scada_normal_start=normal, scada_normal_end=normal + timedelta(minutes=14))))
assert result["normal_pressure"] == {"J1": 100}
assert result["burst_flow"] == {"L1": 80}
def test_replay_location_authorization_requires_explicit_bounded_normal_window(monkeypatch):
binding = BoundContext(
context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host",