feat(investigations): add event impact and work-order feedback flows
Add isolated burst and pollution impact evidence, parallel work-order workflows, persisted notifications and graph updates. Checkpoint current code. Known follow-ups remain for large pollution result previews and graceful geometry failure.
This commit is contained in:
@@ -97,6 +97,23 @@ class BurstLocationInput(CalculationInput):
|
||||
return self
|
||||
|
||||
|
||||
class ContaminantSourceTraceInput(CalculationInput):
|
||||
candidate_node_ids: list[Id] = Field(min_length=1, max_length=20,
|
||||
description="根据事件位置、附近资产或水质设备映射取得的候选模型节点;Server核验节点存在后稳定返回一个候选。当前为临时定位接口,尚未执行污染反演")
|
||||
start: datetime = Field(description="带时区的事件核查窗口开始")
|
||||
end: datetime = Field(description="带时区的事件核查窗口结束")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_window(self):
|
||||
if self.start.tzinfo is None or self.end.tzinfo is None:
|
||||
raise ValueError("timestamps require explicit timezone")
|
||||
if not timedelta(0) < self.end - self.start <= timedelta(days=1):
|
||||
raise ValueError("window must be positive and at most 24 hours")
|
||||
if len(set(self.candidate_node_ids)) != len(self.candidate_node_ids):
|
||||
raise ValueError("candidate_node_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")
|
||||
@@ -143,6 +160,10 @@ 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;省略时采用持续恒定源")
|
||||
include_impact_coverage: bool = Field(default=False, strict=True,
|
||||
description="估计处置前的污染传播范围;在筛选展示节点前汇总全网节点整个模拟时段的浓度,保存影响节点、首个超过阈值的报告时刻、峰值及地图覆盖")
|
||||
impact_threshold_mg_l: float = Field(default=0, ge=0, allow_inf_nan=False,
|
||||
description="模型影响范围的浓度阈值,mg/L,按严格大于判定;默认0表示模型中出现正浓度,仅为情景展示条件,不是饮用水安全限值。沿用模型初始水质与反应条件")
|
||||
|
||||
|
||||
class ValveClosureInput(ScenarioInput):
|
||||
|
||||
@@ -114,3 +114,21 @@ async def calculate_replay_burst_location(
|
||||
final_candidates_csv_path=None,
|
||||
**parameters,
|
||||
)
|
||||
|
||||
|
||||
async def calculate_replay_burst_simulation(args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
|
||||
"""An independent current-model scenario, with no live SCADA substitution."""
|
||||
return await bounded_calculation(
|
||||
calculate_scenario, "run_burst_simulation",
|
||||
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
|
||||
network=network, username=username, model_only=True,
|
||||
)
|
||||
|
||||
|
||||
async def calculate_replay_contaminant_simulation(args: BaseModel, *, network: str, username: str) -> dict[str, Any]:
|
||||
"""Estimate propagation in a current-model scenario without live SCADA substitution."""
|
||||
return await bounded_calculation(
|
||||
calculate_scenario, "run_contaminant_simulation",
|
||||
args.model_dump(exclude={"context_handle", "timeout_seconds"}, exclude_none=True),
|
||||
network=network, username=username, model_only=True,
|
||||
)
|
||||
|
||||
@@ -166,6 +166,19 @@ async def read_calculation_job(scope, binding, tool, args) -> QueryResult:
|
||||
raise HTTPException(422, "offset requires a collection data_path")
|
||||
else:
|
||||
result.data = value
|
||||
if not args.data_path and binding.task_id:
|
||||
from app.mcp.presentation_preview import saved_burst_result_preview, saved_presentation_preview
|
||||
|
||||
presentation = saved_presentation_preview(result)
|
||||
if presentation is not None and isinstance(result.data, dict):
|
||||
result.data = {**result.data, "presentation": presentation}
|
||||
if len(result.model_dump_json().encode()) > 11000:
|
||||
preview = saved_burst_result_preview(result, str(binding.task_id))
|
||||
if preview is not None:
|
||||
result.data = preview
|
||||
result.truncated = True
|
||||
result.data_gaps = [*result.data_gaps,
|
||||
"Inline burst impact summary only; full node comparisons and geometry are saved in task evidence. Read the evidence resource or select a narrower data_path."]
|
||||
if len(result.model_dump_json().encode()) > 11000:
|
||||
raise HTTPException(413, "Select a narrower data_path or smaller result page")
|
||||
return result
|
||||
|
||||
+4
-3
@@ -6,7 +6,7 @@ from mcp import types
|
||||
from app.mcp import models as m
|
||||
from app.mcp import calculation_models as jobs
|
||||
from app.mcp import extended_models as e
|
||||
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput, NodeCoverageInput
|
||||
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ContaminantSourceTraceInput, ValveIsolationInput, LeakageIdentificationInput, NodeCoverageInput
|
||||
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
|
||||
|
||||
|
||||
@@ -62,8 +62,9 @@ class QueryTool:
|
||||
|
||||
|
||||
_specs = [
|
||||
("run_contaminant_source_trace", "水污染候选溯源:提交由事件位置或监测映射取得的候选模型节点及事件窗,快速返回确定的已核验节点。当前为临时稳定选择,正式反演算法后续接入;不生成置信度。取得located_node后先展示节点、保存阶段结论并提现场核查/采样处置工单;工单与污染传播模拟、accident_element_type=node的隔离和影响分析并行,结果补入同一调查,不因算法来源停止", ContaminantSourceTraceInput, "simulation.run", "analysis", False, True),
|
||||
("run_simulation", "按水力步长在在线业务模型上执行指定时间段的实时模拟;更新该时段的模型条件及实时基线,返回指定节点和管段结果;duration单位为秒,输出为在线模型结果,正式分析成果通过方案工具保存", ScenarioInput, "simulation.run", "analysis", False, True),
|
||||
("run_contaminant_simulation", "在隔离模型中模拟假设污染源的浓度传播;返回指定节点/管段结果,结果标注假设来源及模型预测范围", ContaminantSimulationInput, "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_pressure_regulation", "在隔离模型中评估给定调泵序列和水箱初始水位的压力影响;输出为给定方案评估,现场调度按审批执行", PressureRegulationInput, "simulation.run", "analysis", False, True),
|
||||
@@ -258,7 +259,7 @@ _specs += [
|
||||
("get_task_run_history", "分页读取指定项目任务的运行历史", e.TaskHistoryInput, "operational_task.view", "task"),
|
||||
("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后先核实目标数据,再按原请求接续", e.MutationStatusInput, "webgis.view", "task"),
|
||||
("calculate_demand_allocation", "按管长权重分配总需水量到节点、区域或全网,返回分配建议,模型变更通过业务写入执行,按页返回", e.DemandAllocationInput, "webgis.view", "analysis", False, True),
|
||||
("run_burst_simulation", "在隔离模型中模拟假设管道破口面积的水力影响;返回给定情景的模拟结果,正式方案通过方案工具保存", e.BurstSimulationInput, "simulation.run", "analysis", False, True),
|
||||
("run_burst_simulation", "在隔离模型中模拟管道破口;include_impact_coverage=true时按起算同刻已有在线模拟计算全网节点压力下降及1/3/5米水头分档覆盖,保存为展示证据;回放仅用当前模型情景,不替代冻结观测", e.BurstSimulationInput, "simulation.run", "analysis", False, True),
|
||||
("edit_model_element", "预览或增删改模型节点、管段、泵阀、水池和区域;变更范围为业务模型,现场设备操作按对应流程执行", e.ModelElementMutationInput, "webgis.edit", "asset", False, False, True),
|
||||
("edit_model_configuration", "预览或修改模型需水量、模式、曲线、控制规则、水质及模拟选项", e.ConfigurationMutationInput, "webgis.edit", "asset", False, False, True),
|
||||
("clean_scada_readings", "预览或清洗明确设备与24小时内时间窗的数据,写入cleaned_value", e.ScadaCleaningInput, "scada.clean", "observed", False, False, True),
|
||||
|
||||
@@ -116,6 +116,7 @@ class DemandAllocationInput(CalculationInput):
|
||||
|
||||
|
||||
class BurstSimulationInput(ScenarioInput):
|
||||
include_impact_coverage: bool = Field(default=False, strict=True, description="比较起算时刻全网节点与已有在线模拟压力,按下降1≤Δ<3、3≤Δ<5、Δ≥5米水头生成事件影响覆盖;不重算或覆盖在线基线")
|
||||
burst_pipe_ids: list[Id] = Field(min_length=1, max_length=20)
|
||||
orifice_areas_cm2: list[Annotated[float, Field(gt=0, allow_inf_nan=False)]] = Field(min_length=1, max_length=20, description="破口面积,单位cm²;保留面积来源及估算依据")
|
||||
|
||||
|
||||
@@ -5,8 +5,34 @@ from uuid import NAMESPACE_URL, uuid5
|
||||
from app.mcp.models import QueryResult
|
||||
|
||||
|
||||
def saved_burst_result_preview(result: QueryResult, task_id: str) -> dict | None:
|
||||
"""Keep durable full-network results usable without copying node arrays inline."""
|
||||
if result.source != "run_burst_simulation" or not result.evidence_id:
|
||||
return None
|
||||
data = result.data if isinstance(result.data, dict) else {}
|
||||
analysis = data.get("result", {})
|
||||
impact = analysis.get("impact") if isinstance(analysis, dict) else None
|
||||
if not isinstance(impact, dict):
|
||||
return None
|
||||
summary = {key: value for key, value in impact.items()
|
||||
if key not in {"comparisons", "grades", "unavailable_node_ids"}}
|
||||
summary["unavailable_node_count"] = len(impact.get("unavailable_node_ids", []))
|
||||
summary["grades"] = [
|
||||
{**{key: value for key, value in grade.items() if key != "node_ids"},
|
||||
"node_count": len(grade.get("node_ids", []))} for grade in impact.get("grades", [])
|
||||
]
|
||||
preview = {
|
||||
"resource_uri": f"tjwater://tasks/{task_id}/evidence/{result.evidence_id}",
|
||||
"parameters": data.get("parameters", {}), "result": {"impact": summary},
|
||||
}
|
||||
presentation = saved_presentation_preview(result)
|
||||
if presentation is not None:
|
||||
preview["presentation"] = presentation
|
||||
return preview
|
||||
|
||||
|
||||
def saved_presentation_preview(result: QueryResult) -> dict | None:
|
||||
if result.source != "generate_node_coverage" or not result.evidence_id:
|
||||
if result.source not in {"generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation"} or not result.evidence_id:
|
||||
return None
|
||||
analysis = result.data.get("result") if isinstance(result.data, dict) else None
|
||||
if not isinstance(analysis, dict):
|
||||
@@ -15,11 +41,18 @@ def saved_presentation_preview(result: QueryResult) -> dict | None:
|
||||
if not isinstance(payload, dict) or not payload.get("features"):
|
||||
return None
|
||||
coverage = analysis.get("coverage", {})
|
||||
burst = result.source == "run_burst_simulation"
|
||||
contaminant = result.source == "run_contaminant_simulation"
|
||||
if burst and analysis.get("impact", {}).get("phase") != "after_burst_before_intervention":
|
||||
return None
|
||||
if contaminant and analysis.get("impact", {}).get("phase") != "after_contamination_before_intervention":
|
||||
return None
|
||||
return {
|
||||
"command": {
|
||||
"action": "set_overlay", "mode": "geometry", "evidence_id": result.evidence_id,
|
||||
"overlay_id": f"node-coverage-{uuid5(NAMESPACE_URL, result.evidence_id)}",
|
||||
"title": "节点空间覆盖示意",
|
||||
"title": ("爆管后、处置前的预计影响范围(米水头)" if burst else
|
||||
"水污染传播预计影响范围(处置前情景)" if contaminant else "节点空间覆盖示意"),
|
||||
},
|
||||
"display_only": True,
|
||||
"coverage_status": coverage.get("status"),
|
||||
|
||||
@@ -75,16 +75,26 @@ async def authorize_query(
|
||||
if replay_context:
|
||||
from app.services.scada_replay_queries import check_replay_window
|
||||
allowed = {"get_task_context", "get_task_evidence", "list_scada_devices", "get_scada_device",
|
||||
"query_scada_readings", "get_network_schema", "run_burst_location",
|
||||
"query_scada_readings", "get_network_schema", "run_burst_location", "run_contaminant_source_trace",
|
||||
"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",
|
||||
"analyze_valve_isolation", "generate_node_coverage", "run_burst_simulation", "run_contaminant_simulation",
|
||||
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
|
||||
if tool.name not in allowed:
|
||||
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产与拓扑分析;时序观测请使用 query_scada_readings")
|
||||
raise HTTPException(403, "回放调查仅开放冻结 SCADA、资产、拓扑分析及指定模型情景;时序观测请使用 query_scada_readings")
|
||||
if tool.name == "run_burst_simulation":
|
||||
if not args.include_impact_coverage:
|
||||
raise HTTPException(422, "回放爆管情景必须启用影响覆盖,按当前模型估计并保存同刻在线基线")
|
||||
check_replay_window(replay_context, args.start_time, args.start_time)
|
||||
if tool.name == "run_contaminant_simulation":
|
||||
if not args.include_impact_coverage:
|
||||
raise HTTPException(422, "回放污染情景必须启用影响覆盖,按当前模型估计传播范围")
|
||||
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":
|
||||
check_replay_window(replay_context, args.start, args.end)
|
||||
if tool.name == "run_burst_location":
|
||||
if args.data_source != "monitoring" or args.scada_normal_start is None or args.scada_normal_end is None:
|
||||
raise HTTPException(422, "回放定位只支持显式异常窗和正常对照窗的 monitoring 输入")
|
||||
@@ -107,6 +117,8 @@ async def authorize_query(
|
||||
required = {tool.permission}
|
||||
if tool.name in SCENARIO_TOOLS:
|
||||
required.add("scada.view")
|
||||
if tool.name == "run_burst_simulation" and args.include_impact_coverage:
|
||||
required.add("simulation.view")
|
||||
if tool.name in {"run_burst_detection", "run_burst_location", "run_leakage_identification"}:
|
||||
required.add("simulation.view" if getattr(args, "data_source", "monitoring") == "simulation" else "scada.view")
|
||||
if not required.issubset(permissions) or not required.issubset(binding.permission_cap):
|
||||
@@ -182,7 +194,15 @@ async def execute_query(
|
||||
source_ref = "mcp:" + str(uuid4())
|
||||
evidence_id = None
|
||||
replay_context = getattr(binding, "replay_context", None)
|
||||
if name == "query_scada_readings" and args.device_ids is None:
|
||||
if name == "run_contaminant_source_trace":
|
||||
from app.services.contaminant_source_trace import trace_contaminant_source
|
||||
|
||||
data = {"parameters": args.model_dump(mode="json", exclude={"context_handle"}),
|
||||
"result": await trace_contaminant_source(conn, args)}
|
||||
origin = "synthetic"
|
||||
if data["result"]["located_node"] is None:
|
||||
gaps.append(data["result"]["notice"])
|
||||
elif name == "query_scada_readings" and args.device_ids is None:
|
||||
from app.mcp.scada_readings import read_complete_scada
|
||||
data, units, gaps, origin = await read_complete_scada(scope, binding, args)
|
||||
elif name == "query_scada_readings" and replay_context:
|
||||
@@ -191,6 +211,28 @@ async def execute_query(
|
||||
args.device_ids, args.start, args.end, args.limit + 1, args.offset)
|
||||
data, page, truncated = page_result(rows, args)
|
||||
origin = "synthetic"
|
||||
elif name == "run_burst_simulation" and replay_context:
|
||||
from app.mcp.analysis_service import calculate_replay_burst_simulation
|
||||
|
||||
await conn.commit()
|
||||
async with scope.simulation_routing():
|
||||
result = await calculate_replay_burst_simulation(
|
||||
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_contaminant_simulation" and replay_context:
|
||||
from app.mcp.analysis_service import calculate_replay_contaminant_simulation
|
||||
|
||||
await conn.commit()
|
||||
async with scope.simulation_routing():
|
||||
result = await calculate_replay_contaminant_simulation(
|
||||
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
|
||||
@@ -241,7 +283,7 @@ async def execute_query(
|
||||
}
|
||||
gaps = [{
|
||||
"run_simulation": "Online hydraulic recalculation updated the realtime baseline; results cover requested elements only, not observed SCADA data. No standalone analysis run was saved.",
|
||||
"run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination; only requested elements are returned.",
|
||||
"run_contaminant_simulation": "Forward model simulation for an assumed source, not source inversion or observed contamination. Selected series only; opt-in impact coverage uses all native nodes over the simulation window, with an explicit display threshold and existing model initial quality/reactions, not a drinking-water safety limit.",
|
||||
"analyze_valve_closure": "Simulated hydraulic impact for requested elements only; no physical valves were operated.",
|
||||
"analyze_flushing": "Simulated flushing hydraulics only; no field action or water-quality clearance was verified.",
|
||||
"analyze_pressure_regulation": "Evaluation of a supplied pump schedule, not automatic optimization or device control; requested elements only.",
|
||||
@@ -252,7 +294,7 @@ async def execute_query(
|
||||
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
|
||||
"generate_node_coverage": "Derived node coverage for display only; geometry does not establish a fault, contamination, outage or service boundary. Missing GIS positions are reported separately.",
|
||||
"calculate_demand_allocation": "Pipe-length-weighted allocation only; model demands were not modified. Units follow the current model.",
|
||||
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas, not detection or confirmed burst location; only requested elements returned.",
|
||||
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas; selected series only. Opt-in impact coverage compares all native nodes at the start time with existing online results, using current model settings for baseline units. This is an estimated pre-intervention range, not a confirmed outage or historical model reconstruction.",
|
||||
}[name]]
|
||||
if name == "calculate_demand_allocation":
|
||||
truncated = data["result"]["has_more"] or args.offset > 0
|
||||
@@ -491,6 +533,17 @@ async def execute_query(
|
||||
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"]
|
||||
truncated = True
|
||||
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
|
||||
if skill.get("name") == "water-pollution-event":
|
||||
data["source_trace_capability"] = {
|
||||
"available": "run_contaminant_source_trace" in binding.tool_cap,
|
||||
"tool": "run_contaminant_source_trace",
|
||||
"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"],
|
||||
"work_order_wait_blocks_analysis": False,
|
||||
"simulation_parameters": {"include_impact_coverage": True},
|
||||
}
|
||||
if (skill.get("name") == "scada-anomaly" and replay_context
|
||||
and "run_burst_location" in binding.tool_cap):
|
||||
from app.services.burst_location_demo import location_demo_context
|
||||
|
||||
@@ -10,7 +10,8 @@ SCENARIO_TOOLS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, username: str) -> dict[str, Any]:
|
||||
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
|
||||
|
||||
parameters = dict(parameters)
|
||||
@@ -18,6 +19,8 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
||||
links = parameters.pop("output_link_ids")
|
||||
start = parameters.pop("start_time")
|
||||
duration = parameters.pop("duration")
|
||||
include_impact = parameters.pop("include_impact_coverage", False)
|
||||
impact_threshold = parameters.pop("impact_threshold_mg_l", 0)
|
||||
common = dict(name=network, modify_pattern_start_time=start.isoformat(),
|
||||
modify_total_duration=duration, persist_results=False, scheme_name=None)
|
||||
if name == "run_simulation":
|
||||
@@ -27,11 +30,27 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
||||
network, start, duration, output_node_ids=nodes, output_link_ids=links,
|
||||
)
|
||||
elif name == "run_burst_simulation":
|
||||
boundary = {}
|
||||
if model_only:
|
||||
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||
# Replay scenarios use current model assumptions, never mutable live
|
||||
# SCADA readings in place of the investigation's frozen observations.
|
||||
boundary["scada_mappings"] = ScadaElementMappings(
|
||||
reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={},
|
||||
pressure={}, demand={}, quality={},
|
||||
)
|
||||
result = scenarios.burst_analysis(**common, username=username,
|
||||
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"])
|
||||
burst_ID=parameters["burst_pipe_ids"], burst_size=parameters["orifice_areas_cm2"], **boundary)
|
||||
elif name == "run_contaminant_simulation":
|
||||
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.contaminant_simulation(**common, username=username,
|
||||
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"))
|
||||
source=parameters["source"], concentration=parameters["concentration"], source_pattern=parameters.get("pattern"), **boundary)
|
||||
elif name == "analyze_valve_closure":
|
||||
result = scenarios.valve_close_analysis(**common,
|
||||
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
|
||||
@@ -62,7 +81,7 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
||||
raise ValueError("Scenario did not return calculation results")
|
||||
selected_nodes = [row for row in result.get("node_results", []) if str(row.get("node")) in nodes]
|
||||
selected_links = [row for row in result.get("link_results", []) if str(row.get("link")) in links]
|
||||
return {
|
||||
output = {
|
||||
"input_source": "online_model_simulation" if name == "run_simulation" else "isolated_model_simulation",
|
||||
"persisted_baseline": name == "run_simulation", "persisted_scheme": False,
|
||||
"start_time": start.isoformat(), "duration_seconds": duration,
|
||||
@@ -72,3 +91,15 @@ def calculate_scenario(name: str, parameters: dict[str, Any], *, network: str, u
|
||||
"missing_nodes": sorted(set(nodes) - {str(row["node"]) for row in selected_nodes}),
|
||||
"missing_links": sorted(set(links) - {str(row["link"]) for row in selected_links})},
|
||||
}
|
||||
if name == "run_burst_simulation" and include_impact:
|
||||
from app.services.burst_impact import estimate_burst_impact
|
||||
impact = estimate_burst_impact(network, start, result, model_only=model_only)
|
||||
impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})}
|
||||
output.update(impact)
|
||||
if name == "run_contaminant_simulation" and include_impact:
|
||||
from app.services.contaminant_impact import estimate_contaminant_impact
|
||||
impact = estimate_contaminant_impact(network, start, duration, result,
|
||||
threshold_mg_l=impact_threshold, model_only=model_only)
|
||||
impact["coverage"] = {**output["coverage"], **impact.get("coverage", {})}
|
||||
output.update(impact)
|
||||
return output
|
||||
|
||||
@@ -10,6 +10,7 @@ import yaml
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
|
||||
WORKFLOW_SKILLS = {
|
||||
"scada_anomaly": "scada-anomaly",
|
||||
"water_pollution": "water-pollution-event",
|
||||
"burst_report": "burst-event-handling",
|
||||
"water_quality_complaint": "water-quality-complaint",
|
||||
"general": "appointment-follow-up",
|
||||
|
||||
Reference in New Issue
Block a user