diff --git a/minimateplus/event_file_io.py b/minimateplus/event_file_io.py index 082e7ee..a00d02c 100644 --- a/minimateplus/event_file_io.py +++ b/minimateplus/event_file_io.py @@ -818,6 +818,30 @@ def derive_record_type_from_filename(filename, default: str = "Waveform") -> str return _RECORD_TYPE_BY_EXT_SUFFIX.get(ext[-1].upper(), default) +# Marker for the recording-setup config block, and the offset of the record-time +# float32 within it. The configured post-trigger record time (seconds) is a +# big-endian float32 exactly 30 bytes before the "Standard Recording Setup" +# label. Verified across the corpus reading 1.0 / 2.0 / 3.0 s on different +# setups — and ts2 - record_time reproduces Blastware's trigger to the second +# (N844LQHB: stop 10:33:32 - 3.0 = 10:33:29). +_RECSETUP_MARKER = b"Standard Recording Setup" +_RECTIME_OFFSET_BEFORE_MARKER = 30 + + +def _parse_record_time_seconds(raw: bytes) -> Optional[float]: + """The configured post-trigger record time in seconds, from the recording- + setup config block, or None when absent / implausible.""" + a = raw.find(_RECSETUP_MARKER) + if a < _RECTIME_OFFSET_BEFORE_MARKER: + return None + off = a - _RECTIME_OFFSET_BEFORE_MARKER + try: + rt = struct.unpack(">f", raw[off:off + 4])[0] + except struct.error: + return None + return rt if 0.05 <= rt <= 600.0 else None + + def read_blastware_file(path: Union[str, Path]) -> Event: """ Parse a Blastware waveform file into an Event. @@ -966,22 +990,25 @@ def read_blastware_file(path: Union[str, Path]) -> Event: # record type: # * Waveform: ts1 = the monitoring-SESSION start (shared across every # event that day — a unit arming at 06:00 stamps 06:00 on all of them), - # ts2 = THIS event's recording STOP. ts2 is the correct binary-only - # estimate of the event time; BW's displayed Date/Time is the trigger = - # ts2 - record duration (~3 s), but the record-duration byte in the STRT - # record is a misparsed record-type marker here (see the strt-build - # note), so the exact trigger comes from the paired BW report's - # event_datetime — apply_report_to_event() overrides with it when a - # report is present. (Stamping ts1 showed the session start, hours off.) + # ts2 = THIS event's recording STOP. Blastware's Date/Time is the + # TRIGGER = ts2 - record time, and the record time is a float32 in the + # recording-setup config block (see _parse_record_time_seconds), so the + # exact trigger is recoverable from the binary alone. Falls back to ts2 + # (the stop, within the record duration) if the config block is absent. + # (Stamping ts1 showed the session start, hours off.) # * Histogram / undecodable: ts1 = the window start, which IS the event # time — keep it. # Discriminate by ``is_waveform_body`` (the codec), not the filename. if is_waveform_body and ts2 is not None: + _stop = datetime.datetime(ts2.year, ts2.month, ts2.day, + ts2.hour, ts2.minute, ts2.second) + _rt = _parse_record_time_seconds(raw) + _trig = _stop - datetime.timedelta(seconds=_rt) if _rt is not None else _stop ev.timestamp = Timestamp( raw=footer[10:18], flag=0x10, - year=ts2.year, unknown_byte=0, month=ts2.month, day=ts2.day, - hour=ts2.hour, minute=ts2.minute, second=ts2.second, + year=_trig.year, unknown_byte=0, month=_trig.month, day=_trig.day, + hour=_trig.hour, minute=_trig.minute, second=_trig.second, ) elif ts1 is not None: ev.timestamp = Timestamp( diff --git a/tests/test_event_timestamp.py b/tests/test_event_timestamp.py index 06ef82f..f4e692a 100644 --- a/tests/test_event_timestamp.py +++ b/tests/test_event_timestamp.py @@ -29,11 +29,12 @@ def _tuple(ts): return (ts.year, ts.month, ts.day, ts.hour, ts.minute, ts.second) -def test_waveform_timestamp_is_event_stop_not_session_start(): +def test_waveform_timestamp_is_exact_trigger_from_binary(): ev = read_blastware_file(WAVEFORM) - # ts2 (the event's recording stop), NOT the 06:00:13 monitoring-session - # start the old decode used. - assert _tuple(ev.timestamp) == (2026, 8, 25, 10, 33, 32), _tuple(ev.timestamp) + # The EXACT Blastware trigger, from the binary alone: ts2 (stop 10:33:32) + # minus the config record time (3.0 s) = 10:33:29 — NOT the 06:00:13 + # monitoring-session start the old decode used. + assert _tuple(ev.timestamp) == (2026, 8, 25, 10, 33, 29), _tuple(ev.timestamp) def test_histogram_timestamp_is_window_start_unchanged(): @@ -43,11 +44,11 @@ def test_histogram_timestamp_is_window_start_unchanged(): assert _tuple(ev.timestamp) == (2026, 5, 10, 19, 4, 50), _tuple(ev.timestamp) -def test_report_event_datetime_overrides_to_exact_trigger(): - # A paired BW report carries the exact trigger time; applying it must - # override the binary footer's stop time so the event matches BW exactly. +def test_report_event_datetime_is_authoritative_over_binary(): + # The binary already yields the exact trigger, but a paired report stays + # authoritative (e.g. if the unit clock had drifted) — applying it wins. ev = read_blastware_file(WAVEFORM) - assert _tuple(ev.timestamp) == (2026, 8, 25, 10, 33, 32) # stop, pre-report + assert _tuple(ev.timestamp) == (2026, 8, 25, 10, 33, 29) # exact, from binary apply_report_to_event(ev, BwAsciiReport( - event_datetime=datetime.datetime(2026, 8, 25, 10, 33, 29))) - assert _tuple(ev.timestamp) == (2026, 8, 25, 10, 33, 29) # exact BW trigger + event_datetime=datetime.datetime(2026, 8, 25, 10, 35, 0))) + assert _tuple(ev.timestamp) == (2026, 8, 25, 10, 35, 0) # report wins