fix(isolation): distinguish accident links from same-numbered nodes
Generic Container CI/CD / test-build-publish (push) Successful in 2m29s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m29s

Prefer link endpoints for legacy accident IDs and allow explicit node selection. Read authoritative initial statuses and sources, verify source isolation, and separate direct isolation from newly disconnected supply nodes. Return validation errors as HTTP 422.

The node-first lookup misrouted adjacent Fengyang pipes 19025 and 19026 into unrelated regions. Regression cases fail against the previous algorithm and pass after this change.
This commit is contained in:
2026-10-29 11:20:34 +08:00
parent a3b4e75317
commit 38a51ed65c
9 changed files with 405 additions and 45 deletions
+33 -2
View File
@@ -254,14 +254,16 @@ def test_committed_write_refresh_failure_has_explicit_http_contract() -> None:
assert response.json()["code"] == "materialized_view_refresh_failed_after_commit"
def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch) -> None:
@pytest.mark.parametrize("element_type", ["auto", "link", "node"])
def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch, element_type) -> None:
captured: dict[str, object] = {}
def fake_analyze_valve_isolation(network, accident_element, disabled_valves):
def fake_analyze_valve_isolation(network, accident_element, disabled_valves, accident_element_type):
captured.update(
network=network,
accident_element=accident_element,
disabled_valves=disabled_valves,
accident_element_type=accident_element_type,
business_dsn=get_project_pgconn_string(network),
timescale_dsn=get_project_timescale_pgconn_string(network),
)
@@ -288,6 +290,7 @@ def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch) -> None:
("accident_element", "P-1"),
("accident_element", "P-2"),
("disabled_valves", "V-9"),
*([] if element_type == "auto" else [("accident_element_type", element_type)]),
],
)
@@ -297,11 +300,39 @@ def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch) -> None:
"network": "fengyang",
"accident_element": ["P-1", "P-2"],
"disabled_valves": ["V-9"],
"accident_element_type": element_type,
"business_dsn": "postgresql://user:password@biz/fengyang",
"timescale_dsn": "postgresql://user:password@timescale/fengyang",
}
@pytest.mark.parametrize(
"target,disabled,detail",
[("P", "typo", "Disabled valve"), ("missing", "V", "Accident element")],
)
def test_valve_isolation_invalid_ids_return_validation_error(monkeypatch, target, disabled, detail):
from app.services import valve_isolation as service
monkeypatch.setattr(
service, "get_valve_isolation_topology",
lambda _network: (["P:pipe:A:B", "V:valve:B:R"], ["R"], []),
)
monkeypatch.setattr(simulation_endpoint, "analyze_valve_isolation", service.analyze_valve_isolation)
app = FastAPI()
app.include_router(api_router, prefix="/api/v1")
_override_project_routing(app, ProjectContext(
project_id=uuid4(), project_code="project", user_id=uuid4(), project_role="member",
))
response = TestClient(app, raise_server_exceptions=False).post(
"/api/v1/valve-isolation-analyses",
params={"accident_element": target, "disabled_valves": disabled},
)
assert response.status_code == 422
assert detail in response.json()["detail"]
def test_scada_cleaning_runs_are_post() -> None:
methods_by_path = {
route.path: route.methods
+179
View File
@@ -1,3 +1,5 @@
import pytest
from app.algorithms.valve_isolation import topology_search as valve
@@ -23,6 +25,183 @@ def test_isolatable_keeps_affected_node_ids_and_count():
assert result["must_close_valves"] == ["V-close"]
@pytest.fixture
def colliding_topology():
# The reported adjacent pipes and their same-numbered nodes are in
# different valve-bounded regions. Keep this fixture independent of a DB.
return [
"19025:pipe:17038:17037",
"19026:pipe:17038:17039",
"AUTO_TCV_4:valve:17037:D",
"AUTO_TCV_5:valve:17039:E",
"AUTO_TCV_546:valve:17038:R",
"32252:pipe:19025:X",
"15383:pipe:19026:Y",
"V-wrong-25:valve:X:R",
"V-wrong-26:valve:Y:R",
]
@pytest.mark.parametrize("target", ["19025", "19026", ["19025", "19026"]])
def test_adjacent_pipes_take_priority_over_same_numbered_nodes(colliding_topology, target):
result = valve.valve_isolation_analysis(colliding_topology, target)
assert result["affected_nodes"] == ["17037", "17038", "17039"]
assert result["must_close_valves"] == ["AUTO_TCV_4", "AUTO_TCV_5", "AUTO_TCV_546"]
assert result["optional_valves"] == []
def test_explicit_node_type_resolves_the_other_side_of_an_id_collision(colliding_topology):
result = valve.valve_isolation_analysis(
colliding_topology, "19025", accident_element_type="node",
)
assert result["affected_nodes"] == ["19025", "X"]
assert result["must_close_valves"] == ["V-wrong-25"]
def test_unambiguous_legacy_node_input_still_works():
result = valve.valve_isolation_analysis(["P:pipe:A:B", "V:valve:B:R"], "A")
assert result["affected_nodes"] == ["A", "B"]
def test_explicit_link_type_does_not_fall_back_to_a_node():
with pytest.raises(ValueError, match="not found"):
valve.valve_isolation_analysis(
["P:pipe:A:B"], "A", accident_element_type="link",
)
def test_a_boundary_valve_does_not_prove_source_isolation():
result = valve.valve_isolation_analysis(
["P:pipe:R:A", "V:valve:A:B"], "P", water_source_nodes=["R"],
)
assert result["isolatable"] is False
assert result["source_check_performed"] is True
assert result["isolation_failure_reason"] == "connected_to_water_source"
assert result["affected_nodes"] == []
assert result["affected_node_count"] == 2
assert result["supply_affected_nodes"] is None
def test_supply_impact_includes_downstream_regions_but_not_preexisting_outages():
links = [
"P:pipe:A:B", "V-source:valve:A:R", "V-downstream:valve:B:C",
"P-downstream:pipe:C:D", "P-already-off:pipe:X:Y",
]
result = valve.valve_isolation_analysis(links, "P", water_source_nodes=["R"])
assert result["isolatable"] is True
assert result["source_check_performed"] is True
assert result["affected_nodes"] == ["A", "B"]
assert result["affected_node_count"] == 2
assert result["supply_affected_nodes"] == ["A", "B", "C", "D"]
assert result["supply_affected_node_count"] == 4
def test_closed_links_are_not_traversed_or_recommended_for_closure():
result = valve.valve_isolation_analysis(
["P:pipe:A:B", "P-closed:pipe:B:R", "V:valve:A:R", "V-closed:valve:B:C"],
"P", water_source_nodes=["R"], closed_links=["P-closed", "V-closed"],
)
assert result["isolatable"] is True
assert result["affected_nodes"] == ["A", "B"]
assert result["must_close_valves"] == ["V"]
assert result["supply_affected_nodes"] == ["A", "B"]
def test_disabled_valve_cannot_cut_the_source_path():
result = valve.valve_isolation_analysis(
["P:pipe:A:B", "V-disabled:valve:A:R", "V-other:valve:B:C"],
"P", disabled_valves=["V-disabled"], water_source_nodes=["R"],
)
assert result["isolatable"] is False
assert result["isolation_failure_reason"] == "connected_to_water_source"
def test_joint_accidents_fail_if_any_region_remains_connected_to_a_source():
result = valve.valve_isolation_analysis(
["P1:pipe:A:B", "V1:valve:B:R", "P2:pipe:R:C", "V2:valve:C:D"],
["P1", "P2"], water_source_nodes=["R"],
)
assert result["isolatable"] is False
def test_missing_sources_do_not_claim_verified_isolation():
result = valve.valve_isolation_analysis(
["P:pipe:A:B", "V:valve:B:C"], "P", water_source_nodes=[],
)
assert result["isolatable"] is False
assert result["source_check_performed"] is False
assert result["isolation_failure_reason"] == "water_sources_unavailable"
assert result["supply_affected_node_count"] is None
@pytest.mark.parametrize("sources", [["R1"], ["R1", "R2"]])
def test_all_water_sources_are_considered(sources):
result = valve.valve_isolation_analysis(
["P:pipe:A:B", "V:valve:A:R1", "P2:pipe:B:R2"],
"P", water_source_nodes=sources,
)
assert result["isolatable"] is (len(sources) == 1)
def test_empty_targets_are_rejected():
with pytest.raises(ValueError, match="At least one"):
valve.valve_isolation_analysis(["P:pipe:A:B"], [])
def test_unknown_disabled_valve_is_rejected():
with pytest.raises(ValueError, match="Disabled valve"):
valve.valve_isolation_analysis(["P:pipe:A:B"], "P", disabled_valves=["typo"])
def test_service_passes_authoritative_sources_statuses_and_element_type(monkeypatch):
from app.services import valve_isolation as service
projects = []
def snapshot(network):
projects.append(network)
return (
["P:pipe:A:B", "P-closed:pipe:B:R", "V:valve:A:R"],
["R"], ["P-closed"],
)
monkeypatch.setattr(service, "get_valve_isolation_topology", snapshot)
result = service.analyze_valve_isolation(
"trusted-project", ["A"], accident_element_type="node",
)
assert projects == ["trusted-project"]
assert result["isolatable"] is True
assert result["source_check_performed"] is True
assert result["must_close_valves"] == ["V"]
assert result["supply_affected_nodes"] == ["A", "B"]
def test_parallel_links_preserve_an_open_bypass():
result = valve.valve_isolation_analysis(
["P:pipe:A:B", "V:valve:B:R", "P-bypass:pipe:B:R"],
"P", water_source_nodes=["R"],
)
assert result["isolatable"] is False
assert result["isolation_failure_reason"] == "connected_to_water_source"
def test_closed_disabled_valve_does_not_reopen_a_known_closed_link():
result = valve.valve_isolation_analysis(
["P:pipe:A:B", "V:valve:B:R", "V-disabled:valve:A:R"],
"P", disabled_valves=["V-disabled"], closed_links=["V-disabled"],
water_source_nodes=["R"],
)
assert result["isolatable"] is True
assert result["must_close_valves"] == ["V"]
def test_disabled_valve_expands_affected_area_before_counting():
links = [
"P-1:pipe:A:B",
+30
View File
@@ -52,6 +52,36 @@ def test_network_link_nodes_use_one_unified_view_query(monkeypatch) -> None:
assert "FROM gis.network_links" in calls[0][1]
def test_isolation_snapshot_reads_authoritative_links_statuses_and_sources_once(monkeypatch):
calls = []
def fake_read_all(name, statement, params=None):
calls.append((name, statement, params))
return [
{"id": "P", "element_type": "pipe", "start_node_id": "A", "end_node_id": "B", "status": None},
{"id": "P-closed", "element_type": "pipe", "start_node_id": "A", "end_node_id": "R", "status": "CLOSED"},
{"id": "V", "element_type": "valve", "start_node_id": "B", "end_node_id": "R", "status": "CLOSED"},
{"id": "PU", "element_type": "pump", "start_node_id": "T", "end_node_id": "A", "status": "CLOSED"},
{"id": "R", "element_type": "reservoir", "start_node_id": None, "end_node_id": None, "status": None},
{"id": "T", "element_type": "tank", "start_node_id": None, "end_node_id": None, "status": None},
]
monkeypatch.setattr(network_views, "read_all", fake_read_all)
assert network_views.get_valve_isolation_topology("project_a") == (
["P:pipe:A:B", "P-closed:pipe:A:R", "V:valve:B:R", "PU:pump:T:A"],
["R", "T"], ["P-closed", "V", "PU"],
)
assert len(calls) == 1
name, statement, _params = calls[0]
assert name == "project_a"
assert "FROM network.links" in statement
assert "network.link_initial_settings" in statement
assert "COALESCE(s.status::text, p.status::text)" in statement
assert "FROM network.nodes" in statement
assert "UNION ALL" in statement
def test_topology_rows_are_loaded_in_two_batch_queries(monkeypatch) -> None:
calls = []
responses = [