#!/usr/bin/env python3 # -*- coding: utf-8 -*- """生成 docs/images/交流六轮速查.svg —— 交流(正弦)动态验证六轮:每一轮注什么信号、 验什么、主读数走哪个出口,一张图先给全局轮廓,再进 §8.5 逐轮执行卡。 为什么不用 Mermaid:本图的语义重心是「每轮注入信号的波形形状」与「三块小面板里 信号缩略图 + 判据 + 出口的固定排版对位」——Mermaid 画不出正弦/方波/三角/削顶/谐波 叠加这些波形,也无法把六格对齐成 3x2 卡片。它只适合关系拓扑(那部分另在 docs/测试说明/专项验证/ADC前端注入验证流程.md §8.3 / §8.5 用 Mermaid 表达)。 用法(仓库根目录,任意 python3): python tools/arch_diagram/gen_ac_rounds_svg.py 输出: docs/images/交流六轮速查.svg 改内容:轮次事实(接法 / 判据 / 出口)变了才重画;调坐标 / 文案编辑下方常量后重跑。 """ 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 = 680 PAD = 46 RIGHT = W - PAD FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif" TITLE_FONT = 21 SUB_FONT = 13 HEAD_FONT = 15 LABEL_FONT = 11 BODY_FONT = 11.5 TINY_FONT = 10.5 INK = "#0f172a" LINE = "#334155" MUTED = "#64748b" FAINT = "#94a3b8" RULE = "#cbd5e1" WHITE = "#ffffff" C_BLUE = "#2563eb" BG_BLUE = "#eff6ff" C_TEAL = "#0d9488" BG_TEAL = "#f0fdfa" C_GREEN = "#16a34a" BG_GREEN = "#f0fdf4" C_AMBER = "#d97706" BG_AMBER = "#fffbeb" C_ROSE = "#e11d48" BG_ROSE = "#fff1f2" C_PURPLE = "#7c3aed" BG_PURPLE = "#f5f3ff" # ----------------------------- 布局 ----------------------------- COLS = 3 CW = 314 CH = 246 GAPX = 12 GAPY = 14 X0 = PAD Y0 = 106 THUMB_H = 86 LABEL_W = 36 # 「注入 / 判据 / 出口」标签列宽 # ----------------------------- 数据(改这里) ----------------------------- TITLE = "交流动态验证六轮 —— 每一轮注什么信号、读哪个口" SUB1 = ("一卡一轮:注入信号缩略图 → 接法与设定 → 判据一句话 → 主读数出口。" "先看图建立全局轮廓,再按 §8.5 的对应执行卡逐轮跑") SUB2 = ("六轮共用同一套接线与通用动作;过判据的属主是 docs/任务规划/数据可信.md §4.6.2," "本图只是导航,不含新判据") # 每卡:(轮名, 角标, 主色, 底色, 波形类型, 注入, 判据, 出口) CARDS = [ ("幅值轮", "先做 · 主用例", C_BLUE, BG_BLUE, "amp", "UA(R31)端子侧,正弦 50.000 Hz、1.000 Vrms", "ua_mv 中位数 ≈ 万用表 × 1000,偏差 ≤2 %", "meas: ua_mv 为主;104 0x4011 只作交叉读数"), ("波形轮", "真 RMS ≠ 基波", C_TEAL, BG_TEAL, "three", "同峰值 1.414 V 改方波 / 三角(面板有效值各异)", "读数只跟基波:方波 ≈1278、三角 ≈817,±2 %", "meas: ua_mv;抬头记万用表响应类型"), ("相位轮", "定量 · 取段", C_GREEN, BG_GREEN, "phase", "UA(R31)与 UN(R28)同源同幅 1.000 Vrms", "同段内相位差 ≈0°,B 通道改 90° 应跟到 ≈90°,±2°", "wave_read.py fft 定量;phasor(hb) 只核象限"), ("测频轮", "A 档无数 = 过", C_AMBER, BG_AMBER, "sweep", "UA 同幅值轮接法,扫 45 / 50 / 55 Hz", "A 档如实报不可测:valid=0、UI 显 --;报 50.00 反而是 bug", "freq(hb) 0.01 Hz;UI Hz、104 0x401D"), ("削顶轮", "负向 · 单独日志", C_ROSE, BG_ROSE, "clip", "UB(R52):DC −1.00 V 保持 ≥5 s,或 2.0 Hz 正弦", "连续 20 拍贴底 → rail / edge 锁存 + 104 该路 IV 清零;50 Hz 正弦不锁存 = 预期", "cal: rail / edge;取段再跑 shape 对表"), ("谐波轮", "定量 · 取段", C_PURPLE, BG_PURPLE, "harm", "IA(R19):基波 0.800 + 二次 0.160 + 五次 0.160 Vrms", "Ia2 / Ia5 各 ≈0.160 Vrms,±2 %(最坏峰值 1.58 V 留余量)", "wave_read.py fft --ch 4;UI Ia2 / Ia5 目检旁证"), ] FOOTNOTE = ("顺序与耗时见 §8.6(约 135 min 一趟 session);[ERR] 判读见 §8.7,失败先查 §8.8 分诊表。" "波形缩略图只表形状,不代表真实幅度刻度。") # ----------------------------- 工具函数 ----------------------------- 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 poly(pts, color, sw=2.0, dash=None): d = "" if dash is None else ' stroke-dasharray="%s"' % dash p = " ".join("%.1f,%.1f" % (x, y) for x, y in pts) return ('' % (p, color, sw, d)) 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 _sample(fv, x, y0, w, h, n=140): """把 [-1,1] 的值函数 fv(t in 0..1) 画进框 (x, y0, w, h)。""" yc = y0 + h / 2.0 amp = h * 0.36 pts = [] for i in range(n): t = float(i) / (n - 1) pts.append((x + w * t, yc - amp * fv(t))) return pts def thumb(x, y, w, h, kind, c): out = [rect(x, y, w, h, WHITE, RULE, rx=6, sw=1.0)] pad = 10.0 ix, iw, ih = x + pad, w - 2 * pad, h - 2 * pad if kind == "amp": out.append(line(x + 4, y + h / 2, x + w - 4, y + h / 2, FAINT, 0.8, dash="3 3")) yc = y + h / 2.0 amp = ih * 0.36 out.append(line(x + pad, yc - amp, x + w - pad, yc - amp, c, 1.0, dash="5 4")) out.append(line(x + pad, yc + amp, x + w - pad, yc + amp, c, 1.0, dash="5 4")) out.append(poly(_sample(lambda t: math.sin(2 * math.pi * 2 * t), ix, y + pad, iw, ih), c)) out.append(txt(x + pad, y + pad - 1, "峰 ±1.414 V · 基波 RMS 1.000 V", TINY_FONT, MUTED)) elif kind == "three": yc = y + h / 2.0 amp = ih * 0.36 out.append(line(x + pad, yc - amp, x + w - pad, yc - amp, FAINT, 0.9, dash="5 4")) slot = iw / 3.0 kinds = [lambda t: math.sin(4 * math.pi * t), lambda t: 1.0 if math.sin(4 * math.pi * t) >= 0 else -1.0, lambda t: (2.0 / math.pi) * math.asin(math.sin(4 * math.pi * t))] names = ["正弦", "方波", "三角"] for i, fv in enumerate(kinds): sx = ix + i * slot out.append(poly(_sample(fv, sx + 6, y + pad, slot - 12, ih, n=120), c, 1.8)) out.append(txt(sx + slot / 2, y + h - 2, names[i], TINY_FONT, MUTED, anchor="middle")) out.append(txt(x + pad, y + pad - 1, "同峰值对齐(不是同 Vrms)", TINY_FONT, MUTED)) elif kind == "phase": out.append(line(x + 4, y + h / 2, x + w - 4, y + h / 2, FAINT, 0.8, dash="3 3")) out.append(poly(_sample(lambda t: math.sin(2 * math.pi * 1.5 * t), ix, y + pad, iw, ih), c, 2.0)) out.append(poly(_sample(lambda t: math.sin(2 * math.pi * 1.5 * t + math.pi / 2), ix, y + pad, iw, ih), C_ROSE, 1.6, dash="6 4")) out.append(txt(x + pad, y + h - 2, "UA 0° 实线", TINY_FONT, c, weight=700)) out.append(txt(x + pad + 78, y + h - 2, "UN +90° 红虚线", TINY_FONT, C_ROSE, weight=700)) elif kind == "sweep": out.append(line(x + 4, y + h / 2, x + w - 4, y + h / 2, FAINT, 0.8, dash="3 3")) slot = iw / 3.0 cyc = [0.9, 1.4, 2.1] labs = ["45 Hz", "50 Hz", "55 Hz"] for i in range(3): sx = ix + i * slot out.append(poly(_sample(lambda t, cc=cyc[i]: math.sin(2 * math.pi * cc * t), sx + 5, y + pad, slot - 10, ih, n=100), c, 1.8)) out.append(txt(sx + slot / 2, y + h - 2, labs[i], TINY_FONT, MUTED, anchor="middle")) elif kind == "clip": floor_y = y + pad + ih * 0.86 out.append(line(x + pad, floor_y, x + w - pad, floor_y, C_ROSE, 1.2, dash="5 4")) yc = y + pad + ih * 0.40 amp = ih * 0.46 pts = [] n = 160 for i in range(n): t = float(i) / (n - 1) v = math.sin(2 * math.pi * 2 * t) vv = max(v, 0.0) * 2.0 - 1.0 pts.append((ix + iw * t, yc - amp * vv)) out.append(poly(pts, c, 2.0)) out.append(txt(x + pad, y + pad - 1, "负半周钉 0(贴下轨)", TINY_FONT, MUTED)) elif kind == "harm": out.append(line(x + 4, y + h / 2, x + w - 4, y + h / 2, FAINT, 0.8, dash="3 3")) def fv(t): w1 = 2 * math.pi * 1.5 * t return (math.sin(w1) + 0.2 * math.sin(2 * w1) + 0.2 * math.sin(5 * w1)) / 1.35 out.append(poly(_sample(fv, ix, y + pad, iw, ih), c, 2.0)) out.append(txt(x + pad, y + pad - 1, "基波 + 2 次 + 5 次", TINY_FONT, MUTED)) return out # ----------------------------- 生成 ----------------------------- 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) body_x = X0 + 12 + LABEL_W body_avail = CW - 24 - LABEL_W for idx, (name, tag, c, bg, kind, inj, rule, ex) in enumerate(CARDS): cx = X0 + (idx % COLS) * (CW + GAPX) cy = Y0 + (idx // COLS) * (CH + GAPY) body_x = cx + 12 + LABEL_W check_fit(name + tag, HEAD_FONT, CW - 24, "card %s head" % name) for s, where in ((inj, "inj"), (rule, "rule"), (ex, "exit")): check_fit(s[:14], BODY_FONT, body_avail, "card %s %s firstline" % (name, where)) add(rect(cx, cy, CW, CH, bg, c, rx=10, sw=2.0)) add(txt(cx + 12, cy + 26, name, HEAD_FONT, INK, weight=700)) add(txt(cx + CW - 12, cy + 26, tag, TINY_FONT, c, weight=700, anchor="end")) for el in thumb(cx + 12, cy + 36, CW - 24, THUMB_H, kind, c): add(el) ty = cy + 36 + THUMB_H + 16 for label, s, mx in (("注入", inj, 2), ("判据", rule, 2), ("出口", ex, 2)): add(txt(cx + 12, ty, label, LABEL_FONT, c, weight=700)) for ln in wrap(s, BODY_FONT, body_avail, mx, "card %s %s" % (name, label)): add(txt(body_x, ty, ln, BODY_FONT, INK if label == "判据" else MUTED)) ty += 16 ty += 4 fy = Y0 + 2 * CH + GAPY + 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 + 22 + 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()