#!/usr/bin/env python3 # -*- coding: utf-8 -*- """生成 docs/images/数据可信台阶.svg —— 「数据可信」的五级台阶:现在踩在第 2 级, 第 3 级(动态正弦波形)用手边设备就能跨(纯标准库)。 为什么不用 Mermaid:本图要读的是"台阶的高度差 + 每一级踩没踩上去"——Mermaid 会把 五级压成同一高度,"还差一级"这件事就看不出来了。谁挡谁的顺序拓扑另有一张 Mermaid 图,在 docs/任务规划/数据可信.md §6(本图刻意不重复那张的语义)。 用法(仓库根目录,任意 python3): python tools/arch_diagram/gen_data_trust_ladder_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 = 712 PAD = 46 RIGHT = W - PAD FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif" TITLE_FONT = 21 SUB_FONT = 13 HEAD_FONT = 14.5 Q_FONT = 12.5 HOW_FONT = 11.5 TINY_FONT = 11 INK = "#0f172a" LINE = "#334155" MUTED = "#64748b" FAINT = "#94a3b8" RULE = "#cbd5e1" WHITE = "#ffffff" C_GREEN = "#16a34a" BG_GREEN = "#f0fdf4" C_AMBER = "#d97706" BG_AMBER = "#fffbeb" C_ROSE = "#e11d48" BG_ROSE = "#fff1f2" C_BLUE = "#2563eb" BG_BLUE = "#eff6ff" # ----------------------------- 数据(改这里) ----------------------------- TITLE = "「数据可信」是五级台阶 —— 现在踩在第 2 级" SUB1 = ("每升一级只多答一个问题:通路活 → 静态读数对 → 动态波形对 → 换算到端子 → 精度达标;" "台阶的高度差 = 下游能多信多少") SUB2 = ("第 3 级不要标准源、也不要往端子加压:一台信号发生器 + 已有的注入点就能做" "(判据见 docs/任务规划/数据可信.md §4.6)") # 每列:(编号, 台阶名, 状态, 主色, 底色, 这一级多答的问题, 用什么证 / 什么挡路) STEPS = [ ("1", "通路活", "已证 · 2026-10-27", C_GREEN, BG_GREEN, "9 路都在转换?没串道?极性没反?", "开机自检 + 帧计数仍 1000 Hz;逐路注入只动本路"), ("2", "静态引脚电压", "已证 · 2026-10-27", C_GREEN, BG_GREEN, "注 1 V 直流,读数是不是 1 V?", "万用表 + 电位器三点线性:斜率 6544.2 vs 理论 6553.6 码/V,差 −0.14 %"), ("3", "动态引脚波形", "待做 · 本文新增", C_ROSE, BG_ROSE, "注 1 V 正弦,算出来还是不是 1 V?", "注正弦验全周傅氏幅值 / 相位 / 测频 / 削顶 / 谐波抑制。" "挡路:FREQ_AMP_MIN = 50 V 在引脚域恒不过 → 测频只能读到 IV"), ("4", "端子二次值", "等外部资源", C_AMBER, BG_AMBER, "引脚上这 1 V 对应端子侧几 V?", "④ 子板 / 端子板变比:仓内无原理图,须拿图纸或万用表实测"), ("5", "精度等级验收", "等外部资源", C_AMBER, BG_AMBER, "额定工况下整条链误差够几级?", "标准源两点标定写 [adc_cal] 置 VALID → 保护自动解卡 → 送检"), ] BAND_LABEL_1 = "M4 承诺边界(§2.6):蓝底这三列都还在「ADC 引脚」这一侧,eng = 0、单位是引脚电压 V" GATE_LABEL = "eng 升档门:④ 变比到手 + 电气 8 路 VALID 全置位 → eng 才升 1" FOOTNOTE = ("第 2、3 级共用同一个注入点与同一套读数出口,但证的是两件事:" "直流证「一条水平线的高度」,正弦证「整条时间轴的形状与节拍」——第 3 级不能由第 2 级代证。") # ----------------------------- 布局 ----------------------------- STEP_W = 196 STEP_GAP = 6 X0 = PAD FIRST_H = 96 # 第 1 级台阶高度 RISE = 64 # 每高一级加这么多 BASE_Y = 466 # 地面线 GATE_X = X0 + 3 * (STEP_W + STEP_GAP) - STEP_GAP - 3 # 第 3/4 列之间 BAND_TOP = 106 BAND_BOTTOM = BASE_Y + 172 # ----------------------------- 工具函数 ----------------------------- 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): s = "" if stroke is None else ' stroke="%s" stroke-width="%g"' % (stroke, sw) return '' % ( x, y, w, h, rx, 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)) # ---- 「引脚域」底衬(先画,压在台阶下面)---- add(rect(PAD - 12, BAND_TOP, GATE_X - PAD + 3, BAND_BOTTOM - BAND_TOP, BG_BLUE, None, rx=12)) # ---- 台阶 ---- inner_w = STEP_W - 20 for i, (no, name, status, c, bg, q, how) in enumerate(STEPS): x = X0 + i * (STEP_W + STEP_GAP) h = FIRST_H + i * RISE top = BASE_Y - h add(rect(x, top, STEP_W, h, bg, c, rx=8, sw=2.0)) add('' % (x + 24, top + 26, c)) add(txt(x + 24, top + 31, no, 13, WHITE, weight=700, anchor="middle")) check_fit(name, HEAD_FONT, inner_w - 30, "step %s name" % no) add(txt(x + 44, top + 32, name, HEAD_FONT, INK, weight=700)) add(txt(x + 11, top + 58, status, TINY_FONT, c, weight=700)) # ---- 地面线 ---- add(line(PAD, BASE_Y, RIGHT, BASE_Y, LINE, 2.2)) # ---- 地面下的明细(问题 + 用什么证),逐列一行 ---- for i, (no, name, status, c, bg, q, how) in enumerate(STEPS): x = X0 + i * (STEP_W + STEP_GAP) y = BASE_Y + 24 for ln in wrap(q, Q_FONT, inner_w, 2, "step %s q" % no): add(txt(x + 10, y, ln, Q_FONT, INK, weight=700)) y += 17 y += 3 for ln in wrap(how, HOW_FONT, inner_w, 5, "step %s how" % no): add(txt(x + 10, y, ln, HOW_FONT, MUTED)) y += 15 # ---- eng 升档门 ---- add(line(GATE_X, BAND_TOP - 6, GATE_X, BAND_BOTTOM, C_ROSE, 2.0, dash="7 5")) gy = BAND_TOP + 12 for ln in wrap(GATE_LABEL, TINY_FONT, STEP_W - 16, 4, "gate label"): add(txt(GATE_X + 8, gy, ln, TINY_FONT, C_ROSE, weight=700)) gy += 15 # ---- 蓝底标签(在台阶上方的空白区)---- bl = wrap(BAND_LABEL_1, HOW_FONT, GATE_X - PAD - 24, 2, "band label") for i, ln in enumerate(bl): add(txt(PAD, BAND_TOP + 26 + i * 17, ln, HOW_FONT, C_BLUE, weight=700)) # ---- 底部说明 ---- fy = BAND_BOTTOM + 22 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()