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.
148 lines
7.2 KiB
Python
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
|