From de512a0db82d45e83747be2b044bbab68d316814 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Thu, 29 Oct 2026 17:06:28 +0800 Subject: [PATCH 1/5] =?UTF-8?q?docs(algo):=20=E8=A1=A5=20sample=5Fring=5Fw?= =?UTF-8?q?indow=20=E6=B3=A8=E9=87=8A=EF=BC=8C=E8=AF=A6=E8=A7=A3=E7=AA=97?= =?UTF-8?q?=E9=A6=96=E4=B8=8B=E6=A0=87=E6=8E=A8=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补环内布局示意与 s_head 语义(下一待写位置,非最后写入位置), 据此推出窗首 = s_head - n、窗尾 = s_head - 1。 - 重点解释 start 为何写成 (s_head + DEPTH - n) % DEPTH: 加 DEPTH 不改变同余类;规避 16 位下溢后再取模不等价(65536 不是 40 的倍数);规避有符号负取模(-17 % 40 == -17)。 - 补限定条件:仅 adcTask 上下文,写者与读者同任务串行,故不加锁。 - .h 只加一行指针指向 .c 的推导,避免同一事实两处详述。 - 纯注释改动:去注释后代码 token 与 HEAD 完全一致;@build 0 错 0 警。 --- App/algo/sample_ring.c | 88 +++++++++++++++++++++++++++++++++++++++--- App/algo/sample_ring.h | 3 ++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/App/algo/sample_ring.c b/App/algo/sample_ring.c index 033c027d..02455d7b 100644 --- a/App/algo/sample_ring.c +++ b/App/algo/sample_ring.c @@ -73,28 +73,106 @@ int sample_ring_push_frame(const float *values, uint8_t n) { /* * 函数功能:取指定语义通道最近 n 点的窗(按时间序,旧 → 新) - * 入口参数:ch - 语义通道 - * n - 取点数(1..SAMPLE_RING_DEPTH) - * out - 输出缓冲(长度须 >= n) + * 入口参数:ch - 语义通道(0 .. ADC_SEM_COUNT-1) + * n - 取点数(1 .. SAMPLE_RING_DEPTH) + * out - 输出缓冲(调用方保证长度 >= n,本层不校验容量) * 返 回 值:0 - 成功;-1 - 参数错;-2 - 已累积点数不足 n - * 限定条件:无 - * 函数说明:out[n-1] 为最新一点。最老点下标 = head - n(模 DEPTH)。 + * 限定条件:**仅 adcTask 上下文**调用,且须在同拍 sample_ring_push_frame() 之后。 + * 全仓唯一写者 = adcTask 的压环语句,读者(phasor_run / meas_tick)与之 + * 同任务串行执行,写读之间不存在并发 —— 故本函数**不加锁、不用 volatile、 + * 不关中断**。若将来把读者搬到别的任务,必须先在本层补双区/序号快照, + * 否则会读到"半新半旧"的窗(旧点已被覆盖、新点尚未写入)。 + * 函数说明:语义 = "以当前最新一点为终点、向前回溯 n 点"的一周波窗。 + * out[0] 是最老一点、out[n-1] 是最新一点(时间序 旧 → 新), + * 与 phasor_run() 的傅氏求和方向一致,调用方不必再反转。 + * 环内布局与窗首下标的推导见函数体内的分步注释。 */ int sample_ring_window(adc_semantic_t ch, uint8_t n, float *out) { uint16_t start; uint16_t i; + /* ── 环内布局(以 DEPTH = 40、n = 20、s_head = 30 为例,此时 start = 10)── + * + * 下标 0 .. 9 | 10 ........ 29 | 30 .. 39 + * 内容 旧数据 | <-- 本窗 20 点 --> | 更旧(下一拍起被覆盖) + * + * 三个关键位置:start = 10(窗首/最老点)、s_head - 1 = 29(窗尾/最新点)、 + * s_head = 30(下一拍写入处,也是环满时最先被覆盖的那一点)。 + * + * ⚠ s_head 是**下一个待写入**的位置,不是最后写入的位置 —— 这一点是理解 + * 下面 start 公式的全部关键:最后写入的一点 = s_head - 1,于是 + * "以最新点为终点的 n 点窗" = 下标 [s_head-n, s_head-1],共 n 点; + * 窗首(最老点)就是 s_head - n。窗可能跨过环尾(start + i 越过 + * DEPTH - 1 时回绕到 0),故取数必须逐点取模。 */ + + /* 参数校验(通道号):adc_semantic_t 是枚举,C 标准允许其底层类型为有符号整型; + * 若调用方传负值/越界值,直接写 ch >= ADC_SEM_COUNT 会因整数提升变成有符号比较, + * 负值能"骗过"上界判断 → 索引到 s_ring 之外。故先转 unsigned int 再比: + * 负值转成极大无符号数,必然被拦下。(同时覆盖 out == NULL 的空指针。) */ if ((out == NULL) || ((unsigned int)ch >= (unsigned int)ADC_SEM_COUNT)) { return -1; } + /* 参数校验(取点数): + * n == 0 → 空窗无物理意义,且此时 start 会等于 s_head,返回"最新 0 点", + * 容易被调用方当成一次成功取窗 → 显式拒收; + * n > DEPTH → 一个环装不下连续 n 点,逐点回绕会把同一点重复读出, + * 拼出来的窗不再是真实时序(会伪造出"周期性"假象)→ 显式拒收。 */ if ((n == 0u) || (n > SAMPLE_RING_DEPTH)) { return -1; } + /* 数据够不够:s_filled 饱和于 DEPTH(见 sample_ring_push_frame),故对 + * n <= DEPTH 而言,本判据等价于"自 init 起累计入环帧数 >= n"。 + * 返回 -2 而非 -1 是有意区分:-2 表示"再等几拍自然会好",调用方应跳过本拍, + * 不应把它当故障上报(首周波启动期必然出现)。 */ if (s_filled < (uint16_t)n) { return -2; } + /* ── 窗首(最老点)下标:start = (s_head - n) mod DEPTH 的安全写法 ────── + * + * 数学目标很直白:最老点 = 最新点(s_head-1) 往前退 n-1 步 = s_head - n, + * 因为环是循环的,结果要落在 [0, DEPTH - 1],所以取模。但 C 里**不能**直接 + * 写成 (s_head - n) % DEPTH —— 有两个独立的坑,任一个都会让整窗错位: + * + * 坑 1(16 位无符号下溢后再取模,不等价): + * 若写成 (uint16_t)(s_head - n) % DEPTH,s_head < n 时减法先回绕成 + * 65536 - (n - s_head)。而 (x mod 65536) mod 40 **不等于** x mod 40 + * —— 65536 只含因子 2,不是 40(= 8 × 5)的整数倍,模运算不满足这种 + * "分两步取模"的分解。反例:s_head = 3、n = 20,正确 start = 23, + * 而回绕后得 65519,65519 % 40 = 39 → 读到的不是窗首而是环尾附近, + * 整个窗错位(表面现象是相量相位乱跳,极易误判成"采样通道接反")。 + * + * 坑 2(有符号负取模): + * 在 32 位 int 平台,两个 uint16_t 参与运算前先做整数提升 → s_head - n + * 得到 int 且可能为负;C99 规定 % 向零截断,于是 (-17) % 40 == -17 + * (不是数学上的 23)。这个负值再强转 uint16_t 变成 65519, + * 拿去索引 s_ring[ch][65519] 就是**越界读**(可能读到相邻通道或别的全局量, + * 不报错、不 HardFault,数据看着"像那么回事")。 + * + * 修法:**先加一个 DEPTH,再减 n,最后取模**。三件事同时被解决: + * ① 同余不变:DEPTH ≡ 0 (mod DEPTH),加它不改变结果所在的同余类, + * 数学值与 (s_head - n) mod DEPTH 完全一致,不是"凑数"; + * ② 恒非负、不溢出:0 <= s_head <= DEPTH - 1、1 <= n <= DEPTH, + * 故 s_head + DEPTH - n ∈ [0, 2·DEPTH - 2] = [0, 78],恒非负且远小于 65535, + * 两种整数提升规则下都不会下溢/溢出,% 的结果就是唯一的 [0, DEPTH - 1] 解; + * ③ 顺带护住后面的 (start + i):start <= DEPTH - 1、i <= n-1, + * 加法最大 2·DEPTH - 2,同样不会在 16 位域回绕。 + * + * 边界自检:n == DEPTH 时 start == s_head —— 环满时"下一拍将被覆盖的那一点" + * 恰好就是最老点,这是唯一自洽的解,不是特例。环尚未写满(s_filled < DEPTH)时 + * 有 s_head == s_filled,故 start = s_filled - n >= 0,此时窗一定不跨环尾。 + * + * 性能备注:SAMPLE_RING_DEPTH = 40 不是 2 的幂,**不能**用 & (DEPTH - 1) 代替 %; + * Cortex-M4 有硬件 UDIV/SDIV,编译器会生成一次除法指令,本函数每拍最多 n 次 + * 取模(n <= 40),代价可接受。若将来把 DEPTH 改成 2 的幂,这里可换成位与, + * 但须同步核查全文件所有 % SAMPLE_RING_DEPTH 的使用点。 */ start = (uint16_t)((s_head + SAMPLE_RING_DEPTH - (uint16_t)n) % SAMPLE_RING_DEPTH); + + /* 逐点顺序拷贝:从 start 起向前走 n 步,终点正好是 s_head-1(最新点)。 + * 每步单独取模,是因为窗可能**跨过环尾**(start + i > DEPTH - 1 时回绕到 0); + * 环形缓冲在内存里首尾并不相连,故不能用一次 memcpy 拷完 —— 要么先判边界再 + * 分两段拷,要么像这里逐点取模。DEPTH 只有 40,逐点拷贝比"两段 + 分支"更短更稳。 + * 拷贝顺序即时间顺序:out[0] 最老、out[n-1] 最新。 */ for (i = 0u; i < (uint16_t)n; i++) { out[i] = s_ring[ch][(uint16_t)((start + i) % SAMPLE_RING_DEPTH)]; } diff --git a/App/algo/sample_ring.h b/App/algo/sample_ring.h index 2b4301ed..df84572c 100644 --- a/App/algo/sample_ring.h +++ b/App/algo/sample_ring.h @@ -59,6 +59,9 @@ int sample_ring_push_frame(const float *values, uint8_t n); * 返 回 值:0 - 成功;-1 - 参数错;-2 - 已累积点数不足 n * 限定条件:无 * 函数说明:out[n-1] 为最新一点。相量层以本函数取"以当前时刻为终止的一周波"。 + * ⚠ 仅 adcTask 上下文调用(写者与读者同任务串行,本层不加锁); + * 环内布局、窗首下标推导与"先加 DEPTH 再减 n"的理由见 + * `App/algo/sample_ring.c` 同名函数的注释。 */ int sample_ring_window(adc_semantic_t ch, uint8_t n, float *out); From c377fc8c157662a884a9eb65cead3173932eeb8e Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Thu, 29 Oct 2026 17:15:48 +0800 Subject: [PATCH 2/5] =?UTF-8?q?docs(algo):=20=E9=87=87=E6=A0=B7=E7=8E=AF?= =?UTF-8?q?=E7=BC=93=E5=86=B2=E8=A1=A5=E3=80=8C=E4=B8=BA=E4=BB=80=E4=B9=88?= =?UTF-8?q?=E9=9C=80=E8=A6=81/=E8=AE=BE=E8=AE=A1=E6=80=9D=E6=83=B3?= =?UTF-8?q?=E3=80=8D=E4=B8=A4=E8=8A=82=E4=B8=8E=E4=B8=A4=E5=BC=A0=E9=85=8D?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 采集与算法层设计说明第 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 新漂移。 --- docs/images/采样环缓冲_为什么需要.svg | 56 ++++ docs/images/采样环缓冲_环形布局.svg | 115 ++++++++ docs/算法/采集与算法层设计说明.md | 73 ++++- tools/arch_diagram/README.md | 2 + .../gen_sample_ring_layout_svg.py | 276 ++++++++++++++++++ tools/arch_diagram/gen_sample_ring_why_svg.py | 270 +++++++++++++++++ 6 files changed, 788 insertions(+), 4 deletions(-) create mode 100644 docs/images/采样环缓冲_为什么需要.svg create mode 100644 docs/images/采样环缓冲_环形布局.svg create mode 100644 tools/arch_diagram/gen_sample_ring_layout_svg.py create mode 100644 tools/arch_diagram/gen_sample_ring_why_svg.py diff --git a/docs/images/采样环缓冲_为什么需要.svg b/docs/images/采样环缓冲_为什么需要.svg new file mode 100644 index 00000000..373d1c3c --- /dev/null +++ b/docs/images/采样环缓冲_为什么需要.svg @@ -0,0 +1,56 @@ + + +上游只交最新一帧,相量却要一整周波 —— 采样环补的就是这 19 点 +上游(adc_input)每 1 ms 交付一次,且只交本拍;旧值被覆盖、不保留历史。下游相量要的是「以当前时刻为终止的 20 点滑窗」。 +本层不改采集、不改算法,只把「覆盖写的最新值」转成「可回溯的滑动窗」——三个消费者共用同一份。 + +① 上游交付物:1 kHz 覆盖写,每拍只交一帧 +20 个刻度 = 一周波 20 点(1 kHz @ 50 Hz);上游只把最后一个点交出来 + + + + + + + + + + + + + + + + + + + + + + +相量要的 20 点窗(一整周波) + +最新一帧(本拍) + +k−19 +k−9 +k +这 19 个点上游不保留 —— 不缓冲就永远取不到 +② 下游需求:三个消费者,必须看同一份历史 +共用同一份环 = 同一拍只有一个窗 —— 口径不会各自漂移 + +phasor_run() +每 1 ms 要一整周波 20 点,算全周傅氏相量(幅值 + / 相位)。窗短一点就不是「全周」,谐波抑制立刻 +失效。 + +freq_run() +要相邻两拍相量的相位差 → 反推频率。它与相量必须 +落在同一个窗上,否则相位差里混进窗长误差。 + +meas_tick() +每 200 ms 发布遥测快照。快照里的相量 / 频率 / + 品质位都取自同一次窗,不允许各取各的。 + +缓冲只解决「取得到历史」,不解决「这些点是否同刻」:同刻性归上游 adc_input 负责,本层只按时序存 / 取,不做任何跨通道运算。 + diff --git a/docs/images/采样环缓冲_环形布局.svg b/docs/images/采样环缓冲_环形布局.svg new file mode 100644 index 00000000..758217e8 --- /dev/null +++ b/docs/images/采样环缓冲_环形布局.svg @@ -0,0 +1,115 @@ + + +40 格环:写指针指「下一格」,窗永远紧贴它身后 +DEPTH = 2 周波 × 20 点 = 40;窗长 n 取 1~40,取的是 [head−n, head−1] 这 n 格。 +图例:蓝 = 本窗 20 点,灰 = 更旧的 10 点,琥珀 = 最旧的 10 点(下一批被覆盖)。 +窗首 start = (head + 40 − n) % 40 —— 先加 40 再减,减法就不落到负数;每写一格整窗右移一格。 + +0 + +1 + +2 + +3 + +4 + +5 + +6 + +7 + +8 + +9 + +10 + +11 + +12 + +13 + +14 + +15 + +16 + +17 + +18 + +19 + +20 + +21 + +22 + +23 + +24 + +25 + +26 + +27 + +28 + +29 + +30 + +31 + +32 + +33 + +34 + +35 + +36 + +37 + +38 + +39 + + +start(最老点) + + +head−1(最新点) + + +head(下一拍写这里) + + +写满后回绕:39 的下一格是 0 —— 窗可能跨过环尾,所以取数必须逐点取模 + +整帧写完才推进 head +9 路语义值一次写齐、再动指针。单帧内不存在「半 +新半旧」,消费侧取到的窗永远落在整帧边界上。 + +只存不算是分层契约 +同刻性归上游 adc_input 负责;本层不假设、不校 +正,也不做任何跨通道运算——所以它能脱离硬件做主 +机侧单测。 + +容量取 2 周波 +1 周波就够算全周傅氏;另 1 周波是给 1.5~2 周波 +长窗(抗衰减直流)留的余量,改容量只动一个宏。 + +n = 40 时 start == head:环满时「下一拍要覆盖的那一格」恰好就是最老点 —— 不是特例,是唯一自洽解。同理 n = 20 时窗正好是 head 身后的 20 格,不 +多不少。 + diff --git a/docs/算法/采集与算法层设计说明.md b/docs/算法/采集与算法层设计说明.md index 0e3658e2..08d3301d 100644 --- a/docs/算法/采集与算法层设计说明.md +++ b/docs/算法/采集与算法层设计说明.md @@ -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` 后逐点拷贝,跨任务读活缓冲会取到撕裂的窗。 --- diff --git a/tools/arch_diagram/README.md b/tools/arch_diagram/README.md index 730aa19b..e75927ab 100644 --- a/tools/arch_diagram/README.md +++ b/tools/arch_diagram/README.md @@ -21,6 +21,8 @@ | `gen_data_trust_ladder_svg.py` | 数据可信台阶图 | 采集数据可信度的五级台阶:现在踩在哪一级、下一级挡什么 | | `gen_data_trust_dc_ac_svg.py` | ADC 直流标定与交流验证图 | 上排直流注入链、下排信号发生器正弦验证,中间列只有正弦能证的六件事 | | `gen_wave_record_flow_svg.py` | 故障录波数据流图 | 录波段数据的四个所有者与四条「不许」:谁独占写、谁只能读、标志怎么过界 | +| `gen_sample_ring_why_svg.py` | 采样环缓冲_为什么需要 | 上游 1 kHz 覆盖写只交最新一帧 vs 下游要 20 点滑窗:缺口 19 点与三个消费者 | +| `gen_sample_ring_layout_svg.py` | 采样环缓冲_环形布局 | 40 格环里哪 20 格是本窗、写指针指「下一格」、回绕与三条设计不变量 | ## 使用方式 diff --git a/tools/arch_diagram/gen_sample_ring_layout_svg.py b/tools/arch_diagram/gen_sample_ring_layout_svg.py new file mode 100644 index 00000000..2c5eefad --- /dev/null +++ b/tools/arch_diagram/gen_sample_ring_layout_svg.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""生成 docs/images/采样环缓冲_环形布局.svg —— 采样环缓冲的设计思想落在图上的三件事: +写指针指「下一格」而不是「最后一格」、窗永远紧贴指针身后、整窗随每次写入右移一格。 + +为什么不用 Mermaid:本图要读的是"40 个格子里哪 20 格是窗、指针停在第几格"—— +Mermaid 画不出等宽格子与指针位置,盒子宽度随文本变,"窗贴着指针"这个关系就没了。 + +用法(仓库根目录,任意 python3): + python tools/arch_diagram/gen_sample_ring_layout_svg.py +输出: + docs/images/采样环缓冲_环形布局.svg +改内容:容量(周波数 / 每周波点数)或窗长变了才重画;调坐标 / 文案编辑下方常量后重跑。 +""" +import html +import os +import sys +import xml.etree.ElementTree as ET + + +def emit(s): + """控制台打印按目标编码替换,绝不因 GBK 终端遇上 ²/− 之类字符而崩 + (generated-fresh 钩子会把本脚本拷进临时目录重跑,崩了就是一片假红)。""" + enc = getattr(sys.stdout, "encoding", None) or "ascii" + sys.stdout.write(s.encode(enc, "replace").decode(enc, "replace") + "\n") + + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.path.join(ROOT, "docs", "images", "采样环缓冲_环形布局.svg") + +# ----------------------------- 画布与尺寸 ----------------------------- +W = 1060 +H = 580 +PAD = 46 +RIGHT = W - PAD + +FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif" + +TITLE_FONT = 21 +SUB_FONT = 13 +CARD_FONT = 13.5 +BODY_FONT = 11.5 +TINY_FONT = 11 +SLOT_FONT = 9 + +INK = "#0f172a" +LINE = "#334155" +MUTED = "#64748b" +FAINT = "#94a3b8" +RULE = "#cbd5e1" +WHITE = "#ffffff" + +C_BLUE = "#2563eb" +BG_BLUE = "#eff6ff" +C_AMBER = "#d97706" +BG_AMBER = "#fffbeb" +C_GREEN = "#16a34a" +BG_GREEN = "#f0fdf4" +C_ROSE = "#e11d48" +BG_GREY = "#f1f5f9" + +# ----------------------------- 数据(改这里) ----------------------------- +DEPTH = 40 # SAMPLE_RING_DEPTH +WIN_N = 20 # 示例窗长(一周波) +HEAD = 30 # 示例写指针位置 +START = (HEAD + DEPTH - WIN_N) % DEPTH # = 10 + +TITLE = "40 格环:写指针指「下一格」,窗永远紧贴它身后" +SUB1 = "DEPTH = 2 周波 × 20 点 = 40;窗长 n 取 1~40,取的是 [head−n, head−1] 这 n 格。" +SUB2 = "图例:蓝 = 本窗 20 点,灰 = 更旧的 10 点,琥珀 = 最旧的 10 点(下一批被覆盖)。" +SUB3 = ("窗首 start = (head + 40 − n) % 40 —— 先加 40 再减,减法就不落到负数;" + "每写一格整窗右移一格。") + +MARK_START = "start(最老点)" +MARK_NEW = "head−1(最新点)" +MARK_HEAD = "head(下一拍写这里)" +WRAP_NOTE = "写满后回绕:39 的下一格是 0 —— 窗可能跨过环尾,所以取数必须逐点取模" + +CARDS = [ + ("整帧写完才推进 head", C_GREEN, BG_GREEN, + "9 路语义值一次写齐、再动指针。单帧内不存在「半新半旧」," + "消费侧取到的窗永远落在整帧边界上。"), + ("只存不算是分层契约", C_BLUE, BG_BLUE, + "同刻性归上游 adc_input 负责;本层不假设、不校正,也不做任何跨通道运算——" + "所以它能脱离硬件做主机侧单测。"), + ("容量取 2 周波", C_AMBER, BG_AMBER, + "1 周波就够算全周傅氏;另 1 周波是给 1.5~2 周波长窗(抗衰减直流)留的余量," + "改容量只动一个宏。"), +] +FOOTNOTE = ("n = 40 时 start == head:环满时「下一拍要覆盖的那一格」恰好就是最老点 —— 不是特例,是唯一自洽解。" + "同理 n = 20 时窗正好是 head 身后的 20 格,不多不少。") + +# ----------------------------- 布局 ----------------------------- +SLOT_W = 22 +SLOT_GAP = 2 +SLOT_H = 54 +SLOT_X0 = PAD +ROW_Y = 200 + +MARK_Y = 176 # 标记文字基线 +STEM_Y1 = 184 # 标记引线 +STEM_Y2 = ROW_Y + +WRAP_Y = 286 +WRAP_TEXT_Y = 308 + +CARD_TOP = 340 +CARD_W = 300 +CARD_GAP = 19 +CARD_H = 120 + + +# ----------------------------- 工具函数 ----------------------------- +def esc(s): + return html.escape(s, quote=True) + + +def text_w(s, fs): + """粗略估算文本像素宽:CJK/全角 ≈ 1.0em,其余 ≈ 0.58em(宁可偏大)。""" + return sum(fs * (1.0 if ord(ch) >= 0x2E80 else 0.58) for ch in s) + + +OVERFLOW = [] + + +def check_fit(s, fs, avail, where): + if text_w(s, fs) > avail: + OVERFLOW.append("%s: %.0f > %.0f [%s]" % (where, text_w(s, fs), avail, s)) + + +def txt(x, y, s, fs, fill, weight=400, anchor=None): + a = "" if anchor is None else ' text-anchor="%s"' % anchor + w = "" if weight == 400 else ' font-weight="%d"' % weight + return ('%s' + % (x, y, fs, a, w, fill, esc(s))) + + +def line(x1, y1, x2, y2, color=LINE, width=2.0, dash=None): + d = "" if dash is None else ' stroke-dasharray="%s"' % dash + return ('' + % (x1, y1, x2, y2, color, width, d)) + + +def rect(x, y, w, h, fill, stroke=None, rx=8, sw=1.6, dash=None): + s = "" if stroke is None else ' stroke="%s" stroke-width="%g"' % (stroke, sw) + d = "" if dash is None else ' stroke-dasharray="%s"' % dash + return '' % ( + x, y, w, h, rx, fill, s, d) + + +def tri(points, fill): + return '' % ( + " ".join("%.1f,%.1f" % (px, py) for px, py in points), fill) + + +def wrap(s, fs, avail, max_lines, where=""): + """按估算宽度折行(CJK 逐字可断)。超出 max_lines 用省略号收尾—— + 宁可提示文案太长,也不让字溢出盒子。""" + if text_w(s, fs) <= avail: + return [s] + lines = [] + cur = "" + for ch in s: + if text_w(cur + ch, fs) > avail: + lines.append(cur) + cur = ch + else: + cur += ch + if cur: + lines.append(cur) + if len(lines) > max_lines: + OVERFLOW.append("%s: 折行 %d > %d" % (where, len(lines), max_lines)) + lines = lines[:max_lines] + lines[max_lines - 1] = lines[max_lines - 1][:-1] + "…" + return lines + + +def slot_x(i): + return SLOT_X0 + i * (SLOT_W + SLOT_GAP) + + +def slot_c(i): + """第 i 格的横向中心。""" + return slot_x(i) + SLOT_W / 2.0 + + +# ----------------------------- 生成 ----------------------------- +def build(): + out = [] + add = out.append + + avail = RIGHT - PAD + for s, fs, where in ((TITLE, TITLE_FONT, "title"), (SUB1, SUB_FONT, "sub1"), + (SUB2, SUB_FONT, "sub2"), (SUB3, SUB_FONT, "sub3")): + check_fit(s, fs, avail, where) + if OVERFLOW: + for o in OVERFLOW: + emit("OVERFLOW WARN " + o) + + add('' % (W, H, WHITE)) + + # ---- 标题区 ---- + add(txt(PAD, 42, TITLE, TITLE_FONT, INK, weight=700)) + add(txt(PAD, 68, SUB1, SUB_FONT, MUTED)) + add(txt(PAD, 88, SUB2, SUB_FONT, MUTED)) + add(txt(PAD, 108, SUB3, SUB_FONT, MUTED)) + + # ---- 40 个格子 ---- + win_lo = START + win_hi = HEAD - 1 + for i in range(DEPTH): + x = slot_x(i) + if win_lo <= i <= win_hi: + fill, stroke, sw = BG_BLUE, C_BLUE, 2.0 + elif i >= HEAD: + fill, stroke, sw = BG_AMBER, C_AMBER, 1.4 + else: + fill, stroke, sw = BG_GREY, FAINT, 1.2 + add(rect(x, ROW_Y, SLOT_W, SLOT_H, fill, stroke, rx=3, sw=sw)) + add(txt(slot_c(i), ROW_Y + SLOT_H / 2.0 + 3.5, str(i), SLOT_FONT, MUTED, anchor="middle")) + + # ---- 三个关键位置标记 ---- + marks = [ + (slot_c(START), MARK_START, C_BLUE, "start"), + (slot_c(HEAD - 1), MARK_NEW, C_BLUE, "end"), + (slot_c(HEAD), MARK_HEAD, C_AMBER, "start"), + ] + for cx, label, color, anchor in marks: + add(line(cx, STEM_Y1, cx, STEM_Y2 - 2, color, 1.4)) + add(tri([(cx, STEM_Y2), (cx - 4.5, STEM_Y2 - 8), (cx + 4.5, STEM_Y2 - 8)], color)) + check_fit(label, TINY_FONT, 240, "mark %s" % label) + add(txt(cx, MARK_Y, label, TINY_FONT, color, weight=700, anchor=anchor)) + + # ---- 回绕提示:39 的下一格是 0 ---- + x_from = slot_c(DEPTH - 1) + x_to = slot_c(0) + add(line(x_from, WRAP_Y, x_to + 8, WRAP_Y, C_ROSE, 1.6, dash="7 5")) + add(tri([(x_to, WRAP_Y), (x_to + 9, WRAP_Y - 4.5), (x_to + 9, WRAP_Y + 4.5)], C_ROSE)) + for ln in wrap(WRAP_NOTE, TINY_FONT, avail, 1, "wrap note"): + add(txt(PAD, WRAP_TEXT_Y, ln, TINY_FONT, C_ROSE, weight=700)) + + # ---- 三条设计不变量 ---- + inner = CARD_W - 24 + for i, (name, c, bg, body) in enumerate(CARDS): + x = PAD + i * (CARD_W + CARD_GAP) + add(rect(x, CARD_TOP, CARD_W, CARD_H, bg, c, rx=8, sw=1.8)) + check_fit(name, CARD_FONT, inner, "card %d name" % i) + add(txt(x + 12, CARD_TOP + 24, name, CARD_FONT, INK, weight=700)) + y = CARD_TOP + 46 + for ln in wrap(body, BODY_FONT, inner, 5, "card %d body" % i): + add(txt(x + 12, y, ln, BODY_FONT, MUTED)) + y += 15 + + # ---- 底部说明 ---- + fy = CARD_TOP + CARD_H + 30 + add(line(PAD, fy, RIGHT, fy, RULE, 1.0)) + for i, ln in enumerate(wrap(FOOTNOTE, SUB_FONT, avail, 2, "foot wrap")): + add(txt(PAD, fy + 24 + i * 19, ln, SUB_FONT, MUTED)) + + svg = ('\n%s\n\n' + % (W, H, W, H, FONT, "\n".join(out))) + + d = os.path.dirname(OUT) + if not os.path.isdir(d): + os.makedirs(d) + with open(OUT, "w", encoding="utf-8", newline="\n") as fp: + fp.write(svg) + ET.fromstring(svg) + emit("OK %d bytes, canvas %dx%d -> %s" % (len(svg.encode("utf-8")), W, H, OUT)) + if OVERFLOW: + emit("!! %d overflow warnings" % len(OVERFLOW)) + + +if __name__ == "__main__": + build() diff --git a/tools/arch_diagram/gen_sample_ring_why_svg.py b/tools/arch_diagram/gen_sample_ring_why_svg.py new file mode 100644 index 00000000..cfbc028f --- /dev/null +++ b/tools/arch_diagram/gen_sample_ring_why_svg.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""生成 docs/images/采样环缓冲_为什么需要.svg —— 采样环缓冲存在的理由: +上游每拍只交"最新一帧"(覆盖写),下游相量却要"以当前时刻为终止的一整周波"(20 点滑窗), +中间那 19 个历史点没有任何一层替它保留,本层就是补这个缺口的地方。 + +为什么不用 Mermaid:本图要读的是"同一条时间轴上,20 个刻度里只有 1 个点被交付"这个 +**数量差**——Mermaid 的节点按文本自适应宽、节点之间只有连线,表达不出"哪一段拿不到"。 +谁调用谁的拓扑另有 `docs/算法/采集与算法层设计说明.md` 第 2 节的 Mermaid 流程图,本图不重复。 + +用法(仓库根目录,任意 python3): + python tools/arch_diagram/gen_sample_ring_why_svg.py +输出: + docs/images/采样环缓冲_为什么需要.svg +改内容:口径(周波点数 / 消费者清单)变了才重画;调坐标 / 文案编辑下方常量后重跑。 +""" +import html +import os +import sys +import xml.etree.ElementTree as ET + + +def emit(s): + """控制台打印按目标编码替换,绝不因 GBK 终端遇上 ²/− 之类字符而崩 + (generated-fresh 钩子会把本脚本拷进临时目录重跑,崩了就是一片假红)。""" + enc = getattr(sys.stdout, "encoding", None) or "ascii" + sys.stdout.write(s.encode(enc, "replace").decode(enc, "replace") + "\n") + + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.path.join(ROOT, "docs", "images", "采样环缓冲_为什么需要.svg") + +# ----------------------------- 画布与尺寸 ----------------------------- +W = 1060 +H = 600 +PAD = 46 +RIGHT = W - PAD + +FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif" + +TITLE_FONT = 21 +SUB_FONT = 13 +PANEL_FONT = 14.5 +CARD_FONT = 13.5 +BODY_FONT = 11.5 +TINY_FONT = 11 + +INK = "#0f172a" +LINE = "#334155" +MUTED = "#64748b" +FAINT = "#94a3b8" +RULE = "#cbd5e1" +WHITE = "#ffffff" + +C_BLUE = "#2563eb" +BG_BLUE = "#eff6ff" +C_ROSE = "#e11d48" +BG_ROSE = "#fff1f2" +C_GREEN = "#16a34a" +BG_GREEN = "#f0fdf4" +C_AMBER = "#d97706" +BG_AMBER = "#fffbeb" + +# ----------------------------- 数据(改这里) ----------------------------- +TITLE = "上游只交最新一帧,相量却要一整周波 —— 采样环补的就是这 19 点" +SUB1 = ("上游(adc_input)每 1 ms 交付一次,且只交本拍;旧值被覆盖、不保留历史。" + "下游相量要的是「以当前时刻为终止的 20 点滑窗」。") +SUB2 = "本层不改采集、不改算法,只把「覆盖写的最新值」转成「可回溯的滑动窗」——三个消费者共用同一份。" + +PANEL_A = "① 上游交付物:1 kHz 覆盖写,每拍只交一帧" +PANEL_A_NOTE = "20 个刻度 = 一周波 20 点(1 kHz @ 50 Hz);上游只把最后一个点交出来" +BRACKET = "相量要的 20 点窗(一整周波)" +GAP_NOTE = "这 19 个点上游不保留 —— 不缓冲就永远取不到" +NOW_NOTE = "最新一帧(本拍)" + +PANEL_B = "② 下游需求:三个消费者,必须看同一份历史" +CARDS = [ + ("phasor_run()", C_BLUE, BG_BLUE, + "每 1 ms 要一整周波 20 点,算全周傅氏相量(幅值 / 相位)。" + "窗短一点就不是「全周」,谐波抑制立刻失效。"), + ("freq_run()", C_GREEN, BG_GREEN, + "要相邻两拍相量的相位差 → 反推频率。它与相量必须落在同一个窗上," + "否则相位差里混进窗长误差。"), + ("meas_tick()", C_AMBER, BG_AMBER, + "每 200 ms 发布遥测快照。快照里的相量 / 频率 / 品质位都取自同一次窗," + "不允许各取各的。"), +] +BAND_NOTE = "共用同一份环 = 同一拍只有一个窗 —— 口径不会各自漂移" +FOOTNOTE = ("缓冲只解决「取得到历史」,不解决「这些点是否同刻」:同刻性归上游 adc_input 负责," + "本层只按时序存 / 取,不做任何跨通道运算。") + +# ----------------------------- 布局 ----------------------------- +PANEL_A_TOP = 112 +PANEL_A_BOT = 300 +AXIS_Y = 236 +TICK_X0 = 74 +TICK_STEP = 38 +N_TICKS = 20 # 一周波 20 点 +TICK_H = 22 + +BRACKET_TOP = 192 +BRACKET_H = 20 + +PANEL_B_TOP = 330 +CARD_W = 300 +CARD_GAP = 19 +CARD_H = 112 +CARD_X0 = PAD + + +# ----------------------------- 工具函数 ----------------------------- +def esc(s): + return html.escape(s, quote=True) + + +def text_w(s, fs): + """粗略估算文本像素宽:CJK/全角 ≈ 1.0em,其余 ≈ 0.58em(宁可偏大)。""" + return sum(fs * (1.0 if ord(ch) >= 0x2E80 else 0.58) for ch in s) + + +OVERFLOW = [] + + +def check_fit(s, fs, avail, where): + if text_w(s, fs) > avail: + OVERFLOW.append("%s: %.0f > %.0f [%s]" % (where, text_w(s, fs), avail, s)) + + +def txt(x, y, s, fs, fill, weight=400, anchor=None): + a = "" if anchor is None else ' text-anchor="%s"' % anchor + w = "" if weight == 400 else ' font-weight="%d"' % weight + return ('%s' + % (x, y, fs, a, w, fill, esc(s))) + + +def line(x1, y1, x2, y2, color=LINE, width=2.0, dash=None): + d = "" if dash is None else ' stroke-dasharray="%s"' % dash + return ('' + % (x1, y1, x2, y2, color, width, d)) + + +def rect(x, y, w, h, fill, stroke=None, rx=8, sw=1.6, dash=None): + s = "" if stroke is None else ' stroke="%s" stroke-width="%g"' % (stroke, sw) + d = "" if dash is None else ' stroke-dasharray="%s"' % dash + return '' % ( + x, y, w, h, rx, fill, s, d) + + +def circle(cx, cy, r, fill, stroke=None, sw=1.5): + s = "" if stroke is None else ' stroke="%s" stroke-width="%g"' % (stroke, sw) + return '' % (cx, cy, r, fill, s) + + +def wrap(s, fs, avail, max_lines, where=""): + """按估算宽度折行(CJK 逐字可断)。超出 max_lines 用省略号收尾—— + 宁可提示文案太长,也不让字溢出盒子。""" + if text_w(s, fs) <= avail: + return [s] + lines = [] + cur = "" + for ch in s: + if text_w(cur + ch, fs) > avail: + lines.append(cur) + cur = ch + else: + cur += ch + if cur: + lines.append(cur) + if len(lines) > max_lines: + OVERFLOW.append("%s: 折行 %d > %d" % (where, len(lines), max_lines)) + lines = lines[:max_lines] + lines[max_lines - 1] = lines[max_lines - 1][:-1] + "…" + return lines + + +# ----------------------------- 生成 ----------------------------- +def build(): + out = [] + add = out.append + + avail = RIGHT - PAD + for s, fs, where in ((TITLE, TITLE_FONT, "title"), (SUB1, SUB_FONT, "sub1"), + (SUB2, SUB_FONT, "sub2")): + check_fit(s, fs, avail, where) + if OVERFLOW: + for o in OVERFLOW: + emit("OVERFLOW WARN " + o) + + add('' % (W, H, WHITE)) + + # ---- 标题区 ---- + add(txt(PAD, 42, TITLE, TITLE_FONT, INK, weight=700)) + add(txt(PAD, 68, SUB1, SUB_FONT, MUTED)) + add(txt(PAD, 88, SUB2, SUB_FONT, MUTED)) + + # ---- 面板 A:上游覆盖写 vs 下游要 20 点 ---- + add(rect(PAD - 12, PANEL_A_TOP, avail + 24, PANEL_A_BOT - PANEL_A_TOP, BG_ROSE, None, rx=12)) + check_fit(PANEL_A, PANEL_FONT, avail - 24, "panel A") + add(txt(PAD, PANEL_A_TOP + 24, PANEL_A, PANEL_FONT, C_ROSE, weight=700)) + add(txt(PAD, PANEL_A_TOP + 44, PANEL_A_NOTE, TINY_FONT, MUTED)) + + # 时间轴 + add(line(TICK_X0 - 12, AXIS_Y, TICK_X0 + (N_TICKS - 1) * TICK_STEP + 10, AXIS_Y, LINE, 2.0)) + xs = [] + for i in range(N_TICKS): + x = TICK_X0 + i * TICK_STEP + xs.append(x) + # 前 19 点是"上游不保留"的,画成浅灰;最后一点是本拍交付的 + c = C_BLUE if i == N_TICKS - 1 else FAINT + add(line(x, AXIS_Y, x, AXIS_Y - TICK_H, c, 2.0 if i == N_TICKS - 1 else 1.4)) + + # 20 点窗的括号 + bx1 = xs[0] - 9 + bx2 = xs[-1] + 9 + add(rect(bx1, BRACKET_TOP, bx2 - bx1, BRACKET_H, "none", C_ROSE, rx=6, sw=1.8, dash="7 5")) + add(txt((bx1 + bx2) / 2.0, BRACKET_TOP + 15, BRACKET, BODY_FONT, C_ROSE, weight=700, anchor="middle")) + + # 本拍交付点 + add(circle(xs[-1], AXIS_Y, 5.5, C_BLUE)) + add(txt(xs[-1] + 12, AXIS_Y + 5, NOW_NOTE, TINY_FONT, C_BLUE, weight=700)) + + # 轴下的缺口条:只覆盖前 19 点,直观标出"这 19 个点上游没留" + add(rect(xs[0] - 9, AXIS_Y + 8, xs[-2] - xs[0] + 18, 5, C_ROSE, None, rx=2.5)) + + # 轴下刻度标注 + add(txt(xs[0], AXIS_Y + 30, "k−19", TINY_FONT, MUTED, anchor="middle")) + add(txt(xs[0] + 10 * TICK_STEP, AXIS_Y + 30, "k−9", TINY_FONT, MUTED, anchor="middle")) + add(txt(xs[-1], AXIS_Y + 30, "k", TINY_FONT, MUTED, anchor="middle")) + add(txt(PAD, AXIS_Y + 52, GAP_NOTE, TINY_FONT, C_ROSE, weight=700)) + + # ---- 面板 B:三个消费者 ---- + check_fit(PANEL_B, PANEL_FONT, avail, "panel B") + add(txt(PAD, PANEL_B_TOP + 4, PANEL_B, PANEL_FONT, C_BLUE, weight=700)) + add(txt(PAD, PANEL_B_TOP + 24, BAND_NOTE, TINY_FONT, MUTED)) + + card_y = PANEL_B_TOP + 40 + inner = CARD_W - 24 + for i, (name, c, bg, body) in enumerate(CARDS): + x = CARD_X0 + i * (CARD_W + CARD_GAP) + add(rect(x, card_y, CARD_W, CARD_H, bg, c, rx=8, sw=1.8)) + check_fit(name, CARD_FONT, inner, "card %d name" % i) + add(txt(x + 12, card_y + 24, name, CARD_FONT, INK, weight=700)) + y = card_y + 46 + for ln in wrap(body, BODY_FONT, inner, 5, "card %d body" % i): + add(txt(x + 12, y, ln, BODY_FONT, MUTED)) + y += 15 + + # ---- 底部说明 ---- + fy = card_y + CARD_H + 34 + add(line(PAD, fy, RIGHT, fy, RULE, 1.0)) + for i, ln in enumerate(wrap(FOOTNOTE, SUB_FONT, avail, 2, "foot wrap")): + add(txt(PAD, fy + 24 + i * 19, ln, SUB_FONT, MUTED)) + + svg = ('\n%s\n\n' + % (W, H, W, H, FONT, "\n".join(out))) + + d = os.path.dirname(OUT) + if not os.path.isdir(d): + os.makedirs(d) + with open(OUT, "w", encoding="utf-8", newline="\n") as fp: + fp.write(svg) + ET.fromstring(svg) + emit("OK %d bytes, canvas %dx%d -> %s" % (len(svg.encode("utf-8")), W, H, OUT)) + if OVERFLOW: + emit("!! %d overflow warnings" % len(OVERFLOW)) + + +if __name__ == "__main__": + build() From 929cacbd72d2095e8542e60a5310ca1548c0bc36 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Thu, 29 Oct 2026 17:19:42 +0800 Subject: [PATCH 3/5] =?UTF-8?q?docs(algo):=20sample=5Fring=5Fwindow=20?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E6=94=B6=E6=95=9B=EF=BC=8C=E9=95=BF=E6=8E=A8?= =?UTF-8?q?=E5=AF=BC=E7=A7=BB=E5=88=B0=E8=AE=BE=E8=AE=A1=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设计说明第 5 节已详述「为什么需要 / 设计思想 / start 推导」,函数内不再复述: 环内布局示意、两条坑的逐句论证、取值范围推导全部删去,改为一句结论 + 指针 (docs/算法/采集与算法层设计说明.md §5 与 §5.3)。 - 留在代码里的都是「就地可判」的落地要点:通道号为何先转 unsigned、 n 的两条拒收理由、-2 与 -1 的语义区分、先加 DEPTH 的同余理由、逐点取模。 - .h 的指针从 .c 改指设计说明,避免同一事实两处详述。 - 净减 81 行;去注释后代码 token 与 HEAD 逐字相同;@build 0 错 0 警。 --- App/algo/sample_ring.c | 93 +++++++----------------------------------- App/algo/sample_ring.h | 4 +- 2 files changed, 16 insertions(+), 81 deletions(-) diff --git a/App/algo/sample_ring.c b/App/algo/sample_ring.c index 02455d7b..ec52be45 100644 --- a/App/algo/sample_ring.c +++ b/App/algo/sample_ring.c @@ -77,102 +77,37 @@ int sample_ring_push_frame(const float *values, uint8_t n) { * n - 取点数(1 .. SAMPLE_RING_DEPTH) * out - 输出缓冲(调用方保证长度 >= n,本层不校验容量) * 返 回 值:0 - 成功;-1 - 参数错;-2 - 已累积点数不足 n - * 限定条件:**仅 adcTask 上下文**调用,且须在同拍 sample_ring_push_frame() 之后。 - * 全仓唯一写者 = adcTask 的压环语句,读者(phasor_run / meas_tick)与之 - * 同任务串行执行,写读之间不存在并发 —— 故本函数**不加锁、不用 volatile、 - * 不关中断**。若将来把读者搬到别的任务,必须先在本层补双区/序号快照, - * 否则会读到"半新半旧"的窗(旧点已被覆盖、新点尚未写入)。 - * 函数说明:语义 = "以当前最新一点为终点、向前回溯 n 点"的一周波窗。 - * out[0] 是最老一点、out[n-1] 是最新一点(时间序 旧 → 新), - * 与 phasor_run() 的傅氏求和方向一致,调用方不必再反转。 - * 环内布局与窗首下标的推导见函数体内的分步注释。 + * 限定条件:仅 adcTask 上下文(写者与读者同任务串行,故不加锁、不用 volatile); + * 须在同拍 sample_ring_push_frame() 之后调用。 + * 函数说明:以最新一点为终点、向前回溯 n 点;out[0] 最老、out[n-1] 最新, + * 与 phasor_run() 的求和方向一致。 + * 为什么需要这一层、环内布局与窗首下标的推导 → + * docs/算法/采集与算法层设计说明.md §5(本文件只留落地要点)。 */ int sample_ring_window(adc_semantic_t ch, uint8_t n, float *out) { uint16_t start; uint16_t i; - /* ── 环内布局(以 DEPTH = 40、n = 20、s_head = 30 为例,此时 start = 10)── - * - * 下标 0 .. 9 | 10 ........ 29 | 30 .. 39 - * 内容 旧数据 | <-- 本窗 20 点 --> | 更旧(下一拍起被覆盖) - * - * 三个关键位置:start = 10(窗首/最老点)、s_head - 1 = 29(窗尾/最新点)、 - * s_head = 30(下一拍写入处,也是环满时最先被覆盖的那一点)。 - * - * ⚠ s_head 是**下一个待写入**的位置,不是最后写入的位置 —— 这一点是理解 - * 下面 start 公式的全部关键:最后写入的一点 = s_head - 1,于是 - * "以最新点为终点的 n 点窗" = 下标 [s_head-n, s_head-1],共 n 点; - * 窗首(最老点)就是 s_head - n。窗可能跨过环尾(start + i 越过 - * DEPTH - 1 时回绕到 0),故取数必须逐点取模。 */ - - /* 参数校验(通道号):adc_semantic_t 是枚举,C 标准允许其底层类型为有符号整型; - * 若调用方传负值/越界值,直接写 ch >= ADC_SEM_COUNT 会因整数提升变成有符号比较, - * 负值能"骗过"上界判断 → 索引到 s_ring 之外。故先转 unsigned int 再比: - * 负值转成极大无符号数,必然被拦下。(同时覆盖 out == NULL 的空指针。) */ + /* 通道号先转 unsigned 再比:枚举底层可为有符号,负值会骗过 ch >= COUNT 的判据。 */ if ((out == NULL) || ((unsigned int)ch >= (unsigned int)ADC_SEM_COUNT)) { return -1; } - /* 参数校验(取点数): - * n == 0 → 空窗无物理意义,且此时 start 会等于 s_head,返回"最新 0 点", - * 容易被调用方当成一次成功取窗 → 显式拒收; - * n > DEPTH → 一个环装不下连续 n 点,逐点回绕会把同一点重复读出, - * 拼出来的窗不再是真实时序(会伪造出"周期性"假象)→ 显式拒收。 */ + /* n = 0 是空窗;n > DEPTH 一个环装不下连续 n 点(回绕会把同一点重复读出)。 */ if ((n == 0u) || (n > SAMPLE_RING_DEPTH)) { return -1; } - /* 数据够不够:s_filled 饱和于 DEPTH(见 sample_ring_push_frame),故对 - * n <= DEPTH 而言,本判据等价于"自 init 起累计入环帧数 >= n"。 - * 返回 -2 而非 -1 是有意区分:-2 表示"再等几拍自然会好",调用方应跳过本拍, - * 不应把它当故障上报(首周波启动期必然出现)。 */ + /* -2 = 窗还没攒够,调用方跳过本拍即可(首周波启动期必然出现),不是故障。 */ if (s_filled < (uint16_t)n) { return -2; } - /* ── 窗首(最老点)下标:start = (s_head - n) mod DEPTH 的安全写法 ────── - * - * 数学目标很直白:最老点 = 最新点(s_head-1) 往前退 n-1 步 = s_head - n, - * 因为环是循环的,结果要落在 [0, DEPTH - 1],所以取模。但 C 里**不能**直接 - * 写成 (s_head - n) % DEPTH —— 有两个独立的坑,任一个都会让整窗错位: - * - * 坑 1(16 位无符号下溢后再取模,不等价): - * 若写成 (uint16_t)(s_head - n) % DEPTH,s_head < n 时减法先回绕成 - * 65536 - (n - s_head)。而 (x mod 65536) mod 40 **不等于** x mod 40 - * —— 65536 只含因子 2,不是 40(= 8 × 5)的整数倍,模运算不满足这种 - * "分两步取模"的分解。反例:s_head = 3、n = 20,正确 start = 23, - * 而回绕后得 65519,65519 % 40 = 39 → 读到的不是窗首而是环尾附近, - * 整个窗错位(表面现象是相量相位乱跳,极易误判成"采样通道接反")。 - * - * 坑 2(有符号负取模): - * 在 32 位 int 平台,两个 uint16_t 参与运算前先做整数提升 → s_head - n - * 得到 int 且可能为负;C99 规定 % 向零截断,于是 (-17) % 40 == -17 - * (不是数学上的 23)。这个负值再强转 uint16_t 变成 65519, - * 拿去索引 s_ring[ch][65519] 就是**越界读**(可能读到相邻通道或别的全局量, - * 不报错、不 HardFault,数据看着"像那么回事")。 - * - * 修法:**先加一个 DEPTH,再减 n,最后取模**。三件事同时被解决: - * ① 同余不变:DEPTH ≡ 0 (mod DEPTH),加它不改变结果所在的同余类, - * 数学值与 (s_head - n) mod DEPTH 完全一致,不是"凑数"; - * ② 恒非负、不溢出:0 <= s_head <= DEPTH - 1、1 <= n <= DEPTH, - * 故 s_head + DEPTH - n ∈ [0, 2·DEPTH - 2] = [0, 78],恒非负且远小于 65535, - * 两种整数提升规则下都不会下溢/溢出,% 的结果就是唯一的 [0, DEPTH - 1] 解; - * ③ 顺带护住后面的 (start + i):start <= DEPTH - 1、i <= n-1, - * 加法最大 2·DEPTH - 2,同样不会在 16 位域回绕。 - * - * 边界自检:n == DEPTH 时 start == s_head —— 环满时"下一拍将被覆盖的那一点" - * 恰好就是最老点,这是唯一自洽的解,不是特例。环尚未写满(s_filled < DEPTH)时 - * 有 s_head == s_filled,故 start = s_filled - n >= 0,此时窗一定不跨环尾。 - * - * 性能备注:SAMPLE_RING_DEPTH = 40 不是 2 的幂,**不能**用 & (DEPTH - 1) 代替 %; - * Cortex-M4 有硬件 UDIV/SDIV,编译器会生成一次除法指令,本函数每拍最多 n 次 - * 取模(n <= 40),代价可接受。若将来把 DEPTH 改成 2 的幂,这里可换成位与, - * 但须同步核查全文件所有 % SAMPLE_RING_DEPTH 的使用点。 */ + /* 窗首 = s_head - n(s_head 是"下一待写"位置,故窗尾 = s_head - 1)。 + * 必须先加 DEPTH 再减:直接减在 s_head < n 时下溢或得负数,而 + * (x mod 65536) mod 40 不等于 x mod 40、C99 的负数取模也不是同余解。 + * 加 DEPTH 不改同余类,并把中间值锁在 [0, 2·DEPTH - 2]。详见设计说明 §5.3。 */ start = (uint16_t)((s_head + SAMPLE_RING_DEPTH - (uint16_t)n) % SAMPLE_RING_DEPTH); - /* 逐点顺序拷贝:从 start 起向前走 n 步,终点正好是 s_head-1(最新点)。 - * 每步单独取模,是因为窗可能**跨过环尾**(start + i > DEPTH - 1 时回绕到 0); - * 环形缓冲在内存里首尾并不相连,故不能用一次 memcpy 拷完 —— 要么先判边界再 - * 分两段拷,要么像这里逐点取模。DEPTH 只有 40,逐点拷贝比"两段 + 分支"更短更稳。 - * 拷贝顺序即时间顺序:out[0] 最老、out[n-1] 最新。 */ + /* 逐点取模拷贝:窗可能跨环尾,环在内存里首尾不相连,不能用一次 memcpy。 */ for (i = 0u; i < (uint16_t)n; i++) { out[i] = s_ring[ch][(uint16_t)((start + i) % SAMPLE_RING_DEPTH)]; } diff --git a/App/algo/sample_ring.h b/App/algo/sample_ring.h index df84572c..8513d9db 100644 --- a/App/algo/sample_ring.h +++ b/App/algo/sample_ring.h @@ -60,8 +60,8 @@ int sample_ring_push_frame(const float *values, uint8_t n); * 限定条件:无 * 函数说明:out[n-1] 为最新一点。相量层以本函数取"以当前时刻为终止的一周波"。 * ⚠ 仅 adcTask 上下文调用(写者与读者同任务串行,本层不加锁); - * 环内布局、窗首下标推导与"先加 DEPTH 再减 n"的理由见 - * `App/algo/sample_ring.c` 同名函数的注释。 + * 为什么需要这一层、环内布局与窗首下标的推导 → + * `docs/算法/采集与算法层设计说明.md` §5。 */ int sample_ring_window(adc_semantic_t ch, uint8_t n, float *out); From e71d623e6017c5f606a47f0994540e4069710251 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Thu, 29 Oct 2026 17:28:48 +0800 Subject: [PATCH 4/5] =?UTF-8?q?docs(algo):=205.3=20=E8=8A=82=E8=A1=A5?= =?UTF-8?q?=E7=AA=97=E9=A6=96=E4=B8=8B=E6=A0=87=E7=9A=84=E5=9C=86=E5=91=A8?= =?UTF-8?q?=E5=9B=BE=EF=BC=88=E4=B8=89=E7=A7=8D=E5=86=99=E6=B3=95=E5=AF=B9?= =?UTF-8?q?=E7=85=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增配图 采样环缓冲_窗首下标.svg:左侧 40 格圆环走一遍 head = 5、n = 20 的「往回退 19 步」(跨过 0 与 39),右侧把直接减 / 16 位回绕后再取模 / 先加 DEPTH 三种写法的结果并列,只有第三种落在 25。 - 与 5.2 的环形布局图分工:那张讲「窗在哪几格」,这张讲「下标怎么算出来」, 不重复同一张图的语义。 - 生成器 gen_sample_ring_start_svg.py 顺带把 OVERFLOW 明细改到末尾统一打印 (原先只在开头打,折行类告警只报计数、看不出是哪一条)。 - 门禁:doc-links --sections-strict 通过;id-check 新增行无未登记编号; generated-fresh 22 个生成器 17 一致、0 新漂移;产物连跑两次 sha1 不变。 --- docs/images/采样环缓冲_窗首下标.svg | 74 ++++ docs/算法/采集与算法层设计说明.md | 5 + tools/arch_diagram/README.md | 1 + .../arch_diagram/gen_sample_ring_start_svg.py | 316 ++++++++++++++++++ 4 files changed, 396 insertions(+) create mode 100644 docs/images/采样环缓冲_窗首下标.svg create mode 100644 tools/arch_diagram/gen_sample_ring_start_svg.py diff --git a/docs/images/采样环缓冲_窗首下标.svg b/docs/images/采样环缓冲_窗首下标.svg new file mode 100644 index 00000000..3a991871 --- /dev/null +++ b/docs/images/采样环缓冲_窗首下标.svg @@ -0,0 +1,74 @@ + + +窗首 = head − n:head 不够减会走到负数,所以先加一圈 DEPTH 再减 +例:head = 5、n = 20 —— 最新点在 4,沿环往回退 19 步落在 25,途中跨过 0 与 39。DEPTH = 40 时 20 点窗正好占半圈,所以这条弧看起来是个半圆。 +同一个例子三种写法,只有第三种同时做到「同余正确」与「不发生下溢」: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +往回退 19 步 +0 +10 +20 +30 +head = 5 +start = 25 +环 = 40 格 +窗 = 20 格(半圈) + +✗ 5 - 20 += -15 +负数。C99 的 % 向零截断,(-15) % 40 仍是 -15; +转成无符号就是 65521,拿去索引 s_ring 即越界读。 + +✗ (uint16_t)(5 - 20) % 40 += 65521 % 40 = 1 +65536 不是 40 的倍数:(x mod 65536) mod 40 ≠ x mod 40。 +回绕后再取模不等价,读到的是环尾附近而不是窗首。 + +✓ (5 + 40 - 20) % 40 += 25 % 40 = 25 +就是窗首。加 40 不改同余类(40 ≡ 0 mod 40), +中间值恒在 [0, 2·DEPTH - 2],两种提升规则都不下溢。 + +加 DEPTH 不是「凑数」:它是环的周长,加一整圈等价于原地不动——但把减法从「可能为负」搬到了「必然非负」,取模才恢复成数学上的同余解。 + diff --git a/docs/算法/采集与算法层设计说明.md b/docs/算法/采集与算法层设计说明.md index 08d3301d..1ed621c1 100644 --- a/docs/算法/采集与算法层设计说明.md +++ b/docs/算法/采集与算法层设计说明.md @@ -197,6 +197,11 @@ flowchart TD 最后写入的一点是 `s_head - 1`,于是"以最新点为终点的 n 点窗"= 下标 `[s_head-n, s_head-1]`, 窗首(最老点)就是 `s_head - n`,落到 `[0, DEPTH - 1]` 要取模。 +取一个"head 不够减"的例子走一遍:`head = 5`、`n = 20`——最新点在 4,沿环往回退 19 步落在 25, +途中跨过 0 与 39。直接写 `5 - 20` 会得到 −15,压根不在环上。 + +![head 不够减时直接减会走到负数:沿环往回退 19 步跨过 0,加一圈 DEPTH 才回到 25](../images/采样环缓冲_窗首下标.svg) + 代码里写成 `(s_head + SAMPLE_RING_DEPTH - n) % SAMPLE_RING_DEPTH` 而不是 `(s_head - n) % DEPTH`, 是因为后者有两个独立的坑: diff --git a/tools/arch_diagram/README.md b/tools/arch_diagram/README.md index e75927ab..6dfed728 100644 --- a/tools/arch_diagram/README.md +++ b/tools/arch_diagram/README.md @@ -23,6 +23,7 @@ | `gen_wave_record_flow_svg.py` | 故障录波数据流图 | 录波段数据的四个所有者与四条「不许」:谁独占写、谁只能读、标志怎么过界 | | `gen_sample_ring_why_svg.py` | 采样环缓冲_为什么需要 | 上游 1 kHz 覆盖写只交最新一帧 vs 下游要 20 点滑窗:缺口 19 点与三个消费者 | | `gen_sample_ring_layout_svg.py` | 采样环缓冲_环形布局 | 40 格环里哪 20 格是本窗、写指针指「下一格」、回绕与三条设计不变量 | +| `gen_sample_ring_start_svg.py` | 采样环缓冲_窗首下标 | 窗首 = head − n 的圆周走法,与「直接减 / 16 位回绕 / 先加 DEPTH」三种写法对照 | ## 使用方式 diff --git a/tools/arch_diagram/gen_sample_ring_start_svg.py b/tools/arch_diagram/gen_sample_ring_start_svg.py new file mode 100644 index 00000000..2cce1633 --- /dev/null +++ b/tools/arch_diagram/gen_sample_ring_start_svg.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""生成 docs/images/采样环缓冲_窗首下标.svg —— 窗首 start = head − n 为什么要先加一圈 DEPTH: +head 不够减时直接减会走到负数,取模就再也回不到环上。 + +为什么不用 Mermaid:本图要读的是"沿圆环往回退 19 格,途中跨过 0"这条**弧**, +以及三种写法算出来的三个**不同的数**。Mermaid 画不出圆周上的定位与角度, +盒子宽度也随文本变,"退半圈落在哪一格"这个关系就没了。 + +与 5.2 的环形布局图分工:那张讲「窗在哪几格」(线性排开 40 格 + 指针), +本图讲「这个下标怎么算出来」(圆周 + 三种写法对照),不重复。 + +用法(仓库根目录,任意 python3): + python tools/arch_diagram/gen_sample_ring_start_svg.py +输出: + docs/images/采样环缓冲_窗首下标.svg +改内容:示例(head / n)或三种写法的结论变了才重画;调坐标 / 文案编辑下方常量后重跑。 +""" +import html +import math +import os +import sys +import xml.etree.ElementTree as ET + + +def emit(s): + """控制台打印按目标编码替换,绝不因 GBK 终端遇上 ²/− 之类字符而崩 + (generated-fresh 钩子会把本脚本拷进临时目录重跑,崩了就是一片假红)。""" + enc = getattr(sys.stdout, "encoding", None) or "ascii" + sys.stdout.write(s.encode(enc, "replace").decode(enc, "replace") + "\n") + + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.path.join(ROOT, "docs", "images", "采样环缓冲_窗首下标.svg") + +# ----------------------------- 画布与尺寸 ----------------------------- +W = 1060 +H = 600 +PAD = 46 +RIGHT = W - PAD + +FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif" +MONO = "Consolas, Cascadia Mono, DejaVu Sans Mono, monospace" + +TITLE_FONT = 21 +SUB_FONT = 13 +BOX_FONT = 13 +BODY_FONT = 11.5 +TINY_FONT = 11 +DOT_LABEL_FONT = 12 + +INK = "#0f172a" +LINE = "#334155" +MUTED = "#64748b" +FAINT = "#94a3b8" +RULE = "#cbd5e1" +WHITE = "#ffffff" + +C_BLUE = "#2563eb" +BG_BLUE = "#eff6ff" +C_ROSE = "#e11d48" +BG_ROSE = "#fff1f2" +C_GREEN = "#16a34a" +BG_GREEN = "#f0fdf4" +C_AMBER = "#d97706" + +# ----------------------------- 数据(改这里) ----------------------------- +DEPTH = 40 +WIN_N = 20 +HEAD = 5 # 示例写指针:故意取"不够减"的位置 +START = (HEAD + DEPTH - WIN_N) % DEPTH # = 25 + +TITLE = "窗首 = head − n:head 不够减会走到负数,所以先加一圈 DEPTH 再减" +SUB1 = ("例:head = 5、n = 20 —— 最新点在 4,沿环往回退 19 步落在 25,途中跨过 0 与 39。" + "DEPTH = 40 时 20 点窗正好占半圈,所以这条弧看起来是个半圆。") +SUB2 = "同一个例子三种写法,只有第三种同时做到「同余正确」与「不发生下溢」:" + +ARC_LABEL = "往回退 19 步" +CENTER_L1 = "环 = 40 格" +CENTER_L2 = "窗 = 20 格(半圈)" +HEAD_LABEL = "head = 5" +START_LABEL = "start = 25" + +# (公式, 算出来是多少, 判定, 逐条理由, 主色, 底色) +ROWS = [ + ("5 - 20", "= -15", "✗", + ["负数。C99 的 % 向零截断,(-15) % 40 仍是 -15;", + "转成无符号就是 65521,拿去索引 s_ring 即越界读。"], + C_ROSE, BG_ROSE), + ("(uint16_t)(5 - 20) % 40", "= 65521 % 40 = 1", "✗", + ["65536 不是 40 的倍数:(x mod 65536) mod 40 ≠ x mod 40。", + "回绕后再取模不等价,读到的是环尾附近而不是窗首。"], + C_ROSE, BG_ROSE), + ("(5 + 40 - 20) % 40", "= 25 % 40 = 25", "✓", + ["就是窗首。加 40 不改同余类(40 ≡ 0 mod 40),", + "中间值恒在 [0, 2·DEPTH - 2],两种提升规则都不下溢。"], + C_GREEN, BG_GREEN), +] +FOOTNOTE = ("加 DEPTH 不是「凑数」:它是环的周长,加一整圈等价于原地不动——" + "但把减法从「可能为负」搬到了「必然非负」,取模才恢复成数学上的同余解。") + +# ----------------------------- 布局 ----------------------------- +CX = 296.0 +CY = 352.0 +R = 148.0 +DOT_R = 5.0 +DOT_R_HL = 6.0 + +BOX_X = 596 +BOX_W = RIGHT - BOX_X +BOX_TOP = 186 +BOX_H = 96 +BOX_GAP = 14 + +NOTE_Y = 556 + + +# ----------------------------- 工具函数 ----------------------------- +def esc(s): + return html.escape(s, quote=True) + + +def text_w(s, fs): + """粗略估算文本像素宽:CJK/全角 ≈ 1.0em,其余 ≈ 0.58em(宁可偏大)。""" + return sum(fs * (1.0 if ord(ch) >= 0x2E80 else 0.58) for ch in s) + + +OVERFLOW = [] + + +def check_fit(s, fs, avail, where): + if text_w(s, fs) > avail: + OVERFLOW.append("%s: %.0f > %.0f [%s]" % (where, text_w(s, fs), avail, s)) + + +def txt(x, y, s, fs, fill, weight=400, anchor=None, mono=False): + a = "" if anchor is None else ' text-anchor="%s"' % anchor + w = "" if weight == 400 else ' font-weight="%d"' % weight + f = "" if not mono else ' font-family="%s"' % MONO + return ('%s' + % (x, y, fs, a, w, f, fill, esc(s))) + + +def line(x1, y1, x2, y2, color=LINE, width=2.0, dash=None): + d = "" if dash is None else ' stroke-dasharray="%s"' % dash + return ('' + % (x1, y1, x2, y2, color, width, d)) + + +def rect(x, y, w, h, fill, stroke=None, rx=8, sw=1.6, dash=None): + s = "" if stroke is None else ' stroke="%s" stroke-width="%g"' % (stroke, sw) + d = "" if dash is None else ' stroke-dasharray="%s"' % dash + return '' % ( + x, y, w, h, rx, fill, s, d) + + +def circle(cx, cy, r, fill, stroke=None, sw=1.5): + s = "" if stroke is None else ' stroke="%s" stroke-width="%g"' % (stroke, sw) + return '' % (cx, cy, r, fill, s) + + +def tri(points, fill): + return '' % ( + " ".join("%.1f,%.1f" % (px, py) for px, py in points), fill) + + +def pt(r, deg): + """圆上一点:deg 以 +x 轴为 0、屏幕坐标 y 向下(故 90° 在正下方)。""" + a = math.radians(deg) + return CX + r * math.cos(a), CY + r * math.sin(a) + + +def angle_of(idx): + """第 idx 格的角度:0 格在正上方,顺时针每格 360/DEPTH 度。""" + return -90.0 + idx * (360.0 / DEPTH) + + +def arc_arrow(r, a_from, a_to, color, width): + """从 a_from 逆时针画到 a_to(a_to < a_from),末端带箭头。""" + x0, y0 = pt(r, a_from) + x1, y1 = pt(r, a_to) + large = 1 if abs(a_to - a_from) > 180.0 else 0 + d = "M %.1f %.1f A %.1f %.1f 0 %d 0 %.1f %.1f" % (x0, y0, r, r, large, x1, y1) + out = '' % (d, color, width) + # 末端切向:用 a_to 与其后方 1° 两点之差,得到"行进方向" + xb, yb = pt(r, a_to + 1.0) + vx, vy = x1 - xb, y1 - yb + n = math.hypot(vx, vy) or 1.0 + ux, uy = vx / n, vy / n + hl = 10.0 + bx, by = x1 - ux * hl, y1 - uy * hl + px, py = -uy, ux + out += tri([(x1, y1), (bx + px * 4.6, by + py * 4.6), (bx - px * 4.6, by - py * 4.6)], color) + return out + + +def wrap(s, fs, avail, max_lines, where=""): + """按估算宽度折行(CJK 逐字可断)。超出 max_lines 用省略号收尾—— + 宁可提示文案太长,也不让字溢出盒子。""" + if text_w(s, fs) <= avail: + return [s] + lines = [] + cur = "" + for ch in s: + if text_w(cur + ch, fs) > avail: + lines.append(cur) + cur = ch + else: + cur += ch + if cur: + lines.append(cur) + if len(lines) > max_lines: + OVERFLOW.append("%s: 折行 %d > %d" % (where, len(lines), max_lines)) + lines = lines[:max_lines] + lines[max_lines - 1] = lines[max_lines - 1][:-1] + "…" + return lines + + +# ----------------------------- 生成 ----------------------------- +def build(): + out = [] + add = out.append + + avail = RIGHT - PAD + for s, fs, where in ((TITLE, TITLE_FONT, "title"), (SUB1, SUB_FONT, "sub1"), + (SUB2, SUB_FONT, "sub2")): + check_fit(s, fs, avail, where) + if OVERFLOW: + for o in OVERFLOW: + emit("OVERFLOW WARN " + o) + + add('' % (W, H, WHITE)) + + # ---- 标题区 ---- + add(txt(PAD, 42, TITLE, TITLE_FONT, INK, weight=700)) + add(txt(PAD, 68, SUB1, SUB_FONT, MUTED)) + add(txt(PAD, 88, SUB2, SUB_FONT, MUTED)) + + # ---- 左:40 格圆环 ---- + win_lo = START + win_hi = HEAD - 1 + add(circle(CX, CY, R, "none", RULE, 1.2)) + for i in range(DEPTH): + x, y = pt(R, angle_of(i)) + in_win = (i >= win_lo) or (i <= win_hi) + if i == HEAD: + add(circle(x, y, DOT_R_HL, C_AMBER)) + elif in_win: + add(circle(x, y, DOT_R_HL, C_BLUE)) + else: + add(circle(x, y, DOT_R, FAINT)) + + # 往回退的弧(贴着点圈内侧走,末端箭头落在 start 上) + add(arc_arrow(R - 18.0, angle_of(win_hi), angle_of(win_lo) - 360.0, C_ROSE, 1.8)) + lx, ly = pt(R - 70.0, angle_of(33)) + add(txt(lx, ly, ARC_LABEL, TINY_FONT, C_ROSE, weight=700, anchor="middle")) + + # 圆周上的刻度名 + for i in (0, 10, 20, 30): + x, y = pt(R + 22.0, angle_of(i)) + anchor = "middle" + if i == 10: + anchor = "start" + elif i == 30: + anchor = "end" + add(txt(x, y + 4, str(i), DOT_LABEL_FONT, MUTED, anchor=anchor)) + + # head / start 两个关键点 + hx, hy = pt(R + 26.0, angle_of(HEAD)) + check_fit(HEAD_LABEL, TINY_FONT, 200, "head label") + add(txt(hx, hy, HEAD_LABEL, TINY_FONT, C_AMBER, weight=700, anchor="start")) + sx, sy = pt(R + 26.0, angle_of(START)) + check_fit(START_LABEL, TINY_FONT, 200, "start label") + add(txt(sx, sy, START_LABEL, TINY_FONT, C_BLUE, weight=700, anchor="end")) + + # 圆心说明 + add(txt(CX, CY - 6, CENTER_L1, TINY_FONT, FAINT, anchor="middle")) + add(txt(CX, CY + 14, CENTER_L2, TINY_FONT, FAINT, anchor="middle")) + + # ---- 右:三种写法 ---- + inner = BOX_W - 32 + for i, (formula, value, mark, why, c, bg) in enumerate(ROWS): + y = BOX_TOP + i * (BOX_H + BOX_GAP) + add(rect(BOX_X, y, BOX_W, BOX_H, bg, c, rx=8, sw=1.8)) + add(txt(BOX_X + 14, y + 26, "%s %s" % (mark, formula), BOX_FONT, c, weight=700, mono=True)) + add(txt(BOX_X + BOX_W - 14, y + 26, value, BOX_FONT, c, weight=700, anchor="end", mono=True)) + yy = y + 48 + for ln in why: + for sub in wrap(ln, BODY_FONT, inner, 1, "row %d why" % i): + add(txt(BOX_X + 14, yy, sub, BODY_FONT, MUTED)) + yy += 16 + + # ---- 底部说明 ---- + add(line(PAD, NOTE_Y - 26, RIGHT, NOTE_Y - 26, RULE, 1.0)) + for i, ln in enumerate(wrap(FOOTNOTE, SUB_FONT, avail, 2, "foot wrap")): + add(txt(PAD, NOTE_Y + i * 19, ln, SUB_FONT, MUTED)) + + svg = ('\n%s\n\n' + % (W, H, W, H, FONT, "\n".join(out))) + + d = os.path.dirname(OUT) + if not os.path.isdir(d): + os.makedirs(d) + with open(OUT, "w", encoding="utf-8", newline="\n") as fp: + fp.write(svg) + ET.fromstring(svg) + emit("OK %d bytes, canvas %dx%d -> %s" % (len(svg.encode("utf-8")), W, H, OUT)) + if OVERFLOW: + for o in OVERFLOW: + emit("OVERFLOW WARN " + o) + emit("!! %d overflow warnings" % len(OVERFLOW)) + + +if __name__ == "__main__": + build() From 126c4bf57f95e412c01c231a9314e5f4ab6f00aa Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Thu, 29 Oct 2026 17:29:15 +0800 Subject: [PATCH 5/5] =?UTF-8?q?docs(algo):=205.3=20=E8=8A=82=E8=A1=A5?= =?UTF-8?q?=E7=AA=97=E9=A6=96=E4=B8=8B=E6=A0=87=E7=9A=84=E5=9C=86=E5=91=A8?= =?UTF-8?q?=E5=9B=BE=EF=BC=88=E4=B8=89=E7=A7=8D=E5=86=99=E6=B3=95=E5=AF=B9?= =?UTF-8?q?=E7=85=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增配图 采样环缓冲_窗首下标.svg:左侧 40 格圆环走一遍 head = 5、n = 20 的「往回退 19 步」(跨过 0 与 39),右侧把直接减 / 16 位回绕后再取模 / 先加 DEPTH 三种写法的结果并列,只有第三种落在 25。 - 与 5.2 的环形布局图分工:那张讲「窗在哪几格」,这张讲「下标怎么算出来」, 不重复同一张图的语义。 - 生成器 gen_sample_ring_start_svg.py 顺带把 OVERFLOW 明细改到末尾统一打印 (原先只在开头打,折行类告警只报计数、看不出是哪一条)。 - 门禁:doc-links --sections-strict 通过;id-check 新增行无未登记编号; generated-fresh 22 个生成器 17 一致、0 新漂移;产物连跑两次 sha1 不变。 --- docs/算法/采集与算法层设计说明.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/算法/采集与算法层设计说明.md b/docs/算法/采集与算法层设计说明.md index 1ed621c1..1e41d09e 100644 --- a/docs/算法/采集与算法层设计说明.md +++ b/docs/算法/采集与算法层设计说明.md @@ -205,15 +205,18 @@ flowchart TD 代码里写成 `(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。 +- **16 位无符号下溢后再取模不等价**:`s_head < n` 时减法先回绕成 `65536-(n-s_head)` = 65521, + 而 65536 不是 40 的倍数,`(x mod 65536) mod 40 ≠ x mod 40`。同一个例子:正确窗首是 25, + 回绕后 `65521 % 40 = 1` → 整窗错位,读到的是环尾附近。 +- **有符号负取模**:两个 `uint16_t` 先提升成 `int`,`5 - 20 = -15`;C99 的 `%` 向零截断, + `(-15) % 40 == -15`(不是 25),再转回无符号就是一次**越界读**——不报错、不 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` 同名函数的注释。 + +边界自检:`n == DEPTH` 时 `start == s_head`——环满时「下一拍要覆盖的那格」就是最老点; +未写满时 `s_head == s_filled`,故 `start >= 0`,窗一定不跨环尾。 +`App/algo/sample_ring.c` 里只留结论与指向本节的指针,推导以本节为准。 ### 5.4 API 契约