feat(scada): add calibrated monitoring and incident recovery
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user