优化漏损识别器,支持多进程评估

This commit is contained in:
2026-03-05 18:18:28 +08:00
parent b8aee14c00
commit 63d3458fb4
8 changed files with 425 additions and 182 deletions
+104 -102
View File
@@ -9,7 +9,7 @@ import pandas as pd
import wntr
from app.algorithms.leakage_identifier import LeakageIdentifier
from app.infra.db.influxdb import api as influxdb_api
from app.infra.db.timescaledb.internal_queries import InternalQueries
from app.services.scheme_management import (
query_leakage_identify_scheme_detail,
query_leakage_identify_schemes,
@@ -24,6 +24,8 @@ from app.services.tjnetwork import (
get_network_node_coords,
)
DEFAULT_N_WORKERS = max(1, min((os.cpu_count() or 1) - 1, 4))
def run_leakage_identification(
network: str,
@@ -38,6 +40,7 @@ def run_leakage_identification(
output_dir: str = "db_inp",
pop_size: int = 50,
max_gen: int = 100,
n_workers: int = DEFAULT_N_WORKERS,
output_flow_unit: str = "m3/s",
dma_count: int | None = None,
scada_start: datetime | str | None = None,
@@ -57,7 +60,7 @@ def run_leakage_identification(
if not selected_sensor_nodes:
raise ValueError("未提供有效传感器节点,且系统未识别到可用压力传感器。")
area_map, areas, drawing_payload = _build_area_map_by_topology(
area_map, areas, node_coords = _build_area_map_by_topology(
network, selected_sensor_nodes, dma_count
)
@@ -72,7 +75,9 @@ def run_leakage_identification(
observed_source = "backend_timerange"
else:
if observed_pressure_data is None:
raise ValueError("未提供 observed_pressure_data,且未提供 scada_start/scada_end。")
raise ValueError(
"未提供 observed_pressure_data,且未提供 scada_start/scada_end。"
)
observed_df = observed_pressure_data
q_sum_m3s = LeakageIdentifier._flow_to_m3s(q_sum, q_sum_unit)
@@ -90,10 +95,13 @@ def run_leakage_identification(
output_dir=output_dir,
pop_size=pop_size,
max_gen=max_gen,
n_workers=n_workers,
output_flow_unit=output_flow_unit,
save_result=False,
)
rows = result_df.to_dict(orient="records")
# node_visual_payload = _build_node_visual_payload(area_map, node_coords, rows)
# drawing_payload = _build_drawing_payload(node_visual_payload)
payload = {
"result_path": result_df.attrs.get("result_path"),
"sensor_nodes": selected_sensor_nodes,
@@ -101,21 +109,30 @@ def run_leakage_identification(
"area_count": len(set(area_map.values())),
"node_area_map": area_map,
"areas": areas,
"drawing_payload": drawing_payload,
# "node_visual_payload": node_visual_payload,
# "drawing_payload": drawing_payload,
"rows": rows,
}
if scheme_name:
if scheme_name_exists(network, scheme_name):
raise ValueError(f"方案名称已存在: {scheme_name}")
scheme_start_time = (
_to_datetime(scada_start).isoformat() if scada_start is not None else datetime.now().isoformat()
_to_datetime(scada_start).isoformat()
if scada_start is not None
else datetime.now().isoformat()
)
scheme_detail = {
"network": network,
"dma_count": dma_count,
"sensor_nodes": selected_sensor_nodes,
"scada_start": _to_datetime(scada_start).isoformat() if scada_start is not None else None,
"scada_end": _to_datetime(scada_end).isoformat() if scada_end is not None else None,
"scada_start": (
_to_datetime(scada_start).isoformat()
if scada_start is not None
else None
),
"scada_end": (
_to_datetime(scada_end).isoformat() if scada_end is not None else None
),
"algorithm_params": {
"start_time": start_time,
"duration": duration,
@@ -125,11 +142,13 @@ def run_leakage_identification(
"output_flow_unit": output_flow_unit,
"pop_size": pop_size,
"max_gen": max_gen,
"n_workers": n_workers,
},
"result_summary": {
"area_count": len(set(area_map.values())),
"max_leakage": max(
(float(row.get("LeakageFlow_m3_per_s", 0.0)) for row in rows), default=0.0
(float(row.get("LeakageFlow_m3_per_s", 0.0)) for row in rows),
default=0.0,
),
},
}
@@ -149,7 +168,7 @@ def run_leakage_identification(
result_rows=rows,
node_area_map=area_map,
areas=areas,
drawing_payload=drawing_payload,
drawing_payload={},
)
payload["scheme_name"] = scheme_name
return payload
@@ -164,7 +183,9 @@ def list_leakage_identify_schemes(
)
def get_leakage_identify_scheme_detail(network: str, scheme_name: str) -> dict[str, Any]:
def get_leakage_identify_scheme_detail(
network: str, scheme_name: str
) -> dict[str, Any]:
result = query_leakage_identify_scheme_detail(network, scheme_name)
if not result:
raise ValueError(f"未找到漏损识别方案: {scheme_name}")
@@ -189,7 +210,7 @@ def _get_pressure_sensor_nodes(network: str) -> list[str]:
def _build_area_map_by_topology(
network: str, sensor_nodes: list[str], dma_count: int | None
) -> tuple[dict[str, str], list[dict[str, Any]], dict[str, Any]]:
) -> tuple[dict[str, str], list[dict[str, Any]], dict[str, dict[str, float]]]:
node_coords = get_network_node_coords(network)
all_nodes = list(node_coords.keys())
if not all_nodes:
@@ -199,7 +220,9 @@ def _build_area_map_by_topology(
if not available_sensors:
raise ValueError("无可用压力传感器,无法生成虚拟分区。")
area_count = _resolve_dma_count(dma_count, available_sensors, all_nodes)
sensor_area_map = _cluster_sensors_to_areas(available_sensors, node_coords, area_count)
sensor_area_map = _cluster_sensors_to_areas(
available_sensors, node_coords, area_count
)
adjacency = _build_adjacency(network, all_nodes)
distance_by_sensor = {
sensor: _bfs_distances(adjacency, sensor) for sensor in available_sensors
@@ -222,8 +245,7 @@ def _build_area_map_by_topology(
raise ValueError("虚拟分区结果为空,无法生成节点区域映射。")
areas = _build_area_meta(area_map, sensor_area_map)
drawing_payload = _build_drawing_payload(areas, node_coords)
return area_map, areas, drawing_payload
return area_map, areas, node_coords
def _resolve_dma_count(
@@ -247,7 +269,10 @@ def _cluster_sensors_to_areas(
return {sensor: str(i + 1) for i, sensor in enumerate(sensor_nodes)}
points = np.array(
[[float(node_coords[s]["x"]), float(node_coords[s]["y"])] for s in sensor_nodes],
[
[float(node_coords[s]["x"]), float(node_coords[s]["y"])]
for s in sensor_nodes
],
dtype=float,
)
centers = points[:area_count].copy()
@@ -262,7 +287,9 @@ def _cluster_sensors_to_areas(
cluster_points = points[labels == i]
if cluster_points.size > 0:
centers[i] = cluster_points.mean(axis=0)
return {sensor: str(int(labels[idx]) + 1) for idx, sensor in enumerate(sensor_nodes)}
return {
sensor: str(int(labels[idx]) + 1) for idx, sensor in enumerate(sensor_nodes)
}
def _build_adjacency(network: str, all_nodes: list[str]) -> dict[str, set[str]]:
@@ -350,93 +377,72 @@ def _build_area_meta(
return areas
def _build_drawing_payload(
areas: list[dict[str, Any]], node_coords: dict[str, dict[str, float]]
def _build_area_node_map(area_map: dict[str, str]) -> dict[str, list[str]]:
area_node_map: dict[str, list[str]] = {}
for node_id, area_id in area_map.items():
area_node_map.setdefault(area_id, []).append(node_id)
for area_id in list(area_node_map.keys()):
area_node_map[area_id] = sorted(area_node_map[area_id])
return area_node_map
def _build_node_visual_payload(
area_map: dict[str, str],
node_coords: dict[str, dict[str, float]],
rows: list[dict[str, Any]],
) -> dict[str, Any]:
area_leakage_map = _build_area_leakage_map(rows)
max_leakage = max(area_leakage_map.values(), default=0.0)
features: list[dict[str, Any]] = []
for area in areas:
points = [
(
float(node_coords[node_id]["x"]),
float(node_coords[node_id]["y"]),
)
for node_id in area["node_ids"]
if node_id in node_coords
]
ring = _points_to_polygon_ring(points)
for node_id, area_id in area_map.items():
coord = node_coords.get(node_id)
if not coord:
continue
leakage_flow = float(area_leakage_map.get(area_id, 0.0))
leakage_level = _classify_leakage_level(leakage_flow, max_leakage)
features.append(
{
"type": "Feature",
"properties": {
"area_id": area["area_id"],
"node_count": area["node_count"],
"sensor_nodes": area["sensor_nodes"],
"node_id": node_id,
"area_id": area_id,
"leakage_flow_m3_per_s": leakage_flow,
"leakage_level": leakage_level,
},
"geometry": {
"type": "Point",
"coordinates": [float(coord["x"]), float(coord["y"])],
},
"geometry": {"type": "Polygon", "coordinates": [ring]},
}
)
return {"type": "FeatureCollection", "features": features}
def _points_to_polygon_ring(points: list[tuple[float, float]]) -> list[list[float]]:
if not points:
return []
unique_points = list(dict.fromkeys(points))
if len(unique_points) == 1:
x, y = unique_points[0]
delta = 1e-6
return [
[x - delta, y - delta],
[x + delta, y - delta],
[x + delta, y + delta],
[x - delta, y + delta],
[x - delta, y - delta],
]
if len(unique_points) == 2:
(x1, y1), (x2, y2) = unique_points
dx, dy = x2 - x1, y2 - y1
length = math.hypot(dx, dy)
if length == 0:
return _points_to_polygon_ring([unique_points[0]])
width = max(length * 0.02, 1e-6)
nx, ny = -dy / length * width, dx / length * width
return [
[x1 + nx, y1 + ny],
[x2 + nx, y2 + ny],
[x2 - nx, y2 - ny],
[x1 - nx, y1 - ny],
[x1 + nx, y1 + ny],
]
hull = _convex_hull(unique_points)
ring = [[x, y] for x, y in hull]
ring.append([hull[0][0], hull[0][1]])
return ring
def _build_area_leakage_map(rows: list[dict[str, Any]]) -> dict[str, float]:
area_leakage_map: dict[str, float] = {}
for row in rows:
area_id = str(row.get("Area", "")).strip()
if not area_id:
continue
area_leakage_map[area_id] = float(row.get("LeakageFlow_m3_per_s", 0.0))
return area_leakage_map
def _convex_hull(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
pts = sorted(points)
if len(pts) <= 1:
return pts
def _classify_leakage_level(leakage_flow: float, max_leakage: float) -> str:
if max_leakage <= 0:
return "normal"
ratio = leakage_flow / max_leakage
if ratio >= 0.75:
return "high"
if ratio >= 0.4:
return "medium"
if ratio > 0:
return "low"
return "normal"
def cross(
o: tuple[float, float], a: tuple[float, float], b: tuple[float, float]
) -> float:
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
lower: list[tuple[float, float]] = []
for p in pts:
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
lower.pop()
lower.append(p)
upper: list[tuple[float, float]] = []
for p in reversed(pts):
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
upper.pop()
upper.append(p)
return lower[:-1] + upper[:-1]
def _build_drawing_payload(node_visual_payload: dict[str, Any]) -> dict[str, Any]:
return node_visual_payload
def _build_observed_pressure_from_scada(
@@ -459,15 +465,21 @@ def _build_observed_pressure_from_scada(
continue
node_id = item.get("associated_element_id")
query_id = item.get("api_query_id")
if isinstance(node_id, str) and node_id and isinstance(query_id, str) and query_id:
if (
isinstance(node_id, str)
and node_id
and isinstance(query_id, str)
and query_id
):
node_query_id[node_id] = query_id
query_ids = [node_query_id[node] for node in sensor_nodes if node in node_query_id]
if not query_ids:
raise ValueError("未找到可用于压力观测的 SCADA api_query_id。")
scada_data = influxdb_api.query_SCADA_data_by_device_ID_and_timerange(
query_ids_list=query_ids,
scada_data = InternalQueries.query_scada_by_ids_timerange(
db_name=network,
device_ids=query_ids,
start_time=start_dt.isoformat(),
end_time=end_dt.isoformat(),
)
@@ -507,19 +519,9 @@ def _prepare_leakage_inp(network: str) -> str:
db_inp_dir = os.path.join(project_root, "db_inp")
os.makedirs(db_inp_dir, exist_ok=True)
inp_path = os.path.join(db_inp_dir, f"{network}.leakage.inp")
if _is_valid_inp_file(inp_path):
if os.path.isfile(inp_path) and os.path.getsize(inp_path) > 0:
return inp_path
dump_inp(network, inp_path, "2")
if not _is_valid_inp_file(inp_path):
if not os.path.isfile(inp_path) or os.path.getsize(inp_path) <= 0:
raise ValueError(f"漏损识别 INP 文件无效: {inp_path}")
return inp_path
def _is_valid_inp_file(inp_path: str) -> bool:
if not os.path.isfile(inp_path) or os.path.getsize(inp_path) <= 0:
return False
try:
wntr.network.WaterNetworkModel(inp_path)
return True
except Exception:
return False
+2 -2
View File
@@ -220,7 +220,7 @@ def store_leakage_identify_result(
result_rows: list[dict],
node_area_map: dict[str, str],
areas: list[dict],
drawing_payload: dict,
drawing_payload: dict | None = None,
run_status: str = "completed",
error_message: str | None = None,
) -> None:
@@ -257,7 +257,7 @@ def store_leakage_identify_result(
json.dumps(result_rows),
json.dumps(node_area_map),
json.dumps(areas),
json.dumps(drawing_payload),
json.dumps(drawing_payload or {}),
),
)
conn.commit()