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