docs(algo): 采样环缓冲补「为什么需要/设计思想」两节与两张配图

- 采集与算法层设计说明第 5 节由单段说明拆成 5.1~5.5:为什么需要这一层
  (三个缺口 + 两个被否决的替代方案)、设计思想(五条不变量)、环内布局
  与 start 推导、API 契约、要点与禁令。
- 新增两张配图(生成器 + 产物同批提交):
  采样环缓冲_为什么需要.svg —— 上游 1 kHz 覆盖写只交最新一帧 vs 下游
  20 点滑窗,缺口 19 点,以及三个消费者必须同源;
  采样环缓冲_环形布局.svg —— 40 格环里哪 20 格是本窗、写指针指「下一格」、
  回绕方向与三条设计不变量。
- 5.5 补一条「读者与写者必须同任务」,与故障录波设计说明的取窗竞态口径一致。
- 门禁:doc-links --sections-strict 通过(28 处章节引用全部可判定);
  id-check 新增行无未登记编号;generated-fresh 21 个生成器 16 一致、0 新漂移。
This commit is contained in:
2026-10-29 17:15:48 +08:00
parent de512a0db8
commit c377fc8c15
6 changed files with 788 additions and 4 deletions
+69 -4
View File
@@ -146,9 +146,71 @@ flowchart TD
## 5. 全周傅氏相量输入 `sample_ring.c`
**解决的问题**:相量计算要"以当前时刻为终止的一周波",而 语义取数与有效性每次只给最新一帧。本层按**语义通道**
存最近 `SAMPLE_RING_CYCLES = 2` 个周波(`SAMPLE_RING_DEPTH = 40` 点),与参考系统
`CN_BUFSAM_CYCLE = 2` 一致,并为后续加长数据窗(1.5~2 周波抗衰减直流)预留空间。
### 5.1 为什么需要这一层
**一句话**:上游每 1 ms 只交"本拍那一帧",下游相量要的是"以当前时刻为终止的一整周波",
中间那 19 个历史点没有任何一层替它保留——本层就是补这个缺口的地方。
三个缺口,任一个单独存在都能让相量算不出来或算不准:
| 缺口 | 上游给什么 | 下游要什么 | 不缓冲的后果 |
|---|---|---|---|
| **点数** | 每拍 1 帧(9 路语义值) | 一周波 20 点(`PHASOR_N`) | 只有 1 点 → 全周傅氏退化成瞬时值,谐波抑制完全失效 |
| **历史** | 覆盖写,新值顶掉旧值 | 以当前时刻为终点的**滑窗** | 每拍都要重算,但每拍只拿到当前点 → 窗永远凑不齐 |
| **同源** | —— | `phasor` / `freq` / `meas` 必须看**同一个窗** | 各存一份历史 → 窗边界差一拍,相位差与频率跟着漂 |
![上游每拍只交最新一帧、下游要一整周波:采样环补的正是中间那 19 个历史点](../images/采样环缓冲_为什么需要.svg)
**两种"更省事"的替代方案,都否决了**:
- **在 `phasor.c` 里自己存**:`freq` 要相邻两拍相量、`meas` 要同源快照,三个模块各存一份 →
同一时刻存在三个"窗",一旦谁跳过一拍就没有单一真值可对,排查时分不清是采集问题还是缓存问题。
- **在 `adc_task.c` 里存一个数组**:等于把环、窗、覆盖这些算法语义塞进任务层,既破坏
"任务只消费 / 编排"的分层,又让这段逻辑失去主机侧单测能力(`adc_task.c` 依赖 FreeRTOS 与 HAL,
不能原样 `#include`)。
结论:**"保存历史"必须是一个自己的、无依赖的、单一所有者的层**——这就是本模块存在的理由。
### 5.2 设计思想
五条,每条都对着一个具体的失败模式:
1. **按语义通道索引,不认物理通道**:第一维是 `adc_semantic_t`(`ADC_SEM_UA` 等),不是 `AINn`、
不是 `ADCn`、也不是转换器通道号。V0.1 上 8 路电气量分属两片转换器、网表名与板级名有 13 路置换
(见第 3 节),本层若认物理号,改版即全线错位。
2. **整帧原子推进**:`push_frame()` 先把 9 路一次写齐,**最后**才动 `s_head`。消费侧看到的窗
因此永远落在整帧边界上——单帧内不可能"半新半旧"。
3. **只存不算**:本层不校正、不插值、不做任何跨通道运算。同刻性(8 路电气量是否同一时刻)
归上游 `adc_input` 负责,本层既不保证也不假设。这条边界换来的是**可脱离硬件单测**:
纯算法、不碰寄存器、不碰 RTOS,生产 `.c` 能原样 `#include` 进主机侧套件。
4. **环形覆盖写,运行期不分配**:容量固定 40 格、静态数组,无 `malloc`、无 `memmove`。
写满后新值直接覆盖最老值——相量只需要最近一周波,更远的历史没有保留价值。
5. **容量取 2 周波而不是 1 周波**:1 周波够算全周傅氏,多留 1 周波是给 1.5~2 周波长窗
(抗衰减直流分量)预留的余量,与参考系统 `CN_BUFSAM_CYCLE = 2` 同口径。改容量只动一个宏,
但要同步 `docs/内存管理/RAM预算台账.md`(容量翻倍 = 1440 B 变 2880 B,见第 11 节)。
![写指针指「下一格」、窗永远紧贴它身后:40 格里哪 20 格是本窗](../images/采样环缓冲_环形布局.svg)
### 5.3 环内布局与取窗(`start` 为什么这么算)
`s_head` 是**下一个待写入**的位置,不是最后写入的位置——这是全部推导的起点。
最后写入的一点是 `s_head - 1`,于是"以最新点为终点的 n 点窗"= 下标 `[s_head-n, s_head-1]`,
窗首(最老点)就是 `s_head - n`,落到 `[0, DEPTH - 1]` 要取模。
代码里写成 `(s_head + SAMPLE_RING_DEPTH - n) % SAMPLE_RING_DEPTH` 而不是 `(s_head - n) % DEPTH`,
是因为后者有两个独立的坑:
- **16 位无符号下溢后再取模不等价**:`s_head < n` 时减法先回绕成 `65536-(n-s_head)`,
而 65536 不是 40 的倍数,`(x mod 65536) mod 40 ≠ x mod 40`。反例 `s_head = 3`、`n = 20`:
正确窗首是 23,回绕后得 65519,`65519 % 40 = 39` → 整窗错位。
- **有符号负取模**:两个 `uint16_t` 先提升成 `int`,`3 - 20 = -17`;C99 的 `%` 向零截断,
`(-17) % 40 == -17`(不是 23),再转回无符号就是一次**越界读**——不报错、不 HardFault。
**先加一个 DEPTH** 同时解决两件事:`DEPTH ≡ 0 (mod DEPTH)`,加它不改变同余类、数学值不变;
而 `s_head + DEPTH - n ∈ [0, 2·DEPTH - 2]`,恒非负、远小于 65535,两种整数提升规则下都不下溢。
逐条反例与边界自检(`n == DEPTH` 时 `start == s_head`)见 `App/algo/sample_ring.c` 同名函数的注释。
### 5.4 API 契约
| API | 契约 |
|---|---|
@@ -157,13 +219,16 @@ flowchart TD
| `sample_ring_filled()` | 已累积点数(**饱和**于 40)——"够不够一周波"的判据 |
| `sample_ring_frames()` | 累计入环帧数(**不饱和**,1 kHz 下约 49.7 天回绕)——与 `tpafe5160_frame_seq()` 对照即判丢帧 |
**要点**
### 5.5 要点与禁令
- **整帧写入后才统一推进写指针** → 单帧内不可能"半新半旧",消费侧取到的窗永远落在整帧边界。
- 本层**只做按时序存 / 取,不做任何跨通道运算**:同刻性由上游 语义取数与有效性负责,本层既不保证也不假设。
判定也不在本层(本层只出计数)。
- 某通道本拍取数失败 → **整拍跳过入环**(现 `adc_task.c` 的 `s_frames_skip` 即此),
**切勿补 0 点**——单个 0 点会显著污染 20 点 DFT 窗。
- **读者与写者必须同任务**:全仓唯一写者是 `adcTask` 的压环语句,读者(`phasor_run` / `meas_tick`)
与之同拍串行,故本层不加锁、不用 `volatile`、不关中断。要把读者搬到别的任务,必须先在
本层补双区 / 序号快照——`sample_ring_window()` 读 `s_head` 后逐点拷贝,跨任务读活缓冲会取到撕裂的窗。
---