diff --git a/CHANGELOG.md b/CHANGELOG.md index 85936fa..3a9f718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to seismo-relay are documented here. --- +## v0.16.0 — 2026-05-11 + +The "BW ACH ingestion" release. When paired with **series3-watcher v1.5.0**, every Blastware ACH event (binary + `_ASCII.TXT` report) lands in SeismoDb with device-authoritative peaks, project metadata, sensor self-check, and ZC/Time-of-Peak data — without depending on the still-undecoded waveform body codec. This is the end-to-end product win discussed in v0.15.0's "out of scope" notes: sortable / filterable monthly-summary review of historical events, populated from the BW ASCII export rather than re-decoded samples. + +### Added — `/db/import/blastware_file` rich-metadata ingestion + +- **Paired BW ASCII reports.** The endpoint now accepts the `__ASCII.TXT` partner BW writes alongside each event. Pairing handles both filename conventions: ACH (`M529LK44_AB0_ASCII.TXT`) and manual-export (`M529LK44.AB0.TXT`). When both present, ACH wins. +- **`minimateplus/bw_ascii_report.py`** (new) — parser + `BwAsciiReport` dataclass for BW's per-event ASCII export. Handles every field BW writes: identity, trigger config, per-channel PPV / ZC Freq / Time of Peak / Peak Acceleration / Peak Displacement, Peak Vector Sum + time, MicL PSPL / Time of Peak / ZC Freq, sensor self-check (Test Freq / Test Ratio / Test Amplitude / Pass-Fail per channel), monitor log, PC SW version. +- **Position-based user-notes parsing.** BW's Compliance Setup → Notes tab labels (Project / Client / User Name / Seis Loc) are *operator-editable* — an operator can rename them to "Building:", "Site Address:", etc. Rather than maintain a label-spelling map, the parser uses positional matching between the `Units :` and `Geo Range :` anchors in the ASCII output. The four canonical slots (project / client / operator / sensor_location) populate by position regardless of label; the original labels BW wrote are preserved in `report.user_note_labels` for downstream UIs (terra-view) to display verbatim. +- **`bw_report` sidecar block.** New top-level block in `.sfm.json` carrying the parsed BW report (trigger config, peaks with per-channel stats, mic block, sensor_check, monitor_log, PC SW version, operator-label labels). +- **`apply_report_to_event(event, report)` helper.** Overlays the report's device-authoritative fields onto an in-memory `Event` so `SeismoDb.insert_events()` writes correct DB columns instead of the broken-codec values from `_peaks_from_samples()`. + +### Fixed — three compounding bugs that left forwarded events with garbage data + +- **Import endpoint inserted under `serial="UNKNOWN"`.** `_serial_from_event(ev)` was a stub that always returned `None`; the BW-filename-decoded serial that `WaveformStore` had already resolved was never surfaced to `db.insert_events`. Now uses `rec["serial"]` as the authoritative source. `scripts/repair_unknown_serials.py` repairs existing DB rows. +- **`/db/units` ignored events from non-ACH ingest paths.** `query_units()` only aggregated from `ach_sessions` — events that arrived via `save_imported_bw()` were never visible in the fleet overview even though they populated `events` correctly. Now unions both tables. +- **Re-imports left stale DB rows.** The `IntegrityError` handler in `insert_events()` only refreshed filename / sidecar columns when a duplicate `(serial, timestamp)` arrived. Peak values, project info, sample_rate, record_type stayed locked at whatever the first (often broken-codec) insert wrote. Now the upsert path refreshes every device-authoritative column from the new data while preserving `false_trigger` and immutable fields (`id`, `created_at`). +- **Server-side TXT pairing only knew the legacy convention.** The endpoint stripped `.TXT` and looked up `` — which works for manual exports (`.TXT`) but not BW ACH (`__ASCII.TXT`). Reports were arriving in the multipart but silently dropped. Now recognises both conventions and registers each report under all matching binary names. + +### Migration + +For existing deployments where events were forwarded by an older watcher (broken pairing) or imported during the UNKNOWN-bucketing window: + +1. `python -m scripts.repair_unknown_serials --db --apply` to re-attribute `serial="UNKNOWN"` rows. +2. Delete the watcher's `sfm_forwarded.json` state file and let it re-forward. The server's upsert path will refresh the existing DB rows with the report's authoritative values. +3. Operator review state (`false_trigger`, sidecar `review` block) is preserved across the re-import. + ## v0.15.0 — 2026-05-07 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 627c0cb..56a9e69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1353,6 +1353,8 @@ body) because writing a dial string may require DLE escaping for embedded contro ## What's next +**See [README.md → Roadmap (Future)](README.md#roadmap-future) for the canonical deferred-work list.** This section is kept as a status log of in-progress / recently-shipped technical details (encoding schemes, byte layouts, etc.) that are too low-level for the README's roadmap. + - **Database** — SQLite store for events + monitor log entries; dedup by key; queryable - **Histograms** — decode histogram-mode A5 data (noise floor tracking) - **Blastware-compatible file output** — `write_blastware_file()` and `write_mlg()` implemented. `blastware_filename()` generates correct Blastware filenames (AB0 for direct, AB0W/AB0H for ACH). **Confirmed BYTE-PERFECT against BW reference (v0.14.3, 2026-05-05):** when fed the BW 5-1-26 3-sec capture's A5 frames, the SFM-built file matches BW's saved `M529LKIQ.G10` byte-for-byte (8708 bytes, 0 differences). Live SFM downloads of event 0 (3-sec) and event 1 (3-sec continuation) both open cleanly in Blastware with full Event Reports, frequency analysis, and waveform plots. Body assembly is just contiguous concatenation of frame contributions in stream order (probe → meta@0x1002 → meta@0x1004 → samples → TERM); no stripping, no overlay, no special handling. Histogram+Continuous mode deferred (5A stream for those events embeds histogram interval records that may need different handling — untested under v0.14.x). Extension mapping: extensions encode timestamp (AB0T for ACH, AB0 for direct), NOT recording mode. Filename format: `<4-char-base36-stem>` diff --git a/README.md b/README.md index 9590923..fc92a77 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# seismo-relay `v0.15.0` +# seismo-relay `v0.16.0` A ground-up replacement for **Blastware** — Instantel's aging Windows-only software for managing MiniMate Plus seismographs. @@ -14,11 +14,12 @@ over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55). > byte-perfect against Blastware captures across 2-sec, 3-sec, and 10-sec > events.** Generated `.G10` / `.AB0` files open cleanly in Blastware with > full Event Reports, frequency analysis, and waveform plots. -> **v0.15.0 (2026-05-07)** adds layered per-event storage (BW binary + -> raw 5A pickle + HDF5 + `.sfm.json` sidecar), a plot-ready -> `sfm.plot.v1` JSON shape with server-side ADC-to-physical-units -> conversion, and a BW-file importer for ingesting externally-produced -> events. See [CHANGELOG.md](CHANGELOG.md) for full version history. +> **v0.16.0 (2026-05-11)** adds BW ASCII report ingestion to +> `/db/import/blastware_file` — paired with **series3-watcher v1.5.0**, +> every Blastware ACH event lands in SeismoDb with device-authoritative +> peaks, project metadata, sensor self-check, and ZC/Time-of-Peak data, +> without depending on the still-undecoded waveform body codec. +> See [CHANGELOG.md](CHANGELOG.md) for full version history. --- @@ -356,10 +357,40 @@ Use **com0com** or **VSPD** to create the virtual COM pair on Windows. ## Roadmap (Future) -- [ ] Verify 30-sec event download — body may exceed `0xFFFF` and force the device into a different `end_key` encoding (none of 2/3/10-sec test cases hit this boundary) -- [ ] Terra-view integration — seismo-relay router, unit detail page, VISON-style event listing -- [ ] Vibration summary reports — highest legit PPV per project → Word doc (false trigger filtering first) -- [ ] Compliance config encoder — build raw write payloads from a `ComplianceConfig` object -- [ ] Modem manager — push RV50/RV55 configs via Sierra Wireless API -- [ ] Histogram mode recording support (5A stream analysis for mode 0x03) -- [ ] Call Home dial_string write support (requires DLE escaping for embedded control characters) +### High-impact (unblocks product features) + +- [ ] **Waveform body codec reverse-engineering.** The 5A bulk-stream body is some kind of compressed/encoded format (not raw int16 LE as previously assumed — see §7.6.1 retraction in `docs/instantel_protocol_reference.md`). Structural framing is ~50% decoded on branch `claude/codec-re-cBGNe` (tagged-block walker, segment counters); per-byte sample mapping is still open. Until this lands, the in-app waveform viewer renders garbage and BW-import peak values fall back to `_peaks_from_samples()` saturation noise. Workaround: pair every BW-imported event with its `_ASCII.TXT` so the device-authoritative peaks land in the DB regardless of codec. +- [ ] **In-app waveform viewer accuracy.** Depends on codec decode. Plot.v1 JSON pipeline + viewer skeleton already exist; will start showing real waveforms automatically once `_decode_a5_waveform` produces correct samples. +- [ ] **Terra-view integration** — seismo-relay router, unit detail page, VISON-style event listing. +- [ ] **Vibration summary reports** — highest legit PPV per project → Word doc (false-trigger filtering first). + +### BW ASCII report parser enhancements (built in v0.16.0) + +- [ ] **Histogram-specific structural fields.** Current parser handles the shared fields (PPV, ZC Freq, sensor self-check, project) but silently drops histogram-only fields: `Histogram Start/Stop Time`, `Histogram Start/Stop Date`, `Number of Intervals`, `Interval Size`, per-channel `Peak Time` + `Peak Date` (absolute timestamps rather than the waveform's `Time of Peak` relative seconds). +- [ ] **Histogram interval bin-table parsing.** Trailing 792-row table (per-interval Peak/Freq per channel + MicL) in histogram TXTs is unparsed. Probably too big for the sidecar JSON; may want a separate `.histogram.h5` companion file. +- [ ] **`>100 Hz` value parsing.** Histogram TXTs use `>100 Hz` for out-of-range ZC freq; current `_parse_number()` returns `None` for these (loses information). + +### Ingestion gaps + +- [ ] **MLG forwarding.** `series3-watcher` forwards event binaries + their `_ASCII.TXT` reports, but skips `.MLG` per-unit monitor log files entirely. Adding an `POST /db/import/mlg_file` endpoint + watcher scan path would populate `monitor_log` for non-ACH-routed units (coverage queries, "was this unit monitoring on date X" lookups). +- [ ] **0C-record raw bytes persistence in the sidecar.** Currently on branch `claude/codec-re-cBGNe` as commit `a187124`; cherry-pick if useful as a standalone fix. Preserves the 210-byte 0C record under `extensions.raw_records.waveform_record_b64` so future field-offset analysis (Peak Acceleration / Time of Peak / etc. — the fields BW computes client-side from samples) can run offline. + +### Operational + +- [ ] **`series3-watcher` file archive manager** — 90-day-old events moved to `_archive///` subfolders. Plan drafted in `claude/codec-re-cBGNe`'s plan-mode session; awaiting a 5-minute test on whether Blastware UI walks subfolders before any code lands (determines layout: in-place subfolders vs sibling archive). +- [ ] **Compliance config encoder** — build raw write payloads from a `ComplianceConfig` object. +- [ ] **Modem manager** — push RV50/RV55 configs via Sierra Wireless API. +- [ ] **Call Home dial_string write support** (requires DLE escaping for embedded control characters). +- [ ] **Histogram mode recording support** (5A stream analysis for mode 0x03 — separate from histogram ASCII parsing above). + +### Test coverage + +- [ ] Verify 30-sec event download — body may exceed `0xFFFF` and force the device into a different `end_key` encoding (none of the 2/3/10-sec test cases hit this boundary). +- [ ] Histogram mode (0x03) write via SFM — confirmed working for Single Shot / Continuous / Histogram+Continuous; Histogram (0x03) needs a live test from a non-Histogram starting state. + +### Lower-priority cleanups + +- [ ] Compliance write anchor-9 cleanup — when changing recording_mode via SFM, a spurious `0x10` may persist after Histogram→other mode transitions. Doesn't affect device operation but differs from BW's byte-perfect output. +- [ ] Locate "Sensor Check" byte in compliance config (need capture with Disabled vs Before-monitoring). +- [ ] Call Home — map time slots 3/4 offsets; confirm `modem_power_relay_enabled`. +- [ ] RV55 DCD/DTR — newer RV55 firmware doesn't assert DCD by default; units don't resume monitoring after call-home disconnect (`--restart-monitoring` flag deferred). diff --git a/minimateplus/bw_ascii_report.py b/minimateplus/bw_ascii_report.py new file mode 100644 index 0000000..a3aee4b --- /dev/null +++ b/minimateplus/bw_ascii_report.py @@ -0,0 +1,522 @@ +""" +minimateplus/bw_ascii_report.py — parser for Blastware's per-event ASCII +report (the .TXT file BW writes alongside each saved event binary). + +The ASCII export is the authoritative source for every "rich" per-event +field that BW computes from the waveform but never persists in the BW +binary itself: + + - Per-channel PPV (Tran / Vert / Long / MicL) + - Peak Vector Sum + Peak Vector Sum Time + - Per-channel ZC Freq, Time of Peak, Peak Acceleration, Peak Displacement + - MicL PSPL, MicL Time of Peak, MicL ZC Freq + - Per-channel Sensor Self-Check (Test Freq / Test Ratio / Test Results) + - MicL Test Amplitude (mV) + - Battery, calibration date, monitor-log timestamps + +Persisting these values into the SFM database lets the monthly-summary +review workflow ("show me events at Location X with PVS > 0.5") work +without depending on the (still-undecoded) waveform body codec. + +Format (verified against decode-re/5-8-26 4-event bundle): + + - One field per line, wrapped in double quotes: `"Field Name : Value"` + - Field/value separator: literal ` : ` (space-colon-space). + - Some field names contain an internal `:` already (e.g. `"Project:"`), + so we split on the FIRST ` : ` only. + - Some fields have unit suffixes: `"0.500 in/s"` / `"7.5 Hz"` / `"533 mv"`. + - A `"Monitor Log(s)"` marker line is followed by tab-separated rows + of `start_timestop_timedescription`. + - Final `"PC SW Version : ..."` line ends the metadata block. + - A blank line separates metadata from the sample table. + - Sample table starts with ` Tran Vert ...`, then + one row per sample (tab-separated, right-padded numeric values). + - Geo channel values are in in/s; MicL in dB(L) (or 0.000 below threshold). + +Because some metadata fields have whitespace quirks ("MicL Time of +Peak" has two spaces; the leading "Project:" value has its own colon), +we normalise whitespace in the key before lookup. +""" + +from __future__ import annotations + +import datetime +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional, Tuple, Union + + +# ───────────────────────────────────────────────────────────────────────────── +# Output dataclasses +# ───────────────────────────────────────────────────────────────────────────── + + +@dataclass +class ChannelStats: + """Per-channel derived stats, populated from an event report.""" + ppv_ips: Optional[float] = None # in/s (geo channels only) + zc_freq_hz: Optional[float] = None # Hz + time_of_peak_s: Optional[float] = None # seconds (relative to trigger; can be negative) + peak_accel_g: Optional[float] = None # g (geo channels only) + peak_disp_in: Optional[float] = None # in (geo channels only) + + +@dataclass +class MicStats: + """MicL-specific stats.""" + weighting: Optional[str] = None # e.g. "Linear Weighting" + pspl_dbl: Optional[float] = None # dB(L) + zc_freq_hz: Optional[float] = None + time_of_peak_s: Optional[float] = None + + +@dataclass +class SensorCheck: + """Per-channel sensor self-check result. + + Geo channels report a frequency + ratio; MicL reports a frequency + + amplitude (mV). All channels also have a Pass/Fail string. + """ + test_freq_hz: Optional[float] = None + test_ratio: Optional[float] = None # geo channels only + test_amplitude_mv: Optional[float] = None # MicL only + test_results: Optional[str] = None # "Passed" / "Failed" + + +@dataclass +class MonitorLogEntry: + """One row of the trailing Monitor Log(s) block.""" + start_time: Optional[datetime.datetime] = None + stop_time: Optional[datetime.datetime] = None + description: Optional[str] = None + + +@dataclass +class BwAsciiReport: + """Structured representation of one BW per-event ASCII export.""" + # ── Identity ───────────────────────────────────────────────────────────── + event_type: Optional[str] = None # e.g. "Full Waveform" + serial: Optional[str] = None # e.g. "BE11529" + version: Optional[str] = None # firmware version line + file_name: Optional[str] = None # e.g. "M529LK44.AB0" + event_datetime: Optional[datetime.datetime] = None # parsed from Event Time + Event Date + + # ── Trigger / recording config ────────────────────────────────────────── + trigger_channel: Optional[str] = None # e.g. "Vert" or "From Unit" + geo_trigger_level_ips: Optional[float] = None + pretrig_s: Optional[float] = None # negative seconds + record_time_s: Optional[float] = None + record_stop_mode: Optional[str] = None + sample_rate_sps: Optional[int] = None + battery_volts: Optional[float] = None + calibration_date: Optional[datetime.date] = None + calibration_by: Optional[str] = None # e.g. "Instantel" + units: Optional[str] = None # e.g. "in/s and dB(L)" + + # ── Operator-supplied metadata ────────────────────────────────────────── + # Parsed by POSITION from the 4-line "User Notes" block BW writes + # between the `Units :` and `Geo Range :` lines. Position-based so + # the values populate correctly even when an operator renames the + # labels in Blastware's Compliance Setup → Notes tab (the 4 labels + # are user-editable, e.g. "Seis Loc:" → "Building:" → "Site Address:"). + # The original labels BW wrote are preserved in `user_note_labels` + # so terra-view can render them as the operator named them. + project: Optional[str] = None # position 1 (BW default label "Project:") + client: Optional[str] = None # position 2 (BW default label "Client:") + operator: Optional[str] = None # position 3 (BW default label "User Name:") + sensor_location: Optional[str] = None # position 4 (BW default label "Seis Loc:") + + # Maps canonical slot name → the literal label BW wrote in the ASCII + # export. Empty if the User Notes block wasn't present. Example + # when the operator renamed slot 4 to "Building:": + # {"project": "Project:", "client": "Client:", + # "operator": "User Name:", "sensor_location": "Building:"} + user_note_labels: Dict[str, str] = field(default_factory=dict) + + # ── Geo channel scaling ───────────────────────────────────────────────── + geo_range_ips: Optional[float] = None # 10.000 / 1.250 + + # ── Per-channel derived stats (geo + mic) ─────────────────────────────── + channels: Dict[str, ChannelStats] = field(default_factory=dict) + mic: MicStats = field(default_factory=MicStats) + + # ── Vector sum ────────────────────────────────────────────────────────── + peak_vector_sum_ips: Optional[float] = None + peak_vector_sum_time_s: Optional[float] = None + + # ── Sensor self-check (per channel) ───────────────────────────────────── + sensor_check: Dict[str, SensorCheck] = field(default_factory=dict) + + # ── Monitor log + tooling version ─────────────────────────────────────── + monitor_log: List[MonitorLogEntry] = field(default_factory=list) + pc_sw_version: Optional[str] = None + + # ── Sample table (optional; only parsed if requested) ─────────────────── + # Each entry: (Tran, Vert, Long, MicL) in the report's units (geo + # channels in in/s, MicL in dB(L)). None when parse_samples=False. + samples: Optional[List[Tuple[float, float, float, float]]] = None + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + + +_KEY_NORMALISE_RE = re.compile(r"\s+") +_NUMERIC_RE = re.compile(r"^-?\d+(?:\.\d+)?") + + +def _normalise_key(k: str) -> str: + """Collapse whitespace runs (incl. tabs) and strip — handles BW's + "MicL Time of Peak" double-space and leading-colon quirks.""" + return _KEY_NORMALISE_RE.sub(" ", k).strip() + + +def _strip_quotes(line: str) -> str: + line = line.rstrip("\r\n") + if len(line) >= 2 and line.startswith('"') and line.endswith('"'): + return line[1:-1] + return line + + +def _parse_number(value: str) -> Optional[float]: + """Pull the leading numeric portion out of a value like "0.500 in/s".""" + m = _NUMERIC_RE.match(value.strip()) + if not m: + return None + try: + return float(m.group(0)) + except ValueError: + return None + + +def _parse_int(value: str) -> Optional[int]: + n = _parse_number(value) + return None if n is None else int(round(n)) + + +# Months exactly as BW writes them. +_MONTHS = { + "January": 1, "February": 2, "March": 3, "April": 4, + "May": 5, "June": 6, "July": 7, "August": 8, + "September": 9, "October": 10, "November": 11, "December": 12, + # Short forms used in monitor-log rows ("Apr 23 /26"). + "Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "Jun": 6, "Jul": 7, + "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12, +} + + +def _parse_event_date(s: str) -> Optional[datetime.date]: + """Parse "April 23, 2026" or "May 8, 2026" → date.""" + s = s.strip() + parts = s.replace(",", " ").split() + if len(parts) < 3: + return None + month_name, day_str, year_str = parts[0], parts[1], parts[2] + month = _MONTHS.get(month_name) + if month is None: + return None + try: + return datetime.date(int(year_str), month, int(day_str)) + except ValueError: + return None + + +def _parse_event_time(s: str) -> Optional[datetime.time]: + """Parse "15:56:35" → time.""" + s = s.strip() + try: + h, m, sec = s.split(":") + return datetime.time(int(h), int(m), int(sec)) + except (ValueError, IndexError): + return None + + +def _parse_calibration(value: str) -> Tuple[Optional[datetime.date], Optional[str]]: + """Parse "April 29, 2025 by Instantel" → (date, "Instantel").""" + parts = value.split(" by ", 1) + date = _parse_event_date(parts[0]) + by = parts[1].strip() if len(parts) > 1 else None + return date, by + + +def _parse_monitor_row(line: str) -> Optional[MonitorLogEntry]: + """Parse a tab-separated monitor log row. + + Format: `\t\t` where each timestamp is BW's + short form "Mon DD /YY HH:MM:SS" (e.g. "Apr 23 /26 15:46:16"). + Year is encoded as a 2-digit suffix; we expand "/26" → 2026. + """ + parts = line.split("\t") + if len(parts) < 2: + return None + start = _parse_monitor_ts(parts[0]) + stop = _parse_monitor_ts(parts[1]) + desc = parts[2].strip() if len(parts) > 2 else None + if start is None and stop is None and not desc: + return None + return MonitorLogEntry(start_time=start, stop_time=stop, description=desc) + + +def _parse_monitor_ts(s: str) -> Optional[datetime.datetime]: + """Parse "Apr 23 /26 15:46:16" → datetime.""" + s = s.strip() + parts = s.split() + if len(parts) < 4: + return None + month = _MONTHS.get(parts[0]) + if month is None: + return None + try: + day = int(parts[1]) + # parts[2] looks like "/26" → century-flip to 2026 + yy = int(parts[2].lstrip("/")) + year = 2000 + yy if yy < 80 else 1900 + yy + h, m, sec = (int(x) for x in parts[3].split(":")) + return datetime.datetime(year, month, day, h, m, sec) + except (ValueError, IndexError): + return None + + +# ── User-notes positional slot map ────────────────────────────────────────── +# +# Blastware's Compliance Setup → Notes tab shows four operator-supplied +# fields whose LABELS the operator can rename (see screenshot in +# project archive). Defaults are "Project:" / "Client:" / +# "User Name:" / "Seis Loc:", but an operator using a different +# convention can rename them to anything ("Building:", "Site:", +# "Address:", etc.). The ASCII export reflects whatever the operator +# typed, so label-based matching is fragile. +# +# What IS reliable: BW always writes the 4 user-notes lines in the +# same order, contiguously between the `Units :` line and the +# `Geo Range :` line. We parse them by POSITION and preserve the +# operator's labels in `report.user_note_labels` so terra-view can +# render them as the operator intended. + +_USER_NOTE_SLOTS = ("project", "client", "operator", "sensor_location") + + +# ───────────────────────────────────────────────────────────────────────────── +# Top-level parser +# ───────────────────────────────────────────────────────────────────────────── + + +def parse_report(text: Union[str, bytes], *, parse_samples: bool = False) -> BwAsciiReport: + """Parse a BW per-event ASCII export into a structured BwAsciiReport. + + Set ``parse_samples=True`` to also populate ``report.samples`` with + the trailing sample table. Default False because the table is + huge and most callers only want metadata for indexing. + """ + if isinstance(text, bytes): + text = text.decode("ascii", errors="replace") + + report = BwAsciiReport() + # Pre-create channel stat slots so callers can rely on them existing. + for ch in ("Tran", "Vert", "Long", "MicL"): + report.channels.setdefault(ch, ChannelStats()) + report.sensor_check.setdefault(ch, SensorCheck()) + + lines = text.splitlines() + i = 0 + n = len(lines) + + in_monitor_log_section = False + event_time_str: Optional[str] = None + event_date: Optional[datetime.date] = None + + # User-notes block detection. We enter the block after parsing + # the "Units :" line and exit on the "Geo Range :" line. Inside, + # the first 4 unmatched `