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"