"""Pressure sensor placement based on scalable sensitivity analysis. The original implementation expanded a sparse water network into several dense ``node x node``, ``node x pipe``, and ``pipe x pipe`` matrices. That made the memory requirement quadratic and the explicit matrix inverse cubic in time. This module keeps one algorithm for every network size: * run EPANET once and reuse the first hydraulic state; * keep incidence and hydraulic graphs sparse; * estimate the row-wise L1 pressure sensitivity with deterministic Cauchy projections and one sparse factorization; * estimate total directed hydraulic distance from a deterministic spatial coreset without materialising an all-pairs distance matrix; * balance sensitivity score with geographic and pipe-network coverage without materialising candidate-to-candidate distances. The random seed and sample counts are fixed, so the same model and request produce the same placement on every run. """ from __future__ import annotations import logging from dataclasses import dataclass from pathlib import Path from tempfile import TemporaryDirectory from time import perf_counter import numpy as np import wntr from scipy.sparse import csr_matrix, eye from scipy.sparse.csgraph import connected_components, dijkstra from scipy.sparse.linalg import splu from sklearn.cluster import MiniBatchKMeans logger = logging.getLogger(__name__) _RANDOM_SEED = 42 _SENSITIVITY_PROJECTIONS = 256 _HYDRAULIC_LANDMARKS = 256 _PROJECTION_BLOCK_SIZE = 16 _DIJKSTRA_BLOCK_SIZE = 16 _HEADLOSS_EPSILON = 1e-10 _DIAMETER_TOLERANCE_MM = 1e-9 _COVERAGE_ELIGIBILITY_RATIO = 0.70 _COVERAGE_EDGE_EPSILON = 1e-9 @dataclass(frozen=True) class _PreparedNetwork: """Sparse data required by the placement pipeline.""" node_names: tuple[str, ...] full_node_indices: np.ndarray candidate_indices: np.ndarray coordinates: np.ndarray incidence: csr_matrix conductance: np.ndarray roughness_response: np.ndarray distance_graph: csr_matrix coverage_graph: csr_matrix @dataclass(frozen=True) class _CandidatePool: """Aligned candidate arrays consumed by the placement stage.""" full_indices: np.ndarray coordinates: np.ndarray names: np.ndarray scores: np.ndarray def _run_hydraulic_simulation( wn: wntr.network.WaterNetworkModel, ): """Run only the initial EPANET state without shared ``temp.*`` files.""" original_duration = wn.options.time.duration try: # Every downstream calculation reads ``iloc[0]``. Running an extended # simulation only allocates unused time-series results, which is # especially expensive for daily models with tens of thousands of # nodes. Restore the caller's model even when EPANET fails. wn.options.time.duration = 0 with TemporaryDirectory(prefix="tjwater-sensitivity-") as temp_dir: file_prefix = str(Path(temp_dir) / "simulation") return wntr.sim.EpanetSimulator(wn).run_sim(file_prefix=file_prefix) finally: wn.options.time.duration = original_duration def _excluded_elements( wn: wntr.network.WaterNetworkModel, ) -> tuple[set[str], set[str]]: """Return nodes that cannot host sensors and source-connected pipes. Reservoirs, tanks, pump/valve endpoints, and the junction immediately next to a reservoir or tank are treated as hydraulic boundary nodes. Pipes connected directly to a source are removed from the perturbation set, as in the legacy algorithm. """ source_nodes = set(wn.reservoir_name_list) | set(wn.tank_name_list) excluded_nodes = set(source_nodes) source_pipes: set[str] = set() for pipe_name, pipe in wn.pipes(): endpoints = {pipe.start_node_name, pipe.end_node_name} if endpoints & source_nodes: source_pipes.add(pipe_name) excluded_nodes.update(endpoints) for _link_name, link in list(wn.pumps()) + list(wn.valves()): excluded_nodes.add(link.start_node_name) excluded_nodes.add(link.end_node_name) return excluded_nodes, source_pipes def _minimum_weight_csr( rows: list[int], columns: list[int], weights: list[float], *, shape: tuple[int, int], ) -> csr_matrix: """Build a CSR graph while retaining the lightest parallel edge.""" if not rows: return csr_matrix(shape, dtype=np.float64) row_array = np.asarray(rows, dtype=np.int64) column_array = np.asarray(columns, dtype=np.int64) weight_array = np.asarray(weights, dtype=np.float64) order = np.lexsort((column_array, row_array)) row_array = row_array[order] column_array = column_array[order] weight_array = weight_array[order] group_start = np.empty(len(row_array), dtype=bool) group_start[0] = True group_start[1:] = (row_array[1:] != row_array[:-1]) | ( column_array[1:] != column_array[:-1] ) starts = np.flatnonzero(group_start) minimum_weights = np.minimum.reduceat(weight_array, starts) return csr_matrix( (minimum_weights, (row_array[starts], column_array[starts])), shape=shape, ) def _node_coordinates( wn: wntr.network.WaterNetworkModel, node_names: tuple[str, ...], ) -> np.ndarray: coordinate_series = wn.query_node_attribute("coordinates") coordinates = np.asarray( [coordinate_series.loc[node_name] for node_name in node_names], dtype=np.float64, ) if coordinates.ndim != 2 or coordinates.shape[1] < 2: raise ValueError("管网节点缺少二维坐标,无法进行监测点空间布置") coordinates = coordinates[:, :2] if not np.isfinite(coordinates).all(): raise ValueError("管网节点坐标包含非有限值,无法进行监测点空间布置") return coordinates def _build_coverage_graph( wn: wntr.network.WaterNetworkModel, results, full_node_index: dict[str, int], ) -> csr_matrix: """Build the active undirected physical graph used to spread sensors.""" status_series = results.link["status"].iloc[0] rows: list[int] = [] columns: list[int] = [] weights: list[float] = [] for link_name, link in wn.links(): if float(status_series.loc[link_name]) <= 0: continue start = full_node_index[link.start_node_name] end = full_node_index[link.end_node_name] # Pipes carry their physical length. Pumps and valves are point # devices, so a tiny positive length preserves connectivity without # dominating shortest-path distance. weight = max( float(getattr(link, "length", 0.0)), _COVERAGE_EDGE_EPSILON, ) rows.extend((start, end)) columns.extend((end, start)) weights.extend((weight, weight)) return _minimum_weight_csr( rows, columns, weights, shape=(len(full_node_index), len(full_node_index)), ) def _prepare_network( wn: wntr.network.WaterNetworkModel, results, *, min_diameter: int, ) -> _PreparedNetwork: excluded_nodes, source_pipes = _excluded_elements(wn) full_node_names = tuple(wn.node_name_list) full_node_index = { node_name: index for index, node_name in enumerate(full_node_names) } node_names = tuple( node_name for node_name in full_node_names if node_name not in excluded_nodes ) if not node_names: raise ValueError("管网中没有可参与灵敏度分析的节点") node_index = {node_name: index for index, node_name in enumerate(node_names)} full_node_indices = np.asarray( [full_node_index[node_name] for node_name in node_names], dtype=np.int64, ) coordinates = _node_coordinates(wn, node_names) flow_series = results.link["flowrate"].iloc[0] headloss_series = results.link["headloss"].iloc[0] head_series = results.node["head"].iloc[0] candidate_nodes: set[str] = set() for _pipe_name, pipe in wn.pipes(): diameter_mm = float(pipe.diameter) * 1000.0 if diameter_mm + _DIAMETER_TOLERANCE_MM < min_diameter: continue if pipe.start_node_name in node_index: candidate_nodes.add(pipe.start_node_name) if pipe.end_node_name in node_index: candidate_nodes.add(pipe.end_node_name) incidence_rows: list[int] = [] incidence_columns: list[int] = [] incidence_values: list[float] = [] conductance: list[float] = [] roughness_response: list[float] = [] distance_rows: list[int] = [] distance_columns: list[int] = [] distance_weights: list[float] = [] kept_pipe_count = 0 for pipe_name, pipe in wn.pipes(): if pipe_name in source_pipes: continue start_name = pipe.start_node_name end_name = pipe.end_node_name if start_name not in node_index and end_name not in node_index: continue flow = float(flow_series.loc[pipe_name]) absolute_flow = abs(flow) headloss = abs(float(headloss_series.loc[pipe_name])) roughness = float(pipe.roughness) if roughness <= 0: raise ValueError(f"管道 {pipe_name} 的粗糙度必须大于 0") orientation = -1.0 if flow < 0 else 1.0 if start_name in node_index: incidence_rows.append(node_index[start_name]) incidence_columns.append(kept_pipe_count) incidence_values.append(-orientation) if end_name in node_index: incidence_rows.append(node_index[end_name]) incidence_columns.append(kept_pipe_count) incidence_values.append(orientation) conductance.append( absolute_flow / (1.852 * headloss + _HEADLOSS_EPSILON) ) roughness_response.append(absolute_flow / roughness) if flow > 0: upstream_name, downstream_name = start_name, end_name else: upstream_name, downstream_name = end_name, start_name hydraulic_weight = ( abs(float(head_series.loc[start_name]) - float(head_series.loc[end_name])) * float(pipe.length) ) distance_rows.append(full_node_index[upstream_name]) distance_columns.append(full_node_index[downstream_name]) distance_weights.append(hydraulic_weight) kept_pipe_count += 1 if kept_pipe_count == 0: raise ValueError("管网中没有可用于灵敏度分析的管道") incidence = csr_matrix( ( np.asarray(incidence_values, dtype=np.float64), ( np.asarray(incidence_rows, dtype=np.int64), np.asarray(incidence_columns, dtype=np.int64), ), ), shape=(len(node_names), kept_pipe_count), ) conductance_array = np.asarray(conductance, dtype=np.float64) response_array = np.asarray(roughness_response, dtype=np.float64) if not np.isfinite(conductance_array).all() or not np.isfinite( response_array ).all(): raise ValueError("水力结果产生了非有限灵敏度系数") distance_graph = _minimum_weight_csr( distance_rows, distance_columns, distance_weights, shape=(len(full_node_names), len(full_node_names)), ) coverage_graph = _build_coverage_graph(wn, results, full_node_index) candidate_indices = np.asarray( [ index for index, node_name in enumerate(node_names) if node_name in candidate_nodes ], dtype=np.int64, ) return _PreparedNetwork( node_names=node_names, full_node_indices=full_node_indices, candidate_indices=candidate_indices, coordinates=coordinates, incidence=incidence, conductance=conductance_array, roughness_response=response_array, distance_graph=distance_graph, coverage_graph=coverage_graph, ) def _axis_normalized_coordinates(coordinates: np.ndarray) -> np.ndarray: """Scale each axis independently for MiniBatchKMeans.""" minimum = coordinates.min(axis=0) span = np.ptp(coordinates, axis=0) span[span == 0] = 1.0 return (coordinates - minimum) / span def _isotropic_coordinates(coordinates: np.ndarray) -> np.ndarray: """Normalize coordinates without distorting the network aspect ratio.""" minimum = coordinates.min(axis=0) scale = float(np.max(np.ptp(coordinates, axis=0), initial=0.0)) if scale == 0: scale = 1.0 return (coordinates - minimum) / scale def _cluster_labels( coordinates: np.ndarray, cluster_count: int, *, random_seed: int, ) -> tuple[np.ndarray, np.ndarray]: """Cluster coordinates deterministically with one implementation at all sizes.""" normalized = _axis_normalized_coordinates(coordinates) if cluster_count == 1: return np.zeros(len(coordinates), dtype=np.int64), normalized[[0]] if cluster_count >= len(coordinates): return np.arange(len(coordinates), dtype=np.int64), normalized.copy() model = MiniBatchKMeans( n_clusters=cluster_count, random_state=random_seed, n_init=3, batch_size=min(len(coordinates), max(1024, cluster_count * 3)), max_iter=100, max_no_improvement=20, reassignment_ratio=0.0, ) labels = model.fit_predict(normalized).astype(np.int64, copy=False) return labels, np.asarray(model.cluster_centers_, dtype=np.float64) def _estimate_log_pressure_sensitivity(prepared: _PreparedNetwork) -> np.ndarray: """Estimate each row's L1 sensitivity using streaming Cauchy projections.""" weighted_incidence = prepared.incidence.multiply(prepared.conductance) laplacian = (weighted_incidence @ prepared.incidence.T).tocsc() diagonal = np.asarray(laplacian.diagonal(), dtype=np.float64) diagonal_scale = float(np.max(np.abs(diagonal), initial=0.0)) if diagonal_scale == 0: raise ValueError("水力雅可比矩阵为空,无法计算压力灵敏度") regularization = diagonal_scale * np.sqrt(np.finfo(np.float64).eps) laplacian = laplacian + eye( laplacian.shape[0], format="csc", dtype=np.float64 ) * regularization factor = splu( laplacian, permc_spec="MMD_AT_PLUS_A", diag_pivot_thresh=0.0, options={"SymmetricMode": True}, ) random = np.random.default_rng(_RANDOM_SEED) log_absolute_sum = np.zeros(len(prepared.node_names), dtype=np.float64) projection_count = 0 float_epsilon = np.finfo(np.float64).eps float_tiny = np.finfo(np.float64).tiny while projection_count < _SENSITIVITY_PROJECTIONS: block_size = min( _PROJECTION_BLOCK_SIZE, _SENSITIVITY_PROJECTIONS - projection_count, ) uniform = random.random((prepared.incidence.shape[1], block_size)) np.clip(uniform, float_epsilon, 1.0 - float_epsilon, out=uniform) cauchy_projection = np.tan(np.pi * (uniform - 0.5)) projected_response = prepared.incidence @ ( prepared.roughness_response[:, None] * cauchy_projection ) solution = factor.solve(np.asarray(projected_response, dtype=np.float64)) log_absolute_sum += np.log( np.maximum(np.abs(solution), float_tiny) ).sum(axis=1) projection_count += block_size # For a standard Cauchy variable E[log(abs(X))] is zero. Therefore this # streaming geometric mean estimates log(||row||_1) without retaining the # node-by-projection matrix. A finite-sample bias is common to all rows and # does not affect ranking. return log_absolute_sum / _SENSITIVITY_PROJECTIONS def _landmark_coreset(prepared: _PreparedNetwork) -> tuple[np.ndarray, np.ndarray]: landmark_count = min(_HYDRAULIC_LANDMARKS, len(prepared.node_names)) labels, centers = _cluster_labels( prepared.coordinates, landmark_count, random_seed=_RANDOM_SEED + 1, ) normalized = _axis_normalized_coordinates(prepared.coordinates) landmarks: list[int] = [] weights: list[float] = [] for label in np.unique(labels): members = np.flatnonzero(labels == label) center = centers[int(label)] squared_distance = np.square(normalized[members] - center).sum(axis=1) landmarks.append(int(members[int(np.argmin(squared_distance))])) weights.append(float(len(members))) return ( np.asarray(landmarks, dtype=np.int64), np.asarray(weights, dtype=np.float64), ) def _estimate_hydraulic_distance_sums(prepared: _PreparedNetwork) -> np.ndarray: """Estimate outbound distance sums without an all-pairs distance matrix.""" landmark_indices, landmark_weights = _landmark_coreset(prepared) full_landmark_indices = prepared.full_node_indices[landmark_indices] reversed_graph = prepared.distance_graph.transpose().tocsr() distance_sums = np.zeros(len(prepared.node_names), dtype=np.float64) for start in range(0, len(landmark_indices), _DIJKSTRA_BLOCK_SIZE): stop = min(start + _DIJKSTRA_BLOCK_SIZE, len(landmark_indices)) distances = dijkstra( reversed_graph, directed=True, indices=full_landmark_indices[start:stop], return_predecessors=False, ) distances = np.atleast_2d(distances)[:, prepared.full_node_indices] # The legacy matrix represented unreachable pairs as zero. Retaining # that convention prevents disconnected branches from receiving an # artificial infinite score. distances[~np.isfinite(distances)] = 0.0 distance_sums += landmark_weights[start:stop] @ distances return distance_sums def _build_candidate_pool( prepared: _PreparedNetwork, log_sensitivity: np.ndarray, hydraulic_distance_sums: np.ndarray, ) -> _CandidatePool: candidate_indices = prepared.candidate_indices candidate_distance = hydraulic_distance_sums[candidate_indices] with np.errstate(divide="ignore", invalid="ignore"): scores = log_sensitivity[candidate_indices] + np.log(candidate_distance) scores = np.nan_to_num( scores, nan=-np.inf, neginf=-np.inf, posinf=np.finfo(np.float64).max, ) return _CandidatePool( full_indices=prepared.full_node_indices[candidate_indices], coordinates=_isotropic_coordinates(prepared.coordinates[candidate_indices]), names=np.asarray( [prepared.node_names[index] for index in candidate_indices], dtype=str, ), scores=scores, ) def _highest_scoring_position( names: np.ndarray, scores: np.ndarray, positions: np.ndarray, ) -> int: """Return the best position, breaking score ties by node name.""" order = np.lexsort((names[positions], -scores[positions])) return int(positions[order[0]]) def _relative_gap( distances: np.ndarray, available: np.ndarray, ) -> np.ndarray: """Normalize available distances to their current finite maximum.""" maximum = float(np.max(distances[available], initial=0.0)) if not np.isfinite(maximum) or maximum <= np.finfo(np.float64).eps: return np.zeros(len(distances), dtype=np.float64) return distances / maximum def _eligible_gap_positions( relative_gap: np.ndarray, available: np.ndarray, ) -> np.ndarray: """Return positions within the configured fraction of the largest gap.""" maximum = float(np.max(relative_gap[available], initial=0.0)) if maximum <= np.finfo(np.float64).eps: return np.flatnonzero(available) threshold = _COVERAGE_ELIGIBILITY_RATIO * maximum return np.flatnonzero( available & (relative_gap >= threshold - np.finfo(np.float64).eps) ) def _allocate_component_quotas( coverage_graph: csr_matrix, candidate_full_indices: np.ndarray, candidate_scores: np.ndarray, *, sensor_num: int, ) -> tuple[np.ndarray, np.ndarray]: """Allocate sensor counts by active pipe length with candidate caps.""" component_count, node_components = connected_components( coverage_graph, directed=False, return_labels=True, ) candidate_components = node_components[candidate_full_indices] capacities = np.bincount( candidate_components, minlength=component_count, ).astype(np.int64, copy=False) # The graph is symmetric. Summed row weights count every physical edge # twice, hence the division by two after aggregation by component. node_lengths = np.asarray(coverage_graph.sum(axis=1)).ravel() component_lengths = np.bincount( node_components, weights=node_lengths, minlength=component_count, ) / 2.0 component_best_scores = np.full(component_count, -np.inf, dtype=np.float64) np.maximum.at( component_best_scores, candidate_components, candidate_scores, ) active_components = np.flatnonzero(capacities) quotas = np.zeros(component_count, dtype=np.int64) if len(active_components) > sensor_num: order = np.lexsort( ( active_components, -component_best_scores[active_components], -component_lengths[active_components], ) ) quotas[active_components[order[:sensor_num]]] = 1 return candidate_components, quotas quotas[active_components] = 1 remaining = sensor_num - len(active_components) while remaining > 0: available = active_components[ quotas[active_components] < capacities[active_components] ] if len(available) == 0: raise ValueError("连通区域中的候选节点不足,无法分配监测点名额") weights = component_lengths[available] if float(weights.sum()) <= 0: weights = (capacities[available] - quotas[available]).astype( np.float64, copy=False, ) ideal = remaining * weights / float(weights.sum()) whole = np.minimum( np.floor(ideal).astype(np.int64), capacities[available] - quotas[available], ) whole_count = int(whole.sum()) if whole_count: quotas[available] += whole remaining -= whole_count continue fractional = ideal - np.floor(ideal) order = np.lexsort( ( available, -component_best_scores[available], -weights, -fractional, ) ) for component in available[order]: quotas[component] += 1 remaining -= 1 if remaining == 0: break return candidate_components, quotas def _select_component_positions( coverage_graph: csr_matrix, candidates: _CandidatePool, component_positions: np.ndarray, existing_positions: list[int], *, quota: int, ) -> list[int]: """Select one component's sensors with score-aware farthest-first search.""" local_coordinates = candidates.coordinates[component_positions] local_names = candidates.names[component_positions] local_scores = candidates.scores[component_positions] nearest_geographic = np.full(len(component_positions), np.inf) nearest_topological = np.full(len(component_positions), np.inf) for position in existing_positions: nearest_geographic = np.minimum( nearest_geographic, np.linalg.norm( local_coordinates - candidates.coordinates[position], axis=1, ), ) if existing_positions: all_local = np.ones(len(component_positions), dtype=bool) seed_eligible = _eligible_gap_positions( _relative_gap(nearest_geographic, all_local), all_local, ) else: seed_eligible = np.arange(len(component_positions), dtype=np.int64) seed = _highest_scoring_position( local_names, local_scores, seed_eligible, ) selected_local = [seed] remaining = np.ones(len(component_positions), dtype=bool) remaining[seed] = False while len(selected_local) < quota: newest = selected_local[-1] geographic_distance = np.linalg.norm( local_coordinates - local_coordinates[newest], axis=1, ) nearest_geographic = np.minimum( nearest_geographic, geographic_distance, ) source = int(candidates.full_indices[component_positions[newest]]) topological_distance = dijkstra( coverage_graph, directed=False, indices=source, return_predecessors=False, )[candidates.full_indices[component_positions]] nearest_topological = np.minimum( nearest_topological, topological_distance, ) coverage_gap = np.maximum( _relative_gap(nearest_geographic, remaining), _relative_gap(nearest_topological, remaining), ) eligible_local = _eligible_gap_positions(coverage_gap, remaining) next_local = _highest_scoring_position( local_names, local_scores, eligible_local, ) selected_local.append(next_local) remaining[next_local] = False return [int(component_positions[position]) for position in selected_local] def _geographic_coverage_metrics( candidate_coordinates: np.ndarray, selected_positions: list[int], ) -> tuple[float, float, float]: normalized = _isotropic_coordinates(candidate_coordinates) nearest = np.full(len(normalized), np.inf) for position in selected_positions: nearest = np.minimum( nearest, np.linalg.norm(normalized - normalized[position], axis=1), ) selected_coordinates = normalized[selected_positions] if len(selected_positions) < 2: minimum_gap = 0.0 else: pairwise = np.linalg.norm( selected_coordinates[:, None, :] - selected_coordinates[None, :, :], axis=2, ) np.fill_diagonal(pairwise, np.inf) minimum_gap = float(pairwise.min()) return ( float(nearest.max()), float(np.quantile(nearest, 0.95)), minimum_gap, ) def _select_sensor_nodes( prepared: _PreparedNetwork, log_sensitivity: np.ndarray, hydraulic_distance_sums: np.ndarray, *, sensor_num: int, ) -> list[str]: candidate_indices = prepared.candidate_indices if len(candidate_indices) < sensor_num: raise ValueError( "满足最小管径要求的候选节点少于请求的监测点数量:" f"候选 {len(candidate_indices)} 个,请求 {sensor_num} 个" ) candidates = _build_candidate_pool( prepared, log_sensitivity, hydraulic_distance_sums, ) candidate_components, component_quotas = _allocate_component_quotas( prepared.coverage_graph, candidates.full_indices, candidates.scores, sensor_num=sensor_num, ) selected_positions: list[int] = [] quota_components = np.flatnonzero(component_quotas) component_order = np.lexsort( (quota_components, -component_quotas[quota_components]) ) for component in quota_components[component_order]: component_positions = np.flatnonzero(candidate_components == component) selected_positions.extend( _select_component_positions( prepared.coverage_graph, candidates, component_positions, selected_positions, quota=int(component_quotas[component]), ) ) selected_array = np.asarray(selected_positions, dtype=np.int64) selected_order = np.lexsort( ( candidates.names[selected_array], -candidates.scores[selected_array], ) ) selected_positions = selected_array[selected_order].tolist() maximum_radius, p95_radius, minimum_gap = _geographic_coverage_metrics( prepared.coordinates[candidate_indices], selected_positions, ) logger.info( "Sensitivity placement coverage: components=%d max_radius=%.6f " "p95_radius=%.6f min_sensor_gap=%.6f", int(np.count_nonzero(component_quotas)), maximum_radius, p95_radius, minimum_gap, ) return [str(candidates.names[position]) for position in selected_positions] def optimize_sensor_placement( wn: wntr.network.WaterNetworkModel, sensor_num: int, min_diameter: int, ) -> list[str]: """Return deterministic pressure monitoring nodes for a loaded network. ``min_diameter`` is expressed in millimetres, matching the HTTP contract. A node is a valid installation candidate when at least one incident pipe meets the threshold. All valid hydraulic nodes still participate in the sensitivity calculation so small pipes continue to influence the result. """ if sensor_num <= 0: raise ValueError("监测点数量必须大于 0") if min_diameter < 0: raise ValueError("最小管径不能小于 0") total_started = perf_counter() simulation_started = total_started results = _run_hydraulic_simulation(wn) simulation_seconds = perf_counter() - simulation_started preparation_started = perf_counter() prepared = _prepare_network(wn, results, min_diameter=min_diameter) preparation_seconds = perf_counter() - preparation_started sensitivity_started = perf_counter() log_sensitivity = _estimate_log_pressure_sensitivity(prepared) sensitivity_seconds = perf_counter() - sensitivity_started distance_started = perf_counter() hydraulic_distance_sums = _estimate_hydraulic_distance_sums(prepared) distance_seconds = perf_counter() - distance_started selection_started = perf_counter() selected = _select_sensor_nodes( prepared, log_sensitivity, hydraulic_distance_sums, sensor_num=sensor_num, ) selection_seconds = perf_counter() - selection_started logger.info( "Sensitivity placement completed: nodes=%d pipes=%d candidates=%d " "sensors=%d seconds=%.3f " "(simulation=%.3f preparation=%.3f sensitivity=%.3f " "distance=%.3f selection=%.3f)", len(prepared.node_names), prepared.incidence.shape[1], len(prepared.candidate_indices), len(selected), perf_counter() - total_started, simulation_seconds, preparation_seconds, sensitivity_seconds, distance_seconds, selection_seconds, ) return selected def optimize_sensor_placement_from_inp( inp_path: str | Path, sensor_num: int, min_diameter: int, ) -> list[str]: """Load an EPANET INP model and run the unified placement algorithm.""" wn = wntr.network.WaterNetworkModel(str(inp_path)) return optimize_sensor_placement( wn, sensor_num=sensor_num, min_diameter=min_diameter, )