feat(sensor): 返回候选节点最大管径

This commit is contained in:
2026-08-03 19:00:00 +08:00
parent b0e9d480ef
commit 1432934f12
8 changed files with 203 additions and 5 deletions
+22 -1
View File
@@ -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,
+4
View File
@@ -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
+16 -1
View File
@@ -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())
+14
View File
@@ -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],
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": { "contracts": {
"server": { "server": {
"file": "server-v1.openapi.json", "file": "server-v1.openapi.json",
"sha256": "9cd5b962e9556ec227c52d0dc7d4ef4af562dcea86e16877c923c37de0f4f704" "sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f"
} }
} }
} }
+121
View File
@@ -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(
+8 -2
View File
@@ -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,