fix: redefines rectime_seconds from strt[18] byte to new computed time.

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.
This commit is contained in:
2026-04-14 14:19:17 -04:00
parent edb4698bfb
commit 0da88ec6aa
4 changed files with 98 additions and 10 deletions
+28
View File
@@ -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, `stop_after_metadata=True` (default) stops the 5A loop as soon as `b"Project:"` appears,
then sends the termination frame. 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) ### 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 After streaming all waveform chunks, the device sends exactly **1 raw byte** in response to
+23 -6
View File
@@ -603,7 +603,7 @@ class MiniMateClient:
"get_events: 5A full waveform download for key=%s", cur_key.hex() "get_events: 5A full waveform download for key=%s", cur_key.hex()
) )
a5_frames = proto.read_bulk_waveform_stream( 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: if a5_frames:
a5_ok = True a5_ok = True
@@ -1378,25 +1378,42 @@ def _decode_a5_waveform(
# STRT record layout (21 bytes, offsets relative to b'STRT'): # STRT record layout (21 bytes, offsets relative to b'STRT'):
# +0..3 magic 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 # +16..17 uint16 BE pretrig_samples
# +18 uint8 rectime_seconds # +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] strt = w0[strt_pos : strt_pos + 21]
if len(strt) < 21: if len(strt) < 21:
log.warning("_decode_a5_waveform: STRT record truncated (%dB)", len(strt)) log.warning("_decode_a5_waveform: STRT record truncated (%dB)", len(strt))
return 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] 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.total_samples = total_samples
event.pretrig_samples = pretrig_samples event.pretrig_samples = pretrig_samples
event.rectime_seconds = rectime_seconds event.rectime_seconds = rectime_seconds
log.debug( log.debug(
"_decode_a5_waveform: STRT total_samples=%d pretrig=%d rectime=%ds", "_decode_a5_waveform: STRT total_samples=%d pretrig=%d "
total_samples, pretrig_samples, rectime_seconds, "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 ───────── # ── Collect per-frame waveform bytes with global offset tracking ─────────
+10 -1
View File
@@ -689,13 +689,22 @@ def device_event_waveform(
if sample_rate is None and info.compliance_config: if sample_rate is None and info.compliance_config:
sample_rate = info.compliance_config.sample_rate 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 = { result = {
"index": ev.index, "index": ev.index,
"record_type": ev.record_type, "record_type": ev.record_type,
"timestamp": _serialise_timestamp(ev.timestamp), "timestamp": _serialise_timestamp(ev.timestamp),
"total_samples": ev.total_samples, "total_samples": ev.total_samples,
"pretrig_samples": ev.pretrig_samples, "pretrig_samples": ev.pretrig_samples,
"rectime_seconds": ev.rectime_seconds, "rectime_seconds": rectime_seconds,
"samples_decoded": samples_decoded, "samples_decoded": samples_decoded,
"sample_rate": sample_rate, "sample_rate": sample_rate,
"peak_values": _serialise_peak_values(ev.peak_values), "peak_values": _serialise_peak_values(ev.peak_values),
+37 -3
View File
@@ -193,6 +193,10 @@
</div> </div>
<button id="connect-btn" onclick="connectUnit()">Connect</button> <button id="connect-btn" onclick="connectUnit()">Connect</button>
<button id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button> <button id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
<label style="display:flex;align-items:center;gap:4px;color:#8b949e;font-size:12px;cursor:pointer">
<input type="checkbox" id="force-reload" style="accent-color:#1f6feb" />
Force&nbsp;reload
</label>
<button id="prev-btn" onclick="stepEvent(-1)" disabled>◀ Prev</button> <button id="prev-btn" onclick="stepEvent(-1)" disabled>◀ Prev</button>
<button id="next-btn" onclick="stepEvent(+1)" disabled>Next ▶</button> <button id="next-btn" onclick="stepEvent(+1)" disabled>Next ▶</button>
</header> </header>
@@ -404,7 +408,8 @@
btn.disabled = true; btn.disabled = true;
setStatus('Fetching waveform…', 'loading'); 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; let data;
try { try {
@@ -456,7 +461,14 @@
appendMeta('sr', `${sr} sps`); appendMeta('sr', `${sr} sps`);
appendMeta('samples', `${decoded.toLocaleString()} / ${total.toLocaleString()}`); appendMeta('samples', `${decoded.toLocaleString()} / ${total.toLocaleString()}`);
appendMeta('pretrig', pretrig); 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 // No waveform data — show a clear reason instead of empty charts
if (decoded === 0) { if (decoded === 0) {
@@ -490,6 +502,13 @@
const micPeakPsi = data.peak_values?.micl_psi ?? null; const micPeakPsi = data.peak_values?.micl_psi ?? null;
const DBL_REF_PSI = 2.9e-9; // 20 µPa in psi 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)) { for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
const samples = channels[ch]; const samples = channels[ch];
if (!samples || samples.length === 0) continue; if (!samples || samples.length === 0) continue;
@@ -500,9 +519,16 @@
if (isGeo) { if (isGeo) {
// Geo channels: counts × (range / 32767) → in/s // Geo channels: counts × (range / 32767) → in/s
// Scale factor for the waveform shape (may need calibration per unit)
const scale = geoRange / 32767; const scale = geoRange / 32767;
plotSamples = samples.map(c => c * scale); 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`; peakLabel = `${peakIns.toFixed(5)} in/s`;
yUnit = 'in/s'; yUnit = 'in/s';
tooltipFmt = v => `${ch}: ${v.toFixed(5)} in/s`; tooltipFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
@@ -586,6 +612,14 @@
grid: { color: '#21262d' }, grid: { color: '#21262d' },
}, },
y: { 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: { ticks: {
color: '#484f58', color: '#484f58',
maxTicksLimit: 5, maxTicksLimit: 5,