129 lines
5.8 KiB
Python
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
|