From 1432934f12f099d021e60f2272e6188bc200f8d4 Mon Sep 17 00:00:00 2001 From: Jiang Date: Mon, 3 Aug 2026 19:00:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(sensor):=20=E8=BF=94=E5=9B=9E=E5=80=99?= =?UTF-8?q?=E9=80=89=E8=8A=82=E7=82=B9=E6=9C=80=E5=A4=A7=E7=AE=A1=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/endpoints/sensor_placement.py | 23 +++- app/domain/schemas/sensor_placement.py | 4 + app/native/wndb/s42_sensor_placement.py | 17 ++- app/services/sensor_placement.py | 14 +++ contracts/manifest.json | 2 +- contracts/server-v1.openapi.json | 121 +++++++++++++++++++ tests/api/test_sensor_placement_endpoints.py | 17 +++ tests/unit/test_sensor_placement_service.py | 10 +- 8 files changed, 203 insertions(+), 5 deletions(-) diff --git a/app/api/v1/endpoints/sensor_placement.py b/app/api/v1/endpoints/sensor_placement.py index 61f0b26..5ebdd25 100644 --- a/app/api/v1/endpoints/sensor_placement.py +++ b/app/api/v1/endpoints/sensor_placement.py @@ -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, diff --git a/app/domain/schemas/sensor_placement.py b/app/domain/schemas/sensor_placement.py index 71f6680..a8f503b 100644 --- a/app/domain/schemas/sensor_placement.py +++ b/app/domain/schemas/sensor_placement.py @@ -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 diff --git a/app/native/wndb/s42_sensor_placement.py b/app/native/wndb/s42_sensor_placement.py index c65dc66..38eb57a 100644 --- a/app/native/wndb/s42_sensor_placement.py +++ b/app/native/wndb/s42_sensor_placement.py @@ -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()) diff --git a/app/services/sensor_placement.py b/app/services/sensor_placement.py index 3d44686..8d4f4f7 100644 --- a/app/services/sensor_placement.py +++ b/app/services/sensor_placement.py @@ -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], diff --git a/contracts/manifest.json b/contracts/manifest.json index c11cbce..cd3362f 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "9cd5b962e9556ec227c52d0dc7d4ef4af562dcea86e16877c923c37de0f4f704" + "sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index f6ddeac..cd08f0e 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -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", diff --git a/tests/api/test_sensor_placement_endpoints.py b/tests/api/test_sensor_placement_endpoints.py index 226387c..73b42ef 100644 --- a/tests/api/test_sensor_placement_endpoints.py +++ b/tests/api/test_sensor_placement_endpoints.py @@ -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( diff --git a/tests/unit/test_sensor_placement_service.py b/tests/unit/test_sensor_placement_service.py index b4131aa..e14e06b 100644 --- a/tests/unit/test_sensor_placement_service.py +++ b/tests/unit/test_sensor_placement_service.py @@ -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,