Compare commits

..
Author SHA1 Message Date
jiang 11eb09fe8f fix(burst): recognize native pressure units and separate isolation impact
Generic Container CI/CD / test-build-publish (push) Successful in 4m37s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m37s
2026-11-14 23:45:25 +08:00
12 changed files with 87 additions and 11 deletions
+9
View File
@@ -862,9 +862,18 @@ class TaskHighlightFeatures(StrictModel):
class TaskLocateFeatures(StrictModel): class TaskLocateFeatures(StrictModel):
action: Literal["locate_features"] action: Literal["locate_features"]
event_kind: Literal["burst", "pollution"] | None = Field(default=None,
description="定位事件语义:爆管候选传 burst,污染源传 pollution;普通资产定位省略")
layer: TaskMapLayer layer: TaskMapLayer
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)] feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
@model_serializer(mode="wrap")
def serialize_optional_kind(self, handler):
value = handler(self)
if self.event_kind is None:
value.pop("event_kind", None)
return value
class TaskZoomToMap(StrictModel): class TaskZoomToMap(StrictModel):
action: Literal["zoom_to_map"] action: Literal["zoom_to_map"]
+1 -1
View File
@@ -17,7 +17,7 @@ GRADES = (
def _head_factor(unit: Any) -> float | None: def _head_factor(unit: Any) -> float | None:
# Standard water head; unsupported or absent units must not become zero loss. # Standard water head; unsupported or absent units must not become zero loss.
return {"m": 1.0, "meters": 1.0, "metres": 1.0, return {"m": 1.0, "mtr": 1.0, "meters": 1.0, "metres": 1.0,
"kpa": 1 / 9.80665, "psi": 6894.757293168 / 9806.65, "kpa": 1 / 9.80665, "psi": 6894.757293168 / 9806.65,
"bar": 100000 / 9806.65, "mpa": 1000000 / 9806.65, "bar": 100000 / 9806.65, "mpa": 1000000 / 9806.65,
"ft": 0.3048}.get(str(unit).strip().lower()) "ft": 0.3048}.get(str(unit).strip().lower())
+14
View File
@@ -104,3 +104,17 @@ def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
normalized["properties"]["weight"] = weight normalized["properties"]["weight"] = weight
features.append(normalized) features.append(normalized)
return {"type": "FeatureCollection", "features": features} return {"type": "FeatureCollection", "features": features}
def validate_overlay_stage(data: dict, stage: str | None) -> None:
"""An event-impact claim must be supported by the saved scenario geometry."""
if stage != "event_impact":
return
features = data.get("features", [])
require(bool(features) and all(
(feature.get("properties") or {}).get("impact_phase") in {
"after_burst_before_intervention", "after_contamination_before_intervention",
} for feature in features
), "OVERLAY_IMPACT_PHASE_MISMATCH",
"事件影响阶段须引用处置前情景覆盖;关阀及供水失源覆盖请使用 isolation_plan,不能替代不可用的事件降压结果",
status=422)
+2 -1
View File
@@ -75,7 +75,7 @@ from app.domain.schemas.operational_task import (
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.services.content_digest import digest from app.services.content_digest import digest
from app.services.investigation_graph import adopt_legacy_investigation_graph, update_investigation_graph from app.services.investigation_graph import adopt_legacy_investigation_graph, update_investigation_graph
from app.services.operational_presentation import resolve_overlay from app.services.operational_presentation import resolve_overlay, validate_overlay_stage
from app.services.operational_recovery import accept_completion, track_work_order from app.services.operational_recovery import accept_completion, track_work_order
from app.services.operational_scada_coverage import require_complete_scada_evidence from app.services.operational_scada_coverage import require_complete_scada_evidence
from app.services import scada_presentation_coverage from app.services import scada_presentation_coverage
@@ -1124,6 +1124,7 @@ class OperationalTaskService:
) )
require(supplied == resolved, "OVERLAY_DATA_MISMATCH", require(supplied == resolved, "OVERLAY_DATA_MISMATCH",
"Overlay data must match the immutable evidence snapshot", status=422) "Overlay data must match the immutable evidence snapshot", status=422)
validate_overlay_stage(resolved, command.narration.stage if command.narration else None)
payload["data"] = resolved payload["data"] = resolved
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]), metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
"execution_generation": run["generation"], "command": payload} "execution_generation": run["generation"], "command": payload}
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": { "contracts": {
"server": { "server": {
"file": "server-v1.openapi.json", "file": "server-v1.openapi.json",
"sha256": "c31e9b4c71216951d1525acf1bcb8aeba4158b6cdc6b0b9dc01be02f9b6e6af3" "sha256": "9801087d40ac0e1f67871f9a1db850cbcce47614cceec0a7e8771cdf58385f53"
} }
} }
} }
+16
View File
@@ -9945,6 +9945,22 @@
"title": "Action", "title": "Action",
"type": "string" "type": "string"
}, },
"event_kind": {
"anyOf": [
{
"enum": [
"burst",
"pollution"
],
"type": "string"
},
{
"type": "null"
}
],
"description": "定位事件语义:爆管候选传 burst,污染源传 pollution;普通资产定位省略",
"title": "Event Kind"
},
"feature_ids": { "feature_ids": {
"items": { "items": {
"maxLength": 128, "maxLength": 128,
+7
View File
@@ -7,6 +7,13 @@
"type": "string", "type": "string",
"const": "locate_features" "const": "locate_features"
}, },
"event_kind": {
"type": "string",
"enum": [
"burst",
"pollution"
]
},
"layer": { "layer": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -2,7 +2,7 @@
name: burst-event-handling name: burst-event-handling
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。 description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
metadata: metadata:
version: "1.1.4" version: "1.1.5"
--- ---
# 爆管事件处理 # 爆管事件处理
@@ -21,7 +21,7 @@ metadata:
已有管段或节点编号时,用 get_element_properties 核对资产;已有坐标时直接使用,文字地址通过 geocode 解析后用 query_nearby_elements 获取附近资产。结合属性、道路、连接关系和现场记录确定处理对象,使用 get_node_connections 补充连接依据。 已有管段或节点编号时,用 get_element_properties 核对资产;已有坐标时直接使用,文字地址通过 geocode 解析后用 query_nearby_elements 获取附近资产。结合属性、道路、连接关系和现场记录确定处理对象,使用 get_node_connections 补充连接依据。
在地图展示上报位置和关联资产,标明关联依据及现场确认状态。存在多个合理对象时保留候选及选择理由;关键位置需要人工补充时,先完成独立工作,再用 ask_task_user 提问。 爆管候选管段用 locate_features 传 event_kind="burst" 定位。在地图展示上报位置和关联资产,标明关联依据及现场确认状态。存在多个合理对象时保留候选及选择理由;关键位置需要人工补充时,先完成独立工作,再用 ask_task_user 提问。
定位到管道后立即保存 interim/concluded 阶段报告,明确“候选管段待现场核验”,调用 propose_task_action 提出 kind=create_work_order、work_order_type=general 的现场核查/抢修草稿,引用定位证据、地点和管段;沿用已有 action_id,不重复建单。人工审核、派发按 Server 权限与版本完成。审批或现场处理等待期间,继续独立的爆管影响模拟和隔离分析,不等模拟完成才发起工单;模拟结果补入同一调查与后续报告,不能把模型计算成功写成已派单。 定位到管道后立即保存 interim/concluded 阶段报告,明确“候选管段待现场核验”,调用 propose_task_action 提出 kind=create_work_order、work_order_type=general 的现场核查/抢修草稿,引用定位证据、地点和管段;沿用已有 action_id,不重复建单。人工审核、派发按 Server 权限与版本完成。审批或现场处理等待期间,继续独立的爆管影响模拟和隔离分析,不等模拟完成才发起工单;模拟结果补入同一调查与后续报告,不能把模型计算成功写成已派单。
@@ -31,13 +31,13 @@ metadata:
工具在筛选展示节点前比较全网节点的起算同刻在线模拟压力,换算米水头,三档互斥:1≤下降<3、3≤下降<5、下降≥5。只用同刻有效值,不以代表节点、邻近时刻或缺失值代替;保留缺测、单位来源、模型时态、比较节点数及覆盖缺口。范围表示爆管后、关阀或抢修前的模型估计,单独于隔离失源范围。 工具在筛选展示节点前比较全网节点的起算同刻在线模拟压力,换算米水头,三档互斥:1≤下降<3、3≤下降<5、下降≥5。只用同刻有效值,不以代表节点、邻近时刻或缺失值代替;保留缺测、单位来源、模型时态、比较节点数及覆盖缺口。范围表示爆管后、关阀或抢修前的模型估计,单独于隔离失源范围。
读取计算结果后按 data.presentation.command 的 evidence_id 用 publish_task_presentation 发布“爆管后、处置前的预计影响范围”,并发布三档节点计数、比较时刻和假设表;无地图几何时说明原因。证据、地图及表格进入正式任务事件,历史播放还原已保存结果,不重新查询在线基线。先保留此步骤,再展示隔离方案,不能用后续隔离覆盖替换本步。 读取计算结果后按 data.presentation.command 的 evidence_id 用 publish_task_presentation 发布“爆管后、处置前的预计影响范围”,并发布三档节点计数、比较时刻和假设表;无地图几何时说明原因。证据、地图及表格进入正式任务事件,历史播放还原已保存结果,不重新查询在线基线。本步 narration.stage 用 event_impact。三级结果 unavailable 时单列原因和待补项;先保留此步骤,再展示隔离方案,不得用关阀覆盖替代。
## 4. 隔离方案 ## 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 发布地图请求。取得初步隔离方案后继续影响分析和处置建议。 本步 narration.stage 用 isolation_plan,覆盖标题明确“关阀方案影响”。展示方案阀门和直接隔离、供水失源两类节点;节点覆盖使用 generate_node_coverage,再通过 publish_task_presentation 发布地图请求。取得初步隔离方案后继续影响分析和处置建议。
## 5. 影响说明 ## 5. 影响说明
@@ -2,7 +2,7 @@
name: scada-anomaly name: scada-anomaly
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。 description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
metadata: metadata:
version: "1.0.19" version: "1.0.20"
--- ---
# SCADA 数据异常调查 # SCADA 数据异常调查
@@ -31,9 +31,9 @@ metadata:
取得候选即展示管道,发 interim/concluded 报告及现场工单草稿;复用action_id,待审批继续模拟。 取得候选即展示管道,发 interim/concluded 报告及现场工单草稿;复用action_id,待审批继续模拟。
run_burst_simulation设include_impact_coverage=true,用定位管道、异常时刻及有依据的破口面积cm²。全网同刻在线压力差按米水头分档1≤下降<3、3≤下降<5、下降≥5。按data.presentation发布地图及计数,缺口单列;先事件影响,再隔离。回放仅用当前模型、禁实时SCADA;在线参考非冻结观测,历史展示复用保存证据。 run_burst_simulation设include_impact_coverage=true,用定位管道、异常时刻及有依据的破口面积cm²。全网同刻在线压力差按米水头分档1≤下降<3、3≤下降<5、下降≥5。按data.presentation发布地图及计数,narration.stage=event_impact;不可用时单列原因,禁用关阀范围替代。回放仅用当前模型、禁实时SCADA;在线参考非冻结观测,历史展示复用保存证据。
再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示节点覆盖。候选与拓扑建议标注为待现场核验方案。最终报告前核对定位调用及返回结果,调用失败记录具体错误与输入缺口。 再按授权用 analyze_valve_isolation 分析隔离边界,保留必关阀、可选阀、affected_nodes、supply_affected_nodes 及计数;用 generate_node_coverage 展示关阀覆盖,stage=isolation_plan。候选与拓扑建议标注为待现场核验方案。爆管候选用locate_features传event_kind=burst。报告前核对定位结果,失败列明错误与缺口。
## 对比展示与影响 ## 对比展示与影响
+8
View File
@@ -195,3 +195,11 @@ async def test_order_notification_precedes_durable_impact_and_keeps_run_availabl
result.data["result"]["map_payload"]["features"].clear() result.data["result"]["map_payload"]["features"].clear()
assert len(repository.events[-1]["metadata"]["command"]["data"]["features"]) == 3 assert len(repository.events[-1]["metadata"]["command"]["data"]["features"]) == 3
assert repository.run["status"] == "running" assert repository.run["status"] == "running"
@pytest.mark.parametrize("unit", ["MTR", "mtr", " MTR "])
def test_native_mtr_pressure_generates_all_three_grades(inputs, unit):
result = service.estimate_burst_impact("own", START, simulation([1, 2.999, 3, 4.999, 5], unit))
assert result["impact"]["status"] == "ready"
assert [g["node_ids"] for g in result["impact"]["grades"]] == [["J0", "J1"], ["J2", "J3"], ["J4"]]
assert len(result["map_payload"]["features"]) == 3
+13 -1
View File
@@ -7,7 +7,7 @@ from pydantic import ValidationError
from app.domain.domain_errors import DomainError from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_data import TaskDataSnapshot from app.domain.schemas.operational_data import TaskDataSnapshot
from app.domain.schemas.operational_task import TaskPresentationEventCommand, TaskSafeChart from app.domain.schemas.operational_task import TaskPresentationEventCommand, TaskSafeChart
from app.services.operational_presentation import resolve_overlay from app.services.operational_presentation import resolve_overlay, validate_overlay_stage
def _snapshot(data): def _snapshot(data):
@@ -58,6 +58,7 @@ def test_presentation_input_rejects_free_form_or_invalid_commands(command):
@pytest.mark.parametrize("command", [ @pytest.mark.parametrize("command", [
{"action": "locate_features", "layer": "scada", "feature_ids": ["D-1"]}, {"action": "locate_features", "layer": "scada", "feature_ids": ["D-1"]},
{"action": "locate_features", "layer": "pipes", "feature_ids": ["P-1"], "event_kind": "burst"},
{"action": "zoom_to_map", "center": [121.4, 31.1], "zoom": 24}, {"action": "zoom_to_map", "center": [121.4, 31.1], "zoom": 24},
{"action": "render_scada_analysis", "items": [{"device_id": "D-1", "level": "unrated"}]}, {"action": "render_scada_analysis", "items": [{"device_id": "D-1", "level": "unrated"}]},
{"action": "clear_scada_analysis"}, {"action": "clear_scada_analysis"},
@@ -110,3 +111,14 @@ def test_safe_chart_preserves_sparse_scatter_and_real_sample_times():
def test_safe_chart_rejects_invalid_sample_metadata_and_values(series): def test_safe_chart_rejects_invalid_sample_metadata_and_values(series):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
TaskSafeChart(title="X", chart_type="line", x_data=["t"], series=[series]) TaskSafeChart(title="X", chart_type="line", x_data=["t"], series=[series])
@pytest.mark.parametrize("phase", ["after_burst_before_intervention", "after_contamination_before_intervention"])
def test_event_impact_requires_pre_intervention_geometry(phase):
data = {"features": [{"properties": {"impact_phase": phase}}]}
validate_overlay_stage(data, "event_impact")
closure = {"features": [{"properties": {"coverage_group": "supply_affected_nodes"}}]}
validate_overlay_stage(closure, "isolation_plan")
with pytest.raises(DomainError) as error:
validate_overlay_stage(closure, "event_impact")
assert error.value.code == "OVERLAY_IMPACT_PHASE_MISMATCH"
+9
View File
@@ -580,6 +580,15 @@ async def test_saved_mcp_coverage_envelope_publishes_groups_without_agent_geomet
}}), }}),
) )
evidence, _ = await service.record_data_snapshot(task["task_id"], snapshot) evidence, _ = await service.record_data_snapshot(task["task_id"], snapshot)
with pytest.raises(DomainError) as error:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command={"action": "set_overlay", "overlay_id": "wrong-impact", "title": "事件影响",
"mode": "geometry", "evidence_id": evidence.evidence_id},
narration={"stage": "event_impact", "summary": "预计影响范围,现场待核实。",
"evidence_refs": [evidence.evidence_id]},
))
assert error.value.code == "OVERLAY_IMPACT_PHASE_MISMATCH"
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand( await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command={"action": "set_overlay", "overlay_id": "isolation-coverage", "title": "节点覆盖示意", command={"action": "set_overlay", "overlay_id": "isolation-coverage", "title": "节点覆盖示意",