feat(scada): add project monitoring replay
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
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
|
||||
|
||||
|
||||
START = datetime(2026, 10, 8, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class Cursor:
|
||||
def __init__(self, rows): self.rows = rows
|
||||
async def fetchone(self): return self.rows[0] if self.rows else None
|
||||
async def fetchall(self): return self.rows
|
||||
|
||||
|
||||
class Connection:
|
||||
def __init__(self, results):
|
||||
self.results = list(results)
|
||||
self.statements = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self): yield
|
||||
|
||||
async def execute(self, statement, params):
|
||||
self.statements.append((statement, params))
|
||||
return Cursor(self.results.pop(0))
|
||||
|
||||
async def commit(self): pass
|
||||
|
||||
|
||||
def _args(**changes):
|
||||
return BurstLocationInput.model_validate({
|
||||
"context_handle": "a" * 43,
|
||||
"burst_leakage": 0.02,
|
||||
"pressure_scada_ids": ["J1"],
|
||||
"flow_scada_ids": ["L1"],
|
||||
"scada_burst_start": START + timedelta(minutes=15),
|
||||
"scada_burst_end": START + timedelta(minutes=30),
|
||||
"scada_normal_start": START,
|
||||
"scada_normal_end": START + timedelta(minutes=14),
|
||||
**changes,
|
||||
})
|
||||
|
||||
|
||||
def test_replay_location_builds_series_only_from_frozen_outputs():
|
||||
replay_id = str(uuid4())
|
||||
run = {
|
||||
"start_time": START.isoformat(),
|
||||
"devices": [
|
||||
{"device_id": "P1", "node_id": "J1", "link_id": None},
|
||||
{"device_id": "F1", "node_id": None, "link_id": "L1"},
|
||||
],
|
||||
"monitoring_config": {
|
||||
"pressure_device_ids": ["P1"],
|
||||
"outlet_flow_device_ids": ["F1"],
|
||||
},
|
||||
}
|
||||
readings = [
|
||||
{"reading": {"device_id": "P1", "time": START.isoformat(), "cleaned_value": 100, "quality": "valid"}},
|
||||
{"reading": {"device_id": "F1", "time": START.isoformat(), "cleaned_value": 50, "quality": "valid"}},
|
||||
{"reading": {"device_id": "P1", "time": (START + timedelta(minutes=15)).isoformat(), "cleaned_value": 70, "quality": "valid"}},
|
||||
{"reading": {"device_id": "F1", "time": (START + timedelta(minutes=15)).isoformat(), "cleaned_value": 80, "quality": "valid"}},
|
||||
]
|
||||
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()))
|
||||
|
||||
assert result["burst_pressure"] == {"J1": 70}
|
||||
assert result["normal_pressure"] == {"J1": 100}
|
||||
assert result["burst_flow"] == {"L1": 80}
|
||||
assert result["normal_flow"] == {"L1": 50}
|
||||
assert connection.statements[1][1][2] == 9
|
||||
|
||||
|
||||
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",
|
||||
tool_cap=["run_burst_location"], permission_cap=["burst.run", "scada.view"],
|
||||
native_session_id="s", purpose="task_execution", task_id=uuid4(),
|
||||
expires_at=START + timedelta(days=1),
|
||||
replay_context={"replay_id": str(uuid4()),
|
||||
"knowledge_cutoff": (START + timedelta(hours=1)).isoformat(),
|
||||
"batch_sequence": 9},
|
||||
)
|
||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"burst.run", "scada.view"})
|
||||
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
|
||||
|
||||
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_burst_location"], _args()))
|
||||
|
||||
missing_normal = _args().model_copy(update={"scada_normal_start": None, "scada_normal_end": None})
|
||||
with pytest.raises(HTTPException) as caught:
|
||||
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_burst_location"], missing_normal))
|
||||
assert caught.value.status_code == 422
|
||||
|
||||
|
||||
def test_replay_location_execution_uses_frozen_adapter_instead_of_live_scada(monkeypatch):
|
||||
from app.mcp import analysis_service
|
||||
from app.services import scada_replay_queries
|
||||
|
||||
binding = BoundContext(
|
||||
context_handle="a" * 43, project_id=uuid4(), subject="u", client_id="host",
|
||||
tool_cap=["run_burst_location"], permission_cap=["burst.run", "scada.view"],
|
||||
native_session_id="s", purpose="task_execution", task_id=uuid4(),
|
||||
expires_at=START + timedelta(days=1),
|
||||
replay_context={"replay_id": str(uuid4()),
|
||||
"knowledge_cutoff": (START + timedelta(hours=1)).isoformat(),
|
||||
"batch_sequence": 9},
|
||||
)
|
||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"burst.run", "scada.view"})
|
||||
frozen = {"burst_leakage": 0.02, "burst_pressure": {"J1": 70}, "normal_pressure": {"J1": 100}}
|
||||
build = AsyncMock(return_value=frozen)
|
||||
calculate = AsyncMock(return_value={"candidates": [{"id": "PIPE-1"}]})
|
||||
monkeypatch.setattr(scada_replay_queries, "replay_burst_location_inputs", build)
|
||||
monkeypatch.setattr(analysis_service, "calculate_replay_burst_location", calculate)
|
||||
|
||||
@asynccontextmanager
|
||||
async def routing(): yield
|
||||
|
||||
scope = SimpleNamespace(
|
||||
connection=Connection([]),
|
||||
project=SimpleNamespace(project_id=binding.project_id, project_code="demo", project_role="member"),
|
||||
user=SimpleNamespace(username="tester"),
|
||||
simulation_routing=routing,
|
||||
)
|
||||
result = asyncio.run(query_service.execute_query(
|
||||
scope, binding, TOOLS["run_burst_location"], _args(),
|
||||
))
|
||||
|
||||
build.assert_awaited_once()
|
||||
calculate.assert_awaited_once_with(frozen, network="demo", username="tester")
|
||||
assert result.origin == "synthetic"
|
||||
assert result.data["result"]["candidates"][0]["id"] == "PIPE-1"
|
||||
Reference in New Issue
Block a user