143 lines
5.6 KiB
Python
143 lines
5.6 KiB
Python
"""Topology-only valve isolation search."""
|
|
|
|
from collections import defaultdict, deque
|
|
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]:
|
|
parts = link_entry.split(":", 3)
|
|
if len(parts) != 4:
|
|
raise ValueError(f"Invalid link entry format: {link_entry}")
|
|
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]:
|
|
"""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 = (
|
|
[accident_elements]
|
|
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)
|
|
|
|
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()
|
|
for link_entry in link_entries:
|
|
link_id, link_type, node1, node2 = _parse_link_entry(link_entry)
|
|
link_type_name = str(link_type).lower()
|
|
link_lookup[link_id] = (node1, node2, link_type_name)
|
|
node_set.update((node1, node2))
|
|
if link_type_name == VALVE_LINK_TYPE:
|
|
all_valves[link_id] = (node1, node2)
|
|
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 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")
|
|
|
|
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 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:
|
|
optional_valves.append(valve_id)
|
|
elif node1_affected or node2_affected:
|
|
must_close_valves.append(valve_id)
|
|
|
|
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]
|
|
return result
|