diff --git a/CLAUDE.md b/CLAUDE.md
index 710fa6e..a90d4b3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -163,6 +163,34 @@ record — 5A remains the sole source for those fields and they are set uncondit
`stop_after_metadata=True` (default) stops the 5A loop as soon as `b"Project:"` appears,
then sends the termination frame.
+### SUB 5A — STRT record layout and rectime_seconds (CORRECTED 2026-04-14)
+
+The STRT record is 21 bytes embedded at the start of A5[0] data. Offsets relative to
+the `b'STRT'` magic bytes:
+
+```
++0..3 b'STRT' magic
++4..5 flags 0xFF 0xFE (single-shot) or 0xFF 0xFD (continuous)
++6..9 key4 4-byte event key
++10..13 prev_key4
++14..15 uint16 BE total_samples (full event sample-set count) ← confirmed 4-9-26
++16..17 uint16 BE pretrig_samples (pre-trigger sample-set count)
++18 uint8 record-MODE byte — NOT rectime in seconds
++19..20 typically 0x00 0x00
+```
+
+**CRITICAL — strt[18] is a record-mode byte, NOT rectime_seconds (confirmed 2026-04-14):**
+Analysis of 15 distinct STRT records across the 4-9-26 ACH capture shows:
+- `flags=0xFFFE` (single-shot) → `strt[18] = 0x46` ('F') for EVERY event regardless of duration
+- `flags=0xFFFD` (continuous) → `strt[18] = 0x0E` for EVERY event regardless of duration
+
+The actual record duration (post-trigger seconds) must be computed as:
+```python
+rectime_seconds = int(round((total_samples - pretrig_samples) / sample_rate))
+```
+`_decode_a5_waveform` uses `sample_rate=1024` as a default; the server overrides with
+`compliance_config.sample_rate` when available. Do NOT use `strt[18]` for rectime.
+
### SUB 5A — end-of-stream signal (confirmed 2026-04-06)
After streaming all waveform chunks, the device sends exactly **1 raw byte** in response to
diff --git a/minimateplus/client.py b/minimateplus/client.py
index 9ba7c22..4604a42 100644
--- a/minimateplus/client.py
+++ b/minimateplus/client.py
@@ -603,7 +603,7 @@ class MiniMateClient:
"get_events: 5A full waveform download for key=%s", cur_key.hex()
)
a5_frames = proto.read_bulk_waveform_stream(
- cur_key, stop_after_metadata=False, max_chunks=128
+ cur_key, stop_after_metadata=False, max_chunks=2048
)
if a5_frames:
a5_ok = True
@@ -1378,25 +1378,42 @@ def _decode_a5_waveform(
# STRT record layout (21 bytes, offsets relative to b'STRT'):
# +0..3 magic b'STRT'
- # +8..9 uint16 BE total_samples (full-record expected sample-set count)
+ # +4..5 0xFF 0xFE (flags)
+ # +6..9 key4 (4-byte event key)
+ # +10..13 prev_key4
+ # +14..15 uint16 BE total_samples (full-record expected sample-set count)
# +16..17 uint16 BE pretrig_samples
# +18 uint8 rectime_seconds
+ #
+ # NOTE: strt[8:10] is the LOWER 2 bytes of key4, NOT total_samples.
+ # Confirmed from raw_rx capture (4-9-26): strt[14:16] = total_samples. ✅
strt = w0[strt_pos : strt_pos + 21]
if len(strt) < 21:
log.warning("_decode_a5_waveform: STRT record truncated (%dB)", len(strt))
return
- total_samples = struct.unpack_from(">H", strt, 8)[0]
+ total_samples = struct.unpack_from(">H", strt, 14)[0]
pretrig_samples = struct.unpack_from(">H", strt, 16)[0]
- rectime_seconds = strt[18]
+
+ # strt[18] is a record-mode/type byte, NOT rectime in seconds.
+ # Confirmed from analysis of 4-9-26 ACH capture (15 distinct events):
+ # flags=0xFFFE (single-shot) → strt[18]=0x46 ('F') for all events regardless of duration
+ # flags=0xFFFD (continuous) → strt[18]=0x0E for all events regardless of duration
+ # The actual post-trigger record time must be derived from total_samples and pretrig_samples.
+ # Default sample rate of 1024 is used here; the server overrides with compliance config sr.
+ _sample_rate_default = 1024
+ rectime_seconds = int(round(
+ max(0, total_samples - pretrig_samples) / _sample_rate_default
+ ))
event.total_samples = total_samples
event.pretrig_samples = pretrig_samples
event.rectime_seconds = rectime_seconds
log.debug(
- "_decode_a5_waveform: STRT total_samples=%d pretrig=%d rectime=%ds",
- total_samples, pretrig_samples, rectime_seconds,
+ "_decode_a5_waveform: STRT total_samples=%d pretrig=%d "
+ "strt[18]=0x%02X (mode byte, not seconds) computed_rectime=%ds",
+ total_samples, pretrig_samples, strt[18], rectime_seconds,
)
# ── Collect per-frame waveform bytes with global offset tracking ─────────
diff --git a/sfm/server.py b/sfm/server.py
index 32ca875..f584ff3 100644
--- a/sfm/server.py
+++ b/sfm/server.py
@@ -689,13 +689,22 @@ def device_event_waveform(
if sample_rate is None and info.compliance_config:
sample_rate = info.compliance_config.sample_rate
+ # Recompute rectime_seconds using the actual sample rate now that we have it.
+ # _decode_a5_waveform used 1024 sps as default; override if device says otherwise.
+ # strt[18] is a record-mode byte (0x46 / 0x0E), NOT rectime in seconds.
+ rectime_seconds = ev.rectime_seconds
+ if (ev.total_samples is not None and ev.pretrig_samples is not None
+ and sample_rate and sample_rate > 0):
+ post_trig = max(0, ev.total_samples - ev.pretrig_samples)
+ rectime_seconds = round(post_trig / sample_rate, 2)
+
result = {
"index": ev.index,
"record_type": ev.record_type,
"timestamp": _serialise_timestamp(ev.timestamp),
"total_samples": ev.total_samples,
"pretrig_samples": ev.pretrig_samples,
- "rectime_seconds": ev.rectime_seconds,
+ "rectime_seconds": rectime_seconds,
"samples_decoded": samples_decoded,
"sample_rate": sample_rate,
"peak_values": _serialise_peak_values(ev.peak_values),
diff --git a/sfm/waveform_viewer.html b/sfm/waveform_viewer.html
index 651207b..a2880c8 100644
--- a/sfm/waveform_viewer.html
+++ b/sfm/waveform_viewer.html
@@ -193,6 +193,10 @@
+
@@ -404,7 +408,8 @@
btn.disabled = true;
setStatus('Fetching waveform…', 'loading');
- const url = `${apiBase}/device/event/${evIndex}/waveform?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}`;
+ const force = document.getElementById('force-reload')?.checked ? '&force=true' : '';
+ const url = `${apiBase}/device/event/${evIndex}/waveform?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}${force}`;
let data;
try {
@@ -456,7 +461,14 @@
appendMeta('sr', `${sr} sps`);
appendMeta('samples', `${decoded.toLocaleString()} / ${total.toLocaleString()}`);
appendMeta('pretrig', pretrig);
- appendMeta('rectime', `${data.rectime_seconds ?? '?'}s`);
+ // rectime_seconds is computed from (total_samples - pretrig_samples) / sr in
+ // _decode_a5_waveform. Also show the compliance config record_time for reference.
+ const cfgRt = unitInfo?.compliance_config?.record_time;
+ const strtRt = data.rectime_seconds;
+ const rtStr = (strtRt !== null && strtRt !== undefined)
+ ? `${strtRt}s (stored)` + (cfgRt !== null && cfgRt !== undefined ? ` / ${cfgRt}s (cfg)` : '')
+ : (cfgRt !== null && cfgRt !== undefined ? `${cfgRt}s (cfg)` : '?');
+ appendMeta('rectime', rtStr);
// No waveform data — show a clear reason instead of empty charts
if (decoded === 0) {
@@ -490,6 +502,13 @@
const micPeakPsi = data.peak_values?.micl_psi ?? null;
const DBL_REF_PSI = 2.9e-9; // 20 µPa in psi
+ // 0C record peak values (device-computed, authoritative) per channel
+ const peakValues0C = {
+ Tran: data.peak_values?.tran_in_s ?? null,
+ Vert: data.peak_values?.vert_in_s ?? null,
+ Long: data.peak_values?.long_in_s ?? null,
+ };
+
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
const samples = channels[ch];
if (!samples || samples.length === 0) continue;
@@ -500,9 +519,16 @@
if (isGeo) {
// Geo channels: counts × (range / 32767) → in/s
+ // Scale factor for the waveform shape (may need calibration per unit)
const scale = geoRange / 32767;
plotSamples = samples.map(c => c * scale);
- const peakIns = Math.max(...plotSamples.map(Math.abs));
+
+ // Use the device-computed 0C record peak for the label (authoritative).
+ // The raw-sample-computed peak can be inflated by frame-boundary artifacts.
+ const peak0C = peakValues0C[ch];
+ const peakIns = (peak0C !== null && peak0C !== undefined)
+ ? peak0C
+ : Math.max(...plotSamples.map(Math.abs));
peakLabel = `${peakIns.toFixed(5)} in/s`;
yUnit = 'in/s';
tooltipFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
@@ -586,6 +612,14 @@
grid: { color: '#21262d' },
},
y: {
+ // Clamp geo-channel y-axis to ±(0C peak × 1.4) so near-saturation
+ // decode artifacts (which inflate autoscale to full range) don't
+ // squash the actual blast signal into an invisible flat line.
+ // The 0C peak value is authoritative for the true signal amplitude.
+ ...(isGeo && peak0C !== null && peak0C > 0 ? {
+ min: -(peak0C * 1.4),
+ max: (peak0C * 1.4),
+ } : {}),
ticks: {
color: '#484f58',
maxTicksLimit: 5,