feat(sensor): 返回候选节点最大管径
This commit is contained in:
@@ -2,7 +2,7 @@ import logging
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
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 fastapi.responses import StreamingResponse
|
||||||
from starlette.concurrency import run_in_threadpool
|
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.metadata_dependencies import get_current_metadata_user
|
||||||
from app.auth.project_dependencies import ProjectContext, get_project_context
|
from app.auth.project_dependencies import ProjectContext, get_project_context
|
||||||
from app.domain.schemas.sensor_placement import (
|
from app.domain.schemas.sensor_placement import (
|
||||||
|
SensorPointResponse,
|
||||||
SensorPlacementExportRequest,
|
SensorPlacementExportRequest,
|
||||||
SensorPlacementOptimizeRequest,
|
SensorPlacementOptimizeRequest,
|
||||||
SensorPlacementSchemeResponse,
|
SensorPlacementSchemeResponse,
|
||||||
@@ -24,6 +25,7 @@ from app.services.sensor_placement import (
|
|||||||
SensorPlacementValidationError,
|
SensorPlacementValidationError,
|
||||||
build_sensor_placement_workbook,
|
build_sensor_placement_workbook,
|
||||||
can_edit_sensor_placement,
|
can_edit_sensor_placement,
|
||||||
|
get_sensor_placement_candidate,
|
||||||
get_sensor_placement_scheme,
|
get_sensor_placement_scheme,
|
||||||
update_sensor_placement_scheme,
|
update_sensor_placement_scheme,
|
||||||
)
|
)
|
||||||
@@ -94,6 +96,25 @@ def _get_scheme_response(
|
|||||||
raise _service_http_error(exc) from exc
|
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(
|
@router.post(
|
||||||
"/sensor-placement-optimization-runs",
|
"/sensor-placement-optimization-runs",
|
||||||
response_model=SensorPlacementSchemeResponse,
|
response_model=SensorPlacementSchemeResponse,
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ class SensorPlacementExportRequest(BaseModel):
|
|||||||
|
|
||||||
class SensorPointResponse(BaseModel):
|
class SensorPointResponse(BaseModel):
|
||||||
node_id: str
|
node_id: str
|
||||||
|
max_pipe_diameter: float | None = Field(
|
||||||
|
...,
|
||||||
|
description="节点关联管道的最大管径,单位:毫米",
|
||||||
|
)
|
||||||
project_x: float
|
project_x: float
|
||||||
project_y: float
|
project_y: float
|
||||||
map_x: float
|
map_x: float
|
||||||
|
|||||||
@@ -66,8 +66,22 @@ def get_sensor_placement_nodes(
|
|||||||
with conn.cursor(row_factory=dict_row) as cur:
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
cur.execute(
|
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)
|
SELECT DISTINCT ON (gj.id)
|
||||||
gj.id AS node_id,
|
gj.id AS node_id,
|
||||||
|
ipd.max_pipe_diameter,
|
||||||
gj.elevation,
|
gj.elevation,
|
||||||
ST_X(c.coord) AS project_x,
|
ST_X(c.coord) AS project_x,
|
||||||
ST_Y(c.coord) AS project_y,
|
ST_Y(c.coord) AS project_y,
|
||||||
@@ -75,10 +89,11 @@ def get_sensor_placement_nodes(
|
|||||||
ST_Y(gj.geom) AS map_y
|
ST_Y(gj.geom) AS map_y
|
||||||
FROM geo_junctions_mat AS gj
|
FROM geo_junctions_mat AS gj
|
||||||
JOIN coordinates AS c ON c.node = gj.id
|
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)
|
WHERE gj.id = ANY(%s)
|
||||||
ORDER BY gj.id
|
ORDER BY gj.id
|
||||||
""",
|
""",
|
||||||
(node_ids,),
|
(node_ids, node_ids, node_ids),
|
||||||
)
|
)
|
||||||
return list(cur.fetchall())
|
return list(cur.fetchall())
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ def _sensor_points(
|
|||||||
points.append(
|
points.append(
|
||||||
{
|
{
|
||||||
"node_id": node_id,
|
"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_x": project_x,
|
||||||
"project_y": project_y,
|
"project_y": project_y,
|
||||||
"map_x": map_x,
|
"map_x": map_x,
|
||||||
@@ -92,6 +97,15 @@ def _sensor_points(
|
|||||||
return 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(
|
def validate_sensor_placement_nodes(
|
||||||
network: str,
|
network: str,
|
||||||
sensor_location: list[str],
|
sensor_location: list[str],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "9cd5b962e9556ec227c52d0dc7d4ef4af562dcea86e16877c923c37de0f4f704"
|
"sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2598,6 +2598,18 @@
|
|||||||
"title": "Map Y",
|
"title": "Map Y",
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
"max_pipe_diameter": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "节点关联管道的最大管径,单位:毫米",
|
||||||
|
"title": "Max Pipe Diameter"
|
||||||
|
},
|
||||||
"node_id": {
|
"node_id": {
|
||||||
"title": "Node Id",
|
"title": "Node Id",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2613,6 +2625,7 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"node_id",
|
"node_id",
|
||||||
|
"max_pipe_diameter",
|
||||||
"project_x",
|
"project_x",
|
||||||
"project_y",
|
"project_y",
|
||||||
"map_x",
|
"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": {
|
"/api/v1/sensor-placement-optimization-runs": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "post_sensor_placement_optimization_runs",
|
"operationId": "post_sensor_placement_optimization_runs",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ def _scheme(**overrides):
|
|||||||
"sensor_points": [
|
"sensor_points": [
|
||||||
{
|
{
|
||||||
"node_id": "J1",
|
"node_id": "J1",
|
||||||
|
"max_pipe_diameter": 400.0,
|
||||||
"project_x": 13500000.0,
|
"project_x": 13500000.0,
|
||||||
"project_y": 3600000.0,
|
"project_y": 3600000.0,
|
||||||
"map_x": 13500000.0,
|
"map_x": 13500000.0,
|
||||||
@@ -42,6 +43,7 @@ def _scheme(**overrides):
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"node_id": "J2",
|
"node_id": "J2",
|
||||||
|
"max_pipe_diameter": 300.0,
|
||||||
"project_x": 13500100.0,
|
"project_x": 13500100.0,
|
||||||
"project_y": 3600100.0,
|
"project_y": 3600100.0,
|
||||||
"map_x": 13500100.0,
|
"map_x": 13500100.0,
|
||||||
@@ -114,6 +116,9 @@ def _load_module(monkeypatch):
|
|||||||
"get_sensor_placement_scheme": lambda network, scheme_id: _scheme(
|
"get_sensor_placement_scheme": lambda network, scheme_id: _scheme(
|
||||||
id=scheme_id
|
id=scheme_id
|
||||||
),
|
),
|
||||||
|
"get_sensor_placement_candidate": (
|
||||||
|
lambda network, node_id: _scheme()["sensor_points"][0]
|
||||||
|
),
|
||||||
"update_sensor_placement_scheme": (
|
"update_sensor_placement_scheme": (
|
||||||
lambda network, scheme_id, **kwargs: _scheme(
|
lambda network, scheme_id, **kwargs: _scheme(
|
||||||
id=scheme_id,
|
id=scheme_id,
|
||||||
@@ -170,6 +175,18 @@ def test_optimize_returns_created_scheme(monkeypatch):
|
|||||||
assert captured["username"] == "alice"
|
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):
|
def test_optimize_rejects_unsupported_sensor_type(monkeypatch):
|
||||||
module = _load_module(monkeypatch)
|
module = _load_module(monkeypatch)
|
||||||
response = _client(module).post(
|
response = _client(module).post(
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def test_build_workbook_contains_engineering_columns(monkeypatch):
|
|||||||
lambda network, locations: [
|
lambda network, locations: [
|
||||||
{
|
{
|
||||||
"node_id": "J1",
|
"node_id": "J1",
|
||||||
|
"max_pipe_diameter": 400.0,
|
||||||
"project_x": 13500000.0,
|
"project_x": 13500000.0,
|
||||||
"project_y": 3600000.0,
|
"project_y": 3600000.0,
|
||||||
"map_x": 13500000.0,
|
"map_x": 13500000.0,
|
||||||
@@ -75,7 +76,7 @@ def test_build_workbook_contains_engineering_columns(monkeypatch):
|
|||||||
assert workbook["方案信息"]["B8"].value == "未保存草稿"
|
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,
|
||||||
):
|
):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -84,6 +85,7 @@ def test_sensor_points_keep_engineering_coordinates_and_transform_map_coordinate
|
|||||||
lambda network, node_ids: [
|
lambda network, node_ids: [
|
||||||
{
|
{
|
||||||
"node_id": "J1",
|
"node_id": "J1",
|
||||||
|
"max_pipe_diameter": 400.0,
|
||||||
"project_x": 3038.94,
|
"project_x": 3038.94,
|
||||||
"project_y": -34446.59,
|
"project_y": -34446.59,
|
||||||
"map_x": 13525191.530279,
|
"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_x"] == 3038.94
|
||||||
assert point["project_y"] == -34446.59
|
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["longitude"] == pytest.approx(121.498863, abs=1e-6)
|
||||||
assert point["latitude"] == pytest.approx(30.924784, 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_Y(c.coord)" in query
|
||||||
assert "ST_X(gj.geom)" in query
|
assert "ST_X(gj.geom)" in query
|
||||||
assert "ST_Y(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):
|
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: [
|
lambda network, locations: [
|
||||||
{
|
{
|
||||||
"node_id": "J1",
|
"node_id": "J1",
|
||||||
|
"max_pipe_diameter": 400.0,
|
||||||
"project_x": 3038.94,
|
"project_x": 3038.94,
|
||||||
"project_y": -34446.59,
|
"project_y": -34446.59,
|
||||||
"map_x": 13525191.53,
|
"map_x": 13525191.53,
|
||||||
|
|||||||
Reference in New Issue
Block a user