feat: add node coverage and improve investigation presentation
Generic Container CI/CD / test-build-publish (push) Failing after 1m50s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 1m51s

This commit is contained in:
2026-11-02 15:23:34 +08:00
parent 05183aff93
commit f2cfce8192
25 changed files with 588 additions and 21 deletions
+1
View File
@@ -58,6 +58,7 @@ McpSnapshotSource = Literal[
'run_burst_detection',
'run_burst_location',
'analyze_valve_isolation',
'generate_node_coverage',
'run_leakage_identification',
'get_element_properties',
'list_elements',
+1 -1
View File
@@ -252,7 +252,7 @@ def _undeclared_diagram_node_refs(diagram: InvestigationDiagram | None) -> list[
for node in diagram.node_refs:
escaped = re.escape(node.node_id)
if not re.search(
rf"(?<![A-Za-z0-9_-]){escaped}(?=\s*(?:[\[({{@;]|--|==|-\.|$))",
rf"(?<![A-Za-z0-9_-]){escaped}(?=\s*(?:[\[({{@;]|:::|--|==|-\.|$))",
diagram.mermaid,
re.MULTILINE,
):
+31 -1
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.mcp.models import Id, StrictInput
@@ -19,6 +19,36 @@ class CalculationInput(StrictInput):
)
class NodeCoverageGroup(BaseModel):
model_config = ConfigDict(extra="forbid")
group_id: Id = Field(max_length=80)
title: str = Field(min_length=1, max_length=200)
node_ids: list[Id] = Field(min_length=1, max_length=10000)
class NodeCoverageInput(CalculationInput):
groups: list[NodeCoverageGroup] = Field(min_length=1, max_length=8,
description="已核实的模型节点集合;各组为需要比较的业务或空间集合,可重叠,不接受任意坐标")
concave_ratio: float = Field(default=0.25, ge=0, le=1, allow_inf_nan=False,
description="凹包比例;越接近1越平滑接近凸包,参数只决定示意形状")
buffer_m: float = Field(default=25, gt=0, le=1000, allow_inf_nan=False,
description="展示缓冲距离,米;不是服务半径或影响距离")
split_by_connectivity: bool = Field(default=False, strict=True,
description="需要按模型连接分组时设true;地理分布比较默认false,不能据此认定实际供水关系")
excluded_link_ids: list[Id] = Field(default_factory=list, max_length=200,
description="连通分组时额外视为断开的管段/阀门,例如已有隔离建议中的关阀ID;仅影响绘图分组")
@model_validator(mode="after")
def bounded_groups(self):
if len({group.group_id for group in self.groups}) != len(self.groups):
raise ValueError("coverage group IDs must be distinct")
if len({node for group in self.groups for node in group.node_ids}) > 10000:
raise ValueError("coverage supports at most 10000 distinct nodes")
if self.excluded_link_ids and not self.split_by_connectivity:
raise ValueError("excluded_link_ids requires split_by_connectivity")
return self
class BurstDetectionInput(CalculationInput):
target_time: datetime = Field(description="带时区的侦测时刻;读取此前15天同周期压力数据,不是单小时定位")
sensor_nodes: list[Id] = Field(min_length=5, max_length=20, description="已核实的压力监测节点ID,至少5个完整传感器")
+4
View File
@@ -71,6 +71,10 @@ def _calculate(name: str, parameters: dict[str, Any], network: str, username: st
from app.services.valve_isolation import analyze_valve_isolation
return analyze_valve_isolation(network=network, **parameters)
if name == "generate_node_coverage":
from app.services.node_coverage import generate_node_coverage
return generate_node_coverage(network=network, **parameters)
raise ValueError("Calculation is not registered")
finally:
_slots.release()
+4
View File
@@ -441,6 +441,10 @@ class McpQueryApplication:
"resource_uri": uri,
"preview": "The complete query snapshot is saved in task evidence",
}
from app.mcp.presentation_preview import saved_presentation_preview
presentation = saved_presentation_preview(result)
if presentation is not None:
output["data"]["presentation"] = presentation
output["truncated"] = True
output["data_gaps"] = [
*result.data_gaps,
+2 -1
View File
@@ -6,7 +6,7 @@ from mcp import types
from app.mcp import models as m
from app.mcp import calculation_models as jobs
from app.mcp import extended_models as e
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput, NodeCoverageInput
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
@@ -61,6 +61,7 @@ _specs = [
("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;只提示压力异常,不定位、不证明爆管", BurstDetectionInput, "burst.run", "analysis", False, True),
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;burst_leakage必需,可使用有依据的估计或明确给定的情景假设,不要求现场确诊;本工具不反演总漏水量。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True),
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;候选分析无需等待现场确诊。输出不是污染扩散或水力影响范围;现场操作前须核实阀门映射及可用性,本工具不执行关阀", ValveIsolationInput, "simulation.run", "analysis", False, True),
("generate_node_coverage", "为当前项目已核实的模型节点集合生成空间覆盖示意,适用于候选分布、监测覆盖比较、区域成员、隔离或模拟结果节点等,不限调查任务。支持最多8组、合计10000个不同节点,从真实GIS查询坐标;监测设备、投诉或地址须先关联模型节点。可按连接分组,记录凹包/缓冲参数、缺失节点和display_only;不计算故障、污染、实际停水或服务边界。返回result.map_payload及coverage,任务中用set_overlay引用本次evidence_id、省略data展示;生成不自动操作地图", NodeCoverageInput, "webgis.view", "analysis", False, True),
(
"get_element_properties",
"读取当前项目的单个管网元素属性",
+31
View File
@@ -0,0 +1,31 @@
"""Keep a saved geometry discoverable even when its inline response is shortened."""
from uuid import NAMESPACE_URL, uuid5
from app.mcp.models import QueryResult
def saved_presentation_preview(result: QueryResult) -> dict | None:
if result.source != "generate_node_coverage" or not result.evidence_id:
return None
analysis = result.data.get("result") if isinstance(result.data, dict) else None
if not isinstance(analysis, dict):
return None
payload = analysis.get("map_payload")
if not isinstance(payload, dict) or not payload.get("features"):
return None
coverage = analysis.get("coverage", {})
return {
"command": {
"action": "set_overlay", "mode": "geometry", "evidence_id": result.evidence_id,
"overlay_id": f"node-coverage-{uuid5(NAMESPACE_URL, result.evidence_id)}",
"title": "节点空间覆盖示意",
},
"display_only": True,
"coverage_status": coverage.get("status"),
"groups": {role: {
"node_count": len(group.get("node_ids", [])),
"mapped_node_count": group.get("mapped_node_count", 0),
"missing_node_count": len(group.get("missing_node_ids", [])),
} for role, group in coverage.get("groups", {}).items()},
}
+1
View File
@@ -179,6 +179,7 @@ async def execute_query(
"run_burst_detection": "Pressure anomalies alone do not confirm a burst or locate a pipe.",
"run_burst_location": "Candidate ranking depends on the leakage assumption, model and sensor coverage; field confirmation is required.",
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
"generate_node_coverage": "Derived node coverage for display only; geometry does not establish a fault, contamination, outage or service boundary. Missing GIS positions are reported separately.",
"calculate_demand_allocation": "Pipe-length-weighted allocation only; model demands were not modified. Units follow the current model.",
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas, not detection or confirmed burst location; only requested elements returned.",
}[name]]
+29
View File
@@ -5,6 +5,35 @@ from typing import Any
from ..core.database import read, read_all
def get_node_geographic_positions(name: str, node_ids: list[str]) -> list[Any]:
"""Resolve only the selected project's stored node geometries to WGS84."""
if not node_ids:
return []
return read_all(name, """
SELECT node_id AS id,
ST_X(ST_Transform(geom,4326)) AS longitude,
ST_Y(ST_Transform(geom,4326)) AS latitude
FROM gis.node_geometries
WHERE node_id = ANY(%s) AND geom IS NOT NULL AND NOT ST_IsEmpty(geom)
ORDER BY node_id
""", (node_ids,))
def get_node_coverage_links(name: str, node_ids: list[str]) -> list[Any]:
"""Read the selected subgraph and initial statuses without changing the model."""
if not node_ids:
return []
return read_all(name, """
SELECT l.id, l.start_node_id, l.end_node_id,
COALESCE(s.status::text, p.status::text) AS status
FROM network.links l
LEFT JOIN network.pipes p ON p.link_id = l.id
LEFT JOIN network.link_initial_settings s ON s.link_id = l.id
WHERE l.start_node_id = ANY(%s) AND l.end_node_id = ANY(%s)
ORDER BY l.id
""", (node_ids, node_ids))
def _node_coord_rows(
rows: list[Any],
) -> dict[str, dict[str, Any]]:
+128
View File
@@ -0,0 +1,128 @@
"""Reusable geometric coverage of verified project nodes, independent of business analysis."""
import json
from math import isfinite
from typing import Any
from pyproj import CRS, Transformer
from shapely import MultiPoint, concave_hull, get_num_coordinates
from shapely.geometry import mapping
from shapely.ops import transform, unary_union
from app.native.wndb.gis.network_views import get_node_coverage_links, get_node_geographic_positions
CONCAVE_RATIO = 0.25
BUFFER_METERS = 25.0
SIMPLIFY_METERS = 2.0
MAX_COVERAGE_NODES = 10000
def _components(node_ids: list[str], links: list[dict[str, Any]], closed: set[str]) -> list[list[str]]:
parent = {node_id: node_id for node_id in node_ids}
def root(node: str) -> str:
while parent[node] != node:
parent[node] = parent[parent[node]]
node = parent[node]
return node
for link in links:
start, end = link["start_node_id"], link["end_node_id"]
if link["id"] not in closed and link.get("status") != "CLOSED" and start in parent and end in parent:
parent[root(start)] = root(end)
groups: dict[str, list[str]] = {}
for node_id in node_ids:
groups.setdefault(root(node_id), []).append(node_id)
return list(groups.values())
def build_node_coverage(
groups: list[dict[str, Any]], positions: list[dict[str, Any]],
links: list[dict[str, Any]], *, split_by_connectivity: bool = False,
excluded_link_ids: list[str] | None = None,
concave_ratio: float = CONCAVE_RATIO, buffer_m: float = BUFFER_METERS,
) -> dict[str, Any]:
"""Keep caller-defined comparison groups and derive geometry without assigning business meaning."""
coordinates = {}
for row in positions:
longitude, latitude = row.get("longitude"), row.get("latitude")
if (isinstance(longitude, (int, float)) and not isinstance(longitude, bool)
and isinstance(latitude, (int, float)) and not isinstance(latitude, bool)
and isfinite(longitude) and isfinite(latitude)
and -180 <= longitude <= 180 and -90 < latitude < 90):
coordinates[str(row["id"])] = (longitude, latitude)
features, groups_metadata = [], {}
closed = set(excluded_link_ids or [])
simplify_m = min(SIMPLIFY_METERS, buffer_m / 4)
for group in groups:
group_id, label = group["group_id"], group["title"]
ids = sorted(set(group["node_ids"]))
mapped = [node for node in ids if node in coordinates]
missing = [node for node in ids if node not in coordinates]
metadata = {"node_ids": ids, "mapped_node_count": len(mapped), "missing_node_ids": missing}
groups_metadata[group_id] = {"title": label, **metadata}
metadata = groups_metadata[group_id]
if not mapped:
metadata["component_count"] = 0
continue
points = [coordinates[node] for node in mapped]
longitude = sum(point[0] for point in points) / len(points)
latitude = sum(point[1] for point in points) / len(points)
local = CRS.from_proj4(
f"+proj=aeqd +lat_0={latitude} +lon_0={longitude} +datum=WGS84 +units=m"
)
project = Transformer.from_crs(4326, local, always_xy=True).transform
unproject = Transformer.from_crs(local, 4326, always_xy=True).transform
envelopes = []
components = _components(ids, links, closed) if split_by_connectivity else [ids]
for nodes in components:
component_points = [coordinates[node] for node in nodes if node in coordinates]
if not component_points:
continue
cloud = transform(project, MultiPoint(component_points))
# A point or collinear cloud remains drawable through the same metric buffer.
envelope = concave_hull(cloud, ratio=concave_ratio).buffer(buffer_m)
envelopes.append(envelope)
metadata["component_count"] = len(envelopes)
geometry = unary_union(envelopes).simplify(simplify_m, preserve_topology=True)
if geometry.is_empty or not geometry.is_valid or get_num_coordinates(geometry) > 20000:
raise ValueError("Invalid derived coverage")
feature = {
"type": "Feature", "id": group_id,
"properties": {
"coverage_group": group_id, "label": label, "derived": True, "display_only": True,
"node_count": len(ids), "mapped_node_count": len(mapped),
"missing_node_count": len(missing), "component_count": len(envelopes),
},
"geometry": mapping(transform(unproject, geometry)),
}
features.append(feature)
return {
"status": "partial" if any(group["missing_node_ids"] for group in groups_metadata.values())
else "ready" if features else "empty",
"method": "concave_hull_buffer",
"parameters": {"concave_ratio": concave_ratio, "buffer_m": buffer_m,
"split_by_connectivity": split_by_connectivity,
"excluded_link_ids": sorted(closed),
"simplify_m": simplify_m, "projection": "local_azimuthal_equidistant"},
"output_crs": "EPSG:4326", "display_only": True,
"position_source": "gis.node_geometries",
"boundary": "节点空间覆盖示意;不推断故障、污染、停水、水力影响或用户服务边界。",
"groups": groups_metadata,
"map_payload": {"type": "FeatureCollection", "features": features},
}
def generate_node_coverage(network: str, groups: list[dict[str, Any]], **parameters: Any) -> dict[str, Any]:
node_ids = sorted({node for group in groups for node in group["node_ids"]})
if len(node_ids) > MAX_COVERAGE_NODES:
raise ValueError("Node coverage supports at most 10000 distinct nodes")
positions = get_node_geographic_positions(network, node_ids)
links = get_node_coverage_links(network, node_ids) if parameters.get("split_by_connectivity") else []
coverage = build_node_coverage(groups, positions, links, **parameters)
payload = coverage.pop("map_payload")
if len(json.dumps(payload, ensure_ascii=False).encode("utf-8")) > 400 * 1024:
raise ValueError("Coverage exceeds presentation budget; narrow the node selection")
return {"coverage": coverage, "map_payload": payload}
+7
View File
@@ -51,12 +51,19 @@ def _geometry(value):
def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
"""Use recorded GeoJSON or recorded coordinates; never geocode or infer a boundary."""
data = snapshot.data
# MCP persists its complete QueryResult envelope, unlike direct data queries.
if (isinstance(data, dict) and data.get("source") == snapshot.source
and isinstance(data.get("data"), (dict, list))):
data = data["data"]
candidates = [data]
if isinstance(data, dict):
candidates.extend([data.get("map_payload"), data.get("drawing_payload")])
latest = data.get("latest_analysis")
if isinstance(latest, dict):
candidates.append(latest.get("map_payload"))
result = data.get("result")
if isinstance(result, dict):
candidates.append(result.get("map_payload"))
collection = next((item for item in candidates
if isinstance(item, dict) and item.get("type") == "FeatureCollection"), None)
if collection is not None: