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.
This commit is contained in:
2026-11-14 15:14:02 +08:00
parent 471ad92a47
commit 2e4f6cb93b
49 changed files with 1826 additions and 93 deletions
+35 -4
View File
@@ -10,7 +10,8 @@ SCENARIO_TOOLS = frozenset({
})
def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str) -> dict[str, Any]:
def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str,
model_only: bool = False) -> dict[str, Any]:
from app.services import simulation_scenarios as scenarios
parameters = dict(parameters)
@@ -18,6 +19,8 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
links = parameters.pop("output_link_ids")
start = parameters.pop("start_time")
duration = parameters.pop("duration")
include_impact = parameters.pop("include_impact_coverage", False)
impact_threshold = parameters.pop("impact_threshold_mg_l", 0)
common = dict(name=network, modify_pattern_start_time=start.isoformat(),
modify_total_duration=duration, persist_results=False, scheme_name=None)
if name == "run_simulation":
@@ -27,11 +30,27 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
network, start, duration, output_node_ids=nodes, output_link_ids=links,
)
elif name == "run_burst_simulation":
boundary = {}
if model_only:
from app.infra.db.postgresql.scada import ScadaElementMappings
# Replay scenarios use current model assumptions, never mutable live
# SCADA readings in place of the investigation's frozen observations.
boundary["scada_mappings"] = ScadaElementMappings(
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
pressure={}, demand={}, quality={},
)
result = scenarios.burst_analysis(**common, username=username,
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"])
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"], **boundary)
elif name == "run_contaminant_simulation":
boundary = {}
if model_only:
from app.infra.db.postgresql.scada import ScadaElementMappings
boundary["scada_mappings"] = ScadaElementMappings(
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
pressure={}, demand={}, quality={},
)
result = scenarios.contaminant_simulation(**common, username=username,
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"))
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"), **boundary)
elif name == "analyze_valve_closure":
result = scenarios.valve_close_analysis(**common,
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
@@ -62,7 +81,7 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
raise ValueError("Scenario did not return calculation results")
selected_nodes = [row for row in result.get("node_results", []) if str(row.get("node")) in nodes]
selected_links = [row for row in result.get("link_results", []) if str(row.get("link")) in links]
return {
output = {
"input_source": "online_model_simulation" if name == "run_simulation" else "isolated_model_simulation",
"persisted_baseline": name == "run_simulation", "persisted_scheme": False,
"start_time": start.isoformat(), "duration_seconds": duration,
@@ -72,3 +91,15 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
"missing_nodes": sorted(set(nodes) - {str(row["node"]) for row in selected_nodes}),
"missing_links": sorted(set(links) - {str(row["link"]) for row in selected_links})},
}
if name == "run_burst_simulation" and include_impact:
from app.services.burst_impact import estimate_burst_impact
impact = estimate_burst_impact(network, start, result, model_only=model_only)
impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})}
output.update(impact)
if name == "run_contaminant_simulation" and include_impact:
from app.services.contaminant_impact import estimate_contaminant_impact
impact = estimate_contaminant_impact(network, start, duration, result,
threshold_mg_l=impact_threshold, model_only=model_only)
impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})}
output.update(impact)
return output