fix: add STRT invalid detction, ach server passes config for get events,

This commit is contained in:
2026-04-14 17:08:27 -04:00
parent 4f4c1a8f64
commit 171dc2551c
5 changed files with 104 additions and 14 deletions
+12
View File
@@ -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 `_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. `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:** **Sanity check — pretrig_samples must be less than total_samples:**
If `pretrig_samples >= total_samples` the STRT parse is invalid. Possible causes: 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 DLE-stuffed `0x10` byte within prev_key4 or key4 shifted field offsets, or a different
+1
View File
@@ -371,6 +371,7 @@ class AchSession:
full_waveform=True, full_waveform=True,
stop_after_index=stop_idx, stop_after_index=stop_idx,
skip_waveform_for_keys=seen_keys if seen_keys else None, 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. # Filter to events whose keys we haven't saved before.
+67 -4
View File
@@ -448,7 +448,7 @@ class MiniMateClient:
proto.confirm_erase_all() proto.confirm_erase_all()
log.info("delete_all_events: erase confirmed — device memory cleared") 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 Download all stored events from the device using the confirmed
1E 0A 0C 5A 1F event-iterator protocol. 1E 0A 0C 5A 1F event-iterator protocol.
@@ -479,6 +479,7 @@ class MiniMateClient:
ProtocolError: on unrecoverable communication failure. ProtocolError: on unrecoverable communication failure.
""" """
proto = self._require_proto() proto = self._require_proto()
_compliance_config = compliance_config # passed through to _decode_a5_waveform
log.info("get_events: requesting first event (SUB 1E)") log.info("get_events: requesting first event (SUB 1E)")
try: try:
@@ -608,7 +609,7 @@ class MiniMateClient:
if a5_frames: if a5_frames:
a5_ok = True a5_ok = True
_decode_a5_metadata_into(a5_frames, ev) _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( log.info(
"get_events: 5A decoded %d sample-sets", "get_events: 5A decoded %d sample-sets",
len((ev.raw_samples or {}).get("Tran", [])), 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( def _decode_a5_waveform(
frames_data: list[bytes], frames_data: list[bytes],
event: Event, event: Event,
compliance_config: Optional["ComplianceConfig"] = None,
) -> None: ) -> None:
""" """
Decode the raw 4-channel ADC waveform from a complete set of SUB 5A 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. # Sanity check: pretrig must be less than total_samples.
# If not, the STRT layout is suspect (DLE-stuffing shift, different record variant, etc.). # 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: if pretrig_samples >= total_samples:
log.warning( log.warning(
"_decode_a5_waveform: pretrig_samples=%d >= total_samples=%d" "_decode_a5_waveform: pretrig_samples=%d >= total_samples=%d"
"STRT layout suspect. Raw strt[0:21]: %s " "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, total_samples, strt[0:21].hex(' '),
) )
pretrig_samples = 0 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. # strt[18] is a record-mode/type byte, NOT rectime in seconds.
# Confirmed from analysis of 4-9-26 ACH capture (15 distinct events): # Confirmed from analysis of 4-9-26 ACH capture (15 distinct events):
@@ -1534,6 +1540,63 @@ def _decode_a5_waveform(
"Mic": mic, "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]: def _extract_record_type(data: bytes) -> Optional[str]:
""" """
+5 -1
View File
@@ -662,7 +662,11 @@ def device_event_waveform(
with _build_client(port, baud, host, tcp_port, timeout=120.0) as client: with _build_client(port, baud, host, tcp_port, timeout=120.0) as client:
info = client.connect() info = client.connect()
# stop_after_index avoids downloading events beyond the one requested. # 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] matching = [ev for ev in events if ev.index == index]
return matching[0] if matching else None, info return matching[0] if matching else None, info
ev, info = _run_with_retry(_do, is_tcp=_is_tcp(host)) ev, info = _run_with_retry(_do, is_tcp=_is_tcp(host))
+19 -9
View File
@@ -483,9 +483,14 @@
return; return;
} }
// Build time axis (ms) // Clip to total_samples to exclude zero-padding the device appends beyond
const times = Array.from({ length: decoded }, (_, i) => // the configured record window. total = pretrig + post_trig (e.g. 256+3072=3328).
((i - pretrig) / sr * 1000).toFixed(2) // 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 // Show charts area
@@ -517,11 +522,16 @@
const isGeo = ch !== 'Mic'; const isGeo = ch !== 'Mic';
let plotSamples, peakLabel, yUnit, tooltipFmt, tickFmt; 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) { 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) // 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 = clippedSamples.map(c => c * scale);
// Use the device-computed 0C record peak for the label (authoritative). // Use the device-computed 0C record peak for the label (authoritative).
// The raw-sample-computed peak can be inflated by frame-boundary artifacts. // The raw-sample-computed peak can be inflated by frame-boundary artifacts.
@@ -535,11 +545,11 @@
tickFmt = v => v.toFixed(4); tickFmt = v => v.toFixed(4);
} else { } else {
// Mic: derive psi/count scale from the 0C peak value, display as psi; show dBL in header // 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) const micScale = (micPeakPsi !== null && peakCounts > 0)
? Math.abs(micPeakPsi) / peakCounts ? Math.abs(micPeakPsi) / peakCounts
: 1.0; : 1.0;
plotSamples = samples.map(c => c * micScale); plotSamples = clippedSamples.map(c => c * micScale);
const peakPsi = Math.max(...plotSamples.map(Math.abs)); const peakPsi = Math.max(...plotSamples.map(Math.abs));
const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF_PSI) : -Infinity; const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF_PSI) : -Infinity;
peakLabel = `${peakDbl.toFixed(1)} dBL (${peakPsi.toExponential(3)} psi)`; peakLabel = `${peakDbl.toFixed(1)} dBL (${peakPsi.toExponential(3)} psi)`;
@@ -597,7 +607,7 @@
mode: 'index', mode: 'index',
intersect: false, intersect: false,
callbacks: { callbacks: {
title: items => `t = ${items[0].label} ms`, title: items => `t = ${items[0].label} s`,
label: item => tooltipFmt(item.raw), label: item => tooltipFmt(item.raw),
}, },
}, },
@@ -609,7 +619,7 @@
color: '#484f58', color: '#484f58',
maxTicksLimit: 10, maxTicksLimit: 10,
maxRotation: 0, maxRotation: 0,
callback: (val, i) => renderTimes[i] + ' ms', callback: (val, i) => renderTimes[i] + ' s',
}, },
grid: { color: '#21262d' }, grid: { color: '#21262d' },
}, },
@@ -647,7 +657,7 @@
const xAxis = chart.scales.x; const xAxis = chart.scales.x;
const yAxis = chart.scales.y; 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); const zeroIdx = renderTimes.findIndex(t => parseFloat(t) >= 0);
if (zeroIdx < 0) return; if (zeroIdx < 0) return;