Serial Studio 级联包络环(Cascading Envelope Rings)解析:时间轴绘图历史数据的分层降采样金字塔
【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio
Serial Studio 的时间轴(Time-Axis)绘图把历史数据保存在一个有界的(time, value)环形缓冲中,并在摄取时按绝对时间网格做 min/max 包络降采样。本指南以规格文档 doc/claude/specs/0057-cascading-envelope-rings/ 的 plan/spec/tasks/report 四份文档为核心,讲解该项改进引入的DSP::EnvelopeRing级联包络环:在原有TimeRing之上叠加 16 倍逐级粗化的包络金字塔,让宽时间窗口的渲染成本从 O(窗口中样本数) 降到 O(像素数),同时完整保留 level 0 的细节历史。读完本文,你将理解金字塔的写入/读取路径、层级选择算法、内存开销数学、设计权衡与对应的 ctest 验证套件。
背景与动机:为什么"单层绝对时间网格"不够用
时间轴绘图的历史数据保存在一个有界的(time, value)环形缓冲里,摄取时在单条绝对时间网格上做降采样(每个网格单元保留一个 min/max 包络对)。从"只有一张网格"这个事实出发,spec.md归纳出两个固有问题:
- 渲染成本随环的规模缩放,而不是随屏幕缩放。每次绘制都要遍历可见窗口内所有保留样本,再重新分桶到像素列。以 44.1 kHz 的流式(stream-lane)源为例,一个 12.5 s 窗口的环约有 55 万个槽位(在字节上限处最多可达 400 万);一个 1000 像素的图以 60 Hz 刷新,每条曲线每秒要遍历全部样本 60 次。这正是环容量上限(ceiling)和 drain 预算存在的原因,也是宽窗口 + 高密度源"感觉沉重"的根源。
- 网格分辨率必须在摄取时根据源速率猜测。细节保留粒度在摄取那一刻就被固定:网格比源粗,细节永远丢失;网格比内存预算细,又无法覆盖整个窗口。
streamRingCapacity、growTimeRing/resizeCapacity、kMaxRateSizedRingSamples这些机制都是为了在布局时或显示 tick 上"猜"一个合适的粒度。
本方案借鉴示波器/逻辑分析仪查看器的思路:构建一个min/max 包络层级金字塔,每层比下一层粗 16 倍,增量追加,渲染器选择"单元格仍细于一个像素"的最粗层级,只读取可见跨度。由于 Serial Studio 可能连续运行数天,每一层都必须是有限环,绝不能是无界追加日志。
总体设计:EnvelopeRing = 原 TimeRing + 16 倍粗化包络层
plan.md给出了一个关键决策:金字塔作为一个包含TimeRing的新结构体存在(而非改造TimeRing本身),这样 level 0 与今天的环逐字节一致(行为、尺寸都不变),扫描/触发(sweep)引擎可以继续原样使用普通TimeRing。仓库中对应实现在 core/Pipeline/DSP.h(规格文档写作时路径为app/src/DSP.h,现已迁移到core/Pipeline/):
EnvelopeCell(DSP.h#L565):单个包络单元,{t0, v0, t1, v1}四个 double(32 字节),按时间顺序存放两个极值,因此整层可以读成一条单调的(time, value)序列,长度恰好是2 * cells——这正是现有列累加器消费的数据形状。EnvelopeLevel(DSP.h#L577):一个粗化层 = 一个FixedQueue<EnvelopeCell>有界环 +openIndex(该层"开口单元"的索引,即环的 back)+shift(= 4k,见下)。openValid标记是否已有开口单元。EnvelopeRing(DSP.h#L597):金字塔本体,字段为level0(原有TimeRing)、std::vector<EnvelopeLevel> levels、openCell/openCellValid(当前开口 level-0 单元的索引)。常量kLevelShift = 4、kMaxCoarseLevels = 9、kMinLevelCells = 3。
层级几何与"单元身份"
level k 的每个单元覆盖16^k个 level-0 网格单元。单元身份不是浮点边界时间,而是level-0 单元索引的整数右移:cellIndex(t) = floor(t / interval),粗化层归属cellIndex(t) >> 4k。plan.md特别强调这样做的三个好处:网格精确嵌套、环绕(wraparound)一致性天然成立、完全不依赖浮点边界取整,且可以用整数算术做可测试性验证。selectLevel里正是用cellIndex(front.t0) >> shift与oldestId >> shift比较来判断覆盖范围。
内存开销:粗化层合计仅 1.067 倍
spec.md给出了精确的字节账:level 0 有C0个槽位(每槽 16 字节,饱和网格cells0 = C0/2个单元);level k 持有ceil(cells0 / 16^k) + 1个 32 字节单元。全部粗化层合计:
32 × (C0/2) × (1/16 + 1/256 + …) = 16 × C0 / 15 ≈ 1.067 × C0 的字节spec.md中给出了验证数字:C0 = 262144时 level 0 为 4.19 MB、粗化层 0.28 MB(共 5 层);C0 = 4M时为 67.1 MB + 4.47 MB(共 6 层);C0 = 1024时共 3 层、1.070 倍。层级在"某层容纳不足两个真实单元"时停止增长,最多 9 个粗化层(加上 level 0 共 10 层)。这一点也被测试套件固定下来:tst_envelope_ring中ring(4096, 1.0)得到coarseLevelCount() == 2,且断言coarseBytes * 14 <= level0Bytes(见 app/tests/tst_envelope_ring.cpp#L200-L208)。
写入路径:append 的增量维护,从不重扫
上游数据流不变
plan.md明确:Dashboard上游完全不动。数据块在显示 tick 到达(applyBlock);帧车道(frame lane)按不规则样本喂给updateLineSeries/feedMultiRings;流车道(stream lane)按均匀网格列喂给applyBlockColumn;回放 seek 喂给fillSeekPlot*。所有这些入口调用环的 append,其余不变。整条链路都在GUI 线程、单写者、无锁——与今天TimeRing的纪律完全相同。
opensCell:一行谓词,让金字塔与 level 0 天然对齐
TimeRing新增一行谓词opensCell(t)(DSP.h#L474-L477):
[[nodiscard]] bool opensCell(double t) const noexcept { return time.size() == 0 || t >= nextEmit; }appendDecimated原本就在内联地评估这个条件,现在把它提成公共谓词,"这个样本是否开启一个新的 level-0 单元"对金字塔和 level 0 而言由构造保证一致(这是 tasks.md 中的 T1)。
EnvelopeRing::appendDecimated 主流程
虽然plan.md里写作append(t, v),但 report.md 记录了一个落地细节:仓库的 lint 会把热路径翻译单元(TU)上的.append(当作 Qt 容器分配来标记,因此金字塔保留了 level 0 的方法名appendDecimated。实现见 DSP.h#L706-L733:
- 消毒(sanitise):拒绝任何非有限(NaN/Inf)的时间或值,在任何层级被触碰之前就返回——与 level 0 现状一致(需求 R6)。时间回退小于一个
interval时钳到最新样本,跨过整个单元则视为时钟重启并clear()。 - 检测新单元:若
level0.opensCell(t)为真([[unlikely]]分支),先把刚完成的 level-0 单元折叠进所有粗化层——level0.acc*累加器恰好就是这个单元的极值——再记录新的openCell = cellIndex(t)。 - 委托:调用
level0.appendDecimated(t, v),与今天完全相同的降采样逻辑。
折叠规则:一次完成、摊还 O(1)
折叠核心是foldOpenCell→foldCell(DSP.h#L758-L788):
void foldCell(std::int64_t index0, const EnvelopeCell& cell) { for (auto& level : levels) { const std::int64_t index = index0 >> level.shift; if (level.openValid && index == level.openIndex && level.cells.size() > 0) { mergeCell(level.cells[level.cells.size() - 1], cell); // 同一开口单元:合并极值 continue; } level.cells.push(cell); // 新开口单元:直接压入 level.openIndex = index; level.openValid = true; } }mergeCell(DSP.h#L793-L822)把新单元的最小/最大极值并入目标单元,并保持{t0,v0,t1,v1}时间有序。之所以是"完成的 level-0 单元折叠进每一个更粗的开口单元"而不是"k 层完成才折叠进 k+1 层",plan.md的权衡表说明得很清楚:后者会让 level k 滞后最多一个 level-(k-1) 的跨度(在 level 5 处可达秒级),粗化渲染会缺最新数据;前者让每一层都"最新"到不超过一个 level-0 单元的误差,代价只是每个 level-0 单元完成时做一次**有界(≤ 9 次)**的合并循环,摊还仍是 O(1)。
无分配、无拷贝
append 路径零分配:粗化层在构造/重建时一次定型,push只是"普通存储 + 索引自增";不复制Frame;opensCell分支是预测不命中的单分支。这是 report.md 里"counterfactual check"验证的核心规则:追加路径只增加一个分支,pipeline 线程完全未动(grep EnvelopeRing app/src只命中DSP.h、UI/Dashboard.*、两个绘图控件与一个 API handler)。
读取路径:selectLevel 选层 + downsampleTimeWindow 重载
层级选择规则
EnvelopeRing::selectLevel(spanSec, pixels, oldestSec)(DSP.h#L644-L666)按以下规则挑选渲染层:
pixelSec = span / pixels 从 level 1 向上走: 若 levelSpanSec(k) > pixelSec —— 单元格已粗于一个像素:停止 或 cells.size() < 2 —— 层太稀疏:停止 或 该层最老单元仍晚于需要的 oldest —— 覆盖不住窗口:停止 否则 chosen = k 最后一级合格者胜出,默认 level 0即:返回单元格跨度 ≤ 一个像素时间且仍能覆盖请求跨度的最粗层级;否则回退到更细层,level 0 是永远合格的兜底(需求 R4)。
dsTimeWindowCore 提取与共享
重构把downsampleTimeWindow的访问器之后的全部主体提取为模板dsTimeWindowCore(n, xAbs, yAt, newest, xLo, xHi, w, h, out, ws)(core/Pipeline/DSPDownsample.h#L407-L470):窗口解析为两次二分查找,切片只走一遍,列桶落在以 newest 为锚的绝对列宽格点上(修复抖动/shimmer)。原有downsampleTimeWindow(const AxisData&, const AxisData&, ...)重载改为调用它(输出逐语句不变)。
新的金字塔重载(DSPDownsample.h#L511-L554):
newest = level0.time[n0-1],oldest = max(xLo + newest, level0.time[0]);level = ring.selectLevel(xHi - xLo, w, oldest);若level <= 0或超出层数,直接走 level 0 的普通重载;- 否则把所选层的
cells展开成2 * cells个极端点(偶数下标取t0/v0,奇数取t1/v1),rebase 到 level 0 的最新样本,喂给dsTimeWindowCore。
这里有一个值得注意的细节:plan.md的权衡表解释了为什么 rebase 必须用level 0 的最新样本而不是粗化层自己的最后时间——坐标轴的 0 是"现在",粗化层最后一个极值比最新样本滞后最多一个 level-0 单元(按选择规则构造上小于 1/16 像素),若用粗化层自己的时间做锚点,整条曲线会向右偏移这个滞后量。
调用点
- core/Ui/UI/Widgets/Plot.cpp#L616-L624:时间轴绘制路径
m_dashboard.plotTimeRing(m_index)后调用DSP::downsampleTimeWindow(ring, xLo, xHi, m_dataW, m_dataH, m_data, &ws); - core/Ui/UI/Widgets/MultiPlot.cpp#L624:MultiPlot 每曲线同一调用;
- 两条路径都发生在
updateData()(60 Hz 绘制)。
窄窗口因此读 level 0、显示与旧版本完全相同的细节;宽窗口读粗化层、只遍历 O(pixels) 个包络单元——这就是渲染成本与 level-0 密度解耦的关键。
容量重建、快照与生命周期
- 构建层级:
buildLevels()(DSP.h#L741-L753)从 level 0 容量推导:cells0 = capacity / 2,level k 的容量为ceil(cells0 / 16^k) + 1,只要它 ≥kMinLevelCells(3)就继续,最多kMaxCoarseLevels(9)层。 - 重建:只有
resizeCapacity(显示 tick 的 grow 路径或时间范围变更)会触发:level 0 保留最新样本(TimeRing::resizeCapacity),随后按新C0重定粗化层尺寸,并把 level 0 保留的槽位逐个折叠成单样本单元回填(rebuildLevelsFromLevel0,DSP.h#L829-L842)。复杂度 O(C0 × K),且"按构造很罕见"。 - 快照/恢复:
plan.md的数据模型章节明确没有持久化,环只是运行时对象。布局重建时的 snapshot/restore 保持既有契约:level-0 容量与 interval 相同则整座金字塔随FixedQueue的别名共享语义整体搬移(恢复路径检查level0.time.raw());形状不同则保留的 level-0 槽位重放appendDecimated(重建粗化层只是副作用)。 - API / SDK / QML:三处均无变化。
dashboard.tailFrames继续返回 level 0 样本;绘图控件之外的读取者不受影响(需求 R5)。
热路径与线程影响
plan.md对热路径影响逐条做了结论:
- 是否触碰热路径?否。pipeline 线程零改动;Dashboard 摄取(GUI 线程、显示 tick)每个追加样本多一次谓词 + 一个预测不命中分支,以及罕见的 level-0 单元完成时的有界(≤ 9 次)折叠。append 不分配(层级在构造/重建时定型)、不复制
Frame。--benchmark-hotpath的 gated 层按构造不受影响(它们不运行 dashboard);未 gated 的lua+dashboard读数可能只在噪声范围内波动。 - 新跨线程信号/槽?无。
- 新输入到缓存的 hotpath 标志?无。
- 时间戳所有权:不变——环仍消费它之前消费的显示时钟时间。
设计权衡:plan 中的决策表
plan.md用一张决策表记录了六个关键取舍:
| 决策点 | 备选方案 | 选择及理由 |
|---|---|---|
| 金字塔放在哪 | (a) 塞进TimeRing;(b) 新结构体包含一个TimeRing;(c) Dashboard 里放并行 map | (b):level 0 与原环逐字节一致(sweep 引擎可原样继续使用),金字塔按使用点可选启用,且由单一类型持有全部不变量 |
| 折叠拓扑 | (a) 完成的 k 层单元只折叠进 k+1 层;(b) 完成的 level-0 单元折叠进每个更粗开口单元 | (b):否则 level k 会滞后最多一个 level-(k-1) 跨度(level 5 处可达秒级),粗化渲染缺最新数据;(b) 让每层最新到误差 ≤ 1 个 level-0 单元,代价是每个 level-0 完成时一次有界 ≤ 9 路循环,内容相同、摊还 O(1) |
| 单元身份 | (a) 浮点单元起始时间;(b) 整数 level-0 单元索引 >> 4k | (b):精确嵌套、环绕一致、无边界取整,且可用整数算术测试 |
| 粗化层容量 | (a)C0 / 16^k(完整覆盖稀疏 level 0,1.13 倍);(b)(C0/2) / 16^k + 1(覆盖饱和网格,1.067 倍) | (b):可见轴是T而环窗口是1.25 T;稀疏 level 0 下粗化层按到达数据自然延展窗口,且读者覆盖检查会在粗层不够时回退到更细层 |
growTimeRing/resizeCapacity | (a) 删除;(b) 仅为 level 0 保留;(c) 改成每条曲线固定字节预算 | (b):渲染成本侧的猜测理由已消失,但内存侧理由仍在,且无人值守地改内存策略不合规矩;(c) 记录为后续项(见 spec.md 的 open questions) |
| 粗化读取的最新时间 rebase | (a) 对齐粗化层自己的最后时间;(b) 对齐 level 0 的最新样本 | (b):坐标轴 0 是"现在",粗层最后极值滞后最新样本最多一个 level-0 单元(按选择规则构造上小于 1 像素),(a) 会让曲线右移该滞后量 |
风险与缓解
plan.md列出五项风险及对策:
- 粗化层右缘滞后小于一个 level-0 单元(按选择规则 < 1/16 像素)。接受,并在头文件注释中记录。
- 快照拷贝共享存储(
FixedQueue拷贝别名其数组,与现有代码依赖的属性相同)。EnvelopeRing是这类队列的普通聚合,拷贝/移动语义与今天TimeRing一致;恢复路径检查level0.time.raw()。 bulkLoadPlotWindow把时间归一化到 0 结尾,单元索引会变负;C++20 中负std::int64_t的>>是算术(向下取整)右移,正好得到想要的嵌套。- 空静态回退环(
kEmpty,容量 1)不构建任何粗化层,直接按 level 0 读取——与今天相同。 - 静默破坏类:缓存的 hotpath 标志与 push 表都不变;环在使用时仍按 widget 索引寻址,与之前完全相同。
测试与验证:AC1–AC7 与 ctest 套件
spec.md定义了七条验收标准,tasks.md的 T6 与 app/tests/CMakeLists.txt#L549 注册了tst_envelope_ring(见 app/tests/tst_envelope_ring.cpp)。该测试以 Qt Test 的 8 个槽覆盖 AC1–AC4 及粗化读取:
- AC1 逐层暴力比对(
rampMatchesBruteForce):喂入既漂移又振荡的确定性波形(waveform,保证一个单元内 min 与 max 落在不同样本上),对每个已关闭的粗化单元,用记录在案的样本做暴力 min/max,逐层逐单元比对(checkLevels/bruteForce)。例如ring(4096, 1.0)喂 1500 个单元 × 每单元 5 样本后,level0.time.size() == 3000、levels[0].cells.size() == 94、levels[1].cells.size() == 6。 - AC2 层级选择:
selectLevel对给定 seconds-per-pixel 返回期望层;窄窗口回退 level 0;粗层覆盖不足时回退更细层(如selectLevel(wide, 100, time[0]) == 1,而tooOld时== 0)。 - AC3 环绕一致性(
wrapKeepsLevelsConsistent):level 0 与两级粗化层都环绕多次后,每个闭合粗化单元仍等于其覆盖范围的暴力极值、单元保持时间有序,且最粗层保留的历史从不短于 level 0 的。 - AC4 非有限拒绝(
rejectsNonFinite):NaN/±Inf 的时间或值在触碰任何层级前被拒绝,level0、每个粗化层与openCell记账状态原样不动。 - 粗化读取(
downsampleReadsCoarseLevel):窄窗口下金字塔重载与普通重载输出逐点相等;宽窗口下输出点全部落在请求窗口内、有限、数量 ≤w * 4,且selectLevel == 1。 - 尺寸/层级跨度:验证 262144 容量下
coarseBytes * 14 <= level0Bytes,以及levelSpanSec(1) == interval * 16、levelSpanSec(3) == interval * 4096。
此外:集成测试无需新增(tests/integration/的 dashboard 测试经由dashboard.tailFrames验证,其输出不变);热路径用--benchmark-hotpath复验(gated 层必须不动,报告lua+dashboard行);静态检查对每个改动文件跑python3 scripts/code-verify.py --check(AC7)。AC5(维护者,运行应用:44.1 kHz 音频源 + 120 s 时间范围下宽视图 CPU 明显下降、缩放到任意 1 s 切片细节不变)与 AC6(--benchmark-hotpath)需要维护者在构建后手工确认,report.md的 "For the maintainer" 一节给出了操作清单:构建 →ctest -R tst_envelope_ring→--benchmark-hotpath→qt-cpp-review。report.md 还记录了一个有意思的验证:新测试"未在当晚编译",算术正确性通过 phase-0 的 Python 模拟(envelope_sim.py,3 层 0 处不一致、1.067 倍内存)交叉核对。
集成点速览
- Dashboard 持有金字塔:core/Ui/UI/Dashboard.cpp 中
makeHistoryRing(L114-L121)按源类型返回DSP::EnvelopeRing(streamRingCapacity(window, sampleRateHz), window)或DSP::EnvelopeRing(timeRingCapacity(window), window);两张 ring mapm_plotTimeRings/m_multiplotTimeRings、getter(plotTimeRingL955、multiplotTimeRingsL969)、snapshot/restore/replay、growTimeRing(L456)全部改用EnvelopeRing;sweep 引擎的环保持普通TimeRing。 - API 读取 level 0:core/Ui/ApiHandlers/DashboardHandler.cpp#L811 的
tailFrames通过dashboard.plotTimeRing(i).level0取时间轴样本,行为与旧版完全一致。 - 架构文档同步:doc/claude/architecture/dashboard.md#L197-L251 的 TimeRing 段落补上了金字塔结构、折叠规则、选择规则与内存上界。
- 范围边界:sample-axis、dataset-X、FFT、GPS、3D 环,以及 sweep/trigger 环(见
SweepEngine/SweepSegment)均不涉及;level 0 的 grow-on-saturation 路径保留,因为"渲染成本"侧的猜测理由已消失而"内存"侧的理由没有——是否把省下的渲染预算投给更大的 level 0 上限、或用固定字节预算替代 grow 策略,是留给维护者的后续问题(spec.md的 Open Questions)。
小结
EnvelopeRing用"每层 16 倍粗化的包络金字塔 + 整数单元身份 + 完成即折叠"三件事,把 Serial Studio 时间轴绘图的宽窗口渲染从 O(样本数) 变为 O(像素数),内存代价仅为 level 0 的 1.067 倍,且完全不动 pipeline 线程、不引入新依赖(纯头文件,DSP.h/DSPDownsample.h)、不改变任何 API。level 0 细节在窄窗口缩放时与旧版本逐点一致,而粗化层的正确性由 ctest 套件以暴力比对方式钉死。对想在自研遥测/示波器类应用里复刻"包络 mipmap"的读者,plan.md的决策表与 core/Pipeline/DSP.h 的实现是现成的高质量参照。
延伸阅读:规格四件套 spec.md(WHAT/WHY、需求 R1–R7、验收 AC1–AC7)、plan.md(HOW、决策与风险)、tasks.md(T1–T7 落地清单)、report.md(实现记录与维护者清单)。
【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考