refactor(backend)!: separate algorithm and data layers

Reorganize algorithm packages by business responsibility, move orchestration into services, and keep database access behind pooled repositories.

Harden analysis API validation, remove unsafe legacy simulation endpoints, and add regression and architecture boundary coverage.

BREAKING CHANGE: legacy algorithm module paths and obsolete simulation endpoints are removed.
This commit is contained in:
2026-09-04 17:30:55 +08:00
parent 9b095c7439
commit 5966d039de
91 changed files with 1418 additions and 4020 deletions
-111
View File
@@ -1,111 +0,0 @@
import json
from contextlib import contextmanager
from tests.conftest import install_stub, load_module_from_path
def _load_scenarios_module(monkeypatch):
install_stub(monkeypatch, "app.services", package=True)
install_stub(monkeypatch, "app.algorithms", package=True)
install_stub(monkeypatch, "app.algorithms.simulation", package=True)
install_stub(monkeypatch, "app.services.simulation", {})
install_stub(
monkeypatch,
"app.algorithms.simulation.runner",
{
"run_simulation_ex": lambda *args, **kwargs: json.dumps(
{"output": {"node_results": [], "link_results": []}}
),
"from_clock_to_seconds_2": lambda value: value,
},
)
install_stub(monkeypatch, "app.services.scheme_management", {"store_scheme_info": lambda *args, **kwargs: None})
install_stub(
monkeypatch,
"app.services.tjnetwork",
{
"ChangeSet": type("ChangeSet", (), {}),
"OPTION_DEMAND_MODEL_PDA": "OPTION_DEMAND_MODEL_PDA",
"OPTION_QUALITY_CHEMICAL": "OPTION_QUALITY_CHEMICAL",
"SOURCE_TYPE_SETPOINT": "SOURCE_TYPE_SETPOINT",
"add_pattern": lambda *args, **kwargs: None,
"add_source": lambda *args, **kwargs: None,
"copy_project": lambda *args, **kwargs: None,
"delete_project": lambda *args, **kwargs: None,
"get_demand": lambda *args, **kwargs: None,
"get_emitter": lambda *args, **kwargs: None,
"get_node_links": lambda *args, **kwargs: None,
"get_option": lambda *args, **kwargs: None,
"get_pattern": lambda *args, **kwargs: None,
"get_pipe": lambda *args, **kwargs: None,
"get_source": lambda *args, **kwargs: None,
"get_time": lambda *args, **kwargs: None,
"have_project": lambda *args, **kwargs: False,
"is_junction": lambda *args, **kwargs: False,
"set_demand": lambda *args, **kwargs: None,
"set_emitter": lambda *args, **kwargs: None,
"set_option": lambda *args, **kwargs: None,
"set_source": lambda *args, **kwargs: None,
"set_time": lambda *args, **kwargs: None,
},
)
return load_module_from_path(
"tests_age_analysis_scenarios_module",
"app/algorithms/simulation/scenarios.py",
)
def test_age_analysis_passes_duration_by_keyword(monkeypatch):
module = _load_scenarios_module(monkeypatch)
captured = {}
@contextmanager
def fake_temporary_project(project, purpose):
yield f"{purpose}_{project}_run"
monkeypatch.setattr(module, "temporary_project_database", fake_temporary_project)
def fake_run_simulation_ex(*args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
return json.dumps({"output": {"node_results": [], "link_results": []}})
monkeypatch.setattr(module, "run_simulation_ex", fake_run_simulation_ex)
module.age_analysis("demo", "2026-06-03T07:00:00+08:00", 300)
assert captured["args"] == (
"age_analysis_demo_run",
"realtime",
"2026-06-03T07:00:00+08:00",
)
assert captured["kwargs"] == {
"duration": 300,
"downloading_prohibition": True,
}
def test_isolated_analysis_cleans_database_after_early_return(monkeypatch):
module = _load_scenarios_module(monkeypatch)
lifecycle: list[tuple[str, str]] = []
@contextmanager
def fake_temporary_project(project, purpose):
lifecycle.append(("create", purpose))
try:
yield "isolated_run"
finally:
lifecycle.append(("delete", purpose))
monkeypatch.setattr(
module, "temporary_project_database", fake_temporary_project
)
@module._isolated_analysis("probe")
def return_early(name, *, _temporary_project=None):
assert name == "demo"
assert _temporary_project == "isolated_run"
return "done"
assert return_early("demo") == "done"
assert lifecycle == [("create", "probe"), ("delete", "probe")]
+24 -24
View File
@@ -19,38 +19,38 @@ def _empty_scada_mappings(simulation):
)
def test_run_simulation_exposes_explicit_valve_control():
def test_run_simulation_accepts_explicit_valve_control():
from app.services import simulation
assert "valve_control" in inspect.signature(simulation.run_simulation).parameters
def test_extended_runner_cleans_temporary_database_after_failure(monkeypatch):
from app.algorithms.simulation import runner
def test_valve_close_analysis_uses_normalized_scheme_type(monkeypatch):
from app.services import simulation_scenarios
lifecycle: list[tuple[str, str]] = []
captured = {}
monkeypatch.setattr(simulation_scenarios, "get_option", lambda _name: {})
monkeypatch.setattr(
simulation_scenarios,
"set_option",
lambda _name, _changes: None,
)
monkeypatch.setattr(
simulation_scenarios.simulation,
"run_simulation",
lambda **kwargs: captured.update(kwargs),
)
@contextmanager
def temporary_project(project: str, purpose: str):
lifecycle.append(("create", project))
try:
yield "isolated_project"
finally:
lifecycle.append(("delete", project))
simulation_scenarios.valve_close_analysis.__wrapped__(
name="demo",
modify_pattern_start_time="2026-01-01T00:00:00+08:00",
modify_valve_opening={"V1": 0.0},
scheme_name="valve_case",
_temporary_project="temporary_demo",
)
monkeypatch.setattr(runner, "temporary_project_database", temporary_project)
@runner._clean_extended_simulation
def fail(name, simulation_type, *, _temporary_project=None):
assert name == "demo"
assert simulation_type == "extended"
assert _temporary_project == "isolated_project"
raise RuntimeError("simulation failed")
with pytest.raises(RuntimeError, match="simulation failed"):
fail("demo", "extended")
assert lifecycle == [("create", "demo"), ("delete", "demo")]
assert captured["scheme_type"] == "valve_close_analysis"
assert captured["result_db_name"] == "demo"
def test_apply_valve_control_matches_runner_semantics(monkeypatch):
+109
View File
@@ -0,0 +1,109 @@
"""Executable dependency rules for the service/algorithm/database layers."""
import ast
from pathlib import Path
APP_ROOT = Path(__file__).parents[2] / "app"
def _app_imports(path: Path) -> set[str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
imports: set[str] = set()
package_parts = ("app", *path.relative_to(APP_ROOT).parent.parts)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imports.update(alias.name for alias in node.names if alias.name.startswith("app."))
elif isinstance(node, ast.ImportFrom):
if node.level:
parent_count = node.level - 1
if parent_count >= len(package_parts):
continue
base_parts = package_parts[: len(package_parts) - parent_count]
if node.module:
imports.add(".".join((*base_parts, *node.module.split("."))))
else:
imports.update(
".".join((*base_parts, alias.name.split(".")[0]))
for alias in node.names
)
elif node.module and node.module.startswith("app."):
imports.add(node.module)
return imports
def _forbidden_imports(root: Path, prefixes: tuple[str, ...]) -> list[str]:
violations: list[str] = []
for path in sorted(root.rglob("*.py")):
for module in sorted(_app_imports(path)):
if module.startswith(prefixes):
violations.append(f"{path.relative_to(APP_ROOT)} -> {module}")
return violations
def test_algorithms_are_independent_of_application_and_io_layers():
violations = _forbidden_imports(
APP_ROOT / "algorithms",
("app.api", "app.auth", "app.infra", "app.native", "app.services"),
)
assert violations == []
def test_infrastructure_does_not_depend_on_application_or_algorithms():
violations = _forbidden_imports(
APP_ROOT / "infra",
("app.api", "app.algorithms", "app.services"),
)
assert violations == []
def test_services_do_not_execute_sql_directly():
violations = []
for path in sorted((APP_ROOT / "services").rglob("*.py")):
source = path.read_text(encoding="utf-8")
if ".cursor(" in source or ".execute(" in source:
violations.append(str(path.relative_to(APP_ROOT)))
assert violations == []
def test_services_do_not_depend_on_the_http_network_facade():
violations = _forbidden_imports(
APP_ROOT / "services",
("app.services.tjnetwork",),
)
assert violations == []
def test_algorithm_packages_use_explicit_business_names():
legacy_names = {
"burst_location",
"cleaning",
"health",
"isolation",
"leakage",
"sensor",
"simulation",
"water_demand",
}
present_names = {
path.name
for path in (APP_ROOT / "algorithms").iterdir()
if path.is_dir() and any(path.glob("*.py"))
}
assert present_names.isdisjoint(legacy_names)
def test_application_code_does_not_open_direct_psycopg_connections():
violations = []
direct_calls = ("psycopg.connect(", "Connection.connect(", "AsyncConnection.connect(")
for path in sorted(APP_ROOT.rglob("*.py")):
source = path.read_text(encoding="utf-8")
if any(call in source for call in direct_calls):
violations.append(str(path.relative_to(APP_ROOT)))
assert violations == []
+26
View File
@@ -0,0 +1,26 @@
import pytest
from app.algorithms.demand_allocation import allocate_demand_by_pipe_length
def test_allocate_demand_by_incident_pipe_length():
nodes = {
"J1": {"type": "junction", "links": ["P1"]},
"J2": {"type": "junction", "links": ["P1", "P2"]},
"R1": {"type": "reservoir", "links": ["P2"]},
}
links = {"P1": {"length": 100.0}, "P2": {"length": 300.0}}
result = allocate_demand_by_pipe_length(80.0, nodes, links)
assert result == {"J1": pytest.approx(10.0), "J2": pytest.approx(40.0)}
def test_allocate_demand_returns_empty_for_zero_length_topology():
result = allocate_demand_by_pipe_length(
80.0,
{"J1": {"type": "junction", "links": ["P1"]}},
{"P1": {"length": 0.0}},
)
assert result == {}
+20
View File
@@ -0,0 +1,20 @@
import pytest
from app.algorithms.dma_leakage_estimation.genetic_optimizer import DmaLeakageOptimizer
@pytest.mark.parametrize(
("unit", "expected"),
[
("m3/s", 1.0),
("m³/s", 1.0),
("m3/h", 3600.0),
("m³/h", 3600.0),
],
)
def test_dma_leakage_optimizer_accepts_display_flow_units(unit, expected):
assert DmaLeakageOptimizer._flow_from_m3s(1.0, unit) == expected
def test_dma_leakage_optimizer_accepts_display_flow_units_for_input():
assert DmaLeakageOptimizer._flow_to_m3s(3600.0, "m³/h") == 1.0
@@ -0,0 +1,50 @@
import pytest
from app.algorithms.dma_leakage_estimation.topology_partitioning import (
build_dma_partitions,
)
def test_partition_assigns_nodes_to_nearest_connected_sensor():
coordinates = {
"A": {"x": 0.0, "y": 0.0},
"B": {"x": 1.0, "y": 0.0},
"C": {"x": 2.0, "y": 0.0},
"D": {"x": 3.0, "y": 0.0},
}
links = ["P1:pipe:A:B", "P2:pipe:B:C", "P3:pipe:C:D"]
area_map, areas = build_dma_partitions(
["A", "D"], coordinates, links, dma_count=2
)
assert area_map == {"A": "1", "B": "1", "C": "2", "D": "2"}
assert [area["node_count"] for area in areas] == [2, 2]
def test_partition_rejects_more_areas_than_sensors():
with pytest.raises(ValueError, match="传感器数量"):
build_dma_partitions(
["A"],
{"A": {"x": 0.0, "y": 0.0}, "B": {"x": 1.0, "y": 0.0}},
["P1:pipe:A:B"],
dma_count=2,
)
def test_partition_preserves_requested_area_count_for_coincident_sensors():
coordinates = {
"A": {"x": 0.0, "y": 0.0},
"B": {"x": 0.0, "y": 0.0},
"C": {"x": 0.0, "y": 0.0},
}
area_map, areas = build_dma_partitions(
["A", "B", "C"],
coordinates,
["P1:pipe:A:B", "P2:pipe:B:C"],
dma_count=2,
)
assert set(area_map.values()) == {"1", "2"}
assert [area["area_id"] for area in areas] == ["1", "2"]
@@ -5,11 +5,9 @@ from pathlib import Path
import pytest
def _load_time_api_module():
module_path = (
Path(__file__).resolve().parents[2] / "app" / "services" / "time_api.py"
)
spec = importlib.util.spec_from_file_location("tests_time_api_under_test", module_path)
def _load_time_module():
module_path = Path(__file__).resolve().parents[2] / "app" / "domain" / "time.py"
spec = importlib.util.spec_from_file_location("tests_domain_time", module_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
@@ -17,28 +15,28 @@ def _load_time_api_module():
def test_parse_utc_time_rejects_naive_datetimes():
module = _load_time_api_module()
module = _load_time_module()
with pytest.raises(ValueError, match="timezone information"):
module.parse_utc_time("2025-01-01T08:00:00")
def test_parse_utc_time_normalizes_offset_datetime_to_utc():
module = _load_time_api_module()
module = _load_time_module()
result = module.parse_utc_time("2025-01-01T08:00:00+08:00")
assert result == datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
def test_extract_date_keeps_original_offset_calendar_day():
module = _load_time_api_module()
module = _load_time_module()
result = module.extract_date("2025-01-01T00:30:00+08:00")
assert result == date(2025, 1, 1)
def test_utc_now_returns_timezone_aware_utc_datetime():
module = _load_time_api_module()
module = _load_time_module()
result = module.utc_now()
assert result.tzinfo == timezone.utc
@@ -58,14 +56,14 @@ def test_utc_now_returns_timezone_aware_utc_datetime():
def test_parse_clock_duration_seconds_accepts_epanet_clock_formats(
clock, expected_seconds
):
module = _load_time_api_module()
module = _load_time_module()
assert module.parse_clock_duration_seconds(clock) == expected_seconds
@pytest.mark.parametrize("clock", ["bad", "1:60", "1:00:60", "-1:00"])
def test_parse_clock_duration_seconds_rejects_invalid_clock_formats(clock):
module = _load_time_api_module()
module = _load_time_module()
with pytest.raises(ValueError):
module.parse_clock_duration_seconds(clock)
-20
View File
@@ -1,20 +0,0 @@
import pytest
from app.algorithms.leakage.identifier import LeakageIdentifier
@pytest.mark.parametrize(
("unit", "expected"),
[
("m3/s", 1.0),
("m³/s", 1.0),
("m3/h", 3600.0),
("m³/h", 3600.0),
],
)
def test_leakage_identifier_accepts_display_flow_units(unit, expected):
assert LeakageIdentifier._flow_from_m3s(1.0, unit) == expected
def test_leakage_identifier_accepts_display_flow_units_for_input():
assert LeakageIdentifier._flow_to_m3s(3600.0, "m³/h") == 1.0
@@ -1,13 +1,13 @@
"""
tests.unit.test_pipeline_health_analyzer Docstring
"""
"""Pipe health survival predictor tests."""
def test_pipeline_health_analyzer():
from app.algorithms.health.analyzer import PipelineHealthAnalyzer
def test_pipe_health_survival_predictor():
from app.algorithms.pipe_health_prediction.survival_predictor import (
PipeHealthSurvivalPredictor,
)
# 初始化分析器,假设模型文件路径为'models/rsf_model.joblib'
analyzer = PipelineHealthAnalyzer()
analyzer = PipeHealthSurvivalPredictor()
# 创建示例输入数据(9个样本)
import pandas as pd
import time
+1 -1
View File
@@ -4,7 +4,7 @@ import numpy as np
import pandas as pd
import pytest
from app.algorithms.cleaning import pressure as pressure_cleaning
from app.algorithms.scada_cleaning import pressure_series as pressure_cleaning
DATA_DIR = Path(__file__).resolve().parents[3] / "data"
+4 -4
View File
@@ -4,7 +4,7 @@ from unittest.mock import AsyncMock
from uuid import uuid4
from app.api.v1.endpoints import project_data
from app.infra.db.timescaledb import composite_queries
from app.services import timeseries_analysis as composite_queries
PROJECT_SCADA = {
@@ -48,7 +48,7 @@ def test_realtime_scada_simulation_uses_current_project_metadata(monkeypatch):
)
result = asyncio.run(
composite_queries.CompositeQueries.get_scada_associated_realtime_simulation_data(
composite_queries.TimeseriesAnalysisService.get_scada_associated_realtime_simulation_data(
object(),
object(),
[PROJECT_SCADA["device_id"]],
@@ -82,7 +82,7 @@ def test_analysis_scada_simulation_uses_current_project_metadata(monkeypatch):
)
result = asyncio.run(
composite_queries.CompositeQueries.get_scada_associated_analysis_simulation_data(
composite_queries.TimeseriesAnalysisService.get_scada_associated_analysis_simulation_data(
object(),
object(),
[PROJECT_SCADA["device_id"]],
@@ -114,7 +114,7 @@ def test_element_scada_query_uses_current_project_metadata(monkeypatch):
)
result = asyncio.run(
composite_queries.CompositeQueries.get_element_associated_scada_data(
composite_queries.TimeseriesAnalysisService.get_element_associated_scada_data(
object(),
object(),
"J1",
+6 -6
View File
@@ -8,7 +8,7 @@ import pytest
from fastapi import HTTPException
from app.api.v1.endpoints.timeseries import composite as composite_endpoint
from app.infra.db.timescaledb import composite_queries
from app.services import timeseries_analysis as composite_queries
class _FakeTimescaleConnection:
@@ -57,7 +57,7 @@ def test_clean_scada_uses_current_project_metadata(monkeypatch):
)
result = asyncio.run(
composite_queries.CompositeQueries.clean_scada_data(
composite_queries.TimeseriesAnalysisService.clean_scada_data(
_FakeTimescaleConnection(),
object(),
["fengyang-pressure-1"],
@@ -85,7 +85,7 @@ def test_clean_scada_rejects_devices_missing_from_project_metadata(monkeypatch):
with pytest.raises(ValueError, match="缺少元数据"):
asyncio.run(
composite_queries.CompositeQueries.clean_scada_data(
composite_queries.TimeseriesAnalysisService.clean_scada_data(
_FakeTimescaleConnection(),
_FakeTimescaleConnection(),
["fengyang-pressure-1"],
@@ -132,7 +132,7 @@ def test_clean_scada_rejects_zero_database_updates(monkeypatch):
with pytest.raises(ValueError, match="未产生任何数据库更新"):
asyncio.run(
composite_queries.CompositeQueries.clean_scada_data(
composite_queries.TimeseriesAnalysisService.clean_scada_data(
_FakeTimescaleConnection(),
object(),
["fengyang-pressure-1"],
@@ -176,7 +176,7 @@ def test_clean_scada_propagates_write_failures(monkeypatch):
with pytest.raises(RuntimeError, match="database write failed"):
asyncio.run(
composite_queries.CompositeQueries.clean_scada_data(
composite_queries.TimeseriesAnalysisService.clean_scada_data(
_FakeTimescaleConnection(),
object(),
["fengyang-pressure-1"],
@@ -188,7 +188,7 @@ def test_clean_scada_propagates_write_failures(monkeypatch):
def test_clean_scada_endpoint_returns_http_400_for_validation_error(monkeypatch):
monkeypatch.setattr(
composite_endpoint.CompositeQueries,
composite_endpoint.TimeseriesAnalysisService,
"clean_scada_data",
AsyncMock(side_effect=ValueError("当前项目没有可清洗的 SCADA 设备")),
)
+95
View File
@@ -0,0 +1,95 @@
from types import SimpleNamespace
import pytest
from app.algorithms.pressure_sensor_placement.kmeans_placement import (
optimize_sensor_placement,
)
class FakeNetwork:
junction_name_list = ["J1", "J2", "J3"]
pipe_name_list = ["P1", "P2"]
def __init__(self):
self._nodes = {
"J1": SimpleNamespace(coordinates=(0.0, 0.0)),
"J2": SimpleNamespace(coordinates=(1.0, 0.0)),
"J3": SimpleNamespace(coordinates=(2.0, 0.0)),
}
self._links = {
"P1": SimpleNamespace(
diameter=0.4, start_node_name="J1", end_node_name="J2"
),
"P2": SimpleNamespace(
diameter=0.1, start_node_name="J2", end_node_name="J3"
),
}
def get_node(self, node_id):
return self._nodes[node_id]
def get_link(self, link_id):
return self._links[link_id]
class DuplicateNearestNodeNetwork:
junction_name_list = ["J1", "J2", "J3", "J4"]
pipe_name_list = ["P1", "P2", "P3"]
def __init__(self):
self._nodes = {
"J1": SimpleNamespace(
coordinates=(-0.2967811321994103, 0.4867937179527957)
),
"J2": SimpleNamespace(
coordinates=(1.8589649622870763, -1.0070372175769569)
),
"J3": SimpleNamespace(
coordinates=(-1.4882734126175363, -1.6220731365802619)
),
"J4": SimpleNamespace(
coordinates=(1.203441836600899, 2.2320704059758474)
),
}
self._links = {
"P1": SimpleNamespace(
diameter=0.4, start_node_name="J1", end_node_name="J2"
),
"P2": SimpleNamespace(
diameter=0.4, start_node_name="J2", end_node_name="J3"
),
"P3": SimpleNamespace(
diameter=0.4, start_node_name="J3", end_node_name="J4"
),
}
def get_node(self, node_id):
return self._nodes[node_id]
def get_link(self, link_id):
return self._links[link_id]
def test_kmeans_filters_candidates_by_minimum_pipe_diameter():
selected = optimize_sensor_placement(
FakeNetwork(), sensor_count=2, min_diameter_mm=300
)
assert set(selected) == {"J1", "J2"}
def test_kmeans_rejects_sensor_count_above_eligible_candidates():
with pytest.raises(ValueError, match="候选节点数量"):
optimize_sensor_placement(
FakeNetwork(), sensor_count=3, min_diameter_mm=300
)
def test_kmeans_returns_unique_monitoring_nodes():
selected = optimize_sensor_placement(
DuplicateNearestNodeNetwork(), sensor_count=2, min_diameter_mm=300
)
assert len(selected) == 2
assert len(set(selected)) == 2
+1 -1
View File
@@ -6,7 +6,7 @@ import wntr
from scipy.sparse import csr_matrix, isspmatrix_csr
from scipy.sparse.csgraph import dijkstra
from app.algorithms.sensor import sensitivity
from app.algorithms.pressure_sensor_placement import sensitivity_placement as sensitivity
def _build_test_network() -> wntr.network.WaterNetworkModel:
+14 -44
View File
@@ -1,26 +1,10 @@
from collections import defaultdict
from app.algorithms.isolation import valve
from app.algorithms.valve_isolation import topology_search as valve
def test_non_isolatable_omits_affected_node_ids_but_keeps_count(monkeypatch):
pipe_adj = defaultdict(
set,
{
"A": {"B"},
"B": {"A", "C"},
"C": {"B"},
},
)
topology = (
pipe_adj,
{"V-optional": ("A", "C")},
{"P-1": ("A", "B", "pipe")},
{"A", "B", "C"},
)
monkeypatch.setattr(valve, "_get_network_topology", lambda _network: topology)
def test_non_isolatable_omits_affected_node_ids_but_keeps_count():
links = ["P-1:pipe:A:B", "P-2:pipe:B:C", "V-optional:valve:A:C"]
result = valve.valve_isolation_analysis("demo", "P-1")
result = valve.valve_isolation_analysis(links, "P-1")
assert result["isolatable"] is False
assert result["affected_node_count"] == 3
@@ -28,17 +12,10 @@ def test_non_isolatable_omits_affected_node_ids_but_keeps_count(monkeypatch):
assert result["optional_valves"] == ["V-optional"]
def test_isolatable_keeps_affected_node_ids_and_count(monkeypatch):
pipe_adj = defaultdict(set, {"A": {"B"}, "B": {"A"}})
topology = (
pipe_adj,
{"V-close": ("B", "C")},
{"P-1": ("A", "B", "pipe")},
{"A", "B", "C"},
)
monkeypatch.setattr(valve, "_get_network_topology", lambda _network: topology)
def test_isolatable_keeps_affected_node_ids_and_count():
links = ["P-1:pipe:A:B", "V-close:valve:B:C"]
result = valve.valve_isolation_analysis("demo", "P-1")
result = valve.valve_isolation_analysis(links, "P-1")
assert result["isolatable"] is True
assert result["affected_node_count"] == 2
@@ -46,21 +23,14 @@ def test_isolatable_keeps_affected_node_ids_and_count(monkeypatch):
assert result["must_close_valves"] == ["V-close"]
def test_disabled_valve_expands_affected_area_before_counting(monkeypatch):
pipe_adj = defaultdict(set, {"A": {"B"}, "B": {"A"}})
topology = (
pipe_adj,
{
"V-disabled": ("B", "C"),
"V-close": ("C", "D"),
},
{"P-1": ("A", "B", "pipe")},
{"A", "B", "C", "D"},
)
monkeypatch.setattr(valve, "_get_network_topology", lambda _network: topology)
def test_disabled_valve_expands_affected_area_before_counting():
links = [
"P-1:pipe:A:B",
"V-disabled:valve:B:C",
"V-close:valve:C:D",
]
result = valve.valve_isolation_analysis(
"demo",
links,
"P-1",
disabled_valves=["V-disabled"],
)