#!/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()