Files
TJWaterServerBinary/app/services/sensor_placement.py
T
jiang 9b095c7439 refactor(db)!: clean up business SQL access
- make realtime replacement and analysis result writes transactional\n- consolidate SCADA repositories and remove process-global project state\n- validate SCADA batches and use indexed GIS-backed business queries\n\nBREAKING CHANGE: remove the public analysis result writer and the pipeline-health network_name query parameter.
2026-08-28 11:37:36 +08:00

306 lines
9.3 KiB
Python

from datetime import datetime
from io import BytesIO
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
from app.infra.db.postgresql import sensor_placement as sensor_placement_repository
class SensorPlacementNotFoundError(LookupError):
pass
class SensorPlacementValidationError(ValueError):
pass
class SensorPlacementConflictError(RuntimeError):
pass
_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