From 0da88ec6aa50949332dd7ac2e807b0771f665260 Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Tue, 14 Apr 2026 14:19:17 -0400 Subject: [PATCH] fix: redefines rectime_seconds from strt[18] byte to new computed time. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server now re-computes rectime_seconds using the actual sample rate from the compliance config (overriding the default 1024 in the client), so if the device runs at 2048 or 4096 sps it's still correct. Viewer — The rectime display now shows Xs (stored) / Ys (cfg) so you can compare the STRT-derived duration against the compliance config's record_time setting side-by-side. I also clamped the y-axis to ±(0C peak × 1.4) so near-saturation decode artifacts don't squash the real blast signal into a flat line. --- CLAUDE.md | 28 ++++++++++++++++++++++++++++ minimateplus/client.py | 29 +++++++++++++++++++++++------ sfm/server.py | 11 ++++++++++- sfm/waveform_viewer.html | 40 +++++++++++++++++++++++++++++++++++++--- 4 files changed, 98 insertions(+), 10 deletions(-) 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,