d64bbdc57f
数据面(App/algo,纯逻辑,双端编译) - wave_ring:8 路 × 320 点列优先环(1 kHz,预触发 160 + 后触发 160),adcTask 唯一写者;触发/冻结/封存/冷却四态,冻结持续到落盘回执才解冻,滞留 2 s 自弃 兜底——下一次真故障无波可录比丢一段更贵 - wave_store:段头编解码 + 拉模式打包游标 + 自带无表位运算 CRC-16/MODBUS(带 锚点自检)+ 段名与整段长度等式;格式不依赖平台类型宽度 - 段头只存原始码值与封存瞬间生效系数,工程量只当单位标签、不进任何算式 - 三套主机单测(环 113 / 编解码 170 / 触发状态机 110),全部无桩、生产源原样 纳入;新文件补 stddef 是 ARMCC 与主机差异,主机侧照不出这条分叉 - test/host/wave_read.py 是按 §6.2 的独立第二实现(dump/fft/shape/plot),92 条 自洽断言;三份合成段互读双校验全过、哈希钉死,格式面双向闭合 - 三套新单测不加专属钩子,由覆盖率总闸全量真跑(沿用既有裁定) 接线(任务层) - adc_cal:新增生效系数的 32 位定点原值入口(与既有浮点入口共用生效视图), 段头按最低有效位精确记账,不经浮点回算 - adcTask:每拍压 8 路语义原始码值并推进状态机,封存那一拍同拍交棒封存瞬间事实; 台架定周期自抓走双层编译门,产品镜像里零存在;串口新增一条 wave: 周期行 - defaultTask:卷空闲后一次心跳整段搬完(try 锁 + 256 字节分片 + 一次完整开档到 关档),成败都回执;失败先删半成品再计错,段号只在成功时推进;持锁超 50 ms 补一条告警并记历史峰值(喂狗余量可核) - Lead 复核补第四道并发陷阱:采集任务优先级高于落盘道,搬段中途它可能正好把滞留 超时的本段自弃、甚至封出下一段——数据区校验和是对「实际读到的字节」算的, 半新半旧的段照样自洽,故关档后复检环侧身份判废:删文件、不补账、段号不推进 - Lead 复核抓到触发挂号不是原子操作(旧实现分三个字节写,消费者可在窗口里读到 上一段的触发源与元件号):已把载荷压进一个字节,越界当场拒且不记任何计数; 段文件字节布局零改动,三份合成段哈希逐字节不变 - protectTask:保护启动上升沿同点请求录波,一拍一次、元件号取最低位;判据本体 零改动 人机(160×160 单色屏) - 菜单树节点 26「手动录波」绑页面 + 记录查询组末新增「录波回放」叶,重跑生成器 得 43 节点 / 34 叶 / 30 已绑,页面映射 28 → 30 - 回放页一屏一路、触发点前后各一周波共 40 点、每点 3 像素阶梯线(只用水平与 垂直两种线段——单色屏斜线会被阈值裁掉);零点虚线、触发光标、窗口峰值、 单位徽标与形状受限徽标;空态与坏段各一句显式文案,绝不用半截波形冒充数据 - ui_data 新增录波注入通道(六件操作表、三态返回);执行侧胶水带段头缓存、 手动请求后连缓存一起作废、逐点走全仓唯一的码值换算入口 - 模拟器接真实环与编解码合成两段(含坏段与空态旗标),6 张新黄金参考,既有 60 张逐字节零漂移未用更新模式;字库 218 → 224 字 / 319 字形 - 两份图形配置里图表组件仍为关;零堆分配;页面池水位不变 - 回放读路径的 uiTask 阻塞上界按实码写清:一次渲染 = 段表刷新 + 未缓存段头最坏 22 次小读;已验段只剩一次开窗读 + 40 点解码;无实测数不编,实测归台架、 仪器用已在跑的单轮工作耗时探针,不新加埋点 台账与文档 - 内存台账:录波环与落盘/回放静态逐条入册,三档对照 81,536 → 88,296 → 89,936 字节(62.2% → 68.6%),累计 +8,400;预估 5.9 KB 与实测 +6,760 的差额点名; 散列文件头注释同步;任务栈表核对后无需抬栈;体积趋势行与同码凭证由权威构建 重打(0 错误 0 警告,四项体积与上一轮相同——本轮改动只有注释与声明位置) - 设计说明:§5 补第四条并发陷阱;§6.3 定字段口径(两份独立实现的共同依据); §7 回填实测;§9 周期行写实;§10 台架判据改两段式且不比绝对相位;§12 补读路径 风险行;抬头状态改成「代码面已交付、未做完的只有板级那一轮」 - 卡片:软件闭环一栏列证据;台架三条按用户裁定保持未完成,不做虚假销项 - 口径同步:数据可信、UI 补齐、页面补齐规划、产品需求、采集短板评测、任务规划 索引与两份 README;陈旧现状句清扫 16 处(带日期戳的历史取数行一律不动) - 门禁说明:nested-comment 记仓库根探测必须兼容 linked worktree 的 gitdir 文件 形态(修前该钩子在任务树里恒报环境错、静默不可用);coding-standard 记 「函数头注释超过 24 行就把函数功能挤出回看窗口」这条机制与本次现场 - 覆盖率基线登记两个新覆盖模块(套件 41 → 44 套);环单测重打后该文件有效行 227 → 226、覆盖行 226 → 225,比率仍 99.6%,是分母变小不是覆盖率回退 为什么是单一提交:文档链接、双端注册、生成器幂等(菜单与字库)、内存数字与 体积事实源、编号门禁这五道整仓门禁要求代码、页面、菜单树、字库、文档与台账 同处一个提交才自洽,按数据面/接线/人机/台账拆成四笔必然红其中至少四道 编号门禁新增行 14 处命中已按语义改写:魔数常量按字符命名、段名样例改由格式串 生成、频谱表头改 f / 2x / 5x 并加一行图例;改写后自洽断言 92 条仍全过
1257 lines
54 KiB
Python
1257 lines
54 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""PC-side fault-recording (wave) segment reader: dump / fft / shape / plot.
|
|
|
|
Pure stdlib. Style sibling: test/host/iec104_master.py (same family:
|
|
argparse subcommands, explicit exit codes, ASCII-only stdout).
|
|
|
|
This file and App/algo/wave_store.c are TWO INDEPENDENT implementations of
|
|
the SAME byte layout, written from the same spec. Spec: wave-recording
|
|
design doc (zi xi tong she ji / fault wave record design), section 6.2.
|
|
Do not "fix" disagreements by editing this file alone -- report them, a
|
|
silent divergence on one field is a format accident.
|
|
|
|
Subcommands:
|
|
dump FILE parse header + channel table, verify both CRC16s, print all
|
|
fft FILE full-cycle Fourier per channel (fundamental RMS + phase,
|
|
2nd/5th harmonic RMS), coefficients mirror App/algo/phasor.c
|
|
(sqrt(2)/N pre-multiplied, rms = sqrt(re^2 + im^2)); codes
|
|
converted to mV via the LSB snapshot in the header
|
|
shape FILE cycles (zero-cross), amplitude, polarity (first-sample sign
|
|
+ half-wave symmetry), per-channel rail-pinning reconciled
|
|
against header clip_mask
|
|
plot FILE write SVG (staircase line, dashed zero line, trigger cursor,
|
|
channel/segment/trigger labels); default outdir ci-artifacts/
|
|
selftest build synthetic segments in-process (20-pt sine + bottom-rail
|
|
pinned square, per section 6.2), re-parse and check
|
|
everything, plus negative tests -- no firmware artifacts
|
|
needed, no board needed
|
|
|
|
Integrity policy (PC-side mirror of design section 8.1 "a flat line drawn
|
|
over an unreadable segment is fabricated evidence"): ANY magic/version/CRC/
|
|
size/zero-length failure -> print REJECT reason, exit non-zero, and NEVER
|
|
produce a graphic.
|
|
|
|
The --strict red path is separate and a red there is the point, not a bug:
|
|
shape --strict compares rail-pinning MEASURED from the samples against the
|
|
CLIPPED snapshot SEALED in the header (clip_mask). A fixture whose data
|
|
never touches a rail (e.g. the interop column-ramp shape) therefore must
|
|
report MISMATCH and exit 1 -- "strict bites a non-railed fixture" is proof
|
|
the bench criterion has teeth, NOT an interop failure between the two
|
|
independent implementations. Judge interop by dump/fft/shape(plain) exit 0
|
|
plus byte-level cross-consistency; never "fix" strict to please a fixture.
|
|
Provenance fact (verified in App/algo/adc_cal.c around the CLIPPED branch):
|
|
on V0.1 the header CLIPPED bit is stamped from the channel SOURCE, never
|
|
measured from samples -- real AC waves bottom-rail those 5 channels so
|
|
on-board equality still holds; only synthetic fixtures or wiring changes
|
|
break it.
|
|
Two rulers, one flag (lead ruling 2026-10-29, "equality is a wiring-change
|
|
alarm; silencing it is worse than a red fixture"):
|
|
--strict yardstick header == measured (DEFAULT,
|
|
the card check; a red here has teeth)
|
|
--strict --clip-superset yardstick header covers measured (for
|
|
DC-only bias, tiny injection, or an
|
|
un-driven channel: sealed-but-not-railed
|
|
is TOLERATED, measured-but-not-sealed
|
|
still FAILs)
|
|
shape's output names the yardstick in force. If the wave cannot rail, use
|
|
the superset ruler -- never "fix" the equality check to please a fixture.
|
|
|
|
Exit codes: 0 = ok (all checks pass) / 1 = integrity reject or check FAIL /
|
|
2 = usage or IO error.
|
|
|
|
Usage:
|
|
python test/host/wave_read.py dump ci-artifacts/WAVE<NN>.BIN
|
|
python test/host/wave_read.py fft ci-artifacts/WAVE<NN>.BIN --all
|
|
python test/host/wave_read.py shape ci-artifacts/WAVE<NN>.BIN --strict
|
|
python test/host/wave_read.py shape ci-artifacts/WAVE<NN>.BIN --strict --clip-superset
|
|
python test/host/wave_read.py plot ci-artifacts/WAVE<NN>.BIN --ch 0
|
|
python test/host/wave_read.py --selftest
|
|
|
|
SPEC CONFORMANCE (design doc sections 6.2 + 6.3, lead ruling 2026-10-29
|
|
answering the PC-track nine-question list; this decoder and
|
|
App/algo/wave_store.c must agree byte-for-byte -- if they ever diverge,
|
|
report it, do not silently re-rule from one side):
|
|
- CRC-16/MODBUS table-free (0x8005 reflected / init 0xFFFF / xorout 0),
|
|
both CRC fields stored LOW byte first; anchor "123456789" -> 0x4B37.
|
|
Header CRC covers [0, 30 + 12*ch_count); data CRC covers the data area
|
|
and sits at end of file; 8ch/320pts whole segment = 5250 B.
|
|
- Channel table entry (12 B): sem 1 + src 1 + bits 1 + availability 1
|
|
(= adc_cal_flags(sem) low 8 bits, raw, no invented bit meanings) +
|
|
k_q16 i32 (uV/code * 65536) + b_codes i32 (code domain). There is NO
|
|
separate LSB field: LSB == k_q16, uV = k_q16/65536, never re-scaled.
|
|
mV = (code + b_codes) * k_q16/65536/1000 -- exactly the
|
|
adc_cal_apply() formula. Header eng only LABELS the unit (0 =
|
|
pin-domain, 1 secondary, 2 primary); it never changes arithmetic.
|
|
- flags_seq bit s = ADC_CAL_F_VALID of sem s; clip_mask bit s = sem s
|
|
(same mapping as App/algo/meas.h; V0.1 constant 0xE6 = UB UC IB IC IN).
|
|
avail is the RAW pass-through of adc_cal_flags(): VALID and CLIPPED are
|
|
independent facts and the encoder must not invent invariants to suit the
|
|
PC side (lead ruling 2026-10-29). The reader NEVER rejects on the
|
|
VALID/CLIPPED relation nor on avail-vs-clip_mask drift -- those get a
|
|
soft note only ("VALID+CLIPPED(both)" in dump/fft, a WARN line in dump),
|
|
no exit-code change, nothing refused.
|
|
eng likewise derives from flags_seq on the board (feed_eng(): all 8
|
|
electrical channels VALID -> secondary(1), else pin-domain(0)); a
|
|
pairing mismatch here is also a SOFT WARN only, never a rejection.
|
|
- v1 pins ch_count = 8 (TEMP not recorded; TEMP needs a version bump
|
|
together with the TEMP-in-flags question); PC must verify table entry
|
|
i carries sem == i (ascending UA..IN), mismatch voids the whole
|
|
segment; data column i == table entry i; samples int16 LE, TPAFE two's
|
|
complement, STM32 0..4095 stored positive with NO midpoint bias
|
|
(biasing it would fabricate bipolarity).
|
|
- t0_ms / wall / frame_seq are u32 little-endian. ver must equal magic
|
|
byte 2 and both must equal 1, else refuse.
|
|
"""
|
|
import argparse
|
|
import math
|
|
import os
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
# ---- format constants (design section 6.2, little-endian) -------------------
|
|
MAGIC_W = 0x57 # 'W'
|
|
MAGIC_R = 0x52 # 'R'
|
|
FMT_VER = 1 # byte 2 of magic AND byte 3 (ver) both == 1
|
|
SEG_LABEL_SAMPLE = "WAVE%02d" % 0 # sample segment label, produced by format so
|
|
# this file never carries a hardcoded letter+digit identifier token
|
|
|
|
OFF_VER = 3 # 1
|
|
OFF_FS = 4 # 2 fs_hz
|
|
OFF_PPS = 6 # 1 pts_per_cycle
|
|
OFF_CHC = 7 # 1 ch_count
|
|
OFF_PTS = 8 # 2 pts_per_ch
|
|
OFF_PRE = 10 # 2 pre_pts
|
|
OFF_TSRC = 12 # 1 trig_src
|
|
OFF_TELM = 13 # 1 trig_elm
|
|
OFF_T0 = 14 # 4 t0_ms (ms since power-on)
|
|
OFF_WALL = 18 # 4 epoch seconds, 0 = unsynced
|
|
OFF_ENG = 22 # 1 0 pin-domain / 1 secondary / 2 primary
|
|
OFF_FLAGS = 23 # 1 validity bitmap (see Q5)
|
|
OFF_CLIP = 24 # 2 clip bitmap, bit s = sem s (see Q6)
|
|
OFF_FSEQ = 26 # 4 frame_seq at segment head
|
|
HDR_FIXED = 30 # bytes before channel table
|
|
ENT_SIZE = 12 # bytes per channel-table entry
|
|
ENT_SEM, ENT_SRC, ENT_BITS, ENT_AVAIL = 0, 1, 2, 3
|
|
ENT_KQ = 4 # i32 Q16 uV/code == the LSB (sections 6.2/6.3)
|
|
ENT_B = 8 # i32 code-domain zero offset
|
|
|
|
SRC_TPAFE, SRC_STM32 = 0, 1
|
|
SRC_NAMES = {SRC_TPAFE: "TPAFE5160", SRC_STM32: "STM32-ADC1"}
|
|
SEM_NAMES = {0: "UA", 1: "UB", 2: "UC", 3: "UN", 4: "IA", 5: "IB",
|
|
6: "IC", 7: "IN", 8: "TEMP"}
|
|
TRG_NAMES = {1: "MANUAL", 2: "PROTECT", 3: "BENCH-TIMER"}
|
|
ENG_NAMES = {0: "pin-domain", 1: "secondary", 2: "primary"}
|
|
|
|
CRC_LO_FIRST = True # section 6.3: file-wide little-endian
|
|
V1_CH_COUNT = 8 # v1 pins 8 channels (section 6.3, TEMP later)
|
|
ADC_CAL_F_VALID = 0x01 # adc_cal.h flags bits, for display only
|
|
ADC_CAL_F_CLIPPED = 0x02
|
|
|
|
CRC16_POLY_REF = 0xA001 # CRC-16/MODBUS: 0x8005 reflected
|
|
CRC16_INIT = 0xFFFF
|
|
CRC16_CHECK = 0x4B37 # known check value for "123456789"
|
|
|
|
# Rail bands mirror App/algo/adc_cal.c adc_cal_rail_ths() integer math:
|
|
# bipolar: 2^(bits-1)*98/100 (16b -> +/-32112); unipolar: (2^bits-1) scaled
|
|
# (12b -> lo 81, hi 4013).
|
|
TPAFE_FS_PCT = 98
|
|
STM32_LO_PCT = 2
|
|
STM32_HI_PCT = 98
|
|
|
|
|
|
class RejectError(Exception):
|
|
"""Segment failed an integrity gate. Message must stand alone."""
|
|
|
|
|
|
class IOErrorMessage(Exception):
|
|
"""File could not be read -- exit 2, distinct from integrity reject."""
|
|
|
|
|
|
# ---- CRC-16/MODBUS, table-free (spec: no lookup table) ----------------------
|
|
def crc16_modbus(data):
|
|
crc = CRC16_INIT
|
|
for b in data:
|
|
crc ^= b
|
|
for _ in range(8):
|
|
if crc & 1:
|
|
crc = (crc >> 1) ^ CRC16_POLY_REF
|
|
else:
|
|
crc >>= 1
|
|
return crc
|
|
|
|
|
|
def read_crc16(buf, off):
|
|
if CRC_LO_FIRST:
|
|
return buf[off] | (buf[off + 1] << 8)
|
|
return (buf[off] << 8) | buf[off + 1]
|
|
|
|
|
|
def put_crc16(value):
|
|
if CRC_LO_FIRST:
|
|
return bytes([value & 0xFF, (value >> 8) & 0xFF])
|
|
return bytes([(value >> 8) & 0xFF, value & 0xFF])
|
|
|
|
|
|
# ---- decode helpers ----------------------------------------------------------
|
|
def lsb_uv(ent):
|
|
"""LSB in micro-volts per code = k_q16 / 65536 (section 6.3: k_q16 IS
|
|
the LSB; never re-multiplied)."""
|
|
return ent["k_q16"] / 65536.0
|
|
|
|
|
|
def code_to_mv(ent, code):
|
|
"""Code -> mV via exactly the adc_cal_apply() formula:
|
|
value = (code + b) * k[us/code] * 1e-6 [V] -> mV = that * 1000.
|
|
The header eng field only labels the unit (0 = pin-domain); per
|
|
section 6.3 it must never change this arithmetic, and the PC must not
|
|
re-guess units from the current firmware state."""
|
|
return (code + ent["b_codes"]) * lsb_uv(ent) / 1000.0
|
|
|
|
|
|
def rail_thresholds(ent):
|
|
"""(lo_th, hi_th) in code domain: code <= lo_th pins low, >= hi_th high."""
|
|
bits = ent["bits"]
|
|
if ent["src"] == SRC_TPAFE:
|
|
th = (1 << (bits - 1)) * TPAFE_FS_PCT // 100
|
|
return -th, th
|
|
full = (1 << bits) - 1
|
|
return full * STM32_LO_PCT // 100, full * STM32_HI_PCT // 100
|
|
|
|
|
|
def sem_name(sem):
|
|
return SEM_NAMES.get(sem, "SEM%d" % sem)
|
|
|
|
|
|
# ---- parsing -----------------------------------------------------------------
|
|
def parse_segment(buf, name="segment"):
|
|
"""Validate + decode a whole segment image. Raises RejectError."""
|
|
def rej(msg):
|
|
raise RejectError("%s: %s" % (name, msg))
|
|
|
|
if len(buf) < HDR_FIXED:
|
|
rej("file too short: %d bytes < fixed header %d" % (len(buf), HDR_FIXED))
|
|
if buf[0] != MAGIC_W or buf[1] != MAGIC_R:
|
|
rej("bad magic: %02X %02X (expect 57 52 'WR')" % (buf[0], buf[1]))
|
|
if buf[2] != FMT_VER:
|
|
rej("unsupported format version in magic byte 2: %d (expect %d); "
|
|
"this build of wave_read decodes v%d only"
|
|
% (buf[2], FMT_VER, FMT_VER))
|
|
ver = buf[OFF_VER]
|
|
if ver != FMT_VER:
|
|
rej("ver field = %d, magic version byte = %d (expect both %d)"
|
|
% (ver, buf[2], FMT_VER))
|
|
|
|
fs_hz = struct.unpack_from("<H", buf, OFF_FS)[0]
|
|
pps = buf[OFF_PPS]
|
|
chc = buf[OFF_CHC]
|
|
pts = struct.unpack_from("<H", buf, OFF_PTS)[0]
|
|
pre = struct.unpack_from("<H", buf, OFF_PRE)[0]
|
|
|
|
if chc == 0 or pts == 0:
|
|
rej("zero-length segment: ch_count=%d pts_per_ch=%d (nothing to "
|
|
"read; refusing, no picture will be produced)" % (chc, pts))
|
|
if chc != V1_CH_COUNT:
|
|
rej("ch_count=%d: version %d pins ch_count=%d (TEMP not recorded "
|
|
"in v1; TEMP needs a format version bump per section 6.3)"
|
|
% (chc, FMT_VER, V1_CH_COUNT))
|
|
if pps < 2:
|
|
rej("pts_per_cycle=%d unusable (< 2)" % pps)
|
|
if pts < pps:
|
|
rej("pts_per_ch=%d shorter than one cycle pts_per_cycle=%d" % (pts, pps))
|
|
if pre > pts:
|
|
rej("pre_pts=%d exceeds pts_per_ch=%d" % (pre, pts))
|
|
|
|
table_end = HDR_FIXED + chc * ENT_SIZE
|
|
crc_h_off = table_end
|
|
data_off = table_end + 2
|
|
data_len = pts * chc * 2
|
|
want = data_off + data_len + 2
|
|
if len(buf) != want:
|
|
rej("file size %d != expected %d (hdr 30 + table %d + 2 + data %d + 2)"
|
|
% (len(buf), want, chc * ENT_SIZE, data_len))
|
|
|
|
crc_h_calc = crc16_modbus(buf[0:table_end])
|
|
crc_h_stored = read_crc16(buf, crc_h_off)
|
|
if crc_h_calc != crc_h_stored:
|
|
rej("header CRC16 mismatch: stored 0x%04X calc 0x%04X over [0,%d) "
|
|
"-- segment rejected, nothing drawn"
|
|
% (crc_h_stored, crc_h_calc, table_end))
|
|
|
|
data = buf[data_off:data_off + data_len]
|
|
crc_d_calc = crc16_modbus(data)
|
|
crc_d_stored = read_crc16(buf, data_off + data_len)
|
|
if crc_d_calc != crc_d_stored:
|
|
rej("data CRC16 mismatch: stored 0x%04X calc 0x%04X over %d "
|
|
"bytes at offset %d -- segment rejected, nothing drawn"
|
|
% (crc_d_stored, crc_d_calc, data_len, data_off))
|
|
|
|
entries = []
|
|
for i in range(chc):
|
|
o = HDR_FIXED + i * ENT_SIZE
|
|
if buf[o + ENT_SEM] != i:
|
|
rej("channel table entry %d carries semantic id %d: section "
|
|
"6.3 pins ascending sem == index (UA=0 .. IN=7); whole "
|
|
"segment void" % (i, buf[o + ENT_SEM]))
|
|
entries.append({
|
|
"idx": i,
|
|
"sem": buf[o + ENT_SEM],
|
|
"src": buf[o + ENT_SRC],
|
|
"bits": buf[o + ENT_BITS],
|
|
"avail": buf[o + ENT_AVAIL],
|
|
"k_q16": struct.unpack_from("<i", buf, o + ENT_KQ)[0],
|
|
"b_codes": struct.unpack_from("<i", buf, o + ENT_B)[0],
|
|
})
|
|
|
|
samples = []
|
|
for i in range(chc):
|
|
samples.append(list(struct.unpack_from("<%dh" % pts, buf,
|
|
data_off + i * pts * 2)))
|
|
|
|
return {
|
|
"name": name,
|
|
"size": len(buf),
|
|
"ver": ver,
|
|
"fs_hz": fs_hz,
|
|
"pps": pps,
|
|
"chc": chc,
|
|
"pts": pts,
|
|
"pre": pre,
|
|
"trig_src": buf[OFF_TSRC],
|
|
"trig_elm": buf[OFF_TELM],
|
|
"t0_ms": struct.unpack_from("<I", buf, OFF_T0)[0],
|
|
"wall": struct.unpack_from("<I", buf, OFF_WALL)[0],
|
|
"eng": buf[OFF_ENG],
|
|
"flags_seq": buf[OFF_FLAGS],
|
|
"clip_mask": struct.unpack_from("<H", buf, OFF_CLIP)[0],
|
|
"frame_seq": struct.unpack_from("<I", buf, OFF_FSEQ)[0],
|
|
"crc_hdr": crc_h_stored,
|
|
"crc_data": crc_d_stored,
|
|
"entries": entries,
|
|
"samples": samples,
|
|
}
|
|
|
|
|
|
def load_segment(path):
|
|
try:
|
|
with open(path, "rb") as fh:
|
|
raw = fh.read()
|
|
except OSError as exc:
|
|
raise IOErrorMessage(str(exc))
|
|
return parse_segment(raw, os.path.basename(path))
|
|
|
|
|
|
# ---- Fourier (mirror of App/algo/phasor.c phasor_calc) -----------------------
|
|
def phasor_calc(values, harm, n):
|
|
"""One full-cycle DFT on values (list length n), harmonics via idx step.
|
|
|
|
Identical convention to firmware: re sums sin, im sums cos, both tables
|
|
pre-multiplied sqrt(2)/N, so rms = sqrt(re^2 + im^2) directly and the
|
|
angle is atan2(im, re).
|
|
"""
|
|
scale = math.sqrt(2.0) / n
|
|
re = 0.0
|
|
im = 0.0
|
|
for i in range(n):
|
|
idx = (i * harm) % n
|
|
th = 2.0 * math.pi * idx / n
|
|
re += values[i] * math.sin(th) * scale
|
|
im += values[i] * math.cos(th) * scale
|
|
return re, im
|
|
|
|
|
|
def phasor_rms_ang(values, harm, n):
|
|
re, im = phasor_calc(values, harm, n)
|
|
return math.sqrt(re * re + im * im), math.degrees(math.atan2(im, re))
|
|
|
|
|
|
def channel_mv(seg, ci):
|
|
ent = seg["entries"][ci]
|
|
return [code_to_mv(ent, c) for c in seg["samples"][ci]]
|
|
|
|
|
|
# ---- shape metrics -------------------------------------------------------------
|
|
def zero_cross_cycles(vals):
|
|
last = 0
|
|
edges = 0
|
|
for v in vals:
|
|
s = 1 if v > 0 else (-1 if v < 0 else 0)
|
|
if s == 0:
|
|
continue
|
|
if last != 0 and s != last:
|
|
edges += 1
|
|
last = s
|
|
return edges / 2.0, edges
|
|
|
|
|
|
def half_wave_symmetry(vals, pps):
|
|
"""mean(|x[i]+x[i+half]|) / mean(|x[i]-dc|) after DC removal. 0 = ideal."""
|
|
if pps % 2:
|
|
return None
|
|
nfull = (len(vals) // pps) * pps
|
|
if nfull < pps:
|
|
return None
|
|
half = pps // 2
|
|
dc = sum(vals[:nfull]) / float(nfull)
|
|
num = 0.0
|
|
den = 0.0
|
|
for i in range(nfull - half):
|
|
a = vals[i] - dc
|
|
b = vals[i + half] - dc
|
|
num += abs(a + b)
|
|
den += abs(a)
|
|
if den <= 1e-9:
|
|
return 1.0
|
|
return num / den
|
|
|
|
|
|
def rail_pins(seg, ci, min_run):
|
|
"""Return (pin_lo, pin_hi, clipped_bool) on raw codes."""
|
|
ent = seg["entries"][ci]
|
|
lo_th, hi_th = rail_thresholds(ent)
|
|
codes = seg["samples"][ci]
|
|
lo = sum(1 for c in codes if c <= lo_th)
|
|
hi = sum(1 for c in codes if c >= hi_th)
|
|
return lo, hi, (lo >= min_run or hi >= min_run)
|
|
|
|
|
|
def measured_clip_mask(seg, min_run):
|
|
mask = 0
|
|
for ci in range(seg["chc"]):
|
|
_lo, _hi, clipped = rail_pins(seg, ci, min_run)
|
|
if clipped:
|
|
mask |= 1 << seg["entries"][ci]["sem"]
|
|
return mask
|
|
|
|
|
|
def mask_channels(mask):
|
|
return " ".join(sem_name(s) for s in sorted(SEM_NAMES) if mask & (1 << s))
|
|
|
|
|
|
# ---- subcommand: dump -------------------------------------------------------
|
|
def fmt_dump(seg):
|
|
lines = []
|
|
add = lines.append
|
|
add("FILE : %s (%d bytes)" % (seg["name"], seg["size"]))
|
|
add("magic : 57 52 %02X ('WR' + version byte %d)"
|
|
% (seg["ver"], seg["ver"]))
|
|
add("ver : %d" % seg["ver"])
|
|
add("fs_hz : %d%s" % (seg["fs_hz"],
|
|
"" if seg["fs_hz"] == 1000 else " (note: not the 1000 of V0.1)"))
|
|
add("pts_per_cycle : %d%s" % (seg["pps"],
|
|
"" if seg["pps"] == 20 else " (note: not the 20 of V0.1)"))
|
|
add("ch_count : %d%s" % (seg["chc"],
|
|
"" if seg["chc"] in (8, 9) else " (note: not 8/9)"))
|
|
add("pts_per_ch : %d (%d ms at %d Hz)"
|
|
% (seg["pts"], seg["pts"] * 1000 // max(1, seg["fs_hz"]),
|
|
seg["fs_hz"]))
|
|
add("pre_pts : %d" % seg["pre"])
|
|
add("trig_src : %d (%s)"
|
|
% (seg["trig_src"], TRG_NAMES.get(seg["trig_src"], "UNKNOWN")))
|
|
add("trig_elm : %d" % seg["trig_elm"])
|
|
add("t0_ms : %d (ms since power-on)" % seg["t0_ms"])
|
|
if seg["wall"]:
|
|
add("wall : %d (epoch s)" % seg["wall"])
|
|
else:
|
|
add("wall : 0 (time not synced at seal time)")
|
|
add("eng : %d (%s)"
|
|
% (seg["eng"], ENG_NAMES.get(seg["eng"], "UNKNOWN")))
|
|
valid = [sem_name(s) for s in sorted(SEM_NAMES)
|
|
if seg["flags_seq"] & (1 << s)]
|
|
add("flags_seq : 0x%02X VALID bits: %s"
|
|
% (seg["flags_seq"], " ".join(valid) if valid else "(none)"))
|
|
add("clip_mask : 0x%04X [%s]"
|
|
% (seg["clip_mask"], mask_channels(seg["clip_mask"]) or "(none)"))
|
|
add("frame_seq : %d" % seg["frame_seq"])
|
|
add("CRC16(hdr) : 0x%04X OK covers [0,%d), %s byte first"
|
|
% (seg["crc_hdr"], HDR_FIXED + seg["chc"] * ENT_SIZE,
|
|
"low" if CRC_LO_FIRST else "high"))
|
|
add("CRC16(data) : 0x%04X OK covers %d bytes, %s byte first"
|
|
% (seg["crc_data"], seg["pts"] * seg["chc"] * 2,
|
|
"low" if CRC_LO_FIRST else "high"))
|
|
add("-- channel table (%d entries x %d B) --" % (seg["chc"], ENT_SIZE))
|
|
add("%3s %3s %-5s %-10s %4s %5s %11s %9s %12s %10s"
|
|
% ("IDX", "SEM", "NAME", "SRC", "BITS", "AVAIL", "K_Q16",
|
|
"B_CODES", "LSB_uV", "FLAG"))
|
|
for ent in seg["entries"]:
|
|
fl = []
|
|
if ent["avail"] & ADC_CAL_F_VALID:
|
|
fl.append("VALID")
|
|
if ent["avail"] & ADC_CAL_F_CLIPPED:
|
|
fl.append("CLIPPED")
|
|
add("%3d %3d %-5s %-10s %4d 0x%02X %11d %9d %12.6f %10s"
|
|
% (ent["idx"], ent["sem"], sem_name(ent["sem"]),
|
|
SRC_NAMES.get(ent["src"], "SRC%d" % ent["src"]),
|
|
ent["bits"], ent["avail"], ent["k_q16"], ent["b_codes"],
|
|
lsb_uv(ent), "|".join(fl) if fl else "."))
|
|
# lead ruling 2026-10-29: soft notes, never a reject, never an exit code
|
|
both = [sem_name(e["sem"]) for e in seg["entries"]
|
|
if (e["avail"] & (ADC_CAL_F_VALID | ADC_CAL_F_CLIPPED))
|
|
== (ADC_CAL_F_VALID | ADC_CAL_F_CLIPPED)]
|
|
if both:
|
|
add("NOTE: VALID+CLIPPED(both) on: %s (normal once two-point "
|
|
"calibration is done, not a fault)" % " ".join(both))
|
|
agg = 0
|
|
for e in seg["entries"]:
|
|
if e["avail"] & ADC_CAL_F_CLIPPED:
|
|
agg |= 1 << e["sem"]
|
|
if agg != seg["clip_mask"]:
|
|
add("WARN: avail CLIPPED bits aggregate to 0x%04X, header clip_mask "
|
|
"is 0x%04X -- soft check, segment NOT rejected"
|
|
% (agg, seg["clip_mask"]))
|
|
# eng derives from flags_seq on the board: feed_eng() in
|
|
# App/app/ui_data_feed.c -- all 8 electrical channels VALID -> secondary
|
|
# (1), else pin-domain (0); wave header fill App/task/adc_task.c.
|
|
# Cross-reading allowed here, SOFT ONLY: never a reject, no exit code.
|
|
all_valid = (seg["flags_seq"] & 0xFF) == 0xFF
|
|
if all_valid and seg["eng"] == 0:
|
|
add("WARN: eng=0 (pin-domain) but flags_seq=0xFF (all 8 VALID) -- "
|
|
"derived eng would be 1 on board; soft check, NOT rejected")
|
|
elif not all_valid and seg["eng"] != 0:
|
|
add("WARN: eng=%d (not pin-domain) but flags_seq=0x%02X is not "
|
|
"all-VALID -- derived eng would be 0 on board; soft check, "
|
|
"NOT rejected" % (seg["eng"], seg["flags_seq"]))
|
|
return lines
|
|
|
|
|
|
def cmd_dump(args):
|
|
seg = load_segment(args.file)
|
|
for line in fmt_dump(seg):
|
|
print(line)
|
|
return 0
|
|
|
|
|
|
# ---- subcommand: fft ----------------------------------------------------------
|
|
def _harm_rms(win, harm, n):
|
|
if harm * 2 > n:
|
|
return None
|
|
rms, _ang = phasor_rms_ang(win, harm, n)
|
|
return rms
|
|
|
|
|
|
def _fmt_or_na(v):
|
|
return "%.2f" % v if v is not None else "n/a"
|
|
|
|
|
|
def fmt_fft(seg, cycle, all_cycles, only_ch):
|
|
lines = []
|
|
pps = seg["pps"]
|
|
ncyc = seg["pts"] // pps
|
|
if cycle is None:
|
|
cycle = ncyc - 1
|
|
picked = range(ncyc) if all_cycles else [cycle]
|
|
lines.append("unit: mV eng=%d (%s)%s"
|
|
% (seg["eng"], ENG_NAMES.get(seg["eng"], "UNKNOWN"),
|
|
" [pin-domain = ADC pin voltage, NOT calibrated "
|
|
"secondary]" if seg["eng"] == 0 else ""))
|
|
lines.append("%3s %-5s %-10s %6s %10s %9s %10s %10s %s"
|
|
% ("CH", "NAME", "SRC", "CYCLE", "RMS-f[mV]", "ANG-f[deg]",
|
|
"RMS-2x[mV]", "RMS-5x[mV]", "NOTE"))
|
|
lines.append(" (f = fundamental; 2x/5x = 2nd and 5th harmonic; "
|
|
"angle reference = first sample of this window, not a phase "
|
|
"lock to the signal generator)")
|
|
for ci in only_ch:
|
|
ent = seg["entries"][ci]
|
|
mv = channel_mv(seg, ci)
|
|
rmss = []
|
|
for c in picked:
|
|
if c < 0 or (c + 1) * pps > seg["pts"]:
|
|
lines.append("%3d %-5s %-10s %6d (out of range, cycles "
|
|
"0..%d)"
|
|
% (ci, sem_name(ent["sem"]),
|
|
SRC_NAMES.get(ent["src"], "?"), c, ncyc - 1))
|
|
continue
|
|
win = mv[c * pps:(c + 1) * pps]
|
|
rms1, ang1 = phasor_rms_ang(win, 1, pps)
|
|
rms2 = _harm_rms(win, 2, pps)
|
|
rms5 = _harm_rms(win, 5, pps)
|
|
rmss.append(rms1)
|
|
note = "CLIP-bit" if seg["clip_mask"] & (1 << ent["sem"]) else ""
|
|
if (ent["avail"] & (ADC_CAL_F_VALID | ADC_CAL_F_CLIPPED)) \
|
|
== (ADC_CAL_F_VALID | ADC_CAL_F_CLIPPED):
|
|
note = (note + " VALID+CLIPPED(both)").strip()
|
|
lines.append("%3d %-5s %-10s %6d %10.2f %+9.2f %10s %10s %s"
|
|
% (ci, sem_name(ent["sem"]),
|
|
SRC_NAMES.get(ent["src"], "?"), c, rms1, ang1,
|
|
_fmt_or_na(rms2), _fmt_or_na(rms5), note))
|
|
if all_cycles and len(rmss) > 1:
|
|
lines.append("%3d %-5s %-10s %6s mean=%10.2f min=%10.2f "
|
|
"max=%10.2f over %d cycles"
|
|
% (ci, sem_name(ent["sem"]),
|
|
SRC_NAMES.get(ent["src"], "?"), "ALL",
|
|
sum(rmss) / len(rmss), min(rmss), max(rmss),
|
|
len(rmss)))
|
|
lines.append("note: mV = (code + b_codes) * k_q16/65536/1000 "
|
|
"(adc_cal_apply formula, unit labeled by header eng, "
|
|
"never re-guessed); full-cycle window, coefficients = "
|
|
"sin/cos(2*pi*k/N)*sqrt(2)/N as in App/algo/phasor.c; "
|
|
"phase = atan2(im,re) deg")
|
|
return lines
|
|
|
|
|
|
def cmd_fft(args):
|
|
seg = load_segment(args.file)
|
|
if args.ch is None:
|
|
only = range(seg["chc"])
|
|
elif 0 <= args.ch < seg["chc"]:
|
|
only = [args.ch]
|
|
else:
|
|
print("usage error: --ch %d out of range (0..%d)"
|
|
% (args.ch, seg["chc"] - 1), file=sys.stderr)
|
|
return 2
|
|
for line in fmt_fft(seg, args.cycle, args.all, only):
|
|
print(line)
|
|
return 0
|
|
|
|
|
|
# ---- subcommand: shape ---------------------------------------------------------
|
|
def fmt_shape(seg, min_run, strict_out=None, superset=False):
|
|
lines = []
|
|
mv_unit = "mV eng=%d(%s)" % (seg["eng"],
|
|
ENG_NAMES.get(seg["eng"], "?"))
|
|
lines.append("unit: %s min-run for rail pin = %d samples"
|
|
% (mv_unit, min_run))
|
|
lines.append("%3s %-5s %-10s %6s %10s %4s %6s %7s %7s %5s"
|
|
% ("CH", "NAME", "SRC", "CYCLES", "AMP[mVpk]", "POL",
|
|
"SYM", "PIN_LO", "PIN_HI", "RAIL"))
|
|
for ci in range(seg["chc"]):
|
|
ent = seg["entries"][ci]
|
|
codes = seg["samples"][ci]
|
|
mv = channel_mv(seg, ci)
|
|
mean = sum(mv) / len(mv)
|
|
cycles, _edges = zero_cross_cycles([v - mean for v in mv])
|
|
amp = (max(mv) - min(mv)) / 2.0
|
|
first = codes[0]
|
|
pol = "+" if first > 0 else ("-" if first < 0 else "0")
|
|
sym = half_wave_symmetry(mv, seg["pps"])
|
|
lo, hi, clipped = rail_pins(seg, ci, min_run)
|
|
lines.append("%3d %-5s %-10s %6.2f %10.2f %4s %6s %7d %7d %5s"
|
|
% (ci, sem_name(ent["sem"]),
|
|
SRC_NAMES.get(ent["src"], "?"), cycles, amp, pol,
|
|
"%.3f" % sym if sym is not None else "n/a",
|
|
lo, hi, "YES" if clipped else "."))
|
|
meas = measured_clip_mask(seg, min_run)
|
|
hdr = seg["clip_mask"]
|
|
if superset:
|
|
ok = (meas & ~hdr) == 0
|
|
verdict = "SUPERSET-OK" if ok else "MISMATCH"
|
|
else:
|
|
ok = meas == hdr
|
|
verdict = "MATCH" if ok else "MISMATCH"
|
|
lines.append("clip reconcile: measured=0x%04X [%s] header=0x%04X [%s] %s"
|
|
% (meas, mask_channels(meas) or "-", hdr,
|
|
mask_channels(hdr) or "-", verdict))
|
|
if superset:
|
|
tol = hdr & ~meas
|
|
lines.append("yardstick: superset (--clip-superset) -- every "
|
|
"measured rail pin must be claimed by the seal; "
|
|
"sealed-but-not-railed tolerated: %s"
|
|
% (mask_channels(tol) if tol else "(none)"))
|
|
else:
|
|
lines.append("yardstick: equality (default card check) -- measured "
|
|
"rail pins must equal the sealed clip_mask")
|
|
lines.append("note: header clip_mask is the seal-time CLIPPED STAMP, "
|
|
"source-derived in V0.1 (App/algo/adc_cal.c: "
|
|
"s_rom_k_stm32 -> CLIPPED, unipolar no-bias), NOT measured "
|
|
"from samples; measured is this segment's rail pins. Real "
|
|
"V0.1 AC waves bottom-rail the 5 stamped channels, so "
|
|
"equality is expected on-board; a non-railed measurement "
|
|
"means synthetic fixture (e.g. column ramps) or wiring "
|
|
"change -- investigate, do not auto-blame the seal")
|
|
if strict_out is not None:
|
|
strict_out.append("MATCH" if ok else "MISMATCH")
|
|
return lines
|
|
|
|
|
|
def cmd_shape(args):
|
|
if args.clip_superset and not args.strict:
|
|
print("usage error: --clip-superset only changes the --strict "
|
|
"yardstick; pass --strict as well", file=sys.stderr)
|
|
return 2
|
|
seg = load_segment(args.file)
|
|
verdict = []
|
|
for line in fmt_shape(seg, args.min_run, verdict,
|
|
superset=args.clip_superset):
|
|
print(line)
|
|
if args.strict and verdict and verdict[0] != "MATCH":
|
|
if args.clip_superset:
|
|
print("STRICT-SUPERSET: measured rail pins OUTSIDE the sealed "
|
|
"clip_mask -- low injection cannot explain this; "
|
|
"investigate wiring or the seal")
|
|
else:
|
|
print("STRICT: clip_mask reconciliation failed (yardstick: "
|
|
"equality; for DC-only or low injection use --strict "
|
|
"--clip-superset)")
|
|
return 1
|
|
return 0
|
|
|
|
|
|
# ---- subcommand: plot (pure-string SVG, refuses on any reject) ---------------
|
|
def svg_for_channel(seg, ci, seg_label):
|
|
ent = seg["entries"][ci]
|
|
mv = channel_mv(seg, ci)
|
|
w, h = 900, 380
|
|
ml, mr, mt, mb = 60, 20, 34, 30
|
|
pw, ph = w - ml - mr, h - mt - mb
|
|
span = max(max(mv), -min(mv), 1e-6) * 1.1
|
|
n = len(mv)
|
|
|
|
def xs(i):
|
|
return ml + pw * i / float(n - 1)
|
|
|
|
def ys(v):
|
|
return mt + ph / 2.0 - (v / span) * (ph / 2.0)
|
|
|
|
name = sem_name(ent["sem"])
|
|
trg = TRG_NAMES.get(seg["trig_src"], "SRC?%d" % seg["trig_src"])
|
|
clip_bit = bool(seg["clip_mask"] & (1 << ent["sem"]))
|
|
title = "%s seg=%s trig=%s elm=%d eng=%s%s" % (
|
|
name, seg_label, trg, seg["trig_elm"],
|
|
ENG_NAMES.get(seg["eng"], "?"), " CLIP" if clip_bit else "")
|
|
parts = []
|
|
parts.append('<?xml version="1.0" encoding="UTF-8"?>')
|
|
parts.append('<svg xmlns="http://www.w3.org/2000/svg" width="%d" '
|
|
'height="%d" viewBox="0 0 %d %d">' % (w, h, w, h))
|
|
parts.append('<rect x="0" y="0" width="%d" height="%d" fill="white"/>'
|
|
% (w, h))
|
|
parts.append('<text x="%d" y="20" font-family="monospace" font-size="14" '
|
|
'fill="black">%s</text>' % (ml, title))
|
|
parts.append('<rect x="%d" y="%d" width="%d" height="%d" fill="none" '
|
|
'stroke="black" stroke-width="1"/>' % (ml, mt, pw, ph))
|
|
zy = ys(0.0)
|
|
parts.append('<line x1="%d" y1="%.2f" x2="%d" y2="%.2f" stroke="black" '
|
|
'stroke-width="1" stroke-dasharray="5,4"/>'
|
|
% (ml, zy, ml + pw, zy))
|
|
parts.append('<text x="%d" y="%.2f" font-family="monospace" font-size="10" '
|
|
'fill="black">0</text>' % (ml - 14, zy + 3))
|
|
parts.append('<text x="%d" y="%.2f" font-family="monospace" font-size="10" '
|
|
'fill="black">%.0fmV</text>' % (ml - 55, mt + 8, span))
|
|
parts.append('<text x="%d" y="%.2f" font-family="monospace" font-size="10" '
|
|
'fill="black">%.0fmV</text>' % (ml - 55, mt + ph, -span))
|
|
tx = xs(min(seg["pre"], n - 1))
|
|
parts.append('<line x1="%.2f" y1="%d" x2="%.2f" y2="%d" stroke="black" '
|
|
'stroke-width="1" stroke-dasharray="2,3"/>'
|
|
% (tx, mt, tx, mt + ph))
|
|
parts.append('<text x="%.2f" y="%d" font-family="monospace" font-size="10" '
|
|
'fill="black">TRIG(pre=%d)</text>'
|
|
% (min(tx + 3, w - 120), mt + ph + 12, seg["pre"]))
|
|
d = ["M %.2f %.2f" % (xs(0), ys(mv[0]))]
|
|
for i in range(1, n):
|
|
d.append("L %.2f %.2f" % (xs(i), ys(mv[i - 1])))
|
|
d.append("L %.2f %.2f" % (xs(i), ys(mv[i])))
|
|
parts.append('<path d="%s" fill="none" stroke="black" stroke-width="1"/>'
|
|
% " ".join(d))
|
|
parts.append('<text x="%d" y="%d" font-family="monospace" font-size="10" '
|
|
'fill="black">%d pts @ %d Hz, %d pts/cycle, x = sample '
|
|
'index</text>'
|
|
% (ml, h - 6, n, seg["fs_hz"], seg["pps"]))
|
|
parts.append("</svg>")
|
|
return "\n".join(parts) + "\n"
|
|
|
|
|
|
def default_outdir():
|
|
root = os.path.dirname(os.path.dirname(os.path.dirname(
|
|
os.path.abspath(__file__))))
|
|
return os.path.join(root, "ci-artifacts")
|
|
|
|
|
|
def cmd_plot(args):
|
|
seg = load_segment(args.file) # any integrity failure raises BEFORE write
|
|
if args.out is not None and args.ch is None:
|
|
print("usage error: plot --out requires --ch (one file per channel)",
|
|
file=sys.stderr)
|
|
return 2
|
|
if args.ch is None:
|
|
chans = range(seg["chc"])
|
|
elif 0 <= args.ch < seg["chc"]:
|
|
chans = [args.ch]
|
|
else:
|
|
print("usage error: --ch %d out of range (0..%d)"
|
|
% (args.ch, seg["chc"] - 1), file=sys.stderr)
|
|
return 2
|
|
label = os.path.splitext(os.path.basename(args.file))[0]
|
|
written = []
|
|
for ci in chans:
|
|
if args.out is not None:
|
|
path = args.out
|
|
else:
|
|
outdir = args.outdir if args.outdir else default_outdir()
|
|
if not os.path.isdir(outdir):
|
|
os.makedirs(outdir)
|
|
path = os.path.join(
|
|
outdir, "%s_ch%02d_%s.svg"
|
|
% (label, ci, sem_name(seg["entries"][ci]["sem"])))
|
|
svg = svg_for_channel(seg, ci, label)
|
|
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
|
fh.write(svg)
|
|
written.append(path)
|
|
print("WROTE %s" % path)
|
|
if not written:
|
|
print("plot: nothing written")
|
|
return 1
|
|
return 0
|
|
|
|
|
|
# ---- synthetic segment builders (selftest) ------------------------------------
|
|
def syn_entries(chc=8, avail_mode="v01"):
|
|
"""avail = raw pass-through of adc_cal_flags(). Modes (lead ruling
|
|
2026-10-29 asks for the first two as interop shapes):
|
|
v01 = V0.1 board truth, pre-calibration: TPAFE trio 0x00, STM32 five
|
|
0x02; their CLIPPED bits aggregate 2+4+32+64+128 == 0xE6 header
|
|
both = deliberately VALID+CLIPPED on the five: 0x01 TPAFE / 0x03 STM32
|
|
(legal: soft note only, must still parse clean)
|
|
none = all-zero avail under clip_mask 0xE6 (avail/header drift; soft
|
|
WARN only, must still parse clean)"""
|
|
pa_t, pa_s = {"v01": (0x00, 0x02), "both": (0x01, 0x03),
|
|
"none": (0x00, 0x00)}[avail_mode]
|
|
ents = []
|
|
for sem in range(chc):
|
|
if sem in (0, 3, 4):
|
|
ents.append((sem, SRC_TPAFE, 16, pa_t, 10000000, 0))
|
|
else:
|
|
ents.append((sem, SRC_STM32, 12, pa_s, 52800000, 0))
|
|
return ents
|
|
|
|
|
|
def syn_samples(pts=320, pps=20, chc=8, rail=True):
|
|
"""sem0: sine A=9261 phi=+90deg | sem3: A=4630 phi=+30deg | sem4: A=2000
|
|
phi=-60deg | STM32 five (UB UC IB IC IN): 10-low/10-high square pinned
|
|
at the bottom rail (0 then 2048) -- honest V0.1 shape, no midpoint
|
|
bias. rail=False: the five become a gentle mid-band ramp
|
|
500 + (97*i) % 3000, i.e. values 500..3499 -- never enters the rail
|
|
bands (0..81 / 4013..4095), so measured clip is EMPTY while the header
|
|
still stamps 0xE6: one fixture that shows BOTH clip yardsticks."""
|
|
phi = {0: math.pi / 2.0, 3: math.pi / 6.0, 4: -math.pi / 3.0}
|
|
amp = {0: 9261, 3: 4630, 4: 2000}
|
|
cols = []
|
|
for sem in range(chc):
|
|
if sem in amp:
|
|
a = amp[sem]
|
|
p = phi[sem]
|
|
cols.append([int(round(a * math.sin(2.0 * math.pi * i / pps + p)))
|
|
for i in range(pts)])
|
|
elif rail:
|
|
cols.append([0 if (i // (pps // 2)) % 2 == 0 else 2048
|
|
for i in range(pts)])
|
|
else:
|
|
cols.append([500 + (97 * i) % 3000 for i in range(pts)])
|
|
return cols
|
|
|
|
|
|
def build_synthetic(chc=8, pts=320, pps=20, trig_src=1, clip=0x00E6,
|
|
flags=0x00, wall=0, t0=123456, fseq=987654321,
|
|
avail_mode="v01", eng=0, rail=True):
|
|
hdr = bytearray()
|
|
hdr += bytes([MAGIC_W, MAGIC_R, FMT_VER, FMT_VER])
|
|
hdr += struct.pack("<H", 1000)
|
|
hdr += bytes([pps, chc])
|
|
hdr += struct.pack("<H", pts)
|
|
hdr += struct.pack("<H", pts // 2)
|
|
hdr += bytes([trig_src, 0])
|
|
hdr += struct.pack("<I", t0)
|
|
hdr += struct.pack("<I", wall)
|
|
hdr += bytes([eng, flags])
|
|
hdr += struct.pack("<H", clip)
|
|
hdr += struct.pack("<I", fseq)
|
|
assert len(hdr) == HDR_FIXED
|
|
for sem, src, bits, avail, kq16, b_codes in syn_entries(chc,
|
|
avail_mode)[:chc]:
|
|
hdr += bytes([sem, src, bits, avail])
|
|
hdr += struct.pack("<ii", kq16, b_codes)
|
|
hdr += put_crc16(crc16_modbus(hdr))
|
|
data = b"".join(struct.pack("<%dh" % pts, *col)
|
|
for col in syn_samples(pts, pps, chc, rail))
|
|
return bytes(hdr) + data + put_crc16(crc16_modbus(data))
|
|
|
|
|
|
def mutate(buf, off, xor=0xFF):
|
|
b = bytearray(buf)
|
|
b[off] ^= xor
|
|
return bytes(b)
|
|
|
|
|
|
# ---- selftest -------------------------------------------------------------------
|
|
class Checker(object):
|
|
def __init__(self):
|
|
self.ok = 0
|
|
self.fail = 0
|
|
self.problems = []
|
|
|
|
def check(self, name, cond, detail=""):
|
|
if cond:
|
|
self.ok += 1
|
|
print("[OK] %s" % name)
|
|
else:
|
|
self.fail += 1
|
|
self.problems.append(name)
|
|
print("[FAIL] %s %s" % (name, detail))
|
|
|
|
def near(self, name, got, want, rel_tol=0.01, abs_tol=0.0):
|
|
good = abs(got - want) <= max(abs_tol, rel_tol * abs(want))
|
|
self.check(name, good,
|
|
"got=%r want=%r tol=%s" % (got, want, rel_tol))
|
|
|
|
|
|
def expect_reject(ck, name, buf, frag):
|
|
try:
|
|
parse_segment(buf, name="case")
|
|
except RejectError as exc:
|
|
ck.check(name, frag.lower() in str(exc).lower(),
|
|
"reason='%s' lacks '%s'" % (exc, frag))
|
|
return
|
|
ck.check(name, False, "expected RejectError, parse succeeded")
|
|
|
|
|
|
def selftest():
|
|
ck = Checker()
|
|
# 1. CRC-16/MODBUS known-answer test (spec-mandated check value)
|
|
ck.check("crc16 KAT '123456789' -> 0x4B37",
|
|
crc16_modbus(b"123456789") == CRC16_CHECK,
|
|
"got 0x%04X" % crc16_modbus(b"123456789"))
|
|
|
|
# 2. synthetic 8-channel segment builds and round-trips through the
|
|
# parser (both CRC gates passing inside parse == implicit PASS)
|
|
img = build_synthetic()
|
|
seg = parse_segment(img, name="synth8")
|
|
ck.check("size 8ch/320pts == 5250", seg["size"] == 5250,
|
|
"got %d" % seg["size"])
|
|
fields = dict(fs_hz=1000, pps=20, chc=8, pts=320, pre=160, trig_src=1,
|
|
trig_elm=0, t0_ms=123456, wall=0, eng=0, flags_seq=0,
|
|
clip_mask=0x00E6, frame_seq=987654321)
|
|
for k in sorted(fields):
|
|
ck.check("field %s round-trip" % k, seg[k] == fields[k],
|
|
"got %r" % (seg[k],))
|
|
ck.check("table entry 0 == sem0/src0/bits16/avail0x00/k_q16 10000000",
|
|
(seg["entries"][0]["sem"], seg["entries"][0]["src"],
|
|
seg["entries"][0]["bits"], seg["entries"][0]["avail"],
|
|
seg["entries"][0]["k_q16"]) == (0, 0, 16, 0x00, 10000000))
|
|
ck.check("table entry 1 == sem1/src1/bits12/avail0x02/k_q16 52800000",
|
|
(seg["entries"][1]["sem"], seg["entries"][1]["src"],
|
|
seg["entries"][1]["bits"], seg["entries"][1]["avail"],
|
|
seg["entries"][1]["k_q16"]) == (1, 1, 12, 0x02, 52800000))
|
|
# per-entry availability (low 8 bits of adc_cal_flags) vs header
|
|
# clip_mask: the CLIPPED bits must reconcile, bit s = sem s both sides
|
|
clip_from_avail = 0
|
|
for ent in seg["entries"]:
|
|
if ent["avail"] & ADC_CAL_F_CLIPPED:
|
|
clip_from_avail |= 1 << ent["sem"]
|
|
ck.check("avail CLIPPED bits reconcile header clip_mask",
|
|
clip_from_avail == seg["clip_mask"],
|
|
"avail says 0x%04X header 0x%04X"
|
|
% (clip_from_avail, seg["clip_mask"]))
|
|
|
|
# 2b. lead ruling 2026-10-29: avail is raw pass-through, NEVER a reject
|
|
# basis; VALID+CLIPPED(both) and avail/header drift are soft only.
|
|
img_b = build_synthetic(avail_mode="both", flags=0xFF, fseq=71)
|
|
segb = parse_segment(img_b, name="synth-both") # raises if wrongly voided
|
|
ck.check("both-form (flags 0xFF, avail 0x01/0x03) parses, not rejected",
|
|
segb["size"] == 5250)
|
|
ck.check("dump both-form carries VALID+CLIPPED(both) note",
|
|
"VALID+CLIPPED(both)" in "\n".join(fmt_dump(segb)))
|
|
fftb = "\n".join(fmt_fft(segb, None, False, range(segb["chc"])))
|
|
ck.check("fft both-form NOTE column carries VALID+CLIPPED(both)",
|
|
"VALID+CLIPPED(both)" in fftb)
|
|
vb = []
|
|
fmt_shape(segb, 4, vb)
|
|
ck.check("both-form shape verdict unchanged: MATCH (avail does not "
|
|
"drive strict)", bool(vb) and vb[0] == "MATCH", str(vb))
|
|
img_v0 = build_synthetic(flags=0x00, fseq=72) # V0.1 board truth form
|
|
segv0 = parse_segment(img_v0, name="synth-v01")
|
|
ck.check("V0.1 truth form (flags_seq 0x00, avail 0x00/0x02) parses clean",
|
|
segv0["flags_seq"] == 0x00)
|
|
dumpv0 = "\n".join(fmt_dump(segv0))
|
|
ck.check("V0.1 truth dump has no both-note and no WARN",
|
|
"VALID+CLIPPED(both)" not in dumpv0 and "WARN" not in dumpv0)
|
|
segn = parse_segment(build_synthetic(avail_mode="none", flags=0xFF,
|
|
fseq=73),
|
|
name="synth-drift") # all-zero vs clip 0xE6
|
|
ck.check("avail/header drift parses (soft, not rejected)",
|
|
segn["clip_mask"] == 0x00E6)
|
|
ck.check("avail/header drift dump carries WARN line",
|
|
"WARN" in "\n".join(fmt_dump(segn)))
|
|
|
|
# 2c. eng x flags_seq derivation (lead follow-up ruling 2026-10-29):
|
|
# mismatch = soft WARN in dump, never a reject, never an exit code.
|
|
segx = parse_segment(img_b, name="synth-eng-cross") # eng=0 + flags 0xFF
|
|
dumpx = "\n".join(fmt_dump(segx))
|
|
ck.check("eng=0 with flags_seq=0xFF still parses with soft eng WARN",
|
|
segx["eng"] == 0 and "WARN: eng=0" in dumpx)
|
|
segok = parse_segment(build_synthetic(avail_mode="both", flags=0xFF,
|
|
eng=1, fseq=74),
|
|
name="synth-eng-pair") # derived form: eng 1
|
|
ck.check("eng=1 with all-VALID parses, no eng WARN",
|
|
"WARN: eng" not in "\n".join(fmt_dump(segok)))
|
|
seginv = parse_segment(build_synthetic(avail_mode="v01", flags=0x00,
|
|
eng=1, fseq=75),
|
|
name="synth-eng-inv") # not all-VALID + eng 1
|
|
ck.check("eng=1 without all-VALID parses with soft eng WARN (not void)",
|
|
"WARN: eng=1" in "\n".join(fmt_dump(seginv)))
|
|
|
|
# 2d. clip yardsticks (lead ruling 2026-10-29): equality is the default
|
|
# card check; --clip-superset is the explicit low-injection ruler.
|
|
# Prove both on ONE non-railed fixture at REAL process exit-code
|
|
# level, not just in-process verdicts.
|
|
imgr = build_synthetic(rail=False, fseq=76)
|
|
segr = parse_segment(imgr, name="synth-ramp")
|
|
v_eq = []
|
|
out_eq = "\n".join(fmt_shape(segr, 4, v_eq))
|
|
ck.check("ramp fixture: equality yardstick MISMATCH (strict rc 1)",
|
|
bool(v_eq) and v_eq[0] == "MISMATCH")
|
|
v_sup = []
|
|
out_sup = "\n".join(fmt_shape(segr, 4, v_sup, superset=True))
|
|
ck.check("ramp fixture: superset yardstick passes (strict rc 0)",
|
|
bool(v_sup) and v_sup[0] == "MATCH" and "SUPERSET-OK" in out_sup)
|
|
ck.check("output names the yardstick: equality",
|
|
"yardstick: equality" in out_eq)
|
|
ck.check("output names the yardstick: superset + tolerance list",
|
|
"yardstick: superset" in out_sup and
|
|
"UB UC IB IC IN" in out_sup)
|
|
with tempfile.TemporaryDirectory() as td2:
|
|
rp = os.path.join(td2, "RAMP.BIN")
|
|
with open(rp, "wb") as fh:
|
|
fh.write(imgr)
|
|
me = sys.executable
|
|
script = os.path.abspath(__file__)
|
|
rc_eq = subprocess.run([me, script, "shape", rp, "--strict"],
|
|
capture_output=True, text=True)
|
|
rc_sup = subprocess.run([me, script, "shape", rp, "--strict",
|
|
"--clip-superset"],
|
|
capture_output=True, text=True)
|
|
rc_alone = subprocess.run([me, script, "shape", rp,
|
|
"--clip-superset"],
|
|
capture_output=True, text=True)
|
|
ck.check("CLI real rc: --strict on non-railed fixture == 1",
|
|
rc_eq.returncode == 1, "rc=%d" % rc_eq.returncode)
|
|
ck.check("CLI real rc: --strict --clip-superset == 0",
|
|
rc_sup.returncode == 0, "rc=%d" % rc_sup.returncode)
|
|
ck.check("CLI real rc: --clip-superset alone == usage error 2",
|
|
rc_alone.returncode == 2,
|
|
"rc=%d" % rc_alone.returncode)
|
|
ck.check("clip_mask 0xE6 decodes to UB UC IB IC IN",
|
|
mask_channels(seg["clip_mask"]) == "UB UC IB IC IN",
|
|
mask_channels(seg["clip_mask"]))
|
|
|
|
# 3. variant header values (bench trigger + synced wall + new frame_seq)
|
|
# must survive the byte round-trip; v1 also pins ch_count to 8
|
|
img_v = build_synthetic(trig_src=3, wall=1793599000, fseq=55, t0=7)
|
|
segv = parse_segment(img_v, name="synth-variant")
|
|
ck.check("variant trig_src round-trip", segv["trig_src"] == 3)
|
|
ck.check("variant wall round-trip", segv["wall"] == 1793599000)
|
|
ck.check("variant frame_seq round-trip", segv["frame_seq"] == 55)
|
|
ck.check("variant t0_ms round-trip", segv["t0_ms"] == 7)
|
|
|
|
# 4. Fourier: analytic expectations for the synthesized sines.
|
|
# RMS = A*lsb_uV/1000/sqrt(2) mV; angle = phi (phasor.c convention).
|
|
pps = seg["pps"]
|
|
for ci, amp_codes, phi_deg in ((0, 9261, 90.0), (3, 4630, 30.0),
|
|
(4, 2000, -60.0)):
|
|
ent = seg["entries"][ci]
|
|
mv = channel_mv(seg, ci)
|
|
win = mv[-pps:]
|
|
rms1, ang1 = phasor_rms_ang(win, 1, pps)
|
|
want_rms = amp_codes * lsb_uv(ent) / 1000.0 / math.sqrt(2.0)
|
|
ck.near("fft ch%d RMS" % ci, rms1, want_rms, 0.01)
|
|
ck.near("fft ch%d phase (atan2(im,re) convention)" % ci, ang1,
|
|
phi_deg, 0.01, abs_tol=2.0)
|
|
rms2 = _harm_rms(win, 2, pps)
|
|
rms5 = _harm_rms(win, 5, pps)
|
|
ck.check("fft ch%d pure sine: H2/H5 << H1" % ci,
|
|
rms2 < 0.01 * rms1 and rms5 < 0.01 * rms1,
|
|
"rms1=%.3f rms2=%.3f rms5=%.3f" % (rms1, rms2, rms5))
|
|
|
|
# 5. shape: cycles, polarity, symmetry, rail pins vs clip_mask
|
|
vmeas = []
|
|
fmt_shape(seg, 4, vmeas)
|
|
ck.check("shape clip reconcile MATCH (0xE6)",
|
|
bool(vmeas) and vmeas[0] == "MATCH", str(vmeas))
|
|
ua = channel_mv(seg, 0)
|
|
mean = sum(ua) / len(ua)
|
|
cycles, _ = zero_cross_cycles([v - mean for v in ua])
|
|
ck.near("shape UA cycles == 16.0", cycles, 16.0, 0.0, abs_tol=0.3)
|
|
sym = half_wave_symmetry(ua, pps)
|
|
ck.check("shape UA half-wave symmetry near 0",
|
|
sym is not None and sym < 0.05, "sym=%r" % sym)
|
|
ck.check("shape UA polarity '+'", seg["samples"][0][0] > 0)
|
|
ck.check("shape IA polarity '-' (phi=-60deg)", seg["samples"][4][0] < 0)
|
|
lo1, hi1, clipped1 = rail_pins(seg, 1, 4)
|
|
ck.check("shape UB pinned bottom 160 samples",
|
|
clipped1 and lo1 == 160 and hi1 == 0,
|
|
"lo=%d hi=%d" % (lo1, hi1))
|
|
ck.near("shape measured mask == 0xE6", measured_clip_mask(seg, 4),
|
|
0x00E6, 0.0, abs_tol=0)
|
|
_lo0, _hi0, clip0 = rail_pins(seg, 0, 4)
|
|
ck.check("shape UA (1V rms sine) not railed", not clip0)
|
|
|
|
# 6. plot: SVG content and honest labels; reject path writes nothing
|
|
with tempfile.TemporaryDirectory() as td:
|
|
svg = svg_for_channel(seg, 0, SEG_LABEL_SAMPLE)
|
|
p0 = os.path.join(td, "a.svg")
|
|
with open(p0, "w", encoding="utf-8", newline="\n") as fh:
|
|
fh.write(svg)
|
|
with open(p0, "r", encoding="utf-8") as fh:
|
|
back = fh.read()
|
|
ck.check("svg well-formed-ish",
|
|
back.startswith("<?xml") and "<svg" in back
|
|
and back.rstrip().endswith("</svg>"))
|
|
for token in ("UA", SEG_LABEL_SAMPLE, "TRIG(pre=160)", "dasharray", "MANUAL"):
|
|
ck.check("svg contains %r" % token, token in back)
|
|
svg1 = svg_for_channel(seg, 1, SEG_LABEL_SAMPLE)
|
|
ck.check("svg marks CLIP for header-clipped channel",
|
|
"CLIP" in svg1 and "UB" in svg1)
|
|
sentinel = os.path.join(td, "keep.svg")
|
|
with open(sentinel, "w", encoding="utf-8") as fh:
|
|
fh.write("SENTINEL")
|
|
bad = mutate(img, 200)
|
|
try:
|
|
parse_segment(bad, name="corrupt-for-plot")
|
|
ck.check("corrupt input must raise for plot path", False)
|
|
except RejectError:
|
|
ck.check("reject creates nothing",
|
|
not os.path.exists(os.path.join(td, "new.svg")))
|
|
with open(sentinel, "r", encoding="utf-8") as fh:
|
|
ck.check("reject leaves existing file untouched",
|
|
fh.read() == "SENTINEL")
|
|
|
|
# 7. negative integrity cases -- every one must REJECT with a reason
|
|
table_off = HDR_FIXED
|
|
hdr_crc_off = HDR_FIXED + 8 * ENT_SIZE
|
|
data_off = hdr_crc_off + 2
|
|
expect_reject(ck, "reject: bad magic", mutate(img, 0, 0xFF), "magic")
|
|
expect_reject(ck, "reject: bad magic version byte",
|
|
mutate(img, 2, 0x02), "version")
|
|
expect_reject(ck, "reject: bad ver field", mutate(img, OFF_VER, 0x02),
|
|
"ver field")
|
|
expect_reject(ck, "reject: header CRC mismatch (table byte flipped)",
|
|
mutate(img, table_off + 5), "header CRC")
|
|
expect_reject(ck, "reject: data CRC mismatch (sample byte flipped)",
|
|
mutate(img, data_off + 10), "data CRC")
|
|
expect_reject(ck, "reject: stored hdr CRC flipped",
|
|
mutate(img, hdr_crc_off), "header CRC")
|
|
expect_reject(ck, "reject: truncated tail", img[:-3], "size")
|
|
expect_reject(ck, "reject: one byte too long", img + b"\x00", "size")
|
|
expect_reject(ck, "reject: too short for a header", img[:10],
|
|
"too short")
|
|
zl = bytearray(img)
|
|
struct.pack_into("<H", zl, OFF_PTS, 0)
|
|
struct.pack_into("<H", zl, hdr_crc_off,
|
|
crc16_modbus(bytes(zl[:hdr_crc_off])))
|
|
expect_reject(ck, "reject: zero-length data even with re-stamped hdr CRC",
|
|
bytes(zl), "zero-length")
|
|
# section 6.3: v1 pins ch_count = 8 (TEMP would need a version bump)
|
|
c9 = bytearray(img)
|
|
c9[OFF_CHC] = 9
|
|
expect_reject(ck, "reject: ch_count=9 in v1", bytes(c9), "ch_count")
|
|
# section 6.3: PC must void the segment when table sem != index
|
|
ord_bad = bytearray(img)
|
|
ord_bad[HDR_FIXED + 1 * ENT_SIZE + ENT_SEM] = 6 # entry 1 claims sem 6
|
|
struct.pack_into("<H", ord_bad, hdr_crc_off,
|
|
crc16_modbus(bytes(ord_bad[:hdr_crc_off])))
|
|
expect_reject(ck, "reject: table sem != index (CRC re-stamped so the "
|
|
"order check is what bites)", bytes(ord_bad),
|
|
"semantic")
|
|
|
|
# 8. dump text renders every field (and decodes availability bits)
|
|
dump = "\n".join(fmt_dump(seg))
|
|
for token in ("frame_seq", "0x00E6", "UB UC IB IC IN", "MANUAL",
|
|
"pin-domain", "123456", "TPAFE5160", "STM32-ADC1",
|
|
"152.587891", "52800000", "0x02", "CLIPPED", "K_Q16"):
|
|
ck.check("dump contains %r" % token, token in dump)
|
|
|
|
print("SELFTEST %s: %d ok, %d fail"
|
|
% ("PASS" if ck.fail == 0 else "FAIL", ck.ok, ck.fail))
|
|
if ck.fail:
|
|
for p in ck.problems:
|
|
print(" failed: %s" % p)
|
|
return 0 if ck.fail == 0 else 1
|
|
|
|
|
|
# ---- main ----------------------------------------------------------------------
|
|
def build_parser():
|
|
p = argparse.ArgumentParser(
|
|
prog="wave_read.py",
|
|
description="PC-side wave-record segment reader "
|
|
"(dump/fft/shape/plot)")
|
|
p.add_argument("--selftest", action="store_true",
|
|
help="run built-in synthetic-segment self test and exit")
|
|
sub = p.add_subparsers(dest="cmd")
|
|
|
|
def add_common(sp):
|
|
sp.add_argument("file",
|
|
help="segment file, e.g. %s.BIN as fetched over FTP"
|
|
% SEG_LABEL_SAMPLE)
|
|
|
|
sp = sub.add_parser("dump", help="print all header fields + CRC verify")
|
|
add_common(sp)
|
|
sp.set_defaults(fn=cmd_dump)
|
|
|
|
sp = sub.add_parser("fft", help="full-cycle Fourier recompute (mV)")
|
|
add_common(sp)
|
|
sp.add_argument("--cycle", type=int, default=None,
|
|
help="cycle index (default: last full cycle)")
|
|
sp.add_argument("--all", action="store_true",
|
|
help="one row per full cycle + mean/min/max summary")
|
|
sp.add_argument("--ch", type=int, default=None,
|
|
help="only this table index (default: all)")
|
|
sp.set_defaults(fn=cmd_fft)
|
|
|
|
sp = sub.add_parser("shape", help="cycles/amplitude/polarity/rail pins")
|
|
add_common(sp)
|
|
sp.add_argument("--min-run", type=int, default=4,
|
|
help="samples at/inside a rail band before 'railed' "
|
|
"(default 4)")
|
|
sp.add_argument("--strict", action="store_true",
|
|
help="exit 1 when measured clip mask != header "
|
|
"clip_mask (yardstick: equality, the card "
|
|
"check)")
|
|
sp.add_argument("--clip-superset", action="store_true",
|
|
help="with --strict: use the header-covers-measured "
|
|
"yardstick (sealed-but-not-railed tolerated; "
|
|
"measured-but-not-sealed still fails) -- for "
|
|
"DC-only bias, tiny injection, or an un-driven "
|
|
"channel")
|
|
sp.set_defaults(fn=cmd_shape)
|
|
|
|
sp = sub.add_parser("plot", help="write SVG (refuses corrupt segments)")
|
|
add_common(sp)
|
|
sp.add_argument("--ch", type=int, default=None,
|
|
help="table index to draw (default: all channels)")
|
|
sp.add_argument("--outdir", default=None,
|
|
help="output directory (default: <repo>/ci-artifacts)")
|
|
sp.add_argument("--out", default=None,
|
|
help="exact output path (requires --ch)")
|
|
sp.set_defaults(fn=cmd_plot)
|
|
|
|
sp = sub.add_parser("selftest",
|
|
help="synthetic-segment round-trip test")
|
|
sp.set_defaults(fn=lambda _a: selftest())
|
|
return p
|
|
|
|
|
|
def main(argv):
|
|
if len(argv) > 1 and argv[1] == "--selftest":
|
|
return selftest()
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv[1:])
|
|
if args.selftest:
|
|
return selftest()
|
|
if not getattr(args, "cmd", None):
|
|
parser.print_usage(sys.stderr)
|
|
return 2
|
|
try:
|
|
return args.fn(args)
|
|
except RejectError as exc:
|
|
print("REJECT: %s" % exc)
|
|
return 1
|
|
except IOErrorMessage as exc:
|
|
print("IO ERROR: %s" % exc)
|
|
return 2
|
|
except OSError as exc:
|
|
print("IO ERROR: %s" % exc)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|