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