feat: add node coverage and improve investigation presentation
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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个完整传感器")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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",
|
||||
"读取当前项目的单个管网元素属性",
|
||||
|
||||
@@ -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()},
|
||||
}
|
||||
@@ -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]]
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user