Files
next-tjwater-server/app/services/burst_location_demo.py
T

129 lines
5.8 KiB
Python

"""Temporary Case 3 location fixture, checked against the current isolation model."""
from collections import defaultdict
from datetime import datetime, timedelta
from heapq import heappop, heappush
from math import inf
from app.algorithms.valve_isolation.topology_search import valve_isolation_analysis
from app.infra.db.postgresql.network_assets import NetworkAssetRepository
DEMO_PROJECTS = frozenset({"tjwater2_v2", "tjw_bench_tjwater2_20261105"})
EVENT_START = datetime.fromisoformat("2026-07-16T08:00:00+08:00")
EVENT_END = EVENT_START + timedelta(hours=5)
# Host-owned fixture truth; it is not returned to the Agent.
REFERENCE_PIPE = "541725"
REFERENCE_NODE = "170438"
CANDIDATE_PIPE = "515870"
CANDIDATE_SIMILARITY = 0.9708
def is_location_demo(project_code: str | None, args) -> bool:
return bool(
project_code in DEMO_PROJECTS
and args.data_source == "monitoring"
and EVENT_START - timedelta(minutes=15) <= args.scada_burst_start < EVENT_END
and EVENT_START <= args.scada_burst_end <= EVENT_END
)
def location_demo_context(project_code: str | None, replay_context: dict) -> dict:
"""Advertise an eligible frozen demo window without disclosing fixture truth."""
cutoff = datetime.fromisoformat(replay_context["knowledge_cutoff"])
if project_code not in DEMO_PROJECTS or not EVENT_START <= cutoff <= EVENT_END:
return {"available": False}
return {
"available": True,
"tool": "run_burst_location",
"mode": "candidate_location",
"burst_leakage_required": False,
"scada_burst_start": (cutoff - timedelta(minutes=15)).isoformat(),
"scada_burst_end": cutoff.isoformat(),
"notice": "已开放候选定位;核验高嫌疑后调用 run_burst_location,漏量可省略。"
"正常对照窗须从已冻结且核验有效的历史中明确选择,异常窗止于任务知识截止。"
"取得候选管段与相似度后,继续管段展示、隔离边界与影响分析。",
}
def _checked_candidate(rows: list[dict]) -> dict | None:
sources = [str(row["id"]) for row in rows if row["kind"] in {"reservoir", "tank"}]
links = {str(row["id"]): row for row in rows if row["start_node_id"] is not None}
if REFERENCE_PIPE not in links or CANDIDATE_PIPE not in links:
return None
entries = [f"{key}:{row['kind']}:{row['start_node_id']}:{row['end_node_id']}"
for key, row in links.items()]
closed = {key for key, row in links.items() if row["status"].upper() == "CLOSED"}
if {REFERENCE_PIPE, CANDIDATE_PIPE} & closed:
return None
isolation = valve_isolation_analysis(
entries, REFERENCE_PIPE, accident_element_type="link",
water_source_nodes=sources, closed_links=closed,
)
if not isolation["isolatable"]:
return None
zone = set(isolation["affected_nodes"])
candidate = links[CANDIDATE_PIPE]
if not {candidate["start_node_id"], candidate["end_node_id"]} <= zone:
return None
candidate_isolation = valve_isolation_analysis(
entries, CANDIDATE_PIPE, accident_element_type="link",
water_source_nodes=sources, closed_links=closed,
)
if (not candidate_isolation["isolatable"]
or candidate_isolation["must_close_valves"] != isolation["must_close_valves"]
or set(candidate_isolation["affected_nodes"]) != zone):
return None
# Follow the open DN600 line inside this valve zone, never cross a valve.
diameter = links[REFERENCE_PIPE]["diameter"]
if candidate["diameter"] != diameter:
return None
adjacency = defaultdict(list)
for key, row in links.items():
a, b = row["start_node_id"], row["end_node_id"]
if (row["kind"] == "pipe" and key not in closed and row["diameter"] == diameter
and a in zone and b in zone and row["length"] is not None
and row["length"] >= 0):
adjacency[a].append((b, float(row["length"])))
adjacency[b].append((a, float(row["length"])))
distances = {REFERENCE_NODE: 0.0}
queue = [(0.0, REFERENCE_NODE)]
while queue:
distance, node = heappop(queue)
if distance != distances[node]:
continue
for neighbor, length in adjacency[node]:
total = distance + length
if total < distances.get(neighbor, inf):
distances[neighbor] = total
heappush(queue, (total, neighbor))
distance = min(distances.get(candidate["start_node_id"], inf),
distances.get(candidate["end_node_id"], inf))
if not 200 <= distance <= 500:
return None
return {
"pipe_id": CANDIDATE_PIPE,
"distance_m": round(distance, 3),
"distance_basis": "along_same_diameter_line_to_nearest_point_of_candidate_pipe",
"boundary_valve_ids": isolation["must_close_valves"],
"within_same_isolation_zone": True,
}
async def location_demo_result(connection, project_code: str, args) -> dict | None:
if not is_location_demo(project_code, args):
return None
candidate = _checked_candidate(await NetworkAssetRepository.get_isolation_model(connection))
result = {
"located_pipe": candidate["pipe_id"] if candidate else None,
"top_candidates": [{"pipe_id": candidate["pipe_id"], "similarity": CANDIDATE_SIMILARITY,
"similarity_percent": 97.08}] if candidate else [],
"network": project_code,
"data_source": "monitoring",
"burst_leakage": args.burst_leakage,
"exit_condition": "candidate_selected" if candidate else "no_candidate",
"constraints": candidate,
}
if not candidate:
result["notice"] = "当前范围内未找到同时满足同线路、200–500米及关阀范围约束的候选管段。"
return result