#!/usr/bin/env python3 # -*- coding: utf-8 -*- """生成 docs/images/交流定数取中位数.svg —— 交流轮为什么必须「连取 10 行心跳、取中位数」: 左半讲机理(全周窗逐拍滑动 → 单行带拍频跳动),右半讲操作(10 行里任何单行都可能撞 波峰波谷、毛刺行拖偏算术平均,中位数对两头都钝感)。 为什么不用 Mermaid:本图是两幅带刻度的数值示意图(正弦 + 窗框、散点 + 三条水平基准线), 坐标与数值就是语义本身;Mermaid 只能画盒子和箭头,画不出「中位数线贴着密集簇、 平均线被毛刺拽走」这个高度差对比。 用法(仓库根目录,任意 python3): python tools/arch_diagram/gen_ac_median_svg.py 输出: docs/images/交流定数取中位数.svg 改内容:`vals` / 文案变了才重跑;调坐标编辑下方常量。数值一律硬编码,保证逐字节幂等。 """ 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 = 566 PAD = 46 RIGHT = W - PAD FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif" TITLE_FONT = 21 SUB_FONT = 12.5 HEAD_FONT = 13.5 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_GREEN = "#16a34a" C_ROSE = "#e11d48" BG_ROSE = "#fff1f2" C_AMBER = "#d97706" # ----------------------------- 数据(改这里) ----------------------------- TITLE = "交流轮为什么连取 10 行、取中位数 —— 机理与操作" SUB1 = ("meas: 是瞬时基波有效值透传:200 ms 是上送节拍、不是平均窗(数据可信 §4.5)——" "单行可能恰好抓到波峰或波谷,毛刺行会拖偏算术平均,中位数对两头都钝感") # 十行 ua_mv(1 mV 分辨率):第 9 行是偶发毛刺行 VALS = [998, 1003, 996, 1001, 999, 1006, 997, 1004, 1002, 1030] GLITCH_I = 9 TRUE_V = 1000.0 YLO, YHI = 988.0, 1036.0 OPS_NOTE = ("操作:稳定 ≥30 s 后,把连续 10 行心跳的 ua_mv 记进记录表(备注顺手记跳动区间 min–max)," "排序取第 5、6 个的平均 = 本条代表数;偏差 % 用它对万用表 × 1000。") FOOTNOTE = ("非整 50 Hz 时 meas: 来回跳是预期(§8.8 分诊同一条);扫频时读 freq(hb),别读 meas:。" "左图频差为示意刻意夸大,实拍跳动只有几个 LSB 的量级。") # ----------------------------- 布局 ----------------------------- LPX, LPW = PAD, 424 # 左面板(机理) RPX, RPW = 522, RIGHT - 522 # 右面板(操作) PLOT_T, PLOT_B = 176.0, 404.0 HDR_Y = 146.0 # ----------------------------- 工具函数 ----------------------------- 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=""): 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 mechanism(): out = [] add = out.append x0, x1 = LPX, LPX + LPW - 40 per = 24.0 # 示意:信号周期 24 拍(真实约 20 拍;频差夸大) win = 20.0 # 全周窗固定 20 拍 tmax = 44.0 def X(t): return x0 + (x1 - x0) * t / tmax yc = (PLOT_T + PLOT_B) / 2 - 14 amp = 74.0 add(txt(LPX, HDR_Y, "机理:窗定宽 20 拍,截到的相位逐窗滑动", HEAD_FONT, INK, weight=700)) # 信号 pts = [] for i in range(221): t = 44.0 * i / 220.0 pts.append((X(t), yc - amp * math.sin(2 * math.pi * t / per))) add(poly(pts, LINE, 1.8)) # 两个窗 for (t0, c, dash, name) in ((0.0, C_BLUE, None, "窗 A"), (20.0, C_ROSE, "6 4", "窗 B(下一行心跳)")): add(rect(X(t0), yc - amp - 16, X(t0 + win) - X(t0), 2 * amp + 32, "none", c, rx=6, sw=1.8 if dash is None else 1.4)) add(txt(X(t0) + 4, yc - amp - 22, name, TINY_FONT, c, weight=700)) # 拍刻度(每拍一根,只在窗内画,避免刷屏) for k in range(0, 41): add(line(X(k), PLOT_B - 30, X(k), PLOT_B - 24, FAINT, 0.8)) add(line(x0, PLOT_B - 24, x1 + 24, PLOT_B - 24, RULE, 1.0)) add(txt(x1 + 24, PLOT_B - 10, "拍(1 kHz)", TINY_FONT, MUTED, anchor="end")) # 尾部相位差说明 add(txt(x0, PLOT_B + 6, "发生器频率与 1 kHz 时基不严格同步:每过一个窗,尾部多算 / 少算一小段 →", BODY_FONT, MUTED)) add(txt(x0, PLOT_B + 24, "基波值围绕真值上下小幅摆动(拍频式)→ 每一行心跳落在摆动带的不同位置", BODY_FONT, MUTED)) return out # ----------------------------- 右:取数操作 ----------------------------- def operation(): out = [] add = out.append add(txt(RPX, HDR_Y, "操作:这 10 行里取哪个数当代表", HEAD_FONT, INK, weight=700)) ax0, ax1 = RPX, RPX + RPW - 150 ay0, ay1 = PLOT_T + 8, PLOT_B - 8 def Y(v): return ay1 - (v - YLO) / (YHI - YLO) * (ay1 - ay0) def X(i): return ax0 + 24 + i * ((ax1 - ax0 - 40) / 9.0) add(line(ax0, ay0 - 12, ax0, ay1 + 6, LINE, 1.6)) add(line(ax0, ay1 + 6, ax1, ay1 + 6, LINE, 1.6)) for v in (990, 1000, 1010, 1030): add(line(ax0 - 4, Y(v), ax1, Y(v), "#e2e8f0" if v != 990 else RULE, 0.8)) add(txt(ax0 - 8, Y(v) + 4, str(v), TINY_FONT, MUTED, anchor="end")) add(txt(ax0 - 8, ay0 - 16, "ua_mv", TINY_FONT, MUTED, anchor="end")) srt = sorted(VALS) med = (srt[4] + srt[5]) / 2.0 mean = sum(VALS) / float(len(VALS)) # 三条水平基准:右侧标签逐条错开(线位太近,同高必叠字) lx = ax1 + 8 add(line(ax0 + 6, Y(TRUE_V), ax1, Y(TRUE_V), MUTED, 1.2, dash="6 4")) add(txt(lx, Y(TRUE_V) + 15, "真值(万用表 × 1000)", TINY_FONT, MUTED)) check_fit("真值(万用表 × 1000)", TINY_FONT, RIGHT - lx, "lbl true") add(line(ax0 + 6, Y(med), ax1, Y(med), C_GREEN, 2.4)) add(txt(lx, Y(med) - 3, "中位数 %g ← 本条取它" % med, TINY_FONT, C_GREEN, weight=700)) check_fit("中位数 %g ← 本条取它" % med, TINY_FONT, RIGHT - lx, "lbl med") add(line(ax0 + 6, Y(mean), ax1, Y(mean), C_BLUE, 1.4, dash="2 3")) add(txt(lx, Y(mean) - 20, "平均 %g(被拖高)" % mean, TINY_FONT, C_BLUE)) check_fit("平均 %g(被拖高)" % mean, TINY_FONT, RIGHT - lx, "lbl mean") # 十个点 for i, v in enumerate(VALS): c = C_ROSE if i == GLITCH_I else LINE r = 4.6 if i == GLITCH_I else 3.6 add('' % (X(i), Y(v), r, c)) add(txt(X(i), ay1 + 22, str(i + 1), TINY_FONT, MUTED, anchor="middle")) add(txt(X(GLITCH_I), Y(VALS[GLITCH_I]) - 10, "毛刺行", TINY_FONT, C_ROSE, weight=700, anchor="middle")) add(txt(ax0 + 24, ay1 + 40, "心跳行号(每行间隔 200 ms)", TINY_FONT, MUTED)) return out # ----------------------------- 生成 ----------------------------- def build(): avail = RIGHT - PAD check_fit(TITLE, TITLE_FONT, avail, "title") check_fit(SUB1, SUB_FONT, avail, "sub1") out = ['' % (W, H, WHITE)] out.append(txt(PAD, 42, TITLE, TITLE_FONT, INK, weight=700)) out.append(txt(PAD, 68, SUB1, SUB_FONT, MUTED)) out.extend(mechanism()) out.extend(operation()) # 竖向分隔 out.append(line(497.0, 120.0, 497.0, 452.0, RULE, 1.0)) ny = 462 for i, ln in enumerate(wrap(OPS_NOTE, BODY_FONT, avail, 2, "ops")): out.append(txt(PAD, ny + i * 17, ln, BODY_FONT, INK, weight=700)) fy = ny + 40 out.append(line(PAD, fy, RIGHT, fy, RULE, 1.0)) for i, ln in enumerate(wrap(FOOTNOTE, SUB_FONT, avail, 2, "foot")): out.append(txt(PAD, fy + 20 + i * 18, 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()