feat(sensor-placement): add editable scheme APIs
This commit is contained in:
@@ -1,14 +1,68 @@
|
||||
import psycopg
|
||||
from contextlib import contextmanager
|
||||
import fcntl
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.algorithms.sensor import kmeans as kmeans_sensor
|
||||
from app.algorithms.sensor import sensitivity
|
||||
from app.core.config import get_pgconn_string
|
||||
from app.native.wndb.s42_sensor_placement import create_sensor_placement
|
||||
from app.services.sensor_placement import (
|
||||
SensorPlacementValidationError,
|
||||
validate_sensor_placement_nodes,
|
||||
)
|
||||
from app.services.tjnetwork import dump_inp
|
||||
|
||||
|
||||
def _sensor_inp_path(name: str) -> Path:
|
||||
if (
|
||||
not name
|
||||
or name in {".", ".."}
|
||||
or "/" in name
|
||||
or "\\" in name
|
||||
or "\x00" in name
|
||||
):
|
||||
raise SensorPlacementValidationError("管网名称不是有效的项目标识")
|
||||
return Path("db_inp") / f"{name}.db.inp"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _sensor_inp_lock(name: str):
|
||||
inp_path = _sensor_inp_path(name)
|
||||
inp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = inp_path.with_suffix(".sensor.lock")
|
||||
with lock_path.open("w", encoding="utf-8") as lock_file:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield inp_path
|
||||
finally:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _create_validated_placement(
|
||||
name: str,
|
||||
*,
|
||||
scheme_name: str,
|
||||
min_diameter: int,
|
||||
username: str,
|
||||
sensor_location: list[str],
|
||||
) -> dict[str, Any]:
|
||||
validate_sensor_placement_nodes(name, sensor_location)
|
||||
return create_sensor_placement(
|
||||
name,
|
||||
scheme_name=scheme_name,
|
||||
min_diameter=min_diameter,
|
||||
username=username,
|
||||
sensor_location=sensor_location,
|
||||
)
|
||||
|
||||
|
||||
def pressure_sensor_placement_sensitivity(
|
||||
name: str, scheme_name: str, sensor_number: int, min_diameter: int, username: str
|
||||
) -> None:
|
||||
name: str,
|
||||
scheme_name: str,
|
||||
sensor_number: int,
|
||||
min_diameter: int,
|
||||
username: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
基于改进灵敏度法进行压力监测点优化布置
|
||||
:param name: 数据库名称
|
||||
@@ -16,41 +70,32 @@ def pressure_sensor_placement_sensitivity(
|
||||
:param sensor_number: 传感器数目
|
||||
:param min_diameter: 最小管径
|
||||
:param username: 用户名
|
||||
:return:
|
||||
:return: 新建的监测点方案
|
||||
"""
|
||||
sensor_location = sensitivity.get_ID(
|
||||
name=name, sensor_num=sensor_number, min_diameter=min_diameter
|
||||
with _sensor_inp_lock(name):
|
||||
sensor_location = sensitivity.get_ID(
|
||||
name=name,
|
||||
sensor_num=sensor_number,
|
||||
min_diameter=min_diameter,
|
||||
)
|
||||
return _create_validated_placement(
|
||||
name,
|
||||
scheme_name=scheme_name,
|
||||
min_diameter=min_diameter,
|
||||
username=username,
|
||||
sensor_location=sensor_location,
|
||||
)
|
||||
try:
|
||||
conn_string = get_pgconn_string(db_name=name)
|
||||
with psycopg.connect(conn_string) as conn:
|
||||
with conn.cursor() as cur:
|
||||
sql = """
|
||||
INSERT INTO sensor_placement (scheme_name, sensor_number, min_diameter, username, sensor_location)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
cur.execute(
|
||||
sql,
|
||||
(
|
||||
scheme_name,
|
||||
sensor_number,
|
||||
min_diameter,
|
||||
username,
|
||||
sensor_location,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
print("方案信息存储成功!")
|
||||
except Exception as e:
|
||||
print(f"存储方案信息时出错:{e}")
|
||||
|
||||
|
||||
# 2025/08/21
|
||||
# 基于kmeans聚类法进行压力监测点优化布置
|
||||
def pressure_sensor_placement_kmeans(
|
||||
name: str, scheme_name: str, sensor_number: int, min_diameter: int, username: str
|
||||
) -> None:
|
||||
name: str,
|
||||
scheme_name: str,
|
||||
sensor_number: int,
|
||||
min_diameter: int,
|
||||
username: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
基于聚类法进行压力监测点优化布置
|
||||
:param name: 数据库名称(注意,此处数据库名称也是inp文件名称,inp文件与pg库名要一样)
|
||||
@@ -58,34 +103,20 @@ def pressure_sensor_placement_kmeans(
|
||||
:param sensor_number: 传感器数目
|
||||
:param min_diameter: 最小管径
|
||||
:param username: 用户名
|
||||
:return:
|
||||
:return: 新建的监测点方案
|
||||
"""
|
||||
# dump_inp
|
||||
inp_name = f"./db_inp/{name}.db.inp"
|
||||
dump_inp(name, inp_name, "2")
|
||||
sensor_location = kmeans_sensor.kmeans_sensor_placement(
|
||||
name=name, sensor_num=sensor_number, min_diameter=min_diameter
|
||||
with _sensor_inp_lock(name) as inp_path:
|
||||
dump_inp(name, str(inp_path), "2")
|
||||
sensor_location = kmeans_sensor.kmeans_sensor_placement(
|
||||
name=name,
|
||||
sensor_num=sensor_number,
|
||||
min_diameter=min_diameter,
|
||||
)
|
||||
return _create_validated_placement(
|
||||
name,
|
||||
scheme_name=scheme_name,
|
||||
min_diameter=min_diameter,
|
||||
username=username,
|
||||
sensor_location=sensor_location,
|
||||
)
|
||||
try:
|
||||
conn_string = get_pgconn_string(db_name=name)
|
||||
with psycopg.connect(conn_string) as conn:
|
||||
with conn.cursor() as cur:
|
||||
sql = """
|
||||
INSERT INTO sensor_placement (scheme_name, sensor_number, min_diameter, username, sensor_location)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
cur.execute(
|
||||
sql,
|
||||
(
|
||||
scheme_name,
|
||||
sensor_number,
|
||||
min_diameter,
|
||||
username,
|
||||
sensor_location,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
print("方案信息存储成功!")
|
||||
except Exception as e:
|
||||
print(f"存储方案信息时出错:{e}")
|
||||
|
||||
@@ -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="获取主要节点坐标",
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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"])
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
AdjustmentStatus = Literal["current", "original", "added", "replaced"]
|
||||
|
||||
|
||||
def _normalize_location_ids(value: list[str]) -> list[str]:
|
||||
normalized = [str(item).strip() for item in value]
|
||||
if any(not item for item in normalized):
|
||||
raise ValueError("sensor locations cannot contain blank node IDs")
|
||||
if len(set(normalized)) != len(normalized):
|
||||
raise ValueError("sensor locations cannot contain duplicate node IDs")
|
||||
return normalized
|
||||
|
||||
|
||||
class SensorPlacementOptimizeRequest(BaseModel):
|
||||
network: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=63,
|
||||
pattern=r"^[^/\\\x00]+$",
|
||||
)
|
||||
scheme_name: str = Field(..., min_length=1, max_length=32)
|
||||
sensor_type: Literal["pressure"]
|
||||
method: Literal["sensitivity", "kmeans"]
|
||||
sensor_count: int = Field(..., gt=0, le=200)
|
||||
min_diameter: int = Field(default=0, ge=0)
|
||||
|
||||
@field_validator("network")
|
||||
@classmethod
|
||||
def validate_network(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if normalized in {".", ".."}:
|
||||
raise ValueError("network must be a project identifier")
|
||||
return normalized
|
||||
|
||||
|
||||
class SensorPlacementUpdateRequest(BaseModel):
|
||||
expected_sensor_location: list[str] = Field(..., min_length=1)
|
||||
sensor_location: list[str] = Field(..., min_length=1)
|
||||
|
||||
@field_validator("expected_sensor_location", "sensor_location")
|
||||
@classmethod
|
||||
def validate_locations(cls, value: list[str]) -> list[str]:
|
||||
return _normalize_location_ids(value)
|
||||
|
||||
|
||||
class SensorPlacementExportRequest(BaseModel):
|
||||
sensor_location: list[str] = Field(..., min_length=1)
|
||||
adjustment_status: dict[str, AdjustmentStatus] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("sensor_location")
|
||||
@classmethod
|
||||
def validate_locations(cls, value: list[str]) -> list[str]:
|
||||
return _normalize_location_ids(value)
|
||||
|
||||
|
||||
class SensorPointResponse(BaseModel):
|
||||
node_id: str
|
||||
project_x: float
|
||||
project_y: float
|
||||
map_x: float
|
||||
map_y: float
|
||||
longitude: float
|
||||
latitude: float
|
||||
elevation: float
|
||||
|
||||
|
||||
class SensorPlacementSchemeResponse(BaseModel):
|
||||
id: int
|
||||
scheme_name: str
|
||||
sensor_number: int
|
||||
min_diameter: int
|
||||
username: str
|
||||
create_time: datetime
|
||||
sensor_location: list[str]
|
||||
sensor_points: list[SensorPointResponse]
|
||||
can_edit: bool = False
|
||||
@@ -58,6 +58,8 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
||||
"/meta/projects",
|
||||
"/api/v1/openproject/",
|
||||
"/openproject/",
|
||||
"/api/v1/audit/session-events",
|
||||
"/audit/session-events",
|
||||
}
|
||||
EXCLUDED_PATH_PREFIXES = (
|
||||
)
|
||||
@@ -80,27 +82,9 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
||||
request_data = None
|
||||
if should_capture_body:
|
||||
try:
|
||||
# 注意:读取 body 后需要重新设置,避免影响后续处理
|
||||
original_receive = request._receive
|
||||
body = await request.body()
|
||||
if body:
|
||||
request_data = json.loads(body.decode())
|
||||
|
||||
# 重新构造请求以供后续使用:仅回放一次,后续回落原始 receive
|
||||
body_sent = False
|
||||
|
||||
async def receive():
|
||||
nonlocal body_sent
|
||||
if not body_sent:
|
||||
body_sent = True
|
||||
return {
|
||||
"type": "http.request",
|
||||
"body": body,
|
||||
"more_body": False,
|
||||
}
|
||||
return await original_receive()
|
||||
|
||||
request._receive = receive
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read request body for audit: {e}")
|
||||
|
||||
|
||||
@@ -320,7 +320,11 @@ from .s23_options_util import (
|
||||
from .s23_options_util import get_option_v3_schema, get_option_v3
|
||||
from .batch_api import set_option_v3_ex
|
||||
|
||||
from .s24_coordinates import get_node_coord, get_nodes_in_extent, get_links_in_extent
|
||||
from .s24_coordinates import (
|
||||
get_links_in_extent,
|
||||
get_node_coord,
|
||||
get_nodes_in_extent,
|
||||
)
|
||||
|
||||
from .s25_vertices import (
|
||||
get_vertex_schema,
|
||||
@@ -468,6 +472,11 @@ from .s41_pipe_risk_probability import (
|
||||
get_pipe_risk_probability_geometries,
|
||||
)
|
||||
|
||||
from .s42_sensor_placement import get_all_sensor_placements
|
||||
from .s42_sensor_placement import (
|
||||
get_all_sensor_placements,
|
||||
get_sensor_placement,
|
||||
get_sensor_placement_nodes,
|
||||
update_sensor_placement,
|
||||
)
|
||||
|
||||
from .s43_burst_locate_result import get_all_burst_locate_results
|
||||
|
||||
@@ -1,7 +1,109 @@
|
||||
from .database import *
|
||||
from .s0_base import *
|
||||
from .s42_sensor_placement import *
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
def get_all_sensor_placements(name: str) -> list[dict[Any, Any]]:
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from .connection import project_connection
|
||||
from .database import read_all
|
||||
|
||||
|
||||
def get_all_sensor_placements(name: str) -> list[dict[str, Any]]:
|
||||
return read_all(name, "select * from sensor_placement")
|
||||
|
||||
|
||||
def create_sensor_placement(
|
||||
name: str,
|
||||
*,
|
||||
scheme_name: str,
|
||||
min_diameter: int,
|
||||
username: str,
|
||||
sensor_location: list[str],
|
||||
) -> dict[str, Any]:
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sensor_placement (
|
||||
scheme_name,
|
||||
sensor_number,
|
||||
min_diameter,
|
||||
username,
|
||||
sensor_location
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
scheme_name,
|
||||
len(sensor_location),
|
||||
min_diameter,
|
||||
username,
|
||||
sensor_location,
|
||||
),
|
||||
)
|
||||
created = cur.fetchone()
|
||||
if created is None:
|
||||
raise RuntimeError("监测点方案写入失败")
|
||||
return dict(created)
|
||||
|
||||
|
||||
def get_sensor_placement(name: str, scheme_id: int) -> dict[str, Any] | None:
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM sensor_placement WHERE id = %s",
|
||||
(scheme_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def get_sensor_placement_nodes(
|
||||
name: str,
|
||||
node_ids: list[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not node_ids:
|
||||
return []
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT ON (gj.id)
|
||||
gj.id AS node_id,
|
||||
gj.elevation,
|
||||
ST_X(c.coord) AS project_x,
|
||||
ST_Y(c.coord) AS project_y,
|
||||
ST_X(gj.geom) AS map_x,
|
||||
ST_Y(gj.geom) AS map_y
|
||||
FROM geo_junctions_mat AS gj
|
||||
JOIN coordinates AS c ON c.node = gj.id
|
||||
WHERE gj.id = ANY(%s)
|
||||
ORDER BY gj.id
|
||||
""",
|
||||
(node_ids,),
|
||||
)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
def update_sensor_placement(
|
||||
name: str,
|
||||
scheme_id: int,
|
||||
*,
|
||||
expected_sensor_location: list[str],
|
||||
sensor_location: list[str],
|
||||
) -> dict[str, Any] | None:
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE sensor_placement
|
||||
SET sensor_location = %s, sensor_number = %s
|
||||
WHERE id = %s AND sensor_location = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
sensor_location,
|
||||
len(sensor_location),
|
||||
scheme_id,
|
||||
expected_sensor_location,
|
||||
),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
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.native import wndb
|
||||
|
||||
|
||||
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],
|
||||
) -> list[dict[str, Any]]:
|
||||
nodes = wndb.get_sensor_placement_nodes(network, sensor_location)
|
||||
by_id = {str(node["node_id"]): node for node in nodes}
|
||||
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,
|
||||
"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 validate_sensor_placement_nodes(
|
||||
network: str,
|
||||
sensor_location: list[str],
|
||||
) -> None:
|
||||
_sensor_points(network, _normalize_locations(sensor_location))
|
||||
|
||||
|
||||
def get_sensor_placement_scheme(network: str, scheme_id: int) -> dict[str, Any]:
|
||||
scheme = wndb.get_sensor_placement(network, scheme_id)
|
||||
if scheme is None:
|
||||
raise SensorPlacementNotFoundError("监测点方案不存在")
|
||||
|
||||
locations = [str(item) for item in (scheme.get("sensor_location") or [])]
|
||||
return {
|
||||
**scheme,
|
||||
"sensor_number": len(locations),
|
||||
"sensor_location": locations,
|
||||
"sensor_points": _sensor_points(network, locations),
|
||||
}
|
||||
|
||||
|
||||
def update_sensor_placement_scheme(
|
||||
network: str,
|
||||
scheme_id: int,
|
||||
*,
|
||||
expected_sensor_location: list[str],
|
||||
sensor_location: list[str],
|
||||
) -> dict[str, Any]:
|
||||
expected = _normalize_locations(expected_sensor_location)
|
||||
next_locations = _normalize_locations(sensor_location)
|
||||
_sensor_points(network, next_locations)
|
||||
|
||||
updated = wndb.update_sensor_placement(
|
||||
network,
|
||||
scheme_id,
|
||||
expected_sensor_location=expected,
|
||||
sensor_location=next_locations,
|
||||
)
|
||||
if updated is None:
|
||||
if wndb.get_sensor_placement(network, scheme_id) is None:
|
||||
raise SensorPlacementNotFoundError("监测点方案不存在")
|
||||
raise SensorPlacementConflictError("方案已被其他用户修改,请重新加载")
|
||||
return get_sensor_placement_scheme(network, scheme_id)
|
||||
|
||||
|
||||
def can_edit_sensor_placement(user: Any, scheme: dict[str, Any]) -> bool:
|
||||
return bool(
|
||||
getattr(user, "is_superuser", False)
|
||||
or getattr(user, "role", None) == "admin"
|
||||
or getattr(user, "username", None) == scheme.get("username")
|
||||
)
|
||||
|
||||
|
||||
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["create_time"]
|
||||
if isinstance(created_at, datetime):
|
||||
created_at = created_at.isoformat(timespec="minutes")
|
||||
|
||||
rows = [
|
||||
("项目", network),
|
||||
("方案名称", scheme["scheme_name"]),
|
||||
("监测点数量", location_count),
|
||||
("最小管径", scheme["min_diameter"]),
|
||||
("创建人", scheme["username"]),
|
||||
("创建时间", 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_location"])
|
||||
|
||||
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
|
||||
@@ -1370,4 +1370,3 @@ def get_all_sensor_placements(name: str) -> list[dict[Any, Any]]:
|
||||
############################################################
|
||||
def get_all_burst_locate_results(name: str) -> list[dict[Any, Any]]:
|
||||
return api.get_all_burst_locate_results(name)
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from io import BytesIO
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.infra.audit import middleware as audit_middleware
|
||||
from app.infra.audit.middleware import AuditMiddleware
|
||||
|
||||
|
||||
def test_post_streaming_response_survives_audit_body_capture(monkeypatch):
|
||||
log_audit_event = AsyncMock()
|
||||
monkeypatch.setattr(audit_middleware, "log_audit_event", log_audit_event)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(AuditMiddleware)
|
||||
|
||||
@app.post("/exports")
|
||||
async def export(payload: dict[str, str]) -> StreamingResponse:
|
||||
assert payload == {"format": "xlsx"}
|
||||
return StreamingResponse(
|
||||
BytesIO(b"xlsx"),
|
||||
media_type=(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
),
|
||||
)
|
||||
|
||||
response = TestClient(app, raise_server_exceptions=False).post(
|
||||
"/exports",
|
||||
json={"format": "xlsx"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"xlsx"
|
||||
log_audit_event.assert_awaited_once()
|
||||
@@ -0,0 +1,325 @@
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import build_test_app, install_stub, load_module_from_path
|
||||
|
||||
|
||||
class NotFoundError(LookupError):
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ConflictError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _scheme(**overrides):
|
||||
value = {
|
||||
"id": 7,
|
||||
"scheme_name": "北区测压点",
|
||||
"sensor_number": 2,
|
||||
"min_diameter": 300,
|
||||
"username": "alice",
|
||||
"create_time": datetime(2026, 7, 30, 8, 0, tzinfo=timezone.utc),
|
||||
"sensor_location": ["J1", "J2"],
|
||||
"sensor_points": [
|
||||
{
|
||||
"node_id": "J1",
|
||||
"project_x": 13500000.0,
|
||||
"project_y": 3600000.0,
|
||||
"map_x": 13500000.0,
|
||||
"map_y": 3600000.0,
|
||||
"longitude": 121.0,
|
||||
"latitude": 31.0,
|
||||
"elevation": 4.5,
|
||||
},
|
||||
{
|
||||
"node_id": "J2",
|
||||
"project_x": 13500100.0,
|
||||
"project_y": 3600100.0,
|
||||
"map_x": 13500100.0,
|
||||
"map_y": 3600100.0,
|
||||
"longitude": 121.001,
|
||||
"latitude": 31.001,
|
||||
"elevation": 5.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
def _load_module(monkeypatch):
|
||||
install_stub(monkeypatch, "app.algorithms", package=True)
|
||||
install_stub(
|
||||
monkeypatch,
|
||||
"app.algorithms.sensor",
|
||||
{
|
||||
"pressure_sensor_placement_kmeans": lambda **kwargs: {"id": 7},
|
||||
"pressure_sensor_placement_sensitivity": lambda **kwargs: {"id": 7},
|
||||
},
|
||||
)
|
||||
install_stub(monkeypatch, "app.auth", package=True)
|
||||
|
||||
async def current_user():
|
||||
return SimpleNamespace(
|
||||
username="alice",
|
||||
role="user",
|
||||
is_superuser=False,
|
||||
)
|
||||
|
||||
install_stub(
|
||||
monkeypatch,
|
||||
"app.auth.metadata_dependencies",
|
||||
{"get_current_metadata_user": current_user},
|
||||
)
|
||||
|
||||
class ProjectContext:
|
||||
def __init__(self, project_code: str):
|
||||
self.project_code = project_code
|
||||
|
||||
async def project_context():
|
||||
return ProjectContext("tjwater")
|
||||
|
||||
install_stub(
|
||||
monkeypatch,
|
||||
"app.auth.project_dependencies",
|
||||
{
|
||||
"ProjectContext": ProjectContext,
|
||||
"get_project_context": project_context,
|
||||
},
|
||||
)
|
||||
install_stub(monkeypatch, "app.services", package=True)
|
||||
install_stub(
|
||||
monkeypatch,
|
||||
"app.services.sensor_placement",
|
||||
{
|
||||
"SensorPlacementConflictError": ConflictError,
|
||||
"SensorPlacementNotFoundError": NotFoundError,
|
||||
"SensorPlacementValidationError": ValidationError,
|
||||
"build_sensor_placement_workbook": lambda **kwargs: BytesIO(b"xlsx"),
|
||||
"can_edit_sensor_placement": (
|
||||
lambda user, scheme: user.username == scheme["username"]
|
||||
or user.role == "admin"
|
||||
or user.is_superuser
|
||||
),
|
||||
"get_sensor_placement_scheme": lambda network, scheme_id: _scheme(
|
||||
id=scheme_id
|
||||
),
|
||||
"update_sensor_placement_scheme": (
|
||||
lambda network, scheme_id, **kwargs: _scheme(
|
||||
id=scheme_id,
|
||||
sensor_location=kwargs["sensor_location"],
|
||||
sensor_number=len(kwargs["sensor_location"]),
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
return load_module_from_path(
|
||||
"tests_sensor_placement_endpoints_module",
|
||||
"app/api/v1/endpoints/sensor_placement.py",
|
||||
)
|
||||
|
||||
|
||||
def _client(module, user=None):
|
||||
app = build_test_app(module.router, "/api/v1")
|
||||
if user is None:
|
||||
user = SimpleNamespace(
|
||||
username="alice",
|
||||
role="user",
|
||||
is_superuser=False,
|
||||
)
|
||||
app.dependency_overrides[module.get_current_metadata_user] = lambda: user
|
||||
app.dependency_overrides[module.get_project_context] = lambda: (
|
||||
module.ProjectContext("tjwater")
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_optimize_returns_created_scheme(monkeypatch):
|
||||
module = _load_module(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
def optimize(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"id": 7}
|
||||
|
||||
monkeypatch.setattr(module, "pressure_sensor_placement_kmeans", optimize)
|
||||
response = _client(module).post(
|
||||
"/api/v1/sensor-placement-schemes/optimize",
|
||||
json={
|
||||
"network": "tjwater",
|
||||
"scheme_name": "北区测压点",
|
||||
"sensor_type": "pressure",
|
||||
"method": "kmeans",
|
||||
"sensor_count": 2,
|
||||
"min_diameter": 300,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["sensor_location"] == ["J1", "J2"]
|
||||
assert captured["username"] == "alice"
|
||||
|
||||
|
||||
def test_optimize_rejects_unsupported_sensor_type(monkeypatch):
|
||||
module = _load_module(monkeypatch)
|
||||
response = _client(module).post(
|
||||
"/api/v1/sensor-placement-schemes/optimize",
|
||||
json={
|
||||
"network": "tjwater",
|
||||
"scheme_name": "北区测流点",
|
||||
"sensor_type": "flow",
|
||||
"method": "kmeans",
|
||||
"sensor_count": 2,
|
||||
"min_diameter": 300,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_optimize_rejects_network_outside_project_context(monkeypatch):
|
||||
module = _load_module(monkeypatch)
|
||||
response = _client(module).post(
|
||||
"/api/v1/sensor-placement-schemes/optimize",
|
||||
json={
|
||||
"network": "other_project",
|
||||
"scheme_name": "越权方案",
|
||||
"sensor_type": "pressure",
|
||||
"method": "kmeans",
|
||||
"sensor_count": 2,
|
||||
"min_diameter": 300,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_optimize_rejects_network_path_traversal(monkeypatch):
|
||||
module = _load_module(monkeypatch)
|
||||
response = _client(module).post(
|
||||
"/api/v1/sensor-placement-schemes/optimize",
|
||||
json={
|
||||
"network": "../other_project",
|
||||
"scheme_name": "非法路径",
|
||||
"sensor_type": "pressure",
|
||||
"method": "kmeans",
|
||||
"sensor_count": 2,
|
||||
"min_diameter": 300,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_optimize_rejects_unbounded_sensor_count(monkeypatch):
|
||||
module = _load_module(monkeypatch)
|
||||
response = _client(module).post(
|
||||
"/api/v1/sensor-placement-schemes/optimize",
|
||||
json={
|
||||
"network": "tjwater",
|
||||
"scheme_name": "超大方案",
|
||||
"sensor_type": "pressure",
|
||||
"method": "kmeans",
|
||||
"sensor_count": 201,
|
||||
"min_diameter": 300,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_update_rejects_non_owner(monkeypatch):
|
||||
module = _load_module(monkeypatch)
|
||||
response = _client(
|
||||
module,
|
||||
SimpleNamespace(username="bob", role="user", is_superuser=False),
|
||||
).put(
|
||||
"/api/v1/sensor-placement-schemes/7",
|
||||
params={"network": "tjwater"},
|
||||
json={
|
||||
"expected_sensor_location": ["J1", "J2"],
|
||||
"sensor_location": ["J1", "J3"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_admin_can_overwrite_scheme(monkeypatch):
|
||||
module = _load_module(monkeypatch)
|
||||
response = _client(
|
||||
module,
|
||||
SimpleNamespace(username="ops", role="admin", is_superuser=False),
|
||||
).put(
|
||||
"/api/v1/sensor-placement-schemes/7",
|
||||
params={"network": "tjwater"},
|
||||
json={
|
||||
"expected_sensor_location": ["J1", "J2"],
|
||||
"sensor_location": ["J1", "J3"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["sensor_number"] == 2
|
||||
assert response.json()["sensor_location"] == ["J1", "J3"]
|
||||
|
||||
|
||||
def test_update_maps_concurrent_change_to_409(monkeypatch):
|
||||
module = _load_module(monkeypatch)
|
||||
|
||||
def conflict(*args, **kwargs):
|
||||
raise ConflictError("方案已被其他用户修改,请重新加载")
|
||||
|
||||
monkeypatch.setattr(module, "update_sensor_placement_scheme", conflict)
|
||||
response = _client(module).put(
|
||||
"/api/v1/sensor-placement-schemes/7",
|
||||
params={"network": "tjwater"},
|
||||
json={
|
||||
"expected_sensor_location": ["J1", "J2"],
|
||||
"sensor_location": ["J1", "J3"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "重新加载" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_update_rejects_duplicate_nodes_before_service(monkeypatch):
|
||||
module = _load_module(monkeypatch)
|
||||
response = _client(module).put(
|
||||
"/api/v1/sensor-placement-schemes/7",
|
||||
params={"network": "tjwater"},
|
||||
json={
|
||||
"expected_sensor_location": ["J1", "J2"],
|
||||
"sensor_location": ["J1", "J1"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_export_returns_xlsx_download(monkeypatch):
|
||||
module = _load_module(monkeypatch)
|
||||
response = _client(module).post(
|
||||
"/api/v1/sensor-placement-schemes/7/exports/excel",
|
||||
params={"network": "tjwater"},
|
||||
json={
|
||||
"sensor_location": ["J1", "J2"],
|
||||
"adjustment_status": {"J1": "original", "J2": "replaced"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"xlsx"
|
||||
assert response.headers["content-type"].startswith(
|
||||
"application/vnd.openxmlformats-officedocument"
|
||||
)
|
||||
assert "filename*=UTF-8" in response.headers["content-disposition"]
|
||||
@@ -0,0 +1,188 @@
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from app.native.wndb import s42_sensor_placement
|
||||
from app.services import sensor_placement
|
||||
|
||||
|
||||
def _mock_project_cursor(monkeypatch):
|
||||
cursor = MagicMock()
|
||||
connection = MagicMock()
|
||||
connection.cursor.return_value.__enter__.return_value = cursor
|
||||
connection_context = MagicMock()
|
||||
connection_context.__enter__.return_value = connection
|
||||
monkeypatch.setattr(
|
||||
s42_sensor_placement,
|
||||
"project_connection",
|
||||
lambda _network: connection_context,
|
||||
)
|
||||
return cursor
|
||||
|
||||
|
||||
def test_build_workbook_contains_engineering_columns(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
sensor_placement,
|
||||
"_sensor_points",
|
||||
lambda network, locations: [
|
||||
{
|
||||
"node_id": "J1",
|
||||
"project_x": 13500000.0,
|
||||
"project_y": 3600000.0,
|
||||
"map_x": 13500000.0,
|
||||
"map_y": 3600000.0,
|
||||
"longitude": 121.0,
|
||||
"latitude": 31.0,
|
||||
"elevation": 4.5,
|
||||
}
|
||||
],
|
||||
)
|
||||
scheme = {
|
||||
"id": 7,
|
||||
"scheme_name": "北区测压点",
|
||||
"sensor_number": 2,
|
||||
"min_diameter": 300,
|
||||
"username": "alice",
|
||||
"create_time": datetime(2026, 7, 30, tzinfo=timezone.utc),
|
||||
"sensor_location": ["J1", "J2"],
|
||||
}
|
||||
|
||||
output = sensor_placement.build_sensor_placement_workbook(
|
||||
network="tjwater",
|
||||
scheme=scheme,
|
||||
sensor_location=["J1"],
|
||||
adjustment_status={"J1": "replaced"},
|
||||
)
|
||||
workbook = load_workbook(output)
|
||||
|
||||
assert workbook.sheetnames == ["方案信息", "监测点清单"]
|
||||
headers = [cell.value for cell in workbook["监测点清单"][1]]
|
||||
assert headers == [
|
||||
"序号",
|
||||
"节点 ID",
|
||||
"经度",
|
||||
"纬度",
|
||||
"工程 X",
|
||||
"工程 Y",
|
||||
"地图 X",
|
||||
"地图 Y",
|
||||
"高程",
|
||||
"调整状态",
|
||||
]
|
||||
assert workbook["监测点清单"]["J2"].value == "替换"
|
||||
assert workbook["方案信息"]["B8"].value == "未保存草稿"
|
||||
|
||||
|
||||
def test_sensor_points_keep_engineering_coordinates_and_transform_map_coordinates(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
sensor_placement.wndb,
|
||||
"get_sensor_placement_nodes",
|
||||
lambda network, node_ids: [
|
||||
{
|
||||
"node_id": "J1",
|
||||
"project_x": 3038.94,
|
||||
"project_y": -34446.59,
|
||||
"map_x": 13525191.530279,
|
||||
"map_y": 3622984.760237,
|
||||
"elevation": 4.5,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
point = sensor_placement._sensor_points("tjwater", ["J1"])[0]
|
||||
|
||||
assert point["project_x"] == 3038.94
|
||||
assert point["project_y"] == -34446.59
|
||||
assert point["longitude"] == pytest.approx(121.498863, abs=1e-6)
|
||||
assert point["latitude"] == pytest.approx(30.924784, abs=1e-6)
|
||||
|
||||
|
||||
def test_update_validates_nodes_before_write(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
sensor_placement.wndb,
|
||||
"get_sensor_placement_nodes",
|
||||
lambda network, node_ids: [],
|
||||
)
|
||||
|
||||
try:
|
||||
sensor_placement.update_sensor_placement_scheme(
|
||||
"tjwater",
|
||||
7,
|
||||
expected_sensor_location=["J1"],
|
||||
sensor_location=["missing"],
|
||||
)
|
||||
except sensor_placement.SensorPlacementValidationError as exc:
|
||||
assert "missing" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected invalid node to be rejected")
|
||||
|
||||
|
||||
def test_sensor_nodes_use_materialized_web_mercator_geometry(monkeypatch):
|
||||
cursor = _mock_project_cursor(monkeypatch)
|
||||
cursor.fetchall.return_value = []
|
||||
|
||||
s42_sensor_placement.get_sensor_placement_nodes("tjwater", ["J1"])
|
||||
|
||||
query = cursor.execute.call_args.args[0]
|
||||
assert "geo_junctions_mat" in query
|
||||
assert "ST_X(c.coord)" in query
|
||||
assert "ST_Y(c.coord)" in query
|
||||
assert "ST_X(gj.geom)" in query
|
||||
assert "ST_Y(gj.geom)" in query
|
||||
|
||||
|
||||
def test_workbook_escapes_formula_in_scheme_metadata(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
sensor_placement,
|
||||
"_sensor_points",
|
||||
lambda network, locations: [
|
||||
{
|
||||
"node_id": "J1",
|
||||
"project_x": 3038.94,
|
||||
"project_y": -34446.59,
|
||||
"map_x": 13525191.53,
|
||||
"map_y": 3622984.76,
|
||||
"longitude": 121.49,
|
||||
"latitude": 30.92,
|
||||
"elevation": 4.5,
|
||||
}
|
||||
],
|
||||
)
|
||||
output = sensor_placement.build_sensor_placement_workbook(
|
||||
network="tjwater",
|
||||
scheme={
|
||||
"scheme_name": "=1+1",
|
||||
"sensor_location": ["J1"],
|
||||
"min_diameter": 300,
|
||||
"username": "alice",
|
||||
"create_time": datetime(2026, 7, 30, tzinfo=timezone.utc),
|
||||
},
|
||||
sensor_location=["J1"],
|
||||
adjustment_status={},
|
||||
)
|
||||
|
||||
workbook = load_workbook(output, data_only=False)
|
||||
assert workbook["方案信息"]["B2"].value == "'=1+1"
|
||||
assert workbook["方案信息"]["B2"].data_type == "s"
|
||||
|
||||
|
||||
def test_create_sensor_placement_returns_inserted_record(monkeypatch):
|
||||
cursor = _mock_project_cursor(monkeypatch)
|
||||
cursor.fetchone.return_value = {"id": 7, "sensor_location": ["J1", "J2"]}
|
||||
|
||||
created = s42_sensor_placement.create_sensor_placement(
|
||||
"tjwater",
|
||||
scheme_name="北区测压点",
|
||||
min_diameter=300,
|
||||
username="alice",
|
||||
sensor_location=["J1", "J2"],
|
||||
)
|
||||
|
||||
assert created["id"] == 7
|
||||
query, parameters = cursor.execute.call_args.args
|
||||
assert "INSERT INTO sensor_placement" in query
|
||||
assert parameters == ("北区测压点", 2, 300, "alice", ["J1", "J2"])
|
||||
Reference in New Issue
Block a user