Files
sigrok_iso7816_stream/tools/signal_diagnostic.py
T
catarrh d3715c9385 v1.5.0: invalid-INS one-byte re-frame + explicit discard logging
Residual CONCAT mis-frames (4gmodem/A55_sim2/S21p_sim2) traced to a silent
5-byte drop at an invalid-INS T=0 header (0x0f 00 a4 08 04). Drop only the
stray leading byte and re-frame the remaining four via _replay so the real
command (00 a4 08 04 02 2f 00 -> 61 1c) is recovered.

- _note_discard()/_consume_first(): every consumed-but-unemitted byte is
  now logged; idle 0x00/0xFF discards are rate-limited, real data always
  gets an ANN_WARN annotation.
- All read-until-SW loops log skipped non-SW bytes.
- All traces: CONCAT 0; BAD_FCS/GARBAGE 0; test_8/test_7 RESULT OK;
  47/47 unit tests; baseline byte-identical.

History rewritten to a single root commit so no example trace is stored
in the repo (fixtures are local-only, gitignored). README keeps only
generic <trace>.sr placeholders; AGENTS.md retains the fixture names.
2026-09-09 15:51:09 +03:00

312 lines
11 KiB
Python

#!/usr/bin/env python3
"""Lower-level signal quality diagnostics for .sr trace files.
Reads a sigrok .sr file and reports per-signal measurements:
CLK — frequency, duration, frequency changes
RST — transitions, stability (noisy = ignore)
VCC — transitions, stability (noisy = ignore)
DATA — oscillations, correlation with CLK
Usage:
python3 tools/signal_diagnostic.py trace.sr [--clk CLK] [--data DATA] [--rst RST] [--vcc VCC]
Channel names must match the probe names in the .sr metadata
(e.g. CLK, DATA, RST, VCC or D0, D1, etc.).
"""
import argparse
import statistics
import sys
import zipfile
import numpy as np
# ---------------------------------------------------------------------------
# .sr file reading
# ---------------------------------------------------------------------------
def parse_sr_metadata(path):
"""Return dict with 'samplerate', 'probes' (probe_num -> name), 'unitsize'."""
with zipfile.ZipFile(path, 'r') as zf:
meta = zf.read('metadata').decode()
info = {'probes': {}}
for line in meta.splitlines():
line = line.strip()
if line.startswith('samplerate'):
val = line.split('=', 1)[1].strip()
if val.endswith(' MHz'):
info['samplerate'] = float(val[:-4]) * 1e6
elif val.endswith(' kHz'):
info['samplerate'] = float(val[:-4]) * 1e3
elif val.endswith(' Hz'):
info['samplerate'] = float(val[:-3])
else:
info['samplerate'] = float(val)
elif line.startswith('unitsize'):
info['unitsize'] = int(line.split('=', 1)[1].strip())
elif line.startswith('total probes'):
info['total_probes'] = int(line.split('=', 1)[1].strip())
elif line.startswith('probe'):
parts = line.split('=', 1)
num = int(parts[0][5:])
info['probes'][num] = parts[1].strip()
return info
def read_sr_all(path):
"""Read all logic samples into a numpy uint8 array."""
with zipfile.ZipFile(path, 'r') as zf:
chunk_names = [n for n in zf.namelist() if n.startswith('logic-1-')]
chunk_names.sort(key=lambda n: int(n.split('-')[-1]))
chunks = [zf.read(c) for c in chunk_names]
return np.frombuffer(b''.join(chunks), dtype=np.uint8)
def get_channel_bit(metadata, channel_name):
"""Return the bit position (0-indexed) for a channel name, or None."""
for probe_num, name in metadata['probes'].items():
if name == channel_name:
return probe_num - 1 # probe 1 = bit 0, probe 2 = bit 1, ...
return None
# ---------------------------------------------------------------------------
# Signal measurement helpers
# ---------------------------------------------------------------------------
def extract_bit(arr, bit):
"""Extract a single bit from a numpy uint8 array -> bool array."""
return (arr >> bit) & 1
def measure_clk_fast(clk_arr, sr):
"""Measure CLK frequency from rising edges using numpy."""
if len(clk_arr) == 0:
return {'ok': False, 'reason': 'no data'}
# Find rising edges: 0->1 transitions
diffs = np.diff(clk_arr.astype(np.int8))
rising = np.where(diffs == 1)[0] + 1 # +1 because diff shifts by 1
if len(rising) < 2:
return {
'ok': False,
'reason': f'only {len(rising)} rising edges (need >= 2)',
'transitions': len(rising),
}
spacings = np.diff(rising)
median_spc = float(np.median(spacings))
freq = sr / median_spc if median_spc > 0 else 0
duration_s = (rising[-1] - rising[0]) / sr
# Frequency change: compare first and last quarter
freq_change = None
if len(spacings) >= 20:
n = len(spacings)
q1 = float(np.median(spacings[:n // 4]))
q4 = float(np.median(spacings[3 * n // 4:]))
if q1 > 0 and q4 > 0:
f1 = sr / q1
f4 = sr / q4
change_pct = abs(f1 - f4) / max(f1, f4) * 100
if change_pct > 10:
freq_change = f'{change_pct:.0f}% change ({f1/1e6:.2f} -> {f4/1e6:.2f} MHz)'
return {
'ok': True,
'rising_edges': len(rising),
'frequency_mhz': freq / 1e6,
'median_period_samples': median_spc,
'duration_ms': duration_s * 1000,
'freq_change': freq_change,
}
def count_transitions(arr):
"""Count transitions in a numpy bool array."""
if len(arr) == 0:
return 0
return int(np.sum(np.diff(arr.astype(np.int8)) != 0))
def measure_data_fast(data_arr, clk_arr, sr):
"""Measure DATA signal using numpy."""
if len(data_arr) == 0:
return {'ok': False, 'reason': 'no data'}
transitions = count_transitions(data_arr)
clk_data_corr = None
if len(clk_arr) == len(data_arr) and len(clk_arr) > 1:
clk_diffs = np.diff(clk_arr.astype(np.int8))
data_diffs = np.diff(data_arr.astype(np.int8))
clk_edges = np.where(clk_diffs == 1)[0]
if len(clk_edges) > 0:
matching = int(np.sum(data_diffs[clk_edges] != 0))
clk_data_corr = f'{matching}/{len(clk_edges)} ({matching / len(clk_edges) * 100:.0f}%)'
return {
'ok': True,
'transitions': transitions,
'clk_data_correlation': clk_data_corr,
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def run_analysis(path, clk_name, data_name, rst_name, vcc_name):
"""Run full signal analysis on a .sr file. Returns results dict."""
meta = parse_sr_metadata(path)
sr = meta['samplerate']
clk_bit = get_channel_bit(meta, clk_name)
data_bit = get_channel_bit(meta, data_name)
rst_bit = get_channel_bit(meta, rst_name)
vcc_bit = get_channel_bit(meta, vcc_name)
raw = read_sr_all(path)
total_samples = len(raw)
duration_s = total_samples / sr
# Find first CLK activity
first_clk_sample = None
if clk_bit is not None:
clk_arr = extract_bit(raw, clk_bit)
clk_edges = np.where(np.diff(clk_arr.astype(np.int8)) == 1)[0]
if len(clk_edges) > 0:
first_clk_sample = int(clk_edges[0])
results = {
'samplerate_mhz': sr / 1e6,
'total_samples': total_samples,
'duration_ms': duration_s * 1000,
'channels_found': {
'CLK': clk_name if clk_bit is not None else None,
'DATA': data_name if data_bit is not None else None,
'RST': rst_name if rst_bit is not None else None,
'VCC': vcc_name if vcc_bit is not None else None,
},
'first_activity_sample': first_clk_sample or 0,
}
# CLK
if clk_bit is not None:
results['CLK'] = measure_clk_fast(extract_bit(raw, clk_bit), sr)
else:
results['CLK'] = {'ok': False, 'reason': f'channel "{clk_name}" not found'}
# RST
if rst_bit is not None:
rst_arr = extract_bit(raw, rst_bit)
rst_t = count_transitions(rst_arr)
results['RST'] = {
'ok': True, 'transitions': rst_t,
'stable_pct': (1 - rst_t / max(total_samples, 1)) * 100,
'noisy': rst_t > total_samples * 0.01,
}
else:
results['RST'] = {'ok': False, 'reason': f'channel "{rst_name}" not found'}
# VCC
if vcc_bit is not None:
vcc_arr = extract_bit(raw, vcc_bit)
vcc_t = count_transitions(vcc_arr)
results['VCC'] = {
'ok': True, 'transitions': vcc_t,
'stable_pct': (1 - vcc_t / max(total_samples, 1)) * 100,
'noisy': vcc_t > total_samples * 0.01,
}
else:
results['VCC'] = {'ok': False, 'reason': f'channel "{vcc_name}" not found'}
# DATA
if data_bit is not None:
data_arr = extract_bit(raw, data_bit)
clk_arr = extract_bit(raw, clk_bit) if clk_bit is not None else np.array([], dtype=np.uint8)
results['DATA'] = measure_data_fast(data_arr, clk_arr, sr)
else:
results['DATA'] = {'ok': False, 'reason': f'channel "{data_name}" not found'}
return results
def print_results(results):
"""Pretty-print signal diagnostic results."""
print(f" Sample rate: {results['samplerate_mhz']:.1f} MHz")
print(f" Samples: {results['total_samples']}")
print(f" Duration: {results['duration_ms']:.1f} ms")
found = results['channels_found']
print(f" Channels: CLK={found['CLK']} DATA={found['DATA']} RST={found['RST']} VCC={found['VCC']}")
if results.get('first_activity_sample', 0) > 0:
print(f" First activity: sample {results['first_activity_sample']}")
print()
# CLK
clk = results['CLK']
print(" CLK:")
if clk.get('ok'):
print(f" Frequency: {clk['frequency_mhz']:.2f} MHz")
print(f" Period: {clk['median_period_samples']:.1f} samples")
print(f" Rising edges: {clk['rising_edges']}")
print(f" Duration: {clk['duration_ms']:.1f} ms")
if clk.get('freq_change'):
print(f" WARNING: {clk['freq_change']}")
else:
print(f" FAIL: {clk.get('reason', 'unknown')}")
# RST
rst = results['RST']
print(" RST:")
if rst.get('ok'):
print(f" Transitions: {rst['transitions']}")
print(f" Stable: {rst['stable_pct']:.1f}%")
if rst['noisy']:
print(f" WARNING: noisy (>{1}% transitions) — consider --rst=ignore")
else:
print(f" Not present: {rst.get('reason', 'n/a')}")
# VCC
vcc = results['VCC']
print(" VCC:")
if vcc.get('ok'):
print(f" Transitions: {vcc['transitions']}")
print(f" Stable: {vcc['stable_pct']:.1f}%")
if vcc['noisy']:
print(f" WARNING: noisy (>{1}% transitions) — consider --vcc=ignore")
else:
print(f" Not present: {vcc.get('reason', 'n/a')}")
# DATA
data = results['DATA']
print(" DATA:")
if data.get('ok'):
print(f" Transitions: {data['transitions']}")
if data.get('clk_data_correlation'):
print(f" CLK corr: {data['clk_data_correlation']}")
else:
print(f" FAIL: {data.get('reason', 'unknown')}")
print()
def main():
parser = argparse.ArgumentParser(description='Signal quality diagnostic for .sr traces')
parser.add_argument('sr_file', help='Path to .sr file')
parser.add_argument('--clk', default='CLK', help='CLK channel name (default: CLK)')
parser.add_argument('--data', default='DATA', help='DATA channel name (default: DATA)')
parser.add_argument('--rst', default='RST', help='RST channel name (default: RST)')
parser.add_argument('--vcc', default='VCC', help='VCC channel name (default: VCC)')
args = parser.parse_args()
results = run_analysis(args.sr_file, args.clk, args.data, args.rst, args.vcc)
print(f"Signal diagnostic: {args.sr_file}")
print_results(results)
if __name__ == '__main__':
main()