Files
TJWaterServerBinary/app/services/sensor_placement.py
jiang 90b02057bc
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m13s
feat(projects): automate project infrastructure provisioning
2026-09-11 10:57:51 +08:00

421 lines
13 KiB
Python

from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime
import fcntl
from io import BytesIO
from pathlib import Path
from typing import Any
from uuid import UUID
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.worksheet.worksheet import Worksheet
from openpyxl.utils import get_column_letter
from pyproj import Transformer
import wntr
from app.algorithms.pressure_sensor_placement import kmeans_placement
from app.algorithms.pressure_sensor_placement import sensitivity_placement
from app.infra.db.postgresql import sensor_placement as sensor_placement_repository
from app.services.project_inp import temporary_project_inp
class SensorPlacementNotFoundError(LookupError):
pass
class SensorPlacementValidationError(ValueError):
pass
class SensorPlacementConflictError(RuntimeError):
pass
def _sensor_lock_path(project_code: str) -> Path:
if (
not project_code
or project_code in {".", ".."}
or "/" in project_code
or "\\" in project_code
or "\x00" in project_code
):
raise SensorPlacementValidationError("管网名称不是有效的项目标识")
return Path("db_inp") / f"{project_code}.sensor.lock"
@contextmanager
def _sensor_run_lock(project_code: str) -> Iterator[None]:
lock_path = _sensor_lock_path(project_code)
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("w", encoding="utf-8") as lock_file:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise SensorPlacementConflictError(
"当前项目已有监测点优化任务正在运行,请稍后重试"
) from exc
try:
yield
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
@contextmanager
def _sensor_network_model(
project_code: str,
) -> Iterator[wntr.network.WaterNetworkModel]:
with _sensor_run_lock(project_code):
with temporary_project_inp(
project_code,
purpose="sensor-placement",
) as inp_path:
yield wntr.network.WaterNetworkModel(str(inp_path))
def _create_validated_placement(
project_code: str,
*,
run_name: str,
min_diameter: int,
created_by: str,
sensor_locations: list[str],
) -> dict[str, Any]:
validate_sensor_placement_nodes(project_code, sensor_locations)
return sensor_placement_repository.create_sensor_placement(
project_code,
run_name=run_name,
min_diameter=min_diameter,
created_by=created_by,
sensor_locations=sensor_locations,
)
def optimize_sensor_placement_by_sensitivity(
project_code: str,
run_name: str,
sensor_count: int,
min_diameter: int,
created_by: str,
) -> dict[str, Any]:
"""Run sensitivity placement and persist the validated result."""
with _sensor_network_model(project_code) as network_model:
sensor_locations = sensitivity_placement.optimize_sensor_placement(
network_model,
sensor_num=sensor_count,
min_diameter=min_diameter,
)
return _create_validated_placement(
project_code,
run_name=run_name,
min_diameter=min_diameter,
created_by=created_by,
sensor_locations=sensor_locations,
)
def optimize_sensor_placement_by_kmeans(
project_code: str,
run_name: str,
sensor_count: int,
min_diameter: int,
created_by: str,
) -> dict[str, Any]:
"""Export the model, run K-means placement, and persist the result."""
with _sensor_network_model(project_code) as network_model:
sensor_locations = kmeans_placement.optimize_sensor_placement(
network_model,
sensor_count=sensor_count,
min_diameter_mm=min_diameter,
)
return _create_validated_placement(
project_code,
run_name=run_name,
min_diameter=min_diameter,
created_by=created_by,
sensor_locations=sensor_locations,
)
_to_wgs84 = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True)
_STATUS_LABELS = {
"current": "当前方案",
"original": "原方案",
"added": "新增",
"replaced": "替换",
}
_COORDINATE_DESCRIPTION = (
"工程 X/Y: 项目地方坐标系;地图 X/Y: EPSG:3857;经纬度: WGS84"
)
_LIST_HEADERS = (
"序号",
"节点 ID",
"经度",
"纬度",
"工程 X",
"工程 Y",
"地图 X",
"地图 Y",
"高程",
"调整状态",
)
_LIST_COLUMN_WIDTHS = (8, 20, 16, 16, 18, 18, 18, 18, 14, 14)
def _normalize_locations(sensor_location: list[str]) -> list[str]:
normalized = [str(node_id).strip() for node_id in sensor_location]
if not normalized or any(not node_id for node_id in normalized):
raise SensorPlacementValidationError("监测点列表不能为空")
if len(set(normalized)) != len(normalized):
raise SensorPlacementValidationError("监测点列表不能包含重复节点")
return normalized
def _sensor_points(
network: str,
sensor_location: list[str],
*,
nodes_by_id: dict[str, dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
if nodes_by_id is None:
nodes = sensor_placement_repository.get_sensor_placement_nodes(
network, sensor_location
)
nodes_by_id = {str(node["node_id"]): node for node in nodes}
by_id = nodes_by_id
missing = [node_id for node_id in sensor_location if node_id not in by_id]
if missing:
raise SensorPlacementValidationError(
f"以下节点不存在或不是 junction: {', '.join(missing)}"
)
points: list[dict[str, Any]] = []
for node_id in sensor_location:
node = by_id[node_id]
project_x = float(node["project_x"])
project_y = float(node["project_y"])
map_x = float(node["map_x"])
map_y = float(node["map_y"])
longitude, latitude = _to_wgs84.transform(map_x, map_y)
points.append(
{
"node_id": node_id,
"max_pipe_diameter": (
float(node["max_pipe_diameter"])
if node["max_pipe_diameter"] is not None
else None
),
"project_x": project_x,
"project_y": project_y,
"map_x": map_x,
"map_y": map_y,
"longitude": float(longitude),
"latitude": float(latitude),
"elevation": float(node["elevation"]),
}
)
return points
def get_sensor_placement_candidate(
network: str,
node_id: str,
) -> dict[str, Any]:
"""Return the authoritative editable point data for one junction."""
return _sensor_points(network, _normalize_locations([node_id]))[0]
def validate_sensor_placement_nodes(
network: str,
sensor_location: list[str],
) -> None:
_sensor_points(network, _normalize_locations(sensor_location))
def get_sensor_placement_run(network: str, run_id: UUID) -> dict[str, Any]:
run = sensor_placement_repository.get_sensor_placement(network, run_id)
if run is None:
raise SensorPlacementNotFoundError("监测点优化运行不存在")
locations = [str(item) for item in (run.get("sensor_locations") or [])]
return {
**run,
"sensor_count": len(locations),
"sensor_locations": locations,
"sensor_points": _sensor_points(network, locations),
}
def list_sensor_placement_runs(network: str) -> list[dict[str, Any]]:
runs = sensor_placement_repository.get_all_sensor_placements(network)
node_ids = list(
dict.fromkeys(
node_id
for run in runs
for node_id in run["sensor_locations"]
)
)
nodes = sensor_placement_repository.get_sensor_placement_nodes(network, node_ids)
nodes_by_id = {str(node["node_id"]): node for node in nodes}
return [
{
**run,
"sensor_points": _sensor_points(
network,
run["sensor_locations"],
nodes_by_id=nodes_by_id,
),
}
for run in runs
]
def update_sensor_placement_run(
network: str,
run_id: UUID,
*,
expected_sensor_locations: list[str],
sensor_locations: list[str],
) -> dict[str, Any]:
expected = _normalize_locations(expected_sensor_locations)
next_locations = _normalize_locations(sensor_locations)
_sensor_points(network, next_locations)
updated = sensor_placement_repository.update_sensor_placement(
network,
run_id,
expected_sensor_locations=expected,
sensor_locations=next_locations,
)
if updated is None:
if sensor_placement_repository.get_sensor_placement(network, run_id) is None:
raise SensorPlacementNotFoundError("监测点优化运行不存在")
raise SensorPlacementConflictError("运行结果已被其他用户修改,请重新加载")
return {
**updated,
"sensor_points": _sensor_points(network, updated["sensor_locations"]),
}
def can_edit_sensor_placement(user: Any, run: dict[str, Any]) -> bool:
return bool(
getattr(user, "is_superuser", False)
or getattr(user, "role", None) == "admin"
or getattr(user, "username", None) == run.get("created_by")
)
def _safe_excel_text(value: Any) -> str:
text = "" if value is None else str(value)
if text.startswith(("=", "+", "-", "@")):
return f"'{text}"
return text
def _populate_info_sheet(
sheet: Worksheet,
*,
network: str,
scheme: dict[str, Any],
location_count: int,
is_draft: bool,
) -> None:
created_at = scheme["created_at"]
if isinstance(created_at, datetime):
created_at = created_at.isoformat(timespec="minutes")
rows = [
("项目", network),
("运行名称", scheme["name"]),
("监测点数量", location_count),
("最小管径", scheme["min_diameter"]),
("创建人", scheme["created_by"]),
("创建时间", created_at),
("导出时间", datetime.now().astimezone().isoformat(timespec="minutes")),
("文档状态", "未保存草稿" if is_draft else "当前方案"),
("坐标说明", _COORDINATE_DESCRIPTION),
]
for row_index, (label, value) in enumerate(rows, start=1):
sheet.cell(row=row_index, column=1, value=label)
safe_value = _safe_excel_text(value) if isinstance(value, str) else value
sheet.cell(row=row_index, column=2, value=safe_value)
sheet.column_dimensions["A"].width = 18
sheet.column_dimensions["B"].width = 64
def _populate_list_sheet(
sheet: Worksheet,
*,
points: list[dict[str, Any]],
adjustment_status: dict[str, str],
) -> None:
sheet.append(_LIST_HEADERS)
for index, point in enumerate(points, start=1):
status = adjustment_status.get(point["node_id"], "current")
sheet.append(
[
index,
_safe_excel_text(point["node_id"]),
point["longitude"],
point["latitude"],
point["project_x"],
point["project_y"],
point["map_x"],
point["map_y"],
point["elevation"],
_STATUS_LABELS.get(status, "当前方案"),
]
)
header_fill = PatternFill("solid", fgColor="257DD4")
for cell in sheet[1]:
cell.fill = header_fill
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = sheet.dimensions
for index, width in enumerate(_LIST_COLUMN_WIDTHS, start=1):
sheet.column_dimensions[get_column_letter(index)].width = width
for row in sheet.iter_rows(min_row=2):
row[0].alignment = Alignment(horizontal="center")
for cell in row[2:9]:
cell.number_format = "0.000000"
def build_sensor_placement_workbook(
*,
network: str,
scheme: dict[str, Any],
sensor_location: list[str],
adjustment_status: dict[str, str],
) -> BytesIO:
locations = _normalize_locations(sensor_location)
points = _sensor_points(network, locations)
is_draft = locations != list(scheme["sensor_locations"])
workbook = Workbook()
info_sheet = workbook.active
info_sheet.title = "方案信息"
_populate_info_sheet(
info_sheet,
network=network,
scheme=scheme,
location_count=len(locations),
is_draft=is_draft,
)
list_sheet = workbook.create_sheet("监测点清单")
_populate_list_sheet(
list_sheet,
points=points,
adjustment_status=adjustment_status,
)
output = BytesIO()
workbook.save(output)
output.seek(0)
return output