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.
113 lines
6.2 KiB
Python
113 lines
6.2 KiB
Python
"""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
|