Files
next-tjwater-server/tests/unit/test_burst_impact.py
T
jiang 2e4f6cb93b 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.
2026-11-14 15:14:02 +08:00

198 lines
10 KiB
Python

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"