Add project-bound OAuth MCP queries and controlled analysis with scoped evidence, limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs. Default nearby searches to 200 meters and five results. Integrate investigation messaging/presentation, health/readiness and typed history; remove duplicate web search and fix online simulation and compressed baseline writes. Include project migrations 023-025; keep local credentials and demo seeding excluded. Validation: 319 focused pytest checks; real PostGIS temporary-table regression; read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
140 lines
8.0 KiB
Python
140 lines
8.0 KiB
Python
"""Bounded calculation inputs; identity, routing and output paths are host-owned."""
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Annotated, Literal
|
|
from uuid import UUID
|
|
|
|
from pydantic import Field, model_validator
|
|
|
|
from app.mcp.models import Id, StrictInput
|
|
|
|
|
|
MAX_CALCULATION_TIMEOUT_SECONDS = 86400
|
|
|
|
|
|
class CalculationInput(StrictInput):
|
|
timeout_seconds: int = Field(
|
|
default=120, ge=1, le=MAX_CALCULATION_TIMEOUT_SECONDS, strict=True,
|
|
description="本次计算等待预算,秒;由Agent按模型规模和任务时长选择,受工具目录中的服务端上限约束;与模拟duration不同,超时不保证已启动的计算停止或回滚",
|
|
)
|
|
|
|
|
|
class BurstDetectionInput(CalculationInput):
|
|
target_time: datetime = Field(description="带时区的侦测时刻;读取此前15天同周期压力数据,不是单小时定位")
|
|
sensor_nodes: list[Id] = Field(min_length=5, max_length=20, description="已核实的压力监测节点ID,至少5个完整传感器")
|
|
sampling_interval_minutes: int = Field(default=15, ge=1, le=60)
|
|
|
|
@model_validator(mode="after")
|
|
def valid_target(self):
|
|
if self.target_time.tzinfo is None:
|
|
raise ValueError("target_time requires explicit timezone")
|
|
if 1440 % self.sampling_interval_minutes:
|
|
raise ValueError("sampling interval must divide a day")
|
|
if len(set(self.sensor_nodes)) != len(self.sensor_nodes):
|
|
raise ValueError("sensor_nodes must be distinct")
|
|
return self
|
|
|
|
|
|
class BurstLocationInput(CalculationInput):
|
|
data_source: Literal["monitoring", "simulation"] = "monitoring"
|
|
simulation_run_id: UUID | None = Field(default=None, description="已有分析运行UUID,不是方案名称;simulation模式只读取已有异常结果及模型基线")
|
|
burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="有依据的假定漏水流量,WNTR SI单位m³/s;不是已知爆管管段,不能随意猜测")
|
|
pressure_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="压力监测设备关联的模型节点ID,不是设备查询编号;省略时自动读取当前项目SCADA配置中的压力测点,不需要猜测节点ID")
|
|
flow_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="流量监测设备关联的模型要素ID,不是设备查询编号")
|
|
scada_burst_start: datetime
|
|
scada_burst_end: datetime
|
|
scada_normal_start: datetime | None = Field(default=None, description="monitoring模式正常对照窗开始,须与结束时间成对提供;均省略时用爆管窗前一天相同时段,不代表该时段已确认正常")
|
|
scada_normal_end: datetime | None = Field(default=None, description="monitoring模式正常对照窗结束,须与开始时间成对提供;simulation模式不填写,使用同时间窗模型基线")
|
|
|
|
@model_validator(mode="after")
|
|
def valid_windows(self):
|
|
pairs = [(self.scada_burst_start, self.scada_burst_end)]
|
|
if self.data_source == "monitoring":
|
|
if self.simulation_run_id is not None:
|
|
raise ValueError("monitoring cannot use simulation_run_id")
|
|
if self.scada_normal_start is not None or self.scada_normal_end is not None:
|
|
pairs.append((self.scada_normal_start, self.scada_normal_end))
|
|
elif self.simulation_run_id is None or self.scada_normal_start is not None or self.scada_normal_end is not None:
|
|
raise ValueError("simulation requires run_id and uses the same-window model baseline")
|
|
for start, end in pairs:
|
|
if start is None or end is None or start.tzinfo is None or end.tzinfo is None:
|
|
raise ValueError("explicit timezone-aware burst and normal windows are required")
|
|
if not timedelta(0) < end - start <= timedelta(days=1):
|
|
raise ValueError("each window must be positive and at most 24 hours")
|
|
for ids in (self.pressure_scada_ids, self.flow_scada_ids):
|
|
if ids is not None and len(set(ids)) != len(ids):
|
|
raise ValueError("sensor IDs must be distinct")
|
|
return self
|
|
|
|
|
|
class ValveIsolationInput(CalculationInput):
|
|
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
|
|
disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;未提供不代表现场确认全部可用")
|
|
|
|
|
|
class LeakageIdentificationInput(CalculationInput):
|
|
scada_start: datetime
|
|
scada_end: datetime
|
|
sensor_nodes: list[Id] = Field(min_length=1, max_length=20)
|
|
q_sum: float = Field(gt=0, allow_inf_nan=False, description="有依据的总漏损流量约束,m³/s,不得使用无依据默认值")
|
|
start_time: float = Field(ge=0, lt=24, allow_inf_nan=False, description="模型起算小时;需与SCADA时间窗及模型时钟对齐")
|
|
timestep: int = Field(default=15, ge=5, le=60, description="计算步长,分钟;需与观测采样对应")
|
|
dma_count: int = Field(ge=1, le=20)
|
|
|
|
@model_validator(mode="after")
|
|
def valid_window(self):
|
|
if self.scada_start.tzinfo is None or self.scada_end.tzinfo is None:
|
|
raise ValueError("timestamps require explicit timezone")
|
|
duration = self.scada_end - self.scada_start
|
|
if not timedelta(0) < duration <= timedelta(days=1):
|
|
raise ValueError("window must be positive and at most 24 hours")
|
|
if duration.total_seconds() % (self.timestep * 60):
|
|
raise ValueError("window must contain whole calculation steps")
|
|
if len(set(self.sensor_nodes)) != len(self.sensor_nodes):
|
|
raise ValueError("sensor_nodes must be distinct")
|
|
return self
|
|
|
|
|
|
class ScenarioInput(CalculationInput):
|
|
start_time: datetime
|
|
duration: int = Field(default=900, ge=60, le=86400, description="模拟时长,秒;首次优先短时间窗")
|
|
output_node_ids: list[Id] = Field(min_length=1, max_length=20, description="需要返回结果的节点;不把局部结果解释为全网无影响")
|
|
output_link_ids: list[Id] = Field(default_factory=list, max_length=20)
|
|
|
|
@model_validator(mode="after")
|
|
def timezone_required(self):
|
|
if self.start_time.tzinfo is None:
|
|
raise ValueError("start_time requires explicit timezone")
|
|
return self
|
|
|
|
|
|
class ContaminantSimulationInput(ScenarioInput):
|
|
source: Id = Field(description="假设污染源节点;这是正向传播模拟,不是自动反演污染源")
|
|
concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L")
|
|
pattern: Id | None = Field(default=None, description="已存在的污染源时间模式ID;不填为持续恒定源")
|
|
|
|
|
|
class ValveClosureInput(ScenarioInput):
|
|
valves: list[Id] = Field(min_length=1, max_length=20, description="在临时模型中关闭的阀门,不下发现场控制")
|
|
|
|
|
|
class FlushingInput(ScenarioInput):
|
|
drainage_node_id: Id
|
|
flushing_flow_m3h: float = Field(gt=0, allow_inf_nan=False, description="明确的冲洗流量,m³/h;不使用无依据的自动排放量")
|
|
closed_valves: list[Id] = Field(default_factory=list, max_length=20)
|
|
|
|
|
|
NonnegativeValue = Annotated[float, Field(ge=0, allow_inf_nan=False)]
|
|
PumpSeries = Annotated[list[NonnegativeValue], Field(min_length=1, max_length=96)]
|
|
|
|
|
|
class PressureRegulationInput(ScenarioInput):
|
|
pump_control: dict[Id, PumpSeries] = Field(min_length=1, max_length=20, description="已登记泵ID及运行序列;工频值为0/1,变频值为频率Hz,每项对应模型水力步长,不自动优化")
|
|
tank_init_level: dict[Id, Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(default_factory=dict, max_length=20, description="水箱初始水位,须在模型允许范围内")
|
|
|
|
|
|
class SensorPlacementInput(CalculationInput):
|
|
method: Literal["sensitivity", "kmeans"]
|
|
sensor_count: int = Field(ge=1, le=20)
|
|
min_diameter: int = Field(default=0, ge=0, le=5000, description="最小管径,mm")
|