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.
136 lines
7.4 KiB
Python
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
|