feat(sensor-placement): add editable scheme APIs

This commit is contained in:
2026-07-30 16:16:51 +08:00
parent 437eb5a19a
commit ddbb50173c
13 changed files with 1313 additions and 122 deletions
+4 -35
View File
@@ -1,18 +1,14 @@
from fastapi import APIRouter, Request, Depends, Query, Path, Body
from typing import Any, List, Dict, Union
from typing import Any
from fastapi import APIRouter, Query
from app.services.tjnetwork import (
Any,
get_all_scada_info,
get_major_node_coords,
get_major_pipe_nodes,
get_network_in_extent,
get_network_link_nodes,
get_network_node_coords,
get_node_coord,
)
from app.auth.metadata_dependencies import get_current_metadata_user
from app.infra.cache.redis_client import redis_client, encode_datetime, decode_datetime
import msgpack
router = APIRouter()
@@ -62,33 +58,6 @@ async def fastapi_get_network_in_extent(
"""获取地理范围内的网络几何信息。"""
return get_network_in_extent(network, x1, y1, x2, y2)
@router.get(
"/getnetworkgeometries/",
dependencies=[Depends(get_current_metadata_user)],
summary="获取完整网络几何信息",
description="获取整个水网的所有节点、管线和SCADA点的几何信息(需要身份验证)"
)
async def fastapi_get_network_geometries(
network: str = Query(..., description="管网名称(或数据库名称)")
) -> dict[str, Any] | None:
"""获取完整的网络几何信息,包括所有节点、管线和SCADA点。结果从缓存返回。"""
cache_key = f"getnetworkgeometries_{network}"
data = redis_client.get(cache_key)
if data:
loaded_dict = msgpack.unpackb(data, object_hook=decode_datetime)
return loaded_dict
coords = get_network_node_coords(network)
nodes = []
for node_id, coord in coords.items():
nodes.append(f"{node_id}:{coord['type']}:{coord['x']}:{coord['y']}")
links = get_network_link_nodes(network)
scadas = get_all_scada_info(network)
results = {"nodes": nodes, "links": links, "scadas": scadas}
redis_client.set(cache_key, msgpack.packb(results, default=encode_datetime))
return results
@router.get(
"/getmajornodecoords/",
summary="获取主要节点坐标",
+208
View File
@@ -0,0 +1,208 @@
import logging
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from starlette.concurrency import run_in_threadpool
from app.algorithms.sensor import (
pressure_sensor_placement_kmeans,
pressure_sensor_placement_sensitivity,
)
from app.auth.metadata_dependencies import get_current_metadata_user
from app.auth.project_dependencies import ProjectContext, get_project_context
from app.domain.schemas.sensor_placement import (
SensorPlacementExportRequest,
SensorPlacementOptimizeRequest,
SensorPlacementSchemeResponse,
SensorPlacementUpdateRequest,
)
from app.services.sensor_placement import (
SensorPlacementConflictError,
SensorPlacementNotFoundError,
SensorPlacementValidationError,
build_sensor_placement_workbook,
can_edit_sensor_placement,
get_sensor_placement_scheme,
update_sensor_placement_scheme,
)
router = APIRouter()
logger = logging.getLogger(__name__)
def _project_network(network: str, project_context: ProjectContext) -> str:
if network != project_context.project_code:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="请求的管网不属于当前项目",
)
return project_context.project_code
def _service_http_error(exc: Exception) -> HTTPException:
if isinstance(exc, SensorPlacementNotFoundError):
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
if isinstance(exc, SensorPlacementConflictError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
)
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
)
def _get_scheme_response(
network: str,
scheme_id: int,
current_user: Any,
) -> dict[str, Any]:
try:
scheme = get_sensor_placement_scheme(network, scheme_id)
return {
**scheme,
"can_edit": can_edit_sensor_placement(current_user, scheme),
}
except (
SensorPlacementNotFoundError,
SensorPlacementValidationError,
) as exc:
raise _service_http_error(exc) from exc
@router.post(
"/sensor-placement-schemes/optimize",
response_model=SensorPlacementSchemeResponse,
summary="创建并返回监测点优化方案",
)
async def optimize_sensor_placement_scheme(
payload: SensorPlacementOptimizeRequest,
project_context: ProjectContext = Depends(get_project_context),
current_user=Depends(get_current_metadata_user),
) -> dict[str, Any]:
network = _project_network(payload.network, project_context)
optimizer = (
pressure_sensor_placement_sensitivity
if payload.method == "sensitivity"
else pressure_sensor_placement_kmeans
)
try:
created = await run_in_threadpool(
optimizer,
name=network,
scheme_name=payload.scheme_name,
sensor_number=payload.sensor_count,
min_diameter=payload.min_diameter,
username=current_user.username,
)
scheme = get_sensor_placement_scheme(network, int(created["id"]))
return {**scheme, "can_edit": True}
except (SensorPlacementValidationError, ValueError) as exc:
raise _service_http_error(exc) from exc
except Exception as exc:
logger.exception("Sensor placement optimization failed")
raise HTTPException(
status_code=500,
detail="监测点优化失败,请稍后重试",
) from exc
@router.get(
"/sensor-placement-schemes/{scheme_id}",
response_model=SensorPlacementSchemeResponse,
summary="获取监测点方案详情",
)
async def get_sensor_placement_scheme_detail(
scheme_id: int,
network: str = Query(..., min_length=1),
project_context: ProjectContext = Depends(get_project_context),
current_user=Depends(get_current_metadata_user),
) -> dict[str, Any]:
return _get_scheme_response(
_project_network(network, project_context),
scheme_id,
current_user,
)
@router.put(
"/sensor-placement-schemes/{scheme_id}",
response_model=SensorPlacementSchemeResponse,
summary="覆盖保存监测点方案",
)
async def overwrite_sensor_placement_scheme(
scheme_id: int,
payload: SensorPlacementUpdateRequest,
network: str = Query(..., min_length=1),
project_context: ProjectContext = Depends(get_project_context),
current_user=Depends(get_current_metadata_user),
) -> dict[str, Any]:
network = _project_network(network, project_context)
scheme = _get_scheme_response(network, scheme_id, current_user)
if not scheme["can_edit"]:
raise HTTPException(status_code=403, detail="无权修改该监测点方案")
try:
updated = update_sensor_placement_scheme(
network,
scheme_id,
expected_sensor_location=payload.expected_sensor_location,
sensor_location=payload.sensor_location,
)
return {**updated, "can_edit": True}
except (
SensorPlacementConflictError,
SensorPlacementNotFoundError,
SensorPlacementValidationError,
) as exc:
raise _service_http_error(exc) from exc
@router.post(
"/sensor-placement-schemes/{scheme_id}/exports/excel",
summary="导出监测点工程清单",
)
async def export_sensor_placement_excel(
scheme_id: int,
payload: SensorPlacementExportRequest,
network: str = Query(..., min_length=1),
project_context: ProjectContext = Depends(get_project_context),
current_user=Depends(get_current_metadata_user),
) -> StreamingResponse:
network = _project_network(network, project_context)
scheme = _get_scheme_response(network, scheme_id, current_user)
if (
payload.sensor_location != scheme["sensor_location"]
and not scheme["can_edit"]
):
raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿")
try:
workbook = build_sensor_placement_workbook(
network=network,
scheme=scheme,
sensor_location=payload.sensor_location,
adjustment_status=payload.adjustment_status,
)
except SensorPlacementValidationError as exc:
raise _service_http_error(exc) from exc
filename = f"{scheme['scheme_name']}_监测点清单.xlsx"
encoded_filename = quote(filename)
return StreamingResponse(
workbook,
media_type=(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
headers={
"Content-Disposition": (
f"attachment; filename*=UTF-8''{encoded_filename}"
)
},
)
+2
View File
@@ -5,6 +5,7 @@ from app.api.v1.endpoints import (
project,
simulation,
scada,
sensor_placement,
extension,
snapshots,
# data_query,
@@ -89,6 +90,7 @@ api_router.include_router(visuals.router, tags=["Visuals"])
api_router.include_router(simulation.router, tags=["Simulation Control"])
# api_router.include_router(data_query.router, tags=["Data Query & InfluxDB"])
api_router.include_router(scada.router)
api_router.include_router(sensor_placement.router, tags=["Sensor Placement"])
api_router.include_router(snapshots.router, tags=["Snapshots"])
api_router.include_router(users.router, tags=["Users"])
api_router.include_router(schemes.router, tags=["Schemes"])