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:
@@ -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
|
||||
Reference in New Issue
Block a user