feat(sensor): 返回候选节点最大管径
This commit is contained in:
@@ -2,7 +2,7 @@ import logging
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.algorithms.sensor import (
|
||||
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 (
|
||||
SensorPointResponse,
|
||||
SensorPlacementExportRequest,
|
||||
SensorPlacementOptimizeRequest,
|
||||
SensorPlacementSchemeResponse,
|
||||
@@ -24,6 +25,7 @@ from app.services.sensor_placement import (
|
||||
SensorPlacementValidationError,
|
||||
build_sensor_placement_workbook,
|
||||
can_edit_sensor_placement,
|
||||
get_sensor_placement_candidate,
|
||||
get_sensor_placement_scheme,
|
||||
update_sensor_placement_scheme,
|
||||
)
|
||||
@@ -94,6 +96,25 @@ def _get_scheme_response(
|
||||
raise _service_http_error(exc) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sensor-placement-candidates/{node_id}",
|
||||
response_model=SensorPointResponse,
|
||||
summary="获取监测点候选节点详情",
|
||||
)
|
||||
async def get_sensor_placement_candidate_detail(
|
||||
node_id: str = Path(..., min_length=1, max_length=32),
|
||||
project_context: ProjectContext = Depends(get_project_context),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await run_in_threadpool(
|
||||
get_sensor_placement_candidate,
|
||||
project_context.project_code,
|
||||
node_id,
|
||||
)
|
||||
except SensorPlacementValidationError as exc:
|
||||
raise _service_http_error(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sensor-placement-optimization-runs",
|
||||
response_model=SensorPlacementSchemeResponse,
|
||||
|
||||
@@ -67,6 +67,10 @@ class SensorPlacementExportRequest(BaseModel):
|
||||
|
||||
class SensorPointResponse(BaseModel):
|
||||
node_id: str
|
||||
max_pipe_diameter: float | None = Field(
|
||||
...,
|
||||
description="节点关联管道的最大管径,单位:毫米",
|
||||
)
|
||||
project_x: float
|
||||
project_y: float
|
||||
map_x: float
|
||||
|
||||
@@ -66,8 +66,22 @@ def get_sensor_placement_nodes(
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
WITH incident_pipe_diameters AS (
|
||||
SELECT node_id, MAX(diameter) AS max_pipe_diameter
|
||||
FROM (
|
||||
SELECT node1 AS node_id, diameter
|
||||
FROM pipes
|
||||
WHERE node1 = ANY(%s)
|
||||
UNION ALL
|
||||
SELECT node2 AS node_id, diameter
|
||||
FROM pipes
|
||||
WHERE node2 = ANY(%s)
|
||||
) AS incident_pipes
|
||||
GROUP BY node_id
|
||||
)
|
||||
SELECT DISTINCT ON (gj.id)
|
||||
gj.id AS node_id,
|
||||
ipd.max_pipe_diameter,
|
||||
gj.elevation,
|
||||
ST_X(c.coord) AS project_x,
|
||||
ST_Y(c.coord) AS project_y,
|
||||
@@ -75,10 +89,11 @@ def get_sensor_placement_nodes(
|
||||
ST_Y(gj.geom) AS map_y
|
||||
FROM geo_junctions_mat AS gj
|
||||
JOIN coordinates AS c ON c.node = gj.id
|
||||
LEFT JOIN incident_pipe_diameters AS ipd ON ipd.node_id = gj.id
|
||||
WHERE gj.id = ANY(%s)
|
||||
ORDER BY gj.id
|
||||
""",
|
||||
(node_ids,),
|
||||
(node_ids, node_ids, node_ids),
|
||||
)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
@@ -80,6 +80,11 @@ def _sensor_points(
|
||||
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,
|
||||
@@ -92,6 +97,15 @@ def _sensor_points(
|
||||
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],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"contracts": {
|
||||
"server": {
|
||||
"file": "server-v1.openapi.json",
|
||||
"sha256": "9cd5b962e9556ec227c52d0dc7d4ef4af562dcea86e16877c923c37de0f4f704"
|
||||
"sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2598,6 +2598,18 @@
|
||||
"title": "Map Y",
|
||||
"type": "number"
|
||||
},
|
||||
"max_pipe_diameter": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "节点关联管道的最大管径,单位:毫米",
|
||||
"title": "Max Pipe Diameter"
|
||||
},
|
||||
"node_id": {
|
||||
"title": "Node Id",
|
||||
"type": "string"
|
||||
@@ -2613,6 +2625,7 @@
|
||||
},
|
||||
"required": [
|
||||
"node_id",
|
||||
"max_pipe_diameter",
|
||||
"project_x",
|
||||
"project_y",
|
||||
"map_x",
|
||||
@@ -35546,6 +35559,114 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/sensor-placement-candidates/{node_id}": {
|
||||
"get": {
|
||||
"operationId": "get_sensor_placement_candidates_node_id",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "node_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"maxLength": 32,
|
||||
"minLength": 1,
|
||||
"title": "Node Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Project-Id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "X-Project-Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SensorPointResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Insufficient permission"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dependency unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2PasswordBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "获取监测点候选节点详情",
|
||||
"tags": [
|
||||
"Sensor Placement"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/sensor-placement-optimization-runs": {
|
||||
"post": {
|
||||
"operationId": "post_sensor_placement_optimization_runs",
|
||||
|
||||
@@ -32,6 +32,7 @@ def _scheme(**overrides):
|
||||
"sensor_points": [
|
||||
{
|
||||
"node_id": "J1",
|
||||
"max_pipe_diameter": 400.0,
|
||||
"project_x": 13500000.0,
|
||||
"project_y": 3600000.0,
|
||||
"map_x": 13500000.0,
|
||||
@@ -42,6 +43,7 @@ def _scheme(**overrides):
|
||||
},
|
||||
{
|
||||
"node_id": "J2",
|
||||
"max_pipe_diameter": 300.0,
|
||||
"project_x": 13500100.0,
|
||||
"project_y": 3600100.0,
|
||||
"map_x": 13500100.0,
|
||||
@@ -114,6 +116,9 @@ def _load_module(monkeypatch):
|
||||
"get_sensor_placement_scheme": lambda network, scheme_id: _scheme(
|
||||
id=scheme_id
|
||||
),
|
||||
"get_sensor_placement_candidate": (
|
||||
lambda network, node_id: _scheme()["sensor_points"][0]
|
||||
),
|
||||
"update_sensor_placement_scheme": (
|
||||
lambda network, scheme_id, **kwargs: _scheme(
|
||||
id=scheme_id,
|
||||
@@ -170,6 +175,18 @@ def test_optimize_returns_created_scheme(monkeypatch):
|
||||
assert captured["username"] == "alice"
|
||||
|
||||
|
||||
def test_get_candidate_returns_maximum_incident_pipe_diameter(monkeypatch):
|
||||
module = _load_module(monkeypatch)
|
||||
|
||||
response = _client(module).get(
|
||||
"/api/v1/sensor-placement-candidates/J1",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["node_id"] == "J1"
|
||||
assert response.json()["max_pipe_diameter"] == 400.0
|
||||
|
||||
|
||||
def test_optimize_rejects_unsupported_sensor_type(monkeypatch):
|
||||
module = _load_module(monkeypatch)
|
||||
response = _client(module).post(
|
||||
|
||||
@@ -29,6 +29,7 @@ def test_build_workbook_contains_engineering_columns(monkeypatch):
|
||||
lambda network, locations: [
|
||||
{
|
||||
"node_id": "J1",
|
||||
"max_pipe_diameter": 400.0,
|
||||
"project_x": 13500000.0,
|
||||
"project_y": 3600000.0,
|
||||
"map_x": 13500000.0,
|
||||
@@ -75,7 +76,7 @@ def test_build_workbook_contains_engineering_columns(monkeypatch):
|
||||
assert workbook["方案信息"]["B8"].value == "未保存草稿"
|
||||
|
||||
|
||||
def test_sensor_points_keep_engineering_coordinates_and_transform_map_coordinates(
|
||||
def test_candidate_keeps_engineering_coordinates_and_transforms_map_coordinates(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
@@ -84,6 +85,7 @@ def test_sensor_points_keep_engineering_coordinates_and_transform_map_coordinate
|
||||
lambda network, node_ids: [
|
||||
{
|
||||
"node_id": "J1",
|
||||
"max_pipe_diameter": 400.0,
|
||||
"project_x": 3038.94,
|
||||
"project_y": -34446.59,
|
||||
"map_x": 13525191.530279,
|
||||
@@ -93,10 +95,11 @@ def test_sensor_points_keep_engineering_coordinates_and_transform_map_coordinate
|
||||
],
|
||||
)
|
||||
|
||||
point = sensor_placement._sensor_points("tjwater", ["J1"])[0]
|
||||
point = sensor_placement.get_sensor_placement_candidate("tjwater", "J1")
|
||||
|
||||
assert point["project_x"] == 3038.94
|
||||
assert point["project_y"] == -34446.59
|
||||
assert point["max_pipe_diameter"] == 400.0
|
||||
assert point["longitude"] == pytest.approx(121.498863, abs=1e-6)
|
||||
assert point["latitude"] == pytest.approx(30.924784, abs=1e-6)
|
||||
|
||||
@@ -133,6 +136,8 @@ def test_sensor_nodes_use_materialized_web_mercator_geometry(monkeypatch):
|
||||
assert "ST_Y(c.coord)" in query
|
||||
assert "ST_X(gj.geom)" in query
|
||||
assert "ST_Y(gj.geom)" in query
|
||||
assert "MAX(diameter) AS max_pipe_diameter" in query
|
||||
assert cursor.execute.call_args.args[1] == (["J1"], ["J1"], ["J1"])
|
||||
|
||||
|
||||
def test_workbook_escapes_formula_in_scheme_metadata(monkeypatch):
|
||||
@@ -142,6 +147,7 @@ def test_workbook_escapes_formula_in_scheme_metadata(monkeypatch):
|
||||
lambda network, locations: [
|
||||
{
|
||||
"node_id": "J1",
|
||||
"max_pipe_diameter": 400.0,
|
||||
"project_x": 3038.94,
|
||||
"project_y": -34446.59,
|
||||
"map_x": 13525191.53,
|
||||
|
||||
Reference in New Issue
Block a user