diff --git a/docs/images/交流六轮速查.svg b/docs/images/交流六轮速查.svg new file mode 100644 index 00000000..cdbd4928 --- /dev/null +++ b/docs/images/交流六轮速查.svg @@ -0,0 +1,106 @@ + + +幅值轮 +先做 · 主用例 + + + + + +峰 ±1.414 V · 基波 RMS 1.000 V +注入 +UA(R31)端子侧,正弦 50.000 Hz、1.000 V +rms +判据 +ua_mv 中位数 ≈ 万用表 × 1000,偏差 ≤2 % +出口 +meas: ua_mv 为主;104 0x4011 只作交叉读 +数 + +波形轮 +真 RMS ≠ 基波 + + + +正弦 + +方波 + +三角 +同峰值对齐(不是同 Vrms) +注入 +同峰值 1.414 V 改方波 / 三角(面板有效值各 +异) +判据 +读数只跟基波:方波 ≈1278、三角 ≈817,±2 % +出口 +meas: ua_mv;抬头记万用表响应类型 + +相位轮 +定量 · 取段 + + + + +UA 0° 实线 +UN +90° 红虚线 +注入 +UA(R31)与 UN(R28)同源同幅 1.000 Vrms +判据 +同段内相位差 ≈0°,B 通道改 90° 应跟到 ≈90 +°,±2° +出口 +wave_read.py fft 定量;phasor(hb) 只核 +象限 + +测频轮 +A 档无数 = 过 + + + +45 Hz + +50 Hz + +55 Hz +注入 +UA 同幅值轮接法,扫 45 / 50 / 55 Hz +判据 +A 档如实报不可测:valid=0、UI 显 --;报 5 +0.00 反而是 bug +出口 +freq(hb) 0.01 Hz;UI Hz、104 0x401D + +削顶轮 +负向 · 单独日志 + + + +负半周钉 0(贴下轨) +注入 +UB(R52):DC −1.00 V 保持 ≥5 s,或 2.0 +Hz 正弦 +判据 +连续 20 拍贴底 → rail / edge 锁存 + 104 +该路 IV 清零;50 Hz 正弦不锁存 = 预期 +出口 +cal: rail / edge;取段再跑 shape 对表 + +谐波轮 +定量 · 取段 + + + +基波 + 2 次 + 5 次 +注入 +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 +目检旁证 + +顺序与耗时见 §8.6(约 135 min 一趟 session);[ERR] 判读见 §8.7,失败先查 §8.8 分诊表。波形缩略图只表形状,不代表真实幅度刻度。 + diff --git a/docs/images/交流定数取中位数.svg b/docs/images/交流定数取中位数.svg new file mode 100644 index 00000000..f0a66a58 --- /dev/null +++ b/docs/images/交流定数取中位数.svg @@ -0,0 +1,100 @@ + + +交流轮为什么连取 10 行、取中位数 —— 机理与操作 +meas: 是瞬时基波有效值透传:200 ms 是上送节拍、不是平均窗(数据可信 §4.5)——单行可能恰好抓到波峰或波谷,毛刺行会拖偏算术平均,中位数对两头都钝感 +机理:窗定宽 20 拍,截到的相位逐窗滑动 + + +窗 A + +窗 B(下一行心跳) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +拍(1 kHz) +发生器频率与 1 kHz 时基不严格同步:每过一个窗,尾部多算 / 少算一小段 → +基波值围绕真值上下小幅摆动(拍频式)→ 每一行心跳落在摆动带的不同位置 +操作:这 10 行里取哪个数当代表 + + + +990 + +1000 + +1010 + +1030 +ua_mv + +真值(万用表 × 1000) + +中位数 1001.5 ← 本条取它 + +平均 1003.6(被拖高) + +1 + +2 + +3 + +4 + +5 + +6 + +7 + +8 + +9 + +10 +毛刺行 +心跳行号(每行间隔 200 ms) + +操作:稳定 ≥30 s 后,把连续 10 行心跳的 ua_mv 记进记录表(备注顺手记跳动区间 min–max),排序取第 5、6 个的平均 = 本条代表数;偏差 % 用它对万用表 × 1000。 + +非整 50 Hz 时 meas: 来回跳是预期(§8.8 分诊同一条);扫频时读 freq(hb),别读 meas:。左图频差为示意刻意夸大,实拍跳动只有几个 LSB 的量级。 + diff --git a/docs/测试说明/专项验证/ADC前端注入验证流程.md b/docs/测试说明/专项验证/ADC前端注入验证流程.md index 9a938931..ecf05068 100644 --- a/docs/测试说明/专项验证/ADC前端注入验证流程.md +++ b/docs/测试说明/专项验证/ADC前端注入验证流程.md @@ -291,6 +291,10 @@ TPAFE 是双极性、二进制补码,可以注负压;STM32 是单极性, > > **引用写法**:本节凡引用判据文档的小节,一律写成「文件名 + 小节号」的紧邻形式;只写 §8.x 的是本文自己的节。 +先下图建立全局轮廓——每一轮注什么波形、验什么、读哪个出口,再进 §8.5 逐轮执行卡: + +![六轮一图速查:注入信号形状 → 接法与设定 → 判据一句话 → 主读数出口;过判据属主在 数据可信.md §4.6.2](../../images/交流六轮速查.svg) + ### 8.1 动手前先把出口分辨率算清楚(决定今天能判到哪一档) 这一轮和直流轮次**不一样:样本出口已经有了**。2026-10-29 故障录波的数据面并入主干,一次触发就是一段 @@ -369,6 +373,18 @@ TPAFE 是双极性、二进制补码,可以注负压;STM32 是单极性, 4. **电流通道也是注电压**:`IA` 的注入点同样是电压进 R19 端子侧。这一轮证的是"给定引脚电压算得对不对", 不是"一次侧 1 A 怎么办"——后者要变比,属 `docs/任务规划/数据可信.md` §5 的端子 / 子板变比确认。 +四条接线画在一张图上(发生器 → 端子侧焊盘是一条主干,万用表并接、共地是两条必须存在的旁路): + +```mermaid +flowchart LR + G["信号发生器
输出端接 = 高阻
(按 50 Ω 端接读数差 2 倍)"] -->|"注 UA / UN / IA:端子侧焊盘
(1:1 已由直流轮次实测)"| R31["串阻 1 k 的 1 脚侧
R31 / R28 / R19 …"] + R31 --> FE["前端分压 / 滤波"] --> U5["U5 排针 → 转换器引脚
(TPAFE AIN_x 或 STM32 脚)"] + M["万用表 AC 档
记响应类型"] -.->|"并接同一注入点
量真实加到板上的电压"| R31 + G -.->|"LO/GND 与板 AGND
单点共地"| AGND["板 AGND"] + AGND -->|"不共地量到的是两地之差"| U5 + G -.->|"注 UB 等 STM32 那五路(仅削顶轮)
负半周经片内钳位 ≈1.1 mA"| R52["R52 端子侧"] --> STM["STM32 ADC 脚
(无外置钳位 / 隔直电容)"] +``` + ### 8.4 记录表抬头(每条用例一份,与日志一起归档) ```text @@ -389,6 +405,31 @@ TPAFE 是双极性、二进制补码,可以注负压;STM32 是单极性, **瞬时基波有效值透传**、200 ms 是上送节拍不是平均窗(`docs/任务规划/数据可信.md` §4.5), 单行读数带拍频式跳动,必须看一片取**中间水平(中位数)**才能定数。 +通用动作主干与两处分岔(采集链没清 → 回头修;偏差没进 → 先复核查再判): + +```mermaid +flowchart TD + W["接好本路,开机前置门已过(§8.2)"] --> ON["开发生器输出"] + ON --> ST["等 ≥30 s:相量窗与 meas 轨稳定"] + ST --> HB["取连续 ≥10 行心跳"] + HB --> Q1{"frames 在涨、adc1_err = 0 ?"} + Q1 -->|"否:是节拍 / 丢帧问题"| TRI["先查 §8.8 分诊
别归因给波形"] + TRI -.->|"修复后重来"| ST + Q1 -->|是| MED["10 行读数排序
取第 5、6 个的平均 = 中位数"] + MED --> Q2{"与期望的偏差在判据内?"} + Q2 -->|是| OK["记通过"] + Q2 -->|"否"| CHK["先复核查:发生器端接、
万用表响应类型、共地"] + CHK -->|"复查仍超"| BAD["记不通过,进 §8.8 分诊"] + CHK -->|"查出问题改完重测"| ST + OK --> OFF["关输出,填记录表"] + BAD --> OFF + OFF --> NEXT["下一条 / 下一轮"] +``` + +为什么这一步是"中位数"而不是单行或平均——机理与操作: + +![为什么连取 10 行取中位数:窗滑动让读数拍频式上下跳(左);中位数贴着密集簇,平均被毛刺拖走(右)](../../images/交流定数取中位数.svg) + #### 幅值轮:基波幅值(主用例,先做) | 项 | 内容 | diff --git a/tools/arch_diagram/README.md b/tools/arch_diagram/README.md index cb428933..1673ec0f 100644 --- a/tools/arch_diagram/README.md +++ b/tools/arch_diagram/README.md @@ -25,6 +25,8 @@ | `gen_sample_ring_why_svg.py` | 采样环缓冲_为什么需要 | 上游 1 kHz 覆盖写只交最新一帧 vs 下游要 20 点滑窗:缺口 19 点与三个消费者 | | `gen_sample_ring_layout_svg.py` | 采样环缓冲_环形布局 | 40 格环里哪 20 格是本窗、写指针指「下一格」、回绕与三条设计不变量 | | `gen_sample_ring_start_svg.py` | 采样环缓冲_窗首下标 | 窗首 = head − n 的圆周走法,与「直接减 / 16 位回绕 / 先加 DEPTH」三种写法对照 | +| `gen_ac_rounds_svg.py` | 交流六轮速查 | 执行手册 §8 六轮总览卡:每轮注什么波形(正弦/方波三角/异相/扫频/削顶/谐波叠加缩略图)、判据一句话、主读数出口 | +| `gen_ac_median_svg.py` | 交流定数取中位数 | 「连取 10 行取中位数」的机理(全周窗滑动 → 读数拍频跳)与操作(中位数 vs 平均 vs 单行对真值)两栏数值示意 | ## 使用方式 diff --git a/tools/arch_diagram/gen_ac_median_svg.py b/tools/arch_diagram/gen_ac_median_svg.py new file mode 100644 index 00000000..9da983a2 --- /dev/null +++ b/tools/arch_diagram/gen_ac_median_svg.py @@ -0,0 +1,274 @@ +#!/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() diff --git a/tools/arch_diagram/gen_ac_rounds_svg.py b/tools/arch_diagram/gen_ac_rounds_svg.py new file mode 100644 index 00000000..65f24668 --- /dev/null +++ b/tools/arch_diagram/gen_ac_rounds_svg.py @@ -0,0 +1,323 @@ +#!/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()