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.
312 lines
11 KiB
Python
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()
|