feat(investigations): add event impact and work-order feedback flows
Add isolated burst and pollution impact evidence, parallel work-order workflows, persisted notifications and graph updates. Checkpoint current code. Known follow-ups remain for large pollution result previews and graceful geometry failure.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import burst_impact as service
|
||||
from app.services.node_coverage import build_node_coverage
|
||||
|
||||
START = datetime(2026, 7, 16, 5, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def simulation(drops, unit="m"):
|
||||
return {"units": {"pressure": unit}, "times": {"report_start": 0},
|
||||
"node_results": [{"node": f"J{i}", "result": [{"pressure": 30 - drop}]}
|
||||
for i, drop in enumerate(drops)]}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inputs(monkeypatch):
|
||||
monkeypatch.setattr(service, "get_option", lambda _: {"PRESSURE": "METERS"})
|
||||
monkeypatch.setattr(service, "get_option_v3", lambda _: {})
|
||||
read = Mock(side_effect=lambda ids, *a, **kw: {
|
||||
node: [{"time": START.isoformat(), "value": 30}] for node in ids})
|
||||
monkeypatch.setattr(service.InternalQueries, "query_realtime_simulation_by_ids_timerange", read)
|
||||
def coverage(network, groups):
|
||||
ids = sorted({node for group in groups for node in group["node_ids"]})
|
||||
data = build_node_coverage(groups, [
|
||||
{"id": node, "longitude": 121.5 + i * .001, "latitude": 30.9}
|
||||
for i, node in enumerate(ids)], [])
|
||||
payload = data.pop("map_payload")
|
||||
return {"coverage": data, "map_payload": payload}
|
||||
monkeypatch.setattr(service, "generate_node_coverage", coverage)
|
||||
return read
|
||||
|
||||
|
||||
def test_thresholds_are_exclusive_with_no_rounding_and_no_closure(inputs):
|
||||
result = service.estimate_burst_impact("own", START, simulation([.999, 1, 2.999, 3, 4.999, 5, 8, -1]))
|
||||
impact = result["impact"]
|
||||
assert [group["node_ids"] for group in impact["grades"]] == [["J1", "J2"], ["J3", "J4"], ["J5", "J6"]]
|
||||
assert impact["below_threshold_node_count"] == 2
|
||||
assert impact["compared_node_count"] == 8 and impact["affected_node_count"] == 6
|
||||
assert impact["phase"] == "after_burst_before_intervention"
|
||||
assert impact["baseline_frozen"] is False
|
||||
inputs.assert_called_once_with([f"J{i}" for i in range(8)], START, START, "node", "pressure", db_name="own")
|
||||
assert len(result["map_payload"]["features"]) == 3
|
||||
assert all(feature["properties"]["model_estimate"] for feature in result["map_payload"]["features"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unit,factor", [("kPa", 1 / 9.80665), ("PSI", 6894.757293168 / 9806.65),
|
||||
("bar", 100000 / 9806.65), ("m", 1)])
|
||||
def test_normalizes_baseline_and_simulation_independently(monkeypatch, inputs, unit, factor):
|
||||
inputs.return_value = {"J0": [{"time": START.isoformat(), "value": 30 / factor}]}
|
||||
inputs.side_effect = None
|
||||
monkeypatch.setattr(service, "get_option", lambda _: {"PRESSURE": unit})
|
||||
result = service.estimate_burst_impact("own", START, simulation([4]))
|
||||
assert result["impact"]["comparisons"][0]["pressure_drop_m"] == pytest.approx(4)
|
||||
assert result["impact"]["grades"][1]["node_ids"] == ["J0"]
|
||||
|
||||
|
||||
def test_v3_units_take_precedence_over_legacy_settings(monkeypatch, inputs):
|
||||
monkeypatch.setattr(service, "get_option_v3", lambda _: {"PRESSURE_UNITS": "KPA"})
|
||||
inputs.side_effect = None
|
||||
inputs.return_value = {"J0": [{"time": START.isoformat(), "value": 30 * 9.80665}]}
|
||||
impact = service.estimate_burst_impact("own", START, simulation([4]))["impact"]
|
||||
assert impact["comparisons"][0]["pressure_drop_m"] == pytest.approx(4)
|
||||
assert impact["baseline_units_source"] == "current_v3_model_settings"
|
||||
|
||||
|
||||
def test_missing_nonfinite_duplicate_and_wrong_time_are_unavailable_not_zero(inputs):
|
||||
inputs.side_effect = None
|
||||
inputs.return_value = {
|
||||
"J0": [{"time": (START + timedelta(seconds=1)).isoformat(), "value": 30}],
|
||||
"J1": [{"time": START.isoformat(), "value": float("nan")}],
|
||||
"J2": [{"time": START.isoformat(), "value": 30}] * 2,
|
||||
"J3": [{"time": START.isoformat(), "value": 30}],
|
||||
"J4": [{"time": START.isoformat(), "value": 30}],
|
||||
}
|
||||
native = simulation([1] * 6)
|
||||
native["node_results"][3]["result"] = [{"pressure": float("inf")}]
|
||||
result = service.estimate_burst_impact("own", START, native)
|
||||
assert result["impact"]["status"] == "partial"
|
||||
assert result["impact"]["unavailable_node_ids"] == ["J0", "J1", "J2", "J3", "J5"]
|
||||
assert result["impact"]["compared_node_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("times", [{}, {"report_start": 900}, {"sample_times": ["2026-07-16T05:00:01Z"]}])
|
||||
def test_unknown_or_shifted_first_sample_never_queries_baseline(inputs, times):
|
||||
native = simulation([2])
|
||||
native["times"] = times
|
||||
result = service.estimate_burst_impact("own", START, native)
|
||||
assert result["impact"]["reason"] == "simulation_has_no_verified_start_time_sample"
|
||||
assert "map_payload" not in result
|
||||
inputs.assert_not_called()
|
||||
|
||||
|
||||
def test_unknown_units_do_not_draw_an_impact_area(inputs):
|
||||
result = service.estimate_burst_impact("own", START, simulation([2], "unknown"))
|
||||
assert result["impact"]["reason"] == "unsupported_or_missing_pressure_units"
|
||||
inputs.assert_not_called()
|
||||
|
||||
|
||||
def test_full_native_network_compared_before_selected_series_filter(monkeypatch, inputs):
|
||||
from app.mcp import scenario_service
|
||||
from app.services import simulation_scenarios
|
||||
native = simulation([4] * 25)
|
||||
run = Mock(return_value=native)
|
||||
monkeypatch.setattr(simulation_scenarios, "burst_analysis", run)
|
||||
result = scenario_service.calculate_scenario("run_burst_simulation", {
|
||||
"output_node_ids": ["J0"], "output_link_ids": [], "start_time": START, "duration": 900,
|
||||
"burst_pipe_ids": ["P1"], "orifice_areas_cm2": [2], "include_impact_coverage": True,
|
||||
}, network="own", username="user", model_only=True)
|
||||
assert len(result["node_results"]) == 1
|
||||
assert result["impact"]["affected_node_count"] == 25
|
||||
assert result["coverage"]["requested_nodes"] == ["J0"]
|
||||
assert result["persisted_baseline"] is False
|
||||
assert run.call_args.kwargs["persist_results"] is False
|
||||
assert run.call_args.kwargs["burst_ID"] == ["P1"]
|
||||
assert not run.call_args.kwargs["scada_mappings"].reservoirs
|
||||
assert result["impact"]["boundary_source"] == "model_only_no_live_scada"
|
||||
|
||||
|
||||
def test_large_affected_sets_report_full_counts_without_truncating(inputs):
|
||||
result = service.estimate_burst_impact("own", START, simulation([4] * 10001))
|
||||
assert result["impact"]["affected_node_count"] == 10001
|
||||
assert result["impact"]["coverage_status"] == "unavailable"
|
||||
assert "map_payload" not in result
|
||||
|
||||
|
||||
def test_replay_passes_empty_boundary_mappings_to_both_native_runs(monkeypatch):
|
||||
from app.services import simulation_scenarios as scenarios
|
||||
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||
mappings = ScadaElementMappings({}, {}, {}, {}, {}, {}, {})
|
||||
runs = Mock(return_value={"node_results": []})
|
||||
monkeypatch.setattr(scenarios.simulation, "run_simulation", runs)
|
||||
monkeypatch.setattr(scenarios, "get_pipe", lambda *a: {"node1": "A", "node2": "B", "diameter": 100})
|
||||
monkeypatch.setattr(scenarios, "is_junction", lambda *a: True)
|
||||
monkeypatch.setattr(scenarios, "get_emitter", lambda *a: None)
|
||||
monkeypatch.setattr(scenarios, "set_emitter", lambda *a: None)
|
||||
monkeypatch.setattr(scenarios, "get_option", lambda *a: {"UNITS": "LPS"})
|
||||
monkeypatch.setattr(scenarios, "set_option", lambda *a: None)
|
||||
scenarios.burst_analysis.__wrapped__(
|
||||
name="own", modify_pattern_start_time=START.isoformat(), burst_ID=["P1"], burst_size=[2],
|
||||
persist_results=False, _temporary_project="tjw_tmp_own", scada_mappings=mappings,
|
||||
)
|
||||
assert len(runs.call_args_list) == 2
|
||||
assert all(call.kwargs["scada_mappings"] is mappings for call in runs.call_args_list)
|
||||
assert runs.call_args.kwargs["persist_results"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_order_notification_precedes_durable_impact_and_keeps_run_available(inputs):
|
||||
from copy import deepcopy
|
||||
from uuid import uuid4
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from app.domain.schemas.operational_task import ActionProposalCommand, TaskPresentationEventCommand
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||
from app.mcp.models import QueryResult
|
||||
from app.mcp.presentation_preview import saved_presentation_preview
|
||||
from app.services.operational_tasks import OperationalTaskService
|
||||
from tests.support.operational_task_repository import TaskRepository, _context, _task_document, _active_run
|
||||
|
||||
context = _context()
|
||||
task = _task_document(context, START, lease=START + timedelta(minutes=1))
|
||||
task["workflow"] = "burst_report"
|
||||
repository = TaskRepository(task, _active_run(task, START))
|
||||
repository.reports.append({"revision": 1, "execution_generation": 1,
|
||||
"report": {"stage": "interim", "outcome": "concluded"}})
|
||||
tasks = OperationalTaskService(repository, context, set(), clock=lambda: START)
|
||||
order = await tasks.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="P1候选管段待现场核验",
|
||||
payload={"title": "P1现场核查", "description": "核查定位管道并反馈", "work_order_type": "general"},
|
||||
))
|
||||
assert repository.task["status"] == "running"
|
||||
assert repository.notifications[-1]["kind"] == "action_approval_pending"
|
||||
assert order.approval_status == "pending" and order.execution_status == "not_submitted"
|
||||
result = QueryResult(
|
||||
source="run_burst_simulation", source_ref="mcp:burst-impact", queried_at=START,
|
||||
semantics="analysis", origin="system_data", truncated=False,
|
||||
data=jsonable_encoder({"result": service.estimate_burst_impact("own", START, simulation([2, 4, 6]))}),
|
||||
)
|
||||
evidence, _ = await tasks.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||
source=result.source, source_ref=result.source_ref, queried_at=START,
|
||||
origin="system_data", truncated=False, data=jsonable_encoder(result),
|
||||
))
|
||||
result.evidence_id = evidence.evidence_id
|
||||
hint = saved_presentation_preview(result)
|
||||
await tasks.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
|
||||
command=hint["command"],
|
||||
))
|
||||
recorded = deepcopy(repository.events[-1]["metadata"]["command"])
|
||||
assert recorded["data"] == jsonable_encoder(result.data["result"]["map_payload"])
|
||||
assert len(recorded["data"]["features"]) == 3
|
||||
# Saved presentation remains complete even if the live query result changes.
|
||||
result.data["result"]["map_payload"]["features"].clear()
|
||||
assert len(repository.events[-1]["metadata"]["command"]["data"]["features"]) == 3
|
||||
assert repository.run["status"] == "running"
|
||||
Reference in New Issue
Block a user