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,