Files
TJWaterServerBinary/tests/unit/test_sensor_sensitivity.py
jiang 5966d039de 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.
2026-09-04 17:30:55 +08:00

392 lines
12 KiB
Python

from pathlib import Path
import numpy as np
import pytest
import wntr
from scipy.sparse import csr_matrix, isspmatrix_csr
from scipy.sparse.csgraph import dijkstra
from app.algorithms.pressure_sensor_placement import sensitivity_placement as sensitivity
def _build_test_network() -> wntr.network.WaterNetworkModel:
wn = wntr.network.WaterNetworkModel()
wn.options.time.duration = 0
wn.add_reservoir("R1", base_head=100.0, coordinates=(-1.0, 0.0))
wn.add_junction("J0", elevation=5.0, coordinates=(0.0, 0.0))
for index in range(1, 13):
wn.add_junction(
f"J{index}",
base_demand=0.001 + index * 0.00001,
elevation=5.0 + index * 0.05,
coordinates=(float(index % 4), float(index // 4)),
)
wn.add_pipe("P0", "R1", "J0", length=100.0, diameter=0.4, roughness=110)
wn.add_pipe("P1", "J0", "J1", length=100.0, diameter=0.4, roughness=110)
for index in range(1, 11):
wn.add_pipe(
f"P{index + 1}",
f"J{index}",
f"J{index + 1}",
length=80.0 + index,
diameter=0.3,
roughness=105,
)
# J12 is connected only through a small pipe, while J11 also touches P11.
wn.add_pipe("P12", "J11", "J12", length=90.0, diameter=0.1, roughness=105)
wn.add_pipe("PX1", "J2", "J6", length=120.0, diameter=0.3, roughness=105)
wn.add_pipe("PX2", "J5", "J9", length=120.0, diameter=0.3, roughness=105)
return wn
def _prepared_selection_network(
coordinates: np.ndarray,
edges: list[tuple[int, int, float]],
) -> sensitivity._PreparedNetwork:
node_count = len(coordinates)
rows: list[int] = []
columns: list[int] = []
weights: list[float] = []
for start, end, weight in edges:
rows.extend((start, end))
columns.extend((end, start))
weights.extend((weight, weight))
coverage_graph = csr_matrix(
(weights, (rows, columns)),
shape=(node_count, node_count),
)
return sensitivity._PreparedNetwork(
node_names=tuple(f"N{index:04d}" for index in range(node_count)),
full_node_indices=np.arange(node_count, dtype=np.int64),
candidate_indices=np.arange(node_count, dtype=np.int64),
coordinates=np.asarray(coordinates, dtype=np.float64),
incidence=csr_matrix((node_count, 1), dtype=np.float64),
conductance=np.ones(1, dtype=np.float64),
roughness_response=np.ones(1, dtype=np.float64),
distance_graph=csr_matrix((node_count, node_count), dtype=np.float64),
coverage_graph=coverage_graph,
)
def test_algorithm_is_deterministic_and_runs_epanet_once(monkeypatch, tmp_path):
wn = _build_test_network()
original_run_sim = wntr.sim.EpanetSimulator.run_sim
prefixes: list[str] = []
def counted_run_sim(simulator, *args, **kwargs):
prefixes.append(str(kwargs["file_prefix"]))
return original_run_sim(simulator, *args, **kwargs)
monkeypatch.setattr(wntr.sim.EpanetSimulator, "run_sim", counted_run_sim)
monkeypatch.chdir(tmp_path)
first = sensitivity.optimize_sensor_placement(wn, sensor_num=4, min_diameter=0)
second = sensitivity.optimize_sensor_placement(wn, sensor_num=4, min_diameter=0)
assert first == second
assert len(first) == len(set(first)) == 4
assert len(prefixes) == 2
assert all(not Path(prefix).parent.exists() for prefix in prefixes)
assert not list(tmp_path.glob("temp.*"))
def test_hydraulic_simulation_keeps_only_initial_state_and_restores_duration():
wn = _build_test_network()
wn.options.time.duration = 24 * 60 * 60
results = sensitivity._run_hydraulic_simulation(wn)
assert len(results.node["head"].index) == 1
assert wn.options.time.duration == 24 * 60 * 60
def test_preparation_keeps_network_matrices_sparse():
wn = _build_test_network()
results = sensitivity._run_hydraulic_simulation(wn)
prepared = sensitivity._prepare_network(wn, results, min_diameter=0)
assert isspmatrix_csr(prepared.incidence)
assert isspmatrix_csr(prepared.distance_graph)
assert isspmatrix_csr(prepared.coverage_graph)
assert prepared.incidence.nnz <= 2 * prepared.incidence.shape[1]
assert prepared.distance_graph.nnz <= wn.num_pipes
assert prepared.coverage_graph.nnz <= 2 * wn.num_links
assert (prepared.coverage_graph != prepared.coverage_graph.T).nnz == 0
dense_incidence_bytes = int(np.prod(prepared.incidence.shape)) * 8
sparse_payload_bytes = (
prepared.incidence.data.nbytes
+ prepared.incidence.indices.nbytes
+ prepared.incidence.indptr.nbytes
)
assert sparse_payload_bytes < dense_incidence_bytes
def test_minimum_diameter_filters_installation_candidates_in_millimetres():
wn = _build_test_network()
results = sensitivity._run_hydraulic_simulation(wn)
prepared = sensitivity._prepare_network(wn, results, min_diameter=300)
candidate_names = {
prepared.node_names[index] for index in prepared.candidate_indices
}
assert "J12" not in candidate_names
assert "J11" in candidate_names
selected = sensitivity.optimize_sensor_placement(
wn,
sensor_num=4,
min_diameter=300,
)
assert set(selected) <= candidate_names
with pytest.raises(ValueError, match="候选节点少于"):
sensitivity.optimize_sensor_placement(
wn,
sensor_num=len(candidate_names) + 1,
min_diameter=300,
)
def test_sparse_estimate_preserves_dense_reference_placement_quality():
wn = _build_test_network()
results = sensitivity._run_hydraulic_simulation(wn)
prepared = sensitivity._prepare_network(wn, results, min_diameter=0)
approximate_log_sensitivity = sensitivity._estimate_log_pressure_sensitivity(
prepared
)
approximate_distance = sensitivity._estimate_hydraulic_distance_sums(prepared)
approximate_selected = sensitivity._select_sensor_nodes(
prepared,
approximate_log_sensitivity,
approximate_distance,
sensor_num=4,
)
incidence = prepared.incidence.toarray()
laplacian = (
prepared.incidence.multiply(prepared.conductance)
@ prepared.incidence.T
).toarray()
diagonal_scale = float(np.max(np.abs(np.diag(laplacian))))
laplacian += np.eye(laplacian.shape[0]) * (
diagonal_scale * np.sqrt(np.finfo(np.float64).eps)
)
response = np.linalg.solve(
laplacian,
incidence * prepared.roughness_response,
)
exact_sensitivity = np.abs(response).sum(axis=1)
exact_distances = dijkstra(
prepared.distance_graph.transpose().tocsr(),
directed=True,
indices=prepared.full_node_indices,
return_predecessors=False,
)[:, prepared.full_node_indices]
exact_distances[~np.isfinite(exact_distances)] = 0.0
exact_distance = exact_distances.sum(axis=0)
exact_selected = sensitivity._select_sensor_nodes(
prepared,
np.log(np.maximum(exact_sensitivity, np.finfo(np.float64).tiny)),
exact_distance,
sensor_num=4,
)
exact_score = exact_sensitivity * exact_distance
node_index = {
node_name: index for index, node_name in enumerate(prepared.node_names)
}
approximate_objective = sum(
exact_score[node_index[node_name]] for node_name in approximate_selected
)
exact_objective = sum(
exact_score[node_index[node_name]] for node_name in exact_selected
)
assert approximate_objective / exact_objective >= 0.95
def test_mixed_coverage_avoids_candidate_density_bias():
dense_west = np.linspace(0.0, 2.0, 200)
sparse_east = np.linspace(3.0, 10.0, 20)
x_coordinates = np.concatenate((dense_west, sparse_east))
coordinates = np.column_stack(
(x_coordinates, np.zeros(len(x_coordinates), dtype=np.float64))
)
ordered = np.argsort(x_coordinates)
edges = [
(
int(start),
int(end),
float(x_coordinates[end] - x_coordinates[start]),
)
for start, end in zip(ordered[:-1], ordered[1:])
]
prepared = _prepared_selection_network(coordinates, edges)
log_scores = np.linspace(4.0, 0.0, len(coordinates))
distance_sums = np.ones(len(coordinates), dtype=np.float64)
selected = sensitivity._select_sensor_nodes(
prepared,
log_scores,
distance_sums,
sensor_num=6,
)
name_to_position = {
name: position for position, name in enumerate(prepared.node_names)
}
selected_positions = [name_to_position[name] for name in selected]
new_metrics = sensitivity._geographic_coverage_metrics(
coordinates,
selected_positions,
)
legacy_labels, _centers = sensitivity._cluster_labels(
coordinates,
6,
random_seed=sensitivity._RANDOM_SEED + 2,
)
legacy_positions: list[int] = []
represented: set[int] = set()
for position in np.argsort(-log_scores):
label = int(legacy_labels[position])
if label in represented:
continue
represented.add(label)
legacy_positions.append(int(position))
legacy_metrics = sensitivity._geographic_coverage_metrics(
coordinates,
legacy_positions,
)
assert new_metrics[0] <= legacy_metrics[0] * 0.6
assert new_metrics[2] >= legacy_metrics[2] * 1.5
assert max(x_coordinates[selected_positions]) >= 9.0
def test_disconnected_components_each_receive_a_sensor_when_slots_allow():
coordinates = np.asarray(
[
(0.0, 0.0),
(1.0, 0.0),
(0.0, 0.01),
(1.0, 0.01),
]
)
prepared = _prepared_selection_network(
coordinates,
[(0, 1, 1.0), (2, 3, 1.0)],
)
selected = sensitivity._select_sensor_nodes(
prepared,
np.asarray([10.0, 9.0, 8.0, 7.0]),
np.ones(4),
sensor_num=2,
)
assert len(set(selected) & {"N0000", "N0001"}) == 1
assert len(set(selected) & {"N0002", "N0003"}) == 1
def test_overlapping_components_respect_global_geographic_spacing():
coordinates = np.asarray(
[
(0.0, 0.0),
(10.0, 0.0),
(0.1, 0.0),
(10.1, 0.0),
]
)
prepared = _prepared_selection_network(
coordinates,
[(0, 1, 10.0), (2, 3, 10.0)],
)
selected = sensitivity._select_sensor_nodes(
prepared,
np.asarray([10.0, 1.0, 9.0, 0.0]),
np.ones(4),
sensor_num=2,
)
selected_positions = [prepared.node_names.index(name) for name in selected]
minimum_gap = sensitivity._geographic_coverage_metrics(
coordinates,
selected_positions,
)[2]
assert minimum_gap >= 0.9
def test_component_quota_prefers_longer_networks_when_slots_are_limited():
coordinates = np.asarray(
[
(0.0, 0.0),
(10.0, 0.0),
(20.0, 0.0),
(25.0, 0.0),
(30.0, 0.0),
(31.0, 0.0),
]
)
prepared = _prepared_selection_network(
coordinates,
[(0, 1, 10.0), (2, 3, 5.0), (4, 5, 1.0)],
)
selected = sensitivity._select_sensor_nodes(
prepared,
np.asarray([1.0, 1.0, 2.0, 2.0, 100.0, 100.0]),
np.ones(6),
sensor_num=2,
)
assert set(selected) <= {"N0000", "N0001", "N0002", "N0003"}
assert len(set(selected) & {"N0000", "N0001"}) == 1
assert len(set(selected) & {"N0002", "N0003"}) == 1
def test_duplicate_coordinates_use_topology_and_return_exact_count():
coordinates = np.zeros((6, 2), dtype=np.float64)
prepared = _prepared_selection_network(
coordinates,
[(index, index + 1, 1.0) for index in range(5)],
)
log_scores = np.linspace(6.0, 1.0, 6)
first = sensitivity._select_sensor_nodes(
prepared,
log_scores,
np.ones(6),
sensor_num=4,
)
second = sensitivity._select_sensor_nodes(
prepared,
log_scores,
np.ones(6),
sensor_num=4,
)
assert first == second
assert len(first) == len(set(first)) == 4
@pytest.mark.parametrize(
("sensor_num", "min_diameter", "message"),
[
(0, 0, "监测点数量必须大于 0"),
(1, -1, "最小管径不能小于 0"),
],
)
def test_algorithm_rejects_invalid_parameters(sensor_num, min_diameter, message):
with pytest.raises(ValueError, match=message):
sensitivity.optimize_sensor_placement(
_build_test_network(),
sensor_num=sensor_num,
min_diameter=min_diameter,
)