6ffbb7d5b5
§8 开头插「六轮速查卡」:3x2 面板,每轮注入信号缩略图数据驱动
(正弦 / 同峰值方波三角 / UA·UN 异相 / 扫频 / 负半周钉 0 / 谐波叠
加),一卡给接法、判据一句话、主读数出口,先立轮廓再进执行卡。
§8.5 插「为什么连取 10 行取中位数」概念图:左栏机理(定宽 20 拍
窗逐窗滑动 → 读数拍频跳),右栏操作(中位数 / 平均 / 真值三线
错位对照 + 毛刺行标注)——取数口径 4e4d597e 刚定,此图补原理。
§8.3 / §8.5 各加一张 Mermaid:接线拓扑(端接高阻、万用表并接、
单点共地、STM32 五路仅削顶轮支线);单轮执行主干(采集链与偏差
两级决策、分诊虚线回环,汇于关输出填表)。
新增生成器 gen_ac_rounds_svg.py / gen_ac_median_svg.py(纯标准库、
OVERFLOW 预检归零、generated-fresh 自动在册),README 补两行台账。
验证:两产物连跑两次 sha1 不变;check_generated_fresh rc=0 无
DIFF / NEW、漂移基线不增;mermaid-syntax 2 围栏新发 0;headless
Chrome 截图回读三轮,修掉两类只有渲染才暴露的真错(正文 x 在卡
循环外算导致第 2、3 列叠进第 1 列;三角波闭式值域 −2..4 画越界
尖峰)。六文件 BOM / CR / U+FFFD 均零。
注:当前 check_doc_links --all 报 27 处,全属并发会话在改的产品
需求三份文档,与本笔无关、未触碰。
275 lines
11 KiB
Python
275 lines
11 KiB
Python
#!/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 ('<text x="%.1f" y="%.1f" font-size="%g"%s%s fill="%s">%s</text>'
|
||
% (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 ('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="%g"%s/>'
|
||
% (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 '<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="%g" fill="%s"%s/>' % (
|
||
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 ('<polyline points="%s" fill="none" stroke="%s" stroke-width="%g" '
|
||
'stroke-linejoin="round"%s/>' % (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('<circle cx="%.1f" cy="%.1f" r="%.1f" fill="%s"/>' % (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 = ['<rect x="0" y="0" width="%d" height="%d" fill="%s"/>' % (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 = ('<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" '
|
||
'viewBox="0 0 %d %d" font-family="%s">\n%s\n</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()
|