Files
next-tjwater-server/tests/unit/test_contaminant_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

136 lines
7.4 KiB
Python

from copy import deepcopy
from datetime import datetime, timedelta, timezone
import pytest
from app.services import contaminant_impact as impact
from app.services.node_coverage import build_node_coverage
START = datetime(2026, 7, 16, 0, tzinfo=timezone.utc)
def simulation(unit="MGL"):
return {"units": {"quality": unit}, "times": {"report_start": 0, "report_step": 900, "num_periods": 3},
"node_results": [{"node": node, "result": [{"quality": value} for value in values]}
for node, values in {"source": [1, 1, 1], "late": [0, 0.2, 0.5], "clean": [0, 0, 0]}.items()]}
@pytest.fixture
def geometry(monkeypatch):
calls = []
def generate(network, groups):
calls.append((network, deepcopy(groups)))
coverage = build_node_coverage(groups, [{"id": "source", "longitude": 121.4, "latitude": 31.1}], [])
payload = coverage.pop("map_payload")
return {"coverage": coverage, "map_payload": payload}
monkeypatch.setattr(impact, "generate_node_coverage", generate)
return calls
def test_impact_uses_whole_prediction_window_and_real_concentration_units(geometry):
result = impact.estimate_contaminant_impact("project", START, 1800, simulation(), threshold_mg_l=0.2, model_only=True)
data = result["impact"]
assert data["affected_node_ids"] == ["source", "late"]
late = next(row for row in data["nodes"] if row["node_id"] == "late")
assert late["first_exceedance_sample_at"] == (START + timedelta(minutes=30)).isoformat()
assert late["peak_mg_l"] == 0.5 and late["peak_at"] == late["first_exceedance_sample_at"]
assert data["node_count"] == 3 and data["status"] == "partial" # Late node has no GIS position.
assert data["boundary_source"] == "model_only_no_live_scada"
assert data["threshold_purpose"] == "scenario_display_not_drinking_water_standard"
assert geometry[0][1][0]["node_ids"] == ["source", "late"]
assert result["coverage"]["groups"]["contaminant_exposure"]["missing_node_ids"] == ["late"]
assert result["map_payload"]["features"][0]["properties"]["impact_phase"] == "after_contamination_before_intervention"
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.services.operational_presentation import resolve_overlay
from fastapi.encoders import jsonable_encoder
snapshot = TaskDataSnapshot(source="run_contaminant_simulation", source_ref="calculation:propagation",
queried_at=START, origin="synthetic", truncated=False, data=jsonable_encoder({"result": result}))
saved = resolve_overlay(snapshot, "geometry")
assert saved["features"][0]["properties"]["model_estimate"] is True
@pytest.mark.parametrize("unit", ["UGL", "ug/L", "µg/L"])
def test_micrograms_are_converted_before_threshold_comparison(geometry, unit):
result = impact.estimate_contaminant_impact("project", START, 1800, simulation(unit), threshold_mg_l=0.0002)
assert result["impact"]["nodes"][1]["peak_mg_l"] == 0.0005
assert result["impact"]["affected_node_count"] == 2
@pytest.mark.parametrize("problem", ["unit", "times", "duplicate", "too_many_samples", "no_values"])
def test_invalid_results_do_not_turn_into_an_empty_safe_range(geometry, problem):
data = simulation()
if problem == "unit": data["units"]["quality"] = "HOURS"
if problem == "times": data["times"] = {"report_step": 0}
if problem == "duplicate": data["node_results"].append(data["node_results"][0])
if problem == "too_many_samples": data["node_results"][0]["result"].append({"quality": 2})
if problem == "no_values":
for row in data["node_results"]: row["result"] = [{"quality": float("nan")}] * 3
result = impact.estimate_contaminant_impact("project", START, 1800, data)
assert result["impact"]["status"] == "unavailable"
assert "reason" in result["impact"] and "map_payload" not in result
assert not geometry
def test_missing_samples_remain_explicit_and_do_not_replace_first_observation(geometry):
data = simulation()
data["node_results"][1]["result"][0] = {"quality": None}
result = impact.estimate_contaminant_impact("project", START, 1800, data)
assert result["impact"]["incomplete_node_ids"] == ["late"]
assert result["impact"]["nodes"][1]["complete"] is False
def test_geometry_limit_retains_the_numeric_prediction(monkeypatch):
monkeypatch.setattr(impact, "MAX_COVERAGE_NODES", 1)
result = impact.estimate_contaminant_impact("project", START, 1800, simulation())
assert result["impact"]["affected_node_count"] == 2
assert result["impact"]["coverage_status"] == "unavailable"
assert "map_payload" not in result
def test_adapter_estimates_all_native_nodes_before_selecting_display_series(monkeypatch):
from app.mcp.analysis_models import ContaminantSimulationInput
from app.mcp.scenario_service import calculate_scenario
from app.services import simulation_scenarios
native = simulation()
calls = []
monkeypatch.setattr(simulation_scenarios, "contaminant_simulation", lambda **kw: calls.append(kw) or native)
seen = []
monkeypatch.setattr(impact, "estimate_contaminant_impact",
lambda network, start, duration, result, **kw: seen.append((network, start, duration, result, kw))
or {"impact": {"affected_node_count": 2}})
args = ContaminantSimulationInput(context_handle="a" * 43, start_time=START, duration=1800,
output_node_ids=["source"], source="source", concentration=1,
include_impact_coverage=True, impact_threshold_mg_l=0.2)
result = calculate_scenario("run_contaminant_simulation",
args.model_dump(exclude={"context_handle", "timeout_seconds"}), network="project", username="actor", model_only=True)
assert len(result["node_results"]) == 1 and len(seen[0][3]["node_results"]) == 3
assert seen[0][4] == {"threshold_mg_l": 0.2, "model_only": True}
assert result["impact"]["affected_node_count"] == 2
assert calls[0]["scada_mappings"].reservoirs == {} and calls[0]["scada_mappings"].quality == {}
assert calls[0]["persist_results"] is False and calls[0]["scheme_name"] is None
def test_native_contaminant_service_passes_empty_mappings_to_the_engine(monkeypatch):
from app.infra.db.postgresql.scada import ScadaElementMappings
from app.services import simulation_scenarios as native
for name in ("set_time", "set_option", "add_source", "set_demand"):
monkeypatch.setattr(native, name, lambda *args: None)
monkeypatch.setattr(native, "get_time", lambda *_: {"HYDRAULIC TIMESTEP": "0:15:00"})
monkeypatch.setattr(native, "get_pattern", lambda *_: {"id": "pattern"})
monkeypatch.setattr(native, "get_source", lambda *_: {})
monkeypatch.setattr(native, "get_demand", lambda *_: {"demands": []})
monkeypatch.setattr(native, "get_option", lambda *_: {})
calls = []
monkeypatch.setattr(native.simulation, "run_simulation", lambda **kw: calls.append(kw) or simulation())
mappings = ScadaElementMappings(reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={}, pressure={}, demand={}, quality={})
native.contaminant_simulation.__wrapped__(name="project", modify_pattern_start_time=START.isoformat(),
modify_total_duration=1800, source="source", concentration=1, source_pattern="pattern",
username="actor", _temporary_project="temporary", persist_results=False, scada_mappings=mappings)
assert calls[0]["scada_mappings"] is mappings
assert calls[0]["name"] == "temporary" and calls[0]["persist_results"] is False