Files
STM32F4-Base/tools/arch_diagram/gen_sample_ring_why_svg.py
T
wandering1 c377fc8c15 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 新漂移。
2026-10-29 17:15:48 +08:00

271 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 ('<text x="%.1f" y="%.1f" font-size="%g"%s%s fill="%s">%s</text>'
% (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 ('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="%g"%s/>'
% (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 '<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="%g" fill="%s"%s%s/>' % (
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 '<circle cx="%.1f" cy="%.1f" r="%g" fill="%s"%s/>' % (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('<rect x="0" y="0" width="%d" height="%d" fill="%s"/>' % (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 = ('<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" '
'viewBox="0 0 %d %d" font-family="%s">\n%s\n</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()