diff --git a/minimateplus/event_file_io.py b/minimateplus/event_file_io.py index 188df85..082e7ee 100644 --- a/minimateplus/event_file_io.py +++ b/minimateplus/event_file_io.py @@ -296,6 +296,16 @@ def apply_report_to_event(event: Event, report: BwAsciiReport) -> None: event.sample_rate = report.sample_rate_sps if report.record_time_s is not None: event.rectime_seconds = report.record_time_s + # The report's event_datetime is Blastware's exact trigger time (parsed + # from Event Time + Event Date). Prefer it over the binary footer's stop + # time so a report-paired import matches BW to the second. + edt = report.event_datetime + if edt is not None: + event.timestamp = Timestamp( + raw=b"", flag=0x10, + year=edt.year, unknown_byte=0, month=edt.month, day=edt.day, + hour=edt.hour, minute=edt.minute, second=edt.second, + ) def apply_bw_report_dict_to_event(event: Event, bw_report: dict) -> None: @@ -917,6 +927,10 @@ def read_blastware_file(path: Union[str, Path]) -> Event: # rest of the event (timestamp, waveform_key, project strings) is # still recoverable and useful. decoded = decode_waveform_v2(body) + # Discriminator for the timestamp logic below: a waveform (trigger) event + # vs a histogram window. Keyed on the codec, not the filename — the + # save_imported_bw path passes a tmp ".bw" name whose extension lies. + is_waveform_body = decoded is not None if decoded is None: decoded = decode_histogram_body(body) if decoded is None: @@ -948,7 +962,28 @@ def read_blastware_file(path: Union[str, Path]) -> Event: ev.total_samples = strt_fields.get("total_samples") ev.pretrig_samples = strt_fields.get("pretrig_samples") - if ts1 is not None: + # Event timestamp. The footer's two timestamps mean different things by + # 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.) + # * 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: + 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, + ) + elif ts1 is not None: ev.timestamp = Timestamp( raw=footer[2:10], flag=0x10, diff --git a/tests/fixtures/ts-fix/K441LKZU.C30H b/tests/fixtures/ts-fix/K441LKZU.C30H new file mode 100644 index 0000000..36ef2e1 Binary files /dev/null and b/tests/fixtures/ts-fix/K441LKZU.C30H differ diff --git a/tests/test_event_timestamp.py b/tests/test_event_timestamp.py new file mode 100644 index 0000000..06ef82f --- /dev/null +++ b/tests/test_event_timestamp.py @@ -0,0 +1,53 @@ +"""Event timestamp decode — waveform trigger/stop vs histogram window start. + +The Blastware footer holds two timestamps: ts1 = footer[2:10], ts2 = footer[10:18]. +Their meaning depends on record type: + + * Waveform: ts1 is the monitoring-SESSION start (e.g. 06:00 for a unit that + arms at 06:00 daily — shared across every event that day), and ts2 is THIS + event's recording STOP. read_blastware_file used to stamp events with ts1 → + every waveform showed the session start (~4.5 h off). Binary-only, the best + estimate is ts2 (the stop); the exact trigger BW displays (= ts2 - record + duration) comes from the paired report's event_datetime, since the binary + STRT record-time byte is a misparsed record-type marker. + * Histogram: ts1/ts2 are the ~24 h window [start, stop]; the event time is the + window start = ts1 (unchanged). +""" +import datetime +from pathlib import Path + +from minimateplus.event_file_io import read_blastware_file, apply_report_to_event +from minimateplus.bw_ascii_report import BwAsciiReport +from minimateplus.models import Event + +FIX = Path(__file__).parent / "fixtures" +WAVEFORM = FIX / "fft-oracle-2026-09-14" / "N844LQHB.ZT0W" # footer ts2 = 2026-08-25 10:33:32 +HISTOGRAM = FIX / "ts-fix" / "K441LKZU.C30H" # window start 2026-05-10 19:04:50 + + +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(): + 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) + + +def test_histogram_timestamp_is_window_start_unchanged(): + ev = read_blastware_file(HISTOGRAM) + # Histogram event time = the window start (ts1); must NOT get the waveform + # ts2 treatment (that would land ~24 h off). + 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. + ev = read_blastware_file(WAVEFORM) + assert _tuple(ev.timestamp) == (2026, 8, 25, 10, 33, 32) # stop, pre-report + 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