feat: extend operational investigation workflows
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
"""Topology-only valve isolation search."""
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Iterable, Literal
|
||||
|
||||
|
||||
VALVE_LINK_TYPE = "valve"
|
||||
AccidentElementType = Literal["auto", "link", "node"]
|
||||
|
||||
|
||||
def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
|
||||
@@ -14,12 +15,37 @@ def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
|
||||
return parts[0], parts[1], parts[2], parts[3]
|
||||
|
||||
|
||||
def _reachable_nodes(
|
||||
adjacency: dict[str, list[tuple[str, str]]],
|
||||
start_nodes: Iterable[str],
|
||||
blocked_links: set[str],
|
||||
) -> set[str]:
|
||||
visited = set(start_nodes)
|
||||
queue = deque(visited)
|
||||
while queue:
|
||||
for neighbor, link_id in adjacency.get(queue.popleft(), ()):
|
||||
if link_id not in blocked_links and neighbor not in visited:
|
||||
visited.add(neighbor)
|
||||
queue.append(neighbor)
|
||||
return visited
|
||||
|
||||
|
||||
def valve_isolation_analysis(
|
||||
link_entries: Iterable[str],
|
||||
accident_elements: str | list[str],
|
||||
disabled_valves: list[str] | None = None,
|
||||
*,
|
||||
accident_element_type: AccidentElementType = "auto",
|
||||
water_source_nodes: Iterable[str] | None = None,
|
||||
closed_links: Iterable[str] = (),
|
||||
) -> dict[str, Any]:
|
||||
"""Determine boundary valves and affected nodes from a topology snapshot."""
|
||||
"""Find nearest valve boundaries, then check source connectivity.
|
||||
|
||||
Legacy untyped IDs prefer links; explicit node mode handles cross-type ID
|
||||
collisions. Supply impact is a before/after connectivity difference, not
|
||||
a hydraulic pressure or demand calculation. Without source metadata only
|
||||
the provisional boundary search is available.
|
||||
"""
|
||||
|
||||
disabled_valves_set = set(disabled_valves or [])
|
||||
target_elements = (
|
||||
@@ -27,8 +53,13 @@ def valve_isolation_analysis(
|
||||
if isinstance(accident_elements, str)
|
||||
else accident_elements
|
||||
)
|
||||
if not target_elements:
|
||||
raise ValueError("At least one accident element is required")
|
||||
if accident_element_type not in {"auto", "link", "node"}:
|
||||
raise ValueError(f"Invalid accident element type: {accident_element_type}")
|
||||
closed_links_set = set(closed_links)
|
||||
|
||||
pipe_adj: dict[str, set[str]] = defaultdict(set)
|
||||
adjacency: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
all_valves: dict[str, tuple[str, str]] = {}
|
||||
link_lookup: dict[str, tuple[str, str, str]] = {}
|
||||
node_set: set[str] = set()
|
||||
@@ -39,46 +70,31 @@ def valve_isolation_analysis(
|
||||
node_set.update((node1, node2))
|
||||
if link_type_name == VALVE_LINK_TYPE:
|
||||
all_valves[link_id] = (node1, node2)
|
||||
else:
|
||||
pipe_adj[node1].add(node2)
|
||||
pipe_adj[node2].add(node1)
|
||||
if link_id not in closed_links_set:
|
||||
adjacency[node1].append((node2, link_id))
|
||||
adjacency[node2].append((node1, link_id))
|
||||
|
||||
unknown_disabled = disabled_valves_set - all_valves.keys()
|
||||
if unknown_disabled:
|
||||
raise ValueError(f"Disabled valve was not found in topology: {sorted(unknown_disabled)}")
|
||||
|
||||
start_nodes: set[str] = set()
|
||||
for element in target_elements:
|
||||
if element in node_set:
|
||||
start_nodes.add(element)
|
||||
elif element in link_lookup:
|
||||
if accident_element_type != "node" and element in link_lookup:
|
||||
node1, node2, _ = link_lookup[element]
|
||||
start_nodes.update((node1, node2))
|
||||
elif accident_element_type != "link" and element in node_set:
|
||||
start_nodes.add(element)
|
||||
else:
|
||||
raise ValueError(f"Accident element {element} was not found in topology")
|
||||
|
||||
extra_adj: dict[str, list[str]] = defaultdict(list)
|
||||
boundary_valves: dict[str, tuple[str, str]] = {}
|
||||
for valve_id, (node1, node2) in all_valves.items():
|
||||
if valve_id in disabled_valves_set:
|
||||
extra_adj[node1].append(node2)
|
||||
extra_adj[node2].append(node1)
|
||||
else:
|
||||
boundary_valves[valve_id] = (node1, node2)
|
||||
|
||||
affected_nodes: set[str] = set()
|
||||
queue = deque(start_nodes)
|
||||
while queue:
|
||||
node = queue.popleft()
|
||||
if node in affected_nodes:
|
||||
continue
|
||||
affected_nodes.add(node)
|
||||
queue.extend(pipe_adj.get(node, set()) - affected_nodes)
|
||||
queue.extend(
|
||||
neighbor
|
||||
for neighbor in extra_adj.get(node, ())
|
||||
if neighbor not in affected_nodes
|
||||
)
|
||||
closable_valves = set(all_valves) - disabled_valves_set - closed_links_set
|
||||
affected_nodes = _reachable_nodes(adjacency, start_nodes, closable_valves)
|
||||
|
||||
must_close_valves: list[str] = []
|
||||
optional_valves: list[str] = []
|
||||
for valve_id, (node1, node2) in boundary_valves.items():
|
||||
for valve_id in closable_valves:
|
||||
node1, node2 = all_valves[valve_id]
|
||||
node1_affected = node1 in affected_nodes
|
||||
node2_affected = node2 in affected_nodes
|
||||
if node1_affected and node2_affected:
|
||||
@@ -89,14 +105,37 @@ def valve_isolation_analysis(
|
||||
must_close_valves.sort()
|
||||
optional_valves.sort()
|
||||
isolatable = bool(must_close_valves)
|
||||
failure_reason = None if isolatable else "no_boundary_valves"
|
||||
sources = set(water_source_nodes or ())
|
||||
source_check_performed = bool(sources)
|
||||
supply_affected_nodes = None
|
||||
if water_source_nodes is not None:
|
||||
if not sources:
|
||||
isolatable = False
|
||||
failure_reason = "water_sources_unavailable"
|
||||
else:
|
||||
after_closure = _reachable_nodes(adjacency, sources, set(must_close_valves))
|
||||
if affected_nodes & after_closure:
|
||||
isolatable = False
|
||||
failure_reason = "connected_to_water_source"
|
||||
if isolatable:
|
||||
before_closure = _reachable_nodes(adjacency, sources, set())
|
||||
supply_affected_nodes = sorted(before_closure - after_closure)
|
||||
result: dict[str, Any] = {
|
||||
"accident_elements": target_elements,
|
||||
"accident_element_type": accident_element_type,
|
||||
"disabled_valves": disabled_valves,
|
||||
"affected_nodes": sorted(affected_nodes) if isolatable else [],
|
||||
"affected_node_count": len(affected_nodes),
|
||||
"must_close_valves": must_close_valves,
|
||||
"optional_valves": optional_valves,
|
||||
"isolatable": isolatable,
|
||||
"source_check_performed": source_check_performed,
|
||||
"isolation_failure_reason": failure_reason,
|
||||
"supply_affected_nodes": supply_affected_nodes,
|
||||
"supply_affected_node_count": (
|
||||
len(supply_affected_nodes) if supply_affected_nodes is not None else None
|
||||
),
|
||||
}
|
||||
if len(target_elements) == 1:
|
||||
result["accident_element"] = target_elements[0]
|
||||
|
||||
Reference in New Issue
Block a user