From 171dc2551ca270e3ff8d573ba7b3657a73ed5ee3 Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Tue, 14 Apr 2026 17:08:27 -0400 Subject: [PATCH] fix: add STRT invalid detction, ach server passes config for get events, --- CLAUDE.md | 12 +++++++ bridges/ach_server.py | 1 + minimateplus/client.py | 71 +++++++++++++++++++++++++++++++++++++--- sfm/server.py | 6 +++- sfm/waveform_viewer.html | 28 +++++++++++----- 5 files changed, 104 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 399fc3f..b2ceb14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -191,6 +191,18 @@ 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. +**Pre-trigger time is separate from record_time (confirmed 2026-04-14):** +Blastware documentation states: "The default Time Scale is -0.25 second to 1 second — this +negative number accounts for the pre-trigger set for compliance monitoring." Therefore: +- `record_time` (3.0 s) is POST-TRIGGER duration only +- Pre-trigger = 0.25 s = 256 samples at 1024 sps (compliance monitoring standard default) +- The pre-trigger field has NOT yet been located in the raw compliance config bytes +- When STRT layout is invalid, `_decode_a5_waveform` falls back to pretrig = 0.25 × sr +- TODO: locate pretrig_time offset in ComplianceConfig — search around anchor or channel blocks + +The device bulk-streams zero-padded frames BEYOND the configured record window. The +viewer clips `displayCount = total_samples = pretrig + post_trig` to exclude this padding. + **Sanity check — pretrig_samples must be less than total_samples:** If `pretrig_samples >= total_samples` the STRT parse is invalid. Possible causes: DLE-stuffed `0x10` byte within prev_key4 or key4 shifted field offsets, or a different diff --git a/bridges/ach_server.py b/bridges/ach_server.py index 1dbfc85..6b03d2f 100644 --- a/bridges/ach_server.py +++ b/bridges/ach_server.py @@ -371,6 +371,7 @@ class AchSession: full_waveform=True, stop_after_index=stop_idx, skip_waveform_for_keys=seen_keys if seen_keys else None, + compliance_config=device_info.compliance_config if device_info else None, ) # Filter to events whose keys we haven't saved before. diff --git a/minimateplus/client.py b/minimateplus/client.py index c108b95..d3e596d 100644 --- a/minimateplus/client.py +++ b/minimateplus/client.py @@ -448,7 +448,7 @@ class MiniMateClient: proto.confirm_erase_all() log.info("delete_all_events: erase confirmed — device memory cleared") - def get_events(self, full_waveform: bool = False, debug: bool = False, stop_after_index: Optional[int] = None, skip_waveform_for_keys: Optional[set] = None) -> list[Event]: + def get_events(self, full_waveform: bool = False, debug: bool = False, stop_after_index: Optional[int] = None, skip_waveform_for_keys: Optional[set] = None, compliance_config: Optional["ComplianceConfig"] = None) -> list[Event]: """ Download all stored events from the device using the confirmed 1E → 0A → 0C → 5A → 1F event-iterator protocol. @@ -479,6 +479,7 @@ class MiniMateClient: ProtocolError: on unrecoverable communication failure. """ proto = self._require_proto() + _compliance_config = compliance_config # passed through to _decode_a5_waveform log.info("get_events: requesting first event (SUB 1E)") try: @@ -608,7 +609,7 @@ class MiniMateClient: if a5_frames: a5_ok = True _decode_a5_metadata_into(a5_frames, ev) - _decode_a5_waveform(a5_frames, ev) + _decode_a5_waveform(a5_frames, ev, compliance_config=_compliance_config) log.info( "get_events: 5A decoded %d sample-sets", len((ev.raw_samples or {}).get("Tran", [])), @@ -1311,6 +1312,7 @@ def _decode_a5_metadata_into(frames_data: list[bytes], event: Event) -> None: def _decode_a5_waveform( frames_data: list[bytes], event: Event, + compliance_config: Optional["ComplianceConfig"] = None, ) -> None: """ Decode the raw 4-channel ADC waveform from a complete set of SUB 5A @@ -1397,15 +1399,19 @@ def _decode_a5_waveform( # Sanity check: pretrig must be less than total_samples. # If not, the STRT layout is suspect (DLE-stuffing shift, different record variant, etc.). - # Log the raw bytes for diagnosis and clamp pretrig to 0 so the viewer renders. + # Log the raw bytes for diagnosis and clamp pretrig to 0 for now — will try to derive + # a better value from the compliance config after the full waveform is decoded. + _strt_invalid = False if pretrig_samples >= total_samples: log.warning( "_decode_a5_waveform: pretrig_samples=%d >= total_samples=%d — " "STRT layout suspect. Raw strt[0:21]: %s " - "Clamping pretrig to 0 for rendering.", + "Will attempt to derive pretrig from compliance config after decode.", pretrig_samples, total_samples, strt[0:21].hex(' '), ) pretrig_samples = 0 + total_samples = 0 # also invalid; will be filled from decoded count below + _strt_invalid = True # strt[18] is a record-mode/type byte, NOT rectime in seconds. # Confirmed from analysis of 4-9-26 ACH capture (15 distinct events): @@ -1534,6 +1540,63 @@ def _decode_a5_waveform( "Mic": mic, } + # ── Correct STRT-invalid pretrig/total from decoded count + compliance config ── + # When the STRT layout was suspect (pretrig >= total_samples), both values were + # zeroed out earlier. Now that we know the actual decoded sample count, use it + # together with the compliance config record_time to derive a meaningful pretrig. + # + # Formula: + # post_trig = record_time (s) × sample_rate (sps) + # pretrig = decoded_samples − post_trig + # + # This gives the pre-trigger window length, which correctly places t=0 in the + # waveform. If compliance_config is not available, leave pretrig=0 (viewer shows + # full waveform starting at t=0 — better than a crash or garbage). + n_decoded = len(tran) + if _strt_invalid: + if compliance_config is not None: + cc_sr = compliance_config.sample_rate or 1024 + cc_rt = compliance_config.record_time + # Pre-trigger time is a separate device setting from Record Time. + # Blastware documentation confirms the compliance monitoring standard + # is 0.25 seconds pre-trigger ("the default Time Scale is -0.25 to 1 + # second — this negative number accounts for the pre-trigger set for + # compliance monitoring"). + # The pre-trigger field has not yet been located in the raw compliance + # config bytes; 0.25 s is used as the best-known default until it is + # decoded. TODO: locate pretrig_time in ComplianceConfig bytes. + _PRETRIG_SECONDS_DEFAULT = 0.25 + derived_pretrig = int(round(_PRETRIG_SECONDS_DEFAULT * cc_sr)) + if cc_rt and cc_rt > 0: + post_trig_samples = int(round(cc_rt * cc_sr)) + # Clip total to pretrig + post_trig so the viewer doesn't show the + # zero-padded tail frames the device appends beyond the record window. + event.total_samples = derived_pretrig + post_trig_samples + event.pretrig_samples = derived_pretrig + event.rectime_seconds = int(round(cc_rt)) + log.info( + "_decode_a5_waveform: STRT was invalid — using pretrig=%d " + "(%.2fs default) from compliance standard; record_time=%.1fs " + "sr=%d sps decoded=%d samples display_total=%d", + derived_pretrig, _PRETRIG_SECONDS_DEFAULT, + cc_rt, cc_sr, n_decoded, event.total_samples, + ) + else: + event.total_samples = n_decoded + event.pretrig_samples = derived_pretrig + log.warning( + "_decode_a5_waveform: STRT invalid, compliance config missing " + "record_time — pretrig=%d (%.2fs default), total=decoded %d", + derived_pretrig, _PRETRIG_SECONDS_DEFAULT, n_decoded, + ) + else: + event.total_samples = n_decoded + log.warning( + "_decode_a5_waveform: STRT invalid, no compliance config available " + "— pretrig left as 0, total set to decoded count %d", + n_decoded, + ) + def _extract_record_type(data: bytes) -> Optional[str]: """ diff --git a/sfm/server.py b/sfm/server.py index f584ff3..8e15364 100644 --- a/sfm/server.py +++ b/sfm/server.py @@ -662,7 +662,11 @@ def device_event_waveform( with _build_client(port, baud, host, tcp_port, timeout=120.0) as client: info = client.connect() # stop_after_index avoids downloading events beyond the one requested. - events = client.get_events(full_waveform=True, stop_after_index=index) + events = client.get_events( + full_waveform=True, + stop_after_index=index, + compliance_config=info.compliance_config if info else None, + ) matching = [ev for ev in events if ev.index == index] return matching[0] if matching else None, info ev, info = _run_with_retry(_do, is_tcp=_is_tcp(host)) diff --git a/sfm/waveform_viewer.html b/sfm/waveform_viewer.html index 877a259..c719793 100644 --- a/sfm/waveform_viewer.html +++ b/sfm/waveform_viewer.html @@ -483,9 +483,14 @@ return; } - // Build time axis (ms) - const times = Array.from({ length: decoded }, (_, i) => - ((i - pretrig) / sr * 1000).toFixed(2) + // Clip to total_samples to exclude zero-padding the device appends beyond + // the configured record window. total = pretrig + post_trig (e.g. 256+3072=3328). + // decoded may be larger (e.g. 4495) due to trailing zero-padded bulk-stream frames. + const displayCount = (total > 0 && total < decoded) ? total : decoded; + + // Build time axis in seconds (matching Blastware event report layout). + const times = Array.from({ length: displayCount }, (_, i) => + ((i - pretrig) / sr).toFixed(3) ); // Show charts area @@ -517,11 +522,16 @@ const isGeo = ch !== 'Mic'; let plotSamples, peakLabel, yUnit, tooltipFmt, tickFmt; + // Clip channel samples to displayCount (same as time axis) + const clippedSamples = samples.length > displayCount + ? samples.slice(0, displayCount) + : samples; + 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); + plotSamples = clippedSamples.map(c => c * scale); // Use the device-computed 0C record peak for the label (authoritative). // The raw-sample-computed peak can be inflated by frame-boundary artifacts. @@ -535,11 +545,11 @@ tickFmt = v => v.toFixed(4); } else { // Mic: derive psi/count scale from the 0C peak value, display as psi; show dBL in header - const peakCounts = Math.max(...samples.map(Math.abs)); + const peakCounts = Math.max(...clippedSamples.map(Math.abs)); const micScale = (micPeakPsi !== null && peakCounts > 0) ? Math.abs(micPeakPsi) / peakCounts : 1.0; - plotSamples = samples.map(c => c * micScale); + plotSamples = clippedSamples.map(c => c * micScale); const peakPsi = Math.max(...plotSamples.map(Math.abs)); const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF_PSI) : -Infinity; peakLabel = `${peakDbl.toFixed(1)} dBL (${peakPsi.toExponential(3)} psi)`; @@ -597,7 +607,7 @@ mode: 'index', intersect: false, callbacks: { - title: items => `t = ${items[0].label} ms`, + title: items => `t = ${items[0].label} s`, label: item => tooltipFmt(item.raw), }, }, @@ -609,7 +619,7 @@ color: '#484f58', maxTicksLimit: 10, maxRotation: 0, - callback: (val, i) => renderTimes[i] + ' ms', + callback: (val, i) => renderTimes[i] + ' s', }, grid: { color: '#21262d' }, }, @@ -647,7 +657,7 @@ const xAxis = chart.scales.x; const yAxis = chart.scales.y; - // Find index of t=0 + // Find index of the trigger point (t ≥ 0.000 s) const zeroIdx = renderTimes.findIndex(t => parseFloat(t) >= 0); if (zeroIdx < 0) return;