feat(scada): add project monitoring replay
Generic Container CI/CD / test-build-publish (push) Successful in 3m39s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m39s

This commit is contained in:
2026-11-04 14:27:21 +08:00
parent f25423dec4
commit 26fa5d1909
53 changed files with 5153 additions and 20 deletions
+65
View File
@@ -0,0 +1,65 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="362" viewBox="0 0 720 362" role="img" aria-labelledby="title description">
<title id="title">SCADA 演示时间轴 FM 调频盘样式</title>
<desc id="description">静态设计示意。选定区间为十月八日八点至十四点,当前时间十点十五分。半透明玻璃刻度窗内细密时间刻度横向排列,中央红色指示线固定,控制栏包含暂停、下一批和六十倍速。</desc>
<defs>
<linearGradient id="glass" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity=".72"/>
<stop offset=".5" stop-color="#f7faff" stop-opacity=".4"/>
<stop offset="1" stop-color="#fff" stop-opacity=".64"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#f7f9fc"/>
<stop offset=".12" stop-color="#f7f9fc" stop-opacity="0"/>
<stop offset=".88" stop-color="#f7f9fc" stop-opacity="0"/>
<stop offset="1" stop-color="#f7f9fc"/>
</linearGradient>
<filter id="blur"><feGaussianBlur stdDeviation="16"/></filter>
<filter id="shadow" x="-.1" y="-.3" width="1.2" height="1.6">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#243852" flood-opacity=".1"/>
</filter>
<clipPath id="window"><rect x="48" y="130" width="624" height="110" rx="12"/></clipPath>
</defs>
<rect width="720" height="362" rx="18" fill="#e9edf2"/>
<rect x="24" y="22" width="672" height="318" rx="16" fill="#fafbfd" stroke="#d7dee7"/>
<g font-family="'Noto Sans SC Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Noto Sans CJK SC', sans-serif" fill="#263347">
<text x="48" y="54" font-size="13" font-weight="600">SCADA 时段演示</text>
<text x="672" y="54" text-anchor="end" font-size="11" fill="#778398">静态样式示意</text>
<text x="48" y="79" font-size="12" fill="#68758a">演示区间</text>
<text x="114" y="79" font-size="12">10 月 8 日 08:00 — 14:00</text>
<text x="360" y="118" text-anchor="middle" font-size="28" font-weight="500" font-variant-numeric="tabular-nums" letter-spacing="1">10:15:00</text>
<rect x="48" y="130" width="624" height="110" rx="12" fill="#edf2f7" filter="url(#shadow)"/>
<g clip-path="url(#window)">
<g filter="url(#blur)" opacity=".6">
<path d="M12 234 L280 132 L423 247 L749 142" fill="none" stroke="#c7d8e7" stroke-width="35"/>
<path d="M63 134 L221 219 L438 128 L647 212" fill="none" stroke="#fff" stroke-width="38"/>
</g>
<rect x="48" y="130" width="624" height="110" fill="url(#glass)"/>
<path d="M65 131 H655" stroke="#fff" stroke-width="2" opacity=".9"/>
<path d="M49 138 V231 M671 138 V231" stroke="#fff" opacity=".6"/>
<path d="M60 237 H660" stroke="#a8b6c8" opacity=".25"/>
<g fill="none" stroke="#67778b" stroke-width="1">
<path d="M90 166 V197 M180 166 V197 M270 166 V197 M360 166 V197 M450 166 V197 M540 166 V197 M630 166 V197"/>
<path d="M60 179 V196 M120 179 V196 M150 179 V196 M210 179 V196 M240 179 V196 M300 179 V196 M330 179 V196 M390 179 V196 M420 179 V196 M480 179 V196 M510 179 V196 M570 179 V196 M600 179 V196 M660 179 V196" opacity=".65"/>
</g>
<g font-size="11" text-anchor="middle" font-variant-numeric="tabular-nums">
<text x="90" y="216">09:30</text><text x="180" y="216">09:45</text>
<text x="270" y="216">10:00</text><text x="360" y="216" font-weight="600">10:15</text>
<g fill="#7b889a"><text x="450" y="216">10:30</text><text x="540" y="216">10:45</text><text x="630" y="216">11:00</text></g>
</g>
<rect x="48" y="131" width="624" height="108" fill="url(#edge)"/>
</g>
<rect x="48.5" y="130.5" width="623" height="109" rx="11.5" fill="none" stroke="#fff" stroke-opacity=".8"/>
<path d="M356 141 H364 L360 147 Z" fill="#cf453f"/>
<path d="M360 147 V201" stroke="#cf453f" stroke-width="2"/>
<rect x="48" y="256" width="88" height="38" rx="8" fill="#4662ba"/>
<path d="M65 269 V281 M70 269 V281" stroke="#fff" stroke-width="2"/>
<text x="98" y="280" text-anchor="middle" fill="#fff" font-size="12">暂停</text>
<rect x="148" y="256" width="88" height="38" rx="8" fill="#fff" stroke="#d7dee7"/>
<text x="192" y="280" text-anchor="middle" font-size="12" fill="#7b8799">下一批</text>
<rect x="578" y="256" width="94" height="38" rx="8" fill="#fff" stroke="#d7dee7"/>
<text x="601" y="280" font-size="12">60×</text>
<path d="M651 273 L655 277 L659 273" fill="none" stroke="#64738a" stroke-width="1.5"/>
<text x="48" y="319" font-size="11" fill="#68758a">已读取 10:15 · 已检测 10:10</text>
<text x="672" y="319" text-anchor="end" font-size="11" fill="#68758a">红线对应当前时间 · 横向拨动可回看</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+2
View File
@@ -39,6 +39,8 @@ Forward Hostname 后追加 `/api/sample/v1`。本机开发和预览模式也将
- `/api/sample/v1/monitoring/points`、`observations`、`alerts`
- `/api/sample/v1/demo-data`
SCADA 按月数据集、5 分钟时间定位、主动周期监测、质量校验、爆管调查与定位边界见 [SCADA 外围系统时段回放、主动监测与爆管调查设计](scada-peripheral-replay-design.md)。部署回放存储需在项目 BizDB 执行 `031_scada_replays.sql`;项目通用配置需在 MetaDB 执行 `033_metadata_project_settings.sql`。监测频率保存于 `projects.settings.scada_monitoring_interval_seconds`,压力与出厂边界流量角色保存在类型化 `projects.settings.scada_monitoring` 中;本阶段不注册定时任务,也不部署 SCADA 回放 worker。代码和结构迁移完成不等于月度数据或 Agent 联调已经验收。
演示数据通过 `scripts/seed_peripheral_sample.py` 写入 `tjwater_v2` 的 `peripheral_sample` schema。当前固定窗口为 2026-09-17,所有记录标记为 `synthetic`,并使用 `DEMO-` 前缀。
## 工单事件调查
+476
View File
@@ -0,0 +1,476 @@
# SCADA 外围系统时段回放、主动监测与爆管调查设计
状态:时段回放、主动周期监测、条件式 `burst_report` 调查及回放安全的爆管定位适配已实现。单步处理由用户操作触发并在当前 API 请求内完成,不部署 SCADA 回放 worker。`tjwater_v2` 与 `tjwater2_v2` 尚未确认出厂边界流量角色;仍待完整月度数据和真实 Agent 联调。更新日期:2026-11-03。下面明确区分已实现范围、后续目标与验收要求。
本次将外围 SCADA 定义为一个模拟外部数据源:本地 TSDB 已保存原始观测,通过只读 API 按选定时间区间和后台时钟分批读取 `scada.measurements.monitored_value`,将这一批直接交给清洗流水线,保存 `cleaned_value` 后执行异常检测及后续 Agent 调查。原始值、修正值、真实工况异常和处理结论分别保留。浏览器负责控制和展示,关闭页面不停止后台回放。
演示主路径为“TSDB 已存 raw → 用户点击下一步并监测 → 顺序读取一个步长内的观测 → 清洗并更新修正值 → 在步长边界执行多指标监测 → 满足条件时进入爆管调查与定位”。真实外部系统新增观测时,仍先通过入库接口保存 raw,再进入相同的清洗检测流程。演示读取已有数据不再次调用原始值插入接口,也不把同一批数据从一个内部服务读出后再写回原表。
演示操作以用户选定的时间区间为中心:一个月是可选择的数据范围,单次演示可以只覆盖其中几小时或几天。时间轴采用 FM 调频盘样式,玻璃刻度窗内有固定红色指示线,时间刻度带随拨动横向移动。时间轴使用 5 分钟时间戳定位;项目监测频率只决定“下一步并监测”覆盖的业务周期,两者互不替代。
## 首版实现范围与限制
已实现只读 raw 分页、持久回放控制、按项目监测频率推进、逐时刻清洗检测、质量告警与运行异常事件、周期监测回执、组合判定及调查任务。主动单步只有组合结果为 `suspected_burst` 时才创建 `burst_report`。前端改为后端状态投影,主界面只保留选定区间、5 分钟 FM 时间定位、主动周期控制和本周期监测结论,不重复展示 SCADA 设备目录与逐测点读数表。主要代码为 [回放服务](../app/services/scada_replay.py)、[组合监测](../app/services/scada_replay_monitoring.py)、[质量与检测](../app/services/scada_replay_processing.py)、[持久状态](../app/infra/db/postgresql/scada_replays.py) 和 [前端组件](../../next-tjwater-frontend/src/features/workbench/peripheral-sample/scada-replay-section.tsx)。
本版采用每项目最多 500 个设备、一个完整时间戳一批、至多一个未完成批次的串行处理,不实现 20 批并发队列或大于 500 个设备的分片接收。处理变慢时逐批追赶且不跳过中间观测;后台停机期间最多补算两秒现实时间,不一次性追赶全部停机时长。数据质量以有限数值和显式仪表量程为依据,不自动插值或平滑;缺测判断使用设备档案的采样周期。分组规则的恢复需要该组设备在同一帧都有可信观测,不同采样周期或采样相位应拆成规则。
本版冻结设备、区间、规则及批次输出,按批次原始值摘要校验源变更。完整月度数据清单、全月源版本摘要、独立事件真值和检测效果评估仍是后续数据准备工作;能力接口仅判断回放存储是否存在与当前操作权限,不声明月度数据已经就绪。现有 14 天数据不能当作一个月验收。
Agent 查询本轮已检测的固定批次快照,受任务的 replay_id、knowledge_cutoff、batch_sequence 约束。开始前历史只供后台基线初始化,本版调查查询不返回该部分;爆管定位已支持受回放截止约束的冻结输入适配,其他未适配计算工具继续拒绝。新 [SCADA 调查 Skill](../resources/work-order-skills/scada-anomaly/SKILL.md) 要求据实说明这些证据限制。运行异常自动建立调查,数据质量异常仅建立告警,不触发模型;恢复时推进调查代次。真实模型推理与报告效果尚未联调。
控制进度和事件状态使用有界轮询,沿用告警流发布业务告警;本版未新增 scada.replay.changed SSE 主题。演示面板只显示最近一个周期的组合结论,逐测点输出和完整事件历史由分页 API 保留给诊断与调查入口,不在主面板重复铺开。
## 已确定的设计边界
- 数据集覆盖一个月,包含多次独立异常及各次事件发生、持续、恢复的过程。
- 项目配置一个 SCADA 监测频率,只用于一次主动监测所覆盖的周期;时间轴独立使用 5 分钟时间戳,它不关联设备或设备类型,本阶段不据此创建定时任务。
- 设备采样周期仍属于设备档案。回放实际传入时间以原始观测时间戳为准,不使用项目监测任务间隔生成、重采样或判断设备缺测。
- 复用 `scada.measurements.monitored_value` 与 `cleaned_value`;不另建一套正式 SCADA 原始值和修正值表。
- 回放读取 API 只返回原始观测,修正值由本地清洗流水线计算和保存;GET 不更新游标、不清洗、不创建检测任务。
- 真实压力、流量、水质异常保留在历史序列中。事件恢复或调查结束不删除、不抹平历史异常。
- 数据处理顺序为原始观测持久化、数据质量校验、必要的修正、业务异常检测、事件与调查。
- 步长内的数据批次按原始时间驱动清洗和基础规则检测;到达步长边界后,用户触发的本周期监测汇总多项指标。正常周期不启动 Agent。
- 时间范围、设备、数据集版本和检测规则在一次回放创建后固定;改变这些条件需要新建回放。
- 页面首先选择演示起止时间,再建立回放。月度数据集、设备配置和规则作为来源与设置,不占据主播放区。
- 本次演示对已存 raw 只读,清洗阶段仅更新同一条观测的 cleaned_value;不导出整月副本、不重复插入原始值。每次回放的质量结论、检测结果和调查证据按回放身份隔离。
### 项目通用配置与监测频率
MetaDB 的 `projects.settings` 为项目级通用 JSONB 对象,默认 `{}`,用于保存可扩展的业务参数,不存放密钥。新增参数可直接增加 JSON 键,无需为每项配置新增数据库列;后端为已知参数提供类型校验,并保留其他 JSON 键。
其中 `settings.scada_monitoring_interval_seconds` 表示项目的 SCADA 监测频率,单位为秒。本阶段只用于主动处理边界和监测周期标识,不控制时间轴刻度,不注册 cron、后台 schedule 或周期性 Agent 任务。它与设备目录、设备类型、设备采样周期和页面刷新周期均无关联。省略或设为 `null` 表示未配置;有效范围为 60 秒至 365 天。
管理员可通过 `PATCH /api/v1/admin/projects/{project_id}` 配置:
```json
{
"settings": {
"scada_monitoring_interval_seconds": 900
}
}
```
更新时按 `settings` 顶层键合并:未传入的键保留,传入的嵌套对象整体替换,`settings: {}` 不清空已有配置,`settings: null` 会被拒绝。读取项目详情和管理员项目列表均返回 `settings`。
组合监测仍复用该 JSON 对象,不新增固定数据库列。后端类型化嵌套对象显式声明项目实际具备的监测角色:
```json
{
"settings": {
"scada_monitoring_interval_seconds": 900,
"scada_monitoring": {
"rule_version": "burst-monitor-v1",
"pressure_device_ids": ["明确的压力设备 ID"],
"outlet_flow_device_ids": ["明确的出厂边界流量设备 ID"]
}
}
}
```
当前代码已类型校验并在创建回放时冻结 `scada_monitoring`。项目没有出厂流量或不足 5 个完整压力点时保持缺项,能力接口说明原因,监测回执返回 `insufficient_data`;不能为了让演示通过而把其他流量点冒充出厂流量或虚构设备。`tjwater_v2` 现有目录只有通用 `pipe_flow` 类型及模型管段映射,没有足以证明“出厂边界”的角色字段,因此本轮未写入该嵌套配置。
部署前需在 MetaDB 执行 `resources/sql/033_metadata_project_settings.sql`。迁移会将实验版本可能存在的独立监测间隔列搬入 `settings`,核对冲突后移除旧列;重复执行不丢失已有配置。当前 `tjwater_v2` 与 `tjwater2_v2` 均配置 900 秒,即 15 分钟的主动监测周期。该参数不改变 5 分钟时间轴定位,本阶段明确不实现通用定时任务调度。
## 实施前基础与原设计差距
下表记录实施前基础,用于说明改动缘由;本次实际实现以首版范围及后文接口表为准。设备目录采样周期为本轮讨论中 2026-11-02 的只读核查快照,与项目监测任务间隔是两个概念。
| 位置 | 当前能力 | 本次需要补齐 |
| --- | --- | --- |
| [外围 API](../samples/peripheral/api.py)、[存储路由](../samples/peripheral/routing.py) | 挂载在 `/peripheral`,使用 Bearer 与 `X-Project-Id`,按已授权项目解析 BizDB 和 TimescaleDB | 回放控制、源批次、回放状态与能力声明 |
| [历史观测接口](../samples/peripheral/store.py) | 单点、最多 7 天、最多返回 1000 条并声明截断;当前使用 `api_query_id` 查历史 | 回放读取使用规范 `device_id`,按时间和设备联合游标分页;不能直接拼接现有单点接口来装载一个月 |
| [SCADA API](../app/api/v1/endpoints/timeseries/scada.py)、[仓储](../app/infra/db/timescaledb/repositories/scada.py) | 范围读取、最新值读取、批量插入;当前范围读取返回 raw 和 corrected,缺少回放游标及完整流水线 | 增加只读 raw 分页契约;复用存储查询,补齐处理检查点、清洗与检测调度 |
| [清洗服务](../app/services/timeseries_analysis.py) | 从原始值计算并更新 `cleaned_value`;目前支持压力和流量 | 在线质量标记、因果修正及选择值依据;水质不能直接套用现有清洗 |
| [压力清洗](../app/algorithms/scada_cleaning/pressure_series.py)、[流量清洗](../app/algorithms/scada_cleaning/flow_series.py) | 包含居中窗口、后向补值、Kalman 平滑;流量路径还会补为 1 分钟序列 | 在线路径只使用已到达的数据,保留设备频率,不整月预清洗后再模拟检测 |
| [外围工单事件同步](peripheral-work-order-events.md) | 持久事件、通知唤醒、后台同步和定期对账 | 复用可靠处理方式,SCADA 的批次有自己的持久状态 |
| [调查 worker](../scripts/run_operational_worker.py) | 可靠投递 `event_investigation`,恢复执行租约,管理 Agent 执行代次 | 接收由 SCADA 异常事件建立的调查;不承担每条观测的检测 |
| [任务告警](../app/domain/schemas/task_alert.py) | 支持 raised、acknowledged、resolved;当前来源必须携带任务和执行标识 | 增加真实的回放检测来源,不能为调查前告警伪造 `task_id`、`run_id` |
| [MCP 上下文](../app/mcp/contexts.py)、[SCADA 查询](../app/mcp/query_service.py) | 校验任务、项目、执行代次和会话,保存查询证据 | 在所有回放调查读取入口执行统一的时间与数据版本约束 |
| [业务 Skill 映射](../app/mcp/work_order_skills.py) | 目前映射爆管上报、水质投诉、预约跟进 | 增加 SCADA 回放调查流程,不将检测信号直接等同于已确认爆管或污染 |
| [前端 SCADA 区域](../../next-tjwater-frontend/src/features/workbench/peripheral-sample/scada-system-section.tsx) | 旧版同时展示设备表、逐点读数、重复概览和手工合成读数 | 改为服务端状态投影;主界面只承担时间定位、主动单步和周期结论,详细数据留在诊断与调查接口 |
实施前只读核查中,`tjwater_v2` 有压力、流量和水质监测类型,其他项目的类型集合与历史周期并不相同。项目通用配置保存一个监测任务周期,不与设备类型绑定,也不把 5/15 分钟写成枚举;设备原有采样信息仍用于缺测判断。
现有 [演示记录](../../present.md) 描述的是 14 天合成观测,不能作为一个月数据已经准备好的证明。一个月数据准备与本设计实现分别验收。
## 运行链路与职责
```text
本地 TSDB 已存月度原始观测;项目 BizDB 保存设备目录
|
v
只读 raw API / 同一读取服务 <--- 持久回放状态 <--- 页面“下一步并监测”
|
| 返回一批原始观测,不再写入 raw
v
后台固定本批记录引用、摘要和处理身份
|
v
后台质量校验与清洗
|
v
本地 TSDB:仅更新 cleaned_value
|
v
用同批清洗结果执行基础规则检测
|
v
到达步长边界,汇总爆管侦测、压力下降、出厂流量变化
|
v
疑似爆管且定位输入具备 ---> burst_report 任务与 MCP Skill
|
v
工作台告警、调查进度与结果
```
SCADA 单步命令在当前 API 请求内按原始时间顺序完成 raw 读取、清洗、基础规则检测和周期组合监测。每个阶段仍写入持久批次,网络中断后的同一幂等请求可以继续未完成步骤,但页面关闭后不会自动推进时间,也不需要单独的 SCADA 回放进程。
现有调查 worker 只处理已经创建的调查执行,计算 worker 继续处理长计算。当前阶段不增加消息中间件,不扩展通用 schedule API,也不注册 SCADA 定时任务。页面的明确单步命令写入持久 `step_target`,API 只完成这次已授权的周期处理;不能用“每隔几分钟创建一条 Agent 任务”替代。
最小可交付版本必须包含主动周期处理、批次可靠保存、至少一种明确配置的检测规则和结果展示;只移动页面时间轴不能代表数据已经处理。
## 一个自然月的数据集
数据集用带时区的半开区间定义。例如北京时间 `[2026-10-01 00:00, 2026-11-01 00:00)` 是 31 天,不能固定按 30 天计算。内部统一保存带时区时间,界面按 Asia/Shanghai 展示。
准备数据时冻结清单:`dataset_id`、`dataset_revision`、源项目、观测区间、可用历史基线区间、设备规范 ID、类型、单位、设备采样周期、采样相位、指标含义、raw 分片摘要和规则配置版本。由服务端读取项目路由,页面不传物理库名、SQL 或连接信息。摘要只覆盖原始观测与必要元数据,不覆盖会被本轮清洗更新的 cleaned_value。月度摘要由数据集准备流程预先生成,创建回放的 HTTP 请求只校验清单,不同步扫描和加载整个月。
首版演示直接读取已准备在本地 TSDB 的 raw,不另导出一套完整原始数据源。月份数据如果尚未齐备,由单独授权的数据准备过程补齐并生成版本清单;创建或播放回放本身不生成、删除、搬移或重复插入 SCADA 原始观测。演示批次是对已有观测的读取与处理身份,不能在统计中称为本轮新插入条数。
设备档案采样周期与实际观测间隔不一致时,记录配置差异和缺测情况,仍按实际时间戳读取。不会将 15 分钟数据插值成 5 分钟观测,也不会把没有记录的时刻当作正常值。项目监测任务间隔不参与这项判断。
多次异常应同时覆盖:真实压力异常、真实流量变化、真实水质变化、仪表或通信故障、正常调度引起的变化,以及恢复后的再次异常。数据集真值清单独立保存,用于事后计算命中、误报、漏报、重复事件数和发现延迟。真值不得出现在检测输入、Agent 上下文、预告型时间轴标记或普通业务报告中。
水质必须声明具体指标。当前演示中的化学示踪物浓度不能直接解释为余氯、浊度,也不能只凭 `mg/L` 选择检测阈值。流量保留方向,压力确认 m、kPa、MPa 等单位后再应用规则。
数据集准备负责人需要在运行前提交版本化设备清单、规则参数和独立真值。这是新数据集的输入要求,不表示本次已选定新的 5 分钟设备分组或生成了观测。
## 时间轴和读取语义
### 时间与进度
| 名称 | 含义 |
| --- | --- |
| `observed_at` | 原始观测时间,不随回放改写 |
| `virtual_now` | 已完成主动监测的周期边界时间 |
| `delivered_until` | 本轮源区间已读完,且所有分片的记录引用已登记为待处理的时间上限;表示模拟传入进度,不是原始值新插入时间 |
| `processed_until` | 质量校验和检测已完整提交的时间上限 |
| `read_at`、`cleaned_at`、`processed_at` | 实际批次读取、清洗提交和检测完成时间,用于延迟、故障与审计 |
始终满足 `processed_until <= delivered_until <= virtual_now <= end_at`。还未处理任何批次时,读取和处理进度为空,不能伪装成起点已经处理。GET 返回数据不自动推进任何进度;后台确认本批处理身份后才更新读取进度。表格可显示本轮已读取但仍在清洗的 raw 并标明状态,检测只能消费已完成清洗阶段的批次。主界面的次级进度使用“已读取”和“已检测”。
后台仅在播放状态累计模拟时间。暂停期间不推进;恢复时重新锚定现实时间。进程重启从持久进度重新锚定,不把停机时长一次性折算成需要跳过的数据。
### 顺序读取
1. 整次回放选取 `[start_at, end_at)`;首批包含 `start_at` 的观测。
2. 时间推进到 T 时,候选记录必须不晚于 T、早于 `end_at`,且在已成功接收的联合游标 `(observed_at, device_id)` 之后。
3. 所有选中设备合并后按 `observed_at, device_id` 排序;优先按同一观测时刻成批,不按设备分别跑独立时钟。
4. 每个传输片段最多 500 条、序列化正文最多 2 MiB;超限继续分片。记录片段序号与该时刻是否完整,不在半个时刻接收成功时提前推进时间上限;同一时刻的设备分组检测等待该时刻所有分片收齐。
5. 后台将本批记录引用和原始摘要登记成功后,才推进读取游标。重试同一批使用相同编号和摘要;清洗或检测失败从该阶段恢复,不重新选择一批、不重新插入 raw。正常路径复用本次读取的 raw 对象计算清洗结果,清洗提交后直接将同批结果用于检测,不再重新查询该批 raw 或 corrected。
6. 没有观测的时间段按设备档案采样周期与采样相位识别应到时刻。后台提交记录数为零的区间检查批次,推进覆盖进度并触发缺测判断;它不生成伪造观测。不能仅依赖“下一条数据到来”唤醒,否则连续断流永远不会被发现,终点也无法排空完成。
例如设备档案中的压力采样周期为 5 分钟、流量采样周期为 15 分钟。时间轴到 09:10 时,压力设备可传入 09:00、09:05、09:10,流量设备只有 09:00;到 09:15 时,再传入两类设备对应的 09:15 记录。
首版至多保留一个未完成批次,清洗检测提交后才选择下一批。单次请求不跳过周期内的中间观测、质量检查、异常开始和恢复,也不提供播放倍率。
### 页面操作
| 操作 | 约定 |
| --- | --- |
| 创建 | 固定数据集、范围、设备和规则,进入 ready |
| 播放 | ready 或 paused 进入 running,由后台推进 |
| 暂停 | 停止释放新批次;已持久接收的批次继续处理。返回实际生效进度,在途接收与暂停命令通过同一回放版本串行裁决 |
| 下一步并监测 | 在 ready 或 paused 状态推进到下一个项目监测周期边界;周期内的所有观测按原始时间顺序处理,到边界后提交一次本周期监测回执并保持暂停,尾步可短于完整周期 |
| 调速 | 原子更新速度和时间锚点,不改变观测顺序 |
| 拖动时间轴 | 只查看已释放的数据与已产生的事件;未来区间不预览读数,不触发事件 |
| 从指定时刻重播 | 新建 `replay_id`,独立检测与调查;新起点之前仅作为有声明的历史基线,跳过段不声称已经检测 |
| 到达终点 | 停止新批次,进入 draining;待已接收批次完成后 completed。Agent 调查是否完成另行展示 |
| 停止 | 终止继续传入,保留已接收批次、事件和证据;不自动撤销已经建立的调查 |
| 重试故障 | 修复依赖后校验固定配置,failed 进入 paused,重试原未完成批次;源版本或批次内容冲突需要新建回放,不能靠重试忽略 |
生命周期为 ready、running、paused、draining、completed、stopped、failed。队列等待、基线不足和依赖暂不可用是处理状态或原因字段,不再增加一套并行生命周期。
首次运行遇到可重试投递错误,按现实时间退避重试同一批次;权限错误、数据集版本变化、同批编号内容冲突和结构缺失进入 failed,明确原因。用户恢复前必须解决原因;原批次事实继续保留。
### 选定演示区间
用户先在月度数据可用范围内选择起止时间。创建回放后,将该区间固定为当前播放范围;进度比例与剩余时长都相对该区间计算。例如选择 10 月 8 日 08:00 至 14:00,时间轴只覆盖这 6 小时,不能将月内 8 日的位置作为本轮进度。
主要界面常驻显示选定区间和当前监测时间。月份、数据版本、设备范围及规则收进来源与设置区域;需要校验的配置差异仍在开始前明确显示。不存在上一次选择时不自动处理整个月,等待用户确定区间。
演示建立后改变时间区间需要停止并创建新回放;保留旧回放的事件和证据,不把旧回放时间游标直接重设。已完成区间需要再次演示时,新建独立回放;不自动循环处理和重复触发调查。
选定区间之前的可用历史仅用于已声明的基线初始化,不占用主时间轴、不作为新到达批次、不预触发此前事件。基线不足时在当前区间显示具体可用性,不能悄悄扩大演示范围或读取区间之后的数据。
处理到选定终点后停在终点,显示数据处理是否完成及剩余调查数量;不会自动继续处理该月剩余数据。空区间明确显示没有观测,不能通过控件动画假装已经收到 SCADA 数据。
### 5 分钟时间定位与项目监测周期
时间轴以 5 分钟时间戳组织刻度、标签和选择值,只负责定位和回看,不读取 `projects.settings.scada_monitoring_interval_seconds`。方向键按 5 分钟移动;鼠标和触摸的拖动轨迹保持连续调频手感,选定值仍按最接近的 5 分钟时间戳归档。标签密度可以随面板宽度合并,不会改变拖动手感或时间选择精度。
能力接口另行返回当前项目监测周期;创建回放时由服务端固定到回放文档及快照的 `monitoring_interval_seconds`,页面不能提交不同周期。项目配置后续调整只影响新回放,当前演示继续使用固定值;未配置时提示设置。
| 交互 | 行为 |
| --- | --- |
| 方向键 | 前后移动一个 5 分钟时间戳 |
| PageUp / PageDown | 移动一个当前可见的主刻度,主刻度始终为 5 分钟的整数倍 |
| 拖动回看 | 拖动轨迹连续无缝,选择值按 5 分钟量化;只读定位不推进后台 |
| 主刻度 | 宽屏可每 5 分钟标注,窄屏按 10、15 分钟等密度显示,细刻线保留 5 分钟定位 |
| 下一步并监测 | 不读取时间轴位置;如后台已监测至 08:07,15 分钟周期处理到 08:22 |
“下一步并监测”通过持久化 `step_target` 与 `step_pending` 管理。单步开始后,API 按原观测时刻读取、清洗和检测到目标边界,完成前禁用重复单步。若周期内有 5 分钟观测,15 分钟监测周期仍处理其中每个时刻;没有观测则提交空区间检查,不补造数据。尾步到达 end_time 时,仍遵守原始区间 `[start_time, end_time)`。
时间轴以 5 分钟时间戳定位,不能控制或触发周期处理;“下一步并监测”按项目监测频率推进后台周期,不将原始观测重采样。旧回放没有固定周期时,页面要求停止后重新创建,不能把后来修改的项目周期追补为历史事实。
### 步长边界主动监测与爆管流程
一次主动监测的身份为 `(replay_id, period_end, monitor_rule_version)`。同一步骤重试复用原回执,不能重复创建告警、调查或计算。监测回执保存在该步最后一个回放批次的 `monitoring` 中,并将最近一次结果投影到回放快照;内容包含周期起止、知识截止、输入批次上限、数据完整性、各指标状态、组合结论和关联任务。它不是 `scheduled_flow`,也不需要通用定时器。
步长边界按以下顺序执行:
1. 确认本周期内所有已到达观测均已完成质量校验和清洗;缺测、无效值和单位冲突进入数据完整性结果,不能按正常值处理。
2. 汇总压力下降幅度。只使用映射明确、单位一致、质量合格的压力测点,并同时保留异常测点数、覆盖测点数、基线窗口和最大或中位下降幅度。
3. 汇总出厂流量变化。设备必须在项目配置或设备档案中明确标注为“出厂边界流量”,不能把任意 `pipe_flow` 测点当作出厂流量;保留方向、单位、基线和增量。
4. 条件满足时运行爆管侦测。现有侦测要求至少 5 个完整压力点和目标时刻之前 15 天同周期数据;不满足时返回 `baseline_not_ready` 或 `sensor_coverage_insufficient`,不能将“未运行”解释为“无异常”。
5. 组合上述证据形成监测结论;只有疑似爆管才创建 `event_investigation`,工作流使用现有 `burst_report`,并绑定本轮 `replay_context`。正常周期只保存回执,不启动 Agent;数据不足周期保存缺口并在界面展示。
6. `burst_report` Skill 核对证据后决定是否调用爆管定位。定位必须有 m³/s 的有依据漏量估计或明确情景假设,以及模型节点映射、异常窗和正常对照窗;输入不具备时保持“疑似爆管、定位待补充”,不能填默认漏量。
组合判定采用可版本化策略,首版演示建议如下:
| 监测结果 | 判定条件 | 后续动作 |
| --- | --- | --- |
| `insufficient_data` | 必需点位缺测、质量无效、单位或角色未配置,或爆管侦测基线不满足 | 保存缺口,不声明正常,不启动定位 |
| `normal` | 数据完整,爆管侦测未命中,压力与出厂流量规则均未命中 | 保存周期回执,不创建 Agent 任务 |
| `attention` | 只有单项指标命中,或指标间方向不一致 | 保存观察结果;下一步继续监测,必要时由人发起调查 |
| `suspected_burst` | 爆管侦测命中,且压力下降与出厂流量增加在同一业务时间窗内相互支持 | 创建一次 `burst_report` 调查;定位输入具备后进入计算 |
爆管侦测和压力下降存在相关性,因此回执不能把两者描述成完全独立的两票。出厂流量也会受泵站调度、储水设施和正常用水变化影响。组合结论只是调查触发条件,不是现场确诊。
回放中的 MCP 定位已走 Server 持有的安全适配层:按 `replay_id`、`knowledge_cutoff` 和 `batch_sequence` 从已固化清洗输出组装压力与可选流量序列,再调用现有计算队列。回放只允许 monitoring 模式,并要求显式异常窗、正常对照窗和有依据的 `burst_leakage`;不能直接查询 TSDB 中尚未回放的未来记录。步长爆管侦测同样只读取最近 15 天已固化输出。计算结果保存输入、算法与证据引用,任务恢复或重试仍使用相同知识上限。
### FM 调频盘样式
用户明确选择电台调频盘的刻度、红色指示线和玻璃质感。这里的 FM 表示调谐外观,不增加电台、音频、频段切换或拟物旋钮功能。
视觉方向:工作台内一块冷白半透明的玻璃刻度窗,红色细线作为唯一醒目的时间定位标记;文字、控件与下方数据沿用现有工作台主题。内容顺序为选定区间、当前时间、刻度窗、控制栏、传入与检测进度。交互特征为固定指示线与横向移动的刻度带,手动拨动直接跟手。
![FM 调频式时间轴静态样式示意](assets/scada-fm-timeline.svg)
图中时间和状态仅为静态样式示意,不是运行结果。玻璃下的底色用于说明透光与边缘高光,运行时仍需验证实际工作台背景下的对比度。
| 元素 | 设计约定 |
| --- | --- |
| 演示区间 | 常驻显示完整起止日期和时间;更换区间是次要入口,月度来源收在设置中 |
| 当前时间 | 刻度窗上方突出显示红线对应的时间,数字等宽;跨日时同时显示日期 |
| 时间刻度 | 主刻度有时间标签,次刻度细密且高低有别;实际密度按区间与容器宽度计算,窄屏优先减少标签而非缩小文字 |
| 红色指示线 | 固定在刻度窗水平中央,1 至 2px 宽,带小型顶端指示;这是时间定位色,不代表告警严重度 |
| 玻璃刻度窗 | 局部半透明、背景柔化、细边缘高光和轻微内阴影,玻璃层不影响数字、刻度和红线的清晰度 |
| 控制栏 | 播放或暂停是唯一主操作,下一批与倍速为辅助;回看时才出现回到当前 |
| 次级进度 | 单行显示已传入与已检测时间,积压时显示等待处理;不增加三块同等醒目的仪表 |
玻璃效果是本次按用户要求对 SCADA 调频刻度窗的局部材质例外,外围面板外壳、表格、表单、菜单和 Agent 面板仍沿用现有 shadcn/ui 实体设计。新增时间指示颜色使用独立语义变量,不能让正常播放状态套用危险告警颜色。关闭透明效果或不支持背景模糊时,回退到清晰的实体刻度窗。
固定红线表示当前定位时间,刻度带按 5 分钟时间戳移动;只在选定区间内标注时间,两端之外留白,不带出整个月的刻度。该窗是选定区间的只读定位视窗,完整起止范围始终由上方文字明确表达;拨动不改变传入或检测进度。
手动拖动和触屏横拨改变查看位置,不立即改写后台时间。拨动到已释放时间时显示历史快照并标明回看;拨动到尚未释放时间时只显示待传入时间,不展示未来读数,若需从该位置开始则明确新建回放。回到当前只恢复跟随,不补发旧数据、告警或调查。
时间拨动按 slider 语义提供中文名称、时间范围、当前时间文本和键盘操作:方向键按 5 分钟刻度调整,PageUp/PageDown 调整一个主刻度,Home/End 到选定范围两端。键盘操作即时生效;鼠标和触摸拖动轨迹连续无缝、不带惯性,选定时间按 5 分钟量化。减少动态效果时关闭装饰性过渡,保留真实时间定位。状态不用颜色单独表达。
## 数据质量与业务异常
真实外部系统新增观测时,先通过现有入库边界将 raw 保存到 TSDB。本次演示使用已经入库的原始值,读取 API 校验身份、项目、设备与时间范围后返回 raw,直接进入质量校验和清洗;不为了模拟传入而重复执行原始值 INSERT。清洗只写回同一条观测的 cleaned_value,monitored_value 始终保留。
清洗完成后才启动业务异常检测。valid 可将 cleaned_value 设为原始值,corrected 保存有依据的修正值,无法可靠修正的 invalid 或 missing 保持空值并保存质量结论。suspect 保留 raw 与不确定性,不能自动替换为正常值。是否已经清洗以阶段回执和质量结论为准,不能仅用 cleaned_value 是否为空判断。
| 质量结论 | 选择值与后续处理 |
| --- | --- |
| valid | 使用 raw,包括可靠采集的突降、突升、低压和浓度变化 |
| corrected | 使用有依据的修正值,同时保留 raw、修正值、规则版本、原因和可用时间 |
| suspect | 保留 raw 并声明不确定性,结合其他测点和设备状态复核;不自动改成正常值 |
| invalid 或 missing | 保留原始记录或缺测事实,退出要求可信观测的规则输入,形成数据质量事件 |
质量校验依据结构、有限数值、单位、设备身份、观测顺序、重复记录、量程与设备状态;仅偏离日常水平不能直接判 invalid。负流量、零浓度、低压均需按设备和业务含义解释。缺少仪表状态时明确证据不足,不能凭一次尖峰确认仪表损坏。
当前正式表中的 `cleaned_value` 可以保留,但不等于本轮已经完成清洗。读取 raw 时不清空已有 corrected,也不将旧 corrected 当作清洗输入。清洗阶段使用本批 raw 和本轮已知历史重新判断,提交本轮清洗版本并保存处理结果;检测只能使用该版本已提交的结果,不能直接取一个来源和时间不明的旧 corrected 当作已校验输入。
在线修正只使用当前已到达数据。当前居中窗口、后向补值、整段 Kalman 平滑和整段统计阈值保留为离线分析能力;不能一次性清洗整个月后将结果送入时间轴。在线清洗更新目标 TSDB 的修正值投影,原始值不变;原修正版本、当前修正版本和依据保留在处理证据中,旧回放仍读取自己的固定版本。
每条参与判断的数据记录其 `value_source`、质量结论、选用值、原始记录引用、规则版本和计算时间。回放过程不为未知错误强制生成一个修正数值。
这一区分对应 [EPA 在线水质监测指南第 6.1、6.2 节](https://nepis.epa.gov/Exe/ZyPURL.cgi?Dockey=P100UJYF.txt) 的“先校验、再异常检测,并保留原始值”原则;历史修订留痕可参考 [OPC UA Historical Access 第 4.4 节](https://reference.opcfoundation.org/specs/OPC-10000-11/4)。本项目的具体质量判定仍由指标和设备证据决定。
## 检测规则和多次事件
首版实时检测使用有版本的类型化规则:压力变化与上下限、流量变化与方向、水质具体指标的上下限或变化,以及数据质量规则。规则输入包含质量结论,输出只表示异常信号及依据,不确认爆管位置或污染原因。
每项启用的规则必须声明:`rule_id`、指标、方向、单位、阈值、使用的历史窗口、最少有效样本、连续命中点数、恢复阈值、连续恢复点数,以及事件组和最少同时命中的设备数。参数在创建回放前校验并冻结,至少需要一种已配置规则才可创建检测回放。缺失参数时该规则为 not_configured,不能借用统一 30% 或把零到非零统一解释为 100% 污染风险。
同一个事件组由明确的设备集合组成,不使用预设异常真值自动分组。同组、同规则、同一持续异常归并为一个事件;不同事件组可以同时产生不同事件。恢复需要可信的新观测满足恢复条件,缺测不构成恢复。恢复后的再次命中建立新事件。
事件保存首次异常、检测触发、最后异常、恢复时刻、证据引用和调查结论。传感器恢复正常只表示观测恢复,不代表现场维修、污染处置或业务调查已经完成。
事件身份由 `replay_id + rule_id + event_group_id + first_trigger_at` 确定;同一事件更新不创建多条调查。批次重试不重复创建事件,新一次回放的事件与旧回放独立。
现有 [爆管侦测服务](../app/services/burst_detection.py) 可作为满足条件后的进一步分析工具,不能直接等同于逐批在线检测器。当前 target 模式需要 15 天数据及至少 5 个完整压力测点。回放前没有足够历史时,展示 baseline_not_ready;支持短历史的规则仍可运行,不能用未来几天补足参考窗口。
正常基线只使用当时已知、质量合格且适用于该规则的历史。已确认异常可以从正常基线中排除,原始记录仍保留。事后确认或重新标注产生新版本,不改写此前检测“当时知道了什么”。
## 持久化和事务
观测数据以项目目标 TSDB 的 `scada.measurements` 为权威存储,沿用 time、device_id、monitored_value 和 cleaned_value。业务库不替代 TSDB 保存正式观测。
建议在 BizDB 增加以下处理对象:
| 对象 | 权威信息 |
| --- | --- |
| `peripheral_sample.scada_replays` | 回放身份、项目、创建者、冻结配置、生命周期、版本、模拟时间锚点、读取与处理进度、工作租约和恢复检查点 |
| `peripheral_sample.scada_replay_batches` | 本批 TSDB 记录键、raw 摘要、固定规则版本、清洗输出、质量结论、检测结果、处理阶段和重试信息 |
两个对象分别具有控制生命周期与批次处理生命周期。现有 test_data 是手工单条演练 CRUD,缺少顺序、批次和消费语义;任务表表示业务调查,也不能存正常观测批次。这里保存记录引用和处理结果,不复制一整套 raw;需要固化异常证据或修正依据时,只保存必要的原始片段。由于本次不重新摄入 raw,不新增 TSDB 原始值收件表。表和列同时写清数据库注释。
事件及当前告警复用 `workbench.alert_events`、`workbench.alerts`,调查及其证据复用现有 operational 表。告警来源扩展为判别联合:保留现有任务来源,新增 `kind=scada_replay`、`replay_id`、`dataset_revision`、规则组与触发批次,避免伪造调查身份。质量故障也用同一事件存储,按类别区分。
### 批次处理与提交
不使用一个长期跨库事务。批次按以下阶段单调推进,重试保持原批次身份:
1. `selected`:后台读取 raw 后,BizDB 固定记录键、输入摘要、规则与知识截止时间,并在同一事务登记处理身份和读取检查点。GET 本身不做此写入。
2. `clean_prepared`:直接对已读入的这一批 raw 和已加载的历史状态计算质量与修正结果,先将确定的输出及输入摘要保存在 BizDB。正常路径不调用会重新查询同批 raw 的整段清洗入口。
3. `cleaned`:TSDB 用一个有界事务按记录键更新 cleaned_value,并校验 raw 仍与本批输入一致;不更新 monitored_value。只有 TSDB 提交成功且 BizDB 记录已提交阶段后,才允许检测。单行结果相同时不必重写字段。
4. 若 TSDB 已提交但 BizDB 阶段尚未更新,恢复时核验预存的输入与输出;已经匹配则补记 cleaned,未写入则幂等应用预存结果,raw 已变则停止该批次。只在这种恢复或校验冲突路径增加读取,不为每批正常处理增加一次重复查询。
5. `detected`:直接使用本批已提交的清洗结果做检测,BizDB 在同一事务保存检测结果、事件状态、调查意图、检查点和 processed_until,然后发布业务事件。检测失败只重试本阶段,不重新清洗或重写 raw。
以上阶段保存在批次中。页面继续突出模拟时间、已读取进度和已检测进度,清洗中或失败作为当前批次状态展示。正常路径对新批次读取一次 raw、必要时批量更新一次修正值,再复用这批处理结果完成检测;界面后续查看与故障恢复属于独立的必要读取。
BizDB 批次状态是持久待处理队列,通知只负责唤醒。后台启动、通知中断与定期对账均扫描未结束批次,按已提交阶段恢复;同库状态与事件在同一事务保存,不能假定两个数据库天然原子。[PostgreSQL NOTIFY](https://www.postgresql.org/docs/15/sql-notify.html) 和 [SELECT 锁定语义](https://www.postgresql.org/docs/15/sql-select.html) 提供通知及领取基础。
调用慢算法和外部服务前结束数据库事务,不持有事务锁等待 HTTP。清洗结果未提交,不执行异常检测;中断后从已有检查点恢复,不丢弃批次身份重新处理一遍。
### 重复读取与重复回放
读取 API 的同一次游标请求可以安全重试,但相同数据不能被当作新的处理批次。后台以项目、回放、批次序号和分片序号登记幂等身份,记录级使用规范设备 ID 与原始时间;同键同摘要复用现有处理状态,不同摘要返回冲突。
新一轮演示按新的 replay_id 建立独立处理状态,可以重新读取和分析选定区间,但仍不插入 raw。界面统计已读取条数和已处理条数,不能称为新写入 SCADA 条数。
首版同一项目只允许一条活动的读取清洗流水线,避免不同回放同时改写同一 cleaned_value。历史结果仍可同时查看。修正字段表示最近一次已发布清洗结果,旧版结果和依据保留在批次输出及必要的原始证据中;旧 Agent 执行和历史回看不直接读取被后续处理替换的修正投影。
工作台事件流只发布回放控制状态变化、故障与业务告警;不为每个测点值发布 SSE,也不把全部原始数据放入事件日志。未来若新增 `scada.replay.changed` 主题,只用于控制状态,并需同步主题模型、约束和消费契约;当前控制进度由轮询读取。
未读取数据所在 raw 分片摘要变化时停止该回放,要求新数据集版本;本轮 cleaned_value 更新不计作源版本漂移。空检查批次也需要保存处理阶段,记录数为零不代表可以绕过缺测判断。
每个回放同一时刻只有一个有效处理租约,采用数据库会话锁阻止两个请求重复推进同一周期。每个批次提交后再读取下一原始时间戳,不持久保存进程计时器值。
源数据和相关回放证据在本次演示中不自动过期;一个月是数据覆盖范围,不是保存期限。事件结束、暂停或重播不清理已有证据。生产保留策略单独制定。
## Agent 调查与时间隔离
主动单步在边界形成组合监测回执:一般关注和数据不足只保存回执,只有 `suspected_burst` 进入 `burst_report`。SCADA 演示请求只负责建立持久调查任务,不要求先生成一条合成 test_data,也不在请求内执行 Agent。
告警事实与“待建立调查”意图在事件事务中保存。创建调查失败时后台对账重试;组合监测调查的来源稳定标识使用 `scada_replay:<replay_id>:monitor:<period_end>:<rule_version>`,一般事件继续使用 `scada_replay:<replay_id>:event:<event_id>`,均沿用现有外部来源幂等约束。事件不因 Agent 不可用而丢失。
已新增 `scada_anomaly` 工作流与对应 Skill,要求先核验数据质量、点位和单位,再读取事件窗口及正常参考。疑似爆管复用现有 `burst_report` Skill;它会区分侦测、定位候选和现场确诊,并要求漏量依据。`run_burst_location` 在回放任务中通过冻结输出适配执行,其他未适配计算继续拒绝;不能仅修改 Skill 文本或 MCP allowlist 绕过知识截止。重计算沿用 [后台计算队列](calculation-queue.md),保留外层 HTTP 限制。
新增 Server 持有的回放查询上下文:`replay_id`、数据集版本、知识截止时刻 `knowledge_cutoff`、已接收批次上限、处理版本和来源事件。它与任务及执行代次绑定,不能由模型或客户端自行扩大。
每代调查开始时冻结其可用批次与时间上限;同一执行中后续回放推进不会偷偷改变证据。确有新的重要证据时,按现有 [调查轮次规则](operational-tasks.md) 创建或合并后续调查轮次,而不是每批次追加一次模型执行。
时间隔离必须覆盖 MCP 的 SCADA 查询、要素历史、任务数据快照、外围观测,以及检测算法内部读取。raw 源读取 API 只允许读取不晚于模拟时钟的观测;检测只使用已完成本轮清洗的批次结果,原始表格可以显示本轮已读取且尚在清洗的 raw。历史回看和旧代调查读取对应固定版本或原始记录引用,异常证据使用已经固化的必要片段。即使 TSDB 存有整个月记录,也必须按本轮读取检查点、设备集合和知识截止时间过滤,不能只在提示词中要求遵守。
所有回放调查证据保留真实来源:合成数据仍为 synthetic,历史回放有明确标识,不能因为读取的是正式表就改称现场实时观测。普通历史数据管理仍可按原权限查看数据集,但不能作为回放会话绕过截止时间的途径。
回放调查只生成演示分析、计划和报告;真实外围工单和现场控制不属于自动回放动作。Server 动作执行边界必须识别回放上下文,不能仅靠 Agent 提示阻止将演示事件发成真实业务工单。
## 接口与界面
以下为已经加入 Server OpenAPI 的路径,统一使用 Bearer 与 X-Project-Id。保留原有 `/peripheral/api/sample/v1` 工单与手工演练接口,回放不增加一层外围代理。
| 接口 | 语义 |
| --- | --- |
| `GET /api/v1/scada-replays/capabilities` | 回放表可用性、写权限、监测频率、爆管组合监测角色是否就绪及最近一轮状态;不代表完整月度数据就绪 |
| `POST /api/v1/scada-replays` | 固定区间、设备、规则和质量量程,返回 ready;携带稳定 request_id |
| `GET /api/v1/scada-replays/{replay_id}` | 权威控制、时间、计数、设备与固定规则、当前输出及故障 |
| `POST /api/v1/scada-replays/{replay_id}/commands` | play、pause、step、set_speed、stop、retry;request_id + expected_revision |
| `GET /api/v1/scada-replays/{replay_id}/observations?through=...` | 仅本轮已读取时间范围内的历史快照,每设备最近一条、最多 500 个 |
| `GET /api/v1/timeseries/scada-readings/raw` | 当前项目 TSDB 原始值联合游标分页;不插入、不隐式清洗 |
| `GET /api/v1/scada-replays/{replay_id}/events?offset=0&limit=100` | 检测变化与调查关联,返回 items、next_offset |
raw API 允许参数为 start_time、end_time、device_ids、cursor、limit;回放请求携带经服务端验证的 replay_id,任务请求由已有绑定解析回放身份。有效范围为所选区间与模拟时间上限的交集,不能因省略 replay_id 绕过已绑定的任务限制。返回 items(time、device_id、monitored_value、quality)、next_cursor 和 scanned_through;本版不返回尚未准备的 dataset_revision,不返回 corrected 作为源输入。
next_cursor 绑定项目、设备、查询区间并固定本轮分页时间上限;本版不为整个查询提供 MVCC 源版本快照,按 time、device_id 排序,每页最多 500 条。读取窗口未结束但当前页为空时,scanned_through 表示已经确认没有观测的区间上限,供后台登记空检查批次。分页、刷新和网络重试均是无副作用读取。
查询和检查点更新使用类型化服务方法及已验证项目上下文。清洗函数接受已经读入的批次,不再调用当前会重新查询 raw 的整段清洗入口;检测函数接受这批已提交清洗结果。页面的单步 POST 命令同步完成一个监测周期,GET 预览和回看不启动任务。
回放读取要求 scada.view,创建及控制要求 scada.clean;普通 raw API 沿用 timeseries SCADA 路由的读权限。API 不自动创建表,不回退默认项目,也不隐式枚举全部活跃项目。
前端 SCADA 页签按以下顺序组织:
1. 选定演示区间:开始时间、结束时间和项目监测步长;设备范围、规则及基线收纳在折叠的检测参数区域。
2. FM 调频式时间轴:局部玻璃刻度窗、固定红色指示线、两侧渐暗及中央放大;按 5 分钟时间戳定位,不读取逐测点列表,也不触发监测。
3. 主动监测操作:以“开始并监测”或“下一步并监测”为唯一主操作,按项目频率同步处理一个周期。
4. 本周期结论:显示正常、关注、数据不足或疑似爆管,以及是否已经进入爆管调查与定位流程。
设备目录、raw 与修正值明细、完整事件列表和手工单点异常录入不在演示主面板展示。底层数据与 API 继续保留,供诊断、追溯和调查任务按权限读取。
沿用现有 workbench-shadcn-surface、日期控件和实体表面,调频刻度窗采用上文明确的局部玻璃例外;时间轴支持键盘操作和中文可访问名称。拖动回看不触发后台写操作。
页面可见时每 5 秒读取一次权威控制快照;单步请求执行期间由操作本身返回最终快照。页面隐藏时停止界面采样。演示面板不轮询完整事件列表,调查入口需要时再按分页 API 查询。
## 实施范围与验证
这是跨 Server、供水前端和 Agent 契约的变更,不新增后台进程。数据、存储、身份及网络复用现有部署。
| 仓库 | 主要落点 |
| --- | --- |
| Server | `app/api/v1/endpoints/scada_replay.py` 的回放控制与能力;`app/services/` 和 `app/infra/db/` 的批次、质量和事件;现有 task_alert、operational_task、MCP 上下文、查询和 Skill |
| Server 契约与存储 | `resources/sql/` 的 BizDB 两张回放表迁移;`contracts/` 的 raw 读取、Server OpenAPI 和清单;外围协议不变;TSDB 沿用已有观测表 |
| 前端 | `src/features/workbench/peripheral-sample/` 的回放控制与状态 hook;SCADA 客户端与生成契约;告警来源和事件解析 |
| Agent | `src/product/` 的生成契约及任务上下文适配,保留已有原生 MCP 与执行权限边界 |
验收用例必须覆盖:
- 5/15 分钟设备混合、乱序源记录、同一时间戳跨页、空片段、结束边界和多次异常。
- 重复批次、相同编号不同内容、响应丢失、请求中断和恢复重试;不漏读、不重复事件。
- GET 重试、分页重复请求、清洗输出预存后中断、TSDB 更新提交而 BizDB 阶段未更新、检测失败;逐阶段恢复,不提前检测。
- 正常处理每批只读一次 raw,清洗后检测复用结果;回放不执行 raw INSERT,不在读取时清空旧 corrected,空数据不伪造观测。
- 周期内多批次单步、重复单步拒绝、并发请求互斥和尾步完成状态。
- 5 分钟时间定位与 15 分钟监测周期相互独立、非整点起点、周期配置变更不影响当前回放、尾步不足完整周期和未配置时禁止开始。
- 每个步长边界只产生一份幂等监测回执;正常、数据不足、单指标关注和疑似爆管分别处理,不为正常周期创建 Agent。
- 爆管侦测基线不足、压力下降、出厂流量变化、设备角色错误、指标方向冲突和多指标同时命中;只有疑似爆管建立 `burst_report`。
- 回放 MCP 侦测与定位只能读取固定批次和知识截止前数据;定位缺少漏量依据时停止在待补充,不调用算法。
- 选定小时区间、跨日区间和再次演示;刻度与进度只对应选定区间,基线初始化不释放旧事件,到终点不自动播放整月。
- FM 红线固定、刻度带移动、拖动回看不重复投递、未来位置不泄露读数;桌面与 375px、320px 窄屏下的长日期、键盘、减少动态效果及玻璃回退。
- 真实突降不被当作采集误差修掉;负流量、零水质值、空值、错误单位和缺测恢复分别处理。
- 首个异常、持续异常、可信恢复、再次异常及两个事件组并发;不会逐点创建调查。
- 缺少基线、缺少水质指标配置、历史 corrected 无可用时间,以及 source_revision 变化。
- MCP、任务数据接口及算法内部的未来查询均被拒绝;下一代调查才能使用新增证据。
- 合成来源、历史回放来源、事件真值隔离;回放不发布真实现场动作。
- 合成测试链路、真实项目 API、常驻后台、真实 Agent 执行和浏览器分别记录验证结果。
后续实现的验证命令从各仓库执行:Server 运行新增回放、批次、质量、事件与查询边界的聚焦 pytest,并保留现有 SCADA/告警/调查回归;前端运行 `pnpm lint`、`pnpm typecheck`、`pnpm test` 和聚焦 `pnpm test:browser`;Agent 修改后运行 `bun run check` 及对应聚焦测试。具体已执行结果见下方,不把待部署链路计为通过。
数据库应用遵守 [工作台数据库变更门禁](workbench-database-rollout.md):先核对目标项目,备份和回滚演练,再按明确范围应用及验收;不得默认向全部项目写入。本地 MetaDB 已对明确的演示项目应用并复验 `033_metadata_project_settings.sql`,其他项目保持原值;`tjwater2_v2` BizDB 已应用 `031_scada_replays.sql`,不据此推断其他项目已迁移。本次没有生成一个月观测或启动常驻进程。
回退应用前暂停新批次读取,核对 clean_prepared、cleaned 与 detected 阶段,保留原始观测、已提交修正值、回放表、告警与调查证据;升级恢复后从原阶段继续。BizDB 流程记录和 TSDB 修正值不在同一事务,回滚应用不能假定另一库结果也被撤回。不能通过删除事件、重置时间游标或清空 SCADA 原始表来伪装成从未处理。若需要撤回数据库变更,另按备份与新增业务写入情况制定恢复步骤,不在仍有写入时覆盖数据库。
本方案依赖的关键前提是源历史能提供稳定设备身份、可信时间戳和固定数据版本。缺少完整月度观测时,能力接口只能声明实际可用范围;缺少修正来源时采用 raw;缺少检测参数时该类检测不启用。任何这些缺口都不能由时间轴、插值或预设事件答案掩盖。
## 部署准备与验证记录
迁移脚本为 [031_scada_replays.sql](../resources/sql/031_scada_replays.sql),显式限制物理数据库 `tjwater_v2` 与 `tjwater2_v2`,依赖既有 peripheral_sample schema、告警及调查结构。仅新增两张 BizDB 表,不新增 TSDB 观测表。`tjwater2_v2` 已应用该迁移并配置 `settings.scada_monitoring_interval_seconds=900`。
真实数据库验证使用 `SCADA_REPLAY_STORAGE_TEST=1 conda run -n server python -m pytest tests/integration/test_scada_replay_storage.py -q`。该测试在项目路由选出的 BizDB 与 TSDB 双回滚事务中临时建表、写测试观测,覆盖真实 SQL 的 cleaned 更新、事件持久化和主动周期推进,退出全部回滚;它不证明身份认证 HTTP 和真实 Agent 推理已经验收。浏览器测试以 mock Server 验证交互与窄屏布局,不能作为现场检测效果证明。
本轮验证结果(2026-11-03,当前工作区):
| 层级 | 结果与边界 |
| --- | --- |
| Server | 主动监测、条件式爆管调查、冻结定位适配、MCP、项目配置、raw、处理及 OpenAPI 聚焦回归共 120 项通过 |
| 数据库与契约 | 本地 MetaDB 的 033 迁移已单项目应用并重复执行验证;OpenAPI 279 条路径与清单摘要一致;本轮未运行 031 的真实双库集成测试 |
| 前端 | lint、typecheck、全量 148 文件共 884 项 Vitest 通过;设备列表、手工单点演练及对应触发适配已移除 |
| 浏览器 | 1 项 mock Server 场景通过,包含 5 分钟刻度、选择值与键盘步进、连续拖动轨迹、15 分钟主动监测、中央放大、两侧渐暗、固定红针及 1280/375/320px 布局;未作为真实检测效果证明 |
| Agent | 同步 Server OpenAPI 并生成 server-api.d.ts,bun run check 通过;无真实模型推理 |
| 仓库 | Server、供水前端、Agent 的 git diff --check 通过;未提交、推送或部署 |
生产联调仍需核验真实 HTTP 接口、调查执行链路和 Agent 报告,并单独准备完整月度数据及事件真值。不能将以上局部测试解释为这些环节已经完成。