#!/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.BIN python test/host/wave_read.py fft ci-artifacts/WAVE.BIN --all python test/host/wave_read.py shape ci-artifacts/WAVE.BIN --strict python test/host/wave_read.py shape ci-artifacts/WAVE.BIN --strict --clip-superset python test/host/wave_read.py plot ci-artifacts/WAVE.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(" 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(" 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('') parts.append('' % (w, h, w, h)) parts.append('' % (w, h)) parts.append('%s' % (ml, title)) parts.append('' % (ml, mt, pw, ph)) zy = ys(0.0) parts.append('' % (ml, zy, ml + pw, zy)) parts.append('0' % (ml - 14, zy + 3)) parts.append('%.0fmV' % (ml - 55, mt + 8, span)) parts.append('%.0fmV' % (ml - 55, mt + ph, -span)) tx = xs(min(seg["pre"], n - 1)) parts.append('' % (tx, mt, tx, mt + ph)) parts.append('TRIG(pre=%d)' % (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('' % " ".join(d)) parts.append('%d pts @ %d Hz, %d pts/cycle, x = sample ' 'index' % (ml, h - 6, n, seg["fs_hz"], seg["pps"])) parts.append("") 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(" 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("")) 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(" 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))