27 KiB
历史数据模块 — 开发规格说明
本文档属于《污水厂智能控制平台》的一部分,详细描述历史数据模块的功能、数据模型、API接口和业务逻辑,细度可达直接开发级别。 本模块与 数据管理模块 紧密关联,依赖其中的设备、采集点、TDengine 超级表等设计。
目录
1. 概述
1.1 模块定位
历史数据模块负责展示平台中所有已存储的历史时序数据,提供两种查看方式:
- 曲线模式:多条数据在同一时间轴上的变化趋势对比,支持游标查看
- 表格模式:按固定时间间隔展示数据,支持导出CSV
1.2 模块边界
| 交互对象 | 方向 | 内容 |
|---|---|---|
| Web前端 | 返回 | 历史点位树、历史数据查询结果、CSV文件 |
| TDengine | 查询 | 读取 collection_data 超级表下的子表数据 |
| PostgreSQL | 查询 | 读取设备、采集点配置,构建树形结构 |
| 数据管理模块 | 依赖 | 采集点配置、设备配置、分组信息 |
1.3 页面层级
历史数据与数据管理在同一层级,位于左侧导航栏:
┌──────────┐
│ ▽ 数据管理 │
│ 设备管理 │
│ 数据采集 │
│ 数据写入 │
│ │
│ ○ 历史数据 │ ← 同层级,无子菜单
└──────────┘
2. 数据来源
2.1 当前数据来源:采集点历史数据
来自数据管理模块中启用了 store_history=true 的采集点,数据存储在 TDengine 的 collection_data 超级表中。
-- 已在数据管理模块中定义的超级表
CREATE STABLE IF NOT EXISTS collection_data (
ts TIMESTAMP,
value DOUBLE,
quality INT, -- 0=good, 1=bad
point_id VARCHAR(32),
point_name VARCHAR(128)
) TAGS (
device_id VARCHAR(32),
device_name VARCHAR(128),
data_type VARCHAR(16)
);
2.2 预留数据来源:非采集点位(内部数据)
未来的扩展数据(如AI模型推理的建议值、人工录入的补充数据等),统一归属为内部数据。
预留数据模型:新增 computed_data 超级表
-- 预留:非采集点位的时序数据
CREATE STABLE IF NOT EXISTS computed_data (
ts TIMESTAMP,
value DOUBLE,
quality INT, -- 0=good, 1=bad
point_id VARCHAR(32),
point_name VARCHAR(128),
source VARCHAR(32) -- 数据来源: 'ai_model', 'manual_input', 'external' 等
) TAGS (
category VARCHAR(32) -- 类别,如 'aeration', 'dosing', 'prediction'
);
当前阶段只实现采集点历史数据展示,内部数据在树形结构中占位,不可勾选,显示"暂无数据"。
2.3 历史数据点位判定规则
一个采集点是否出现在历史数据树形结构中,取决于:
采集点出现在历史树中 ⇔ collection_points.enabled = true
AND collection_points.deleted = false
AND collection_points.store_history = true
AND 关联的 devices.enabled = true
AND 关联的 devices.deleted = false
3. 历史点位树形结构
3.1 树结构定义
所有历史点位
├── 分组A
│ ├── ☐ 采集点1(最新值,质量戳)
│ ├── ☐ 采集点2(最新值,质量戳)
│ └── ...
├── 分组B
│ ├── ☐ 采集点3(最新值,质量戳)
│ └── ...
├── ...(其他分组)
└── 内部数据(预留)
└── (暂无数据)
分组名称直接取自数据采集点配置中的 group_name,同一分组下的点位可来自不同设备。
3.2 构建规则
| 层级 | 数据来源 | 说明 |
|---|---|---|
| 第一层:分组 | PostgreSQL collection_points 表的 group_name |
去重后作为树的顶层节点,只有包含可用历史数据点的分组才显示 |
| 第二层:点位 | PostgreSQL collection_points |
满足 store_history=true 且 enabled 的采集点,按 group_name 归属到对应分组下 |
| 独立节点:内部数据 | 硬编码占位 | 当前阶段显示"暂无数据",后续扩展 |
3.3 节点属性
每个采集点节点应包含以下信息(用于展示和查询):
{
"id": "point-uuid",
"name": "曝气池DO_01",
"type": "collection", // 或 'computed'(预留)
"data_type": "REAL",
"device_id": "device-uuid",
"device_name": "一期曝气柜PLC",
"group_name": "曝气池",
"latest_value": 2.35,
"latest_quality": "good",
"latest_ts": "2026-07-09T10:00:01+08:00"
}
由于树结构按分组平铺,点位节点需额外携带
device_id和device_name以便前端识别点位所属设备。
3.4 复选框行为
| 操作 | 行为 |
|---|---|
| 勾选点位 | 该点位数据加入曲线/表格 |
| 取消勾选点位 | 该点位数据从曲线/表格中移除 |
| 勾选上限 | 建议最多同时勾选20个点位(前端友好性考虑) |
| 勾选状态 | 切换曲线/表格模式时,勾选状态保持不变 |
4. 数据保留策略
4.1 配置方式
在系统配置中增加一个全局参数:
| 参数 | 类型 | 范围 | 默认值 | 说明 |
|---|---|---|---|---|
| history_retention_days | int | 1~730 | 365 | 历史数据保留天数 |
4.2 实现方式
通过 TDengine 的数据库级 KEEP 参数控制:
-- 创建数据库时指定保留天数
CREATE DATABASE IF NOT EXISTS aquacontrolai
KEEP 365 -- 数据保留天数(对应配置值)
DAYS 10 -- 每10天一个文件
BLOCKS 100;
-- 修改保留天数(当用户在系统配置中修改时执行)
ALTER DATABASE aquacontrolai KEEP 730;
4.3 注意
- 修改保留天数后,TDengine 会自动清理超出保留期的数据
- 保留天数对
collection_data和computed_data两个超级表同时生效 - 建议在系统配置页面提供"立即清理过期数据"按钮,但通常不需要手动操作
5. RESTful API
5.1 接口列表
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/v1/history/tree | 获取历史点位树形结构 |
| POST | /api/v1/history/query | 查询历史数据(曲线模式) |
| POST | /api/v1/history/query-table | 查询历史数据(表格模式) |
| POST | /api/v1/history/export | 导出历史数据为CSV |
5.2 接口详细定义
5.2.1 GET /api/v1/history/tree — 获取历史点位树
无需参数,从 PostgreSQL 查询所有符合条件的历史点位,按分组→点位构建树。
Response (200):
{
"tree": [
{
"id": "group-aeration",
"name": "曝气池",
"type": "group",
"children": [
{
"id": "point-uuid-1",
"name": "曝气池DO_01",
"type": "collection",
"data_type": "REAL",
"device_id": "device-uuid-1",
"device_name": "一期曝气柜PLC",
"latest_value": 2.35,
"latest_quality": "good",
"latest_ts": "2026-07-09T10:00:01+08:00"
},
{
"id": "point-uuid-2",
"name": "曝气池温度",
"type": "collection",
"data_type": "REAL",
"device_id": "device-uuid-1",
"device_name": "一期曝气柜PLC",
"latest_value": 25.1,
"latest_quality": "good",
"latest_ts": "2026-07-09T10:00:01+08:00"
}
]
},
{
"id": "internal-data",
"name": "内部数据",
"type": "reserved",
"children": [
{
"id": "placeholder",
"name": "暂无数据",
"type": "placeholder",
"disabled": true
}
]
}
]
}
5.2.2 POST /api/v1/history/query — 曲线模式查询
Request body:
{
"point_ids": ["point-uuid-1", "point-uuid-2"],
"start_time": "2026-07-09T00:00:00+08:00",
"end_time": "2026-07-09T01:00:00+08:00"
}
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| point_ids | string[] | 是 | 要查询的点位ID列表,最少1个,最多20个 |
| start_time | string | 是 | 开始时间,ISO 8601格式 |
| end_time | string | 是 | 结束时间,ISO 8601格式 |
后端逻辑:
1. 根据 point_ids 获取每个点位的子表名(从 point_id 映射)
2. 对每个点位,查询 TDengine:
SELECT ts, value, quality
FROM {subtable_name}
WHERE ts >= start_time AND ts <= end_time
ORDER BY ts ASC
3. 聚合结果,返回
Response (200):
{
"series": [
{
"point_id": "point-uuid-1",
"point_name": "曝气池DO_01",
"data_type": "REAL",
"data": [
{ "ts": "2026-07-09T00:00:01+08:00", "value": 2.35, "quality": "good" },
{ "ts": "2026-07-09T00:00:02+08:00", "value": 2.36, "quality": "good" },
{ "ts": "2026-07-09T00:00:05+08:00", "value": 2.38, "quality": "good" },
{ "ts": "2026-07-09T00:00:08+08:00", "value": 2.30, "quality": "bad" },
{ "ts": "2026-07-09T00:00:10+08:00", "value": 2.40, "quality": "good" }
]
},
{
"point_id": "point-uuid-2",
"point_name": "曝气池温度",
"data_type": "REAL",
"data": [
{ "ts": "2026-07-09T00:00:01+08:00", "value": 25.1, "quality": "good" },
{ "ts": "2026-07-09T00:00:05+08:00", "value": 25.2, "quality": "good" },
{ "ts": "2026-07-09T00:00:10+08:00", "value": 25.0, "quality": "good" }
]
}
]
}
5.2.3 POST /api/v1/history/query-table — 表格模式查询
Request body:
{
"point_ids": ["point-uuid-1", "point-uuid-2"],
"start_time": "2026-07-09T00:00:00+08:00",
"end_time": "2026-07-09T01:00:00+08:00",
"interval_minutes": 10
}
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| point_ids | string[] | 是 | 要查询的点位ID列表,最少1个,最多20个 |
| start_time | string | 是 | 开始时间 |
| end_time | string | 是 | 结束时间 |
| interval_minutes | int | 是 | 间隔分钟数,范围1~1440,必须能被60整除(建议限制为1, 2, 5, 10, 15, 20, 30, 60) |
后端逻辑(重点 — 最近邻匹配):
1. 根据 point_ids 获取每个点位的子表名
2. 对每个点位,查询 TDengine 获取 start_time ~ end_time 范围内的所有原始数据
3. 生成目标时间序列:从 start_time 开始,每隔 interval_minutes 生成一个目标时间点
- 例如:00:00, 00:10, 00:20, ..., 01:00
4. 对每个目标时间点 T,执行最近邻匹配:
a. 定义匹配窗口 = interval_minutes / 2(向上取整,单位分钟)
b. 在原始数据中,找时间戳距离 T 最近的数据点
c. 如果最近距离 ≤ 匹配窗口 → 匹配成功,使用该数据点的 value 和 quality
d. 如果最近距离 > 匹配窗口 → 匹配失败,标记为 null
5. 返回按目标时间序列对齐的多条数据
举例: interval_minutes=10,匹配窗口=5分钟
| 目标时间 | 原始数据 | 最近距离 | 匹配结果 |
|---|---|---|---|
| 00:00:00 | 有 00:00:01 的数据 | 1秒 | 2.35 (good) |
| 00:10:00 | 有 00:00:08 和 00:10:02 的数据 | 2秒(00:10:02) | 2.40 (good) |
| 00:20:00 | 最近数据在 00:14:00,距离6分钟 | 6分钟 > 5分钟 | null(显示—) |
Response (200):
{
"time_column": [
"2026-07-09T00:00:00+08:00",
"2026-07-09T00:10:00+08:00",
"2026-07-09T00:20:00+08:00",
"2026-07-09T00:30:00+08:00",
"2026-07-09T00:40:00+08:00",
"2026-07-09T00:50:00+08:00",
"2026-07-09T01:00:00+08:00"
],
"columns": [
{
"point_id": "point-uuid-1",
"point_name": "曝气池DO_01",
"data": [
{ "value": 2.35, "quality": "good" },
{ "value": 2.40, "quality": "good" },
null,
{ "value": 2.38, "quality": "good" },
{ "value": 2.42, "quality": "good" },
null,
{ "value": 2.36, "quality": "good" }
]
},
{
"point_id": "point-uuid-2",
"point_name": "曝气池温度",
"data": [
{ "value": 25.1, "quality": "good" },
{ "value": 25.0, "quality": "good" },
{ "value": 25.3, "quality": "good" },
null,
{ "value": 25.1, "quality": "good" },
{ "value": 25.2, "quality": "good" },
{ "value": 25.0, "quality": "good" }
]
}
]
}
5.2.4 POST /api/v1/history/export — 导出CSV
Request body 与 query-table 一致:
{
"point_ids": ["point-uuid-1", "point-uuid-2"],
"start_time": "2026-07-09T00:00:00+08:00",
"end_time": "2026-07-09T01:00:00+08:00",
"interval_minutes": 10
}
Response:CSV文件(Content-Type: text/csv; charset=utf-8-sig)
CSV格式:
时间,曝气池DO_01,曝气池DO_01_质量,曝气池温度,曝气池温度_质量
2026-07-09 00:00:00,2.35,good,25.1,good
2026-07-09 00:10:00,2.40,good,25.0,good
2026-07-09 00:20:00,—,—,25.3,good
2026-07-09 00:30:00,2.38,good,—,—
注意:
- CSV使用UTF-8 with BOM编码
- 首行为标题行,包含时间、每个点位的数据列和质量列
- 质量戳为 bad 时,数据列显示为
—(全角破折号),质量列显示bad - 未匹配到数据(null)时,数据列显示为
—,质量列显示—
6. 曲线模式(前端)
6.1 布局
┌───────────┬──────────────────────────────────────┐
│ 点位树 │ 时间范围选择器 [过去1小时] [今天] [自定义] │
│ (复选框) │ │
│ │ ┌──────────────────────────────────┐ │
│ ☑ 曝气池 │ │ │ │
│ ☑ DO │ │ 曲线图区域 │ │
│ ☐ 温度 │ │ (ECharts 折线图) │ │
│ ☐ 加药间 │ │ │ │
│ │ └──────────────────────────────────┘ │
│ 内部数据 │ ┌──────────┬──────────┬──────────┐ │
│ ☐ (暂无) │ │ 点位名称 │ 时间 │ 数值 │ │
│ │ │ DO │ 00:00:05 │ 2.38 │ │
│ │ │ 温度 │ 00:00:05 │ 25.2 │ │
│ │ └──────────┴──────────┴──────────┘ │
│ [曲线] [表格]│ │
└───────────┴──────────────────────────────────────┘
6.2 时间范围选择
提供快捷选择 + 自定义:
| 选项 | 说明 |
|---|---|
| 过去1小时 | 快速查看最近1小时 |
| 过去6小时 | 快速查看最近6小时 |
| 过去24小时 | 快速查看最近1天 |
| 过去7天 | 快速查看最近1周 |
| 自定义 | 自由选择起止时间(精确到秒) |
6.3 曲线图规则
6.3.1 横轴(X轴)
- 类型:时间轴
- 自适应:根据选择的时间范围自动调整刻度
- 刻度标签格式:
- 1小时内:
HH:mm:ss - 1小时~1天:
HH:mm - 1天以上:
MM-dd HH:mm
- 1小时内:
6.3.2 纵轴(Y轴)— 单轴分段自适应
核心需求:一个Y轴,刻度不按数值均匀分布,而是根据数据分布自动调整疏密——数据密集的区域刻度变密(放大),数据稀疏的区域刻度变疏(压缩),让不同量级的多条曲线都能在同一个Y轴上看出变化趋势。
场景举例:
同时展示两条曲线,一条 DO 值在 2.03.0,一条温度在 2030。如果Y轴从0均匀到30,DO曲线会被压缩成一条几乎水平的直线,完全看不出变化。
实现方式 — 分段映射法:
原始Y轴(均匀) 显示Y轴(分段自适应)
30 ─ 30 ─
│
│ 温度变化区域(20~30)
│ 该区域被适当压缩
20 ─ 20 ─
│
│ ─ ─ ─ 中间区域(3~20)
│ 该区域数据稀疏,被大幅压缩
│
3 ─ 3 ─
│
│ DO变化区域(2.0~3.0)
│ 该区域数据密集,被放大展开
2 ─ 2 ─
│
0 ─ 0 ─
技术实现:
- 后端(或前端)对查询到的所有曲线数据进行全局分析,找出数据分布
- 将Y轴划分为若干段,每段内使用线性映射,段间不等距
- 段划分规则:
- 找出所有曲线的数据值,按值聚类
- 每个聚类形成一个"密度段",该段在Y轴上占据较大比例的高度
- 聚类之间的空白区域形成一个"稀疏段",该段在Y轴上占据较小比例的高度
- 数据值 → 显示坐标的映射公式:
对于每个数据值 v,计算其在Y轴上的显示位置 p:
p = sum( previous_segment_heights ) + (v - segment_start) / (segment_end - segment_start) * segment_height
其中 segment_height 根据该段的数据密度分配:
- 密度段(包含大量数据点):分配较高比例(如60%的Y轴高度)
- 稀疏段(几乎无数据点):分配较低比例(如10%的Y轴高度)
- Y轴刻度标签显示原始值,位置按映射后的坐标放置
ECharts 实现方案:
方案一(推荐 — 数据变换):后端将数据值按分段映射关系转换为显示坐标,前端使用线性Y轴绘制,Y轴通过 axisLabel.formatter 显示原始值标签,tooltip.formatter 显示原始值。
方案二(纯前端):前端获取原始数据后,使用 ECharts 的 custom 系列或数据变换插件,在前端完成映射计算。
Y轴标签规则:
| 规则 | 说明 |
|---|---|
| 单Y轴 | 物理上只有一个Y轴,位于图表左侧 |
| 数值标签 | 显示原始数值,不显示映射后的坐标值 |
| 刻度位置 | 刻度线在Y轴上的物理位置由映射函数决定,不是均匀分布 |
| 刻度数量 | 自动控制,保持5~10个刻度标签,避免过密 |
| 标题标注 | Y轴标题显示 数值 |
| 分段视觉提示 | 在Y轴背景上用浅色横条标记不同分段区域,帮助用户识别分段边界 |
6.3.3 折线规则
| 数据质量 | 显示方式 |
|---|---|
| 连续 good 数据 | 实线折线,正常连接 |
| 连续 bad 数据 | 虚线连接(-- 样式) |
| good → bad 过渡 | 实线连接到 bad 点,bad 点之后变为虚线 |
| 数据缺失(gap) | 断线,不连接 |
6.3.4 游标功能
游标是一条垂直的十字线,跟随鼠标移动。
行为:
鼠标在图表区域移动
│
▼
游标垂直线跟随鼠标位置
│
▼
对每条曲线,找到游标所在时刻最近的数据点
│
├── 找到数据点 → 显示该点的值
└── 未找到数据点 → 显示 "--"
│
▼
游标信息显示在右侧表格中
右侧游标信息表格:
| 点位名称 | 时间 | 数值 | 质量 |
|---|---|---|---|
| 曝气池DO_01 | 00:00:05 | 2.38 | good |
| 曝气池温度 | 00:00:05 | 25.2 | good |
游标交互细节:
- 游标跟随鼠标移动,实时更新
- 鼠标离开图表区域时,游标消失
- 游标所在时刻精确到最近的数据点,不插值
- 游标表格中,质量戳为 bad 时,数值显示为
—
7. 表格模式(前端)
7.1 布局
┌───────────┬──────────────────────────────────────┐
│ 点位树 │ 时间范围选择器 [自定义] │
│ (复选框) │ 间隔: [10分钟] ▼ │
│ │ │
│ ☑ 曝气池 │ ┌──────────────────────────────────┐ │
│ ☑ DO │ │ 时间 │ DO │ 温度 │ │
│ ☐ 温度 │ │──────────────┼─────────┼────────│ │
│ ☐ 加药间 │ │ 00:00:00 │ 2.35 │ 25.1 │ │
│ │ │ 00:10:00 │ 2.40 │ 25.0 │ │
│ 内部数据 │ │ 00:20:00 │ — │ 25.3 │ │
│ ☐ (暂无) │ │ 00:30:00 │ 2.38 │ — │ │
│ │ │ ... │ ... │ ... │ │
│ [曲线] [表格]│ └──────────────────────────────────┘ │
│ │ [导出CSV] │
└───────────┴──────────────────────────────────────┘
7.2 时间间隔选择
| 参数 | 说明 |
|---|---|
| 间隔时间 | 下拉选择:1分钟、2分钟、5分钟、10分钟、15分钟、20分钟、30分钟、60分钟 |
| 默认值 | 10分钟 |
7.3 表格数据规则
| 规则 | 说明 |
|---|---|
| 第一列 | 时间序列,从 start_time 开始,按 interval 递增 |
| 数据列 | 每个勾选的点位对应一列,列标题为 点位名称(单位) |
| 数据对齐 | 使用最近邻匹配(见 5.2.3 后端逻辑) |
| bad 质量 | 数据单元格显示 —(全角破折号) |
| 无匹配数据 | 数据单元格显示 —(全角破折号) |
| 空值处理 | 空白单元格统一显示 —,不显示空单元格 |
7.4 导出CSV
点击"导出CSV"按钮,触发 POST /api/v1/history/export 接口。
- 导出内容与当前表格显示内容一致(相同的时间范围、间隔、点位)
- 文件名格式:
历史数据_{start_time}_{end_time}_{interval}min.csv - 导出完成后浏览器自动下载
8. 前端页面结构
8.1 页面布局
┌──────────────────────────────────────────┐
│ ┌──────────┐ ┌───────────────────────┐ │
│ │ ▽ 数据管理 │ │ │ │
│ │ 设备管理 │ │ │ │
│ │ 数据采集 │ │ 内容区域 │ │
│ │ 数据写入 │ │ │ │
│ │ │ │ ┌───────────────────┐ │ │
│ │ ○ 历史数据 │ │ │ ☑ 曝气池 │ │ │
│ │ │ │ │ ☑ DO │ │ │
│ └──────────┘ │ │ ☐ 温度 │ │ │
│ │ │ ☐ 加药间 │ │ │
│ │ │ ─────── │ │ │
│ │ │ 内部数据 │ │ │
│ │ │ ☐ (暂无数据) │ │ │
│ │ └───────────────────┘ │ │
│ │ [曲线] [表格] 切换 │ │
│ │ ┌───────────────────┐ │ │
│ │ │ 曲线图/表格内容区域 │ │ │
│ │ │ │ │ │
│ │ └───────────────────┘ │ │
│ └───────────────────────┘ │
└──────────────────────────────────────────┘
8.2 模式切换
- 曲线模式和表格模式通过 Tab 按钮切换
- 切换时,勾选的点位状态保持不变
- 切换时,时间范围保持不变
- 切换时,不重复请求数据,按需加载(点击曲线Tab时请求曲线数据,点击表格Tab时请求表格数据)
8.3 交互流程
用户进入历史数据页面
│
▼
加载历史点位树(GET /api/v1/history/tree),按分组→点位两层构建
│
▼
左侧树渲染,用户勾选点位
│
▼
用户选择时间范围
│
├── 曲线模式 → 点击查询 → POST /api/v1/history/query → 渲染曲线图
└── 表格模式 → 选择间隔 → 点击查询 → POST /api/v1/history/query-table → 渲染表格
│
▼
用户勾选/取消勾选点位 → 自动重新查询(保持时间范围不变)
用户切换模式 → 自动重新查询(保持点位和时间范围不变)
附录A:关键业务规则一览
| 编号 | 规则 | 说明 |
|---|---|---|
| H001 | 历史数据来源 | 仅展示 store_history=true 且设备/点位均启用的采集点数据 |
| H002 | 树形结构 | 按分组→点位两层组织,分组名称取自采集点配置的 group_name,外加"内部数据"预留节点 |
| H003 | 最大勾选数 | 同时勾选的点位不超过20个 |
| H004 | 数据保留 | 可配置1~730天,通过TDengine KEEP参数实现 |
| H005 | 表格时间对齐 | 最近邻匹配,匹配窗口 = interval/2 |
| H006 | 曲线Y轴 | 单Y轴分段自适应,数据密集区放大,稀疏区压缩 |
| H007 | bad质量显示 | 曲线:虚线;表格:—;游标:— |
| H008 | 数据缺失 | 曲线:断线;表格:— |
| H009 | 模式切换 | 保持点位勾选状态和时间范围不变 |
| H010 | CSV导出 | 使用UTF-8 with BOM编码,与表格显示内容一致 |
本文档版本:v1.0 最后更新:2026-07-09