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
+112
View File
@@ -0,0 +1,112 @@
"""Estimate a scenario's time-cumulative concentration coverage before output filtering."""
from datetime import datetime, timedelta
from math import isfinite
from typing import Any
from app.services.node_coverage import MAX_COVERAGE_NODES, generate_node_coverage
def _sample_times(start: datetime, duration: int, metadata: dict) -> list[datetime]:
explicit = metadata.get("sample_times")
if explicit is not None:
times = [datetime.fromisoformat(value) for value in explicit]
else:
offset, step, count = (metadata.get(key) for key in ("report_start", "report_step", "num_periods"))
if (any(type(value) is not int for value in (offset, step, count))
or offset < 0 or step <= 0 or not 0 < count <= 86401):
raise ValueError("missing or invalid report grid")
times = [start + timedelta(seconds=offset + index * step) for index in range(count)]
if (not times or len(times) > 86401 or any(at.tzinfo is None for at in times)
or any(not start <= at <= start + timedelta(seconds=duration) for at in times)
or any(left >= right for left, right in zip(times, times[1:]))):
raise ValueError("invalid simulation sample times")
return times
def estimate_contaminant_impact(network: str, start: datetime, duration: int,
simulation: dict[str, Any], *, threshold_mg_l: float = 0,
model_only: bool = False) -> dict[str, Any]:
"""Use every native node and report sample; never infer propagation from requested nodes."""
impact: dict[str, Any] = {
"status": "unavailable", "phase": "after_contamination_before_intervention",
"window_start": start.isoformat(), "window_end": (start + timedelta(seconds=duration)).isoformat(),
"threshold_mg_l": threshold_mg_l, "threshold_operator": ">", "concentration_unit": "mg/L",
"threshold_purpose": "scenario_display_not_drinking_water_standard",
"model_temporality": "current_model", "quantity": "total_model_concentration",
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
"boundary": "给定源浓度、模型初始水质及反应条件下,整个模拟时段内超过展示阈值的节点覆盖;"
"首达时间为首个超过阈值的报告采样时刻。不是污染确诊、源独立贡献、现场污染边界或饮用水合格判定。",
}
output = {"impact": impact}
unit = simulation.get("units", {}).get("quality")
factor = {"mgl": 1.0, "mg/l": 1.0, "ugl": 0.001, "ug/l": 0.001}.get(
str(unit).strip().lower().replace("μ", "u").replace("µ", "u"))
impact["input_concentration_unit"] = unit
if factor is None:
impact["reason"] = "unsupported_or_missing_concentration_unit"
return output
try:
times = _sample_times(start, duration, simulation.get("times", {}))
except (TypeError, ValueError, OverflowError):
impact["reason"] = "simulation_has_no_verified_sample_times"
return output
rows = simulation.get("node_results", [])
ids = [str(row["node"]) for row in rows]
if not ids or len(ids) != len(set(ids)):
impact["reason"] = "missing_or_duplicate_simulation_nodes"
return output
affected, unavailable, incomplete = [], [], []
for row in rows:
node = str(row["node"])
samples = row.get("result") or []
if len(samples) > len(times):
impact["reason"] = "simulation_sample_count_mismatch"
return output
valid = []
for at, sample in zip(times, samples):
value = sample.get("quality") if isinstance(sample, dict) else None
if isinstance(value, bool) or value is None:
continue
try:
concentration = float(value) * factor
except (TypeError, ValueError, OverflowError):
continue
if isfinite(concentration) and concentration >= 0:
valid.append((at, concentration))
if not valid:
unavailable.append(node)
continue
if len(valid) != len(times):
incomplete.append(node)
exceedances = [(at, value) for at, value in valid if value > threshold_mg_l]
if exceedances:
peak_at, peak = max(valid, key=lambda item: item[1])
affected.append({"node_id": node, "first_exceedance_sample_at": exceedances[0][0].isoformat(),
"peak_mg_l": peak, "peak_at": peak_at.isoformat(),
"valid_sample_count": len(valid), "complete": len(valid) == len(times)})
impact.update(status="partial" if unavailable or incomplete else "ready",
node_count=len(ids), report_sample_count=len(times),
reported_from=times[0].isoformat(), reported_through=times[-1].isoformat(),
unavailable_node_ids=unavailable, incomplete_node_ids=incomplete,
affected_node_count=len(affected), nodes=affected,
affected_node_ids=[item["node_id"] for item in affected])
if len(unavailable) == len(ids):
impact.update(status="unavailable", reason="no_valid_concentration_samples")
return output
if len(affected) > MAX_COVERAGE_NODES:
impact.update(coverage_status="unavailable", coverage_reason="affected_nodes_exceed_10000_coverage_limit")
return output
geometry = generate_node_coverage(network, [{
"group_id": "contaminant_exposure", "title": f"模拟浓度>{threshold_mg_l:g} mg/L的累计节点范围",
"node_ids": impact["affected_node_ids"],
}])
impact["coverage_status"] = geometry["coverage"]["status"]
geometry["coverage"]["boundary"] = impact["boundary"]
for feature in geometry["map_payload"]["features"]:
feature["properties"].update(impact_phase=impact["phase"], concentration_unit="mg/L",
threshold_mg_l=threshold_mg_l, model_estimate=True,
window_start=impact["window_start"], window_end=impact["window_end"])
if geometry["coverage"]["status"] == "partial":
impact["status"] = "partial"
output.update(geometry)
return output