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:
@@ -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")]
|
||||
@@ -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):
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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 == {}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
+6
-6
@@ -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
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 设备")),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user