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:
@@ -51,6 +51,7 @@ McpSnapshotSource = Literal[
|
|||||||
'cancel_task_run',
|
'cancel_task_run',
|
||||||
'run_simulation',
|
'run_simulation',
|
||||||
'run_contaminant_simulation',
|
'run_contaminant_simulation',
|
||||||
|
'run_contaminant_source_trace',
|
||||||
'analyze_valve_closure',
|
'analyze_valve_closure',
|
||||||
'analyze_flushing',
|
'analyze_flushing',
|
||||||
'analyze_pressure_regulation',
|
'analyze_pressure_regulation',
|
||||||
|
|||||||
@@ -252,6 +252,10 @@ class InvestigationDiagramNodeRef(_InvestigationDiagramNode):
|
|||||||
|
|
||||||
|
|
||||||
class InvestigationGraphNodeUpdate(_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=(
|
status: InvestigationNodeAnnotation | None = Field(default=None, description=(
|
||||||
"Optional reference annotation: reference, recorded finding, needs_input, failed attempt, or not_taken. "
|
"Optional reference annotation: reference, recorded finding, needs_input, failed attempt, or not_taken. "
|
||||||
"No live node/task binding or per-step synchronization is required."
|
"No live node/task binding or per-step synchronization is required."
|
||||||
@@ -752,7 +756,7 @@ class ScadaChartGap(StrictModel):
|
|||||||
|
|
||||||
class ScadaChartContext(StrictModel):
|
class ScadaChartContext(StrictModel):
|
||||||
role: Literal["current", "comparison"]
|
role: Literal["current", "comparison"]
|
||||||
metric: Literal["pressure", "flow"]
|
metric: Literal["pressure", "flow", "quality"]
|
||||||
unit: Annotated[str, Field(min_length=1, max_length=128)]
|
unit: Annotated[str, Field(min_length=1, max_length=128)]
|
||||||
window_start: WorkbenchDateTime
|
window_start: WorkbenchDateTime
|
||||||
window_end: WorkbenchDateTime
|
window_end: WorkbenchDateTime
|
||||||
@@ -1015,6 +1019,13 @@ class HumanEvidenceCommand(StrictModel):
|
|||||||
evidence: EvidenceInput
|
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):
|
class OperationalTaskNotification(StrictModel):
|
||||||
notification_id: UUID
|
notification_id: UUID
|
||||||
task_id: UUID
|
task_id: UUID
|
||||||
@@ -1022,7 +1033,7 @@ class OperationalTaskNotification(StrictModel):
|
|||||||
"task_created", "action_pending", "report_updated", "task_completed",
|
"task_created", "action_pending", "report_updated", "task_completed",
|
||||||
"task_failed", "run_delivery_degraded", "access_approval_pending",
|
"task_failed", "run_delivery_degraded", "access_approval_pending",
|
||||||
"action_approval_pending", "human_input_required",
|
"action_approval_pending", "human_input_required",
|
||||||
"external_coordination_failed"
|
"external_coordination_failed", "work_order_feedback"
|
||||||
]
|
]
|
||||||
title: NonEmptyStr
|
title: NonEmptyStr
|
||||||
description: NonEmptyStr
|
description: NonEmptyStr
|
||||||
@@ -1031,6 +1042,8 @@ class OperationalTaskNotification(StrictModel):
|
|||||||
subject_type: Literal["task", "run", "access_request", "action", "question"] = "task"
|
subject_type: Literal["task", "run", "access_request", "action", "question"] = "task"
|
||||||
subject_id: UUID | None = None
|
subject_id: UUID | None = None
|
||||||
report_reference: InvestigationReportReference | None = None
|
report_reference: InvestigationReportReference | None = None
|
||||||
|
work_order_action: HumanApprovalAction | None = None
|
||||||
|
work_order_reference: WorkOrderNotificationReference | None = None
|
||||||
resolved_at: WorkbenchDateTime | None = None
|
resolved_at: WorkbenchDateTime | None = None
|
||||||
resolution_reason: NonEmptyStr | None = None
|
resolution_reason: NonEmptyStr | None = None
|
||||||
status: Literal["unread", "read"]
|
status: Literal["unread", "read"]
|
||||||
|
|||||||
@@ -97,6 +97,23 @@ class BurstLocationInput(CalculationInput):
|
|||||||
return self
|
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):
|
class ValveIsolationInput(CalculationInput):
|
||||||
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
|
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")
|
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="假设污染源节点;这是正向传播模拟,结果按给定源解释")
|
source: Id = Field(description="假设污染源节点;这是正向传播模拟,结果按给定源解释")
|
||||||
concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L")
|
concentration: float = Field(gt=0, allow_inf_nan=False, description="假设源浓度,mg/L")
|
||||||
pattern: Id | None = Field(default=None, description="已存在的污染源时间模式ID;省略时采用持续恒定源")
|
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):
|
class ValveClosureInput(ScenarioInput):
|
||||||
|
|||||||
@@ -114,3 +114,21 @@ async def calculate_replay_burst_location(
|
|||||||
final_candidates_csv_path=None,
|
final_candidates_csv_path=None,
|
||||||
**parameters,
|
**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")
|
raise HTTPException(422, "offset requires a collection data_path")
|
||||||
else:
|
else:
|
||||||
result.data = value
|
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:
|
if len(result.model_dump_json().encode()) > 11000:
|
||||||
raise HTTPException(413, "Select a narrower data_path or smaller result page")
|
raise HTTPException(413, "Select a narrower data_path or smaller result page")
|
||||||
return result
|
return result
|
||||||
|
|||||||
+4
-3
@@ -6,7 +6,7 @@ from mcp import types
|
|||||||
from app.mcp import models as m
|
from app.mcp import models as m
|
||||||
from app.mcp import calculation_models as jobs
|
from app.mcp import calculation_models as jobs
|
||||||
from app.mcp import extended_models as e
|
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
|
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
|
||||||
|
|
||||||
|
|
||||||
@@ -62,8 +62,9 @@ class QueryTool:
|
|||||||
|
|
||||||
|
|
||||||
_specs = [
|
_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_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_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),
|
("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_task_run_history", "分页读取指定项目任务的运行历史", e.TaskHistoryInput, "operational_task.view", "task"),
|
||||||
("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后先核实目标数据,再按原请求接续", e.MutationStatusInput, "webgis.view", "task"),
|
("get_mutation_status", "读取当前身份和会话内的写入执行状态;started只表示已启动,超时后先核实目标数据,再按原请求接续", e.MutationStatusInput, "webgis.view", "task"),
|
||||||
("calculate_demand_allocation", "按管长权重分配总需水量到节点、区域或全网,返回分配建议,模型变更通过业务写入执行,按页返回", e.DemandAllocationInput, "webgis.view", "analysis", False, True),
|
("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_element", "预览或增删改模型节点、管段、泵阀、水池和区域;变更范围为业务模型,现场设备操作按对应流程执行", e.ModelElementMutationInput, "webgis.edit", "asset", False, False, True),
|
||||||
("edit_model_configuration", "预览或修改模型需水量、模式、曲线、控制规则、水质及模拟选项", e.ConfigurationMutationInput, "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),
|
("clean_scada_readings", "预览或清洗明确设备与24小时内时间窗的数据,写入cleaned_value", e.ScadaCleaningInput, "scada.clean", "observed", False, False, True),
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ class DemandAllocationInput(CalculationInput):
|
|||||||
|
|
||||||
|
|
||||||
class BurstSimulationInput(ScenarioInput):
|
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)
|
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²;保留面积来源及估算依据")
|
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
|
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:
|
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
|
return None
|
||||||
analysis = result.data.get("result") if isinstance(result.data, dict) else None
|
analysis = result.data.get("result") if isinstance(result.data, dict) else None
|
||||||
if not isinstance(analysis, dict):
|
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"):
|
if not isinstance(payload, dict) or not payload.get("features"):
|
||||||
return None
|
return None
|
||||||
coverage = analysis.get("coverage", {})
|
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 {
|
return {
|
||||||
"command": {
|
"command": {
|
||||||
"action": "set_overlay", "mode": "geometry", "evidence_id": result.evidence_id,
|
"action": "set_overlay", "mode": "geometry", "evidence_id": result.evidence_id,
|
||||||
"overlay_id": f"node-coverage-{uuid5(NAMESPACE_URL, 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,
|
"display_only": True,
|
||||||
"coverage_status": coverage.get("status"),
|
"coverage_status": coverage.get("status"),
|
||||||
|
|||||||
@@ -75,16 +75,26 @@ async def authorize_query(
|
|||||||
if replay_context:
|
if replay_context:
|
||||||
from app.services.scada_replay_queries import check_replay_window
|
from app.services.scada_replay_queries import check_replay_window
|
||||||
allowed = {"get_task_context", "get_task_evidence", "list_scada_devices", "get_scada_device",
|
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_element_properties", "list_elements", "query_nearby_elements", "get_network_options",
|
||||||
"get_node_connections", "get_element_gis_properties", "query_network_extent",
|
"get_node_connections", "get_element_gis_properties", "query_network_extent",
|
||||||
"list_regions", "get_region", "get_region_nodes",
|
"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"}
|
"get_calculation_job", "get_calculation_result", "list_calculation_jobs"}
|
||||||
if tool.name not in allowed:
|
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":
|
if tool.name == "query_scada_readings":
|
||||||
check_replay_window(replay_context, args.start, args.end)
|
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 tool.name == "run_burst_location":
|
||||||
if args.data_source != "monitoring" or args.scada_normal_start is None or args.scada_normal_end is None:
|
if args.data_source != "monitoring" or args.scada_normal_start is None or args.scada_normal_end is None:
|
||||||
raise HTTPException(422, "回放定位只支持显式异常窗和正常对照窗的 monitoring 输入")
|
raise HTTPException(422, "回放定位只支持显式异常窗和正常对照窗的 monitoring 输入")
|
||||||
@@ -107,6 +117,8 @@ async def authorize_query(
|
|||||||
required = {tool.permission}
|
required = {tool.permission}
|
||||||
if tool.name in SCENARIO_TOOLS:
|
if tool.name in SCENARIO_TOOLS:
|
||||||
required.add("scada.view")
|
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"}:
|
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")
|
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):
|
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())
|
source_ref = "mcp:" + str(uuid4())
|
||||||
evidence_id = None
|
evidence_id = None
|
||||||
replay_context = getattr(binding, "replay_context", 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
|
from app.mcp.scada_readings import read_complete_scada
|
||||||
data, units, gaps, origin = await read_complete_scada(scope, binding, args)
|
data, units, gaps, origin = await read_complete_scada(scope, binding, args)
|
||||||
elif name == "query_scada_readings" and replay_context:
|
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)
|
args.device_ids, args.start, args.end, args.limit + 1, args.offset)
|
||||||
data, page, truncated = page_result(rows, args)
|
data, page, truncated = page_result(rows, args)
|
||||||
origin = "synthetic"
|
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:
|
elif name == "run_burst_location" and replay_context:
|
||||||
from app.mcp.analysis_service import calculate_replay_burst_location
|
from app.mcp.analysis_service import calculate_replay_burst_location
|
||||||
from app.services.scada_replay_queries import replay_burst_location_inputs
|
from app.services.scada_replay_queries import replay_burst_location_inputs
|
||||||
@@ -241,7 +283,7 @@ async def execute_query(
|
|||||||
}
|
}
|
||||||
gaps = [{
|
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_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_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_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.",
|
"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.",
|
"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.",
|
"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.",
|
"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]]
|
}[name]]
|
||||||
if name == "calculate_demand_allocation":
|
if name == "calculate_demand_allocation":
|
||||||
truncated = data["result"]["has_more"] or args.offset > 0
|
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"]
|
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"]
|
||||||
truncated = True
|
truncated = True
|
||||||
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
|
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
|
if (skill.get("name") == "scada-anomaly" and replay_context
|
||||||
and "run_burst_location" in binding.tool_cap):
|
and "run_burst_location" in binding.tool_cap):
|
||||||
from app.services.burst_location_demo import location_demo_context
|
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
|
from app.services import simulation_scenarios as scenarios
|
||||||
|
|
||||||
parameters = dict(parameters)
|
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")
|
links = parameters.pop("output_link_ids")
|
||||||
start = parameters.pop("start_time")
|
start = parameters.pop("start_time")
|
||||||
duration = parameters.pop("duration")
|
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(),
|
common = dict(name=network, modify_pattern_start_time=start.isoformat(),
|
||||||
modify_total_duration=duration, persist_results=False, scheme_name=None)
|
modify_total_duration=duration, persist_results=False, scheme_name=None)
|
||||||
if name == "run_simulation":
|
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,
|
network, start, duration, output_node_ids=nodes, output_link_ids=links,
|
||||||
)
|
)
|
||||||
elif name == "run_burst_simulation":
|
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,
|
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":
|
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,
|
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":
|
elif name == "analyze_valve_closure":
|
||||||
result = scenarios.valve_close_analysis(**common,
|
result = scenarios.valve_close_analysis(**common,
|
||||||
modify_valve_opening={valve: 0.0 for valve in parameters["valves"]})
|
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")
|
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_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]
|
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",
|
"input_source": "online_model_simulation" if name == "run_simulation" else "isolated_model_simulation",
|
||||||
"persisted_baseline": name == "run_simulation", "persisted_scheme": False,
|
"persisted_baseline": name == "run_simulation", "persisted_scheme": False,
|
||||||
"start_time": start.isoformat(), "duration_seconds": duration,
|
"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_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})},
|
"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"
|
SKILL_ROOT = Path(__file__).resolve().parents[2] / "resources" / "work-order-skills"
|
||||||
WORKFLOW_SKILLS = {
|
WORKFLOW_SKILLS = {
|
||||||
"scada_anomaly": "scada-anomaly",
|
"scada_anomaly": "scada-anomaly",
|
||||||
|
"water_pollution": "water-pollution-event",
|
||||||
"burst_report": "burst-event-handling",
|
"burst_report": "burst-event-handling",
|
||||||
"water_quality_complaint": "water-quality-complaint",
|
"water_quality_complaint": "water-quality-complaint",
|
||||||
"general": "appointment-follow-up",
|
"general": "appointment-follow-up",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
"提交的候选节点均不在当前项目模型中;核对事件位置及资产映射后重新提交,继续不依赖源节点的调查。"),
|
||||||
|
}
|
||||||
@@ -23,10 +23,11 @@ def adopt_legacy_investigation_graph(diagram: InvestigationDiagram) -> Investiga
|
|||||||
def update_investigation_graph(
|
def update_investigation_graph(
|
||||||
previous: InvestigationDiagram | None, update: InvestigationGraphUpdate,
|
previous: InvestigationDiagram | None, update: InvestigationGraphUpdate,
|
||||||
) -> InvestigationDiagram:
|
) -> 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
|
Unspecified fields retain their saved values. A failed attempt remains
|
||||||
impose no ordering or completion requirements on the Agent.
|
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:
|
if previous is not None:
|
||||||
previous = adopt_legacy_investigation_graph(previous)
|
previous = adopt_legacy_investigation_graph(previous)
|
||||||
@@ -47,15 +48,19 @@ def update_investigation_graph(
|
|||||||
if existing is None:
|
if existing is None:
|
||||||
require(not in_source(node.node_id), "GRAPH_LEGACY_NODE_CONFLICT",
|
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)
|
"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))
|
nodes[node.node_id] = InvestigationDiagramNodeRef.model_validate(node.model_dump(exclude_unset=True))
|
||||||
continue
|
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)
|
"GRAPH_LEGACY_NODE_CONFLICT", "Keep the original node label; use a new node for a new step", status=409)
|
||||||
require(existing.status != "failed" or (
|
require(existing.status != "failed" or (
|
||||||
("status" not in node.model_fields_set or node.status == "failed")
|
("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)
|
), "GRAPH_FAILED_ATTEMPT_IMMUTABLE", "Keep the failed attempt; use a new node for a retry", status=409)
|
||||||
changes = node.model_dump(exclude_unset=True)
|
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"):
|
if existing.status == "failed" and existing.detail and not changes.get("detail"):
|
||||||
changes.pop("detail", None)
|
changes.pop("detail", None)
|
||||||
for field in ("evidence_refs", "action_refs", "map_targets"):
|
for field in ("evidence_refs", "action_refs", "map_targets"):
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
|||||||
from app.services.operational_tasks import OperationalTaskService
|
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:
|
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.
|
# edits. A revised field receipt can explicitly correct the verification start.
|
||||||
if feedback["status"] == "completed":
|
if feedback["status"] == "completed":
|
||||||
order["returned_at"] = feedback["occurred_at"]
|
order["returned_at"] = feedback["occurred_at"]
|
||||||
|
if task.get("workflow") == "water_pollution":
|
||||||
|
order["feedback_evidence_id"] = (feedback.get("evidence") or {}).get("evidence_id")
|
||||||
else:
|
else:
|
||||||
order.pop("returned_at", None)
|
order.pop("returned_at", None)
|
||||||
if order.get("superseded_by"):
|
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")]
|
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):
|
if orders and all(item["status"] == "completed" for item in orders):
|
||||||
returned_at = max((item["returned_at"] for item in orders), key=datetime.fromisoformat)
|
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")
|
previous = context.get("returned_at")
|
||||||
if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at)
|
if (previous is None or datetime.fromisoformat(previous) != datetime.fromisoformat(returned_at)
|
||||||
or context.get("phase") == "waiting_work_order"):
|
or context.get("phase") == "waiting_work_order"):
|
||||||
|
|||||||
@@ -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:
|
def require_complete_scada_evidence(task: dict, evidence: list[dict], refs: list[str]) -> None:
|
||||||
replay = task.get("source", {}).get("replay_context")
|
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
|
return
|
||||||
target = datetime.fromisoformat(
|
target = datetime.fromisoformat(
|
||||||
replay["knowledge_cutoff"] if replay else task["trigger"]["occurred_at"]
|
replay["knowledge_cutoff"] if replay else task["trigger"]["occurred_at"]
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ from app.domain.schemas.operational_task import (
|
|||||||
OperationalTaskCreate,
|
OperationalTaskCreate,
|
||||||
OperationalTaskDetail,
|
OperationalTaskDetail,
|
||||||
OperationalTaskNotification,
|
OperationalTaskNotification,
|
||||||
|
WorkOrderNotificationReference,
|
||||||
OperationalTaskPermissions,
|
OperationalTaskPermissions,
|
||||||
OperationalTaskSchedule,
|
OperationalTaskSchedule,
|
||||||
OperationalTaskScheduleCreate,
|
OperationalTaskScheduleCreate,
|
||||||
@@ -874,7 +875,7 @@ class OperationalTaskService:
|
|||||||
diagram = update_investigation_graph(saved_diagram, command.progress.graph_update)
|
diagram = update_investigation_graph(saved_diagram, command.progress.graph_update)
|
||||||
elif saved_diagram is not None:
|
elif saved_diagram is not None:
|
||||||
require(diagram is None or saved_diagram.edges is None, "GRAPH_UPDATE_REQUIRED",
|
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:
|
if diagram is None:
|
||||||
diagram = adopt_legacy_investigation_graph(saved_diagram)
|
diagram = adopt_legacy_investigation_graph(saved_diagram)
|
||||||
if diagram:
|
if diagram:
|
||||||
@@ -1258,6 +1259,7 @@ class OperationalTaskService:
|
|||||||
task_id, "action_approval_pending", task["title"], command.reason, now,
|
task_id, "action_approval_pending", task["title"], command.reason, now,
|
||||||
required_permission=approval_permission,
|
required_permission=approval_permission,
|
||||||
subject_type="action", subject_id=action.action_id,
|
subject_type="action", subject_id=action.action_id,
|
||||||
|
work_order_action=action if action.kind == "create_work_order" else None,
|
||||||
)
|
)
|
||||||
await self._save_request(
|
await self._save_request(
|
||||||
scope, command.request_id, body, {"action_id": str(action.action_id)}
|
scope, command.request_id, body, {"action_id": str(action.action_id)}
|
||||||
@@ -1743,6 +1745,29 @@ class OperationalTaskService:
|
|||||||
task_id, "task_failed",
|
task_id, "task_failed",
|
||||||
task["title"], command.detail, now,
|
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 = {
|
response = {
|
||||||
"source_event_id": command.source_event_id,
|
"source_event_id": command.source_event_id,
|
||||||
"source_revision": command.source_revision,
|
"source_revision": command.source_revision,
|
||||||
@@ -2161,12 +2186,24 @@ class OperationalTaskService:
|
|||||||
lifecycle = self._investigation_status(actions, task)
|
lifecycle = self._investigation_status(actions, task)
|
||||||
recovery = task.get("handling_context") or {}
|
recovery = task.get("handling_context") or {}
|
||||||
if lifecycle == "completed" and recovery:
|
if lifecycle == "completed" and recovery:
|
||||||
require(recovery.get("verification_evidence_id") in report.report.evidence_refs,
|
if task.get("workflow") == "water_pollution":
|
||||||
"RECOVERY_EVIDENCE_REQUIRED", "最终复核报告必须引用恢复监测证据", status=409)
|
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",
|
require(report.report.outcome == "concluded", "RECOVERY_EVIDENCE_REQUIRED",
|
||||||
"恢复复核通过后才能结束事件", status=409)
|
"恢复复核通过后才能结束事件", status=409)
|
||||||
recovery.update(phase="completed", title="恢复供水复核成功", completed_at=now.isoformat())
|
recovery.update(phase="completed", title=("水污染处置反馈已复核" if task.get("workflow") == "water_pollution"
|
||||||
await self._resolve_investigation_alerts(str(task_id), recovery["verification_evidence_id"])
|
else "恢复供水复核成功"), completed_at=now.isoformat())
|
||||||
|
await self._resolve_investigation_alerts(str(task_id), verification_id)
|
||||||
elif report.report.outcome == "insufficient_data":
|
elif report.report.outcome == "insufficient_data":
|
||||||
terminal = all(node.status in {"completed", "skipped", "cancelled"}
|
terminal = all(node.status in {"completed", "skipped", "cancelled"}
|
||||||
for node in plan.plan.nodes)
|
for node in plan.plan.nodes)
|
||||||
@@ -2519,6 +2556,8 @@ class OperationalTaskService:
|
|||||||
*, required_permission: str | None = None,
|
*, required_permission: str | None = None,
|
||||||
subject_type: str = "task", subject_id: UUID | None = None,
|
subject_type: str = "task", subject_id: UUID | None = None,
|
||||||
report_reference: InvestigationReportReference | None = None,
|
report_reference: InvestigationReportReference | None = None,
|
||||||
|
work_order_reference: WorkOrderNotificationReference | None = None,
|
||||||
|
work_order_action: HumanApprovalAction | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
notification = OperationalTaskNotification(
|
notification = OperationalTaskNotification(
|
||||||
notification_id=uuid4(), task_id=task_id, kind=kind,
|
notification_id=uuid4(), task_id=task_id, kind=kind,
|
||||||
@@ -2527,6 +2566,8 @@ class OperationalTaskService:
|
|||||||
lifecycle_status="active", subject_type=subject_type,
|
lifecycle_status="active", subject_type=subject_type,
|
||||||
subject_id=subject_id or task_id,
|
subject_id=subject_id or task_id,
|
||||||
report_reference=report_reference,
|
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.repo.insert_notification(_json(notification))
|
||||||
await self._append_event(
|
await self._append_event(
|
||||||
|
|||||||
@@ -19,9 +19,20 @@ def current_calibration(document: dict, start: datetime) -> dict | None:
|
|||||||
return 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:
|
def diagnostic_signal(document: dict, event: dict) -> bool:
|
||||||
"""Never revive a persisted fixed-percentage event as new diagnosis evidence."""
|
"""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
|
return False
|
||||||
if not weighted_diagnosis(document):
|
if not weighted_diagnosis(document):
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ from app.domain.domain_errors import require
|
|||||||
|
|
||||||
|
|
||||||
DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
|
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:
|
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
|
return False
|
||||||
# Recovery has its own Server-verified post-repair evidence and finalization gate.
|
# 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:
|
def cutoff(task: dict) -> datetime:
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ class ScadaReplayService:
|
|||||||
await self.repo.save(document)
|
await self.repo.save(document)
|
||||||
|
|
||||||
async def _create_investigations(self, document, changes, batch, *, create_tasks=True):
|
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.domain.schemas.operational_task import OperationalTaskCreate, TaskSource, TaskTrigger
|
||||||
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
from app.services.operational_tasks import OperationalTaskService
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
@@ -443,9 +444,10 @@ class ScadaReplayService:
|
|||||||
self.context, permissions_for_context(self.context),
|
self.context, permissions_for_context(self.context),
|
||||||
execution_scope=settings.operational_task_execution_scope)
|
execution_scope=settings.operational_task_execution_scope)
|
||||||
for event in changes:
|
for event in changes:
|
||||||
# Period diagnosis owns measurement notifications during manual steps.
|
pollution = pollution_signal(document, event)
|
||||||
# Retain rule events as evidence; quality alerts remain independent.
|
# Pressure/flow rules share period diagnosis during manual steps.
|
||||||
if (not create_tasks and event.get("category") != "data_quality"
|
# 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"))):
|
and (event["change"] == "raised" or event.get("notification_suppressed"))):
|
||||||
if event["change"] == "raised":
|
if event["change"] == "raised":
|
||||||
event["batch_sequence"] = batch["sequence"]
|
event["batch_sequence"] = batch["sequence"]
|
||||||
@@ -457,13 +459,33 @@ class ScadaReplayService:
|
|||||||
if event["change"] == "raised":
|
if event["change"] == "raised":
|
||||||
event["batch_sequence"] = batch["sequence"]
|
event["batch_sequence"] = batch["sequence"]
|
||||||
document["rule_states"][event.get("state_key", event["rule_id"])]["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(
|
await alerts.push(TaskAlertEvent(
|
||||||
event_id=uuid5(NAMESPACE_URL, event["event_id"] + ":" + event["change"]),
|
event_id=uuid5(NAMESPACE_URL, event["event_id"] + ":" + event["change"]),
|
||||||
alert_id=event["event_id"], producer_id=producer, event_type=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"],
|
source={"kind": "scada_replay", "replay_id": document["replay_id"],
|
||||||
"rule_id": event["rule_id"], "batch_sequence": event["batch_sequence"]},
|
"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["change"] != "raised":
|
||||||
if event.get("task_id"):
|
if event.get("task_id"):
|
||||||
@@ -472,25 +494,6 @@ class ScadaReplayService:
|
|||||||
"batch_sequence": batch["sequence"],
|
"batch_sequence": batch["sequence"],
|
||||||
}, uuid5(NAMESPACE_URL, event["event_id"] + ":recovery"))
|
}, uuid5(NAMESPACE_URL, event["event_id"] + ":recovery"))
|
||||||
continue
|
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):
|
async def _create_monitoring_task(self, document, monitoring, batch):
|
||||||
from app.services.scada_monitoring_incidents import attach_incident, update_incident
|
from app.services.scada_monitoring_incidents import attach_incident, update_incident
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from app.native.wndb.model.sources import (
|
|||||||
)
|
)
|
||||||
from app.native.wndb.model.times import get_time, set_time
|
from app.native.wndb.model.times import get_time, set_time
|
||||||
from app.domain.time import parse_clock_duration_seconds
|
from app.domain.time import parse_clock_duration_seconds
|
||||||
|
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||||
|
|
||||||
|
|
||||||
def _isolated_analysis(purpose: str):
|
def _isolated_analysis(purpose: str):
|
||||||
@@ -79,6 +80,7 @@ def burst_analysis(
|
|||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
persist_results: bool = True,
|
persist_results: bool = True,
|
||||||
_temporary_project: str | None = None,
|
_temporary_project: str | None = None,
|
||||||
|
scada_mappings: ScadaElementMappings | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""
|
"""
|
||||||
爆管模拟
|
爆管模拟
|
||||||
@@ -123,6 +125,7 @@ def burst_analysis(
|
|||||||
name=new_name,
|
name=new_name,
|
||||||
simulation_type="manually_temporary",
|
simulation_type="manually_temporary",
|
||||||
modify_pattern_start_time=modify_pattern_start_time,
|
modify_pattern_start_time=modify_pattern_start_time,
|
||||||
|
scada_mappings=scada_mappings,
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
|
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_variable_pump_pattern=modify_variable_pump_pattern,
|
||||||
modify_valve_opening=modify_valve_opening,
|
modify_valve_opening=modify_valve_opening,
|
||||||
scheme_type="burst_analysis",
|
scheme_type="burst_analysis",
|
||||||
|
scada_mappings=scada_mappings,
|
||||||
scheme_name=scheme_name,
|
scheme_name=scheme_name,
|
||||||
result_db_name=name,
|
result_db_name=name,
|
||||||
scheme_username=username,
|
scheme_username=username,
|
||||||
@@ -469,6 +473,7 @@ def contaminant_simulation(
|
|||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
_temporary_project: str | None = None,
|
_temporary_project: str | None = None,
|
||||||
persist_results: bool = True,
|
persist_results: bool = True,
|
||||||
|
scada_mappings: ScadaElementMappings | None = None,
|
||||||
) -> dict | str | None:
|
) -> dict | str | None:
|
||||||
"""
|
"""
|
||||||
污染模拟
|
污染模拟
|
||||||
@@ -591,6 +596,7 @@ def contaminant_simulation(
|
|||||||
scheme_username=username,
|
scheme_username=username,
|
||||||
scheme_detail=scheme_detail,
|
scheme_detail=scheme_detail,
|
||||||
persist_results=persist_results,
|
persist_results=persist_results,
|
||||||
|
scada_mappings=scada_mappings,
|
||||||
)
|
)
|
||||||
return result if not persist_results else None
|
return result if not persist_results else None
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "d96df154b86b797d0db913d1e69cf335d92d34dd119f800c9346b90c6119c9bb"
|
"sha256": "aeff2c644006bf7eddd6cfe1c8aa0bad7f38c4632a456b5d3be1bb8201efc0db"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3739,9 +3739,17 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"minLength": 1,
|
"anyOf": [
|
||||||
"title": "Label",
|
{
|
||||||
"type": "string"
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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.",
|
||||||
|
"title": "Label"
|
||||||
},
|
},
|
||||||
"map_targets": {
|
"map_targets": {
|
||||||
"items": {
|
"items": {
|
||||||
@@ -3777,8 +3785,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"node_id",
|
"node_id"
|
||||||
"label"
|
|
||||||
],
|
],
|
||||||
"title": "InvestigationGraphNodeUpdate",
|
"title": "InvestigationGraphNodeUpdate",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -5160,7 +5167,8 @@
|
|||||||
"access_approval_pending",
|
"access_approval_pending",
|
||||||
"action_approval_pending",
|
"action_approval_pending",
|
||||||
"human_input_required",
|
"human_input_required",
|
||||||
"external_coordination_failed"
|
"external_coordination_failed",
|
||||||
|
"work_order_feedback"
|
||||||
],
|
],
|
||||||
"title": "Kind",
|
"title": "Kind",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -5295,6 +5303,26 @@
|
|||||||
"minimum": 1.0,
|
"minimum": 1.0,
|
||||||
"title": "Version",
|
"title": "Version",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"work_order_action": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/HumanApprovalAction"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"work_order_reference": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/WorkOrderNotificationReference"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -7431,7 +7459,8 @@
|
|||||||
"metric": {
|
"metric": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"pressure",
|
"pressure",
|
||||||
"flow"
|
"flow",
|
||||||
|
"quality"
|
||||||
],
|
],
|
||||||
"title": "Metric",
|
"title": "Metric",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -9712,6 +9741,7 @@
|
|||||||
"cancel_task_run",
|
"cancel_task_run",
|
||||||
"run_simulation",
|
"run_simulation",
|
||||||
"run_contaminant_simulation",
|
"run_contaminant_simulation",
|
||||||
|
"run_contaminant_source_trace",
|
||||||
"analyze_valve_closure",
|
"analyze_valve_closure",
|
||||||
"analyze_flushing",
|
"analyze_flushing",
|
||||||
"analyze_pressure_regulation",
|
"analyze_pressure_regulation",
|
||||||
@@ -11130,6 +11160,53 @@
|
|||||||
"title": "WorkOrderDraftUpdateCommand",
|
"title": "WorkOrderDraftUpdateCommand",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"WorkOrderNotificationReference": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"evidence_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Evidence Id"
|
||||||
|
},
|
||||||
|
"source_event_id": {
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Source Event Id",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"source_revision": {
|
||||||
|
"minimum": 1.0,
|
||||||
|
"title": "Source Revision",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"scheduled",
|
||||||
|
"assigned",
|
||||||
|
"in_progress",
|
||||||
|
"awaiting_feedback",
|
||||||
|
"completed",
|
||||||
|
"cancelled",
|
||||||
|
"failed"
|
||||||
|
],
|
||||||
|
"title": "Status",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"source_event_id",
|
||||||
|
"source_revision",
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"title": "WorkOrderNotificationReference",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"WorkbenchAlertSource": {
|
"WorkbenchAlertSource": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -221,7 +221,8 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"pressure",
|
"pressure",
|
||||||
"flow"
|
"flow",
|
||||||
|
"quality"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"unit": {
|
"unit": {
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
flowchart TD
|
flowchart TD
|
||||||
order[爆管上报工单] --> context[读取原工单与当前任务]
|
order[爆管上报工单] --> context[读取原工单与当前任务]
|
||||||
context --> asset[核对地点并关联管网资产]
|
context --> asset[核对地点并关联管网资产]
|
||||||
asset --> isolation[分析隔离边界与阀门方案]
|
asset --> interim[保存定位阶段报告]
|
||||||
isolation --> impact[说明事件与隔离方案影响]
|
interim --> approval[立即发起工单草稿、人工审核派发]
|
||||||
impact --> report[保存阶段报告与处置建议]
|
interim --> burst[爆管模拟与同刻在线压力差]
|
||||||
report --> approval[人工审批与现场执行]
|
burst --> coverage[保存并展示爆管后、处置前影响覆盖]
|
||||||
|
coverage --> isolation[分析隔离边界与阀门方案]
|
||||||
|
isolation --> report[补充阶段报告与处置建议]
|
||||||
approval --> feedback[接收实际处置与恢复反馈]
|
approval --> feedback[接收实际处置与恢复反馈]
|
||||||
feedback --> verify[复核影响与结案条件]
|
feedback --> verify[复核影响与结案条件]
|
||||||
verify --> close[Server 完成任务并解除告警]
|
verify --> close[Server 完成任务并解除告警]
|
||||||
@@ -20,10 +22,11 @@ flowchart TD
|
|||||||
|
|
||||||
1. **接收核对**:保留来源工单ID、单调版本、发生时间、位置、描述、现场反馈和已有动作;后续轮次从新增事实接续。
|
1. **接收核对**:保留来源工单ID、单调版本、发生时间、位置、描述、现场反馈和已有动作;后续轮次从新增事实接续。
|
||||||
2. **资产关联**:优先核对上报资产编号。坐标或地址通过地理编码、附近资产、属性和连接关系形成有依据的关联;位置歧义列为具体待确认项。
|
2. **资产关联**:优先核对上报资产编号。坐标或地址通过地理编码、附近资产、属性和连接关系形成有依据的关联;位置歧义列为具体待确认项。
|
||||||
3. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。
|
3. **并行工单与影响估计**:定位到管道后保存 interim/concluded 阶段报告,随即提出现场核查/抢修 general 工单草稿;复用同一 action_id,人工审核、确认派发保持现有权限和版本校验。待审核或现场处理期间继续独立的影响模拟,不等待模型完成才发起工单。
|
||||||
4. **影响说明**:先展示拓扑范围,再按可用SCADA、水力结果和用户关联资料解释事件当前影响、方案预计影响和保障需求。
|
4. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。
|
||||||
5. **报告与处置**:保存阶段报告后提出核验、通知、保障、抢修和恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。
|
5. **影响说明**:先保留爆管后、处置前的压力下降覆盖步骤,再展示隔离方案的拓扑范围;结合可用SCADA、水力与用户关联说明影响。
|
||||||
6. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。现场资料缺项仅限定未核实细节,不新增本任务的关闭门槛。来源系统另有明确行政结案流程时,按其实际状态另记。
|
6. **报告与处置**:定位阶段已发起现场工单,后续复用原动作;更新报告后按新增需要提出通知、保障或恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。
|
||||||
|
7. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。现场资料缺项仅限定未核实细节,不新增本任务的关闭门槛。来源系统另有明确行政结案流程时,按其实际状态另记。
|
||||||
|
|
||||||
SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口读取。具备水力输入且工具已授权时,使用 analyze_valve_closure 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。
|
SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口读取。具备水力输入且工具已授权时,使用 analyze_valve_closure 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。
|
||||||
|
|
||||||
@@ -64,3 +67,19 @@ SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口
|
|||||||
检查Skill名称、版本、摘要与正文digest一致、内联体积、角色权限、工单工具隔离和SCADA定位回归。Agent类型检查与会话工具策略测试共同覆盖运行时入口。发布后分别验收工单事件处理报告和SCADA候选定位,原有报告按既有修订保留。
|
检查Skill名称、版本、摘要与正文digest一致、内联体积、角色权限、工单工具隔离和SCADA定位回归。Agent类型检查与会话工具策略测试共同覆盖运行时入口。发布后分别验收工单事件处理报告和SCADA候选定位,原有报告按既有修订保留。
|
||||||
|
|
||||||
关联契约:[分析 MCP](mcp-analysis-tools.md)、[调查展示](investigation-progress-and-presentation.md)、[报告契约](investigation-report-contract.md)、[任务与多轮反馈](operational-tasks.md)。
|
关联契约:[分析 MCP](mcp-analysis-tools.md)、[调查展示](investigation-progress-and-presentation.md)、[报告契约](investigation-report-contract.md)、[任务与多轮反馈](operational-tasks.md)。
|
||||||
|
|
||||||
|
## 爆管压力下降覆盖与历史还原
|
||||||
|
|
||||||
|
`run_burst_simulation` 新增可选 `include_impact_coverage=true`,原默认行为保留。指定已定位管道、带时区的事件起算时刻及有来源或明确估计依据的破口面积(cm²)。计算保持隔离临时模型、不保存分析方案、不重算在线基线,不施加新的关阀或抢修操作。
|
||||||
|
|
||||||
|
在筛选最多20个展示节点之前,以原生模拟全网节点的起算首帧为计算对象,严格读取同一时刻已有在线模拟压力。不同压力单位换算为米水头,下降量为在线压力减爆管压力,互斥划分 `1≤Δ<3 m`、`3≤Δ<5 m`、`Δ≥5 m`。小于1米与缺少可比数据分别计数;错时、缺测、无效值或未知单位不补零、不取邻近值,不从代表测点外推全网。需要 `simulation.run`、`scada.view`,影响比较另需 `simulation.view`。
|
||||||
|
|
||||||
|
输出 `impact` 保留每个可比节点的在线压力、爆管压力、下降量、分组、缺测清单、比较时刻和读取来源;`coverage` 和 `map_payload` 使用实际GIS节点坐标生成三档空间包络。缺坐标、覆盖节点超过10000或几何超预算时明确报告,不截取前若干节点冒充完整范围。地图采用黄/橙/红,图例保留每档已绘制/分组节点数。
|
||||||
|
|
||||||
|
爆管模型使用 PDA 需水方式和原有10模型压力单位的服务压力设定;在线模型方式、当前模型时态及在线压力单位推定来源一并保留。在线记录没有独立版本化单位元数据,不能把此估计称为严格历史同模型反事实或现场停水边界。
|
||||||
|
|
||||||
|
携带 `replay_context` 的调查只开放启用影响覆盖的爆管情景,起算时刻不得超过 `knowledge_cutoff`。使用当前模型的独立假设情景,初始化及扩展模拟均传入空SCADA边界映射,禁止从实时SCADA回填冻结观测;同刻在线结果仅作为计算时读取的模型参考,标明非冻结基线。未来样本属于预测,不能作为已发生的观测。
|
||||||
|
|
||||||
|
完成持久计算后,Agent按回执 `data.presentation.command` 用证据编号发布覆盖,并保存三档计数、假设与缺口表。Server从任务所属的不可变证据解析几何,将完整展示命令写入事件。历史播放还原这份命令,前进到隔离范围和后退到事件影响均不重新查询在线模型,也不派单。工单审核、派发与反馈继续由通知和标准工单窗口记录;模型结果补入同一调查,不重复创建工单。
|
||||||
|
|
||||||
|
全网结果超过单次工具回执上限时,默认 `get_calculation_result` 返回标明缩略的计数与模型条件、保存证据资源链接及可发布地图命令。原始逐节点结果和几何保持完整,可通过证据资源或 `data_path=["result","impact","comparisons"]` 分页读取;不将全网计算缩为20个展示节点。
|
||||||
|
|||||||
@@ -55,6 +55,23 @@ Server 不验证依赖执行顺序或推断节点完工。
|
|||||||
`progress.graph_update` 包含可选 `title`、`nodes` 和 `edges`。节点沿用 `node_id/label/status/evidence_refs/action_refs/map_targets`,
|
`progress.graph_update` 包含可选 `title`、`nodes` 和 `edges`。节点沿用 `node_id/label/status/evidence_refs/action_refs/map_targets`,
|
||||||
可用 `detail` 记录实际结果或失败原因;连线为 `{source,target,label?}`。仅提交新增或变化的内容,
|
可用 `detail` 记录实际结果或失败原因;连线为 `{source,target,label?}`。仅提交新增或变化的内容,
|
||||||
`update_investigation_graph` 合并节点、追加明确连线并生成 Mermaid,保留已有路径和引用。
|
`update_investigation_graph` 合并节点、追加明确连线并生成 Mermaid,保留已有路径和引用。
|
||||||
|
“保留路径”约束图的结构,不冻结已有节点的标记。`nodes` 按 `node_id` 合并:新增节点必须提供 `label`;
|
||||||
|
修改已保存节点时可只提交 `node_id` 与变化字段,无需再提供 `label`、全部节点或连线。
|
||||||
|
省略字段保留原值,`label: null` 也保留名称;`status: null` 显式恢复为参考步骤。
|
||||||
|
证据、动作和地图引用继续按集合累加,空列表不移除已有引用。资料或条件补齐后,应显式修订原 `needs_input` 节点,
|
||||||
|
新增下游节点不会自动改变上游标记,也不根据节点顺序推断执行结果。例如已有节点 `read` 可更新为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "补充资料已核对,更新原核查节点。",
|
||||||
|
"graph_update": {
|
||||||
|
"nodes": [{"node_id": "read", "status": "recorded", "detail": "已核对收到的现场记录。"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
上述更新不增加节点或连线,也不改写已归档的报告图。旧图已保存于 `node_refs` 的节点支持相同的局部修订;
|
||||||
|
仅存在于旧 Mermaid 源码、尚无节点元数据的编号仍不能作为新增步骤重用。
|
||||||
尝试失败时标记 `failed`,重试或替代路线使用新节点及连线;原失败节点不能改写为成功。
|
尝试失败时标记 `failed`,重试或替代路线使用新节点及连线;原失败节点不能改写为成功。
|
||||||
仅更新正文或传入 `diagram: null` 均保留累计图,原始 Mermaid 不再由新 Agent 生成。
|
仅更新正文或传入 `diagram: null` 均保留累计图,原始 Mermaid 不再由新 Agent 生成。
|
||||||
首次增量更新沿用最近保存的旧图,将原源码保存在 `source_mermaid`,保留原分支和形状后追加内容;
|
首次增量更新沿用最近保存的旧图,将原源码保存在 `source_mermaid`,保留原分支和形状后追加内容;
|
||||||
@@ -236,3 +253,12 @@ git diff --check
|
|||||||
```
|
```
|
||||||
|
|
||||||
这些检查覆盖隔离 HTTP 与状态机,不代替共享数据库迁移、真实模型运行或现场复测验收。
|
这些检查覆盖隔离 HTTP 与状态机,不代替共享数据库迁移、真实模型运行或现场复测验收。
|
||||||
|
|
||||||
|
|
||||||
|
## 工单通知与历史展示
|
||||||
|
|
||||||
|
工单审核通知保存提议时的 `work_order_action` 快照。外部工单的新来源修订在反馈事务中同时保存 `work_order_feedback` 通知与 `operational.notification.changed` 事件,`work_order_reference` 记录来源、修订、证据及状态;关联动作存在时一并保存反馈时的动作快照。重复请求或已处理来源修订不重复生成通知。字段保存在现有通知 JSON 文档中,无新增数据库迁移。
|
||||||
|
|
||||||
|
前端通过事件流刷新通知及关联任务详情,初始快照满页时补取后续通知,并补载首屏任务列表以外的未读通知任务。报告、通知和标准浮动工单窗口定位到同一任务/动作。已读状态与通知生命周期独立,关闭窗口不执行审核或派单。
|
||||||
|
|
||||||
|
历史演示保留已解决通知,并按通知发生时间还原审核、反馈窗口及历史提醒。历史节点只读,不能调用审批、派单或反馈写接口;打开当前工单须退出历史,使用当前动作及来源最新修订。旧通知缺少工单快照时只能标注当前资料,不宣称精确恢复当时的工单。
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共67项:42
|
|||||||
| 工具名(不含前缀) | 能力与边界 | 必要权限 |
|
| 工具名(不含前缀) | 能力与边界 | 必要权限 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `run_simulation` | 在线模拟:按水力步长直接计算业务库,不复制数据库;更新模型条件和实时基线,返回指定节点/管段,不创建方案或正式分析运行 | `simulation.run` + `scada.view` |
|
| `run_simulation` | 在线模拟:按水力步长直接计算业务库,不复制数据库;更新模型条件和实时基线,返回指定节点/管段,不创建方案或正式分析运行 | `simulation.run` + `scada.view` |
|
||||||
|
| `run_burst_simulation` | 假设管道破口正向模拟;可选全网同刻在线压力差与1/3/5米水头覆盖,详见[爆管流程](burst-response-workflow.md) | `simulation.run` + `scada.view`,影响比较另需 `simulation.view` |
|
||||||
| `run_burst_detection` | 指定时刻的15天压力异常侦测,至少5个完整监测节点;不定位 | `burst.run` + `scada.view` |
|
| `run_burst_detection` | 指定时刻的15天压力异常侦测,至少5个完整监测节点;不定位 | `burst.run` + `scada.view` |
|
||||||
| `run_burst_location` | 异常/正常压力、可选流量及漏水量假设的候选管段排序 | `burst.run` + 对应数据的 `scada.view` 或 `simulation.view` |
|
| `run_burst_location` | 异常/正常压力、可选流量及漏水量假设的候选管段排序 | `burst.run` + 对应数据的 `scada.view` 或 `simulation.view` |
|
||||||
| `analyze_valve_isolation` | 拓扑隔离边界、候选关阀及影响节点;不是水力计算或真实关阀 | `simulation.run` |
|
| `analyze_valve_isolation` | 拓扑隔离边界、候选关阀及影响节点;不是水力计算或真实关阀 | `simulation.run` |
|
||||||
| `run_leakage_identification` | SCADA压力约束下的DMA漏损分配估计;需总漏损量依据 | `burst.run` + `scada.view` |
|
| `run_leakage_identification` | SCADA压力约束下的DMA漏损分配估计;需总漏损量依据 | `burst.run` + `scada.view` |
|
||||||
| `run_contaminant_simulation` | 已假设污染源的正向浓度传播;不是源头反演 | `simulation.run` + `scada.view` |
|
| `run_contaminant_simulation` | 假设源正向传播;可选按全网全时段浓度汇总累计范围、首个超阈值报告时刻与峰值,详见[污染流程](water-pollution-response-workflow.md);与工单并行,不承担反演或安全判定 | `simulation.run` + `scada.view` |
|
||||||
| `analyze_valve_closure` | 给定关阀组合的水力影响 | `simulation.run` + `scada.view` |
|
| `analyze_valve_closure` | 给定关阀组合的水力影响 | `simulation.run` + `scada.view` |
|
||||||
| `analyze_flushing` | 指定排放节点和流量的冲洗水力影响;不证明消毒合格 | `simulation.run` + `scada.view` |
|
| `analyze_flushing` | 指定排放节点和流量的冲洗水力影响;不证明消毒合格 | `simulation.run` + `scada.view` |
|
||||||
| `analyze_pressure_regulation` | 给定调泵序列、水箱初始水位的压力影响;不自动优化调度 | `simulation.run` + `scada.view` |
|
| `analyze_pressure_regulation` | 给定调泵序列、水箱初始水位的压力影响;不自动优化调度 | `simulation.run` + `scada.view` |
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 水污染侦测事件调查
|
||||||
|
|
||||||
|
水质监测侦测到异常后创建 `workflow=water_pollution`、`event_kind=water_pollution` 的事件调查,分发 [water-pollution-event](../resources/work-order-skills/water-pollution-event/SKILL.md)。`water_quality_complaint` 保持供水水质投诉上报语义,使用原投诉流程。两者不共用触发条件;污染事件不依赖爆管评分、压力下降或工单上报。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
detection[有效水质观测命中业务规则] --> task[独立水污染事件调查]
|
||||||
|
task --> evidence[全量观测及七天同期核验]
|
||||||
|
evidence --> trace[快速候选溯源]
|
||||||
|
trace --> node[展示已核验节点]
|
||||||
|
node --> report[候选源阶段结论]
|
||||||
|
report --> action[立即提出现场核查采样工单供审批派发]
|
||||||
|
report --> simulation[污染传播模拟与累计浓度影响范围]
|
||||||
|
report --> isolation[节点隔离与阀门边界]
|
||||||
|
simulation --> impact[同一调查补充证据地图与报告]
|
||||||
|
isolation --> impact
|
||||||
|
action --> feedback[采样处置复测反馈]
|
||||||
|
feedback --> review[接续原调查复核]
|
||||||
|
impact --> review
|
||||||
|
review --> final[最终报告及事件收尾]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 侦测入口
|
||||||
|
|
||||||
|
现有 `ScadaReplayService` 使用冻结的水质设备类型 `quality` / `water_quality` 判别水质规则事件。仪表无效值、缺测产生的 `data_quality` 告警单独处理。水质业务规则命中直接创建污染调查,即使同时运行压力/流量周期诊断,也不被合并成爆管调查。持续规则事件沿用同一任务,规则恢复后接续该任务的新一轮。压力/流量与水质同时异常时各自保留调查。
|
||||||
|
|
||||||
|
当前侦测来源沿用既有 SCADA 历史回放处理器;本次没有新增实时采集或常驻水质侦测调度器。其他可信侦测生产者可经现有 operational-tasks 创建接口提交同一 workflow,保留实际侦测信号与发生时间;普通任务无需 replay_context。
|
||||||
|
|
||||||
|
## 临时溯源契约
|
||||||
|
|
||||||
|
`run_contaminant_source_trace` 要求 `simulation.run`,只读当前项目模型,禁止只读调查子角色调用。输入包含从水质设备映射或资产查询取得的 `candidate_node_ids`(1–20 个不同节点)及带时区、最长24小时的 `start/end`。回放窗口不能越过知识截止。接口直接返回结果,当前不入计算队列。
|
||||||
|
|
||||||
|
临时实现从提交的候选中核验模型节点,按字符串排序返回首个存在的节点,不执行反演或拟合。返回 `located_node`、`top_candidates`、`validated_node_ids`、`missing_node_ids`、`selection_basis`、`algorithm_status=provisional`;证据 `origin=synthetic`,不编造相似度或置信度。所有节点不存在时明确返回 `no_candidate`。后续正式算法替换服务内部选择逻辑,保留节点结果契约及后续调查步骤。
|
||||||
|
|
||||||
|
候选节点只是当前可分析目标;定位后先保存 `interim/concluded`,立即提出关联 `general` 现场核查/采样处置工单,随后继续污染传播模拟和 `accident_element_type=node` 的隔离。审核和派发不依赖模拟完成;等待工单时继续独立分析,模拟结果补入同一 `task_id`,不重复创建工单或改写已审核动作,权限与版本校验保持原有流程。
|
||||||
|
|
||||||
|
## 污染传播模拟与影响覆盖
|
||||||
|
|
||||||
|
`run_contaminant_simulation` 增加 `include_impact_coverage`(默认 false)与 `impact_threshold_mg_l`(默认0,严格大于)。开启时,在筛选最多20个展示节点之前,使用原生结果的全网节点、整个模拟窗口的报告采样汇总累计超阈值节点数、各节点首个超阈值报告时刻和峰值。原生浓度 `MGL` / `UGL` 分别转换为 mg/L 后比较;缺失、无效浓度、错误时间网格或不支持的单位保留为缺口,不能转成零浓度或安全范围。几何超过10000节点上限时仍返回数值估计,并明确地图不可用。
|
||||||
|
|
||||||
|
阈值仅为模型展示条件,默认0表示模型正浓度,不是水质安全限值。沿用当前模型初始水质、已有源和反应条件;统计的是总模型浓度,不能称为本次源的独立贡献。源浓度、模式、业务起算时刻、预测时长及其假设必须有依据,不能把余氯下降或浊度换算成污染源浓度。
|
||||||
|
|
||||||
|
回放同样允许该工具,要求启用影响覆盖且起算不晚于知识截止;预测时段可以越过截止,但后续时刻只是模型预测。与爆管情景一样,使用当前模型和空 SCADA 映射传入临时模型计算,不读取可变实时监测,不重建历史模型。仍禁止借此放开未授权观测或其他水力工具。
|
||||||
|
|
||||||
|
工具沿用计算队列和 owner/run 校验,返回终态后通过 `get_calculation_result` 读取并持久保存证据。`presentation_preview` 提供基于 `evidence_id` 的地图请求;用正式调查展示事件保存模型曲线和覆盖,历史播放还原已保存证据。审批、派发可在计算排队或运行期间进行;存在未终态计算时继续保留原运行,不允许发布 final 或成功结束调查,已有工单不受模拟失败影响。
|
||||||
|
|
||||||
|
## 展示、反馈与收尾
|
||||||
|
|
||||||
|
水质图表使用 `chart.scada.metric=quality`,沿用真实单位、三小时当前趋势、前七天同期散点及证据校验;不同指标分图。同单位不同水质指标通过图表纵轴名称区分展示分组。Server、Agent、Frontend 的图表协议同步支持 quality,正式报告仍需引用本轮全量 SCADA 证据。
|
||||||
|
|
||||||
|
关联现场任务使用 general 工单。成功创建后保存在 `handling_context.work_orders`,进入 `waiting_work_order`;全部有效工单 completed 后进入 `field_review`,接续 Agent,而非执行爆管压力恢复计算。最终 concluded 报告必须引用全部有效工单最新的 `feedback_evidence_id`,然后由 Server 完成任务并解除关联告警。完工反馈或新版本复测资料会重新进入复核;证据不齐时 Agent 使用现有提问/新动作流程等待事实。该机制校验反馈引用与调查收尾,不自动判断实验室水质合格。
|
||||||
|
|
||||||
|
所有新增状态保存在既有 JSON 文档,无数据库迁移、环境配置或新服务。现有服务更新代码及流程包后才生效;单元测试和契约校验不等同真实模型 Agent 调查验收。
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
name: burst-event-handling
|
name: burst-event-handling
|
||||||
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
|
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.1.3"
|
version: "1.1.4"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 爆管事件处理
|
# 爆管事件处理
|
||||||
|
|
||||||
适用于工单触发的 `workflow=burst_report`。以工单上报的事件位置和资产线索开展处置,依次完成下列步骤;每一步给出实际结果或具体待补项,已有证据可直接复用。SCADA 异常发现使用独立的 scada-anomaly 流程。
|
适用于工单触发的 `workflow=burst_report`。以工单上报的事件位置和资产线索开展处置,依次完成下列步骤;每一步给出实际结果或具体待补项,已有证据可直接复用。SCADA 异常发现使用独立的 scada-anomaly 流程。
|
||||||
|
|
||||||
先按handling_context接续:等待阶段按第6节保存本轮阶段报告;verification_ready复用已有异常分析与资产、隔离证据,仅补反馈和恢复复核,不重跑初次调查步骤。真正新增的独立事实另行核查。
|
先按handling_context接续:等待阶段按第7节保存本轮阶段报告;verification_ready复用已有异常分析与资产、隔离证据,仅补反馈和恢复复核,不重跑初次调查步骤。真正新增的独立事实另行核查。
|
||||||
|
|
||||||
正文、表格、图表及引用时间统一按Asia/Shanghai(UTC+8)阅读。工具返回的+08:00时间保持原值;UTC来源转换后同步更新日期与窗口,例如2026-07-16T00:00:00Z对应7月16日08:00。结构化查询与sample_times保留明确时区,跨日按转换后的日期展示。
|
正文、表格、图表及引用时间统一按Asia/Shanghai(UTC+8)阅读。工具返回的+08:00时间保持原值;UTC来源转换后同步更新日期与窗口,例如2026-07-16T00:00:00Z对应7月16日08:00。结构化查询与sample_times保留明确时区,跨日按转换后的日期展示。
|
||||||
|
|
||||||
@@ -23,30 +23,40 @@ metadata:
|
|||||||
|
|
||||||
在地图展示上报位置和关联资产,标明关联依据及现场确认状态。存在多个合理对象时保留候选及选择理由;关键位置需要人工补充时,先完成独立工作,再用 ask_task_user 提问。
|
在地图展示上报位置和关联资产,标明关联依据及现场确认状态。存在多个合理对象时保留候选及选择理由;关键位置需要人工补充时,先完成独立工作,再用 ask_task_user 提问。
|
||||||
|
|
||||||
## 3. 隔离方案
|
定位到管道后立即保存 interim/concluded 阶段报告,明确“候选管段待现场核验”,调用 propose_task_action 提出 kind=create_work_order、work_order_type=general 的现场核查/抢修草稿,引用定位证据、地点和管段;沿用已有 action_id,不重复建单。人工审核、派发按 Server 权限与版本完成。审批或现场处理等待期间,继续独立的爆管影响模拟和隔离分析,不等模拟完成才发起工单;模拟结果补入同一调查与后续报告,不能把模型计算成功写成已派单。
|
||||||
|
|
||||||
|
## 3. 爆管后、处置前的影响范围
|
||||||
|
|
||||||
|
核对定位管道后,用 run_burst_simulation(include_impact_coverage=true)模拟事件时刻的破口,orifice_areas_cm2 保留来源;无实测破口面积时可用有说明依据的估计情景,明确假设与不确定性,不把定位漏量直接当作破口面积。缺少依据则提具体待补参数,继续工单与隔离分析。
|
||||||
|
|
||||||
|
工具在筛选展示节点前比较全网节点的起算同刻在线模拟压力,换算米水头,三档互斥:1≤下降<3、3≤下降<5、下降≥5。只用同刻有效值,不以代表节点、邻近时刻或缺失值代替;保留缺测、单位来源、模型时态、比较节点数及覆盖缺口。范围表示爆管后、关阀或抢修前的模型估计,单独于隔离失源范围。
|
||||||
|
|
||||||
|
读取计算结果后按 data.presentation.command 的 evidence_id 用 publish_task_presentation 发布“爆管后、处置前的预计影响范围”,并发布三档节点计数、比较时刻和假设表;无地图几何时说明原因。证据、地图及表格进入正式任务事件,历史播放还原已保存结果,不重新查询在线基线。先保留此步骤,再展示隔离方案,不能用后续隔离覆盖替换本步。
|
||||||
|
|
||||||
|
## 4. 隔离方案
|
||||||
|
|
||||||
对关联资产调用 analyze_valve_isolation,保留 isolatable、must_close_valves、optional_valves、affected_nodes、supply_affected_nodes 及计数。单个候选分别分析;联合事故按真实联合场景提交。已知失效阀门通过 disabled_valves 评估替代边界;现场可操作性列为执行前核验条件。
|
对关联资产调用 analyze_valve_isolation,保留 isolatable、must_close_valves、optional_valves、affected_nodes、supply_affected_nodes 及计数。单个候选分别分析;联合事故按真实联合场景提交。已知失效阀门通过 disabled_valves 评估替代边界;现场可操作性列为执行前核验条件。
|
||||||
|
|
||||||
展示方案阀门和直接隔离、供水失源两类节点;节点覆盖使用 generate_node_coverage,再通过 publish_task_presentation 发布地图请求。取得初步隔离方案后继续影响分析和处置建议。
|
展示方案阀门和直接隔离、供水失源两类节点;节点覆盖使用 generate_node_coverage,再通过 publish_task_presentation 发布地图请求。取得初步隔离方案后继续影响分析和处置建议。
|
||||||
|
|
||||||
## 4. 影响说明
|
## 5. 影响说明
|
||||||
|
|
||||||
报告固定包含“影响说明”,逐项列出对象、范围、时间、单位、依据和实际或预计的性质:
|
报告固定包含“影响说明”,逐项列出对象、范围、时间、单位、依据和实际或预计的性质:
|
||||||
|
|
||||||
- **事件当前影响**:核查相关测点在事件窗与正常对照窗的压力、流量变化,给出已有数据覆盖的异常对象、持续时间和恢复情况。SCADA 用于核验事件影响,数据缺口与已完成的资产、拓扑结果分别记录。
|
- **事件当前影响**:核查相关测点在事件窗与正常对照窗的压力、流量变化,给出已有数据覆盖的异常对象、持续时间和恢复情况。SCADA 用于核验事件影响,数据缺口与已完成的资产、拓扑结果分别记录。
|
||||||
- **隔离方案影响**:分别说明 affected_nodes 对应的直接隔离范围,以及 supply_affected_nodes 对应的新增失去水源连接范围,保留各自计数和地图覆盖。
|
- **隔离方案影响**:分别说明 affected_nodes 对应的直接隔离范围,以及 supply_affected_nodes 对应的新增失去水源连接范围,保留各自计数和地图覆盖。
|
||||||
- **水力影响**:输入具备且工具已授权时,用 analyze_valve_closure 评估关阀后的压力、流量和低压范围。起算时间、阀门组合、模型边界及输出对象明确可比时给出前后差异;其余情况说明当前模型结果和待补条件。已有可靠破口参数时可按需补充事件情景模拟。
|
- **水力影响**:输入具备且工具已授权时,用 analyze_valve_closure 评估关阀后的压力、流量和低压范围。起算时间、阀门组合、模型边界及输出对象明确可比时给出前后差异;其余情况说明当前模型结果和待补条件。事件情景采用第3节结果,缺口单列。
|
||||||
- **用户与保障影响**:有用户、设施或区域关联资料时,列出受影响用户、重要设施和保障需求。仅有模型节点资料时报告节点与区域范围,将用户数量列为待核实。
|
- **用户与保障影响**:有用户、设施或区域关联资料时,列出受影响用户、重要设施和保障需求。仅有模型节点资料时报告节点与区域范围,将用户数量列为待核实。
|
||||||
|
|
||||||
现场实际停水、模型预计低压、拓扑失源和展示覆盖分别标注。漏量、累计漏水量使用有来源的方法与输入,列明单位及覆盖;其余情况记录待补依据。缺少可选模拟参数时,继续形成已有依据的隔离和保障建议。
|
现场实际停水、模型预计低压、拓扑失源和展示覆盖分别标注。漏量、累计漏水量使用有来源的方法与输入,列明单位及覆盖;其余情况记录待补依据。缺少可选模拟参数时,继续形成已有依据的隔离和保障建议。
|
||||||
|
|
||||||
## 5. 处置建议与报告
|
## 6. 处置建议与报告
|
||||||
|
|
||||||
先保存 interim/concluded 阶段报告,再按需要提出核验、通知、供水保障、隔离与抢修、恢复复测等动作草稿。关联现场工作采用 work_order_type=general,保留原爆管上报工单作为事件来源。草稿标题写“地点+动作”,正文以三条概括任务、执行条件和回执要求;审批与实际执行按 Server 流程完成。
|
复用定位阶段已发起的工单及 action_id;保存更新后的 interim/concluded 阶段报告,再按新增需要提出核验、通知、供水保障、隔离与抢修、恢复复测等动作草稿。关联现场工作采用 work_order_type=general,保留原爆管上报工单作为事件来源。草稿标题写“地点+动作”,正文以三条概括任务、执行条件和回执要求;审批与实际执行按 Server 流程完成。
|
||||||
|
|
||||||
报告固定给出事件概况、资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。本轮可执行分析完成且依赖结果已读取后,若仍待审批、现场工单或恢复监测,按第6节以interim结束本轮,恢复闭环后发布final;无此依赖时按后台调查停止条件发布final。
|
报告固定给出事件概况、资产关联、隔离方案、影响说明、处置建议和待办。摘要以150字以内为目标,指标和资产明细放表格,证据展开保留来源。待办应指向具体事实、参数或动作。本轮可执行分析完成且依赖结果已读取后,若仍待审批、现场工单或恢复监测,按第7节以interim结束本轮,恢复闭环后发布final;无此依赖时按后台调查停止条件发布final。
|
||||||
|
|
||||||
## 6. 反馈与闭环复核
|
## 7. 反馈与闭环复核
|
||||||
|
|
||||||
读取摘要中的 handling_context。waiting_work_order 表示已派单、等待有效完工反馈,创建回执的 completed_at 只证明创建动作完成。现场处理和恢复供水以关联工单的 completed 反馈为准;returned_at 是现场反馈的业务时间,演示时不得换成电脑当前时间。
|
读取摘要中的 handling_context。waiting_work_order 表示已派单、等待有效完工反馈,创建回执的 completed_at 只证明创建动作完成。现场处理和恢复供水以关联工单的 completed 反馈为准;returned_at 是现场反馈的业务时间,演示时不得换成电脑当前时间。
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
name: scada-anomaly
|
name: scada-anomaly
|
||||||
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
|
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
|
||||||
metadata:
|
metadata:
|
||||||
version: "1.0.16"
|
version: "1.0.18"
|
||||||
---
|
---
|
||||||
|
|
||||||
# SCADA 数据异常调查
|
# SCADA 数据异常调查
|
||||||
@@ -15,7 +15,7 @@ metadata:
|
|||||||
|
|
||||||
## 数据核验与诊断
|
## 数据核验与诊断
|
||||||
|
|
||||||
任一已配置指标异常即进入调查,包含压力、流量、水质规则和压力模型信号。先核验数据质量与测点,再判别原因;模型或组合指标有缺口时,继续核查已发现的单项异常。
|
任一已配置压力、流量或压力模型信号异常即调查。先核验数据质量与测点,再判别原因;组合指标有缺口时继续核查单项异常。水质侦测使用独立 water_pollution / water-pollution-event 流程,与 water_quality_complaint 投诉分开,不计入爆管支持分。
|
||||||
|
|
||||||
通过 query_scada_readings 省略device_ids读取冻结异常窗内全部SCADA设备的完整属性和授权观测,覆盖压力、流量、水质等全部类型;正常对照也采用全量读取。Server自动读完所有设备页和观测页,完整返回devices、readings、coverage,结果仍受8MiB整份上限约束,超限明确报错后按时间分段,不能丢弃设备。query_complete表示查询已读完,缺测与无有效值另行判断。查询结束时间止于knowledge_cutoff,数据批次止于batch_sequence;冻结范围外设备单列,不回退实时数据。按需核查开始前15天基线,复用已有完整监测回执。结合monitored_value、cleaned_value和质量标记判断变化,可靠采集的低压与水质变化保留为业务异常。
|
通过 query_scada_readings 省略device_ids读取冻结异常窗内全部SCADA设备的完整属性和授权观测,覆盖压力、流量、水质等全部类型;正常对照也采用全量读取。Server自动读完所有设备页和观测页,完整返回devices、readings、coverage,结果仍受8MiB整份上限约束,超限明确报错后按时间分段,不能丢弃设备。query_complete表示查询已读完,缺测与无有效值另行判断。查询结束时间止于knowledge_cutoff,数据批次止于batch_sequence;冻结范围外设备单列,不回退实时数据。按需核查开始前15天基线,复用已有完整监测回执。结合monitored_value、cleaned_value和质量标记判断变化,可靠采集的低压与水质变化保留为业务异常。
|
||||||
|
|
||||||
@@ -29,7 +29,11 @@ metadata:
|
|||||||
|
|
||||||
检查摘要或 skill 响应顶层的 location_capability。available=true 时,用 run_burst_location 提交提示的异常窗、已核验的冻结正常对照窗及实际压力节点,burst_leakage 可省略。读取返回的候选管段与相似度,优先按 similarity_percent 展示百分比;只有 similarity 时,按0–1小数换算。取得候选后继续地图展示、隔离边界、节点覆盖与影响分析,完成报告后再收尾。其他定位路径使用有依据的漏量估计或明确情景假设。缺少定位输入时记录待补项,继续独立的资产、空间与拓扑核查。
|
检查摘要或 skill 响应顶层的 location_capability。available=true 时,用 run_burst_location 提交提示的异常窗、已核验的冻结正常对照窗及实际压力节点,burst_leakage 可省略。读取返回的候选管段与相似度,优先按 similarity_percent 展示百分比;只有 similarity 时,按0–1小数换算。取得候选后继续地图展示、隔离边界、节点覆盖与影响分析,完成报告后再收尾。其他定位路径使用有依据的漏量估计或明确情景假设。缺少定位输入时记录待补项,继续独立的资产、空间与拓扑核查。
|
||||||
|
|
||||||
取得候选后展示管段,再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示节点覆盖。候选与拓扑建议标注为待现场核验方案。最终报告前核对定位调用及返回结果,调用失败记录具体错误与输入缺口。
|
取得候选即展示管道,发 interim/concluded 报告及现场工单草稿;复用action_id,待审批继续模拟。
|
||||||
|
|
||||||
|
run_burst_simulation设include_impact_coverage=true,用定位管道、异常时刻及有依据的破口面积cm²。全网同刻在线压力差按米水头分档1≤下降<3、3≤下降<5、下降≥5。按data.presentation发布地图及计数,缺口单列;先事件影响,再隔离。回放仅用当前模型、禁实时SCADA;在线参考非冻结观测,历史展示复用保存证据。
|
||||||
|
|
||||||
|
再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示节点覆盖。候选与拓扑建议标注为待现场核验方案。最终报告前核对定位调用及返回结果,调用失败记录具体错误与输入缺口。
|
||||||
|
|
||||||
## 对比展示与影响
|
## 对比展示与影响
|
||||||
|
|
||||||
@@ -45,9 +49,9 @@ metadata:
|
|||||||
|
|
||||||
本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。定位调用执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。
|
本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。定位调用执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。
|
||||||
|
|
||||||
报告给出观测事实、规则信号、原因候选、定位与隔离结果、影响说明和建议。摘要以150字以内为目标,测点明细用表格、完整依据留在证据。候选、相似度和拓扑结果具备时,完成相应分析、展示与报告后再收尾;现场核验、审批和维修按实际进度列为待办,已完工事项采用回执状态。现场工单与设备操作遵守服务端动作权限,恢复复核通过后按下节闭环。
|
报告含观测、规则、原因、定位隔离、影响和建议;摘要≤150字,测点用表格、完整依据留证据。完成分析、展示与报告后收尾;现场核验、审批维修按回执列待办。现场工单与设备操作遵守服务端动作权限,恢复复核通过后闭环。
|
||||||
|
|
||||||
需要现场核验时,先发布本轮outcome=concluded的调查报告,再调用propose_task_action提出kind=create_work_order、work_order_type=general的关联预约工单草稿,引用候选与影响证据,写明核验对象、内容和回执要求。带replay_context的任务同样可提出草稿,描述中注明历史异常时刻及回放来源;草稿须由有权限的用户编辑、确认后发布,后续派发与反馈进入预约任务。草稿待确认时说明等待人工处理,不将调查报告完成写成现场处置完成。回放任务不支持设备命令。
|
现场工单用propose_task_action(kind=create_work_order、work_order_type=general),注明对象、条件、回执及回放来源;有权限者确认派发,通知保留,报告完成不等于现场完成;回放禁设备命令。
|
||||||
|
|
||||||
## 完工后的恢复复核
|
## 完工后的恢复复核
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
name: water-pollution-event
|
||||||
|
description: 从水质监测侦测信号调查水污染事件,完成数据核验、候选溯源、隔离与影响分析、处置报告和反馈复核。
|
||||||
|
metadata:
|
||||||
|
version: "1.1.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 水污染侦测事件调查
|
||||||
|
|
||||||
|
适用于 `workflow=water_pollution`,入口是水质监测规则侦测异常,与供水水质投诉 `water_quality_complaint` 分开。无需投诉或人工上报才能调查,也不需要压力下降、流量增加或爆管综合评分达到门槛。侦测异常、候选污染源和污染确诊分别说明。
|
||||||
|
|
||||||
|
先读取任务、规则信号、异常设备、发生时刻、已有证据与报告。任务携带 replay_context 时,查询严格遵守 knowledge_cutoff 和 batch_sequence;普通侦测任务使用实际异常时刻。时间统一按 Asia/Shanghai 阅读,查询和 sample_times 保留明确时区,图表标题只写日期、时刻与单位。后续轮次优先处理新事实,复用已有资产、溯源、隔离和展示成果。
|
||||||
|
|
||||||
|
## 1. 核验侦测与观测
|
||||||
|
|
||||||
|
调用 query_scada_readings,省略 device_ids,全量读取异常时刻之前三小时的授权 SCADA;核对 devices、readings、coverage、分页完成与缺测。核查水质指标名称、单位、设备状态、清洗质量、模型节点映射以及实际规则上下限和连续命中条件。超出仪表量程、无效值和缺测属于数据质量问题,不能直接当作污染。有效水质读数超出配置业务范围支持异常调查,不自行认定为法定水质超标。
|
||||||
|
|
||||||
|
对照同一设备前七天同期及实际采样记录,保留真实时间和 null 缺口。压力、流量仅作关联依据,正常压力不能排除污染,爆管得分不用于替代水污染判断。没有适用的限值或采样结果时记录具体缺口,继续候选分析。
|
||||||
|
|
||||||
|
## 2. 候选溯源与节点展示
|
||||||
|
|
||||||
|
使用水质设备映射取得异常关联节点,以 get_element_properties、get_node_connections 核对。映射缺失时查询设备坐标附近模型节点,不猜编号。候选必须来自当前项目已查询资产。
|
||||||
|
|
||||||
|
检查 source_trace_capability,调用 run_contaminant_source_trace:candidate_node_ids 提交已核验的候选节点,start/end 为实际核查窗。读取 located_node、top_candidates、validated_node_ids、missing_node_ids 和 selection_basis。当前接口以稳定规则快速返回确定节点,正式反演算法后续接入,不虚构相似度、确诊概率或污染范围。
|
||||||
|
|
||||||
|
临时算法和 synthetic 来源只说明结果取得方式,不构成停止调查的条件。取得 located_node 后立即通过 publish_task_presentation 展示候选节点,继续隔离、覆盖、影响和报告。无候选时修正资产映射;确实无可用节点时仅暂停依赖源节点的分支,完成已有监测事实与处置建议。
|
||||||
|
|
||||||
|
定位到源节点后,立即保存 interim/concluded 阶段报告,注明“候选污染源待现场核验、传播范围计算中”,调用 propose_task_action 提出 kind=create_work_order、work_order_type=general 的现场核查/采样处置草稿,引用节点、地点和定位证据。沿用已有 action_id,不因模拟返回再建同类工单。工单审核、派发与传播模拟、隔离分析并行,不等模拟完成才发起工单;审批权限与动作版本校验沿用 Server 流程。
|
||||||
|
|
||||||
|
## 3. 处置前的污染传播模拟
|
||||||
|
|
||||||
|
继续调用 run_contaminant_simulation,source=located_node、include_impact_coverage=true;start_time 使用事件业务时刻,duration 使用明确的情景预测时长,output_node_ids 选择源节点和需展示曲线的监测/重要节点。源浓度 concentration 单位 mg/L,使用有来源的浓度或有明确依据的情景假设,保留依据与不确定性,不能把浊度、余氯下降或感官描述直接换算成污染源浓度。缺少可用依据时记录具体参数缺口,继续工单和隔离分支。
|
||||||
|
|
||||||
|
impact_threshold_mg_l 仅作模型影响范围的展示条件;默认0表示正浓度,非饮用水安全限值。工具在筛选展示节点前汇总全网节点整个模拟时段,返回累计超阈值节点、首个超阈值报告时刻、峰值和地图覆盖。沿用模型初始水质及反应条件,不能将总模型浓度当作源独立贡献,不能据此宣布范围外安全。
|
||||||
|
|
||||||
|
queued/running 时继续独立工作,用 get_calculation_job 跟踪并以 get_calculation_result 读取终态,不以回执代替结果。待审批或 waiting_work_order 不阻止继续可执行分析;模拟结果补入同一 task_id 的证据、进度与报告,不覆盖已审核工单或绕过版本校验。模拟失败只记录该分支问题,不撤回已有现场核查工单。存在未完成计算时不能结束调查运行或发布 final,按宿主计算等待机制接续。
|
||||||
|
|
||||||
|
读取结果后,用 presentation_preview 的 evidence_id 发布“水污染传播预计影响范围(处置前情景)”地图;将源浓度、阈值、预测窗、影响节点数、首达采样时刻和峰值写入报告表格,按真实时刻展示模型浓度曲线,模型曲线不填观测专用 chart.scada。地图和图表进入正式调查展示事件,历史播放读取已保存证据,不重新运行模拟。空范围、缺测、不支持的单位或几何超限按实际结果说明。保留污染传播步骤,再展示隔离范围,不以隔离覆盖替代传播范围。
|
||||||
|
|
||||||
|
带 replay_context 时同样可运行此情景,起算不得晚于知识截止;使用当前模型且不读取实时 SCADA。知识截止后的模拟时刻是预测,不得当作已观测数据或历史模型重建。
|
||||||
|
|
||||||
|
## 4. 隔离边界与影响
|
||||||
|
|
||||||
|
调用 analyze_valve_isolation,accident_element=[located_node],明确 accident_element_type="node",避免节点与管段重号。保留 isolatable、must_close_valves、optional_valves、affected_nodes、supply_affected_nodes 及各自计数。已知失效阀门通过 disabled_valves 分析替代边界;不能隔离时记录原因并继续其他处置建议。
|
||||||
|
|
||||||
|
展示候选节点、必关与可选阀门。用 generate_node_coverage 分组生成直接隔离、新增供水失源覆盖,再通过 publish_task_presentation 发布。空节点组不提交覆盖计算。拓扑隔离、供水失源、污染传播、水力变化和用户影响分别说明,节点覆盖不能当作污染传播边界,无用户资料不虚构户数。
|
||||||
|
|
||||||
|
输入和权限具备时使用 analyze_valve_closure 评估关阀水力影响,污染传播复用第3节结果。有排放节点、流量和前置条件时用 analyze_flushing 评估冲洗,缺少可选参数不阻止形成隔离、采样和保障建议。回放未开放的其他水力工具按实际权限记录限制,继续已授权分析。
|
||||||
|
|
||||||
|
## 5. 过程图表与报告
|
||||||
|
|
||||||
|
水质曲线填写 chart.scada:metric="quality"、真实 unit、role=current/comparison、完整三小时 window_start/window_end;series 绑定 device_id 和 day_offset。不同水质指标或单位分图,不能把浊度、余氯或其他指标混成同一浓度。当前总览覆盖报告与地图列出的全部目标;各设备七天同期图采用 time_comparison,当前三小时为 line,前七天每天末三个实际采样时刻为 scatter,sample_times 与 data 等长且保留真实日期。缺测保留 null,超过槽位上限按时间分图。所有数据引用本轮证据;展示不要求等待浏览器。
|
||||||
|
|
||||||
|
报告包含“事件概况、侦测与数据核验、候选溯源、隔离方案、影响说明、处置建议、反馈复核与待办”。影响说明列出实际异常指标、单位、时间、测点、直接隔离与失源节点数、水力或传播预测和用户保障。明确临时选点、观测事实、模型假设及待现场确认事项。最终报告引用本轮全量 SCADA 证据及已取得的溯源、隔离和覆盖证据。查询失败或权限拒绝记录为执行异常,不能写成无数据或未见异常。
|
||||||
|
|
||||||
|
## 6. 处置与反馈接续
|
||||||
|
|
||||||
|
第2节已提出的 general 工单继续沿用,按新增证据组织污染进入控制、隔离核验、通知保障、清洗冲洗及复测;已有工单确有不足时再提出必要的新动作。标题写“地点+动作”,写明执行条件、真实节点、时间要求与回执内容;审批、派发和现场执行遵守 Server 权限。没有用户通知适配器时交由人工实施。回放草稿注明异常业务时间及来源,不直接执行设备命令。
|
||||||
|
|
||||||
|
待审批、waiting_work_order 且无独立工作时,发布本轮 interim/concluded 并引用真实 action_id 后结束本轮,释放租约,不循环查询或重复建单。field_review 时核对各关联工单反馈及实际采样、处置、复测的时间、位置、数值和判定依据,再查询反馈业务时间之后已有水质观测;不要重跑原始调查。缺少关键事实时先完成独立工作,再用 ask_task_user 明确待补内容并结束本轮等待。
|
||||||
|
|
||||||
|
本轮调查成果完成与现场事件结案分别表述。工单 completed 或压力恢复不证明水质合格,不套用爆管压力恢复的自动结案条件。反馈复核具备依据时发布 final/concluded,引用最新现场反馈证据;仍异常时调整建议或新建必要动作,未来采样、复测和恢复不能写成已完成。
|
||||||
@@ -39,6 +39,28 @@ def test_dynamic_investigation_routes_are_in_the_public_contract():
|
|||||||
assert any(item["name"] == "report_revision" and item["in"] == "query" for item in context_params)
|
assert any(item["name"] == "report_revision" and item["in"] == "query" for item in context_params)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_progress_route_accepts_partial_node_updates_without_weakening_saved_diagrams(monkeypatch):
|
||||||
|
from app.domain.schemas.operational_task import AgentArtifactsCommand
|
||||||
|
|
||||||
|
schemas = app.openapi()["components"]["schemas"]
|
||||||
|
assert "label" not in schemas["InvestigationGraphNodeUpdate"]["required"]
|
||||||
|
assert "label" in schemas["InvestigationDiagramNodeRef"]["required"]
|
||||||
|
service = SimpleNamespace(publish_artifacts=AsyncMock(return_value={"progress_revision": 2}))
|
||||||
|
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
|
||||||
|
task_id = uuid4()
|
||||||
|
command = AgentArtifactsCommand.model_validate({
|
||||||
|
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": str(uuid4()),
|
||||||
|
"progress": {"summary": "已核对补充资料", "graph_update": {
|
||||||
|
"nodes": [{"node_id": "review", "status": "recorded"}],
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
await endpoints.publish_operational_task_artifacts(task_id, command, ctx=_service_context(), conn=object())
|
||||||
|
forwarded = service.publish_artifacts.await_args.args[1].progress.graph_update.nodes[0]
|
||||||
|
assert forwarded.node_id == "review" and forwarded.status == "recorded"
|
||||||
|
assert "label" not in forwarded.model_fields_set
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_presentation_routes_preserve_domain_rejections_and_do_not_create_runs(monkeypatch):
|
async def test_presentation_routes_preserve_domain_rejections_and_do_not_create_runs(monkeypatch):
|
||||||
from app.domain.domain_errors import DomainError
|
from app.domain.domain_errors import DomainError
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import burst_impact as service
|
||||||
|
from app.services.node_coverage import build_node_coverage
|
||||||
|
|
||||||
|
START = datetime(2026, 7, 16, 5, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def simulation(drops, unit="m"):
|
||||||
|
return {"units": {"pressure": unit}, "times": {"report_start": 0},
|
||||||
|
"node_results": [{"node": f"J{i}", "result": [{"pressure": 30 - drop}]}
|
||||||
|
for i, drop in enumerate(drops)]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def inputs(monkeypatch):
|
||||||
|
monkeypatch.setattr(service, "get_option", lambda _: {"PRESSURE": "METERS"})
|
||||||
|
monkeypatch.setattr(service, "get_option_v3", lambda _: {})
|
||||||
|
read = Mock(side_effect=lambda ids, *a, **kw: {
|
||||||
|
node: [{"time": START.isoformat(), "value": 30}] for node in ids})
|
||||||
|
monkeypatch.setattr(service.InternalQueries, "query_realtime_simulation_by_ids_timerange", read)
|
||||||
|
def coverage(network, groups):
|
||||||
|
ids = sorted({node for group in groups for node in group["node_ids"]})
|
||||||
|
data = build_node_coverage(groups, [
|
||||||
|
{"id": node, "longitude": 121.5 + i * .001, "latitude": 30.9}
|
||||||
|
for i, node in enumerate(ids)], [])
|
||||||
|
payload = data.pop("map_payload")
|
||||||
|
return {"coverage": data, "map_payload": payload}
|
||||||
|
monkeypatch.setattr(service, "generate_node_coverage", coverage)
|
||||||
|
return read
|
||||||
|
|
||||||
|
|
||||||
|
def test_thresholds_are_exclusive_with_no_rounding_and_no_closure(inputs):
|
||||||
|
result = service.estimate_burst_impact("own", START, simulation([.999, 1, 2.999, 3, 4.999, 5, 8, -1]))
|
||||||
|
impact = result["impact"]
|
||||||
|
assert [group["node_ids"] for group in impact["grades"]] == [["J1", "J2"], ["J3", "J4"], ["J5", "J6"]]
|
||||||
|
assert impact["below_threshold_node_count"] == 2
|
||||||
|
assert impact["compared_node_count"] == 8 and impact["affected_node_count"] == 6
|
||||||
|
assert impact["phase"] == "after_burst_before_intervention"
|
||||||
|
assert impact["baseline_frozen"] is False
|
||||||
|
inputs.assert_called_once_with([f"J{i}" for i in range(8)], START, START, "node", "pressure", db_name="own")
|
||||||
|
assert len(result["map_payload"]["features"]) == 3
|
||||||
|
assert all(feature["properties"]["model_estimate"] for feature in result["map_payload"]["features"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("unit,factor", [("kPa", 1 / 9.80665), ("PSI", 6894.757293168 / 9806.65),
|
||||||
|
("bar", 100000 / 9806.65), ("m", 1)])
|
||||||
|
def test_normalizes_baseline_and_simulation_independently(monkeypatch, inputs, unit, factor):
|
||||||
|
inputs.return_value = {"J0": [{"time": START.isoformat(), "value": 30 / factor}]}
|
||||||
|
inputs.side_effect = None
|
||||||
|
monkeypatch.setattr(service, "get_option", lambda _: {"PRESSURE": unit})
|
||||||
|
result = service.estimate_burst_impact("own", START, simulation([4]))
|
||||||
|
assert result["impact"]["comparisons"][0]["pressure_drop_m"] == pytest.approx(4)
|
||||||
|
assert result["impact"]["grades"][1]["node_ids"] == ["J0"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_v3_units_take_precedence_over_legacy_settings(monkeypatch, inputs):
|
||||||
|
monkeypatch.setattr(service, "get_option_v3", lambda _: {"PRESSURE_UNITS": "KPA"})
|
||||||
|
inputs.side_effect = None
|
||||||
|
inputs.return_value = {"J0": [{"time": START.isoformat(), "value": 30 * 9.80665}]}
|
||||||
|
impact = service.estimate_burst_impact("own", START, simulation([4]))["impact"]
|
||||||
|
assert impact["comparisons"][0]["pressure_drop_m"] == pytest.approx(4)
|
||||||
|
assert impact["baseline_units_source"] == "current_v3_model_settings"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_nonfinite_duplicate_and_wrong_time_are_unavailable_not_zero(inputs):
|
||||||
|
inputs.side_effect = None
|
||||||
|
inputs.return_value = {
|
||||||
|
"J0": [{"time": (START + timedelta(seconds=1)).isoformat(), "value": 30}],
|
||||||
|
"J1": [{"time": START.isoformat(), "value": float("nan")}],
|
||||||
|
"J2": [{"time": START.isoformat(), "value": 30}] * 2,
|
||||||
|
"J3": [{"time": START.isoformat(), "value": 30}],
|
||||||
|
"J4": [{"time": START.isoformat(), "value": 30}],
|
||||||
|
}
|
||||||
|
native = simulation([1] * 6)
|
||||||
|
native["node_results"][3]["result"] = [{"pressure": float("inf")}]
|
||||||
|
result = service.estimate_burst_impact("own", START, native)
|
||||||
|
assert result["impact"]["status"] == "partial"
|
||||||
|
assert result["impact"]["unavailable_node_ids"] == ["J0", "J1", "J2", "J3", "J5"]
|
||||||
|
assert result["impact"]["compared_node_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("times", [{}, {"report_start": 900}, {"sample_times": ["2026-07-16T05:00:01Z"]}])
|
||||||
|
def test_unknown_or_shifted_first_sample_never_queries_baseline(inputs, times):
|
||||||
|
native = simulation([2])
|
||||||
|
native["times"] = times
|
||||||
|
result = service.estimate_burst_impact("own", START, native)
|
||||||
|
assert result["impact"]["reason"] == "simulation_has_no_verified_start_time_sample"
|
||||||
|
assert "map_payload" not in result
|
||||||
|
inputs.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_units_do_not_draw_an_impact_area(inputs):
|
||||||
|
result = service.estimate_burst_impact("own", START, simulation([2], "unknown"))
|
||||||
|
assert result["impact"]["reason"] == "unsupported_or_missing_pressure_units"
|
||||||
|
inputs.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_native_network_compared_before_selected_series_filter(monkeypatch, inputs):
|
||||||
|
from app.mcp import scenario_service
|
||||||
|
from app.services import simulation_scenarios
|
||||||
|
native = simulation([4] * 25)
|
||||||
|
run = Mock(return_value=native)
|
||||||
|
monkeypatch.setattr(simulation_scenarios, "burst_analysis", run)
|
||||||
|
result = scenario_service.calculate_scenario("run_burst_simulation", {
|
||||||
|
"output_node_ids": ["J0"], "output_link_ids": [], "start_time": START, "duration": 900,
|
||||||
|
"burst_pipe_ids": ["P1"], "orifice_areas_cm2": [2], "include_impact_coverage": True,
|
||||||
|
}, network="own", username="user", model_only=True)
|
||||||
|
assert len(result["node_results"]) == 1
|
||||||
|
assert result["impact"]["affected_node_count"] == 25
|
||||||
|
assert result["coverage"]["requested_nodes"] == ["J0"]
|
||||||
|
assert result["persisted_baseline"] is False
|
||||||
|
assert run.call_args.kwargs["persist_results"] is False
|
||||||
|
assert run.call_args.kwargs["burst_ID"] == ["P1"]
|
||||||
|
assert not run.call_args.kwargs["scada_mappings"].reservoirs
|
||||||
|
assert result["impact"]["boundary_source"] == "model_only_no_live_scada"
|
||||||
|
|
||||||
|
|
||||||
|
def test_large_affected_sets_report_full_counts_without_truncating(inputs):
|
||||||
|
result = service.estimate_burst_impact("own", START, simulation([4] * 10001))
|
||||||
|
assert result["impact"]["affected_node_count"] == 10001
|
||||||
|
assert result["impact"]["coverage_status"] == "unavailable"
|
||||||
|
assert "map_payload" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_passes_empty_boundary_mappings_to_both_native_runs(monkeypatch):
|
||||||
|
from app.services import simulation_scenarios as scenarios
|
||||||
|
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||||
|
mappings = ScadaElementMappings({}, {}, {}, {}, {}, {}, {})
|
||||||
|
runs = Mock(return_value={"node_results": []})
|
||||||
|
monkeypatch.setattr(scenarios.simulation, "run_simulation", runs)
|
||||||
|
monkeypatch.setattr(scenarios, "get_pipe", lambda *a: {"node1": "A", "node2": "B", "diameter": 100})
|
||||||
|
monkeypatch.setattr(scenarios, "is_junction", lambda *a: True)
|
||||||
|
monkeypatch.setattr(scenarios, "get_emitter", lambda *a: None)
|
||||||
|
monkeypatch.setattr(scenarios, "set_emitter", lambda *a: None)
|
||||||
|
monkeypatch.setattr(scenarios, "get_option", lambda *a: {"UNITS": "LPS"})
|
||||||
|
monkeypatch.setattr(scenarios, "set_option", lambda *a: None)
|
||||||
|
scenarios.burst_analysis.__wrapped__(
|
||||||
|
name="own", modify_pattern_start_time=START.isoformat(), burst_ID=["P1"], burst_size=[2],
|
||||||
|
persist_results=False, _temporary_project="tjw_tmp_own", scada_mappings=mappings,
|
||||||
|
)
|
||||||
|
assert len(runs.call_args_list) == 2
|
||||||
|
assert all(call.kwargs["scada_mappings"] is mappings for call in runs.call_args_list)
|
||||||
|
assert runs.call_args.kwargs["persist_results"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_order_notification_precedes_durable_impact_and_keeps_run_available(inputs):
|
||||||
|
from copy import deepcopy
|
||||||
|
from uuid import uuid4
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from app.domain.schemas.operational_task import ActionProposalCommand, TaskPresentationEventCommand
|
||||||
|
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||||
|
from app.mcp.models import QueryResult
|
||||||
|
from app.mcp.presentation_preview import saved_presentation_preview
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
from tests.support.operational_task_repository import TaskRepository, _context, _task_document, _active_run
|
||||||
|
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, START, lease=START + timedelta(minutes=1))
|
||||||
|
task["workflow"] = "burst_report"
|
||||||
|
repository = TaskRepository(task, _active_run(task, START))
|
||||||
|
repository.reports.append({"revision": 1, "execution_generation": 1,
|
||||||
|
"report": {"stage": "interim", "outcome": "concluded"}})
|
||||||
|
tasks = OperationalTaskService(repository, context, set(), clock=lambda: START)
|
||||||
|
order = await tasks.propose_action(task["task_id"], ActionProposalCommand(
|
||||||
|
request_id=uuid4(), kind="create_work_order", reason="P1候选管段待现场核验",
|
||||||
|
payload={"title": "P1现场核查", "description": "核查定位管道并反馈", "work_order_type": "general"},
|
||||||
|
))
|
||||||
|
assert repository.task["status"] == "running"
|
||||||
|
assert repository.notifications[-1]["kind"] == "action_approval_pending"
|
||||||
|
assert order.approval_status == "pending" and order.execution_status == "not_submitted"
|
||||||
|
result = QueryResult(
|
||||||
|
source="run_burst_simulation", source_ref="mcp:burst-impact", queried_at=START,
|
||||||
|
semantics="analysis", origin="system_data", truncated=False,
|
||||||
|
data=jsonable_encoder({"result": service.estimate_burst_impact("own", START, simulation([2, 4, 6]))}),
|
||||||
|
)
|
||||||
|
evidence, _ = await tasks.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||||
|
source=result.source, source_ref=result.source_ref, queried_at=START,
|
||||||
|
origin="system_data", truncated=False, data=jsonable_encoder(result),
|
||||||
|
))
|
||||||
|
result.evidence_id = evidence.evidence_id
|
||||||
|
hint = saved_presentation_preview(result)
|
||||||
|
await tasks.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
||||||
|
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
|
||||||
|
command=hint["command"],
|
||||||
|
))
|
||||||
|
recorded = deepcopy(repository.events[-1]["metadata"]["command"])
|
||||||
|
assert recorded["data"] == jsonable_encoder(result.data["result"]["map_payload"])
|
||||||
|
assert len(recorded["data"]["features"]) == 3
|
||||||
|
# Saved presentation remains complete even if the live query result changes.
|
||||||
|
result.data["result"]["map_payload"]["features"].clear()
|
||||||
|
assert len(repository.events[-1]["metadata"]["command"]["data"]["features"]) == 3
|
||||||
|
assert repository.run["status"] == "running"
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import contaminant_impact as impact
|
||||||
|
from app.services.node_coverage import build_node_coverage
|
||||||
|
|
||||||
|
|
||||||
|
START = datetime(2026, 7, 16, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def simulation(unit="MGL"):
|
||||||
|
return {"units": {"quality": unit}, "times": {"report_start": 0, "report_step": 900, "num_periods": 3},
|
||||||
|
"node_results": [{"node": node, "result": [{"quality": value} for value in values]}
|
||||||
|
for node, values in {"source": [1, 1, 1], "late": [0, 0.2, 0.5], "clean": [0, 0, 0]}.items()]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def geometry(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def generate(network, groups):
|
||||||
|
calls.append((network, deepcopy(groups)))
|
||||||
|
coverage = build_node_coverage(groups, [{"id": "source", "longitude": 121.4, "latitude": 31.1}], [])
|
||||||
|
payload = coverage.pop("map_payload")
|
||||||
|
return {"coverage": coverage, "map_payload": payload}
|
||||||
|
|
||||||
|
monkeypatch.setattr(impact, "generate_node_coverage", generate)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_impact_uses_whole_prediction_window_and_real_concentration_units(geometry):
|
||||||
|
result = impact.estimate_contaminant_impact("project", START, 1800, simulation(), threshold_mg_l=0.2, model_only=True)
|
||||||
|
data = result["impact"]
|
||||||
|
assert data["affected_node_ids"] == ["source", "late"]
|
||||||
|
late = next(row for row in data["nodes"] if row["node_id"] == "late")
|
||||||
|
assert late["first_exceedance_sample_at"] == (START + timedelta(minutes=30)).isoformat()
|
||||||
|
assert late["peak_mg_l"] == 0.5 and late["peak_at"] == late["first_exceedance_sample_at"]
|
||||||
|
assert data["node_count"] == 3 and data["status"] == "partial" # Late node has no GIS position.
|
||||||
|
assert data["boundary_source"] == "model_only_no_live_scada"
|
||||||
|
assert data["threshold_purpose"] == "scenario_display_not_drinking_water_standard"
|
||||||
|
assert geometry[0][1][0]["node_ids"] == ["source", "late"]
|
||||||
|
assert result["coverage"]["groups"]["contaminant_exposure"]["missing_node_ids"] == ["late"]
|
||||||
|
assert result["map_payload"]["features"][0]["properties"]["impact_phase"] == "after_contamination_before_intervention"
|
||||||
|
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||||
|
from app.services.operational_presentation import resolve_overlay
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
snapshot = TaskDataSnapshot(source="run_contaminant_simulation", source_ref="calculation:propagation",
|
||||||
|
queried_at=START, origin="synthetic", truncated=False, data=jsonable_encoder({"result": result}))
|
||||||
|
saved = resolve_overlay(snapshot, "geometry")
|
||||||
|
assert saved["features"][0]["properties"]["model_estimate"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("unit", ["UGL", "ug/L", "µg/L"])
|
||||||
|
def test_micrograms_are_converted_before_threshold_comparison(geometry, unit):
|
||||||
|
result = impact.estimate_contaminant_impact("project", START, 1800, simulation(unit), threshold_mg_l=0.0002)
|
||||||
|
assert result["impact"]["nodes"][1]["peak_mg_l"] == 0.0005
|
||||||
|
assert result["impact"]["affected_node_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("problem", ["unit", "times", "duplicate", "too_many_samples", "no_values"])
|
||||||
|
def test_invalid_results_do_not_turn_into_an_empty_safe_range(geometry, problem):
|
||||||
|
data = simulation()
|
||||||
|
if problem == "unit": data["units"]["quality"] = "HOURS"
|
||||||
|
if problem == "times": data["times"] = {"report_step": 0}
|
||||||
|
if problem == "duplicate": data["node_results"].append(data["node_results"][0])
|
||||||
|
if problem == "too_many_samples": data["node_results"][0]["result"].append({"quality": 2})
|
||||||
|
if problem == "no_values":
|
||||||
|
for row in data["node_results"]: row["result"] = [{"quality": float("nan")}] * 3
|
||||||
|
result = impact.estimate_contaminant_impact("project", START, 1800, data)
|
||||||
|
assert result["impact"]["status"] == "unavailable"
|
||||||
|
assert "reason" in result["impact"] and "map_payload" not in result
|
||||||
|
assert not geometry
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_samples_remain_explicit_and_do_not_replace_first_observation(geometry):
|
||||||
|
data = simulation()
|
||||||
|
data["node_results"][1]["result"][0] = {"quality": None}
|
||||||
|
result = impact.estimate_contaminant_impact("project", START, 1800, data)
|
||||||
|
assert result["impact"]["incomplete_node_ids"] == ["late"]
|
||||||
|
assert result["impact"]["nodes"][1]["complete"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_geometry_limit_retains_the_numeric_prediction(monkeypatch):
|
||||||
|
monkeypatch.setattr(impact, "MAX_COVERAGE_NODES", 1)
|
||||||
|
result = impact.estimate_contaminant_impact("project", START, 1800, simulation())
|
||||||
|
assert result["impact"]["affected_node_count"] == 2
|
||||||
|
assert result["impact"]["coverage_status"] == "unavailable"
|
||||||
|
assert "map_payload" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_estimates_all_native_nodes_before_selecting_display_series(monkeypatch):
|
||||||
|
from app.mcp.analysis_models import ContaminantSimulationInput
|
||||||
|
from app.mcp.scenario_service import calculate_scenario
|
||||||
|
from app.services import simulation_scenarios
|
||||||
|
|
||||||
|
native = simulation()
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(simulation_scenarios, "contaminant_simulation", lambda **kw: calls.append(kw) or native)
|
||||||
|
seen = []
|
||||||
|
monkeypatch.setattr(impact, "estimate_contaminant_impact",
|
||||||
|
lambda network, start, duration, result, **kw: seen.append((network, start, duration, result, kw))
|
||||||
|
or {"impact": {"affected_node_count": 2}})
|
||||||
|
args = ContaminantSimulationInput(context_handle="a" * 43, start_time=START, duration=1800,
|
||||||
|
output_node_ids=["source"], source="source", concentration=1,
|
||||||
|
include_impact_coverage=True, impact_threshold_mg_l=0.2)
|
||||||
|
result = calculate_scenario("run_contaminant_simulation",
|
||||||
|
args.model_dump(exclude={"context_handle", "timeout_seconds"}), network="project", username="actor", model_only=True)
|
||||||
|
assert len(result["node_results"]) == 1 and len(seen[0][3]["node_results"]) == 3
|
||||||
|
assert seen[0][4] == {"threshold_mg_l": 0.2, "model_only": True}
|
||||||
|
assert result["impact"]["affected_node_count"] == 2
|
||||||
|
assert calls[0]["scada_mappings"].reservoirs == {} and calls[0]["scada_mappings"].quality == {}
|
||||||
|
assert calls[0]["persist_results"] is False and calls[0]["scheme_name"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_contaminant_service_passes_empty_mappings_to_the_engine(monkeypatch):
|
||||||
|
from app.infra.db.postgresql.scada import ScadaElementMappings
|
||||||
|
from app.services import simulation_scenarios as native
|
||||||
|
|
||||||
|
for name in ("set_time", "set_option", "add_source", "set_demand"):
|
||||||
|
monkeypatch.setattr(native, name, lambda *args: None)
|
||||||
|
monkeypatch.setattr(native, "get_time", lambda *_: {"HYDRAULIC TIMESTEP": "0:15:00"})
|
||||||
|
monkeypatch.setattr(native, "get_pattern", lambda *_: {"id": "pattern"})
|
||||||
|
monkeypatch.setattr(native, "get_source", lambda *_: {})
|
||||||
|
monkeypatch.setattr(native, "get_demand", lambda *_: {"demands": []})
|
||||||
|
monkeypatch.setattr(native, "get_option", lambda *_: {})
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(native.simulation, "run_simulation", lambda **kw: calls.append(kw) or simulation())
|
||||||
|
mappings = ScadaElementMappings(reservoirs={}, tanks={}, fixed_pumps={}, variable_pumps={}, pressure={}, demand={}, quality={})
|
||||||
|
native.contaminant_simulation.__wrapped__(name="project", modify_pattern_start_time=START.isoformat(),
|
||||||
|
modify_total_duration=1800, source="source", concentration=1, source_pattern="pattern",
|
||||||
|
username="actor", _temporary_project="temporary", persist_results=False, scada_mappings=mappings)
|
||||||
|
assert calls[0]["scada_mappings"] is mappings
|
||||||
|
assert calls[0]["name"] == "temporary" and calls[0]["persist_results"] is False
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.mcp import analysis_service, query_service
|
||||||
|
from app.mcp.analysis_models import ContaminantSimulationInput
|
||||||
|
from app.mcp.calculation_jobs import should_queue
|
||||||
|
from app.mcp.catalog import TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
def args(**extra):
|
||||||
|
return ContaminantSimulationInput(context_handle="a" * 43, **({
|
||||||
|
"start_time": "2026-07-16T08:00:00+08:00", "duration": 1800,
|
||||||
|
"output_node_ids": ["Q1"], "source": "Q1", "concentration": 1,
|
||||||
|
"include_impact_coverage": True, "impact_threshold_mg_l": 0.01,
|
||||||
|
} | extra))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("extra", [{"impact_threshold_mg_l": -1}, {"impact_threshold_mg_l": float("nan")},
|
||||||
|
{"include_impact_coverage": "true"}])
|
||||||
|
def test_impact_inputs_reject_invalid_thresholds_and_flags(extra):
|
||||||
|
with pytest.raises(ValidationError): args(**extra)
|
||||||
|
|
||||||
|
|
||||||
|
def context(monkeypatch):
|
||||||
|
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run", "scada.view"})
|
||||||
|
@asynccontextmanager
|
||||||
|
async def routing():
|
||||||
|
yield
|
||||||
|
scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()),
|
||||||
|
project=SimpleNamespace(project_code="project", project_role="member"), user=SimpleNamespace(username="actor"),
|
||||||
|
simulation_routing=routing)
|
||||||
|
binding = SimpleNamespace(tool_cap=["run_contaminant_simulation"], permission_cap=["simulation.run", "scada.view"],
|
||||||
|
purpose="task_execution", task_id=uuid4(), task_scope_cap=[],
|
||||||
|
replay_context={"knowledge_cutoff": "2026-07-16T08:00:00+08:00"})
|
||||||
|
return scope, binding
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_prediction_can_extend_past_cutoff_without_reading_future_observations(monkeypatch):
|
||||||
|
scope, binding = context(monkeypatch)
|
||||||
|
worker = AsyncMock(return_value={"impact": {"affected_node_count": 2}})
|
||||||
|
monkeypatch.setattr(analysis_service, "calculate_replay_contaminant_simulation", worker)
|
||||||
|
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_contaminant_simulation"], args()))
|
||||||
|
assert result.origin == "synthetic" and result.data["result"]["impact"]["affected_node_count"] == 2
|
||||||
|
assert "预测" in result.data_gaps[0]
|
||||||
|
scope.connection.commit.assert_awaited_once()
|
||||||
|
worker.assert_awaited_once()
|
||||||
|
assert worker.await_args.kwargs == {"network": "project", "username": "actor"}
|
||||||
|
assert should_queue(TOOLS["run_contaminant_simulation"], args())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("problem", ["future_start", "no_impact", "reader", "permission"])
|
||||||
|
def test_replay_admission_keeps_time_permission_and_reader_boundaries(monkeypatch, problem):
|
||||||
|
scope, binding = context(monkeypatch)
|
||||||
|
parameters = args()
|
||||||
|
if problem == "future_start": parameters = args(start_time="2026-07-16T08:15:00+08:00")
|
||||||
|
if problem == "no_impact": parameters = args(include_impact_coverage=False)
|
||||||
|
if problem == "reader": binding.purpose = "task_reader"
|
||||||
|
if problem == "permission": binding.permission_cap.remove("scada.view")
|
||||||
|
with pytest.raises(HTTPException) as caught:
|
||||||
|
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_simulation"], parameters))
|
||||||
|
assert caught.value.status_code == (422 if problem in {"future_start", "no_impact"} else 403)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.mcp import query_service
|
||||||
|
from app.mcp.analysis_models import ContaminantSourceTraceInput
|
||||||
|
from app.mcp.calculation_jobs import should_queue
|
||||||
|
from app.mcp.catalog import TOOLS
|
||||||
|
from app.services import contaminant_source_trace as trace
|
||||||
|
|
||||||
|
|
||||||
|
def arguments(**extra):
|
||||||
|
return ContaminantSourceTraceInput(context_handle="a" * 43,
|
||||||
|
**({"candidate_node_ids": ["J2", "J1", "missing"],
|
||||||
|
"start": "2026-07-16T08:00:00+08:00", "end": "2026-07-16T08:15:00+08:00"} | extra))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("nodes", [[{"id": "J2"}, {"id": "J1"}], [{"id": "J1"}, {"id": "J2"}]])
|
||||||
|
def test_trace_is_immediate_project_scoped_stable_and_explicit_about_algorithm(monkeypatch, nodes):
|
||||||
|
read = AsyncMock(return_value=nodes)
|
||||||
|
monkeypatch.setattr(trace, "read_rows", read)
|
||||||
|
connection = object()
|
||||||
|
args = arguments()
|
||||||
|
result = asyncio.run(trace.trace_contaminant_source(connection, args))
|
||||||
|
assert result["located_node"] == "J1"
|
||||||
|
assert result["missing_node_ids"] == ["missing"]
|
||||||
|
assert result["top_candidates"] == [{"node_id": "J1"}]
|
||||||
|
assert result["algorithm_status"] == "provisional"
|
||||||
|
assert result["selection_basis"] == "lexicographically_first_existing_candidate_node"
|
||||||
|
assert "相似度" not in result and "confidence" not in result
|
||||||
|
assert read.await_args.args[0] is connection
|
||||||
|
assert "network.nodes" in read.await_args.args[1] and "ANY(%s)" in read.await_args.args[1]
|
||||||
|
assert read.await_args.args[2] == (args.candidate_node_ids,)
|
||||||
|
assert not should_queue(TOOLS["run_contaminant_source_trace"], args)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("change", [{"candidate_node_ids": []}, {"candidate_node_ids": ["J1", "J1"]},
|
||||||
|
{"candidate_node_ids": [str(index) for index in range(21)]},
|
||||||
|
{"start": "2026-07-16T08:00:00"}, {"end": "2026-07-16T08:00:00+08:00"},
|
||||||
|
{"end": "2026-07-18T08:00:00+08:00"}])
|
||||||
|
def test_trace_rejects_unbounded_or_ambiguous_inputs(change):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
arguments(**change)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("nodes", [[], [{"id": "J1"}]])
|
||||||
|
def test_dispatch_preserves_provenance_and_reports_only_actual_node_gaps(monkeypatch, nodes):
|
||||||
|
monkeypatch.setattr(trace, "read_rows", AsyncMock(return_value=nodes))
|
||||||
|
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run"})
|
||||||
|
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_role="member"))
|
||||||
|
binding = SimpleNamespace(tool_cap=["run_contaminant_source_trace"], purpose="task_execution",
|
||||||
|
permission_cap=["simulation.run"], task_id=uuid4(), task_scope_cap=[], replay_context=None)
|
||||||
|
result = asyncio.run(query_service.execute_query(scope, binding, TOOLS["run_contaminant_source_trace"], arguments()))
|
||||||
|
assert result.origin == "synthetic" and result.semantics == "analysis"
|
||||||
|
assert (result.data["result"]["located_node"] is None) == (not nodes)
|
||||||
|
assert bool(result.data_gaps) == (not nodes)
|
||||||
|
assert "context_handle" not in result.data["parameters"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_trace_cannot_escape_frozen_window(monkeypatch):
|
||||||
|
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: {"simulation.run"})
|
||||||
|
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
|
||||||
|
binding = SimpleNamespace(tool_cap=["run_contaminant_source_trace"], purpose="task_execution",
|
||||||
|
permission_cap=["simulation.run"], task_id=uuid4(), task_scope_cap=[],
|
||||||
|
replay_context={"knowledge_cutoff": "2026-07-16T08:00:00+08:00"})
|
||||||
|
with pytest.raises(HTTPException) as caught:
|
||||||
|
asyncio.run(query_service.authorize_query(scope, binding, TOOLS["run_contaminant_source_trace"], arguments()))
|
||||||
|
assert caught.value.status_code == 422
|
||||||
@@ -80,6 +80,78 @@ async def test_failed_attempt_cannot_be_relabelled_as_a_success():
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_existing_waiting_node_accepts_a_status_only_update_without_extending_the_graph():
|
||||||
|
task, repo, service = setup_service()
|
||||||
|
first = await publish(service, repo, task["task_id"], nodes=[
|
||||||
|
{"node_id": "read", "label": "读取监测", "status": "recorded"},
|
||||||
|
{"node_id": "review", "label": "核对补充资料", "status": "needs_input", "detail": "等待现场记录"},
|
||||||
|
], edges=[{"source": "read", "target": "review"}])
|
||||||
|
original = first.task.latest_progress.progress.diagram
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand.model_validate({
|
||||||
|
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
|
||||||
|
"run_id": repo.run["run_id"], "report": {
|
||||||
|
"title": "阶段报告", "current_conclusion": "仍需核对现场资料", "conclusion_boundary": "现有资料范围",
|
||||||
|
"stage": "interim", "outcome": "concluded", "data_gaps": [],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
result = await publish(service, repo, task["task_id"], nodes=[
|
||||||
|
{"node_id": "review", "status": "recorded", "detail": "已核对收到的现场记录"},
|
||||||
|
])
|
||||||
|
updated = result.task.latest_progress.progress.diagram
|
||||||
|
assert [node.node_id for node in updated.node_refs] == ["read", "review"]
|
||||||
|
assert updated.node_refs[1].label == "核对补充资料"
|
||||||
|
assert updated.node_refs[1].status == "recorded"
|
||||||
|
assert updated.node_refs[1].detail == "已核对收到的现场记录"
|
||||||
|
assert updated.edges == original.edges
|
||||||
|
assert updated.mermaid == original.mermaid
|
||||||
|
assert original.node_refs[1].status == "needs_input"
|
||||||
|
archived = (await service.report(task["task_id"], 1)).report.diagram
|
||||||
|
assert archived.node_refs[1].status == "needs_input"
|
||||||
|
assert archived.node_refs[1].detail == "等待现场记录"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("label_patch", [{}, {"label": None}])
|
||||||
|
def test_partial_updates_keep_labels_and_accumulated_references_including_legacy_nodes(label_patch):
|
||||||
|
evidence_id = "saved-reading"
|
||||||
|
action_id = uuid4()
|
||||||
|
original = InvestigationDiagram.model_validate({
|
||||||
|
"title": "旧调查图", "mermaid": "flowchart LR\nold{核对资料} --> next[继续调查]",
|
||||||
|
"node_refs": [{"node_id": "old", "label": "核对资料", "status": "waiting_human",
|
||||||
|
"detail": "等待资料", "evidence_refs": [evidence_id], "action_refs": [action_id],
|
||||||
|
"map_targets": [{"source_id": "scada", "feature_id": "P1"}]}],
|
||||||
|
})
|
||||||
|
updated = update_investigation_graph(original, InvestigationGraphUpdate.model_validate({
|
||||||
|
"nodes": [{"node_id": "old", "status": "recorded", **label_patch}],
|
||||||
|
}))
|
||||||
|
assert updated.mermaid == updated.source_mermaid == original.mermaid
|
||||||
|
assert updated.node_refs[0] == original.node_refs[0].model_copy(update={"status": "recorded"})
|
||||||
|
assert original.node_refs[0].status == "needs_input"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_only_updates_cannot_create_an_unlabelled_node_or_rewrite_a_failed_attempt():
|
||||||
|
with pytest.raises(DomainError) as error:
|
||||||
|
update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
|
||||||
|
"nodes": [{"node_id": "missing", "status": "recorded"}],
|
||||||
|
}))
|
||||||
|
assert error.value.code == "GRAPH_NODE_LABEL_REQUIRED"
|
||||||
|
failed = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
|
||||||
|
"nodes": [{"node_id": "attempt", "label": "失败尝试", "status": "failed", "detail": "接口超时"}],
|
||||||
|
}))
|
||||||
|
enriched = update_investigation_graph(failed, InvestigationGraphUpdate.model_validate({
|
||||||
|
"nodes": [{"node_id": "attempt", "evidence_refs": ["timeout-record"]}],
|
||||||
|
}))
|
||||||
|
assert enriched.node_refs[0].label == "失败尝试"
|
||||||
|
assert enriched.node_refs[0].detail == "接口超时"
|
||||||
|
assert enriched.node_refs[0].status == "failed"
|
||||||
|
assert enriched.node_refs[0].evidence_refs == ["timeout-record"]
|
||||||
|
with pytest.raises(DomainError) as error:
|
||||||
|
update_investigation_graph(enriched, InvestigationGraphUpdate.model_validate({
|
||||||
|
"nodes": [{"node_id": "attempt", "status": "recorded"}],
|
||||||
|
}))
|
||||||
|
assert error.value.code == "GRAPH_FAILED_ATTEMPT_IMMUTABLE"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_new_execution_round_continues_the_same_task_graph():
|
async def test_new_execution_round_continues_the_same_task_graph():
|
||||||
task, repo, service = setup_service()
|
task, repo, service = setup_service()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ HANDLE = "x" * 43
|
|||||||
START = "2026-10-08T12:00:00+08:00"
|
START = "2026-10-08T12:00:00+08:00"
|
||||||
END = "2026-10-08T13:00:00+08:00"
|
END = "2026-10-08T13:00:00+08:00"
|
||||||
INPUTS = {
|
INPUTS = {
|
||||||
|
"run_contaminant_source_trace": {"candidate_node_ids": ["J1"], "start": START, "end": END},
|
||||||
"run_simulation": {"start_time": START, "duration": 3600, "output_node_ids": ["J1"], "output_link_ids": ["L1"]},
|
"run_simulation": {"start_time": START, "duration": 3600, "output_node_ids": ["J1"], "output_link_ids": ["L1"]},
|
||||||
"run_contaminant_simulation": {"start_time": START, "output_node_ids": ["J1"], "source": "J2", "concentration": 1.0},
|
"run_contaminant_simulation": {"start_time": START, "output_node_ids": ["J1"], "source": "J2", "concentration": 1.0},
|
||||||
"analyze_valve_closure": {"start_time": START, "output_node_ids": ["J1"], "valves": ["V1"]},
|
"analyze_valve_closure": {"start_time": START, "output_node_ids": ["J1"], "valves": ["V1"]},
|
||||||
|
|||||||
@@ -204,3 +204,44 @@ def test_legacy_execution_timeout_does_not_block_owner_authorization(monkeypatch
|
|||||||
assert args.timeout_seconds <= 240
|
assert args.timeout_seconds <= 240
|
||||||
assert row['parameters']['timeout_seconds'] == 3600
|
assert row['parameters']['timeout_seconds'] == 3600
|
||||||
authorize.assert_awaited_once()
|
authorize.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_large_burst_result_keeps_saved_map_command_and_full_evidence(monkeypatch):
|
||||||
|
from copy import deepcopy
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from app.mcp import calculation_jobs as jobs
|
||||||
|
from app.mcp.calculation_models import CalculationResultInput
|
||||||
|
from app.mcp.models import QueryResult
|
||||||
|
original = binding()
|
||||||
|
original.task_id = uuid4()
|
||||||
|
full = {"parameters": {"burst_pipe_ids": ["P1"]}, "result": {
|
||||||
|
"impact": {"phase": "after_burst_before_intervention", "status": "ready",
|
||||||
|
"compared_node_count": 1000, "comparisons": [{"node_id": f"J{i}", "pressure_drop_m": 4} for i in range(1000)],
|
||||||
|
"grades": [{"group_id": "drop_3_5", "node_ids": [f"J{i}" for i in range(1000)]}],
|
||||||
|
"unavailable_node_ids": []},
|
||||||
|
"coverage": {"status": "ready", "groups": {"drop_3_5": {"node_ids": [f"J{i}" for i in range(1000)],
|
||||||
|
"mapped_node_count": 1000, "missing_node_ids": []}}},
|
||||||
|
"map_payload": {"type": "FeatureCollection", "features": [{"large": "recorded-geometry"}]},
|
||||||
|
}}
|
||||||
|
result = QueryResult(source='run_burst_simulation', source_ref='mcp:burst', queried_at=datetime.now(timezone.utc),
|
||||||
|
semantics='analysis', data=full, evidence_id='saved-burst')
|
||||||
|
row = {'job_id': uuid4(), 'binding': durable_binding(original), 'status': 'succeeded', 'result': result.model_dump(mode='json')}
|
||||||
|
persisted = deepcopy(row)
|
||||||
|
monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: SimpleNamespace(get=AsyncMock(return_value=row)))
|
||||||
|
monkeypatch.setattr(jobs, 'authorize_original', AsyncMock())
|
||||||
|
monkeypatch.setattr(jobs, 'attach_calculation_evidence', AsyncMock(side_effect=lambda scope, row, result: result))
|
||||||
|
scope = SimpleNamespace(connection=None, project=SimpleNamespace(project_id=original.project_id, user_id=uuid4()))
|
||||||
|
args = CalculationResultInput(context_handle='x' * 32, job_id=row['job_id'])
|
||||||
|
response = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'), args))
|
||||||
|
assert response.truncated and len(response.model_dump_json().encode()) < 11000
|
||||||
|
assert response.data['result']['impact']['compared_node_count'] == 1000
|
||||||
|
assert response.data['result']['impact']['grades'][0]['node_count'] == 1000
|
||||||
|
assert response.data['presentation']['command']['evidence_id'] == 'saved-burst'
|
||||||
|
assert response.data['presentation']['command']['action'] == 'set_overlay'
|
||||||
|
assert response.data['resource_uri'].endswith('/evidence/saved-burst')
|
||||||
|
assert 'comparisons' not in response.data['result']['impact']
|
||||||
|
assert row == persisted
|
||||||
|
# Explicit path pagination still reads every original node without summary replacement.
|
||||||
|
page = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'),
|
||||||
|
args.model_copy(update={'data_path': ['result', 'impact', 'comparisons'], 'offset': 990, 'limit': 10})))
|
||||||
|
assert page.data[0]['node_id'] == 'J990' and page.page.total == 1000
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ def test_artifacts_are_integrity_checked_bounded_binary_envelopes():
|
|||||||
|
|
||||||
def test_no_health_prediction_tool_and_every_write_requests_native_permission():
|
def test_no_health_prediction_tool_and_every_write_requests_native_permission():
|
||||||
assert not any("health" in name for name in TOOLS)
|
assert not any("health" in name for name in TOOLS)
|
||||||
assert len(TOOLS) == 72
|
assert len(TOOLS) == 73
|
||||||
assert sum(t.mutating for t in TOOLS.values()) == 13
|
assert sum(t.mutating for t in TOOLS.values()) == 13
|
||||||
for tool in TOOLS.values():
|
for tool in TOOLS.values():
|
||||||
if tool.mutating:
|
if tool.mutating:
|
||||||
|
|||||||
@@ -34,3 +34,32 @@ def test_missing_geometry_does_not_offer_a_broken_overlay_command():
|
|||||||
data={"result": {"coverage": {"status": "unavailable"}}},
|
data={"result": {"coverage": {"status": "unavailable"}}},
|
||||||
)
|
)
|
||||||
assert saved_presentation_preview(result) is None
|
assert saved_presentation_preview(result) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pollution_impact_keeps_a_saved_overlay_reference_for_history():
|
||||||
|
result = QueryResult(source="run_contaminant_simulation", source_ref="calculation:pollution",
|
||||||
|
evidence_id="pollution:impact", queried_at=datetime.now(timezone.utc), semantics="analysis", origin="synthetic",
|
||||||
|
data={"result": {"impact": {"phase": "after_contamination_before_intervention"},
|
||||||
|
"map_payload": {"type": "FeatureCollection", "features": [{"geometry": "saved"}]},
|
||||||
|
"coverage": {"status": "ready", "groups": {"contaminant_exposure": {"node_ids": ["Q1"], "mapped_node_count": 1}}}}})
|
||||||
|
preview = saved_presentation_preview(result)
|
||||||
|
assert preview["command"]["evidence_id"] == "pollution:impact"
|
||||||
|
assert preview["command"]["title"] == "水污染传播预计影响范围(处置前情景)"
|
||||||
|
assert "data" not in preview["command"] and preview["groups"]["contaminant_exposure"]["node_count"] == 1
|
||||||
|
result.data["result"]["impact"]["phase"] = "unknown"
|
||||||
|
assert saved_presentation_preview(result) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_burst_impact_hint_uses_saved_evidence_and_distinct_pre_intervention_title():
|
||||||
|
result = QueryResult(
|
||||||
|
source="run_burst_simulation", source_ref="mcp:burst", evidence_id="mcp:burst",
|
||||||
|
queried_at=datetime.now(timezone.utc), semantics="analysis", origin="system_data", truncated=False,
|
||||||
|
data={"result": {"impact": {"phase": "after_burst_before_intervention"},
|
||||||
|
"map_payload": {"type": "FeatureCollection", "features": [{"large": "geometry"}]}}},
|
||||||
|
)
|
||||||
|
hint = saved_presentation_preview(result)
|
||||||
|
assert hint["command"]["evidence_id"] == "mcp:burst"
|
||||||
|
assert "爆管后、处置前" in hint["command"]["title"]
|
||||||
|
assert "data" not in hint["command"]
|
||||||
|
result.data["result"].pop("impact")
|
||||||
|
assert saved_presentation_preview(result) is None
|
||||||
|
|||||||
@@ -373,6 +373,30 @@ def test_replay_binding_denies_future_and_allows_device_metadata(monkeypatch):
|
|||||||
asyncio.run(query_service.authorize_query(SimpleNamespace(project=object()), bound, TOOLS["list_scada_devices"], SimpleNamespace()))
|
asyncio.run(query_service.authorize_query(SimpleNamespace(project=object()), bound, TOOLS["list_scada_devices"], SimpleNamespace()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_burst_scenario_requires_coverage_permission_and_known_start(monkeypatch):
|
||||||
|
bound = binding(task=True)
|
||||||
|
bound.replay_context = {"knowledge_cutoff": "2026-10-08T01:00:00Z"}
|
||||||
|
permissions = PERMISSIONS | {"simulation.run", "scada.view", "simulation.view"}
|
||||||
|
bound.permission_cap = sorted(permissions)
|
||||||
|
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions)
|
||||||
|
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
|
||||||
|
args = TOOLS["run_burst_simulation"].model.model_validate({
|
||||||
|
"context_handle": bound.context_handle, "start_time": "2026-10-08T01:00:00Z",
|
||||||
|
"output_node_ids": ["J1"], "burst_pipe_ids": ["P1"], "orifice_areas_cm2": [2],
|
||||||
|
"include_impact_coverage": True,
|
||||||
|
})
|
||||||
|
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args))
|
||||||
|
for changed, status in (({"include_impact_coverage": False}, 422),
|
||||||
|
({"start_time": datetime(2026, 10, 8, 2, tzinfo=timezone.utc)}, 422)):
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args.model_copy(update=changed)))
|
||||||
|
assert error.value.status_code == status
|
||||||
|
bound.permission_cap = sorted(permissions - {"simulation.view"})
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
asyncio.run(query_service.authorize_query(scope, bound, TOOLS["run_burst_simulation"], args))
|
||||||
|
assert error.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_model_response_displays_utc8_without_changing_query_instants_or_source(runtime):
|
def test_model_response_displays_utc8_without_changing_query_instants_or_source(runtime):
|
||||||
app, client, _, _ = runtime
|
app, client, _, _ = runtime
|
||||||
at = datetime(2026, 12, 31, 16, tzinfo=timezone.utc)
|
at = datetime(2026, 12, 31, 16, tzinfo=timezone.utc)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.mcp.work_order_skills import read_work_order_skill
|
|||||||
("water_quality_complaint", "water-quality-complaint"),
|
("water_quality_complaint", "water-quality-complaint"),
|
||||||
("general", "appointment-follow-up"),
|
("general", "appointment-follow-up"),
|
||||||
("scada_anomaly", "scada-anomaly"),
|
("scada_anomaly", "scada-anomaly"),
|
||||||
|
("water_pollution", "water-pollution-event"),
|
||||||
])
|
])
|
||||||
def test_skill_descriptor_and_body_have_same_version_and_digest(workflow, name):
|
def test_skill_descriptor_and_body_have_same_version_and_digest(workflow, name):
|
||||||
descriptor = read_work_order_skill(workflow)
|
descriptor = read_work_order_skill(workflow)
|
||||||
@@ -59,9 +60,20 @@ def test_scada_instructions_advance_candidate_analysis_independently_of_source_l
|
|||||||
assert not any(word in text for word in ("演示", "mock", "location_demo", "只发布回放成果"))
|
assert not any(word in text for word in ("演示", "mock", "location_demo", "只发布回放成果"))
|
||||||
|
|
||||||
|
|
||||||
def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch):
|
def test_pollution_instructions_offer_work_order_before_simulation_and_reuse_the_investigation():
|
||||||
|
skill = read_work_order_skill("water_pollution", include_content=True)
|
||||||
|
text = skill["instructions"]
|
||||||
|
assert text.index("propose_task_action") < text.index("run_contaminant_simulation")
|
||||||
|
assert "include_impact_coverage=true" in text and "不等模拟完成才发起工单" in text
|
||||||
|
assert "同一 task_id" in text and "不覆盖已审核工单或绕过版本校验" in text
|
||||||
|
assert "模型曲线不填观测专用 chart.scada" in text
|
||||||
|
assert "不能把浊度、余氯下降或感官描述直接换算成污染源浓度" in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("workflow", ["burst_report", "water_quality_complaint", "water_pollution"])
|
||||||
|
def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch, workflow):
|
||||||
task_id, project_id = uuid4(), uuid4()
|
task_id, project_id = uuid4(), uuid4()
|
||||||
reader = AsyncMock(return_value={"task": {"workflow": "burst_report"}})
|
reader = AsyncMock(return_value={"task": {"workflow": workflow}})
|
||||||
monkeypatch.setattr(query_service.reads, "task_context_summary", reader)
|
monkeypatch.setattr(query_service.reads, "task_context_summary", reader)
|
||||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"})
|
monkeypatch.setattr(query_service, "permissions_for_context", lambda project: {"operational_task.view"})
|
||||||
scope = SimpleNamespace(connection=object(), project=object())
|
scope = SimpleNamespace(connection=object(), project=object())
|
||||||
@@ -80,7 +92,15 @@ def test_task_skill_uses_persisted_workflow_and_preserves_task_scope(monkeypatch
|
|||||||
body = query("skill")
|
body = query("skill")
|
||||||
assert summary.data["handling_skill"]["content_sha256"] == body.data["skill"]["content_sha256"]
|
assert summary.data["handling_skill"]["content_sha256"] == body.data["skill"]["content_sha256"]
|
||||||
assert summary.data["display_time_zone"] == "Asia/Shanghai"
|
assert summary.data["display_time_zone"] == "Asia/Shanghai"
|
||||||
assert body.data["skill"]["workflow"] == "burst_report"
|
assert body.data["skill"]["workflow"] == workflow
|
||||||
|
for result in (summary, body):
|
||||||
|
assert ("source_trace_capability" in result.data) == (workflow == "water_pollution")
|
||||||
|
if workflow == "water_pollution":
|
||||||
|
assert result.data["source_trace_capability"]["tool"] == "run_contaminant_source_trace"
|
||||||
|
capability = result.data["source_trace_capability"]
|
||||||
|
assert capability["work_order_wait_blocks_analysis"] is False
|
||||||
|
assert capability["simulation_parameters"] == {"include_impact_coverage": True}
|
||||||
|
assert capability["next_steps"].index("propose_task_action") < capability["next_steps"].index("run_contaminant_simulation")
|
||||||
assert body.data["skill"]["instructions"] and not body.truncated
|
assert body.data["skill"]["instructions"] and not body.truncated
|
||||||
assert body.evidence_id is None and body.semantics == "task"
|
assert body.evidence_id is None and body.semantics == "task"
|
||||||
reader.assert_awaited_with(scope.connection, project_id=project_id, task_id=task_id, report_revision=None)
|
reader.assert_awaited_with(scope.connection, project_id=project_id, task_id=task_id, report_revision=None)
|
||||||
|
|||||||
@@ -22,6 +22,25 @@ def observations():
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_pollution_field_feedback_resumes_review_without_pressure_recovery():
|
||||||
|
task = {"workflow": "water_pollution"}
|
||||||
|
for order_id in ("sampling", "flush"):
|
||||||
|
assert track_work_order(task, {"kind": "create_work_order", "action_id": str(uuid4()),
|
||||||
|
"execution_status": "succeeded", "receipt": {"adapter": "peripheral_sample", "receipt_id": order_id}})
|
||||||
|
feedback = {"source_event_id": "peripheral_sample:sampling", "status": "completed", "source_revision": 2,
|
||||||
|
"occurred_at": RETURNED.isoformat(), "evidence": {"evidence_id": "sampling:2"}}
|
||||||
|
accept_completion(task, feedback)
|
||||||
|
assert task["handling_context"]["phase"] == "waiting_work_order"
|
||||||
|
accept_completion(task, {**feedback, "source_event_id": "peripheral_sample:flush", "evidence": {"evidence_id": "flush:2"}})
|
||||||
|
assert task["handling_context"]["phase"] == "field_review"
|
||||||
|
assert "verification_evidence_id" not in task["handling_context"]
|
||||||
|
assert task["handling_context"]["work_orders"]["sampling"]["feedback_evidence_id"] == "sampling:2"
|
||||||
|
task["handling_context"]["phase"] = "completed"
|
||||||
|
accept_completion(task, {**feedback, "source_revision": 3, "evidence": {"evidence_id": "sampling:3"}})
|
||||||
|
assert task["handling_context"]["phase"] == "field_review"
|
||||||
|
assert task["handling_context"]["work_orders"]["sampling"]["feedback_evidence_id"] == "sampling:3"
|
||||||
|
|
||||||
|
|
||||||
def evaluate(rows):
|
def evaluate(rows):
|
||||||
return evaluate_recovery(rows, device_ids=["P1", "F1"], returned_at=RETURNED,
|
return evaluate_recovery(rows, device_ids=["P1", "F1"], returned_at=RETURNED,
|
||||||
incident_at=INCIDENT, interval_seconds=900)
|
incident_at=INCIDENT, interval_seconds=900)
|
||||||
|
|||||||
@@ -675,6 +675,19 @@ async def test_completed_investigation_resumes_once_for_linked_new_field_feedbac
|
|||||||
again = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(request_id=uuid4(), **identity))
|
again = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(request_id=uuid4(), **identity))
|
||||||
assert again.task.execution_generation == 2
|
assert again.task.execution_generation == 2
|
||||||
assert len([event for event in repository.events if event["topic"] == "operational.run.queued"]) == 1
|
assert len([event for event in repository.events if event["topic"] == "operational.run.queued"]) == 1
|
||||||
|
notices = [item for item in repository.notifications if item["kind"] == "work_order_feedback"]
|
||||||
|
assert len(notices) == 1
|
||||||
|
assert notices[0]["subject_id"] == str(action.action_id)
|
||||||
|
assert notices[0]["work_order_action"]["receipt"]["receipt_id"] == child_id
|
||||||
|
review_notice = next(item for item in repository.notifications if item["kind"] == "action_approval_pending")
|
||||||
|
assert review_notice["work_order_action"]["approval_status"] == "pending"
|
||||||
|
assert review_notice["work_order_action"]["receipt"] is None
|
||||||
|
assert notices[0]["work_order_reference"] == {
|
||||||
|
"source_event_id": f"peripheral_sample:{child_id}", "source_revision": 2,
|
||||||
|
"evidence_id": None, "status": "completed",
|
||||||
|
}
|
||||||
|
assert any(event["topic"] == "operational.notification.changed"
|
||||||
|
and event["metadata"].get("kind") == "work_order_feedback" for event in repository.events)
|
||||||
repository.task.update(status="cancelled", lifecycle_status="cancelled")
|
repository.task.update(status="cancelled", lifecycle_status="cancelled")
|
||||||
with pytest.raises(DomainError) as cancelled:
|
with pytest.raises(DomainError) as cancelled:
|
||||||
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.domain_errors import DomainError
|
||||||
|
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||||
|
from app.domain.schemas.operational_task import (
|
||||||
|
ActionDecisionCommand, ActionExecuteCommand, ActionProposalCommand, ActionReceipt,
|
||||||
|
AgentArtifactsCommand, AgentRunCommand, ExternalFeedbackCommand,
|
||||||
|
)
|
||||||
|
from app.services.operational_recovery import track_work_order
|
||||||
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
|
from tests.support.operational_task_repository import TaskRepository, _active_run, _context, _task_document
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_pollution_work_order_can_be_approved_and_dispatched_while_simulation_runs():
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from app.auth.permissions import OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE
|
||||||
|
from app.infra.integrations.operational_actions import ActionDispatchOutcome
|
||||||
|
|
||||||
|
now = datetime(2026, 7, 16, 6, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
task["workflow"] = "water_pollution"
|
||||||
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
|
repository.pending_calculation_jobs = AsyncMock(return_value=["pollution-simulation"])
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
report = {"stage": "interim", "outcome": "concluded", "data_gaps": [], "title": "候选源核验",
|
||||||
|
"current_conclusion": "候选节点已取得,可先派现场核查", "conclusion_boundary": "污染传播计算中",
|
||||||
|
"evidence_refs": [], "recommendations": ["采样核验"], "pending_items": ["传播模拟"]}
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report=report))
|
||||||
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||||
|
request_id=uuid4(), kind="create_work_order", reason="先核验候选源",
|
||||||
|
payload={"title": "候选节点现场核查", "description": "核查污染进入路径并采样", "work_order_type": "general"}))
|
||||||
|
with pytest.raises(DomainError) as denied:
|
||||||
|
await service.decide_action(task["task_id"], action.action_id, ActionDecisionCommand(
|
||||||
|
request_id=uuid4(), expected_version=action.version, decision="approved"))
|
||||||
|
assert denied.value.code == "PERMISSION_DENIED"
|
||||||
|
service = OperationalTaskService(repository, context,
|
||||||
|
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE}, clock=lambda: now)
|
||||||
|
approved = await service.decide_action(task["task_id"], action.action_id, ActionDecisionCommand(
|
||||||
|
request_id=uuid4(), expected_version=action.version, decision="approved"))
|
||||||
|
dispatcher = SimpleNamespace(dispatch=AsyncMock(return_value=ActionDispatchOutcome("succeeded", "现场工单已创建",
|
||||||
|
ActionReceipt(adapter="peripheral_sample", receipt_id=str(uuid4()), submitted_at=now,
|
||||||
|
completed_at=now, external_status="open", detail="创建成功"))))
|
||||||
|
with pytest.raises(DomainError) as stale:
|
||||||
|
await service.submit_action(task["task_id"], action.action_id, ActionExecuteCommand(
|
||||||
|
request_id=uuid4(), expected_version=action.version), dispatcher, None)
|
||||||
|
assert stale.value.code == "VERSION_CONFLICT"
|
||||||
|
dispatched = await service.submit_action(task["task_id"], action.action_id, ActionExecuteCommand(
|
||||||
|
request_id=uuid4(), expected_version=approved.version), dispatcher, None)
|
||||||
|
assert dispatched.execution_status == "succeeded"
|
||||||
|
assert repository.task["handling_context"]["phase"] == "waiting_work_order"
|
||||||
|
assert repository.run["status"] == "running" and repository.task["execution_generation"] == 1
|
||||||
|
dispatcher.dispatch.assert_awaited_once()
|
||||||
|
repository.pending_calculation_jobs.assert_not_awaited() # No simulation dependency on dispatch.
|
||||||
|
|
||||||
|
with pytest.raises(DomainError) as pending:
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report={**report, "stage": "final"}))
|
||||||
|
assert pending.value.code == "PENDING_CALCULATIONS"
|
||||||
|
repository.pending_calculation_jobs.return_value = []
|
||||||
|
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||||
|
source="run_contaminant_simulation", source_ref="calculation:pollution", queried_at=now,
|
||||||
|
origin="synthetic", truncated=False, data={"result": {"impact": {"affected_node_count": 2}}}))
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report={**report,
|
||||||
|
"current_conclusion": "传播模拟完成,现场核查继续", "evidence_refs": [evidence.evidence_id],
|
||||||
|
"pending_items": [f"等待现场工单反馈,action_id={action.action_id}"]}))
|
||||||
|
assert len(repository.reports) == 2 and repository.action["version"] == dispatched.version
|
||||||
|
assert repository.action["approval_status"] == "approved" and repository.run["status"] == "running"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend():
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_pollution_report_waits_for_field_feedback_and_requires_latest_feedback_evidence(monkeypatch):
|
||||||
|
now = datetime(2026, 7, 16, 6, tzinfo=timezone.utc)
|
||||||
|
context = _context()
|
||||||
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
|
task.update(workflow="water_pollution", source={"kind": "external_system", "external_id": "scada:quality:1",
|
||||||
|
"external_revision": 1, "event_kind": "water_pollution"})
|
||||||
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
monkeypatch.setattr(service, "_resolve_investigation_alerts", AsyncMock())
|
||||||
|
scada, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||||
|
source="query_scada_readings", source_ref="mcp:quality", queried_at=now,
|
||||||
|
origin="system_data", truncated=False, data={
|
||||||
|
"window": {"start": (now - timedelta(hours=3)).isoformat(), "end": now.isoformat()},
|
||||||
|
"data": {"devices": [], "readings": [], "coverage": {"mode": "all_scada", "query_complete": True}},
|
||||||
|
}))
|
||||||
|
report = {"stage": "interim", "outcome": "concluded", "data_gaps": [], "title": "水污染事件调查",
|
||||||
|
"current_conclusion": "候选分析完成,待采样与处置反馈", "conclusion_boundary": "未确诊污染源",
|
||||||
|
"evidence_refs": [scada.evidence_id], "recommendations": ["现场采样复测"], "pending_items": ["现场反馈"]}
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report=report))
|
||||||
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||||
|
request_id=uuid4(), kind="create_work_order", reason="采样和处置核验",
|
||||||
|
payload={"title": "节点采样复测", "description": "回传位置、时间、数值和判定依据", "work_order_type": "general"}))
|
||||||
|
child = str(uuid4())
|
||||||
|
repository.action = action.model_copy(update={"approval_status": "approved", "execution_status": "succeeded"}).model_dump(mode="json")
|
||||||
|
repository.action["receipt"] = {"adapter": "peripheral_sample", "receipt_id": child,
|
||||||
|
"submitted_at": now.isoformat(), "completed_at": now.isoformat(), "external_status": "open", "detail": "创建成功"}
|
||||||
|
track_work_order(repository.task, repository.action)
|
||||||
|
result = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||||
|
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||||
|
assert result.status == "waiting_human"
|
||||||
|
service._resolve_investigation_alerts.assert_not_awaited()
|
||||||
|
|
||||||
|
evidence_id = "field-quality:2"
|
||||||
|
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
||||||
|
request_id=uuid4(), source_event_id=f"peripheral_sample:{child}", source_revision=2,
|
||||||
|
status="completed", occurred_at=now, detail="采样、处置与复测反馈已返回",
|
||||||
|
evidence={"evidence_id": evidence_id, "kind": "external_feedback", "summary": "现场记录及复测结果",
|
||||||
|
"source_label": "现场反馈", "source_ref": f"peripheral_sample:{child}", "observed_at": now}))
|
||||||
|
assert repository.task["handling_context"]["phase"] == "field_review"
|
||||||
|
assert "last_check" not in repository.task["handling_context"] # No pressure recovery shortcut.
|
||||||
|
await service.claim_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||||
|
request_id=repository.run["execution_request_id"], expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report={**report, "stage": "final"}))
|
||||||
|
with pytest.raises(DomainError) as rejected:
|
||||||
|
await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||||
|
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||||
|
assert rejected.value.code == "POLLUTION_FEEDBACK_REQUIRED"
|
||||||
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
|
request_id=uuid4(), source_session_id="session-1", report={**report, "stage": "final",
|
||||||
|
"evidence_refs": [scada.evidence_id, evidence_id], "pending_items": []}))
|
||||||
|
result = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||||
|
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1"))
|
||||||
|
assert result.status == "completed" and result.handling_context["phase"] == "completed"
|
||||||
|
service._resolve_investigation_alerts.assert_awaited_once_with(task["task_id"], evidence_id)
|
||||||
@@ -85,6 +85,25 @@ async def investigation():
|
|||||||
return task, repository, service, evidence.evidence_id
|
return task, repository, service, evidence.evidence_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_pollution_charts_validate_quality_values_and_keep_their_own_workflow():
|
||||||
|
from app.services.scada_presentation_coverage import add_targets, applies, ScadaObservations, verify_chart
|
||||||
|
task, repository, _, evidence_id = await investigation()
|
||||||
|
task["workflow"] = "water_pollution"
|
||||||
|
evidence = repository.evidence[evidence_id]
|
||||||
|
for device in evidence["data_snapshot"]["data"]["data"]["devices"]:
|
||||||
|
device.update(device_type="quality", measurement_unit="mg/L")
|
||||||
|
value = chart(devices=[DEVICES[0]])
|
||||||
|
value["scada"].update(metric="quality", unit="mg/L")
|
||||||
|
assert applies(task)
|
||||||
|
verified = verify_chart(task, value, [evidence], [evidence_id])
|
||||||
|
assert verified["metric"] == "quality" and verified["gaps"] == []
|
||||||
|
observations = ScadaObservations(task, [evidence])
|
||||||
|
assert add_targets(None, [DEVICES[0]], observations)["targets"] == [DEVICES[0]]
|
||||||
|
task["handling_context"] = {"phase": "field_review"}
|
||||||
|
assert not applies(task)
|
||||||
|
|
||||||
|
|
||||||
def coverage(comparison_devices):
|
def coverage(comparison_devices):
|
||||||
slots = [(START + timedelta(minutes=15 * i)).isoformat() for i in range(13)]
|
slots = [(START + timedelta(minutes=15 * i)).isoformat() for i in range(13)]
|
||||||
common = {"coverage": {device: {"covered": slots} for device in DEVICES}, "metric": "flow", "window_start": START.isoformat(), "window_end": END.isoformat(), "gaps": []}
|
common = {"coverage": {device: {"covered": slots} for device in DEVICES}, "metric": "flow", "window_start": START.isoformat(), "window_end": END.isoformat(), "gaps": []}
|
||||||
|
|||||||
@@ -807,6 +807,60 @@ def test_combined_monitoring_keeps_rule_evidence_and_quality_notifications(monke
|
|||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("create_tasks", [False, True])
|
||||||
|
def test_pollution_detection_creates_own_investigation_even_during_period_diagnosis(monkeypatch, create_tasks):
|
||||||
|
from app.services.scada_normal_profile import diagnostic_signal, pollution_signal
|
||||||
|
from app.services.scada_replay_processing import detect_frame
|
||||||
|
|
||||||
|
_, service, *_ = setup(monkeypatch)
|
||||||
|
push = AsyncMock()
|
||||||
|
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
|
||||||
|
advance = AsyncMock()
|
||||||
|
monkeypatch.setattr("app.infra.db.postgresql.task_alerts.TaskAlertRepository", lambda *_: object())
|
||||||
|
monkeypatch.setattr("app.services.task_alerts.TaskAlertService", lambda *_args, **_kwargs: SimpleNamespace(push=push))
|
||||||
|
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
|
||||||
|
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService",
|
||||||
|
lambda *_, **_kwargs: SimpleNamespace(create=create_task, advance_replay_investigation=advance))
|
||||||
|
config = ScadaReplayCreate(request_id=uuid4(), start_time=START, end_time=START + timedelta(hours=1),
|
||||||
|
device_ids=["Q1"], rules=[{"rule_id": "chlorine", "device_ids": ["Q1"],
|
||||||
|
"kind": "bounds", "lower": 0.1, "unit": "mg/L", "metric_name": "余氯"}])
|
||||||
|
document = {"replay_id": str(uuid4()), "config": config.model_dump(mode="json"),
|
||||||
|
"devices": [{"device_id": "Q1", "device_type": "quality", "transmission_frequency": "0:15:00"}]}
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
nonlocal document
|
||||||
|
# One continuous quality episode creates exactly one task, without any
|
||||||
|
# pressure roles, burst score or complaint.
|
||||||
|
for step, value in enumerate((0.05, 0.04, 0.2, 0.3)):
|
||||||
|
at = START + timedelta(minutes=15 * step)
|
||||||
|
document, changes = detect_frame(document, [{"device_id": "Q1", "time": at.isoformat(),
|
||||||
|
"quality": "valid", "cleaned_value": value, "monitored_value": value}], config, at)
|
||||||
|
for event in changes:
|
||||||
|
assert pollution_signal(document, event)
|
||||||
|
assert not diagnostic_signal(document, event)
|
||||||
|
await ScadaReplayService._create_investigations(service, document, changes,
|
||||||
|
{"sequence": step + 1, "at": at.isoformat()}, create_tasks=create_tasks)
|
||||||
|
create_task.assert_awaited_once()
|
||||||
|
command = create_task.await_args.args[0]
|
||||||
|
assert command.workflow == command.source.event_kind == "water_pollution"
|
||||||
|
assert command.task_type == "event_investigation"
|
||||||
|
assert command.source.replay_context.batch_sequence == 1
|
||||||
|
assert command.trigger.kind == "event"
|
||||||
|
assert "投诉" not in command.title and "爆管" not in command.summary
|
||||||
|
assert [call.args[0].event_type for call in push.await_args_list] == ["raised", "resolved"]
|
||||||
|
assert all(call.args[0].condition_id == f"operational-task:{create_task.return_value.task_id}"
|
||||||
|
for call in push.await_args_list)
|
||||||
|
advance.assert_awaited_once()
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_quality_data_is_not_a_pollution_detection():
|
||||||
|
from app.services.scada_normal_profile import pollution_signal
|
||||||
|
document = {"devices": [{"device_id": "Q1", "device_type": "quality"}]}
|
||||||
|
assert not pollution_signal(document, {"category": "data_quality", "device_ids": ["Q1"]})
|
||||||
|
assert not pollution_signal(document, {"device_ids": ["unknown"]})
|
||||||
|
|
||||||
|
|
||||||
def test_failed_incident_publication_rolls_back_state_and_retries_same_task_request(monkeypatch):
|
def test_failed_incident_publication_rolls_back_state_and_retries_same_task_request(monkeypatch):
|
||||||
repo, service, _, _, create = setup(monkeypatch)
|
repo, service, _, _, create = setup(monkeypatch)
|
||||||
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
|
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
|
||||||
|
|||||||
Reference in New Issue
Block a user