feat(scada): add project monitoring replay
Generic Container CI/CD / test-build-publish (push) Successful in 3m39s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m39s

This commit is contained in:
2026-11-04 14:27:21 +08:00
parent f25423dec4
commit 26fa5d1909
53 changed files with 5153 additions and 20 deletions
+21
View File
@@ -300,6 +300,27 @@ def binding(task=False):
return value
@pytest.mark.parametrize("tool_name", ["run_burst_detection", "query_element_history"])
def test_replay_binding_denies_unscoped_computations_and_history(tool_name):
bound = binding(task=True)
bound.replay_context = {"replay_id": str(uuid4()), "knowledge_cutoff": "2026-10-08T01:00:00+00:00", "batch_sequence": 1}
with pytest.raises(HTTPException) as error:
asyncio.run(query_service.authorize_query(SimpleNamespace(), bound, TOOLS[tool_name], SimpleNamespace()))
assert error.value.status_code == 403
def test_replay_binding_denies_future_and_allows_device_metadata(monkeypatch):
bound = binding(task=True)
bound.replay_context = {"replay_id": str(uuid4()), "knowledge_cutoff": "2026-10-08T01:00:00+00:00", "batch_sequence": 1}
args = SimpleNamespace(start=datetime(2026, 10, 8, tzinfo=timezone.utc),
end=datetime(2026, 10, 8, 2, tzinfo=timezone.utc))
with pytest.raises(HTTPException) as error:
asyncio.run(query_service.authorize_query(SimpleNamespace(), bound, TOOLS["query_scada_readings"], args))
assert error.value.status_code == 422
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: PERMISSIONS)
asyncio.run(query_service.authorize_query(SimpleNamespace(project=object()), bound, TOOLS["list_scada_devices"], SimpleNamespace()))
@pytest.mark.parametrize("seconds", [30, 120, 240])
def test_all_tools_advertise_the_effective_host_request_budget(runtime, seconds):
app, client, _, _ = runtime
@@ -7,6 +7,7 @@ from sqlalchemy.dialects import postgresql
from app.infra.db.metadb.repositories import metadata_repository
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.domain.schemas.project_settings import ProjectSettings
@pytest.fixture
@@ -99,3 +100,60 @@ async def test_update_map_config_preserves_legacy_settings_and_other_projects():
repository.get_project_by_id.assert_awaited_once_with(project_id)
assert project.map_config == {'legend': 'original', 'coordinate_reference': {'source_srid': 990001}, 'place_search': search}
session.commit.assert_awaited_once()
@pytest.mark.anyio
async def test_update_project_settings_preserves_unmentioned_parameters():
original = {"scada_monitoring_interval_seconds": 300, "future_option": {"enabled": True}}
project = SimpleNamespace(settings=original)
session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock())
repository = MetadataRepository(session)
repository.get_project_by_id = AsyncMock(return_value=project)
project_id = uuid4()
await repository.update_project(
project_id,
updates={"settings": {"scada_monitoring_interval_seconds": 900}},
)
assert project.settings == {"scada_monitoring_interval_seconds": 900, "future_option": {"enabled": True}}
assert original["scada_monitoring_interval_seconds"] == 300
session.commit.assert_awaited_once()
@pytest.mark.anyio
async def test_empty_settings_update_preserves_values_and_explicit_null_clears_interval():
project = SimpleNamespace(settings={"scada_monitoring_interval_seconds": 900, "other": True})
session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock())
repository = MetadataRepository(session)
repository.get_project_by_id = AsyncMock(return_value=project)
project_id = uuid4()
await repository.update_project(project_id, updates={"settings": {}})
assert project.settings == {"scada_monitoring_interval_seconds": 900, "other": True}
await repository.update_project(
project_id, updates={"settings": {"scada_monitoring_interval_seconds": None}}
)
assert project.settings == {"scada_monitoring_interval_seconds": None, "other": True}
def test_scada_monitoring_roles_are_typed_inside_extensible_project_settings():
settings = ProjectSettings.model_validate({
"scada_monitoring_interval_seconds": 900,
"scada_monitoring": {
"rule_version": "burst-monitor-v1",
"pressure_device_ids": ["P1", "P2"],
"outlet_flow_device_ids": ["F1"],
},
"future_option": True,
})
assert settings.scada_monitoring.pressure_device_ids == ["P1", "P2"]
assert settings.model_dump()["future_option"] is True
with pytest.raises(ValueError):
ProjectSettings.model_validate({
"scada_monitoring": {
"pressure_device_ids": ["D1"],
"outlet_flow_device_ids": ["D1"],
}
})
+65
View File
@@ -0,0 +1,65 @@
import asyncio
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock
import pytest
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
from app.services.scada_raw import read_raw_page
START = datetime(2026, 10, 8, tzinfo=timezone.utc)
def reading(minutes, device="P1", value=1):
return {"time": START + timedelta(minutes=minutes), "device_id": device,
"monitored_value": value, "source_hash": "hash"}
def test_pagination_keeps_same_timestamp_devices_and_freezes_clock(monkeypatch):
fetch = AsyncMock(side_effect=[[reading(0), reading(0, "P2")], [reading(0, "P2")]])
monkeypatch.setattr(ScadaRepository, "get_raw_page", fetch)
async def run():
args = dict(project_id="project", device_ids=["P1", "P2"], start=START,
end=START + timedelta(hours=1), limit=1)
first = await read_raw_page(None, **args, through=START + timedelta(minutes=15))
second = await read_raw_page(None, **args, cursor=first.next_cursor, through=START + timedelta(minutes=30))
assert first.items[0].device_id == "P1"
assert second.items[0].device_id == "P2"
assert second.scanned_through == START + timedelta(minutes=15)
assert fetch.await_args.args[-1] == (START, "P1")
asyncio.run(run())
def test_cursor_cannot_expand_query_or_move_into_future(monkeypatch):
fetch = AsyncMock(return_value=[reading(0), reading(5)])
monkeypatch.setattr(ScadaRepository, "get_raw_page", fetch)
async def run():
args = dict(project_id="project", device_ids=["P1"], start=START,
end=START + timedelta(hours=1), limit=1)
first = await read_raw_page(None, **args, through=START + timedelta(minutes=15))
for change in ({"project_id": "other"}, {"device_ids": ["P2"]}, {"through": START}):
with pytest.raises(ValueError, match="游标"):
await read_raw_page(None, **(args | change), cursor=first.next_cursor)
asyncio.run(run())
assert fetch.await_count == 1
def test_raw_response_does_not_expose_corrected_values_and_marks_bad_numbers(monkeypatch):
monkeypatch.setattr(ScadaRepository, "get_raw_page", AsyncMock(return_value=[
reading(0, value=float("nan")), reading(5, value=None), reading(10, value=0),
]))
page = asyncio.run(read_raw_page(None, project_id="project", device_ids=["P1"], start=START,
end=START + timedelta(hours=1)))
assert [item.quality for item in page.items] == ["invalid", "missing", "unvalidated"]
assert page.items[2].monitored_value == 0
assert "cleaned_value" not in page.model_dump_json()
@pytest.mark.parametrize("cursor", ["not-base64", "e30=", "W10="])
def test_malformed_cursor_is_rejected_without_query(monkeypatch, cursor):
fetch = AsyncMock()
monkeypatch.setattr(ScadaRepository, "get_raw_page", fetch)
with pytest.raises(ValueError):
asyncio.run(read_raw_page(None, project_id="project", device_ids=["P1"], start=START,
end=START + timedelta(hours=1), cursor=cursor))
fetch.assert_not_awaited()
+108
View File
@@ -0,0 +1,108 @@
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from app.domain.schemas.scada_replay import ScadaReplayCreate
from app.services import scada_replay_monitoring
TARGET = datetime(2026, 10, 16, tzinfo=timezone.utc)
PRESSURE_IDS = [f"P{index}" for index in range(1, 6)]
FLOW_IDS = ["F1"]
def _document():
config = ScadaReplayCreate.model_validate({
"request_id": str(uuid4()),
"start_time": TARGET - timedelta(days=15),
"end_time": TARGET + timedelta(hours=1),
"device_ids": PRESSURE_IDS + FLOW_IDS,
"rules": [
{"rule_id": "pressure_drop", "device_ids": PRESSURE_IDS,
"kind": "relative_change", "direction": "decrease", "threshold": 20, "unit": "m"},
{"rule_id": "outlet_flow", "device_ids": FLOW_IDS,
"kind": "relative_change", "direction": "increase", "threshold": 20, "unit": "m3/h"},
],
})
return {
"replay_id": str(uuid4()),
"config": config.model_dump(mode="json"),
"monitoring_interval_seconds": 900,
"monitoring_config": {
"rule_version": "burst-monitor-v1",
"pressure_device_ids": PRESSURE_IDS,
"outlet_flow_device_ids": FLOW_IDS,
},
}
def _rows():
rows = []
count = 15 * 96
start = TARGET - timedelta(minutes=15 * (count - 1))
for index in range(count):
at = start + timedelta(minutes=15 * index)
for device_id in PRESSURE_IDS:
rows.append({"time": at.isoformat(), "device_id": device_id,
"cleaned_value": 70 if at == TARGET else 100,
"quality": "valid"})
rows.append({"time": at.isoformat(), "device_id": "F1",
"cleaned_value": 150 if at == TARGET else 100,
"quality": "valid"})
return rows
def test_multiple_indicators_create_suspected_burst_result(monkeypatch):
monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", lambda **_: {
"rows": [{"Score": -0.05}],
"summary": {"latest_sensor_rankings": []},
})
result = scada_replay_monitoring.evaluate_monitoring_period(
_document(), _rows(),
period_start=TARGET - timedelta(minutes=15),
period_end=TARGET,
batch_sequence=1440,
)
assert result["status"] == "suspected_burst"
assert result["pressure"]["hit_device_ids"] == PRESSURE_IDS
assert result["outlet_flow"]["hit_device_ids"] == FLOW_IDS
assert result["burst_detection"]["hit"] is True
def test_single_indicator_stays_attention(monkeypatch):
monkeypatch.setattr(scada_replay_monitoring, "run_burst_detection", lambda **_: {
"rows": [{"Score": 0.1}],
"summary": {"latest_sensor_rankings": []},
})
rows = _rows()
for row in rows:
if row["device_id"] == "F1" and row["time"] == TARGET.isoformat():
row["cleaned_value"] = 100
result = scada_replay_monitoring.evaluate_monitoring_period(
_document(), rows,
period_start=TARGET - timedelta(minutes=15),
period_end=TARGET,
batch_sequence=1440,
)
assert result["status"] == "attention"
assert result["pressure"]["hit"] is True
assert result["outlet_flow"]["hit"] is False
assert result["burst_detection"]["hit"] is False
def test_missing_project_roles_is_data_gap_not_normal():
document = _document()
document["monitoring_config"] = None
result = scada_replay_monitoring.evaluate_monitoring_period(
document, [],
period_start=TARGET - timedelta(minutes=15),
period_end=TARGET,
batch_sequence=1,
)
assert result["status"] == "insufficient_data"
assert result["burst_detection"]["status"] == "not_run"
@@ -0,0 +1,66 @@
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from app.domain.schemas.scada_replay import ScadaReplayCreate
from app.services.scada_replay_processing import prepare_cleaning, detect_frame
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"
+145
View File
@@ -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"
+242
View File
@@ -0,0 +1,242 @@
import asyncio
from contextlib import asynccontextmanager
from copy import deepcopy
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.domain.schemas.scada_replay import ScadaReplayCreate, ScadaReplayCommand
from app.infra.db.postgresql.scada import ScadaInfoRepository
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
from app.services.scada_replay import ScadaReplayService
START = datetime(2026, 10, 8, tzinfo=timezone.utc)
class Connection:
@asynccontextmanager
async def transaction(self):
yield
class Repository:
def __init__(self):
self.project_id = str(uuid4())
self.connection = Connection()
self.document = None
self.batches = {}
async def available(self): return True
@asynccontextmanager
async def control(self): yield
@asynccontextmanager
async def processing(self): yield True
async def by_request(self, key):
return deepcopy(self.document) if self.document and self.document["config"]["request_id"] == str(key) else None
async def active_other(self, key): return False
async def insert(self, document): self.document = deepcopy(document)
async def save(self, document): self.document = deepcopy(document)
async def get(self, _): return deepcopy(self.document)
async def current(self): return deepcopy(self.document)
async def pending_batch(self, _):
return next((deepcopy(batch) for batch in self.batches.values() if batch["stage"] != "detected"), None)
async def save_batch(self, _, batch): self.batches[batch["sequence"]] = deepcopy(batch)
async def monitoring_outputs(self, *_args): return []
def setup(monkeypatch):
repo = Repository()
context = SimpleNamespace(
project_id=repo.project_id,
user_id=uuid4(),
)
service = ScadaReplayService(repo, context, Connection(), clock=lambda: START + timedelta(seconds=2))
monkeypatch.setattr(ScadaInfoRepository, "get_scadas", AsyncMock(return_value=[{
"device_id": "P1", "device_type": "pressure", "measurement_unit": "m", "transmission_frequency": "0:15:00"
}]))
async def raw_page(_connection, _devices, _start, _end, _through, _limit, after=None, **_):
return ([{"time": START, "device_id": "P1", "monitored_value": 20., "source_hash": "h"}]
if after is None else [])
query = AsyncMock(side_effect=raw_page)
cleaning = AsyncMock()
monkeypatch.setattr(ScadaRepository, "get_raw_page", query)
monkeypatch.setattr(ScadaRepository, "apply_replay_cleaning", cleaning)
monkeypatch.setattr(service, "_create_investigations", AsyncMock())
create = ScadaReplayCreate(request_id=uuid4(), start_time=START, end_time=START + timedelta(hours=1),
device_ids=["P1"], rules=[{"rule_id": "p", "device_ids": ["P1"], "kind": "bounds", "upper": 10, "unit": "m"}])
return repo, service, query, cleaning, create
def test_step_command_is_revisioned_idempotent_and_does_not_process_until_requested(monkeypatch):
repo, service, query, cleaning, create = setup(monkeypatch)
async def run():
first = await service.create(create, monitoring_interval_seconds=900)
restored = await service.create(create, monitoring_interval_seconds=300)
assert restored.replay_id == first.replay_id
assert restored.monitoring_interval_seconds == 900
command = ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step")
played = await service.command(first.replay_id, command)
assert played.status == "paused" and played.step_pending and played.revision == 2
assert (await service.command(first.replay_id, command)).revision == 2
with pytest.raises(HTTPException) as conflict:
await service.command(first.replay_id, command.model_copy(update={"request_id": uuid4()}))
assert conflict.value.status_code == 409
asyncio.run(run())
query.assert_not_awaited(); cleaning.assert_not_awaited()
def test_one_raw_read_then_correction_then_detection_without_reread(monkeypatch):
repo, service, query, cleaning, create = setup(monkeypatch)
async def run():
first = await service.create(create, monitoring_interval_seconds=900)
repo.document["warmed"] = True
await service.command(first.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step"))
result = await service.process_pending_step(first.replay_id)
assert result.processed_count == 1
assert result.anomaly_count == 1
assert repo.batches[1]["stage"] == "detected"
asyncio.run(run())
assert query.await_count == 2
assert cleaning.await_count == 2
assert cleaning.await_args_list[0].args[1][0]["cleaned_value"] == 20
def test_detection_failure_resumes_cleaned_batch_without_duplicate_reads_or_writes(monkeypatch):
repo, service, query, cleaning, create = setup(monkeypatch)
publish = AsyncMock(side_effect=[RuntimeError("Agent storage unavailable"), None, None])
monkeypatch.setattr(service, "_create_investigations", publish)
async def run():
first = await service.create(create, monitoring_interval_seconds=900)
repo.document["warmed"] = True
await service.command(first.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step"))
with pytest.raises(RuntimeError): await service.process_pending_step(first.replay_id)
assert repo.document["status"] == "failed"
assert repo.batches[1]["stage"] == "cleaned"
assert repo.document["processed_count"] == 0
await service.command(first.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=2, action="retry"))
result = await service.process_pending_step(first.replay_id)
assert result.processed_count == 1 and result.anomaly_count == 1
asyncio.run(run())
assert query.await_count == 2
assert cleaning.await_count == 2
def test_cleaning_failure_never_runs_detection_and_preserves_prepared_result(monkeypatch):
repo, service, query, cleaning, create = setup(monkeypatch)
cleaning.side_effect = RuntimeError("Timescale unavailable")
async def run():
first = await service.create(create, monitoring_interval_seconds=900)
repo.document["warmed"] = True
await service.command(first.replay_id, ScadaReplayCommand(request_id=uuid4(), expected_revision=1, action="step"))
with pytest.raises(RuntimeError): await service.process_pending_step(first.replay_id)
assert repo.batches[1]["stage"] == "clean_prepared"
assert repo.document["processed_count"] == 0
asyncio.run(run())
service._create_investigations.assert_not_awaited()
def test_creation_requires_explicit_project_monitoring_interval(monkeypatch):
repo, service, query, cleaning, create = setup(monkeypatch)
async def run():
with pytest.raises(HTTPException) as missing:
await service.create(create, monitoring_interval_seconds=None)
assert missing.value.status_code == 422
assert repo.document is None
asyncio.run(run())
query.assert_not_awaited()
cleaning.assert_not_awaited()
def test_step_processes_every_observation_within_one_monitoring_interval(monkeypatch):
repo, service, query, cleaning, create = setup(monkeypatch)
rows = [{"time": START + timedelta(minutes=minute), "device_id": "P1",
"monitored_value": 20., "source_hash": f"h{minute}"} for minute in (0, 5, 10, 15, 20)]
async def raw_page(_connection, _devices, _start, _end, through, _limit, after=None, **_):
return next(([row] for row in rows if row["time"] <= through
and (after is None or (row["time"], row["device_id"]) > after)), [])
query.side_effect = raw_page
async def run():
first = await service.create(create, monitoring_interval_seconds=900)
repo.document["warmed"] = True
stepping = await service.command(first.replay_id, ScadaReplayCommand(
request_id=uuid4(), expected_revision=1, action="step"
))
assert stepping.status == "paused" and stepping.step_pending
with pytest.raises(HTTPException):
await service.command(first.replay_id, ScadaReplayCommand(
request_id=uuid4(), expected_revision=2, action="step"
))
result = await service.process_pending_step(first.replay_id)
assert result.processed_count == 4
assert result.virtual_now == START + timedelta(minutes=15)
assert result.status == "paused"
assert not result.step_pending
assert result.processed_until == START + timedelta(minutes=15)
assert result.monitoring_count == 1
assert result.last_monitoring.status == "insufficient_data"
assert "step_target" not in repo.document
assert all(call.args[4] == START + timedelta(minutes=15) for call in query.await_args_list)
asyncio.run(run())
assert cleaning.await_count == 4
def test_partial_final_step_checks_empty_interval_and_completes_without_fabricated_rows(monkeypatch):
repo, service, query, cleaning, create = setup(monkeypatch)
create = create.model_copy(update={"end_time": START + timedelta(minutes=22)})
query.side_effect = None
query.return_value = []
async def run():
first = await service.create(create, monitoring_interval_seconds=900)
repo.document.update(warmed=True, virtual_now=(START + timedelta(minutes=15)).isoformat())
await service.command(first.replay_id, ScadaReplayCommand(
request_id=uuid4(), expected_revision=1, action="step"
))
result = await service.process_pending_step(first.replay_id)
assert result.virtual_now == create.end_time
assert result.processed_until == create.end_time
assert result.status == "completed" and not result.step_pending
assert result.processed_count == 0
asyncio.run(run())
assert query.await_args.args[4] == create.end_time
def test_suspected_monitoring_creates_burst_report_with_frozen_replay_context(monkeypatch):
repo, service, *_ = setup(monkeypatch)
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr(
"app.services.operational_tasks.OperationalTaskService",
lambda *_: SimpleNamespace(create=create_task),
)
monitoring = {
"status": "suspected_burst",
"period_end": (START + timedelta(minutes=15)).isoformat(),
"knowledge_cutoff": (START + timedelta(minutes=15)).isoformat(),
"rule_version": "burst-monitor-v1",
"pressure": {"hit_device_ids": ["P1", "P2"]},
"outlet_flow": {"hit_device_ids": ["F1"]},
"burst_detection": {"target_score": -0.05},
"task_id": None,
}
document = {"replay_id": str(uuid4())}
asyncio.run(service._create_monitoring_task(document, monitoring, {"sequence": 8}))
command = create_task.await_args.args[0]
assert command.workflow == "burst_report"
assert command.source.replay_context.batch_sequence == 8
assert command.source.replay_context.knowledge_cutoff == START + timedelta(minutes=15)
assert monitoring["task_id"] == str(create_task.return_value.task_id)