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