"""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