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