Files
next-tjwater-server/app/services/burst_impact.py
T
jiang 2e4f6cb93b 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.
2026-11-14 15:14:02 +08:00

148 lines
7.2 KiB
Python

"""Estimate pre-intervention burst pressure loss against a read-only online snapshot."""
from datetime import datetime
from math import isfinite
from typing import Any
from app.infra.db.timescaledb.internal_queries import InternalQueries
from app.native.wndb.model.options import get_option, get_option_v3
from app.services.node_coverage import MAX_COVERAGE_NODES, generate_node_coverage
GRADES = (
("drop_1_3", "压力下降 1≤Δ<3 m", 1, 3),
("drop_3_5", "压力下降 3≤Δ<5 m", 3, 5),
("drop_5_plus", "压力下降 Δ≥5 m", 5, None),
)
def _head_factor(unit: Any) -> float | None:
# Standard water head; unsupported or absent units must not become zero loss.
return {"m": 1.0, "meters": 1.0, "metres": 1.0,
"kpa": 1 / 9.80665, "psi": 6894.757293168 / 9806.65,
"bar": 100000 / 9806.65, "mpa": 1000000 / 9806.65,
"ft": 0.3048}.get(str(unit).strip().lower())
def _number(value: Any) -> float | None:
if isinstance(value, bool):
return None
try:
result = float(value)
except (ValueError, TypeError, OverflowError):
return None
return result if isfinite(result) else None
def _instant(value: Any) -> datetime | None:
try:
result = value if isinstance(value, datetime) else datetime.fromisoformat(value)
except (ValueError, TypeError):
return None
return result if result.tzinfo is not None else None
def estimate_burst_impact(network: str, start: datetime, simulation: dict[str, Any], *,
model_only: bool = False) -> dict[str, Any]:
"""Compare every native node at t=0, before the adapter filters requested outputs.
The online snapshot is a model reference read at calculation time, not frozen
SCADA or a reconstructed historical model. All numerical inputs are retained
in task evidence so playback does not repeat this mutable reference query.
"""
impact: dict[str, Any] = {
"status": "unavailable", "phase": "after_burst_before_intervention",
"comparison_time": start.isoformat(), "pressure_drop_unit": "m",
"baseline_source": "existing_online_simulation",
"baseline_read_at": datetime.now().astimezone().isoformat(),
"baseline_units_source": "current_model_settings",
"model_temporality": "current_model", "baseline_frozen": False,
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
"boundary": "假设破口情景与同刻在线模拟的节点压力差估计;覆盖面为节点空间包络,非现场确认停水或用户服务边界。在线基线及其单位按计算时可用数据和模型设置读取。",
"grades": [{"group_id": key, "title": title, "min_inclusive_m": low,
"max_exclusive_m": high, "node_ids": []} for key, title, low, high in GRADES],
}
output = {"impact": impact}
# Never silently shift t=0 to an unknown/delayed report sample or another day.
times = simulation.get("times", {})
sample_times = times.get("sample_times")
if ((sample_times and _instant(sample_times[0]) != start)
or (not sample_times and times.get("report_start") != 0)):
impact["reason"] = "simulation_has_no_verified_start_time_sample"
return output
options = get_option(network)
options_v3 = get_option_v3(network)
baseline_unit = options_v3.get("PRESSURE_UNITS") or options.get("PRESSURE")
impact["baseline_units_source"] = (
"current_v3_model_settings" if options_v3.get("PRESSURE_UNITS") else "current_legacy_model_settings"
)
impact["model_conditions"] = {
"baseline_demand_model": options_v3.get("DEMAND_MODEL") or options.get("DEMAND MODEL"),
"burst_demand_model": "PDA", "burst_required_pressure_model_units": 10,
"note": "爆管模拟采用压力驱动需水;与在线模型的需水方式差异也可能影响压力差。",
}
scenario_unit = simulation.get("units", {}).get("pressure")
baseline_factor, scenario_factor = _head_factor(baseline_unit), _head_factor(scenario_unit)
impact["input_pressure_units"] = {"baseline": baseline_unit, "burst": scenario_unit}
if baseline_factor is None or scenario_factor is None:
impact["reason"] = "unsupported_or_missing_pressure_units"
return output
nodes = simulation.get("node_results", [])
ids = [str(row["node"]) for row in nodes]
if not ids or len(ids) != len(set(ids)):
impact["reason"] = "missing_or_duplicate_simulation_nodes"
return output
baseline = InternalQueries.query_realtime_simulation_by_ids_timerange(
ids, start, start, "node", "pressure", db_name=network,
)
comparisons, missing = [], []
for row in nodes:
node_id = str(row["node"])
samples = row.get("result") or []
burst = _number(samples[0].get("pressure")) if samples and isinstance(samples[0], dict) else None
exact = [item for item in baseline.get(node_id, []) if _instant(item.get("time")) == start]
normal = _number(exact[0].get("value")) if len(exact) == 1 else None
if burst is None or normal is None:
missing.append(node_id)
continue
normal_m, burst_m = normal * baseline_factor, burst * scenario_factor
drop = normal_m - burst_m
if not all(isfinite(value) for value in (normal_m, burst_m, drop)):
missing.append(node_id)
continue
comparisons.append({"node_id": node_id, "baseline_m": normal_m,
"burst_m": burst_m, "pressure_drop_m": drop})
for group in impact["grades"]:
if drop >= group["min_inclusive_m"] and (
group["max_exclusive_m"] is None or drop < group["max_exclusive_m"]
):
group["node_ids"].append(node_id)
break
impact.update(status="partial" if missing else "ready", node_count=len(ids),
compared_node_count=len(comparisons), unavailable_node_ids=missing,
comparisons=comparisons)
affected = sum(len(group["node_ids"]) for group in impact["grades"])
impact["affected_node_count"] = affected
impact["below_threshold_node_count"] = len(comparisons) - affected
if not comparisons:
impact.update(status="unavailable", reason="no_exact_comparable_online_snapshot")
return output
if affected > MAX_COVERAGE_NODES:
impact["coverage_status"] = "unavailable"
impact["coverage_reason"] = "affected_nodes_exceed_10000_coverage_limit"
return output
groups = [{key: group[key] for key in ("group_id", "title", "node_ids")}
for group in impact["grades"]]
geometry = generate_node_coverage(network, groups)
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"], pressure_drop_unit="m",
pressure_drop_grade=feature["properties"]["coverage_group"],
comparison_time=start.isoformat(), model_estimate=True,
)
if geometry["coverage"]["status"] == "partial":
impact["status"] = "partial"
output.update(geometry)
return output