feat(investigations): refine report authoring and replay flushing
Generic Container CI/CD / test-build-publish (push) Successful in 4m31s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m31s

This commit is contained in:
2026-11-14 23:00:15 +08:00
parent 2e4f6cb93b
commit b45c5c6f46
27 changed files with 1125 additions and 94 deletions
+13 -2
View File
@@ -171,9 +171,20 @@ class ValveClosureInput(ScenarioInput):
class FlushingInput(ScenarioInput):
drainage_node_id: Id
drainage_node_id: Id = Field(description="已核实的冲洗排放节点;清洁水来源、流向及污染水处置须在方案中说明")
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)
closed_valves: list[Id] = Field(default_factory=list, max_length=20,
description="本次冲洗情景中保持关闭的完整阀门清单,用于隔离用户或约束流向")
opened_valves: list[Id] = Field(default_factory=list, max_length=20,
description="本次冲洗情景中明确打开的阀门,如清洁水进水和导流阀;不表示现场已开阀或恢复用户供水。未列阀门沿用当前模型状态,不继承上次独立模拟")
@model_validator(mode="after")
def distinct_valve_states(self):
if any(len(values) != len(set(values)) for values in (self.closed_valves, self.opened_valves)):
raise ValueError("flushing valve lists must contain distinct IDs")
if set(self.closed_valves) & set(self.opened_valves):
raise ValueError("a flushing valve cannot be both opened and closed")
return self
NonnegativeValue = Annotated[float, Field(ge=0, allow_inf_nan=False)]
+9
View File
@@ -132,3 +132,12 @@ async def calculate_replay_contaminant_simulation(args: BaseModel, *, network: s
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
network=network, username=username, model_only=True,
)
async def calculate_replay_flushing(args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
"""Evaluate a model-only flushing plan without live SCADA or field execution."""
return await bounded_calculation(
calculate_scenario, "analyze_flushing",
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
network=network, username=username, model_only=True,
)
+1 -1
View File
@@ -66,7 +66,7 @@ _specs = [
("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,输出为在线模型结果,正式分析成果通过方案工具保存", ScenarioInput, "simulation.run", "analysis", False, True),
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;include_impact_coverage=true时汇总全网节点整个模拟时段超过impact_threshold_mg_l的累计范围、首个超阈值报告时刻和峰值,并保存地图证据。阈值为情景展示条件,非饮用水安全限值;回放按当前模型独立估计,不读取实时SCADA。定位后先提现场核查/采样处置工单,工单审核派发与本计算并行,结果补入同一调查", ContaminantSimulationInput, "simulation.run", "analysis", False, True),
("analyze_valve_closure", "在隔离模型中模拟关阀的水力影响,返回指定节点/管段结果;现场阀门操作按业务审批执行", ValveClosureInput, "simulation.run", "analysis", False, True),
("analyze_flushing", "在隔离模型中评估指定排放节点、冲洗流量和关阀组合的水力结果;现场效果由执行回执和复测核验", FlushingInput, "simulation.run", "analysis", False, True),
("analyze_flushing", "在独立模型中评估指定排放节点、流量及明确开关阀组合的冲洗水力结果;未列阀门沿用当前模型,不继承上次模拟;不判定污染清除或恢复供水", FlushingInput, "simulation.run", "analysis", False, True),
("analyze_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;输出为给定方案评估,现场调度按审批执行", PressureRegulationInput, "simulation.run", "analysis", False, True),
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;返回候选节点,正式部署另行保存并核验观测能力", SensorPlacementInput, "optimization.run", "analysis", False, True),
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回待现场核验的区域估计。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
+2 -1
View File
@@ -189,11 +189,12 @@ def saved_scenario(network: str, username: str, args: BaseModel) -> Any:
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
if args.scenario == "flushing":
from app.native.wndb.model.options import get_option
from app.mcp.scenario_service import flushing_valve_controls
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
raise ValueError("Flushing supports LPS or CMH models only")
return scenarios.flushing_analysis(**common, username=username,
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
valve_control=flushing_valve_controls(parameters))
mappings = query_corresponding_element_id_and_query_id(network)
controls = parameters["pump_control"]
if set(controls) - (set(mappings.fixed_pumps) | set(mappings.variable_pumps)):
+16 -2
View File
@@ -79,7 +79,7 @@ async def authorize_query(
"get_element_properties", "list_elements", "query_nearby_elements", "get_network_options",
"get_node_connections", "get_element_gis_properties", "query_network_extent",
"list_regions", "get_region", "get_region_nodes",
"analyze_valve_isolation", "generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation",
"analyze_valve_isolation", "generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation", "analyze_flushing",
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
if tool.name not in allowed:
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产、拓扑分析及指定模型情景;时序观测请使用 query_scada_readings")
@@ -91,6 +91,8 @@ async def authorize_query(
if not args.include_impact_coverage:
raise HTTPException(422, "回放污染情景必须启用影响覆盖,按当前模型估计传播范围")
check_replay_window(replay_context, args.start_time, args.start_time)
if tool.name == "analyze_flushing":
check_replay_window(replay_context, args.start_time, args.start_time)
if tool.name == "query_scada_readings":
check_replay_window(replay_context, args.start, args.end)
if tool.name == "run_contaminant_source_trace":
@@ -233,6 +235,17 @@ async def execute_query(
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
origin = "synthetic"
gaps = ["回放污染传播为当前模型的独立假设情景,不读取实时SCADA,不代表冻结观测或历史模型重建。未来模拟样本仅为预测;浓度阈值仅用于情景展示,不是饮用水安全限值。"]
elif name == "analyze_flushing" and replay_context:
from app.mcp.analysis_service import calculate_replay_flushing
await conn.commit()
async with scope.simulation_routing():
result = await calculate_replay_flushing(
args, network=scope.project.project_code, username=scope.user.username,
)
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}), "result": result}
origin = "synthetic"
gaps = ["回放冲洗为当前模型的独立水力情景,不读取实时SCADA,不继承上次模拟;未来模拟样本仅为预测,不验证污染清除、水质合格或现场操作。"]
elif name == "run_burst_location" and replay_context:
from app.mcp.analysis_service import calculate_replay_burst_location
from app.services.scada_replay_queries import replay_burst_location_inputs
@@ -540,7 +553,8 @@ async def execute_query(
"algorithm_status": "provisional",
"required_inputs": ["candidate_node_ids", "start", "end"],
"next_steps": ["present_node", "interim_report", "propose_task_action", "run_contaminant_simulation",
"analyze_valve_isolation", "generate_node_coverage", "report"],
"source_control", "analyze_valve_isolation", "generate_node_coverage",
"analyze_flushing", "field_feedback", "clearance_verification", "report"],
"work_order_wait_blocks_analysis": False,
"simulation_parameters": {"include_impact_coverage": True},
}
+27 -1
View File
@@ -10,6 +10,12 @@ SCENARIO_TOOLS = frozenset({
})
def flushing_valve_controls(parameters: dict[str, Any]) -> dict[str, dict[str, str]]:
"""Apply explicit states without changing regulating-valve settings."""
return {**{valve: {"status": "CLOSED"} for valve in parameters.get("closed_valves", [])},
**{valve: {"status": "OPEN"} for valve in parameters.get("opened_valves", [])}}
def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str,
model_only: bool = False) -> dict[str, Any]:
from app.services import simulation_scenarios as scenarios
@@ -60,9 +66,16 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
from app.native.wndb.model.options import get_option
if get_option(network).get("UNITS") not in {"LPS", "CMH"}:
raise ValueError("Flushing currently supports LPS or CMH models only")
boundary = {}
if model_only:
from app.infra.db.postgresql.scada import ScadaElementMappings
boundary["scada_mappings"] = ScadaElementMappings(
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
pressure={}, demand={}, quality={},
)
result = scenarios.flushing_analysis(**common, username=username,
drainage_node_ID=parameters["drainage_node_id"], flushing_flow=parameters["flushing_flow_m3h"],
modify_valve_opening={valve: 0.0 for valve in parameters["closed_valves"]})
valve_control=flushing_valve_controls(parameters), **boundary)
elif name == "analyze_pressure_regulation":
from app.services.simulation import query_corresponding_element_id_and_query_id
mappings = query_corresponding_element_id_and_query_id(network)
@@ -102,4 +115,17 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
threshold_mg_l=impact_threshold, model_only=model_only)
impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})}
output.update(impact)
if name == "analyze_flushing":
output["flushing_plan"] = {
"opened_valves": parameters.get("opened_valves", []),
"closed_valves": parameters.get("closed_valves", []),
"drainage_node_id": parameters["drainage_node_id"],
"flushing_flow_m3h": parameters["flushing_flow_m3h"],
"estimated_discharge_volume_m3": parameters["flushing_flow_m3h"] * duration / 3600,
"state_basis": "independent_current_model_scenario",
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
"result_scope": "hydraulic_only",
"field_execution_verified": False,
"water_quality_clearance_verified": False,
}
return output