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] =?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 @@
+
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 @@
+
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` 必须看**同一个窗** | 各存一份历史 → 窗边界差一拍,相位差与频率跟着漂 |
+
+
+
+**两种"更省事"的替代方案,都否决了**:
+
+- **在 `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 节)。
+
+
+
+### 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'
+ % (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'
+ % (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()