171 lines
10 KiB
Python
171 lines
10 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 BaseModel, ConfigDict, Field, model_validator
|
||
|
||
from app.mcp.models import Id, StrictInput
|
||
|
||
|
||
MAX_REQUEST_TIMEOUT_SECONDS = 240
|
||
|
||
|
||
class CalculationInput(StrictInput):
|
||
timeout_seconds: int = Field(
|
||
default=120, ge=1, le=MAX_REQUEST_TIMEOUT_SECONDS, strict=True,
|
||
description="本次HTTP请求响应等待预算,秒,默认120秒;后台执行时限由作业系统管理,受工具目录中的请求上限约束;后台任务通过状态和结果工具查询",
|
||
)
|
||
|
||
|
||
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个完整传感器")
|
||
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 | None = Field(default=None, gt=0, allow_inf_nan=False, description="水力定位必需的假定漏水流量,WNTR SI单位m³/s;使用有依据的估计或明确给定的情景假设,保留估计方法与输入来源。仅 Server 明确开放的候选定位路径可省略,该路径直接返回候选管段与相似度;其他项目和时段省略将被拒绝")
|
||
pressure_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="压力监测设备关联的模型节点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")
|
||
accident_element_type: Literal["auto", "link", "node"] = Field(default="auto", description="事故要素类型;auto优先识别管段,再识别节点;节点与管段重号时,节点分析须指定node,纯管段分析可指定link")
|
||
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")
|