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 = 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")
|
|
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")
|