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:
2026-11-14 15:14:02 +08:00
parent 471ad92a47
commit 2e4f6cb93b
49 changed files with 1826 additions and 93 deletions
+1
View File
@@ -51,6 +51,7 @@ McpSnapshotSource = Literal[
'cancel_task_run',
'run_simulation',
'run_contaminant_simulation',
'run_contaminant_source_trace',
'analyze_valve_closure',
'analyze_flushing',
'analyze_pressure_regulation',
+15 -2
View File
@@ -252,6 +252,10 @@ class InvestigationDiagramNodeRef(_InvestigationDiagramNode):
class InvestigationGraphNodeUpdate(_InvestigationDiagramNode):
label: NonEmptyStr | None = Field(default=None, description=(
"Required for a new node. Omit or use null to retain an existing node's label. "
"Use the same node_id to revise status or detail without adding another step."
))
status: InvestigationNodeAnnotation | None = Field(default=None, description=(
"Optional reference annotation: reference, recorded finding, needs_input, failed attempt, or not_taken. "
"No live node/task binding or per-step synchronization is required."
@@ -752,7 +756,7 @@ class ScadaChartGap(StrictModel):
class ScadaChartContext(StrictModel):
role: Literal["current", "comparison"]
metric: Literal["pressure", "flow"]
metric: Literal["pressure", "flow", "quality"]
unit: Annotated[str, Field(min_length=1, max_length=128)]
window_start: WorkbenchDateTime
window_end: WorkbenchDateTime
@@ -1015,6 +1019,13 @@ class HumanEvidenceCommand(StrictModel):
evidence: EvidenceInput
class WorkOrderNotificationReference(StrictModel):
source_event_id: NonEmptyStr
source_revision: PositiveInt
evidence_id: NonEmptyStr | None = None
status: Literal["scheduled", "assigned", "in_progress", "awaiting_feedback", "completed", "cancelled", "failed"]
class OperationalTaskNotification(StrictModel):
notification_id: UUID
task_id: UUID
@@ -1022,7 +1033,7 @@ class OperationalTaskNotification(StrictModel):
"task_created", "action_pending", "report_updated", "task_completed",
"task_failed", "run_delivery_degraded", "access_approval_pending",
"action_approval_pending", "human_input_required",
"external_coordination_failed"
"external_coordination_failed", "work_order_feedback"
]
title: NonEmptyStr
description: NonEmptyStr
@@ -1031,6 +1042,8 @@ class OperationalTaskNotification(StrictModel):
subject_type: Literal["task", "run", "access_request", "action", "question"] = "task"
subject_id: UUID | None = None
report_reference: InvestigationReportReference | None = None
work_order_action: HumanApprovalAction | None = None
work_order_reference: WorkOrderNotificationReference | None = None
resolved_at: WorkbenchDateTime | None = None
resolution_reason: NonEmptyStr | None = None
status: Literal["unread", "read"]
+21
View File
@@ -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):
+18
View File
@@ -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,
)
+13
View File
@@ -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
View File
@@ -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),
+1
View File
@@ -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²;保留面积来源及估算依据")
+35 -2
View File
@@ -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"),
+59 -6
View File
@@ -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
+35 -4
View File
@@ -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
+1
View File
@@ -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",
+147
View File
@@ -0,0 +1,147 @@
"""Estimate pre-intervention burst pressure loss against a read-only online snapshot."""
from datetime import datetime
from math import isfinite
from typing import Any
from app.infra.db.timescaledb.internal_queries import InternalQueries
from app.native.wndb.model.options import get_option, get_option_v3
from app.services.node_coverage import MAX_COVERAGE_NODES, generate_node_coverage
GRADES = (
("drop_1_3", "压力下降 1≤Δ<3 m", 1, 3),
("drop_3_5", "压力下降 3≤Δ<5 m", 3, 5),
("drop_5_plus", "压力下降 Δ≥5 m", 5, None),
)
def _head_factor(unit: Any) -> float | None:
# Standard water head; unsupported or absent units must not become zero loss.
return {"m": 1.0, "meters": 1.0, "metres": 1.0,
"kpa": 1 / 9.80665, "psi": 6894.757293168 / 9806.65,
"bar": 100000 / 9806.65, "mpa": 1000000 / 9806.65,
"ft": 0.3048}.get(str(unit).strip().lower())
def _number(value: Any) -> float | None:
if isinstance(value, bool):
return None
try:
result = float(value)
except (ValueError, TypeError, OverflowError):
return None
return result if isfinite(result) else None
def _instant(value: Any) -> datetime | None:
try:
result = value if isinstance(value, datetime) else datetime.fromisoformat(value)
except (ValueError, TypeError):
return None
return result if result.tzinfo is not None else None
def estimate_burst_impact(network: str, start: datetime, simulation: dict[str, Any], *,
model_only: bool = False) -> dict[str, Any]:
"""Compare every native node at t=0, before the adapter filters requested outputs.
The online snapshot is a model reference read at calculation time, not frozen
SCADA or a reconstructed historical model. All numerical inputs are retained
in task evidence so playback does not repeat this mutable reference query.
"""
impact: dict[str, Any] = {
"status": "unavailable", "phase": "after_burst_before_intervention",
"comparison_time": start.isoformat(), "pressure_drop_unit": "m",
"baseline_source": "existing_online_simulation",
"baseline_read_at": datetime.now().astimezone().isoformat(),
"baseline_units_source": "current_model_settings",
"model_temporality": "current_model", "baseline_frozen": False,
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
"boundary": "假设破口情景与同刻在线模拟的节点压力差估计;覆盖面为节点空间包络,非现场确认停水或用户服务边界。在线基线及其单位按计算时可用数据和模型设置读取。",
"grades": [{"group_id": key, "title": title, "min_inclusive_m": low,
"max_exclusive_m": high, "node_ids": []} for key, title, low, high in GRADES],
}
output = {"impact": impact}
# Never silently shift t=0 to an unknown/delayed report sample or another day.
times = simulation.get("times", {})
sample_times = times.get("sample_times")
if ((sample_times and _instant(sample_times[0]) != start)
or (not sample_times and times.get("report_start") != 0)):
impact["reason"] = "simulation_has_no_verified_start_time_sample"
return output
options = get_option(network)
options_v3 = get_option_v3(network)
baseline_unit = options_v3.get("PRESSURE_UNITS") or options.get("PRESSURE")
impact["baseline_units_source"] = (
"current_v3_model_settings" if options_v3.get("PRESSURE_UNITS") else "current_legacy_model_settings"
)
impact["model_conditions"] = {
"baseline_demand_model": options_v3.get("DEMAND_MODEL") or options.get("DEMAND MODEL"),
"burst_demand_model": "PDA", "burst_required_pressure_model_units": 10,
"note": "爆管模拟采用压力驱动需水;与在线模型的需水方式差异也可能影响压力差。",
}
scenario_unit = simulation.get("units", {}).get("pressure")
baseline_factor, scenario_factor = _head_factor(baseline_unit), _head_factor(scenario_unit)
impact["input_pressure_units"] = {"baseline": baseline_unit, "burst": scenario_unit}
if baseline_factor is None or scenario_factor is None:
impact["reason"] = "unsupported_or_missing_pressure_units"
return output
nodes = simulation.get("node_results", [])
ids = [str(row["node"]) for row in nodes]
if not ids or len(ids) != len(set(ids)):
impact["reason"] = "missing_or_duplicate_simulation_nodes"
return output
baseline = InternalQueries.query_realtime_simulation_by_ids_timerange(
ids, start, start, "node", "pressure", db_name=network,
)
comparisons, missing = [], []
for row in nodes:
node_id = str(row["node"])
samples = row.get("result") or []
burst = _number(samples[0].get("pressure")) if samples and isinstance(samples[0], dict) else None
exact = [item for item in baseline.get(node_id, []) if _instant(item.get("time")) == start]
normal = _number(exact[0].get("value")) if len(exact) == 1 else None
if burst is None or normal is None:
missing.append(node_id)
continue
normal_m, burst_m = normal * baseline_factor, burst * scenario_factor
drop = normal_m - burst_m
if not all(isfinite(value) for value in (normal_m, burst_m, drop)):
missing.append(node_id)
continue
comparisons.append({"node_id": node_id, "baseline_m": normal_m,
"burst_m": burst_m, "pressure_drop_m": drop})
for group in impact["grades"]:
if drop >= group["min_inclusive_m"] and (
group["max_exclusive_m"] is None or drop < group["max_exclusive_m"]
):
group["node_ids"].append(node_id)
break
impact.update(status="partial" if missing else "ready", node_count=len(ids),
compared_node_count=len(comparisons), unavailable_node_ids=missing,
comparisons=comparisons)
affected = sum(len(group["node_ids"]) for group in impact["grades"])
impact["affected_node_count"] = affected
impact["below_threshold_node_count"] = len(comparisons) - affected
if not comparisons:
impact.update(status="unavailable", reason="no_exact_comparable_online_snapshot")
return output
if affected > MAX_COVERAGE_NODES:
impact["coverage_status"] = "unavailable"
impact["coverage_reason"] = "affected_nodes_exceed_10000_coverage_limit"
return output
groups = [{key: group[key] for key in ("group_id", "title", "node_ids")}
for group in impact["grades"]]
geometry = generate_node_coverage(network, groups)
impact["coverage_status"] = geometry["coverage"]["status"]
geometry["coverage"]["boundary"] = impact["boundary"]
for feature in geometry["map_payload"]["features"]:
feature["properties"].update(
impact_phase=impact["phase"], pressure_drop_unit="m",
pressure_drop_grade=feature["properties"]["coverage_group"],
comparison_time=start.isoformat(), model_estimate=True,
)
if geometry["coverage"]["status"] == "partial":
impact["status"] = "partial"
output.update(geometry)
return output
+112
View File
@@ -0,0 +1,112 @@
"""Estimate a scenario's time-cumulative concentration coverage before output filtering."""
from datetime import datetime, timedelta
from math import isfinite
from typing import Any
from app.services.node_coverage import MAX_COVERAGE_NODES, generate_node_coverage
def _sample_times(start: datetime, duration: int, metadata: dict) -> list[datetime]:
explicit = metadata.get("sample_times")
if explicit is not None:
times = [datetime.fromisoformat(value) for value in explicit]
else:
offset, step, count = (metadata.get(key) for key in ("report_start", "report_step", "num_periods"))
if (any(type(value) is not int for value in (offset, step, count))
or offset < 0 or step <= 0 or not 0 < count <= 86401):
raise ValueError("missing or invalid report grid")
times = [start + timedelta(seconds=offset + index * step) for index in range(count)]
if (not times or len(times) > 86401 or any(at.tzinfo is None for at in times)
or any(not start <= at <= start + timedelta(seconds=duration) for at in times)
or any(left >= right for left, right in zip(times, times[1:]))):
raise ValueError("invalid simulation sample times")
return times
def estimate_contaminant_impact(network: str, start: datetime, duration: int,
simulation: dict[str, Any], *, threshold_mg_l: float = 0,
model_only: bool = False) -> dict[str, Any]:
"""Use every native node and report sample; never infer propagation from requested nodes."""
impact: dict[str, Any] = {
"status": "unavailable", "phase": "after_contamination_before_intervention",
"window_start": start.isoformat(), "window_end": (start + timedelta(seconds=duration)).isoformat(),
"threshold_mg_l": threshold_mg_l, "threshold_operator": ">", "concentration_unit": "mg/L",
"threshold_purpose": "scenario_display_not_drinking_water_standard",
"model_temporality": "current_model", "quantity": "total_model_concentration",
"boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada",
"boundary": "给定源浓度、模型初始水质及反应条件下,整个模拟时段内超过展示阈值的节点覆盖;"
"首达时间为首个超过阈值的报告采样时刻。不是污染确诊、源独立贡献、现场污染边界或饮用水合格判定。",
}
output = {"impact": impact}
unit = simulation.get("units", {}).get("quality")
factor = {"mgl": 1.0, "mg/l": 1.0, "ugl": 0.001, "ug/l": 0.001}.get(
str(unit).strip().lower().replace("μ", "u").replace("µ", "u"))
impact["input_concentration_unit"] = unit
if factor is None:
impact["reason"] = "unsupported_or_missing_concentration_unit"
return output
try:
times = _sample_times(start, duration, simulation.get("times", {}))
except (TypeError, ValueError, OverflowError):
impact["reason"] = "simulation_has_no_verified_sample_times"
return output
rows = simulation.get("node_results", [])
ids = [str(row["node"]) for row in rows]
if not ids or len(ids) != len(set(ids)):
impact["reason"] = "missing_or_duplicate_simulation_nodes"
return output
affected, unavailable, incomplete = [], [], []
for row in rows:
node = str(row["node"])
samples = row.get("result") or []
if len(samples) > len(times):
impact["reason"] = "simulation_sample_count_mismatch"
return output
valid = []
for at, sample in zip(times, samples):
value = sample.get("quality") if isinstance(sample, dict) else None
if isinstance(value, bool) or value is None:
continue
try:
concentration = float(value) * factor
except (TypeError, ValueError, OverflowError):
continue
if isfinite(concentration) and concentration >= 0:
valid.append((at, concentration))
if not valid:
unavailable.append(node)
continue
if len(valid) != len(times):
incomplete.append(node)
exceedances = [(at, value) for at, value in valid if value > threshold_mg_l]
if exceedances:
peak_at, peak = max(valid, key=lambda item: item[1])
affected.append({"node_id": node, "first_exceedance_sample_at": exceedances[0][0].isoformat(),
"peak_mg_l": peak, "peak_at": peak_at.isoformat(),
"valid_sample_count": len(valid), "complete": len(valid) == len(times)})
impact.update(status="partial" if unavailable or incomplete else "ready",
node_count=len(ids), report_sample_count=len(times),
reported_from=times[0].isoformat(), reported_through=times[-1].isoformat(),
unavailable_node_ids=unavailable, incomplete_node_ids=incomplete,
affected_node_count=len(affected), nodes=affected,
affected_node_ids=[item["node_id"] for item in affected])
if len(unavailable) == len(ids):
impact.update(status="unavailable", reason="no_valid_concentration_samples")
return output
if len(affected) > MAX_COVERAGE_NODES:
impact.update(coverage_status="unavailable", coverage_reason="affected_nodes_exceed_10000_coverage_limit")
return output
geometry = generate_node_coverage(network, [{
"group_id": "contaminant_exposure", "title": f"模拟浓度>{threshold_mg_l:g} mg/L的累计节点范围",
"node_ids": impact["affected_node_ids"],
}])
impact["coverage_status"] = geometry["coverage"]["status"]
geometry["coverage"]["boundary"] = impact["boundary"]
for feature in geometry["map_payload"]["features"]:
feature["properties"].update(impact_phase=impact["phase"], concentration_unit="mg/L",
threshold_mg_l=threshold_mg_l, model_estimate=True,
window_start=impact["window_start"], window_end=impact["window_end"])
if geometry["coverage"]["status"] == "partial":
impact["status"] = "partial"
output.update(geometry)
return output
+34
View File
@@ -0,0 +1,34 @@
"""Temporary deterministic candidate selection behind the source-tracing contract.
Replace this selection with source inversion when the algorithm is available.
Only project-owned nodes supplied from the investigation are eligible. No
observation fit, confidence or contamination extent is inferred here.
"""
from psycopg import AsyncConnection
from app.infra.db.postgresql.read_queries import read_rows
from app.mcp.analysis_models import ContaminantSourceTraceInput
async def trace_contaminant_source(connection: AsyncConnection, args: ContaminantSourceTraceInput) -> dict:
rows = await read_rows(connection, """
SELECT id, node_type::text AS kind FROM network.nodes
WHERE id = ANY(%s) ORDER BY id
""", (args.candidate_node_ids,))
# Sort in Python as well so selection does not depend on DB collation.
nodes = sorted({str(row["id"]) for row in rows})
selected = nodes[0] if nodes else None
return {
"located_node": selected,
"top_candidates": [{"node_id": selected}] if selected else [],
"exit_condition": "candidate_selected" if selected else "no_candidate",
"method": "deterministic_candidate_selection",
"algorithm_status": "provisional",
"selection_basis": "lexicographically_first_existing_candidate_node",
"validated_node_ids": nodes,
"missing_node_ids": sorted(set(args.candidate_node_ids) - set(nodes)),
"notice": ("已返回待现场核验的候选节点;先展示节点、保存阶段结论并提出现场核查/采样处置工单,"
"工单审核派发与污染传播模拟、节点隔离及影响分析并行,结果补入同一调查。"
"正式污染反演算法尚未接入,本次选择不代表污染源确诊或污染传播范围。" if selected else
"提交的候选节点均不在当前项目模型中;核对事件位置及资产映射后重新提交,继续不依赖源节点的调查。"),
}
+10 -5
View File
@@ -23,10 +23,11 @@ def adopt_legacy_investigation_graph(diagram: InvestigationDiagram) -> Investiga
def update_investigation_graph(
previous: InvestigationDiagram | None, update: InvestigationGraphUpdate,
) -> InvestigationDiagram:
"""Upsert nodes and append explicit edges; never delete a path or erase a failed attempt.
"""Patch saved nodes or add labelled nodes and explicit edges; never erase a path.
A retry uses a new node id. Relationships describe activity for readers and
impose no ordering or completion requirements on the Agent.
Unspecified fields retain their saved values. A failed attempt remains
failed, and a retry uses a new node id. Relationships impose no execution
ordering or completion requirements on the Agent.
"""
if previous is not None:
previous = adopt_legacy_investigation_graph(previous)
@@ -47,15 +48,19 @@ def update_investigation_graph(
if existing is None:
require(not in_source(node.node_id), "GRAPH_LEGACY_NODE_CONFLICT",
"This id belongs to the original Mermaid; use a new node id", status=409)
require(node.label is not None, "GRAPH_NODE_LABEL_REQUIRED",
"A new graph node requires a label; use an existing node_id for a partial update", status=422)
nodes[node.node_id] = InvestigationDiagramNodeRef.model_validate(node.model_dump(exclude_unset=True))
continue
require(not in_source(node.node_id) or existing.label == node.label,
require(not in_source(node.node_id) or node.label is None or existing.label == node.label,
"GRAPH_LEGACY_NODE_CONFLICT", "Keep the original node label; use a new node for a new step", status=409)
require(existing.status != "failed" or (
("status" not in node.model_fields_set or node.status == "failed")
and existing.label == node.label
and (node.label is None or existing.label == node.label)
), "GRAPH_FAILED_ATTEMPT_IMMUTABLE", "Keep the failed attempt; use a new node for a retry", status=409)
changes = node.model_dump(exclude_unset=True)
if node.label is None:
changes.pop("label", None)
if existing.status == "failed" and existing.detail and not changes.get("detail"):
changes.pop("detail", None)
for field in ("evidence_refs", "action_refs", "map_targets"):
+10 -1
View File
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
from app.services.operational_tasks import OperationalTaskService
RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly"}
RECOVERY_WORKFLOWS = {"burst_report", "scada_anomaly", "water_pollution"}
def track_work_order(task: dict, action: dict) -> bool:
@@ -60,6 +60,8 @@ def accept_completion(task: dict, feedback: dict, *, recorded_at: datetime | Non
# edits. A revised field receipt can explicitly correct the verification start.
if feedback["status"] == "completed":
order["returned_at"] = feedback["occurred_at"]
if task.get("workflow") == "water_pollution":
order["feedback_evidence_id"] = (feedback.get("evidence") or {}).get("evidence_id")
else:
order.pop("returned_at", None)
if order.get("superseded_by"):
@@ -67,6 +69,13 @@ def accept_completion(task: dict, feedback: dict, *, recorded_at: datetime | Non
orders = [item for item in context["work_orders"].values() if not item.get("superseded_by")]
if orders and all(item["status"] == "completed" for item in orders):
returned_at = max((item["returned_at"] for item in orders), key=datetime.fromisoformat)
if task.get("workflow") == "water_pollution":
# A completion receipt resumes review, never the pressure-based
# automatic recovery calculation. Source revisions may carry new
# sampling or retest facts even when the field time is unchanged.
context.update(phase="field_review", title="水污染处置反馈复核", returned_at=returned_at)
context.pop("verification_evidence_id", None)
return True
previous = context.get("returned_at")
if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at)
or context.get("phase") == "waiting_work_order"):
+1 -1
View File
@@ -7,7 +7,7 @@ from app.domain.domain_errors import require
def require_complete_scada_evidence(task: dict, evidence: list[dict], refs: list[str]) -> None:
replay = task.get("source", {}).get("replay_context")
if task.get("workflow") != "scada_anomaly" and not replay:
if task.get("workflow") not in {"scada_anomaly", "water_pollution"} and not replay:
return
target = datetime.fromisoformat(
replay["knowledge_cutoff"] if replay else task["trigger"]["occurred_at"]
+46 -5
View File
@@ -56,6 +56,7 @@ from app.domain.schemas.operational_task import (
OperationalTaskCreate,
OperationalTaskDetail,
OperationalTaskNotification,
WorkOrderNotificationReference,
OperationalTaskPermissions,
OperationalTaskSchedule,
OperationalTaskScheduleCreate,
@@ -874,7 +875,7 @@ class OperationalTaskService:
diagram = update_investigation_graph(saved_diagram, command.progress.graph_update)
elif saved_diagram is not None:
require(diagram is None or saved_diagram.edges is None, "GRAPH_UPDATE_REQUIRED",
"Use graph_update to extend the saved investigation graph", status=422)
"Use graph_update to patch saved nodes or extend the investigation graph", status=422)
if diagram is None:
diagram = adopt_legacy_investigation_graph(saved_diagram)
if diagram:
@@ -1258,6 +1259,7 @@ class OperationalTaskService:
task_id, "action_approval_pending", task["title"], command.reason, now,
required_permission=approval_permission,
subject_type="action", subject_id=action.action_id,
work_order_action=action if action.kind == "create_work_order" else None,
)
await self._save_request(
scope, command.request_id, body, {"action_id": str(action.action_id)}
@@ -1743,6 +1745,29 @@ class OperationalTaskService:
task_id, "task_failed",
task["title"], command.detail, now,
)
if command.source_revision > 1 and task["task_type"] in {"external_work_order", "event_investigation"}:
# The feedback, its notification and outbox event commit together.
# Duplicate request/source versions return above, before publishing.
work_order_action = next((item for item in await self.repo.list_actions(str(task_id))
if item["kind"] == "create_work_order" and item.get("receipt")
and command.source_event_id == f"{item['receipt']['adapter']}:{item['receipt']['receipt_id']}"), None)
is_work_order_feedback = (task["task_type"] == "external_work_order"
or work_order_action is not None
or (task["task_type"] == "event_investigation" and matching_source))
if is_work_order_feedback:
await self._notify(
task_id, "work_order_feedback", "收到工单反馈",
command.evidence.summary if command.evidence else command.detail, now,
subject_type="action" if work_order_action else "task",
subject_id=UUID(str(work_order_action["action_id"])) if work_order_action else task_id,
work_order_action=HumanApprovalAction.model_validate(work_order_action) if work_order_action else None,
work_order_reference=WorkOrderNotificationReference(
source_event_id=command.source_event_id,
source_revision=command.source_revision,
evidence_id=command.evidence.evidence_id if command.evidence else None,
status=command.status,
),
)
response = {
"source_event_id": command.source_event_id,
"source_revision": command.source_revision,
@@ -2161,12 +2186,24 @@ class OperationalTaskService:
lifecycle = self._investigation_status(actions, task)
recovery = task.get("handling_context") or {}
if lifecycle == "completed" and recovery:
require(recovery.get("verification_evidence_id") in report.report.evidence_refs,
"RECOVERY_EVIDENCE_REQUIRED", "最终复核报告必须引用恢复监测证据", status=409)
if task.get("workflow") == "water_pollution":
orders = [item for item in recovery.get("work_orders", {}).values()
if not item.get("superseded_by")]
feedback_refs = [item.get("feedback_evidence_id") for item in orders]
require(recovery.get("phase") in {"field_review", "completed"} and bool(orders)
and all(item["status"] == "completed" for item in orders)
and all(ref and ref in report.report.evidence_refs for ref in feedback_refs),
"POLLUTION_FEEDBACK_REQUIRED", "水污染最终报告必须复核并引用全部最新现场反馈证据", status=409)
verification_id = feedback_refs[-1]
else:
verification_id = recovery.get("verification_evidence_id")
require(verification_id in report.report.evidence_refs,
"RECOVERY_EVIDENCE_REQUIRED", "最终复核报告必须引用恢复监测证据", status=409)
require(report.report.outcome == "concluded", "RECOVERY_EVIDENCE_REQUIRED",
"恢复复核通过后才能结束事件", status=409)
recovery.update(phase="completed", title="恢复供水复核成功", completed_at=now.isoformat())
await self._resolve_investigation_alerts(str(task_id), recovery["verification_evidence_id"])
recovery.update(phase="completed", title=("水污染处置反馈已复核" if task.get("workflow") == "water_pollution"
else "恢复供水复核成功"), completed_at=now.isoformat())
await self._resolve_investigation_alerts(str(task_id), verification_id)
elif report.report.outcome == "insufficient_data":
terminal = all(node.status in {"completed", "skipped", "cancelled"}
for node in plan.plan.nodes)
@@ -2519,6 +2556,8 @@ class OperationalTaskService:
*, required_permission: str | None = None,
subject_type: str = "task", subject_id: UUID | None = None,
report_reference: InvestigationReportReference | None = None,
work_order_reference: WorkOrderNotificationReference | None = None,
work_order_action: HumanApprovalAction | None = None,
) -> None:
notification = OperationalTaskNotification(
notification_id=uuid4(), task_id=task_id, kind=kind,
@@ -2527,6 +2566,8 @@ class OperationalTaskService:
lifecycle_status="active", subject_type=subject_type,
subject_id=subject_id or task_id,
report_reference=report_reference,
work_order_reference=work_order_reference,
work_order_action=work_order_action,
)
await self.repo.insert_notification(_json(notification))
await self._append_event(
+12 -1
View File
@@ -19,9 +19,20 @@ def current_calibration(document: dict, start: datetime) -> dict | None:
return None
def pollution_signal(document: dict, event: dict) -> bool:
"""Water-quality measurements have their own incident investigation workflow."""
if event.get("category") == "data_quality":
return False
devices = {item["device_id"]: item.get("device_type") for item in document.get("devices", [])}
ids = event.get("device_ids") or next((item.get("device_ids", [])
for item in (document.get("config") or {}).get("rules", [])
if item["rule_id"] == event.get("rule_id")), [])
return bool(ids) and all(devices.get(device) in {"quality", "water_quality"} for device in ids)
def diagnostic_signal(document: dict, event: dict) -> bool:
"""Never revive a persisted fixed-percentage event as new diagnosis evidence."""
if event.get("category") == "data_quality":
if event.get("category") == "data_quality" or pollution_signal(document, event):
return False
if not weighted_diagnosis(document):
return True
+3 -3
View File
@@ -10,14 +10,14 @@ from app.domain.domain_errors import require
DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
METRICS = {"pressure": "pressure", "pipe_flow": "flow", "flow": "flow"}
METRICS = {"pressure": "pressure", "pipe_flow": "flow", "flow": "flow", "quality": "quality", "water_quality": "quality"}
def applies(task: dict) -> bool:
if task.get("workflow") != "scada_anomaly" and not task.get("source", {}).get("replay_context"):
if task.get("workflow") not in {"scada_anomaly", "water_pollution"} and not task.get("source", {}).get("replay_context"):
return False
# Recovery has its own Server-verified post-repair evidence and finalization gate.
return (task.get("handling_context") or {}).get("phase") not in {"verification_ready", "completed"}
return (task.get("handling_context") or {}).get("phase") not in {"verification_ready", "field_review", "completed"}
def cutoff(task: dict) -> datetime:
+27 -24
View File
@@ -429,6 +429,7 @@ class ScadaReplayService:
await self.repo.save(document)
async def _create_investigations(self, document, changes, batch, *, create_tasks=True):
from app.services.scada_normal_profile import pollution_signal
from app.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.services.operational_tasks import OperationalTaskService
@@ -443,9 +444,10 @@ class ScadaReplayService:
self.context, permissions_for_context(self.context),
execution_scope=settings.operational_task_execution_scope)
for event in changes:
# Period diagnosis owns measurement notifications during manual steps.
# Retain rule events as evidence; quality alerts remain independent.
if (not create_tasks and event.get("category") != "data_quality"
pollution = pollution_signal(document, event)
# Pressure/flow rules share period diagnosis during manual steps.
# Water-quality incidents and telemetry-quality alerts stay independent.
if (not create_tasks and not pollution and event.get("category") != "data_quality"
and (event["change"] == "raised" or event.get("notification_suppressed"))):
if event["change"] == "raised":
event["batch_sequence"] = batch["sequence"]
@@ -457,13 +459,33 @@ class ScadaReplayService:
if event["change"] == "raised":
event["batch_sequence"] = batch["sequence"]
document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["batch_sequence"] = batch["sequence"]
if event["change"] == "raised" and event.get("category") != "data_quality" and (create_tasks or pollution):
external = f"scada_replay:{document['replay_id']}:event:{event['event_id']}"
command = OperationalTaskCreate(
request_id=uuid5(NAMESPACE_URL, external), task_type="event_investigation",
workflow="water_pollution" if pollution else "scada_anomaly",
title=f"{'水污染事件调查' if pollution else 'SCADA 回放调查'} · {event['rule_id']}",
summary=event["detail"], source=TaskSource(
kind="external_system", external_id=external, external_revision=1,
event_kind="water_pollution" if pollution else "scada_anomaly",
replay_context={"replay_id": document["replay_id"],
"knowledge_cutoff": batch["at"], "batch_sequence": batch["sequence"]},
),
trigger=TaskTrigger(kind="event", occurred_at=event["occurred_at"], detail=(
"水质监测规则检出异常,启动水污染事件调查;污染源及影响待核验" if pollution else
"已存 SCADA 历史的回放检测信号,原因尚待调查")),
)
task = await tasks.create(command)
event["task_id"] = str(task.task_id)
document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["task_id"] = event["task_id"]
await alerts.push(TaskAlertEvent(
event_id=uuid5(NAMESPACE_URL, event["event_id"] + ":" + event["change"]),
alert_id=event["event_id"], producer_id=producer, event_type=event["change"],
occurred_at=event["occurred_at"], condition_id=None,
occurred_at=event["occurred_at"],
condition_id=f"operational-task:{event['task_id']}" if pollution and event.get("task_id") else None,
source={"kind": "scada_replay", "replay_id": document["replay_id"],
"rule_id": event["rule_id"], "batch_sequence": event["batch_sequence"]},
title=f"SCADA 回放异常 · {event['rule_id']}", description=event["detail"], severity="warning",
title=f"{'水污染侦测异常' if pollution else 'SCADA 回放异常'} · {event['rule_id']}", description=event["detail"], severity="warning",
))
if event["change"] != "raised":
if event.get("task_id"):
@@ -472,25 +494,6 @@ class ScadaReplayService:
"batch_sequence": batch["sequence"],
}, uuid5(NAMESPACE_URL, event["event_id"] + ":recovery"))
continue
if event.get("category") == "data_quality":
continue
if not create_tasks:
continue
external = f"scada_replay:{document['replay_id']}:event:{event['event_id']}"
command = OperationalTaskCreate(
request_id=uuid5(NAMESPACE_URL, external), task_type="event_investigation",
workflow="scada_anomaly", title=f"SCADA 回放调查 · {event['rule_id']}",
summary=event["detail"], source=TaskSource(
kind="external_system", external_id=external, external_revision=1,
event_kind="scada_anomaly",
replay_context={"replay_id": document["replay_id"],
"knowledge_cutoff": batch["at"], "batch_sequence": batch["sequence"]},
),
trigger=TaskTrigger(kind="event", occurred_at=event["occurred_at"], detail="已存 SCADA 历史的回放检测信号,原因尚待调查"),
)
task = await tasks.create(command)
event["task_id"] = str(task.task_id)
document["rule_states"][event.get("state_key", event["rule_id"])]["event"]["task_id"] = event["task_id"]
async def _create_monitoring_task(self, document, monitoring, batch):
from app.services.scada_monitoring_incidents import attach_incident, update_incident
+6
View File
@@ -29,6 +29,7 @@ from app.native.wndb.model.sources import (
)
from app.native.wndb.model.times import get_time, set_time
from app.domain.time import parse_clock_duration_seconds
from app.infra.db.postgresql.scada import ScadaElementMappings
def _isolated_analysis(purpose: str):
@@ -79,6 +80,7 @@ def burst_analysis(
username: str | None = None,
persist_results: bool = True,
_temporary_project: str | None = None,
scada_mappings: ScadaElementMappings | None = None,
) -> dict | None:
"""
爆管模拟
@@ -123,6 +125,7 @@ def burst_analysis(
name=new_name,
simulation_type="manually_temporary",
modify_pattern_start_time=modify_pattern_start_time,
scada_mappings=scada_mappings,
)
print(
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
@@ -203,6 +206,7 @@ def burst_analysis(
modify_variable_pump_pattern=modify_variable_pump_pattern,
modify_valve_opening=modify_valve_opening,
scheme_type="burst_analysis",
scada_mappings=scada_mappings,
scheme_name=scheme_name,
result_db_name=name,
scheme_username=username,
@@ -469,6 +473,7 @@ def contaminant_simulation(
username: str | None = None,
_temporary_project: str | None = None,
persist_results: bool = True,
scada_mappings: ScadaElementMappings | None = None,
) -> dict | str | None:
"""
污染模拟
@@ -591,6 +596,7 @@ def contaminant_simulation(
scheme_username=username,
scheme_detail=scheme_detail,
persist_results=persist_results,
scada_mappings=scada_mappings,
)
return result if not persist_results else None