30 Commits

Author SHA1 Message Date
serversdown 8f568b809b Merge pull request 'v0.19.0 - minimate compatability + family separation' (#22) from dev into main
## v0.19.0 — 2026-05-20

The "device-family separation" release.  Tightens the boundary between Series III (MiniMate Plus / Blastware) and Series IV (Micromate / Thor) so the UI and storage layer dispatch deterministically by family instead of sniffing filename extensions or magnitude heuristics.

### Added — Phase 1: `device_family` column on `events`

- **`events.device_family TEXT`** — new column carrying `"series3"` or `"series4"`.  Populated by every import path (`/db/import/blastware_file`, `/db/import/idf_file`, ACH server, BW CLI, sidecar backfill script).  Returned through `/db/events` since `query_events` uses `SELECT *`.
- **Self-applying migration** — on startup, `ALTER TABLE ... ADD COLUMN` lands the new column; a follow-on `UPDATE` backfills existing rows from the binary filename extension (`.IDFH`/`.IDFW` → `series4`, everything else → `series3`).  No manual SQL needed.
- **UPSERT preserves family** — re-imports without an explicit family don't blank existing rows (`COALESCE(?, device_family)`).
- **UI dispatches on the column** — `sfm_webapp.html` events-table mic formatter now branches on `ev.device_family === 'series4'` (Thor stores native dB(L); BW stores psi).  Modal uses `source.kind === 'idf-import'` from the sidecar (sidecars don't carry the DB column).  Source-files section labels changed from "BW filename / BW filesize / BW sha256" to format-neutral "Event file / File size / File sha256".

### Added — Phase 2: `micromate/` package alongside `minimateplus/`

- **`micromate/`** — new sibling package for the Thor / Micromate Series IV device.  Currently scoped to offline-file ingest; live-device support (TCP transport, framing, protocol, client) will land here when reverse-engineering happens.
  - `micromate/idf_ascii_report.py` — moved from `sfm/idf_ascii_report.py`.  No behaviour change.
  - `micromate/models.py` — typed `IdfReport`, `IdfEvent`, `IdfPeaks`, `IdfProjectInfo`, `IdfSensorCheck`.  Stores mic in native `mic_pspl_dbl` (dB(L)) instead of the pseudo-psi shoehorn that the BW-shaped model uses.  `IdfEvent.from_report()` constructs from a parsed dict + filename; `IdfEvent.to_minimateplus_event(waveform_key)` bridges to the existing sidecar / DB-insert machinery.
  - `micromate/idf_file.py` — placeholder for the binary codec (`.IDFH` / `.IDFW`).  Stubbed `read_idf_file()` raises `NotImplementedError`; documents the planned reverse-engineering path.
- **`WaveformStore.save_imported_idf`** refactored to use the native `IdfEvent` and bridge at the SQL-insert boundary.  Cleaner separation of "parse a Thor event" (in `micromate/`) from "store it on disk + write a sidecar" (in `sfm/waveform_store.py`).
- **Tests** — `tests/test_idf_ascii_report.py` imports updated to `micromate.idf_ascii_report`.  All 1,014 example-data sidecars round-trip through `IdfEvent.from_report()` without errors.

### Companion releases

- **thor-watcher** unaffected — it talks to the relay over HTTP only.  No version bump needed.
- **terra-view** unaffected today; can use `device_family` in its event-detail rendering when convenient.

---

## v0.18.0 — 2026-05-19

The "Thor / Series IV ingest adapter" release.  Seismo-relay can now accept event files from Instantel Micromate Series IV (Thor) units alongside the existing MiniMate Plus (Series III) Blastware pipeline.

### Added — Thor (Series IV) IDF ingest

- **`POST /db/import/idf_file`** (`sfm/server.py`) — multipart upload endpoint for `.IDFH` (histogram) and `.IDFW` (waveform) event files plus their `.IDFH.txt` / `.IDFW.txt` ASCII sidecars.  Mirrors the shape of `/db/import/blastware_file`: pairing by filename, optional `serial` query hint, per-file outcome reporting.
- **`sfm/idf_ascii_report.py`** — parser for Thor's TXT sidecars (verified against 1,014 real-world samples).  Extracts device-authoritative PPV, ZC Freq, Peak Vector Sum, Mic PSPL, calibration date, firmware version, sensor self-check results, and project/client/operator strings.
- **`WaveformStore.save_imported_idf()`** (`sfm/waveform_store.py`) — stores Thor binaries verbatim in `<root>/<serial>/<filename>`, writes a `.sfm.json` sidecar with `source.kind = "idf-import"` and the full parsed report under `extensions.idf_report`.  Reuses the existing `events` table — Thor events dedupe on (serial, timestamp) and surface in `/db/events` alongside BW events.
- **`tests/test_idf_ascii_report.py`** — parser tests against the `thor-watcher/example-data/` corpus.

### Changed

- `event_to_sidecar_dict()` (`minimateplus/event_file_io.py`) allow-list for `source_kind` now includes `"idf-import"` so the existing sidecar machinery can carry Thor imports.
- Bumped `pyproject.toml` version to `0.18.0`.

### Companion release

This release ships alongside **thor-watcher v0.3.0**, which adds the SFM forwarder that targets the new `/db/import/idf_file` endpoint.  Operators flip the switch in thor-watcher's new "SFM Forward" Settings tab; events POST to seismo-relay just like the series3-watcher BW forwarder does today.
2026-05-20 11:22:54 -04:00
serversdown ecc935482b seismo-relay v0.19.0 — device-family separation + micromate/ package
Tighten the Series III / Series IV boundary so UI and storage dispatch
on a clean signal instead of sniffing filenames or applying magnitude
heuristics.

Phase 1 — events.device_family column ("series3" | "series4"):
  self-applying migration with filename-based backfill of existing rows
  (1,132 backfilled on prod 2026-05-20); plumbed through every import
  path (BW endpoint, IDF endpoint, ACH server, BW CLI, sidecar
  backfill); UPSERT preserves via COALESCE; UI dispatches on it.

Phase 2 — extract micromate/ package alongside minimateplus/:
  native IdfEvent / IdfReport / IdfPeaks / IdfProjectInfo /
  IdfSensorCheck (mic in dB(L), not pseudo-psi); moved
  idf_ascii_report.py from sfm/ to micromate/; refactored
  save_imported_idf to use IdfEvent and bridge to minimateplus.Event at
  the SQL-insert boundary; idf_file.py stub for the future binary codec.

Phase 3 prep — docs/idf_protocol_reference.md captures the two
observed Thor binary header signatures (1,012 newer-firmware files vs
2 old files whose layout is byte-for-byte BW-STRT-compatible), file-size
hints suggesting int8 sample encoding, open questions in dependency
order, and a concrete first-session plan for cracking the codec.

Also rolled in the v0.18.1 hotfixes that motivated this work:
  - idf_ascii_report parser now handles "<0.005 in/s" (below-threshold)
    and "N/A" markers without leaving raw strings in numeric DB columns.
  - sfm_webapp.html: defensive _ppvFmt / mic formatter so future
    data-shape drift can't kill the whole events table render.

All 1,014 example-data sidecars round-trip through the new package.
See CHANGELOG.md for full notes.
2026-05-20 15:19:49 +00:00
serversdown e95ac692ee feat: add device family to separate s3 and s4 events. 2026-05-20 06:15:50 +00:00
serversdown 3265ad6fa3 fix: apply psi dbL conversion rule 2026-05-20 05:43:52 +00:00
serversdown 350f81f8b5 fix: add thor specific ascii parser. 2026-05-20 05:22:28 +00:00
serversdown cd20be2eff feat: add thor/micromate compatibility v0.18.0 2026-05-19 04:32:43 +00:00
serversdown 512d82c720 merge: update to 0.17.0' (#21) from ach-report-ingestion into main
Reviewed-on: #21

## v0.17.0 — 2026-05-17

The "field rescue + DB management" release.  Hardened against units that are stuck in a runaway call-home loop, and added an operator-facing path for purging bogus events that those same units dump into the DB before recovery.  All work in this release was driven by the BE9558H incident (full incident log + recovery procedure at `docs/runbooks/wedged_unit_recovery.md`).

### Added — wedged-unit recovery toolkit

A toolkit for breaking the call-home loop on a misbehaving unit whose firmware is too busy to keep up with normal request/response handshakes.  Tested in production against BE9558H (16 May 2026) — a unit with a stuck-triggered Long-axis geophone that had been call-homing the office BW ACH server every 30 seconds for hours.  Endpoints layered from "single attempt" to "siege mode" to suit different contention levels:

- **`GET /device/events/storage_range`** — SUB 0x06 probe.  POLL + one read; ~2s.  Returns first/last event keys and an `is_empty` flag.  Use to triage whether a unit has stored events without invoking the slow `count_events()` 1E/1F chain (which choked on BE9558H's corrupted event chain).
- **`GET /device/events/index`** — SUB 0x08 probe.  POLL + one read; ~2s.  Returns the lifetime event counter (does NOT decrement on erase — use `storage_range` for "right now" state).
- **`POST /device/events/erase`** — full erase sequence `0xA3 → 0x1C → 0x06 → 0xA2` (confirmed 2026-04-11, see the protocol reference).  Resets event keys to `0x01110000`.  Caller's responsibility to disable ACH first if the underlying trigger condition will re-fill the buffer.
- **`POST /device/rescue`** — one TCP session, short connect+recv timeouts: POLL → disable ACH (compliance config write) → erase events → close.  Designed for race-loop usage when the device is busy in another session.  503 on connect-refused, 502 on protocol failure, 200 on full sequence success.
- **`POST /device/stop_monitoring_blind`** — fire-and-forget Stop Monitoring (SUB 0x97), TCP-only.  Dumps `SESSION_RESET + POLL_PROBE + SESSION_RESET + POLL_DATA + 0x97 × repeat` and closes without reading any S3 response.  The full POLL preamble is required — write commands without it are silently ignored by the device's protocol parser (false-positive surface area that bit the first version of this endpoint).  Use when the device's firmware can't keep up with full request/response but might process inbound bytes at its own pace.
- **`POST /device/stop_monitoring_spam`** — server-side hammer loop, duration-bounded.  Open TCP → write the same blind payload → close → repeat as fast as possible until `duration_s` elapses.  Configurable `connect_timeout` (default 500ms) and `repeat` (frames per session).  Reports `sent_ok`, `connect_failed`, `write_failed`, `rate_attempts_per_s`.  Clamped to 5min duration.
- **`POST /device/stop_monitoring_slow_drip`** — opposite of spam.  Open ONE TCP session, drip the wake handshake + stop frames at `interval_s` (default 3s) for `duration_s` (default 120s, max 10min).  Each drip is ~23 bytes — well under any UART FIFO size.  Opportunistically drains any inbound bytes the device sends back; `bytes_received > 0` in the response strongly suggests the device has started talking and the session is healthy.  **This is the endpoint that saved BE9558H.** Spam mode had been overrunning the device's UART FIFO; slow drip stayed under it.
- **Six rescue scripts** under `scripts/` — thin bash wrappers around the endpoints, default `SFM_BASE_URL=http://localhost:8200` (direct, not via Terra-View proxy whose 60s timeout would cut off the longer endpoints):
    - `rescue_device.sh` — race-loop wrapper for `/device/rescue`
    - `blind_stop.sh` — race-loop wrapper for `/device/stop_monitoring_blind`
    - `spam_stop.sh` — single-call burst hammer
    - `slow_drip.sh` — single-call held-session drip
    - `watch_unit.sh` — passive periodic reachability check (every N min, logs to file), useful for unattended overnight monitoring of a wedged unit
- **`docs/runbooks/wedged_unit_recovery.md`** — symptoms, quick-reference recovery procedure, the modem-layer mechanism (Sierra Wireless serial-port mode-flipping is the real failure mode — not the device firmware), and a table of "why simpler approaches don't work" so the next incident skips the dead ends.

### Added — operator event DB management

Endpoints powering Terra-View's new `/admin/events` page (v0.12.0).  Designed for purging bogus events from a unit that's been forwarding them in bulk (e.g. a stuck-triggered seismograph dumping hundreds of junk events before it's recovered).

- **`DELETE /db/events/{event_id}`** — hard-delete one event row.  Also unlinks the associated blastware binary (`.AB0*`), `.a5.pkl`, `.sfm.json` sidecar, and `.h5` clean-waveform files via the WaveformStore.  Returns the per-file removal status.  404 if the event doesn't exist.
- **`POST /db/events/delete_bulk`** — filter-based or id-list-based bulk delete with safety rails:
    - Filters (`serial`, `from_dt`, `to_dt`, `false_trigger`) combine with AND; same semantics as `GET /db/events`.  `ids` is an additional inclusion list.  Refuses to run with no filters (would wipe the whole table — raises 422).
    - `confirm` must be `true` to actually delete.  Otherwise returns a dry-run summary (`status: "dry_run"`, `matched: N`, `sample_serials: [...]`).
    - `max_rows` (default 10,000) caps how many rows can be deleted by-filter in one call.  If exceeded, returns `status: "too_many"` with a hint to narrow or raise the cap.  Bypassed when only `ids` is supplied.
- **`_cleanup_event_files(row)`** helper in `sfm/server.py` — best-effort `unlink()` of all four sidecar paths derived from the row's `blastware_filename`.  Logged at WARN if a path exists but unlink fails; the DB row deletion still proceeds.
- **`SeismoDb.delete_event(id)` and `SeismoDb.delete_events_bulk(...)`** in `sfm/database.py` — both return the deleted row dict(s) so callers can do file cleanup.  `delete_events_bulk` raises `ValueError` if no filters are supplied.

### Changed

- **Default protocol recv timeout dropped from 30s → 10s** in `_build_client()`.  The unit usually responds in well under a second over cellular; 10s leaves comfortable headroom for retransmits while failing reasonably fast when a unit is wedged.  The two endpoints that perform full 5A waveform downloads still pass `timeout=120.0` explicitly so multi-minute event transfers are unaffected.
- **`_build_client()` now accepts an optional `connect_timeout`** (TCP-only) so rescue / race-loop endpoints can fail fast on busy modems without affecting the protocol-level recv timeout.

### Fixed

- **`GET /device/monitor/status` returned HTTP 500 + uncaught traceback when the device was unresponsive**.  The retry-on-`Exception` inner block let the second `client.poll()`'s `ProtocolError` propagate out of the handler.  Now wrapped in proper try/except — returns 502 with `{"detail": "Protocol error: No S3 frame received within 10.0s ..."}` on timeout, 502 on connection errors, 500 only for genuinely unexpected exceptions.

### Migration

No schema changes.  No data migration required.

If you've been running a previous version against a wedged unit and accumulated bogus events, the new `/admin/events` page in Terra-View v0.12.0 (or direct `POST /db/events/delete_bulk` with `confirm: true`) is the cleanup tool.  Watcher state on the upstream DL2 PC does NOT need separate cleaning — the watcher's `sfm_forwarded.json` keys on file sha256 and won't re-forward the same files.

### Pairing

This release pairs with **Terra-View v0.12.0**, which adds the `/admin/events` UI that consumes the new bulk-delete endpoints, the bulk false-trigger flagging on `/unit/{id}`, and the field-deployment workflow that uses the same `series3-watcher` → SFM ingest path as before.

---

## v0.16.1 — 2026-05-14

### Fixed

- **`record_type` always "Waveform" for forwarded events.**  `read_blastware_file()` hardcoded `ev.record_type = "Waveform"` regardless of the file's actual type.  The watcher-forward pipeline (the main BW ACH ingest path) compounds this by parsing files from a tmp path with a `.bw` suffix, so even a filename-based fallback inside the parser still wouldn't see the original extension.  Now:

  1. New `derive_record_type_from_filename(filename)` helper in `minimateplus/event_file_io.py` derives the type from the LAST character of the filename's extension (V10.72+ AB0T scheme: `H`=Histogram, `W`=Waveform, `M`=Manual, `E`=Event, `C`=Combo).  Falls back to `"Waveform"` for old S338 firmware (3-char extensions ending in `0`) and any unrecognized suffix.
  2. `read_blastware_file()` now calls the helper with its `path.name` so direct callers (the `--dry-run` path in `scripts/import_bw.py`, tests, ad-hoc scripts) get the right value automatically.
  3. `WaveformStore.save_imported_bw()` overrides `ev.record_type` with the **original** filename's derived type after parsing (the tmp file inside the parser doesn't carry the original extension).  This is the path the live watcher-forwarder hits, so the DB column now reflects the actual event type going forward.

  Events ingested before this fix are stuck with `record_type="Waveform"` in the DB; a one-off backfill (`UPDATE events SET record_type = ... WHERE blastware_filename LIKE '%H'`) would fix them retroactively if desired.  Terra-view's event modal also derives client-side from the filename, so the UI already shows the correct type for old events even without the backfill.

---
2026-05-17 19:13:56 -04:00
serversdown 57287a2ade chore: update to 0.17.0 2026-05-17 23:07:12 +00:00
serversdown 1fff8179d6 Add runbook for recovering wedged units and new scripts for device management
- Created a comprehensive runbook (`wedged_unit_recovery.md`) detailing the recovery process for units stuck in a call-home loop, including symptoms, recovery steps, and explanations of the failure mode.
- Added `blind_stop.sh` script to send stop-monitoring commands in a tight loop for unresponsive devices.
- Introduced `rescue_device.sh` script to disable Auto Call Home and erase events from a busy device.
- Implemented `slow_drip.sh` script to send stop-monitoring frames at a slow rate to prevent UART overrun.
- Developed `spam_stop.sh` script to rapidly send stop-monitoring commands to a device.
- Created `watch_unit.sh` script for passive monitoring of device reachability, logging results over time.
2026-05-17 07:58:13 +00:00
serversdown ae7edac83f chore(doc): bump to 0.16.1 2026-05-15 23:35:35 +00:00
serversdown b6911009ff scripts: backfill record_type on legacy events imported with hardcoded "Waveform"
Pre-v0.16.1 (commit aac1c8e), every event ingested through
read_blastware_file got record_type="Waveform" regardless of actual
type because the field was hardcoded.  New ingests derive correctly
from the AB0T filename scheme (H/W/M/E/C).  Existing rows still hold
the wrong value.

This script walks the events table, derives the correct record_type
from each row's blastware_filename, and bulk-updates rows that differ.
Idempotent + dry-run by default.

Usage:
  python -m scripts.backfill_record_type --db bridges/captures/seismo_relay.db
  python -m scripts.backfill_record_type --db bridges/captures/seismo_relay.db --apply

Terra-view's event-detail modal already derives the record_type
client-side from the filename for display, so operators see the
correct type in the UI even before this backfill runs.  This script
brings the DB column in line with what the UI is already showing —
matters for reporting and any downstream consumer that reads the
column directly.
2026-05-15 06:38:09 +00:00
serversdown aac1c8e06d fix(import): derive record_type from filename suffix instead of hardcoding "Waveform"
The BW ACH ingest path was inserting every event with
record_type="Waveform" regardless of the actual type because
read_blastware_file() had `ev.record_type = "Waveform"` hardcoded, and
the live watcher-forward path parses files from a tmp path (suffix
".bw") that doesn't carry the original extension.

V10.72+ MiniMate Plus firmware encodes the event type as the last
character of the AB0T extension scheme (H=Histogram, W=Waveform,
M=Manual, E=Event, C=Combo).  This change:

  1. Adds derive_record_type_from_filename() public helper in
     minimateplus/event_file_io.py
  2. Uses it inside read_blastware_file() so direct callers (the
     --dry-run path of scripts/import_bw.py, tests, ad-hoc scripts)
     get correct types automatically
  3. Overrides ev.record_type in WaveformStore.save_imported_bw()
     using the ORIGINAL filename (source_path.name) — required
     because the parser sees only the tmp file

Old S338 firmware (3-char extensions ending in `0`) and any
unrecognized suffix fall back to "Waveform".

Existing DB rows ingested before this fix are stuck with
record_type="Waveform" — a one-off SQL backfill would fix them
retroactively if desired.  Terra-view's event modal also derives
client-side from the filename, so the UI already shows the correct
type for old events even without the backfill.

Version bumped to 0.16.1 in pyproject.toml, event_file_io.py
TOOL_VERSION, sfm/server.py FastAPI version, and CHANGELOG.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 21:09:21 +00:00
serversdown 87675ac2d8 Merge pull request 'docker: add .dockerignore and Dockerfile for containerization.' (#20) from dockerize into main
Reviewed-on: #20
2026-05-11 17:40:56 -04:00
serversdown 83d69b9220 chore(server): update inline version to 0.16.0 2026-05-11 21:40:18 +00:00
serversdown 3e247e2182 docker: add .dockerignore and Dockerfile for containerization. 2026-05-11 21:38:03 +00:00
serversdown d2e48c62b5 Merge pull request 'feat(import): v0.16.0 - Fully implemented series 3 BW-ACH pipeline stablized.' (#19) from ach-report-ingestion into main
Reviewed-on: #19
2026-05-11 15:55:23 -04:00
serversdown 988d26c03d docs: capture deferred work in README Roadmap
Consolidates everything that was floating in chat-only "parking
lot" status into the README's Roadmap (Future) section:

  High-impact (unblocks product features):
    - Waveform body codec reverse-engineering
    - In-app waveform viewer accuracy (depends on codec)
    - Terra-view integration
    - Vibration summary reports

  BW ASCII report parser enhancements:
    - Histogram-specific structural fields
    - Histogram interval bin-table parsing
    - ">100 Hz" value parsing

  Ingestion gaps:
    - MLG forwarding (watcher + SFM endpoint)
    - 0C-record raw bytes persistence in sidecar

  Operational:
    - series3-watcher file archive manager
    - Existing operational items (compliance encoder, modem manager,
      Call Home dial_string write, histogram mode 5A stream)

  Test coverage + lower-priority cleanups.

CLAUDE.md "What's next" section now points to the README as the
canonical deferred-work list, and keeps its own low-level technical
status log for byte-layout details that don't belong in the
roadmap.
2026-05-11 16:08:02 +00:00
serversdown 197c0630e2 chore(release): v0.16.0 — BW ACH ingestion
The "BW ACH ingestion" release.  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.

Bumps pyproject.toml + minimateplus/event_file_io.py TOOL_VERSION
to 0.16.0.  README banner + CHANGELOG entry summarise the work
that landed across commits cdfe4ad..f83993a on this branch.
2026-05-11 07:33:48 +00:00
serversdown f83993ad1d fix(import): pair _ASCII.TXT reports on the SFM server side too
The series3-watcher v1.5.0 fix taught the WATCHER to look for BW
ACH's _ASCII.TXT report alongside each binary.  But the SFM
SERVER's import endpoint only knew about the legacy <binary>.TXT
naming when building its TXT lookup table.

Effect: even though the watcher correctly shipped both files in
the multipart POST (and logged "+ <name>_ASCII.TXT attached"),
the server's reports dict was keyed on the wrong name, so
report_bytes resolved to None for every event.  Without the
report, save_imported_bw fell back to broken-codec peak values
and no project info — exactly the same symptom as before the
watcher fix landed, just for a different reason.

Fix: when stripping the ".TXT" suffix, also recognise the
"_ASCII" trailer and reconstruct the binary's filename by
converting the last "_" back to ".".  Register the report under
BOTH possible binary names so the subsequent lookup matches
whichever convention the operator's BW installation uses.

  ACH convention (Blastware ACH):
    binary T003L2G6.0E0H  + report T003L2G6_0E0H_ASCII.TXT  
  Manual export (operator clicks Save As Text in BW):
    binary M529LK44.AB0   + report M529LK44.AB0.TXT          
  Both for same event (e.g. ACH + operator manual save):
    register under both names; binary lookup wins             

Smoke-tested against the four real fixture filenames in the
project archive.  Full SFM suite still 62 pass.

For the user's situation: pull, restart, and the NEXT re-forward
pass (after deleting watcher state file again if needed) will
hit this code path, parse the report correctly, apply the
overlay onto the Event, and the upsert path will land
authoritative peak values + project info in the DB.
2026-05-11 07:25:04 +00:00
serversdown 6b2a44ff02 fix(import): overlay BW report onto Event + upsert DB row on re-import
Two compounding bugs caused forwarded events to land in the DB with
broken-codec peak values (~10 in/s saturation on every channel) and
no project info, even when the watcher correctly paired a BW ASCII
report with the binary.

Bug 1: save_imported_bw built the sidecar JSON with the report's
authoritative peak / project values via event_to_sidecar_dict(
bw_report=...), but never overlaid those onto the in-memory Event
that flows to db.insert_events().  So the DB row got peak_values
from read_blastware_file()._peaks_from_samples() — which runs the
still-undecoded waveform body codec assuming raw int16 LE and
produces ±32K-shaped noise (= ±10 in/s at Normal range) regardless
of the actual signal.  The sidecar JSON had the truth but the DB
columns (which the webapp queries for fast filter/sort) lied.

Bug 2: insert_events' IntegrityError handler only refreshed the
filename/filesize/a5_pickle/sidecar columns when a duplicate
(serial, timestamp) was seen.  Peak values, project info,
sample_rate, record_type stayed locked in at whatever the FIRST
insert wrote.  So even after Bug 1 was fixed, the historical
events in the DB (already inserted with broken-codec peaks) would
never get their values corrected, because a re-forward would just
hit IntegrityError and skip the field refresh.

Fix 1 (minimateplus/event_file_io.py + sfm/waveform_store.py):
  - New apply_report_to_event(event, report) helper folds the BW
    report's device-authoritative fields onto the Event in-place:
    per-channel PPV, peak vector sum, mic PSPL→psi, project /
    client / operator / sensor_location, sample_rate, record_time.
  - save_imported_bw() calls the helper right after parsing the
    report.  The Event that flows to insert_events() now carries
    correct values.

Fix 2 (sfm/database.py):
  - insert_events()'s IntegrityError UPDATE now refreshes every
    device-authoritative column from the new data: tran_ppv,
    vert_ppv, long_ppv, peak_vector_sum, mic_ppv, project, client,
    operator, sensor_location, sample_rate, record_type, plus
    the existing filename/filesize/a5_pickle/sidecar fields.
  - Preserves: id, waveform_key, session_id, created_at (immutable
    / FK fields), and false_trigger (operator review state).

End-to-end simulation verified:
  - Step 1: import without report → DB has ±10 in/s peaks, no project
  - Step 2: re-import WITH report → upsert path fires, DB now has
            device-authoritative 0.005 in/s peaks + sensor_location
  - Step 3: operator sets false_trigger=1, re-import again → flag
            preserved, peaks remain correct

For the user's situation: deleting the watcher state file forces a
re-forward of all events.  Each re-forward now pairs with its
_ASCII.TXT, applies the report onto the Event, and the upsert
refreshes the DB row.  No DB nuke needed.

Full SFM suite: 62 passed, 44 skipped.
2026-05-11 05:51:39 +00:00
serversdown cc57a8e618 fix(db): /db/units surfaces events-only serials too
Previous query_units() only joined on ach_sessions, which is created
exclusively by the live ACH server.  The BW-importer path
(/db/import/blastware_file → WaveformStore.save_imported_bw →
SeismoDb.insert_events) populates `events` but never creates an
ach_sessions row.  Consequence: every serial whose events flowed in
through the series3-watcher forwarder was invisible to
/db/units (and therefore to the SFM webapp's fleet overview / units
list), even though the events were correctly populated in the
events table with proper serial attribution.

Rewrite query_units() to aggregate from BOTH tables and union the
serials:
  - total_events / last_event_at  come from `events` (every ingest path)
  - last_session_at / total_monitor_entries / total_sessions
                                  come from `ach_sessions` (ACH-only),
                                  0 when no sessions exist for the serial
  - last_seen = max(last_event_at, last_session_at)

Verified on the user's actual prod DB after the
repair_unknown_serials run: /db/units now returns 24 serials instead
of 2.  All 3,257 watcher-forwarded events become visible in the
fleet overview without any further DB surgery.
2026-05-11 05:15:09 +00:00
serversdown 082e5946bc fix(import): resolve real serial from BW filename instead of bucketing to UNKNOWN
The /db/import/blastware_file endpoint was bucketing every
forwarded event into serial='UNKNOWN' in the DB.  WaveformStore
correctly decoded the serial from the BW filename and saved
files to <store>/<serial>/<filename> (e.g.
.../BE17353/S353L5KC.DR0H.h5), but the endpoint code called
db.insert_events(serial=_serial_from_event(ev)) — and
_serial_from_event was a stub that always returned None,
falling back to "UNKNOWN".

Effect on the user's prod server: 3,039 events forwarded across
24 distinct units, ALL inserted under serial='UNKNOWN'.  The
on-disk waveform store + sidecars + HDF5s were fine, but the
SFM webapp's /db/units only showed the two original manually-
uploaded serials because every forwarded row had its serial
column zeroed to UNKNOWN.

Fix:
  - WaveformStore.save_imported_bw() now surfaces the decoded
    serial on the returned `rec` dict (rec["serial"]).
  - The import endpoint uses rec["serial"] as the authoritative
    fallback when the operator hasn't supplied a serial_hint query
    parameter.  Order of precedence:
      query string `serial` → rec["serial"] → _serial_from_event(ev) → "UNKNOWN"
  - Response payload now includes `serial` per file so the watcher
    log lines (or any future caller) can see which unit each event
    was attributed to.

Recovery for existing DB rows:
  scripts/repair_unknown_serials.py walks the events table looking
  for rows with serial='UNKNOWN' and re-attributes each one to the
  serial decoded from blastware_filename.  Updates the row in place
  unless the target (serial, timestamp) already has a row, in which
  case the UNKNOWN duplicate is deleted.  Idempotent.  Default
  dry-run; pass --apply to commit.

  Verified on the user's actual DB (dry-run):
    UNKNOWN rows scanned:       3039
    Updated to real serial:     2602
    Deleted (duplicate of an
     already-correct row):      437
    Unresolved (bad filename):  0

After running the repair, /db/units will show all 24 units
correctly populated.
2026-05-11 02:25:08 +00:00
serversdown a032fa5451 refactor(bw-report): parse user notes by POSITION, not by label
The four operator-supplied note fields in BW's Compliance Setup →
Notes tab (Project / Client / User Name / Seis Loc) have
USER-EDITABLE LABELS — an operator can rename them in BW's UI to
"Building:", "Site Address:", "Inspector:", or anything else, and
the ASCII export writes those literal labels verbatim.  The
previous label-normalisation map approach (just added in commit
6a7e8c6) was fragile: it could only match label spellings we'd
enumerated in advance.  An operator using "Site:" instead of
"Seis Loc:" would have their sensor location silently dropped.

What IS reliable: BW always writes the 4 user-notes lines
contiguously, in the same order, between the "Units :" line and
the "Geo Range :" line of the export.  So parse them by POSITION:

  position 1 → project
  position 2 → client
  position 3 → operator
  position 4 → sensor_location

The original labels BW wrote are preserved in a new
`BwAsciiReport.user_note_labels` dict (canonical slot → literal
label string) so terra-view can render them as the operator named
them.

Removes the `_OPERATOR_LABEL_MAP` / `_normalise_label_for_lookup`
helpers and the elif-by-normalised-label branch in `parse_report`.
Replaces with a small state machine that flips on the "Units" line
and flips off on the "Geo Range" line.

Tests:
  - Default-label fixtures (waveform + histogram) still populate
    correctly, with operator's labels captured.
  - Synthetic custom-labelled exports ("Building:" / "Site Address:" /
    etc.) populate the right slots by position.
  - Histogram-specific "Seis. Location:" works.
  - Lines outside the Units→Geo Range range are ignored even if
    they look like user notes (defensive against malformed exports).
  - Partial blocks (fewer than 4 lines) leave later slots None.
  - Extra lines beyond 4 are dropped (5th slot doesn't exist).

26 tests in test_bw_ascii_report.py (was 33; net drop reflects
parametrised label tests collapsed into 6 focused position tests).
Full SFM suite: 62 passed, 44 skipped.

Pairs with series3-watcher v1.5.0 which fixes the filename pairing
so the report reaches this parser in the first place.
2026-05-10 22:28:31 +00:00
serversdown 6a7e8c6e86 feat(bw-report): normalise operator-field label variants
Blastware writes the operator-supplied fields with different label
spellings across firmware versions and recording modes — most
notably "Seis. Location" on histogram exports vs "Seis Loc:" on
waveform exports.  Previous parser only matched the latter, so
every histogram event silently lost its sensor_location field.

Replace the four hardcoded `key.rstrip(":") == "X"` branches with
a single `_OPERATOR_LABEL_MAP` dispatch table keyed by normalised
label (lowercase, trailing colon/period stripped, internal
whitespace collapsed).  Adds these variants on day 1:

  project:         "Project:" / "Project"
  client:          "Client:"  / "Client"
  operator:        "User Name:" / "User Name"
  sensor_location: "Seis Loc:" / "Seis. Location" / "Seis Location"
                 / "Sensor Location" / "Seis Loc"

To absorb future BW label drift, add a one-line dict entry — no
new elif branch.

14 new tests cover:
  - Each label variant routes to the correct field (parametrised)
  - Case-insensitive matching ("seis loc" / "SEIS LOC" / "SeIs LoC")
  - Whitespace-collapse ("Seis  Loc" with double-space)
  - End-to-end parse of a real histogram fixture from
    example-events/histogram/ — sensor_location ('Loc #1 - 2652 Hepner...')
    populates correctly even though the file uses "Seis. Location"

Total bw_ascii_report tests: 19 → 33.  Full SFM suite still green
(69 passed, 44 skipped — pre-existing skips for h5py-dep tests).

Pairs with series3-watcher v1.5.4 (which fixes the filename pairing
so histograms actually reach this parser in the first place).
2026-05-10 20:13:44 +00:00
serversdown cdfe4ad3c8 feat(import): parse paired BW ASCII reports on /db/import/blastware_file
Blastware's ACH writes a per-event ASCII report (.TXT) alongside each
event binary, containing the rich derived per-channel fields BW
computes (PPV, ZC Freq, Time of Peak, Peak Acceleration, Peak
Displacement, Peak Vector Sum + time, sensor self-check Pass/Fail,
monitor-log timestamps).  None of this lives in the BW binary itself.

When the watcher daemon forwards both files to /db/import/blastware_file
in one multipart POST, we now:

  - Pair binaries with their .TXT partners by filename match
  - Parse the report into a structured BwAsciiReport
  - Land the rich fields in a new top-level `bw_report` block of the
    sidecar JSON
  - Overlay the report's peaks/project_info/timestamp/sample_rate/
    record_time/total_samples/pretrig_samples onto the canonical
    sidecar fields (the report values are device-authoritative; the
    BW-binary STRT-derived values had bugs like reading the 0x46
    record-type marker as rectime)

This unblocks the monthly-summary review workflow — events become
sortable/filterable by peak, location, project, etc. — without
depending on the still-undecoded waveform body codec.
2026-05-08 23:56:43 +00:00
serversdown 510cec8395 add example events for decode reverse engineering. 2026-05-08 15:44:54 -04:00
serversdown 7e13c2020f Merge pull request 'doc(fix): retracts raw int16 LE sample set assumptions.' (#18) from sfm-waveform-store into main
Reviewed-on: #18
2026-05-08 15:27:26 -04:00
serversdown 0f7630c10d Merge pull request 'doc: update readme to 0.15.0' (#17) from sfm-waveform-store into main
Reviewed-on: #17
2026-05-08 15:15:36 -04:00
serversdown e1a73b2c44 Merge pull request 'feat: add waveform store handling' (#16) from sfm-waveform-store into main
Reviewed-on: #16
2026-05-08 15:03:32 -04:00
serversdown 429c6ac87a feat(protocol): implement v0.14.0 SUB 5A protocol rewrite with enhanced chunk handling and new helpers
test: add regression tests for v0.14.x SUB 5A protocol fixes
refactor(logging): change warning logs to debug for less verbosity in write_blastware_file
2026-05-06 14:18:31 -04:00
39 changed files with 13748 additions and 129 deletions
+28
View File
@@ -0,0 +1,28 @@
.git
.gitignore
.venv
venv
env
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.mypy_cache
.ruff_cache
*.db
*.db-wal
*.db-shm
*.sqlite
*.sqlite3
sfm/data
bridges/captures
example-events
captures
logs
.DS_Store
Thumbs.db
+146
View File
@@ -4,6 +4,152 @@ All notable changes to seismo-relay are documented here.
---
## v0.19.0 — 2026-05-20
The "device-family separation" release. Tightens the boundary between Series III (MiniMate Plus / Blastware) and Series IV (Micromate / Thor) so the UI and storage layer dispatch deterministically by family instead of sniffing filename extensions or magnitude heuristics.
### Added — Phase 1: `device_family` column on `events`
- **`events.device_family TEXT`** — new column carrying `"series3"` or `"series4"`. Populated by every import path (`/db/import/blastware_file`, `/db/import/idf_file`, ACH server, BW CLI, sidecar backfill script). Returned through `/db/events` since `query_events` uses `SELECT *`.
- **Self-applying migration** — on startup, `ALTER TABLE ... ADD COLUMN` lands the new column; a follow-on `UPDATE` backfills existing rows from the binary filename extension (`.IDFH`/`.IDFW``series4`, everything else → `series3`). No manual SQL needed.
- **UPSERT preserves family** — re-imports without an explicit family don't blank existing rows (`COALESCE(?, device_family)`).
- **UI dispatches on the column** — `sfm_webapp.html` events-table mic formatter now branches on `ev.device_family === 'series4'` (Thor stores native dB(L); BW stores psi). Modal uses `source.kind === 'idf-import'` from the sidecar (sidecars don't carry the DB column). Source-files section labels changed from "BW filename / BW filesize / BW sha256" to format-neutral "Event file / File size / File sha256".
### Added — Phase 2: `micromate/` package alongside `minimateplus/`
- **`micromate/`** — new sibling package for the Thor / Micromate Series IV device. Currently scoped to offline-file ingest; live-device support (TCP transport, framing, protocol, client) will land here when reverse-engineering happens.
- `micromate/idf_ascii_report.py` — moved from `sfm/idf_ascii_report.py`. No behaviour change.
- `micromate/models.py` — typed `IdfReport`, `IdfEvent`, `IdfPeaks`, `IdfProjectInfo`, `IdfSensorCheck`. Stores mic in native `mic_pspl_dbl` (dB(L)) instead of the pseudo-psi shoehorn that the BW-shaped model uses. `IdfEvent.from_report()` constructs from a parsed dict + filename; `IdfEvent.to_minimateplus_event(waveform_key)` bridges to the existing sidecar / DB-insert machinery.
- `micromate/idf_file.py` — placeholder for the binary codec (`.IDFH` / `.IDFW`). Stubbed `read_idf_file()` raises `NotImplementedError`; documents the planned reverse-engineering path.
- **`WaveformStore.save_imported_idf`** refactored to use the native `IdfEvent` and bridge at the SQL-insert boundary. Cleaner separation of "parse a Thor event" (in `micromate/`) from "store it on disk + write a sidecar" (in `sfm/waveform_store.py`).
- **Tests** — `tests/test_idf_ascii_report.py` imports updated to `micromate.idf_ascii_report`. All 1,014 example-data sidecars round-trip through `IdfEvent.from_report()` without errors.
### Companion releases
- **thor-watcher** unaffected — it talks to the relay over HTTP only. No version bump needed.
- **terra-view** unaffected today; can use `device_family` in its event-detail rendering when convenient.
---
## v0.18.0 — 2026-05-19
The "Thor / Series IV ingest adapter" release. Seismo-relay can now accept event files from Instantel Micromate Series IV (Thor) units alongside the existing MiniMate Plus (Series III) Blastware pipeline.
### Added — Thor (Series IV) IDF ingest
- **`POST /db/import/idf_file`** (`sfm/server.py`) — multipart upload endpoint for `.IDFH` (histogram) and `.IDFW` (waveform) event files plus their `.IDFH.txt` / `.IDFW.txt` ASCII sidecars. Mirrors the shape of `/db/import/blastware_file`: pairing by filename, optional `serial` query hint, per-file outcome reporting.
- **`sfm/idf_ascii_report.py`** — parser for Thor's TXT sidecars (verified against 1,014 real-world samples). Extracts device-authoritative PPV, ZC Freq, Peak Vector Sum, Mic PSPL, calibration date, firmware version, sensor self-check results, and project/client/operator strings.
- **`WaveformStore.save_imported_idf()`** (`sfm/waveform_store.py`) — stores Thor binaries verbatim in `<root>/<serial>/<filename>`, writes a `.sfm.json` sidecar with `source.kind = "idf-import"` and the full parsed report under `extensions.idf_report`. Reuses the existing `events` table — Thor events dedupe on (serial, timestamp) and surface in `/db/events` alongside BW events.
- **`tests/test_idf_ascii_report.py`** — parser tests against the `thor-watcher/example-data/` corpus.
### Changed
- `event_to_sidecar_dict()` (`minimateplus/event_file_io.py`) allow-list for `source_kind` now includes `"idf-import"` so the existing sidecar machinery can carry Thor imports.
- Bumped `pyproject.toml` version to `0.18.0`.
### Companion release
This release ships alongside **thor-watcher v0.3.0**, which adds the SFM forwarder that targets the new `/db/import/idf_file` endpoint. Operators flip the switch in thor-watcher's new "SFM Forward" Settings tab; events POST to seismo-relay just like the series3-watcher BW forwarder does today.
---
## v0.17.0 — 2026-05-17
The "field rescue + DB management" release. Hardened against units that are stuck in a runaway call-home loop, and added an operator-facing path for purging bogus events that those same units dump into the DB before recovery. All work in this release was driven by the BE9558H incident (full incident log + recovery procedure at `docs/runbooks/wedged_unit_recovery.md`).
### Added — wedged-unit recovery toolkit
A toolkit for breaking the call-home loop on a misbehaving unit whose firmware is too busy to keep up with normal request/response handshakes. Tested in production against BE9558H (16 May 2026) — a unit with a stuck-triggered Long-axis geophone that had been call-homing the office BW ACH server every 30 seconds for hours. Endpoints layered from "single attempt" to "siege mode" to suit different contention levels:
- **`GET /device/events/storage_range`** — SUB 0x06 probe. POLL + one read; ~2s. Returns first/last event keys and an `is_empty` flag. Use to triage whether a unit has stored events without invoking the slow `count_events()` 1E/1F chain (which choked on BE9558H's corrupted event chain).
- **`GET /device/events/index`** — SUB 0x08 probe. POLL + one read; ~2s. Returns the lifetime event counter (does NOT decrement on erase — use `storage_range` for "right now" state).
- **`POST /device/events/erase`** — full erase sequence `0xA3 → 0x1C → 0x06 → 0xA2` (confirmed 2026-04-11, see the protocol reference). Resets event keys to `0x01110000`. Caller's responsibility to disable ACH first if the underlying trigger condition will re-fill the buffer.
- **`POST /device/rescue`** — one TCP session, short connect+recv timeouts: POLL → disable ACH (compliance config write) → erase events → close. Designed for race-loop usage when the device is busy in another session. 503 on connect-refused, 502 on protocol failure, 200 on full sequence success.
- **`POST /device/stop_monitoring_blind`** — fire-and-forget Stop Monitoring (SUB 0x97), TCP-only. Dumps `SESSION_RESET + POLL_PROBE + SESSION_RESET + POLL_DATA + 0x97 × repeat` and closes without reading any S3 response. The full POLL preamble is required — write commands without it are silently ignored by the device's protocol parser (false-positive surface area that bit the first version of this endpoint). Use when the device's firmware can't keep up with full request/response but might process inbound bytes at its own pace.
- **`POST /device/stop_monitoring_spam`** — server-side hammer loop, duration-bounded. Open TCP → write the same blind payload → close → repeat as fast as possible until `duration_s` elapses. Configurable `connect_timeout` (default 500ms) and `repeat` (frames per session). Reports `sent_ok`, `connect_failed`, `write_failed`, `rate_attempts_per_s`. Clamped to 5min duration.
- **`POST /device/stop_monitoring_slow_drip`** — opposite of spam. Open ONE TCP session, drip the wake handshake + stop frames at `interval_s` (default 3s) for `duration_s` (default 120s, max 10min). Each drip is ~23 bytes — well under any UART FIFO size. Opportunistically drains any inbound bytes the device sends back; `bytes_received > 0` in the response strongly suggests the device has started talking and the session is healthy. **This is the endpoint that saved BE9558H.** Spam mode had been overrunning the device's UART FIFO; slow drip stayed under it.
- **Six rescue scripts** under `scripts/` — thin bash wrappers around the endpoints, default `SFM_BASE_URL=http://localhost:8200` (direct, not via Terra-View proxy whose 60s timeout would cut off the longer endpoints):
- `rescue_device.sh` — race-loop wrapper for `/device/rescue`
- `blind_stop.sh` — race-loop wrapper for `/device/stop_monitoring_blind`
- `spam_stop.sh` — single-call burst hammer
- `slow_drip.sh` — single-call held-session drip
- `watch_unit.sh` — passive periodic reachability check (every N min, logs to file), useful for unattended overnight monitoring of a wedged unit
- **`docs/runbooks/wedged_unit_recovery.md`** — symptoms, quick-reference recovery procedure, the modem-layer mechanism (Sierra Wireless serial-port mode-flipping is the real failure mode — not the device firmware), and a table of "why simpler approaches don't work" so the next incident skips the dead ends.
### Added — operator event DB management
Endpoints powering Terra-View's new `/admin/events` page (v0.12.0). Designed for purging bogus events from a unit that's been forwarding them in bulk (e.g. a stuck-triggered seismograph dumping hundreds of junk events before it's recovered).
- **`DELETE /db/events/{event_id}`** — hard-delete one event row. Also unlinks the associated blastware binary (`.AB0*`), `.a5.pkl`, `.sfm.json` sidecar, and `.h5` clean-waveform files via the WaveformStore. Returns the per-file removal status. 404 if the event doesn't exist.
- **`POST /db/events/delete_bulk`** — filter-based or id-list-based bulk delete with safety rails:
- Filters (`serial`, `from_dt`, `to_dt`, `false_trigger`) combine with AND; same semantics as `GET /db/events`. `ids` is an additional inclusion list. Refuses to run with no filters (would wipe the whole table — raises 422).
- `confirm` must be `true` to actually delete. Otherwise returns a dry-run summary (`status: "dry_run"`, `matched: N`, `sample_serials: [...]`).
- `max_rows` (default 10,000) caps how many rows can be deleted by-filter in one call. If exceeded, returns `status: "too_many"` with a hint to narrow or raise the cap. Bypassed when only `ids` is supplied.
- **`_cleanup_event_files(row)`** helper in `sfm/server.py` — best-effort `unlink()` of all four sidecar paths derived from the row's `blastware_filename`. Logged at WARN if a path exists but unlink fails; the DB row deletion still proceeds.
- **`SeismoDb.delete_event(id)` and `SeismoDb.delete_events_bulk(...)`** in `sfm/database.py` — both return the deleted row dict(s) so callers can do file cleanup. `delete_events_bulk` raises `ValueError` if no filters are supplied.
### Changed
- **Default protocol recv timeout dropped from 30s → 10s** in `_build_client()`. The unit usually responds in well under a second over cellular; 10s leaves comfortable headroom for retransmits while failing reasonably fast when a unit is wedged. The two endpoints that perform full 5A waveform downloads still pass `timeout=120.0` explicitly so multi-minute event transfers are unaffected.
- **`_build_client()` now accepts an optional `connect_timeout`** (TCP-only) so rescue / race-loop endpoints can fail fast on busy modems without affecting the protocol-level recv timeout.
### Fixed
- **`GET /device/monitor/status` returned HTTP 500 + uncaught traceback when the device was unresponsive**. The retry-on-`Exception` inner block let the second `client.poll()`'s `ProtocolError` propagate out of the handler. Now wrapped in proper try/except — returns 502 with `{"detail": "Protocol error: No S3 frame received within 10.0s ..."}` on timeout, 502 on connection errors, 500 only for genuinely unexpected exceptions.
### Migration
No schema changes. No data migration required.
If you've been running a previous version against a wedged unit and accumulated bogus events, the new `/admin/events` page in Terra-View v0.12.0 (or direct `POST /db/events/delete_bulk` with `confirm: true`) is the cleanup tool. Watcher state on the upstream DL2 PC does NOT need separate cleaning — the watcher's `sfm_forwarded.json` keys on file sha256 and won't re-forward the same files.
### Pairing
This release pairs with **Terra-View v0.12.0**, which adds the `/admin/events` UI that consumes the new bulk-delete endpoints, the bulk false-trigger flagging on `/unit/{id}`, and the field-deployment workflow that uses the same `series3-watcher` → SFM ingest path as before.
---
## v0.16.1 — 2026-05-14
### Fixed
- **`record_type` always "Waveform" for forwarded events.** `read_blastware_file()` hardcoded `ev.record_type = "Waveform"` regardless of the file's actual type. The watcher-forward pipeline (the main BW ACH ingest path) compounds this by parsing files from a tmp path with a `.bw` suffix, so even a filename-based fallback inside the parser still wouldn't see the original extension. Now:
1. New `derive_record_type_from_filename(filename)` helper in `minimateplus/event_file_io.py` derives the type from the LAST character of the filename's extension (V10.72+ AB0T scheme: `H`=Histogram, `W`=Waveform, `M`=Manual, `E`=Event, `C`=Combo). Falls back to `"Waveform"` for old S338 firmware (3-char extensions ending in `0`) and any unrecognized suffix.
2. `read_blastware_file()` now calls the helper with its `path.name` so direct callers (the `--dry-run` path in `scripts/import_bw.py`, tests, ad-hoc scripts) get the right value automatically.
3. `WaveformStore.save_imported_bw()` overrides `ev.record_type` with the **original** filename's derived type after parsing (the tmp file inside the parser doesn't carry the original extension). This is the path the live watcher-forwarder hits, so the DB column now reflects the actual event type going forward.
Events ingested before this fix are stuck with `record_type="Waveform"` in the DB; a one-off backfill (`UPDATE events SET record_type = ... WHERE blastware_filename LIKE '%H'`) would fix them retroactively if desired. Terra-view's event modal also derives client-side from the filename, so the UI already shows the correct type for old events even without the backfill.
---
## 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 `<binary>_<ext>_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 `<binary>` — which works for manual exports (`<binary>.TXT`) but not BW ACH (`<stem>_<ext>_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 <path> --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
+3 -1
View File
@@ -2,7 +2,7 @@
Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for
managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem
(Sierra Wireless RV50 / RV55). Current version: **v0.14.3**.
(Sierra Wireless RV50 / RV55). Current version: **v0.17.0**.
When new information about the protocol is discovered, please update the instantel_protocol_reference.md with the findings in addition to this document
@@ -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: `<prefix_letter><serial3><4-char-base36-stem><ext>`
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
COPY pyproject.toml requirements.txt ./
COPY minimateplus ./minimateplus
COPY sfm ./sfm
COPY bridges ./bridges
RUN pip install --no-cache-dir -e .
EXPOSE 8200
CMD ["python", "-m", "uvicorn", "sfm.server:app", "--host", "0.0.0.0", "--port", "8200"]
+169 -34
View File
@@ -1,7 +1,11 @@
# seismo-relay `v0.15.0`
# seismo-relay `v0.19.0`
A ground-up replacement for **Blastware** — Instantel's aging Windows-only
software for managing MiniMate Plus seismographs.
software for managing seismographs. Supports both the **MiniMate Plus
(Series III)** and the **Micromate (Series IV / "Thor")** families:
Series III via the live RS-232 / TCP wire protocol *and* Blastware ACH file
ingest; Series IV currently via Thor TXT-paired IDF file ingest, with the
binary codec on the roadmap.
Built in Python. Runs on Windows, Linux, or macOS. Connects to instruments
over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55).
@@ -14,11 +18,24 @@ 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.
> **v0.18.0 (2026-05-19)** adds Thor / Micromate Series IV ingest at
> `/db/import/idf_file` — paired with **thor-watcher v0.3.0**, every
> `.IDFH` / `.IDFW` event file (plus its `.txt` sidecar) lands in
> SeismoDb the same way BW events do. See
> [`docs/idf_protocol_reference.md`](docs/idf_protocol_reference.md) for
> the IDF format reference and reverse-engineering plan.
> **v0.19.0 (2026-05-20)** separates Series III and Series IV at the
> code level: new `micromate/` package alongside `minimateplus/`, new
> `events.device_family` DB column ("series3" / "series4") so the UI
> and storage layer dispatch deterministically instead of sniffing
> filenames. Self-applying migration backfills existing rows from the
> binary filename extension.
> See [CHANGELOG.md](CHANGELOG.md) for full version history.
---
@@ -28,17 +45,25 @@ over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55).
seismo-relay/
├── seismo_lab.py ← Main GUI (Bridge + Analyzer + Download + Console tabs)
├── minimateplus/ ← MiniMate Plus client library
├── minimateplus/ ← Series III (MiniMate Plus) client library
│ ├── transport.py ← SerialTransport, TcpTransport, SocketTransport
│ ├── protocol.py ← DLE frame layer, SUB command dispatch
│ ├── client.py ← High-level client (connect, get_events, delete_all_events, push_config, get_call_home_config, …)
│ ├── framing.py ← Frame builders, DLE codec, S3FrameParser
│ ├── models.py ← DeviceInfo, Event, ComplianceConfig, MonitorLogEntry, CallHomeConfig, …
│ ├── bw_ascii_report.py ← Parse BW per-event ASCII reports (.TXT sidecars)
│ ├── event_file_io.py ← Read BW binaries, write .sfm.json sidecars
│ └── blastware_file.py ← Write events to Blastware-compatible .AB0 files
├── micromate/ ← Series IV (Micromate / Thor) client library (NEW v0.19)
│ ├── models.py ← IdfEvent, IdfReport, IdfPeaks, IdfProjectInfo, IdfSensorCheck (mic in native dB(L))
│ ├── idf_ascii_report.py ← Parse Thor .IDFW.txt / .IDFH.txt event sidecars
│ └── idf_file.py ← Stub for the .IDFW / .IDFH binary codec (reverse-engineering pending)
├── sfm/ ← SFM REST API server (FastAPI, port 8200)
│ ├── server.py ← Live device endpoints + DB query endpoints + caching
│ ├── database.py ← SeismoDb — SQLite persistence (events, monitor_log, ach_sessions, sessions table)
│ ├── server.py ← Live device endpoints + DB query + ingest endpoints + caching
│ ├── database.py ← SeismoDb — SQLite persistence (events, monitor_log, ach_sessions)
│ ├── waveform_store.py ← On-disk store for BW + IDF event binaries + .sfm.json sidecars
│ └── sfm_webapp.html ← Embedded web UI with Call Home config tab
├── bridges/
@@ -55,7 +80,8 @@ seismo-relay/
│ └── frame_db.py ← SQLite frame database
└── docs/
── instantel_protocol_reference.md ← Reverse-engineered protocol spec
── instantel_protocol_reference.md ← Series III protocol spec (the Rosetta Stone)
└── idf_protocol_reference.md ← Series IV (Thor IDF) format reference + codec RE plan
```
---
@@ -147,11 +173,23 @@ Query the SQLite database written by `ach_server.py`. All read-only except
| Method | URL | Description |
|--------|-----|-------------|
| `GET` | `/db/units` | All known serials with summary stats |
| `GET` | `/db/events` | Triggered events (filter by serial, date range, false_trigger) |
| `GET` | `/db/events` | Triggered events (filter by serial, date range, false_trigger). Response rows include `device_family` ("series3" / "series4") so clients dispatch on unit type without sniffing filenames. |
| `GET` | `/db/monitor_log` | Monitoring intervals |
| `GET` | `/db/sessions` | ACH call-home session history |
| `PATCH` | `/db/events/{id}/false_trigger?value=true` | Flag / unflag false triggers |
### File ingest endpoints
Used by watcher daemons to push field-collected event files into the SFM DB
+ waveform store. Both accept multipart uploads of binary event files
optionally paired with their ASCII sidecar reports; both dedup by
`(serial, timestamp)` and UPSERT device-authoritative fields on re-import.
| Method | URL | Description |
|--------|-----|-------------|
| `POST` | `/db/import/blastware_file` | Series III: `.AB0*` / `.N00` binaries + paired `_ASCII.TXT`. Source: `series3-watcher`. |
| `POST` | `/db/import/idf_file` | Series IV: `.IDFH` / `.IDFW` binaries + paired `.IDFW.txt` / `.IDFH.txt`. Source: `thor-watcher`. |
---
## minimateplus library
@@ -213,22 +251,77 @@ not per individual event).
---
## micromate library
Series IV / Thor support, sibling to `minimateplus`. Currently scoped to
offline-file ingest from Thor's TXT exporter; live-device protocol is
deferred until the binary codec is cracked.
```python
from micromate import IdfEvent, parse_idf_report
# Parse a .IDFW.txt / .IDFH.txt sidecar (1014 example files round-trip cleanly)
text = open("UM11719_20231219162723.IDFW.txt").read()
report_dict = parse_idf_report(text) # permissive dict
# Wrap into a typed event using the device-native binary filename
event = IdfEvent.from_report(report_dict, "UM11719_20231219162723.IDFW")
event.serial # "UM11719"
event.kind # "Waveform" or "Histogram"
event.peaks.transverse_ips # 0.0251 (in/s, native unit)
event.peaks.mic_pspl_dbl # 99.4 (dB(L), Thor's native mic unit — NOT psi)
event.project_info.project # "UPMC Presby-Loc 3-Level1-1R Elevator Rm"
event.sensor_check.tran # True (passed self-check)
event.firmware_version # "Micromate ISEE 11.0AK"
event.calibration_text # "November 22, 2023 by Instantel"
# Bridge to the existing minimateplus.Event shape for the DB / sidecar paths
# (waveform_key is a 16-byte sha256 prefix when ingesting from a binary file)
bridged_event = event.to_minimateplus_event(waveform_key=b"\x00" * 16)
```
The binary codec (`.IDFW` / `.IDFH` event files themselves) is on the
roadmap — see [`docs/idf_protocol_reference.md`](docs/idf_protocol_reference.md)
for everything known so far, the two observed file signatures, and the
reverse-engineering plan. The `micromate/idf_file.py` stub is where
`read_idf_file()` will land.
---
## Database
`ach_server.py` writes to `bridges/captures/seismo_relay.db` (SQLite, WAL mode) using the
`SeismoDb` persistence layer. Four tables, all unit-keyed by serial number:
`ach_server.py` and the file-ingest endpoints write to
`bridges/captures/seismo_relay.db` (SQLite, WAL mode) via the `SeismoDb`
persistence layer. Three tables, all unit-keyed by serial number:
| Table | Key | Contents |
|-------|-----|----------|
| `ach_sessions` | UUID | Per-call-home audit record: serial, timestamp, peer IP, events_downloaded, monitor_entries, duration_seconds |
| `events` | UUID, UNIQUE(serial, waveform_key) | Triggered events: timestamp, Tran/Vert/Long/VectorSum/Mic PPV, project/client/operator/sensor_location strings, sample_rate, record_type, false_trigger flag |
| `monitor_log` | UUID, UNIQUE(serial, waveform_key) | Monitoring intervals: serial, waveform_key, start_time, stop_time, duration_seconds, geo_threshold_ips |
| `events.false_trigger` | Boolean flag | PATCH endpoint to mark/unmark false triggers for review |
| `events` | UUID, UNIQUE(serial, timestamp) | Triggered events: timestamp, Tran/Vert/Long/VectorSum/Mic PPV, project/client/operator/sensor_location strings, sample_rate, record_type, false_trigger flag, **`device_family`** ("series3" / "series4"), `blastware_filename` (binary at-rest in `waveforms/`), sidecar references |
| `monitor_log` | UUID, UNIQUE(serial, start_time) | Monitoring intervals: serial, waveform_key, start_time, stop_time, duration_seconds, geo_threshold_ips |
Deduplication is by `(serial, waveform_key)`repeat call-homes or re-runs never
produce duplicate rows. Post-erase key reuse is handled automatically via the
high-water mark in `ach_state.json`. Key-based state tracking allows correct
handling of device erasures (external or post-download).
**Deduplication is by `(serial, timestamp)`**the device clock is the
stable natural key. Repeat call-homes or re-runs UPSERT the row in place,
refreshing every device-authoritative field (peaks, project strings,
sample_rate, file references) so the latest writer wins. `false_trigger`
and `device_family` are preserved across UPSERTs. Earlier versions used
`(serial, waveform_key)` for dedup, but the device's event-key counter
resets to `0x01110000` after every erase, so timestamps are the correct
dedup field. Migration handles the transition transparently on first
startup.
**`device_family` (added v0.19.0)** discriminates Series III from Series
IV at the SQL level. Set by every import path; the UI dispatches on it
to render mic units correctly (Series III: psi → dBL conversion; Series
IV: native dBL passthrough). Existing rows are backfilled at first
startup of v0.19.0+ by sniffing the binary filename extension.
The on-disk waveform store lives at `bridges/captures/waveforms/<serial>/`
and holds the original event binaries (BW `.AB0*` / `.N00` for Series III,
`.IDFH` / `.IDFW` for Series IV) plus their `.sfm.json` review/metadata
sidecars. Series III events also produce `.a5.pkl` source-frame pickles
and `.h5` clean-waveform exports; Series IV doesn't yet (pending codec).
---
@@ -310,18 +403,27 @@ Use **com0com** or **VSPD** to create the virtual COM pair on Windows.
## Key Features
**Device support:**
- [x] Full read/write/erase pipelines
**Series III (MiniMate Plus) device support:**
- [x] Full read/write/erase pipelines over RS-232 or TCP/cellular
- [x] Compliance config (recording mode, sample rate, histogram interval, geo sensitivity, project strings)
- [x] Auto Call Home config (read/write ACH settings, dial string, time slots, retries)
- [x] Monitor control (start/stop, status polling, battery/memory)
- [x] Monitor log entries (continuous monitoring intervals without full waveform download)
- [x] Blastware file ingest at `/db/import/blastware_file` (paired with `series3-watcher`)
**Series IV (Micromate / Thor) device support:**
- [x] Thor IDF file ingest at `/db/import/idf_file` (paired with `thor-watcher`, v0.18.0+)
- [x] Native `IdfEvent` / `IdfReport` typed models — mic in dB(L), full title strings, sensor self-check, calibration, firmware version
- [x] Parser verified against 1,014 paired `.txt` sidecars in `thor-watcher/example-data/`
- [ ] Binary `.IDFW` / `.IDFH` codec — pending (see Roadmap + [`docs/idf_protocol_reference.md`](docs/idf_protocol_reference.md))
- [ ] Live-device protocol — pending codec
**Data persistence:**
- [x] SQLite database (`seismo_relay.db`) with 4 tables: ach_sessions, events, monitor_log, plus false_trigger flag
- [x] Deduplication by waveform key (handles re-runs and repeat call-homes)
- [x] Post-erase key-reuse detection (tracks high-water mark)
- [x] Session state (`ach_state.json`) with downloaded keys and max key
- [x] SQLite database (`seismo_relay.db`) with `events`, `monitor_log`, `ach_sessions` tables
- [x] Per-row `device_family` column ("series3" / "series4") for clean UI / unit-of-measurement dispatch (v0.19.0+)
- [x] Deduplication by `(serial, timestamp)` — natural key handles post-erase counter resets
- [x] UPSERT on re-import refreshes every device-authoritative field (peaks, project, sample_rate); preserves operator review state (`false_trigger`)
- [x] Post-erase key-reuse detection (tracks high-water mark in `ach_state.json`)
**REST API:**
- [x] Live device endpoints with in-memory caching (`_LiveCache`)
@@ -329,6 +431,7 @@ Use **com0com** or **VSPD** to create the virtual COM pair on Windows.
- [x] DB query endpoints (units, events, monitor_log, sessions, false_trigger PATCH)
- [x] Call Home config read/write endpoints
- [x] Blastware file download endpoint (`/device/event/{index}/blastware_file`)
- [x] Import endpoints for both device families (`/db/import/blastware_file`, `/db/import/idf_file`)
**File output (v0.7+, byte-perfect as of v0.14.3):**
- [x] Blastware-compatible `.AB0` / `.G10` file generation (waveform + metadata)
@@ -356,10 +459,42 @@ 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)
- [ ] **Series III 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.
- [ ] **Series IV (Thor IDF) binary codec reverse-engineering.** `.IDFH` / `.IDFW` files are currently stored opaquely by `WaveformStore.save_imported_idf`, with all metadata sourced from the paired `.txt` sidecar. This works because thor-watcher forwards both files together, but operators who haven't enabled Thor's TXT exporter get rows with NULL peaks. Cracking the binary closes that gap and unlocks waveform display. Starting-point reference at [`docs/idf_protocol_reference.md`](docs/idf_protocol_reference.md) — two observed file signatures (1,012 newer-firmware files + 2 old files whose layout matches the Series III STRT-record format), suggested first-session plan (~2-4 hrs), 1,014 paired binary+txt files available as ground truth in `thor-watcher/example-data/`. Code seam ready at `micromate/idf_file.py`.
- [ ] **In-app waveform viewer accuracy.** Depends on Series III codec decode. Plot.v1 JSON pipeline + viewer skeleton already exist; will start showing real waveforms automatically once `_decode_a5_waveform` produces correct samples. Series IV waveforms come online when the IDF codec lands.
- [ ] **Series IV live-device support.** Once the IDF binary is decoded, extend `micromate/` with `transport.py` / `framing.py` / `protocol.py` / `client.py` mirroring the `minimateplus/` package layout — depends on capturing Thor's wire protocol (TCP / RS-232 captures TBD).
- [ ] **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 `<watch_folder>_archive/<year>/<month>/` 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).
+1
View File
@@ -516,6 +516,7 @@ class AchSession:
serial=serial or self.peer,
session_id=None,
waveform_records=waveform_records,
device_family="series3",
)
_ml_ins, _ml_skip = self.db.insert_monitor_log(
new_monitor_entries, session_id=None
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+284
View File
@@ -0,0 +1,284 @@
# IDF Protocol Reference — Thor / Micromate Series IV
Starting-point reference for reverse-engineering Instantel's Micromate
Series IV event-file format. Sibling to
[instantel_protocol_reference.md](instantel_protocol_reference.md) (the
Series III "Rosetta Stone") — this doc holds what we know so far and
the open questions still to crack.
**Status (2026-05-20):** ASCII text sidecar fully decoded (1,014
sample files round-trip). Binary `.IDFH` / `.IDFW` codec
**not yet implemented** — binaries are stored opaquely by
`WaveformStore.save_imported_idf`, with metadata sourced from the
paired `.txt` sidecar.
---
## File model
### Filename convention
```
<SERIAL>_<YYYYMMDDHHMMSS>.<KIND>
```
- **SERIAL** — literal device serial, two-letter prefix + numeric
suffix. Examples seen: `UM11719`, `UM13981`, `UM20147`, `BE9439`.
Unlike Series III BW filenames (`M529LK44.AB0`, base-36 stem),
Series IV filenames carry the serial in plain text.
- **YYYYMMDDHHMMSS** — 14-char ASCII timestamp in **device local
time** (no timezone marker).
- **KIND**`IDFH` for histograms, `IDFW` for waveforms.
The `.IDFH.txt` / `.IDFW.txt` ASCII sidecar lives in a `TXT/`
**subfolder** of the unit's directory, not alongside the binary.
This pairing convention is encoded in
`event_forwarder.idf_report_path()`.
### Directory layout
```
C:\THORDATA\
└── <Project>\
└── <UM####>\ ← unit serial dir
├── UM12345_20260520100000.MLG ← monitor log (not events)
├── UM12345_20260520100000.IDFH ← histogram event (binary)
├── UM12345_20260520100000.IDFW ← waveform event (binary)
├── UM12345_20260520100000.IDFW.CDB ← cache-DB variant (skip)
├── TXT\
│ ├── UM12345_20260520100000.IDFH.txt ← histogram ASCII sidecar
│ └── UM12345_20260520100000.IDFW.txt ← waveform ASCII sidecar
├── CSV\, HTML\, PDF\, XML\ ← operator-facing derived exports
└── ...
```
The `.IDFW.CDB` files share the binary's basename but appear to be a
separate cache/database variant. Their first 8 bytes match the
**old**-firmware Thor signature (see below) regardless of which
signature the paired `.IDFW` uses. Purpose unknown; sizes vary
wildly (observed 123 B → 40,491 B). Thor-watcher's forwarder
deliberately skips them.
### Sample corpus
The `thor-watcher/example-data/THORDATA_example/` tree carries
**1,014 paired .IDFW / .IDFH + .txt files** spanning 20202023
across nine units (UM11719, UM13981, UM20147, …, plus BE9439 from
2020). This is the reverse-engineering ground truth.
---
## ASCII sidecar (`.IDFW.txt` / `.IDFH.txt`) — fully decoded
Shape: plain text, one `"Key : Value"` line per metadata field,
followed for waveforms by a tab-separated sample table headed by
the literal line `Waveform Data Channels`. Parsed by
[`micromate/idf_ascii_report.py`](../micromate/idf_ascii_report.py).
See [`micromate/models.py`](../micromate/models.py) for the typed
`IdfReport` shape.
### Notable conventions
- **Units are native to Thor** — geophone in **in/s**, microphone in
**dB(L)** (not psi like Series III BW reports), frequency in Hz,
acceleration in g, displacement in in.
- **Below-threshold readings** appear as the literal string
`<0.005 in/s` (155 occurrences in the sample corpus) — the parser
strips the `<` and treats the numeric remainder as the value.
- **Out-of-range / not-measured** values appear as `N/A` — parser
drops the field rather than letting the string leak into a numeric
column.
- **Firmware string** observed: `Micromate ISEE 11.0AK`.
- **TitleString1..4** are operator-defined free-text slots; Thor's
default labels map them to Location / Client / Company / Notes,
which the parser surfaces as `project` / `client` / `operator` /
`notes`.
- **Histogram sidecars** use `HistogramStartDate` / `HistogramStartTime`
in place of waveform's `EventDate` / `EventTime`. Parser falls
through to either.
- **Histogram tabular block** lacks the `Waveform Data Channels`
marker; instead it's a multi-line column header followed by
per-interval rows (`<date> <time> <tran-ppv> <freq> ...`). Parser
silently ignores lines after the metadata block since they lack a
colon-separated `key : value` shape (the timestamps DO contain
colons but produce garbage keys that don't collide with any
recognised field).
---
## Binary header signatures (observed)
Hex dump of the first 32 bytes across 1,014 sample files reveals
**two distinct file signatures**, both anchored by the literal
ASCII string `"\x00Instantel\x00"` at offset 616:
### Signature A — newer firmware (1,012 files, 99.8% of corpus)
```
00000000: 0012 0100 0000 496e 7374 616e 7465 6c00 ......Instantel.
00000010: 0000 a695 002e b500 4f70 6572 6174 6f72 ........Operator
^^^^^^^^^^^^^^^^
operator/title string starts at 0x18
```
Header bytes 05: `00 12 01 00 00 00`. Followed immediately by the
8-byte ASCII tag, then 6 unknown bytes, then ASCII operator-supplied
strings (Operator name, etc.) and on through the project / client /
title strings. No `STRT` record observed in this layout.
### Signature B — older firmware (2 files: BE9439 from 2020)
```
00000000: 1000 0180 0000 496e 7374 616e 7465 6c00 ......Instantel.
00000010: 072c 0012 0300 5354 5254 fffe 0111 2340 .,....STRT....#@
^^^^^^^^^ ^^^^^^^^^
STRT magic 4-byte end_key
00000020: 0111 0000 2e5f 00ac 4600 0000 0200 0000 ....._..F.......
^^^^^^^^^ ^^^
4-byte start_key 0x46 (BW WAVEHDR record-type marker)
```
Header bytes 05: `10 00 01 80 00 00`. The structure after the
`Instantel` magic is **byte-for-byte identical to a BW SUB 5A
probe-response STRT record** as documented in
[instantel_protocol_reference.md → "SUB 5A — STRT record encodes
end_offset"](instantel_protocol_reference.md). Specifically:
| Offset | Bytes | Meaning (per BW reference) |
|--------|---------------------|--------------------------------------|
| 0x14 | `53 54 52 54` | `STRT` magic |
| 0x18 | `ff fe` | STRT sentinel |
| 0x1A | `01 11 23 40` | `end_key` (4 bytes) |
| 0x1E | `01 11 00 00` | `start_key` (4 bytes) |
| 0x26 | `46` | `0x46` waveform-record type marker |
**Hypothesis:** Older Micromate firmware writes a wrapped BW-format
event into the `.IDFW` file — essentially the same on-disk shape as
a Series III device, with the new filename convention applied at
export time. Newer firmware (signature A) abandoned the
BW-compatible layout for an Instantel-specific format.
If that hypothesis holds, the 2 signature-B files can already be
parsed via `minimateplus/event_file_io.read_blastware_file()` — worth
testing. The 1,012 signature-A files are the real reverse-engineering
target.
### `.IDFW.CDB` cache files
Always carry signature B (`10 00 01 80 ...`), even when the paired
`.IDFW` carries signature A. Plausible explanation: the CDB is an
internal Thor cache-database export that retains the legacy BW-style
record layout regardless of the user-facing `.IDFW` format version.
Not currently consumed by the forwarder.
---
## File-size patterns (Signature A, the main target)
Survey of 1,012 signature-A files:
| Event type | Typical size | Source of variance |
|--------------|-------------------|----------------------------------------------|
| `.IDFW` 2-sec | 9,200 10,500 B | Operator-supplied strings (TitleString1..4) of varying length |
| `.IDFH` | 2,944 4,076 B | Histogram interval count (record duration / interval) |
**Naive arithmetic for 2-sec waveform:**
- 4 channels × 2 sec × 1024 sps = 8,192 samples
- At 2 bytes/sample (int16) = 16,384 sample bytes → file would be > 16 KB
- Observed: ~910 KB
- → samples are likely **1 byte each** (int8 quantised), **or** stored
with bit-packing / delta encoding, **or** only one channel's
full-rate samples are stored with the others reconstructed
arithmetically. Verifying this is the **first RE milestone**.
Project-stringlength variance (~1 KB across the corpus) is consistent
with the file carrying a single copy of each TitleString1..4 plus
operator + setup-name as null-padded ASCII regions.
---
## Open questions
The reverse-engineering targets, roughly in dependency order:
1. **Sample encoding (signature A)** — int8? int16 LE/BE? Bit-packed?
Delta-coded? Per-channel interleaved or sequential blocks?
2. **Header field layout (signature A)** — where do sample_rate,
record_time, channel count, and per-channel peaks live in the
binary? The ASCII sidecar gives the device-authoritative values,
so binary fields can be confirmed by diff.
3. **Operator-string offsets**`Operator` at 0x18 is the first
visible string in signature-A files; the rest (project, client,
notes, setup) follow. Need to map exact offsets and null-padding
conventions.
4. **Signature-B → BW codec compatibility** — does
`minimateplus/event_file_io.read_blastware_file()` actually parse
the 2 BE9439 signature-B files as-is? If yes, the OLD-format
ingest is free.
5. **`.IDFW.CDB` purpose** — is it an internal Thor cache, a
ring-buffer dump, or something else? Worth a single small effort
to characterise so we know what we're skipping.
6. **Footer / checksum** — every BW event file has a footer; does
IDF? Where does the per-channel sample block end?
---
## Reverse-engineering playbook (when we start)
The Series III BW codec took ~2 months of MITM wire captures
because we didn't have ground-truth metadata. Thor's situation is
**substantially better**:
- **Ground truth is on disk.** Every binary in `example-data/`
has a paired `.IDFW.txt` carrying the full decoded sample table
(`Waveform Data Channels` block — see any sample file in
`thor-watcher/example-data/.../TXT/`). Aligning binary bytes
to the table's float-per-row values gives an immediate per-byte
hypothesis test.
- **Cross-event diffing.** 1,012 signature-A samples from 9 units
spanning 4 years means any field that varies between events is
immediately localisable. Fields that are constant across all
files (firmware ID, channel labels, format-version word) are also
immediately localisable by complementary search.
- **No protocol surface.** Files at rest, not a wire dialect. No
DLE stuffing, no inner-frame parsing, no probe/data two-step.
Suggested first session (2-4 hours): hand-decode `UM11719_20231219162723.IDFW`
(10,290 bytes) against its `TXT/UM11719_20231219162723.IDFW.txt`
sample table (the 2-sec waveform at 1024 sps × 4 channels = 8,192
sample rows). Find the first per-channel sample value (`0.0003` in
the Tran column at t=0) in the binary. Confirms sample encoding.
Everything else flows from there.
---
## Code seams ready to receive the codec
When the codec lands, it goes into
[`micromate/idf_file.py`](../micromate/idf_file.py) (currently a
stub raising `NotImplementedError`). Public API:
```python
from micromate import IdfEvent
from micromate.idf_file import read_idf_file
event: IdfEvent = read_idf_file(Path("UM11719_20231219163444.IDFW"))
# event.peaks.transverse_ips, event.timestamp, event.raw_samples, ...
```
The ingest pipeline (`WaveformStore.save_imported_idf`) currently
builds the `IdfEvent` from the `.txt` parser only. Once
`read_idf_file()` works, the binary becomes authoritative; the
`.txt` parser drops to fast-path metadata cross-check. Operators
who don't enable Thor's TXT exporter still get fully populated
events.
---
## See also
- [instantel_protocol_reference.md](instantel_protocol_reference.md) — Series III BW protocol reference (the Rosetta Stone). STRT record format, DLE framing, BW filename encoding.
- [`micromate/idf_ascii_report.py`](../micromate/idf_ascii_report.py) — `.txt` sidecar parser.
- [`micromate/models.py`](../micromate/models.py) — `IdfEvent`, `IdfReport` typed dataclasses.
- [`micromate/idf_file.py`](../micromate/idf_file.py) — placeholder for the binary codec.
- [`thor-watcher/example-data/THORDATA_example/`](../../thor-watcher/example-data/) — 1,014 paired binary + .txt files for codec validation.
+255
View File
@@ -0,0 +1,255 @@
# Runbook — Recovering a wedged unit stuck in a call-home loop
**Original incident:** BE9558H at `166.246.130.1:9034`, recovered 2026-05-17.
A field unit with a stuck-triggered geophone (or any hardware fault causing
constant event triggering) will record events back-to-back, and if Auto Call
Home is set to "After Event Recorded" the device will dial the office BW
ACH server in a tight loop. Combined with a Sierra Wireless modem in
bidirectional serial-TCP mode, this makes the unit effectively unreachable
from SFM — every TCP connection we open gets killed when the modem flips
from server-mode to client-mode to honor the device's next AT dial command.
This runbook describes how to break the loop and recover control.
---
## Symptoms
- Terra-View / SFM `/device/info` either hangs or fails on `count_events()`.
- `/device/monitor/status` and `/device/rescue` return 502 (protocol timeout
waiting for POLL response) or 503 (TCP connect refused).
- ACEmanager serial log shows repeating
`Connect to IP: <BW_IP> Port: <BW_PORT>``Shutdown TCP socket` cycles
every 30-60 seconds.
- Spam-mode endpoints (`/device/stop_monitoring_spam`) report many
`sent_ok` but the device's monitoring state never changes.
- `slow_drip` reports `[Errno 32] Broken pipe` after sending the preamble
but before completing the drip loop.
If you see *all* of these, the unit is in this exact failure mode.
---
## Quick reference — how to recover
You need **ACEmanager access** to the unit's modem.
### Step 1: stop the modem's mode-flipping
In ACEmanager → **Serial → Port Configuration**:
| Field | Set to |
|---|---|
| **Destination Address** | clear (blank) |
| **Destination Port** | `0` |
Click **Apply**. This removes the modem's auto-dial-out target. The device's
AT dial commands now error back at the modem instead of triggering a
mode-flip, so the modem stays in TCP-server mode permanently and our inbound
TCP sessions stay alive.
*(Optional belt-and-suspenders: also add the BW server's port to
**Security → Port Filtering - Outbound** as a blocked port, with
Outbound Port Filtering Mode = Blocked Ports.)*
### Step 2: stop monitoring on the device (slow drip)
From the SFM host:
```bash
/home/serversdown/seismo-relay/scripts/slow_drip.sh <DEVICE_IP> <PORT>
```
Defaults are 120s duration with a drip every 3s. Watch the response:
- `duration_s ≈ 120` and `drips_sent ≈ 40` → session held the full duration ✓
- `bytes_received > 0` → device is responding ✓ (this is the success signal)
If `duration_s` is small or `send_error: "Broken pipe"`, Step 1 didn't take
hold — re-check ACEmanager, may need to reboot the modem after Apply.
### Step 3: confirm monitoring stopped
```bash
curl 'http://localhost:8200/device/monitor/status?host=<DEVICE_IP>&tcp_port=<PORT>&force=true'
# expect: {"is_monitoring": false, ...}
```
### Step 4: disable ACH at the device level + erase corrupted events
Either fire the rescue endpoint:
```bash
/home/serversdown/seismo-relay/scripts/rescue_device.sh <DEVICE_IP> <PORT>
```
Or do the two steps manually:
```bash
# Disable ACH in the device's compliance config
curl -X POST 'http://localhost:8200/device/call_home?host=<DEVICE_IP>&tcp_port=<PORT>' \
-H 'Content-Type: application/json' \
-d '{"auto_call_home_enabled": false}'
# Erase corrupted event chain
curl -X POST 'http://localhost:8200/device/events/erase?host=<DEVICE_IP>&tcp_port=<PORT>'
```
You can also do this via the SFM standalone UI → **Call Home** tab → set
`Enable Auto Call Home` to `Disabled`**Write to Device**.
### Step 5: restore modem config (housekeeping)
Once the device-side ACH is disabled, restore the modem's Destination
Address and Port to the original values (e.g. `50.197.32.92` / `12345`) in
ACEmanager. The modem will resume normal bidirectional behavior, but the
unit won't issue any dial commands until ACH is explicitly re-enabled on
the device.
### Step 6: do NOT re-enable ACH on this unit until the underlying hardware
fault is repaired. If you do, the call-home loop starts again immediately
and you'll be running this runbook a second time.
---
## Why this works — the failure mode explained
The Sierra Wireless RV50/RV55 serial port operates in one of two TCP modes
at any moment:
- **Server mode** — listens on `Device Port` (e.g. 9034), bridges inbound
TCP to the device's serial port. This is what we need to interact with
the device.
- **Client mode** — when the device sends an AT dial command on its serial
TX line, the modem opens an outbound TCP to `Destination Address:Port`
and bridges that to serial.
A serial port in this configuration is **bidirectional**: the modem flips
between server and client modes on demand. When the device's firmware is
healthy and only dials occasionally, this works fine.
When the unit is constantly triggering events and ACH is set to "After
Event Recorded", the device sends an AT dial command every few seconds.
Each one causes the modem to:
1. Drop any active inbound TCP session
2. Flip to client mode
3. Attempt outbound TCP to `Destination Address:Port`
4. Hang for up to a minute waiting for it to succeed/fail
5. Drop back to server mode
**During the entire hang, no inbound TCP can establish.** Even between
hangs, the modem closes any existing inbound session before flipping. So
any tool that needs more than a few seconds of held TCP (e.g. POLL +
config read + write) gets repeatedly kicked off.
Clearing `Destination Address` removes step 3-4 from the cycle: the modem
has nowhere to dial, so it doesn't flip modes when it receives an AT dial
command. The serial port effectively becomes server-only, and inbound TCP
sessions can stay open as long as needed.
**This is a modem-layer issue, not a device firmware issue.** The device
is alive and responsive the whole time — confirmed in the BE9558H
recovery by 990 bytes of S3 responses received over a 120s slow-drip
session once the modem was no longer mode-flipping.
---
## Why simpler approaches don't work
| Approach | Why it fails |
|---|---|
| Standard `/device/info` | Triggers `count_events()` 1E/1F walk, takes 90s+ and hits corrupted event chain in this scenario |
| `/device/rescue` race loop | Gets 502 (protocol timeout) because the modem closes the TCP before the POLL handshake can complete |
| `/device/stop_monitoring_blind` (single frame) | Even if the bytes leave the wire, the device's protocol parser ignores write commands without a preceding POLL handshake (early-version bug, now fixed by including POLL preamble in blind sends) |
| `/device/stop_monitoring_spam` (sub-second cadence) | Each session is killed by the modem's mode-flip before the device can drain its UART RX buffer; high-rate spam also risks UART FIFO overrun on the device side |
| Outbound port firewall block alone | Stops the outbound TCP from succeeding, but doesn't stop the modem from *trying* and mode-flipping. Reduces but doesn't eliminate the contention. |
| Modem reboot | Temporary — as soon as the device starts triggering again, the loop resumes within seconds |
The combination of `slow_drip` + cleared `Destination Address` works because:
1. The modem stops mode-flipping → TCP session stays open for the full
drip duration
2. Slow drip rate → device's UART RX FIFO never overflows even if
firmware is busy with event recording
3. The drip is `SESSION_RESET + STOP_MONITORING` every 3s → many
independent chances for the parser to land one valid frame
4. Once one Stop Monitoring is parsed, event recording halts → firmware
has CPU to spare → subsequent operations are trivially easy
---
## Tooling reference
All endpoints live in `seismo-relay/sfm/server.py`. All scripts live in
`seismo-relay/scripts/` and default to SFM direct (`http://localhost:8200`),
overridable via `SFM_BASE_URL`.
### Endpoints added during BE9558H recovery
| Endpoint | Purpose |
|---|---|
| `GET /device/events/storage_range` | SUB 0x06 — first/last event keys, `is_empty` flag. ~2s, no event walk. |
| `GET /device/events/index` | SUB 0x08 — lifetime event counter (does NOT decrement on erase). ~2s. |
| `POST /device/events/erase` | Full erase sequence 0xA3 → 0x1C → 0x06 → 0xA2. |
| `POST /device/rescue` | Disable ACH + erase in one TCP session. Short timeouts for race-loop usage. |
| `POST /device/stop_monitoring_blind` | Fire-and-forget Stop with full POLL preamble (single attempt). |
| `POST /device/stop_monitoring_spam` | Server-side tight retry loop, sub-second cadence, duration-bounded. |
| `POST /device/stop_monitoring_slow_drip` | One held TCP session, slow trickle of stop frames. **The endpoint that saved BE9558H.** |
Also changed: default protocol recv timeout dropped from 30s → 10s in
`_build_client`. Added `connect_timeout` knob to same. Cleaned up
unhandled-exception path in `/device/monitor/status` so it returns 502
instead of 500 on protocol timeouts.
### Scripts
| Script | Purpose |
|---|---|
| `scripts/rescue_device.sh` | Race-loop wrapper around `/device/rescue` |
| `scripts/blind_stop.sh` | Race-loop wrapper around `/device/stop_monitoring_blind` |
| `scripts/spam_stop.sh` | Single-call burst hammer (`/device/stop_monitoring_spam`) |
| `scripts/slow_drip.sh` | Single-call held-session drip (`/device/stop_monitoring_slow_drip`) |
| `scripts/watch_unit.sh` | Passive periodic reachability check, logs to file |
---
## Incident log — BE9558H, 2026-05-16/17
What was wrong: Long-axis geophone developed an offset, constantly above
trigger threshold → constant event recording → after-event ACH set →
modem dialing office BW server (`50.197.32.92:12345`) every 30-60s.
Local event chain corrupted (`next_boundary 0x100EE exceeds uint16`).
Diagnostic path:
1. `/device/info` slow, choked on event walk
2. Built lightweight probe endpoints (`storage_range`, `index`) — useful
but didn't reach the wedged unit
3. Built `/device/rescue` with short timeouts — got 502 (POLL no response)
4. Built `/device/stop_monitoring_blind` — first version was a false
positive (no POLL preamble); fixed by including
`SESSION_RESET+POLL_PROBE+SESSION_RESET+POLL_DATA` in the dump
5. Verified blind stop works on bench unit
6. Built `/device/stop_monitoring_spam` — 420 successful sends over
5 min, zero behavior change on field unit
7. Inspected ACEmanager logs → saw outbound dial-out attempts every ~30s,
confirmed device was not fully locked up
8. Added outbound port-12345 firewall block → outbound attempts now fail
instantly but contention persisted
9. Built `/device/stop_monitoring_slow_drip` — session died at 3s with
broken pipe (modem closing on us)
10. Looked at full ACEmanager Port Configuration → **found
`Destination Address: 50.197.32.92` configured**, realized every AT
dial command was triggering a modem mode-flip that killed our inbound
11. Cleared Destination Address + Port → slow_drip held 120s, device
responded with 990 bytes, 39 stop commands acked
12. Disabled ACH at device level via `/device/call_home`, erased events
Final state: device IDLE, memory 958.1 / 960 KB free, ACH disabled at
device level, modem destination cleared (to be restored after physical
service).
Total time from "i was wondering if its possible to" first attempt to
recovery: ~7 hours of intermittent debugging across one evening.
+48
View File
@@ -0,0 +1,48 @@
"""
micromate Instantel Micromate (Series IV) device library.
Sibling of ``minimateplus`` (the Series III library). Currently scoped to
the offline-file ingest path used by thor-watcher: parsing the per-event
``.IDFH``/``.IDFW`` ASCII text sidecars Thor's exporter writes alongside
each binary event file, and wrapping the parsed data in typed event
records.
Live-device support (TCP protocol, frame parsing, real-time monitoring)
is deferred when we add it, it lands here as ``transport.py`` /
``framing.py`` / ``protocol.py`` / ``client.py``, mirroring the
``minimateplus`` package layout.
Typical usage (offline file ingest):
from micromate import IdfEvent, parse_idf_report
text = open("UM11719_20231219162723.IDFW.txt").read()
rep = parse_idf_report(text) # dict
event = IdfEvent.from_report(rep, "UM11719_20231219162723.IDFW")
print(event.serial, event.peaks.transverse_ips, event.mic_pspl_dbl)
"""
from .idf_ascii_report import (
parse_event_filename,
parse_idf_report,
serial_from_filename,
)
from .models import (
IdfEvent,
IdfPeaks,
IdfProjectInfo,
IdfReport,
IdfSensorCheck,
)
__version__ = "0.1.0"
__all__ = [
"IdfEvent",
"IdfPeaks",
"IdfProjectInfo",
"IdfReport",
"IdfSensorCheck",
"parse_event_filename",
"parse_idf_report",
"serial_from_filename",
]
+315
View File
@@ -0,0 +1,315 @@
"""
micromate/idf_ascii_report.py parse Thor (Micromate Series IV) IDF ASCII reports.
Thor exports a `.IDFW.txt` or `.IDFH.txt` sidecar next to each `.IDFW`
(waveform) or `.IDFH` (histogram) event binary. Each sidecar is a
plain-text file with `"Key : Value"` lines covering the full device-
authoritative event metadata PPV per channel, ZC Freq, Time of Peak,
Peak Acceleration / Displacement, sensor self-check results, project
strings, calibration date, battery level, etc. followed by a raw
waveform-samples block headed by the literal line "Waveform Data Channels".
This is the Thor analogue of `minimateplus/bw_ascii_report.py` for the
Blastware (Series III) report format. The parser is intentionally
permissive: we extract everything we recognise into a flat dict and
silently ignore anything we don't. Downstream callers parse units
(`"0.2119 in/s"` 0.2119) only on the fields they need.
Example input (truncated):
"EventType : Full Waveform"
"SampleRate : 1024 sps"
"EventTime : 16:27:23"
"EventDate : 2023-12-19"
"TranPPV : 0.0251 in/s"
"VertPPV : 0.2119 in/s"
"LongPPV : 0.0282 in/s"
"PeakVectorSum : 0.2131 in/s"
"MicPSPL : 99.4 dB(L)"
"TranZCFreq : 6.5 Hz"
"SerialNumber : UM11719"
"Version : Micromate ISEE 11.0AK"
"FileName : UM11719_20231219162723.IDFW"
"BatteryLevel : 3.8 volts"
"Calibration : November 22, 2023 by Instantel"
"TranTestResults : Passed"
"TitleString1 : UPMC Presby-Loc 3-Level1-1R Elevator Rm"
Waveform Data Channels
Tran Vert Long MicL
0.0003 -0.0003 0.0003 0.00013
...
"""
from __future__ import annotations
import datetime
import re
from typing import Any, Dict, Optional, Tuple, Union
# Lines look like: "Key : Value" (quotes literal, single ":" separator)
_LINE_RE = re.compile(r'^\s*"?([^":]+?)"?\s*:\s*"?(.*?)"?\s*$')
# Marker that ends the metadata block — everything after is raw sample data.
_WAVEFORM_BLOCK_MARKER = "waveform data channels"
def _normalize_key(raw: str) -> str:
"""Convert "TranPPV" / "PreTriggerLength" → snake_case."""
s = raw.strip()
# Insert underscore between lower→upper / digit→letter transitions
s = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", s)
s = re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", "_", s)
s = s.replace("-", "_").replace(" ", "_")
return s.lower()
def _strip_unit_suffix(value: str) -> str:
"""Return the numeric part of values like "0.2119 in/s""0.2119".
Also strips Thor's below/above-threshold prefixes:
"<0.005 in/s" "0.005" (below-noise-floor reading)
">100 Hz" "100" (above-measurement-range reading)
"""
parts = value.strip().split()
token = parts[0] if parts else value.strip()
if token.startswith("<") or token.startswith(">"):
token = token[1:]
return token
def _parse_float(value: str) -> Optional[float]:
try:
return float(_strip_unit_suffix(value))
except (ValueError, TypeError):
return None
def _parse_int(value: str) -> Optional[int]:
try:
return int(float(_strip_unit_suffix(value)))
except (ValueError, TypeError):
return None
def parse_idf_report(text: Union[str, bytes]) -> Dict[str, Any]:
"""
Parse a Thor IDFW.txt / IDFH.txt sidecar.
Returns a flat dict with two kinds of entries:
- **Raw fields** every `Key : Value` line, keyed by snake_case
of the original key, value as a string (unit suffix preserved).
Lets callers grab any field we haven't explicitly normalised.
- **Derived fields** a curated set with parsed types:
* `serial_number` str
* `event_type` str ("Full Waveform" / "Full Histogram")
* `event_datetime` ISO-8601 string ("YYYY-MM-DDTHH:MM:SS") when
both EventDate and EventTime are present
* `sample_rate` int (samples/sec)
* `tran_ppv`,`vert_ppv`,`long_ppv` float (in/s)
* `mic_ppv` float (dB or psi same units as MicPSPL)
* `peak_vector_sum` float (in/s)
* `tran_zc_freq`,`vert_zc_freq`,`long_zc_freq` float (Hz)
* `record_time_sec` float (seconds)
* `pre_trigger_sec` float (seconds)
* `project` str (from TitleString1 Thor's location)
* `client` str (TitleString2)
* `operator` str (TitleString3 company/operator)
* `notes` str (TitleString4)
* `setup` str
* `version` str (firmware)
* `battery_volts` float
* `calibration_text` str (e.g. "November 22, 2023 by Instantel")
* `tran_test_passed`, `vert_test_passed`, `long_test_passed`,
`mic_test_passed` bool ("Passed" True; anything else False)
* `filename` str (FileName line useful sanity check)
Stops parsing at the literal "Waveform Data Channels" line; the
raw-samples block is left to whoever wants to decode the binary.
Input may be `str` or `bytes` (`utf-8`/`latin-1` tolerant).
"""
if isinstance(text, bytes):
try:
text = text.decode("utf-8")
except UnicodeDecodeError:
text = text.decode("latin-1", errors="replace")
raw: Dict[str, str] = {}
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.lower().startswith(_WAVEFORM_BLOCK_MARKER):
break
m = _LINE_RE.match(stripped)
if not m:
continue
key = _normalize_key(m.group(1))
value = m.group(2).strip()
# Multi-value lines (Channel, Units, etc.) — coalesce by appending.
if key in raw:
raw[key] = raw[key] + "; " + value
else:
raw[key] = value
out: Dict[str, Any] = dict(raw) # keep all raw fields
# ── Derived fields ───────────────────────────────────────────────────────
def _take(*candidates: str) -> Optional[str]:
for c in candidates:
if c in raw:
return raw[c]
return None
# Event identity
if "serial_number" in raw:
out["serial_number"] = raw["serial_number"]
if "event_type" in raw:
out["event_type"] = raw["event_type"]
if "file_name" in raw:
out["filename"] = raw["file_name"]
# Combined date+time. Waveform sidecars use "EventDate" / "EventTime";
# histogram sidecars use "HistogramStartDate" / "HistogramStartTime".
# Prefer the event_* names when both are present.
ed = raw.get("event_date") or raw.get("histogram_start_date")
et = raw.get("event_time") or raw.get("histogram_start_time")
if ed and et:
try:
dt = datetime.datetime.strptime(f"{ed} {et}", "%Y-%m-%d %H:%M:%S")
out["event_datetime"] = dt.isoformat()
except ValueError:
pass
# Numeric scalars. For every field we typify here, we MUST drop the
# raw string copy from `out` when parsing fails — Thor writes things
# like "<0.005 in/s" (below threshold) and "N/A" (not measured) that
# would otherwise linger in `out` as strings, sneak into SQLite REAL
# columns via permissive type affinity, and then crash the JS
# frontend on `.toFixed(...)`.
int_fields = ("sample_rate",)
for key in int_fields:
v = raw.get(key)
if v is None:
continue
iv = _parse_int(v)
if iv is not None:
out[key] = iv
else:
out.pop(key, None)
float_fields = (
"tran_ppv", "vert_ppv", "long_ppv", "peak_vector_sum",
"tran_zc_freq", "vert_zc_freq", "long_zc_freq",
"tran_peak_acceleration", "vert_peak_acceleration",
"long_peak_acceleration",
"tran_peak_displacement", "vert_peak_displacement",
"long_peak_displacement",
"tran_time_of_peak", "vert_time_of_peak", "long_time_of_peak",
"mic_time_of_peak", "mic_zc_freq",
)
for key in float_fields:
v = raw.get(key)
if v is None:
continue
fv = _parse_float(v)
if fv is not None:
out[key] = fv
else:
out.pop(key, None)
# Microphone — Thor reports MicPSPL (dB(L)) which is the closest
# analogue to BW's mic_ppv. The raw "99.4 dB(L)" string stays in
# `out` under the original `mic_pspl` key for display; the parsed
# float goes in `mic_ppv`.
mic = raw.get("mic_pspl")
if mic is not None:
fv = _parse_float(mic)
if fv is not None:
out["mic_ppv"] = fv
# Record / pre-trigger duration — same drop-on-failure discipline.
rt = raw.get("record_time")
if rt is not None:
fv = _parse_float(rt)
if fv is not None:
out["record_time_sec"] = fv
pt = raw.get("pre_trigger_length")
if pt is not None:
fv = _parse_float(pt)
if fv is not None:
out["pre_trigger_sec"] = fv
# Project / client / operator / location strings. Thor's title
# strings are operator-defined; conventional mapping (per Thor's
# default TitleNote labels in the example data):
# TitleString1 = Location → project (sensor location identifier)
# TitleString2 = Client → client
# TitleString3 = Company → operator (the monitoring company)
# TitleString4 = Notes → notes
out["project"] = _take("title_string1")
out["client"] = _take("title_string2")
out["operator"] = _take("title_string3", "operator")
out["notes"] = _take("title_string4", "post_event_note")
if "setup" in raw:
out["setup"] = raw["setup"]
if "version" in raw:
out["version"] = raw["version"]
# Battery (e.g. "3.8 volts" → 3.8)
bl = raw.get("battery_level")
if bl is not None:
fv = _parse_float(bl)
if fv is not None:
out["battery_volts"] = fv
# Calibration line is free-form (e.g. "November 22, 2023 by Instantel").
if "calibration" in raw:
out["calibration_text"] = raw["calibration"]
# Sensor self-check results — bool flags
for key, out_key in (
("tran_test_results", "tran_test_passed"),
("vert_test_results", "vert_test_passed"),
("long_test_results", "long_test_passed"),
("mic_test_results", "mic_test_passed"),
):
v = raw.get(key)
if v is not None:
out[out_key] = v.strip().lower() == "passed"
return out
def serial_from_filename(name: str) -> Optional[str]:
"""Convenience: pull the serial prefix from a Thor event filename.
Thor uses the literal serial as the filename prefix:
UM11719_20231219163444.IDFW "UM11719"
BE9439_20200713124251.IDFH "BE9439"
"""
m = re.match(r"^([A-Z]{2}\d+)_\d{14}\.(IDFH|IDFW)(?:\.txt)?$",
name, re.IGNORECASE)
return m.group(1).upper() if m else None
def parse_event_filename(name: str) -> Optional[Tuple[str, datetime.datetime, str]]:
"""Parse `<SERIAL>_<YYYYMMDDHHMMSS>.<KIND>` → (serial, datetime, kind).
`kind` is "IDFH" or "IDFW" (upper-case). Returns None on no match.
"""
m = re.match(r"^([A-Z]{2}\d+)_(\d{14})\.(IDFH|IDFW)$",
name, re.IGNORECASE)
if not m:
return None
try:
ts = datetime.datetime.strptime(m.group(2), "%Y%m%d%H%M%S")
except ValueError:
return None
return m.group(1).upper(), ts, m.group(3).upper()
+64
View File
@@ -0,0 +1,64 @@
"""
micromate/idf_file.py placeholder for the Thor IDF binary codec.
Thor's ``.IDFH`` (histogram) and ``.IDFW`` (waveform) event files are an
Instantel proprietary binary format that has not yet been reverse-
engineered. Today seismo-relay treats them as opaque blobs:
``WaveformStore.save_imported_idf`` stores the bytes verbatim and reads
all device-authoritative metadata from the paired ``.IDFW.txt`` /
``.IDFH.txt`` ASCII sidecar (parsed by ``idf_ascii_report.py``).
When we crack the binary codec same reverse-engineering playbook we
used to byte-perfect-parse Series III BW files (see
``docs/instantel_protocol_reference.md`` and ``minimateplus/event_file_io.py``)
this module will grow:
- ``read_idf_file(path) -> IdfEvent``
Parse a ``.IDFW``/``.IDFH`` binary and return a fully populated
``IdfEvent`` whose waveform-sample arrays come from the binary
(the .txt sidecar's tabular sample block being a best-effort
check). Lets us ingest Thor events even when the operator
hasn't enabled the .txt exporter — closing the
``had_report=False`` gap that the thor-watcher forwarder
currently tolerates as a known limitation.
- ``write_idf_file(path, event)`` (eventually)
Round-trip event reconstruction, used for verifying the codec
against captured device files the way ``write_blastware_file``
verifies the Series III codec.
- Helpers for decoding the binary's per-channel sample arrays into
physical units, the per-event flash buffer's monitor-log records,
etc.
The reverse-engineering path: pair every ``.IDFW`` binary in
``thor-watcher/example-data/`` with its sibling ``.IDFW.txt``, treating
the txt's "Waveform Data Channels" block as ground-truth, and align
the binary's per-channel int16-or-similar arrays against it. Header
fields (sample rate, channel count, record time, timestamps) sit before
the sample block same approach as the BW codec where ASCII strings
inside the binary (``Project:``, ``Client:``, etc.) anchored field
discovery.
"""
from __future__ import annotations
from pathlib import Path
from typing import Union
from .models import IdfEvent
def read_idf_file(path: Union[str, Path]) -> "IdfEvent":
"""Parse a Thor ``.IDFW``/``.IDFH`` binary into an ``IdfEvent``.
Not yet implemented. When implemented, this will be the canonical
entry point for reading Thor binaries the ASCII sidecar parser
becomes an optional fast-path metadata supplement rather than the
sole source of device-authoritative data.
"""
raise NotImplementedError(
"IDF binary codec not yet implemented; the .IDFW/.IDFH binary format "
"is undecoded. Use parse_idf_report() on the paired .txt sidecar "
"for device-authoritative metadata."
)
+377
View File
@@ -0,0 +1,377 @@
"""
Micromate (Series IV / Thor) native data models.
These are the right-shaped dataclasses for Thor data Thor measures
the microphone in dB(L) directly, so this model carries
``mic_pspl_dbl`` rather than the pseudo-``psi`` shoehorn that
``minimateplus.PeakValues`` uses for Series III BW data.
The ingest pipeline today goes:
.IDFW.txt parse_idf_report() dict
dict IdfEvent.from_report() IdfEvent (typed)
IdfEvent IdfEvent.to_minimateplus_event() shape DB / sidecar
machinery expects
The ``to_minimateplus_event()`` bridge is a temporary boundary when we
crack the binary IDF codec and have richer per-event data to store, the
DB schema will grow Series-IV-specific columns and the bridge will
shrink or disappear.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Tuple
# ── IdfReport ─────────────────────────────────────────────────────────────────
@dataclass
class IdfReport:
"""Typed wrapper around the dict returned by ``parse_idf_report``.
All fields optional Thor's exporter is permissive and some IDF .txt
files (especially histograms) omit fields that waveform sidecars
include. Use ``.raw`` for any field this dataclass hasn't surfaced
yet (the parser keeps every recognised key in the raw dict).
"""
# Identity / kind
serial_number: Optional[str] = None
event_type: Optional[str] = None # "Full Waveform" | "Full Histogram"
event_datetime: Optional[datetime.datetime] = None
filename: Optional[str] = None # echoed by Thor's exporter
# Sampling / timing
sample_rate: Optional[int] = None # samples/sec
record_time_sec: Optional[float] = None
pre_trigger_sec: Optional[float] = None
# Geophone peaks (in/s)
tran_ppv: Optional[float] = None
vert_ppv: Optional[float] = None
long_ppv: Optional[float] = None
peak_vector_sum: Optional[float] = None
# Microphone — Thor's native unit is dB(L), NOT psi.
mic_pspl_dbl: Optional[float] = None
# Zero-crossing frequencies (Hz)
tran_zc_freq: Optional[float] = None
vert_zc_freq: Optional[float] = None
long_zc_freq: Optional[float] = None
mic_zc_freq: Optional[float] = None
# Per-channel time of peak (sec, since event start)
tran_time_of_peak: Optional[float] = None
vert_time_of_peak: Optional[float] = None
long_time_of_peak: Optional[float] = None
mic_time_of_peak: Optional[float] = None
# Derived per-channel motion
tran_peak_acceleration: Optional[float] = None # g
vert_peak_acceleration: Optional[float] = None
long_peak_acceleration: Optional[float] = None
tran_peak_displacement: Optional[float] = None # in
vert_peak_displacement: Optional[float] = None
long_peak_displacement: Optional[float] = None
# Operator-supplied strings (Thor's TitleString1..4 → semantic slots)
project: Optional[str] = None # TitleString1
client: Optional[str] = None # TitleString2
operator: Optional[str] = None # TitleString3
notes: Optional[str] = None # TitleString4 / PostEventNote
setup: Optional[str] = None # setup file name
# Sensor self-check results
tran_test_passed: Optional[bool] = None
vert_test_passed: Optional[bool] = None
long_test_passed: Optional[bool] = None
mic_test_passed: Optional[bool] = None
# Device-fixed metadata
firmware_version: Optional[str] = None
calibration_text: Optional[str] = None
battery_volts: Optional[float] = None
# Original parser dict — preserves every recognised key (including
# raw unit-suffixed strings) for forward-compatible field access.
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "IdfReport":
"""Build an IdfReport from the dict returned by ``parse_idf_report``."""
ed = d.get("event_datetime")
if isinstance(ed, str):
try:
ed = datetime.datetime.fromisoformat(ed)
except ValueError:
ed = None
return cls(
serial_number = d.get("serial_number"),
event_type = d.get("event_type"),
event_datetime = ed if isinstance(ed, datetime.datetime) else None,
filename = d.get("filename"),
sample_rate = d.get("sample_rate"),
record_time_sec = d.get("record_time_sec"),
pre_trigger_sec = d.get("pre_trigger_sec"),
tran_ppv = d.get("tran_ppv"),
vert_ppv = d.get("vert_ppv"),
long_ppv = d.get("long_ppv"),
peak_vector_sum = d.get("peak_vector_sum"),
mic_pspl_dbl = d.get("mic_ppv"), # parser names it mic_ppv (legacy)
tran_zc_freq = d.get("tran_zc_freq"),
vert_zc_freq = d.get("vert_zc_freq"),
long_zc_freq = d.get("long_zc_freq"),
mic_zc_freq = d.get("mic_zc_freq"),
tran_time_of_peak = d.get("tran_time_of_peak"),
vert_time_of_peak = d.get("vert_time_of_peak"),
long_time_of_peak = d.get("long_time_of_peak"),
mic_time_of_peak = d.get("mic_time_of_peak"),
tran_peak_acceleration = d.get("tran_peak_acceleration"),
vert_peak_acceleration = d.get("vert_peak_acceleration"),
long_peak_acceleration = d.get("long_peak_acceleration"),
tran_peak_displacement = d.get("tran_peak_displacement"),
vert_peak_displacement = d.get("vert_peak_displacement"),
long_peak_displacement = d.get("long_peak_displacement"),
project = d.get("project"),
client = d.get("client"),
operator = d.get("operator"),
notes = d.get("notes"),
setup = d.get("setup"),
tran_test_passed = d.get("tran_test_passed"),
vert_test_passed = d.get("vert_test_passed"),
long_test_passed = d.get("long_test_passed"),
mic_test_passed = d.get("mic_test_passed"),
firmware_version = d.get("version"),
calibration_text = d.get("calibration_text"),
battery_volts = d.get("battery_volts"),
raw = d,
)
# ── IdfPeaks / IdfProjectInfo / IdfSensorCheck (narrow grouping types) ───────
@dataclass
class IdfPeaks:
"""Geophone + mic peak values for one Thor event. Native Thor units."""
transverse_ips: Optional[float] = None # in/s
vertical_ips: Optional[float] = None # in/s
longitudinal_ips: Optional[float] = None # in/s
peak_vector_sum_ips: Optional[float] = None # in/s
mic_pspl_dbl: Optional[float] = None # dB(L)
@dataclass
class IdfProjectInfo:
"""Operator-supplied strings from Thor's TitleString1..4."""
project: Optional[str] = None
client: Optional[str] = None
operator: Optional[str] = None
notes: Optional[str] = None
setup: Optional[str] = None
@dataclass
class IdfSensorCheck:
"""Per-channel pass/fail from Thor's self-test."""
tran: Optional[bool] = None
vert: Optional[bool] = None
long: Optional[bool] = None
mic: Optional[bool] = None
# ── IdfEvent ─────────────────────────────────────────────────────────────────
@dataclass
class IdfEvent:
"""A single Thor / Micromate Series IV event.
Built from a parsed .IDFW.txt or .IDFH.txt sidecar via
``IdfEvent.from_report()``. The filename is the authoritative
source for serial + timestamp + kind; the .txt provides
device-authoritative peak values, frequencies, project strings,
sensor self-check, firmware, calibration.
"""
# Identity
serial: str
timestamp: datetime.datetime
kind: str # "Waveform" | "Histogram"
filename: str # device-native binary filename, e.g. "UM11719_20231219163444.IDFW"
# Sampling / timing
sample_rate: Optional[int] = None
record_time_sec: Optional[float] = None
pre_trigger_sec: Optional[float] = None
# Peaks
peaks: IdfPeaks = field(default_factory=IdfPeaks)
# Per-channel frequencies (Hz)
tran_zc_freq: Optional[float] = None
vert_zc_freq: Optional[float] = None
long_zc_freq: Optional[float] = None
mic_zc_freq: Optional[float] = None
# Project strings
project_info: IdfProjectInfo = field(default_factory=IdfProjectInfo)
# Sensor self-check
sensor_check: IdfSensorCheck = field(default_factory=IdfSensorCheck)
# Device-fixed
firmware_version: Optional[str] = None
calibration_text: Optional[str] = None
battery_volts: Optional[float] = None
# The full parsed report — preserves anything not surfaced as a typed field
report: IdfReport = field(default_factory=IdfReport)
@classmethod
def from_report(
cls,
report: Any,
filename: str,
) -> "IdfEvent":
"""Build an IdfEvent from a parsed report (dict or IdfReport) and
the device-native binary filename.
The filename is authoritative for serial + timestamp + kind:
Thor's filenames are literal ``<SERIAL>_<YYYYMMDDHHMMSS>.<KIND>``
and the device's own clock is the canonical event timestamp.
If the report carries an ``event_datetime`` that differs from
what's in the filename, the report wins (it has finer-grained
device-reported time-of-trigger semantics).
"""
from .idf_ascii_report import parse_event_filename
# Normalise input to IdfReport
if isinstance(report, IdfReport):
rep = report
elif isinstance(report, dict):
rep = IdfReport.from_dict(report)
else:
raise TypeError(
f"report must be IdfReport or dict; got {type(report).__name__}"
)
# Filename → (serial, timestamp, kind). Required — fall back to
# report-supplied values only if filename parsing fails.
parsed = parse_event_filename(filename)
if parsed is not None:
fn_serial, fn_ts, fn_kind = parsed
kind = "Histogram" if fn_kind == "IDFH" else "Waveform"
else:
fn_serial = rep.serial_number or "UNKNOWN"
fn_ts = rep.event_datetime or datetime.datetime(1970, 1, 1)
kind = "Waveform" if (rep.event_type or "").lower().startswith("full waveform") else "Histogram"
# Prefer report's event_datetime (device-authoritative) over the filename.
ts = rep.event_datetime or fn_ts
serial = rep.serial_number or fn_serial
return cls(
serial=serial,
timestamp=ts,
kind=kind,
filename=filename,
sample_rate=rep.sample_rate,
record_time_sec=rep.record_time_sec,
pre_trigger_sec=rep.pre_trigger_sec,
peaks=IdfPeaks(
transverse_ips = rep.tran_ppv,
vertical_ips = rep.vert_ppv,
longitudinal_ips = rep.long_ppv,
peak_vector_sum_ips = rep.peak_vector_sum,
mic_pspl_dbl = rep.mic_pspl_dbl,
),
tran_zc_freq=rep.tran_zc_freq,
vert_zc_freq=rep.vert_zc_freq,
long_zc_freq=rep.long_zc_freq,
mic_zc_freq=rep.mic_zc_freq,
project_info=IdfProjectInfo(
project=rep.project,
client=rep.client,
operator=rep.operator,
notes=rep.notes,
setup=rep.setup,
),
sensor_check=IdfSensorCheck(
tran=rep.tran_test_passed,
vert=rep.vert_test_passed,
long=rep.long_test_passed,
mic=rep.mic_test_passed,
),
firmware_version=rep.firmware_version,
calibration_text=rep.calibration_text,
battery_volts=rep.battery_volts,
report=rep,
)
# ── Bridge to minimateplus shape (for the existing DB / sidecar paths) ──
def to_minimateplus_event(self, waveform_key: bytes) -> Any:
"""Project this Thor event into the shape ``minimateplus.Event``
carries, so it can flow through the existing
``SeismoDb.insert_events()`` and ``event_to_sidecar_dict()``
machinery without those code paths needing to know about Thor.
Caveats of the bridge:
- ``mic_ppv`` on the produced Event carries Thor's dB(L) value
verbatim the UI distinguishes via the ``device_family``
column (Phase 1). Don't run the BW psi→dBL converter on
Series IV rows.
- Many Thor-specific fields (Peak Acceleration / Displacement,
sensor self-check, calibration) don't have a slot in
``Event``. The full IdfReport is preserved on the
``.sfm.json`` sidecar under ``extensions.idf_report`` via
``save_imported_idf`` that's the source of truth for them.
"""
from minimateplus.models import (
Event, PeakValues, ProjectInfo, Timestamp,
)
ts_obj = Timestamp(
raw=bytes(9),
flag=0,
year=self.timestamp.year,
unknown_byte=0,
month=self.timestamp.month,
day=self.timestamp.day,
hour=self.timestamp.hour,
minute=self.timestamp.minute,
second=self.timestamp.second,
)
pv = PeakValues(
tran=self.peaks.transverse_ips,
vert=self.peaks.vertical_ips,
long=self.peaks.longitudinal_ips,
micl=self.peaks.mic_pspl_dbl, # dB(L) — see caveat above
peak_vector_sum=self.peaks.peak_vector_sum_ips,
)
pi = ProjectInfo(
setup_name=self.project_info.setup,
project=self.project_info.project,
client=self.project_info.client,
operator=self.project_info.operator,
sensor_location=None, # Thor folds location into project string
notes=self.project_info.notes,
)
ev = Event(
index=0,
timestamp=ts_obj,
sample_rate=self.sample_rate,
peak_values=pv,
project_info=pi,
record_type=self.kind,
rectime_seconds=self.record_time_sec,
)
ev._waveform_key = waveform_key
return ev
+522
View File
@@ -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_time<TAB>stop_time<TAB>description`.
- Final `"PC SW Version : ..."` line ends the metadata block.
- A blank line separates metadata from the sample table.
- Sample table starts with ` Tran <TAB> Vert <TAB>...`, 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: `<start>\t<stop>\t<desc>` 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 `<label> : <value>` lines are assigned to
# the 4 canonical operator-supplied slots by POSITION (project,
# client, operator, sensor_location) regardless of what the
# operator named the labels in BW's Compliance Setup → Notes tab.
in_user_notes_block = False
user_note_position = 0
while i < n:
raw_line = lines[i]
i += 1
# Blank line marks the start of the sample table.
if raw_line.strip() == "":
break
line = _strip_quotes(raw_line)
# Monitor log section: "Monitor Log(s)" header followed by N rows
# (still inside double-quoted lines), terminated by a non-row line
# like "PC SW Version : ..." or a blank line.
if not in_monitor_log_section and line.strip() == "Monitor Log(s)":
in_monitor_log_section = True
continue
if in_monitor_log_section:
# Heuristic: monitor rows contain a tab; the next "Field : Value"
# line ends the section.
if "\t" in line:
entry = _parse_monitor_row(line)
if entry:
report.monitor_log.append(entry)
continue
# Falls through to the field parser below; clear the flag.
in_monitor_log_section = False
# "Field : Value" — split on FIRST occurrence of " : "
idx = line.find(" : ")
if idx < 0:
continue
key = _normalise_key(line[:idx])
value = line[idx + 3 :].strip()
# ── Identity / config ────────────────────────────────────────────────
if key == "Event Type": report.event_type = value
elif key == "Serial Number": report.serial = value
elif key == "Version": report.version = value
elif key == "File Name": report.file_name = value
elif key == "Event Time": event_time_str = value
elif key == "Event Date": event_date = _parse_event_date(value)
elif key == "Trigger": report.trigger_channel = value
elif key == "Geo Trigger Level": report.geo_trigger_level_ips = _parse_number(value)
elif key == "Pre-trigger Length": report.pretrig_s = _parse_number(value)
elif key == "Record Time": report.record_time_s = _parse_number(value)
elif key == "Record Stop Mode": report.record_stop_mode = value
elif key == "Sample Rate": report.sample_rate_sps = _parse_int(value)
elif key == "Battery Level": report.battery_volts = _parse_number(value)
elif key == "Calibration":
report.calibration_date, report.calibration_by = _parse_calibration(value)
elif key == "Units":
report.units = value
# Entering the user-notes block. Next ~4 lines until
# "Geo Range :" are the operator-supplied notes.
in_user_notes_block = True
user_note_position = 0
elif key == "Geo Range":
# Exiting the user-notes block.
in_user_notes_block = False
report.geo_range_ips = _parse_number(value)
# User-notes block: assign by position (operator may have
# renamed the labels, so we don't trust them). Preserve the
# original labels in `user_note_labels` for downstream UIs
# (terra-view) that want to display them as the operator
# named them.
elif in_user_notes_block and user_note_position < len(_USER_NOTE_SLOTS):
slot = _USER_NOTE_SLOTS[user_note_position]
setattr(report, slot, value)
report.user_note_labels[slot] = key
user_note_position += 1
# ── Per-channel stats ────────────────────────────────────────────────
# All match the pattern "{Channel} <stat-name>"
elif key in (
"Tran PPV", "Vert PPV", "Long PPV",
"Tran ZC Freq", "Vert ZC Freq", "Long ZC Freq",
"Tran Time of Peak", "Vert Time of Peak", "Long Time of Peak",
"Tran Peak Acceleration", "Vert Peak Acceleration", "Long Peak Acceleration",
"Tran Peak Displacement", "Vert Peak Displacement", "Long Peak Displacement",
):
ch_name, stat = key.split(" ", 1)
cs = report.channels.setdefault(ch_name, ChannelStats())
num = _parse_number(value)
if stat == "PPV": cs.ppv_ips = num
elif stat == "ZC Freq": cs.zc_freq_hz = num
elif stat == "Time of Peak": cs.time_of_peak_s = num
elif stat == "Peak Acceleration": cs.peak_accel_g = num
elif stat == "Peak Displacement": cs.peak_disp_in = num
# ── Vector Sum ───────────────────────────────────────────────────────
elif key == "Peak Vector Sum":
report.peak_vector_sum_ips = _parse_number(value)
elif key == "Peak Vector Sum Time":
report.peak_vector_sum_time_s = _parse_number(value)
# ── Microphone block ────────────────────────────────────────────────
elif key == "Microphone":
report.mic.weighting = value
elif key == "MicL PSPL":
report.mic.pspl_dbl = _parse_number(value)
# Mirror onto the "MicL" entry in channels so callers querying
# `channels["MicL"].ppv_ips` see something — but it's dB(L), not
# in/s, so we store as-is in the MicStats and mark the channel.
elif key == "MicL Time of Peak":
report.mic.time_of_peak_s = _parse_number(value)
cs = report.channels.setdefault("MicL", ChannelStats())
cs.time_of_peak_s = report.mic.time_of_peak_s
elif key == "MicL ZC Freq":
report.mic.zc_freq_hz = _parse_number(value)
cs = report.channels.setdefault("MicL", ChannelStats())
cs.zc_freq_hz = report.mic.zc_freq_hz
# ── Sensor self-check ────────────────────────────────────────────────
elif key in (
"Tran Test Freq", "Vert Test Freq", "Long Test Freq", "MicL Test Freq",
"Tran Test Ratio", "Vert Test Ratio", "Long Test Ratio",
"MicL Test Amplitude",
"Tran Test Results", "Vert Test Results", "Long Test Results", "MicL Test Results",
):
ch_name, stat = key.split(" ", 1)
sc = report.sensor_check.setdefault(ch_name, SensorCheck())
if stat == "Test Freq": sc.test_freq_hz = _parse_number(value)
elif stat == "Test Ratio": sc.test_ratio = _parse_number(value)
elif stat == "Test Amplitude": sc.test_amplitude_mv = _parse_number(value)
elif stat == "Test Results": sc.test_results = value
# ── Trailer ─────────────────────────────────────────────────────────
elif key == "PC SW Version":
report.pc_sw_version = value
# Unknown keys are silently dropped — forward-compat for future
# BW versions that may add fields.
# Combine event date + time into a datetime
if event_date is not None and event_time_str is not None:
t = _parse_event_time(event_time_str)
if t is not None:
report.event_datetime = datetime.datetime.combine(event_date, t)
if parse_samples:
report.samples = _parse_sample_table(lines, i)
return report
def _parse_sample_table(
lines: List[str], start: int,
) -> List[Tuple[float, float, float, float]]:
"""Parse the trailing sample table.
The table starts with a header row (" Tran <TAB>...") and continues
until EOF. Each data row is a tab-separated quartet of numeric values.
"""
samples: List[Tuple[float, float, float, float]] = []
seen_header = False
for line in lines[start:]:
line = line.rstrip("\r\n")
if not line.strip():
continue
cols = [c.strip() for c in line.split("\t") if c.strip()]
if not seen_header:
# Header row contains channel names; numeric rows don't.
if any(c in ("Tran", "Vert", "Long", "MicL") for c in cols):
seen_header = True
continue
if len(cols) < 4:
continue
try:
samples.append((
float(cols[0]), float(cols[1]),
float(cols[2]), float(cols[3]),
))
except ValueError:
continue
return samples
def parse_report_file(
path: Union[str, Path], *, parse_samples: bool = False,
) -> BwAsciiReport:
"""Convenience: read a .TXT file from disk and parse it."""
return parse_report(Path(path).read_bytes(), parse_samples=parse_samples)
+298 -17
View File
@@ -26,6 +26,12 @@ from typing import Optional, Union
from .models import Event, PeakValues, ProjectInfo, Timestamp
from . import blastware_file as _bw # avoid circular reference at module load
from .bw_ascii_report import BwAsciiReport
# Reference pressure for dB(L) → psi conversion (20 µPa expressed in psi).
# Same constant as sfm/sfm_webapp.html so server-side and browser-side
# conversions agree.
_DBL_REF_PSI = 2.9e-9
log = logging.getLogger(__name__)
@@ -41,7 +47,7 @@ SIDECAR_KIND = "sfm.event"
# bumped without a `pip install` re-run — leading to confusing stale
# version stamps in sidecars. Bump this constant and CHANGELOG.md
# together at release time.
TOOL_VERSION = "0.15.0"
TOOL_VERSION = "0.16.1"
try:
# Best-effort: prefer the installed metadata when it's NEWER than the
@@ -94,6 +100,158 @@ def _peak_values_to_dict(pv: Optional[PeakValues]) -> dict:
}
def _bw_report_to_dict(report: BwAsciiReport) -> dict:
"""Project a parsed BW ASCII report into the sidecar's `bw_report` block.
All fields are rendered as plain JSON-compatible types (no datetime
objects). Channels are uniformly lowercased for stable JSON keys.
"""
def _ch(ch_name: str) -> dict:
cs = report.channels.get(ch_name)
if cs is None:
return {}
out = {
"ppv_ips": cs.ppv_ips,
"zc_freq_hz": cs.zc_freq_hz,
"time_of_peak_s": cs.time_of_peak_s,
"peak_accel_g": cs.peak_accel_g,
"peak_disp_in": cs.peak_disp_in,
}
# Drop all-None entries — keeps the JSON tidy for partial reports.
return {k: v for k, v in out.items() if v is not None}
def _sc(ch_name: str) -> dict:
sc = report.sensor_check.get(ch_name)
if sc is None:
return {}
out = {
"freq_hz": sc.test_freq_hz,
"ratio": sc.test_ratio,
"amplitude_mv": sc.test_amplitude_mv,
"result": sc.test_results,
}
return {k: v for k, v in out.items() if v is not None}
monitor_log = []
for entry in report.monitor_log:
e = {
"start": entry.start_time.isoformat() if entry.start_time else None,
"stop": entry.stop_time.isoformat() if entry.stop_time else None,
"description": entry.description,
}
monitor_log.append({k: v for k, v in e.items() if v is not None})
return {
"available": True,
"event_type": report.event_type,
"version": report.version,
"trigger": {
"channel": report.trigger_channel,
"geo_level_ips": report.geo_trigger_level_ips,
},
"recording": {
"sample_rate_sps": report.sample_rate_sps,
"record_time_s": report.record_time_s,
"pretrig_s": report.pretrig_s,
"stop_mode": report.record_stop_mode,
"geo_range_ips": report.geo_range_ips,
"units": report.units,
},
"device": {
"battery_volts": report.battery_volts,
"calibration_date": report.calibration_date.isoformat() if report.calibration_date else None,
"calibration_by": report.calibration_by,
},
"peaks": {
"tran": _ch("Tran"),
"vert": _ch("Vert"),
"long": _ch("Long"),
"vector_sum": {
"ips": report.peak_vector_sum_ips,
"time_s": report.peak_vector_sum_time_s,
},
},
"mic": {
"weighting": report.mic.weighting,
"pspl_dbl": report.mic.pspl_dbl,
"zc_freq_hz": report.mic.zc_freq_hz,
"time_of_peak_s": report.mic.time_of_peak_s,
},
"sensor_check": {
"tran": _sc("Tran"),
"vert": _sc("Vert"),
"long": _sc("Long"),
"mic": _sc("MicL"),
},
"monitor_log": monitor_log,
"pc_sw_version": report.pc_sw_version,
}
def _dbl_to_psi(pspl_dbl: float) -> float:
"""Convert dB(L) sound pressure level back to psi. Uses the same
20 µPa reference (= 2.9e-9 psi) as the webapp so server-side and
browser-side conversions agree."""
return _DBL_REF_PSI * (10.0 ** (pspl_dbl / 20.0))
def apply_report_to_event(event: Event, report: BwAsciiReport) -> None:
"""Overlay device-authoritative fields from a parsed BW ASCII report
onto an in-memory Event, IN-PLACE.
Why this exists
`read_blastware_file()` parses the BW binary and fills `Event.peak_values`
via `_peaks_from_samples()` which runs the (still-undecoded) BW body
codec assuming raw int16 LE and produces ±32K-shaped noise on every
channel. Result: peak values land in the SeismoDb event row as
~10 in/s on every event regardless of the actual signal.
When a paired BW ASCII report is available, the report carries the
device's own authoritative peak / project / sample-rate / record-time
values. This helper folds those onto the Event before it flows to
`SeismoDb.insert_events()`, so the DB columns reflect the report
rather than the broken-codec output.
Fields overlaid (only when the report supplies a non-None value):
- peak_values.tran / .vert / .long (from report.channels)
- peak_values.peak_vector_sum (from report.peak_vector_sum_ips)
- peak_values.micl (psi) (from report.mic.pspl_dbl psi)
- project_info.project / .client / .operator / .sensor_location
- sample_rate (from report.sample_rate_sps)
- rectime_seconds (from report.record_time_s)
Fields NOT touched (operator-edit / parser-output preserved):
- timestamp, raw_samples, record_type, total_samples,
pretrig_samples, _waveform_key, _a5_frames, _raw_record
- false_trigger and review state (those live on the sidecar, not on Event)
"""
if event.peak_values is None:
event.peak_values = PeakValues()
pv = event.peak_values
ch = report.channels
if (t := ch.get("Tran")) and t.ppv_ips is not None: pv.tran = t.ppv_ips
if (v := ch.get("Vert")) and v.ppv_ips is not None: pv.vert = v.ppv_ips
if (l := ch.get("Long")) and l.ppv_ips is not None: pv.long = l.ppv_ips
if report.peak_vector_sum_ips is not None:
pv.peak_vector_sum = report.peak_vector_sum_ips
if report.mic.pspl_dbl is not None and report.mic.pspl_dbl > 0:
pv.micl = _dbl_to_psi(report.mic.pspl_dbl)
if event.project_info is None:
event.project_info = ProjectInfo()
pi = event.project_info
if report.project: pi.project = report.project
if report.client: pi.client = report.client
if report.operator: pi.operator = report.operator
if report.sensor_location: pi.sensor_location = report.sensor_location
if report.sample_rate_sps:
event.sample_rate = report.sample_rate_sps
if report.record_time_s is not None:
event.rectime_seconds = report.record_time_s
def _project_info_to_dict(pi: Optional[ProjectInfo]) -> dict:
if pi is None:
return {
@@ -123,35 +281,104 @@ def event_to_sidecar_dict(
captured_at: Optional[datetime.datetime] = None,
review: Optional[dict] = None,
extensions: Optional[dict] = None,
bw_report: Optional[BwAsciiReport] = None,
) -> dict:
"""
Build a v1 sidecar dict from an Event + the surrounding metadata.
Pure helper no file I/O. Callers stitch the result into a sidecar
via `write_sidecar()` (or POST it back via the PATCH endpoint).
When *bw_report* is supplied (e.g. by the ACH-forwarded import path
where Blastware writes a per-event ASCII report alongside the binary),
its decoded fields are folded into the sidecar:
- A new top-level ``bw_report`` block carries the rich derived
per-channel stats (Peak Acceleration, Peak Displacement, ZC Freq,
Time of Peak), the Peak Vector Sum + time, the per-channel sensor
self-check results, and monitor-log timestamps.
- ``peak_values`` is overlaid from the report (the report's PPV/PVS
values are computed by the device firmware and are authoritative;
anything ``read_blastware_file()`` derived from samples is
approximate at best until the body codec is decoded).
- ``project_info`` is overlaid from the report when the report
supplies a non-empty value (the report mirrors the device's
compliance config, which is what BW shows in its event report).
- ``event.timestamp`` is overlaid from the report's Event Date +
Event Time (BW's report timestamps are second-resolution and
match the binary's footer; we prefer the report value because
the BW-binary footer timestamp can drift on some firmware).
"""
if source_kind not in {"sfm-live", "sfm-ach", "bw-import"}:
if source_kind not in {"sfm-live", "sfm-ach", "bw-import", "idf-import"}:
raise ValueError(f"unknown source_kind: {source_kind!r}")
captured_at = captured_at or datetime.datetime.utcnow()
return {
# ── Overlay event fields from the report when present ───────────────────
timestamp_iso = _ts_iso(event.timestamp)
if bw_report and bw_report.event_datetime:
timestamp_iso = bw_report.event_datetime.isoformat()
# Build peak_values, optionally overlaid from the report. The report
# stores Mic peak as PSPL (dB(L)); we convert to psi to match the
# existing peak_values.mic_psi field.
peak_dict = _peak_values_to_dict(event.peak_values)
if bw_report:
ch = bw_report.channels
if (t := ch.get("Tran")) and t.ppv_ips is not None: peak_dict["transverse"] = t.ppv_ips
if (v := ch.get("Vert")) and v.ppv_ips is not None: peak_dict["vertical"] = v.ppv_ips
if (l := ch.get("Long")) and l.ppv_ips is not None: peak_dict["longitudinal"] = l.ppv_ips
if bw_report.peak_vector_sum_ips is not None:
peak_dict["vector_sum"] = bw_report.peak_vector_sum_ips
if bw_report.mic.pspl_dbl is not None and bw_report.mic.pspl_dbl > 0:
peak_dict["mic_psi"] = _dbl_to_psi(bw_report.mic.pspl_dbl)
# Project info: overlay from report (the report mirrors the
# session-start compliance config that BW renders in event reports).
proj_dict = _project_info_to_dict(event.project_info)
if bw_report:
if bw_report.project: proj_dict["project"] = bw_report.project
if bw_report.client: proj_dict["client"] = bw_report.client
if bw_report.operator: proj_dict["operator"] = bw_report.operator
if bw_report.sensor_location: proj_dict["sensor_location"] = bw_report.sensor_location
# Event-block fields: overlay from report where available.
event_block = {
"serial": serial,
"timestamp": timestamp_iso,
"waveform_key": event._waveform_key.hex() if event._waveform_key else None,
"record_type": event.record_type,
"sample_rate": event.sample_rate,
"rectime_seconds": event.rectime_seconds,
"total_samples": event.total_samples,
"pretrig_samples": event.pretrig_samples,
}
if bw_report:
# Report values are authoritative — they're the user-configured
# values BW reads back, not STRT-derived guesses. In particular
# `event.rectime_seconds` from `read_blastware_file()` reads
# STRT[18] which is actually the `0x46` record-type marker (= 70)
# rather than the user's Record Time setting. Always overwrite.
if bw_report.sample_rate_sps:
event_block["sample_rate"] = bw_report.sample_rate_sps
if bw_report.record_time_s is not None:
event_block["rectime_seconds"] = bw_report.record_time_s
# Derive total_samples + pretrig_samples per channel from the
# report's sample_rate × times. These match the row count of
# the report's sample table (verified: event-c reports 1024 sps
# × (1.0 + 0.25) = 1280 rows).
if (sr := bw_report.sample_rate_sps) and bw_report.record_time_s is not None:
pretrig_s = abs(bw_report.pretrig_s) if bw_report.pretrig_s is not None else 0.0
event_block["total_samples"] = int(round(sr * (bw_report.record_time_s + pretrig_s)))
event_block["pretrig_samples"] = int(round(sr * pretrig_s))
out = {
"schema_version": SCHEMA_VERSION,
"kind": SIDECAR_KIND,
"event": {
"serial": serial,
"timestamp": _ts_iso(event.timestamp),
"waveform_key": event._waveform_key.hex() if event._waveform_key else None,
"record_type": event.record_type,
"sample_rate": event.sample_rate,
"rectime_seconds": event.rectime_seconds,
"total_samples": event.total_samples,
"pretrig_samples": event.pretrig_samples,
},
"peak_values": _peak_values_to_dict(event.peak_values),
"project_info": _project_info_to_dict(event.project_info),
"event": event_block,
"peak_values": peak_dict,
"project_info": proj_dict,
"blastware": {
"filename": blastware_filename,
@@ -177,6 +404,11 @@ def event_to_sidecar_dict(
"extensions": extensions or {},
}
if bw_report:
out["bw_report"] = _bw_report_to_dict(bw_report)
return out
# ── Sidecar IO ────────────────────────────────────────────────────────────────
@@ -414,6 +646,50 @@ def _peaks_from_samples(samples: dict[str, list[int]]) -> PeakValues:
)
_RECORD_TYPE_BY_EXT_SUFFIX = {
'H': 'Histogram',
'W': 'Waveform',
'M': 'Manual',
'E': 'Event',
'C': 'Combo',
}
def derive_record_type_from_filename(filename, default: str = "Waveform") -> str:
"""Derive a BW Event's record_type from its filename's extension suffix.
V10.72+ MiniMate Plus firmware encodes the event type as the LAST
character of the extension (the `T` in BW's `AB0T` scheme):
``M529LKIQ.G10H`` H ``"Histogram"``
``T350L385.VY0W`` W ``"Waveform"``
``...M`` M ``"Manual"``
``...E`` E ``"Event"``
``...C`` C ``"Combo"``
Old S338 firmware uses 3-char extensions ending in ``0`` whose
encoding is not yet known those fall through to ``default``.
Micromate Series 4 uses a different scheme entirely (observed:
``IDFH``, ``IDFW``) but the LAST-char convention (H / W) still holds
for the type code, so it works for both families.
Returns ``default`` if filename is empty, has no extension, or the
suffix char isn't a recognized type code.
"""
if not filename:
return default
try:
name = Path(filename).name
except (TypeError, ValueError):
return default
if '.' not in name:
return default
ext = name.rsplit('.', 1)[1]
if not ext:
return default
return _RECORD_TYPE_BY_EXT_SUFFIX.get(ext[-1].upper(), default)
def read_blastware_file(path: Union[str, Path]) -> Event:
"""
Parse a Blastware waveform file into an Event.
@@ -495,7 +771,12 @@ def read_blastware_file(path: Union[str, Path]) -> Event:
ev = Event(index=-1)
if strt_fields.get("waveform_key"):
ev._waveform_key = bytes.fromhex(strt_fields["waveform_key"])
ev.record_type = "Waveform"
# Derive record_type from the filename's extension suffix (H/W/M/E/C).
# When called from save_imported_bw the path here is a tmp file with a
# ".bw" suffix, so the derivation falls back to "Waveform" and the
# caller overrides ev.record_type using the original filename — see
# waveform_store.save_imported_bw.
ev.record_type = derive_record_type_from_filename(path.name)
ev.rectime_seconds = strt_fields.get("rectime_seconds")
ev.total_samples = strt_fields.get("total_samples")
ev.pretrig_samples = strt_fields.get("pretrig_samples")
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "seismo-relay"
version = "0.15.0"
version = "0.19.0"
description = "Python client and REST server for MiniMate Plus seismographs"
requires-python = ">=3.10"
dependencies = [
@@ -18,6 +18,6 @@ dependencies = [
]
[tool.setuptools.packages.find]
# Auto-discovers minimateplus/, sfm/, bridges/ as packages
# Auto-discovers minimateplus/, micromate/, sfm/, bridges/ as packages
where = ["."]
include = ["minimateplus*", "sfm*", "bridges*"]
include = ["minimateplus*", "micromate*", "sfm*", "bridges*"]
+150
View File
@@ -0,0 +1,150 @@
"""
scripts/backfill_record_type.py fix `record_type` on legacy event
rows whose value was hardcoded to "Waveform" regardless of actual type.
Why this is needed
Pre-v0.16.1 the BW file importer (`event_file_io.read_blastware_file`)
hardcoded `ev.record_type = "Waveform"` for every imported event. Fixed
in commit aac1c8e new ingests now derive the type from the Blastware
filename's extension last character (H=Histogram, W=Waveform, M=Manual,
E=Event, C=Combo) per the V10.72+ MiniMate Plus AB0T filename scheme.
Effect on a server that imported events under the old code: every
events row has `record_type = "Waveform"`, even for histograms,
manuals, etc. Visible in terra-view's event-detail modal under the
"Record Type" field. Terra-view also has a client-side workaround
that derives the type from the filename for display purposes, so
operators see the correct type in the UI even before this backfill.
This script makes the DB column match what the UI is already showing,
which matters for reporting and any downstream consumer that reads
events.record_type directly.
This script
Walks the `events` table and updates each row's `record_type` to the
derived value from its `blastware_filename`. Old S338 firmware files
(3-char extensions ending in `0`) and any unrecognized suffix get
left at the existing value (defaults to "Waveform").
Idempotent: re-running after a successful backfill finds zero rows
needing updates and exits cleanly (it always re-derives but only
writes when the value would change).
Usage
# Dry-run (default): print what would change, don't touch the DB
python -m scripts.backfill_record_type --db bridges/captures/seismo_relay.db
# Apply the backfill
python -m scripts.backfill_record_type --db bridges/captures/seismo_relay.db --apply
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from collections import Counter
from pathlib import Path
# Must stay in sync with minimateplus.event_file_io._RECORD_TYPE_BY_EXT_SUFFIX.
_TYPE_FROM_SUFFIX = {
"H": "Histogram",
"W": "Waveform",
"M": "Manual",
"E": "Event",
"C": "Combo",
}
def derive_record_type(filename: str | None, default: str = "Waveform") -> str:
"""Mirror of minimateplus.event_file_io.derive_record_type_from_filename.
Vendored here so this script runs without needing the seismo-relay
package on the Python path (useful on prod where you might be
running it via `docker exec` against a container's DB volume).
"""
if not filename:
return default
name = Path(filename).name
if "." not in name:
return default
ext = name.rsplit(".", 1)[1]
if not ext:
return default
return _TYPE_FROM_SUFFIX.get(ext[-1].upper(), default)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--db", required=True, help="Path to seismo_relay.db")
ap.add_argument("--apply", action="store_true",
help="Actually write changes (default is dry-run).")
ap.add_argument("--default", default="Waveform",
help="Fallback record_type when filename doesn't encode one. "
"Default: Waveform (matches the pre-fix bug's behavior).")
args = ap.parse_args()
db_path = Path(args.db)
if not db_path.exists():
print(f"ERROR: database not found at {db_path}", file=sys.stderr)
return 1
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("""
SELECT id, blastware_filename, record_type
FROM events
WHERE blastware_filename IS NOT NULL
AND blastware_filename != ''
""")
rows = cur.fetchall()
total = len(rows)
print(f"Scanning {total:,} event rows…")
print()
# Tally proposed changes.
transitions: Counter[tuple[str, str]] = Counter()
update_ids: list[tuple[str, str]] = []
unrecognized = 0
for row in rows:
derived = derive_record_type(row["blastware_filename"], default=args.default)
current = row["record_type"] or ""
if derived == current:
continue
transitions[(current, derived)] += 1
update_ids.append((row["id"], derived))
if not update_ids:
print("Nothing to update — all rows already match.")
conn.close()
return 0
print(f"{len(update_ids):,} row(s) need updating:")
for (old, new), count in sorted(transitions.items(), key=lambda x: -x[1]):
print(f" {count:>6,} {old!r:14s}{new!r}")
print()
if not args.apply:
print("(dry-run — re-run with --apply to write changes)")
conn.close()
return 0
print("Applying changes…")
cur.executemany(
"UPDATE events SET record_type = ? WHERE id = ?",
[(new, eid) for eid, new in update_ids],
)
conn.commit()
print(f"Done. Updated {cur.rowcount:,} row(s).")
conn.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -326,6 +326,7 @@ def main(argv=None) -> int:
}}
if ev._waveform_key else None
),
device_family="series3",
)
except Exception as exc:
log.warning("DB upsert failed for %s: %s", path.name, exc)
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Fire-and-forget Stop Monitoring loop — for wedged or constantly-triggering units.
#
# Hammers POST /device/stop_monitoring_blind in a tight loop. The endpoint
# opens TCP, dumps SESSION_RESET + a few copies of the SUB 0x97 frame, and
# closes — without ever reading an S3 response. Each TCP-won attempt is
# ~50ms of wire activity instead of the multi-frame handshake the regular
# rescue endpoint does, so windows that are too small for the full rescue
# can still land a stop-monitoring command.
#
# Usage:
# ./blind_stop.sh <host> [tcp_port]
#
# Env:
# SFM_BASE_URL Default: http://localhost:8200 (SFM direct).
# Set to http://localhost:8001/api/sfm to route through
# Terra-View's proxy.
# MAX_ATTEMPTS Default: 600
# SLEEP_S Default: 0 (no backoff — hammer it)
# MAX_TIME_S Default: 15
# CONNECT_TIMEOUT Default: 5
# REPEAT Frames per TCP session (default 3 — increases hit rate
# if the device is busy reading its own buffer).
# STOP_ON_OK Default: 1. Set to 0 to keep hammering indefinitely
# even after successful sends (every 503 means the device
# is in *another* session, every 200 means our bytes got
# through — but the device may not have processed them).
set -u
host="${1:-}"
tcp_port="${2:-9034}"
if [[ -z "$host" ]]; then
echo "usage: $0 <host> [tcp_port]" >&2
exit 2
fi
base="${SFM_BASE_URL:-http://localhost:8200}"
max_attempts="${MAX_ATTEMPTS:-600}"
sleep_s="${SLEEP_S:-0}"
max_time_s="${MAX_TIME_S:-15}"
connect_timeout="${CONNECT_TIMEOUT:-5}"
repeat="${REPEAT:-3}"
stop_on_ok="${STOP_ON_OK:-1}"
url="${base}/device/stop_monitoring_blind?host=${host}&tcp_port=${tcp_port}&connect_timeout=${connect_timeout}&repeat=${repeat}"
echo "blind_stop: target ${host}:${tcp_port} connect_timeout=${connect_timeout}s repeat=${repeat}"
echo "blind_stop: POST ${url}"
echo "blind_stop: up to ${max_attempts} attempts, ${sleep_s}s between, ${max_time_s}s per request"
echo "blind_stop: stop_on_ok=${stop_on_ok}"
echo
ok_count=0
busy_count=0
err_count=0
started=$(date +%s)
for ((i=1; i<=max_attempts; i++)); do
printf "[%4d] %s " "$i" "$(date +%H:%M:%S)"
http_code=$(curl -sS -o /tmp/blind_resp.$$ -w "%{http_code}" \
--max-time "$max_time_s" \
-X POST "$url" || echo "000")
body=$(cat /tmp/blind_resp.$$ 2>/dev/null || true)
rm -f /tmp/blind_resp.$$
case "$http_code" in
200|201)
ok_count=$((ok_count + 1))
echo "SENT $body"
if [[ "$stop_on_ok" == "1" ]]; then
elapsed=$(( $(date +%s) - started ))
echo
echo "blind_stop: success after ${i} attempts (${elapsed}s). ok=${ok_count} busy=${busy_count} err=${err_count}"
echo "blind_stop: NEXT — wait ~10s, then try the full rescue:"
echo " /home/serversdown/seismo-relay/scripts/rescue_device.sh ${host} ${tcp_port}"
exit 0
fi
;;
503)
busy_count=$((busy_count + 1))
echo "busy (503)"
;;
000)
err_count=$((err_count + 1))
echo "curl error"
;;
*)
err_count=$((err_count + 1))
echo "HTTP $http_code $body" | head -c 400
echo
;;
esac
[[ "$sleep_s" != "0" ]] && sleep "$sleep_s"
done
elapsed=$(( $(date +%s) - started ))
echo
echo "blind_stop: gave up after ${max_attempts} attempts (${elapsed}s). ok=${ok_count} busy=${busy_count} err=${err_count}" >&2
exit 1
+151
View File
@@ -0,0 +1,151 @@
"""
scripts/repair_unknown_serials.py re-attribute events stuck under
`serial = 'UNKNOWN'` to their correct serial by decoding the BW filename.
Why this is needed
The /db/import/blastware_file endpoint had a bug (fixed in commit a032fa5+1
on the ach-report-ingestion branch) where every forwarded event was inserted
with serial='UNKNOWN' because the endpoint's `_serial_from_event(ev)` stub
returned None and never consulted the BW-filename serial that
`WaveformStore.save_imported_bw()` had already decoded.
Effect on a server that ran a buggy version: every forwarded event's
SeismoDb row has `serial='UNKNOWN'`, even though the on-disk waveform
store has correctly bucketed the files into `BE<NNNN>/` folders. So
the BW binaries / sidecars / HDF5s are fine, but `/db/units` and
`/db/events?serial=...` queries don't surface the events.
This script
Walks the events table looking for rows with `serial='UNKNOWN'` and
re-attributes each one to the serial decoded from its
`blastware_filename` column. If the row's serial would collide with
an existing row (already-correct duplicate from a later re-forward),
the UNKNOWN row is deleted. Otherwise the row's `serial` column is
updated in-place.
Idempotent: re-running after a successful repair finds zero matching
rows and exits cleanly.
Usage
# Dry-run (default): print what would change, don't touch the DB
python -m scripts.repair_unknown_serials --db bridges/captures/seismo_relay.db
# Apply the repair
python -m scripts.repair_unknown_serials --db bridges/captures/seismo_relay.db --apply
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from pathlib import Path
# Reach into sfm.waveform_store for the serial decoder. This script
# is run from the repo root via `python -m scripts.repair_unknown_serials`.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sfm.waveform_store import _serial_from_bw_filename
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
description="Re-attribute events stuck under serial='UNKNOWN'.",
)
p.add_argument(
"--db", required=True, type=Path,
help="Path to seismo_relay.db (e.g. bridges/captures/seismo_relay.db)",
)
p.add_argument(
"--apply", action="store_true",
help="Apply the repair. Without this flag the script runs in "
"dry-run mode and only reports what would change.",
)
args = p.parse_args(argv)
if not args.db.exists():
print(f"DB not found: {args.db}", file=sys.stderr)
return 2
conn = sqlite3.connect(str(args.db))
conn.row_factory = sqlite3.Row
rows = list(conn.execute(
"SELECT id, serial, timestamp, blastware_filename "
" FROM events "
" WHERE serial = 'UNKNOWN' "
" ORDER BY timestamp",
))
print(f"Found {len(rows)} UNKNOWN-serial rows in events table.")
if not rows:
return 0
updated = 0
deleted = 0
unresolved = 0
by_serial: dict[str, int] = {}
for row in rows:
rid = row["id"]
ts = row["timestamp"]
bw_name = row["blastware_filename"]
new_serial = _serial_from_bw_filename(bw_name) if bw_name else None
if not new_serial:
print(f" ⚠ id={rid[:8]} ts={ts} filename={bw_name!r}"
f"cannot decode serial from filename; skipping")
unresolved += 1
continue
# Check for an existing row at the target (serial, timestamp).
existing = conn.execute(
"SELECT id FROM events WHERE serial = ? AND timestamp = ?",
(new_serial, ts),
).fetchone()
action: str
if existing is None:
# Safe to UPDATE in place.
if args.apply:
conn.execute(
"UPDATE events SET serial = ? WHERE id = ?",
(new_serial, rid),
)
action = "UPDATE"
updated += 1
else:
# A correctly-attributed row already exists. Drop the
# UNKNOWN duplicate.
if args.apply:
conn.execute("DELETE FROM events WHERE id = ?", (rid,))
action = "DELETE (dup)"
deleted += 1
by_serial[new_serial] = by_serial.get(new_serial, 0) + 1
print(f" {action:14s} id={rid[:8]} ts={ts} "
f"filename={bw_name}{new_serial}")
if args.apply:
conn.commit()
conn.close()
print()
print(f"Summary:")
print(f" UNKNOWN rows scanned: {len(rows)}")
print(f" Updated to real serial: {updated}")
print(f" Deleted (duplicate of an ")
print(f" already-correct row): {deleted}")
print(f" Unresolved (bad filename): {unresolved}")
print()
if by_serial:
print(f"Per-serial breakdown of repaired rows:")
for serial, count in sorted(by_serial.items()):
print(f" {serial:12s} {count}")
if not args.apply:
print()
print("(dry-run — re-run with --apply to commit)")
return 0
if __name__ == "__main__":
sys.exit(main())
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Rescue an uncooperative MiniMate that's busy with another ACH session.
#
# Hammers POST /device/rescue in a tight loop with a short timeout. When the
# device is in an ACH session our SYN either gets refused or silently dropped
# (5s connect timeout inside the endpoint) and we retry immediately. When the
# device is between sessions, our TCP wins, the endpoint disables Auto Call
# Home and erases events inside the same session, then returns success.
#
# Usage:
# ./rescue_device.sh <host> [tcp_port] [--no-erase] [--no-disable-ach]
#
# Examples:
# ./rescue_device.sh 166.246.130.1 9034
# ./rescue_device.sh 166.246.130.1 9034 --no-erase # just silence it
#
# Environment:
# SFM_BASE_URL Defaults to http://localhost:8200 (SFM direct).
# Set to http://localhost:8001/api/sfm to route through
# Terra-View's proxy. Direct mode avoids the proxy's
# 60s timeout, which matters for long-running endpoints.
# MAX_ATTEMPTS Cap on retries (default 600 ≈ 30+ min).
# SLEEP_S Backoff between attempts (default 1).
# MAX_TIME_S Per-request timeout (default 60).
# CONNECT_TIMEOUT TCP connect timeout (default 5).
# RECV_TIMEOUT Per-frame S3 recv timeout (default 5). If POLL or any
# subsequent frame doesn't respond within this window, the
# rescue endpoint bails and this script retries.
set -u
host="${1:-}"
tcp_port="${2:-9034}"
shift 2 2>/dev/null || shift $# 2>/dev/null
if [[ -z "$host" ]]; then
echo "usage: $0 <host> [tcp_port] [--no-erase] [--no-disable-ach]" >&2
exit 2
fi
disable_ach="true"
erase="true"
for arg in "$@"; do
case "$arg" in
--no-erase) erase="false" ;;
--no-disable-ach) disable_ach="false" ;;
*) echo "unknown flag: $arg" >&2; exit 2 ;;
esac
done
base="${SFM_BASE_URL:-http://localhost:8200}"
max_attempts="${MAX_ATTEMPTS:-600}"
sleep_s="${SLEEP_S:-1}"
max_time_s="${MAX_TIME_S:-60}"
connect_timeout="${CONNECT_TIMEOUT:-5}"
recv_timeout="${RECV_TIMEOUT:-5}"
url="${base}/device/rescue?host=${host}&tcp_port=${tcp_port}&disable_ach=${disable_ach}&erase=${erase}&connect_timeout=${connect_timeout}&recv_timeout=${recv_timeout}"
echo "rescue: target ${host}:${tcp_port} disable_ach=${disable_ach} erase=${erase}"
echo "rescue: connect_timeout=${connect_timeout}s recv_timeout=${recv_timeout}s"
echo "rescue: POST ${url}"
echo "rescue: up to ${max_attempts} attempts, ${sleep_s}s between, ${max_time_s}s per request"
echo
started=$(date +%s)
for ((i=1; i<=max_attempts; i++)); do
printf "[%3d] %s " "$i" "$(date +%H:%M:%S)"
http_code=$(curl -sS -o /tmp/rescue_resp.$$ -w "%{http_code}" \
--max-time "$max_time_s" \
-X POST "$url" || echo "000")
body=$(cat /tmp/rescue_resp.$$ 2>/dev/null || true)
rm -f /tmp/rescue_resp.$$
case "$http_code" in
200|201)
elapsed=$(( $(date +%s) - started ))
echo "OK (${elapsed}s total)"
echo "$body"
exit 0
;;
503)
# Connection refused / timeout — device busy in another session. Retry fast.
echo "busy (503)"
;;
000)
echo "curl error (network)"
;;
*)
echo "HTTP $http_code"
echo " $body" | head -c 400
echo
;;
esac
sleep "$sleep_s"
done
echo "rescue: gave up after ${max_attempts} attempts" >&2
exit 1
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Hold a single TCP session open and drip stop-monitoring frames at a slow
# rate, so the device's UART RX FIFO has time to drain between sends.
#
# Use when high-rate spam isn't landing — typically because the device's
# firmware is too busy to drain its serial buffer fast enough and bytes
# are being lost to UART overrun.
#
# Usage:
# ./slow_drip.sh <host> [tcp_port] [duration_s]
#
# Env:
# DURATION Default: 120 (seconds; arg 3 overrides). Clamped 1..600.
# INTERVAL Seconds between drip sends (default 3). Lower = more
# aggressive, more risk of FIFO overrun. Higher = safer
# but fewer total drips per duration.
# CONNECT_TIMEOUT Default: 5
# SFM_BASE_URL Default: http://localhost:8200 (SFM direct).
set -u
host="${1:-}"
tcp_port="${2:-9034}"
duration="${3:-${DURATION:-120}}"
if [[ -z "$host" ]]; then
echo "usage: $0 <host> [tcp_port] [duration_s]" >&2
exit 2
fi
base="${SFM_BASE_URL:-http://localhost:8200}"
interval="${INTERVAL:-3}"
connect_timeout="${CONNECT_TIMEOUT:-5}"
url="${base}/device/stop_monitoring_slow_drip?host=${host}&tcp_port=${tcp_port}&duration_s=${duration}&interval_s=${interval}&connect_timeout=${connect_timeout}"
echo "slow_drip: target ${host}:${tcp_port} duration=${duration}s interval=${interval}s connect_timeout=${connect_timeout}s"
echo "slow_drip: POST ${url}"
echo
# Give curl enough slack to wait out the duration plus a buffer
max_time=$(awk -v d="$duration" 'BEGIN { printf "%d", d + 30 }')
curl -sS --max-time "$max_time" -X POST "$url"
echo
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Hammer a device with blind stop-monitoring sessions as fast as possible.
# Single HTTP call kicks off the burst inside SFM (no per-attempt HTTP
# overhead). Default: 10 seconds, ~500 ms per attempt = ~20 attempts/sec.
#
# Usage:
# ./spam_stop.sh <host> [tcp_port] [duration_s]
#
# Examples:
# ./spam_stop.sh 166.246.130.1 # 10s burst
# ./spam_stop.sh 166.246.130.1 9034 30 # 30s burst
# DURATION=60 CONNECT_TIMEOUT=0.2 ./spam_stop.sh 166.246.130.1
#
# Env:
# SFM_BASE_URL Default: http://localhost:8200 (SFM direct).
# Set to http://localhost:8001/api/sfm to route through
# Terra-View's proxy — but note the proxy has a 60s
# timeout, so long bursts need direct mode.
# DURATION Default: 10 (seconds; arg 3 overrides)
# CONNECT_TIMEOUT Default: 0.5 (seconds)
# REPEAT Default: 3 (stop frames per TCP session)
set -u
host="${1:-}"
tcp_port="${2:-9034}"
duration="${3:-${DURATION:-10}}"
if [[ -z "$host" ]]; then
echo "usage: $0 <host> [tcp_port] [duration_s]" >&2
exit 2
fi
base="${SFM_BASE_URL:-http://localhost:8200}"
connect_timeout="${CONNECT_TIMEOUT:-0.5}"
repeat="${REPEAT:-3}"
url="${base}/device/stop_monitoring_spam?host=${host}&tcp_port=${tcp_port}&duration_s=${duration}&connect_timeout=${connect_timeout}&repeat=${repeat}"
echo "spam_stop: target ${host}:${tcp_port} duration=${duration}s connect_timeout=${connect_timeout}s repeat=${repeat}"
echo "spam_stop: POST ${url}"
echo
# Give curl enough slack to wait out the duration plus a buffer
max_time=$(awk -v d="$duration" 'BEGIN { printf "%d", d + 10 }')
curl -sS --max-time "$max_time" -X POST "$url"
echo
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Passive monitor for a misbehaving unit. Every INTERVAL seconds, attempts
# a single short TCP probe + storage_range read and logs the result. Designed
# to run unattended for hours/days and tell you when the unit comes back.
#
# Usage:
# ./watch_unit.sh <host> [tcp_port]
#
# Env:
# INTERVAL Seconds between checks (default 300 = 5 min)
# LOG_FILE Append results here (default /tmp/watch_<host>.log)
# SFM_BASE_URL Default: http://localhost:8200
set -u
host="${1:-}"
tcp_port="${2:-9034}"
if [[ -z "$host" ]]; then
echo "usage: $0 <host> [tcp_port]" >&2
exit 2
fi
interval="${INTERVAL:-300}"
log_file="${LOG_FILE:-/tmp/watch_${host}.log}"
base="${SFM_BASE_URL:-http://localhost:8200}"
url="${base}/device/events/storage_range?host=${host}&tcp_port=${tcp_port}"
echo "watch_unit: target ${host}:${tcp_port} interval=${interval}s log=${log_file}"
echo "watch_unit: Ctrl-C to stop"
while true; do
ts=$(date '+%Y-%m-%d %H:%M:%S')
http_code=$(curl -sS -o /tmp/watch_resp.$$ -w "%{http_code}" \
--max-time 20 "$url" || echo "000")
body=$(cat /tmp/watch_resp.$$ 2>/dev/null || true)
rm -f /tmp/watch_resp.$$
case "$http_code" in
200|201)
# Strip the raw_hex for readability
summary=$(echo "$body" | sed 's/"raw_hex":"[^"]*",*//; s/,*$//' | head -c 200)
echo "$ts REACHABLE $summary" | tee -a "$log_file"
;;
502|503)
err=$(echo "$body" | head -c 150)
echo "$ts ERROR_$http_code $err" | tee -a "$log_file"
;;
000)
echo "$ts CURL_FAIL (network/timeout)" | tee -a "$log_file"
;;
*)
echo "$ts HTTP_$http_code $(echo "$body" | head -c 150)" | tee -a "$log_file"
;;
esac
sleep "$interval"
done
+256 -40
View File
@@ -85,6 +85,7 @@ CREATE TABLE IF NOT EXISTS events (
blastware_filesize INTEGER, -- bytes; NULL if no event file saved
a5_pickle_filename TEXT, -- "<filename>.a5.pkl" sidecar
sidecar_filename TEXT, -- "<filename>.sfm.json" review/metadata sidecar
device_family TEXT, -- "series3" (MiniMate Plus / BW) | "series4" (Micromate / Thor) drives per-family UI rendering (units, labels)
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(serial, timestamp)
);
@@ -198,11 +199,53 @@ class SeismoDb:
("blastware_filesize", "INTEGER"),
("a5_pickle_filename", "TEXT"),
("sidecar_filename", "TEXT"),
("device_family", "TEXT"),
):
if col not in existing_cols:
log.info("_migrate: events ADD COLUMN %s %s", col, ddl)
conn.execute(f"ALTER TABLE events ADD COLUMN {col} {ddl}")
# Migration 1c: backfill device_family for existing rows by sniffing
# the device-native binary filename's extension. Thor (Micromate
# Series IV) writes `.IDFH` / `.IDFW`; MiniMate Plus (Series III)
# writes `.AB0*` / `.N00` / `.<base36>` Blastware extensions. We do
# this here rather than from sidecars so the migration is fully
# self-contained (doesn't need the waveform-store root) and runs at
# DB-init time. Only fills NULL device_family so re-runs are no-ops.
rebackfill = conn.execute(
"SELECT COUNT(*) FROM events WHERE device_family IS NULL"
).fetchone()
if rebackfill and rebackfill[0] > 0:
log.info("_migrate: backfilling device_family for %d events", rebackfill[0])
# Series IV (Thor IDF) — extension is exactly .IDFH or .IDFW
conn.execute(
"""
UPDATE events
SET device_family = 'series4'
WHERE device_family IS NULL
AND (
UPPER(blastware_filename) LIKE '%.IDFH'
OR UPPER(blastware_filename) LIKE '%.IDFW'
)
"""
)
# Everything else with a filename → Series III (Blastware family)
conn.execute(
"""
UPDATE events
SET device_family = 'series3'
WHERE device_family IS NULL
AND blastware_filename IS NOT NULL
"""
)
# Rows with no filename (e.g. older monitor_log-derived events)
# stay NULL — UI handles NULL as "unknown family".
remaining = conn.execute(
"SELECT COUNT(*) FROM events WHERE device_family IS NULL"
).fetchone()[0]
log.info("_migrate: device_family backfill complete (remaining NULL=%d)",
remaining)
# Migration 2: change monitor_log UNIQUE from (serial, waveform_key) to
# (serial, start_time) — same reasoning as events.
row = conn.execute(
@@ -302,6 +345,7 @@ class SeismoDb:
serial: str,
session_id: Optional[str] = None,
waveform_records: Optional[dict[str, dict]] = None,
device_family: Optional[str] = None,
) -> tuple[int, int]:
"""
Insert triggered events. Silently skips duplicates (serial+timestamp).
@@ -316,6 +360,11 @@ class SeismoDb:
(dedup hit), the matching waveform record is upserted onto the
existing row so a re-download via the live endpoint refreshes the
file metadata.
``device_family`` (optional): "series3" (MiniMate Plus / Blastware) or
"series4" (Micromate / Thor). Drives per-family UI rendering most
importantly the mic-unit convention (psi vs dB(L)). Set on every
insert and overwritten on every UPSERT so the latest writer wins.
"""
inserted = skipped = 0
wave_recs = waveform_records or {}
@@ -349,8 +398,9 @@ class SeismoDb:
project, client, operator, sensor_location,
sample_rate, record_type,
blastware_filename, blastware_filesize,
a5_pickle_filename, sidecar_filename)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
a5_pickle_filename, sidecar_filename,
device_family)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
self._new_id(), serial, key, session_id, ts,
@@ -369,33 +419,72 @@ class SeismoDb:
rec.get("filesize"),
rec.get("a5_pickle_filename"),
rec.get("sidecar_filename"),
device_family,
),
)
inserted += 1
except sqlite3.IntegrityError:
skipped += 1
# Upsert waveform fields onto the existing dedup row so a
# re-download via the live endpoint refreshes filename /
# size / sidecar without churning the rest of the row.
if rec and ts:
conn.execute(
"""
UPDATE events
SET blastware_filename = ?,
blastware_filesize = ?,
a5_pickle_filename = ?,
sidecar_filename = ?
WHERE serial = ? AND timestamp = ?
""",
(
rec.get("filename"),
rec.get("filesize"),
rec.get("a5_pickle_filename"),
rec.get("sidecar_filename"),
serial,
ts,
),
)
# UPSERT path: a row for this (serial, timestamp) already
# exists. Refresh every device-authoritative field from
# the new data so that a re-import with better data (e.g.
# a watcher re-forward where the previous attempt missed
# the paired BW ASCII report) replaces stale peaks /
# project info / sample_rate.
#
# Preserved (not in this UPDATE):
# id, waveform_key, session_id, created_at — immutable / FK
# false_trigger — operator review state
#
# Behaviour change vs prior versions: this UPDATE used
# to only refresh filename / filesize / a5_pickle /
# sidecar fields. As a result, the first insert's
# broken-codec peak values were locked in forever even
# if subsequent re-forwards arrived with correct
# report-derived values. Now every re-import lifts the
# DB row up to whatever the latest Event carries.
conn.execute(
"""
UPDATE events
SET tran_ppv = ?,
vert_ppv = ?,
long_ppv = ?,
peak_vector_sum = ?,
mic_ppv = ?,
project = ?,
client = ?,
operator = ?,
sensor_location = ?,
sample_rate = ?,
record_type = ?,
blastware_filename = ?,
blastware_filesize = ?,
a5_pickle_filename = ?,
sidecar_filename = ?,
device_family = COALESCE(?, device_family)
WHERE serial = ? AND timestamp = ?
""",
(
pv.tran if pv else None,
pv.vert if pv else None,
pv.long if pv else None,
pv.peak_vector_sum if pv else None,
pv.micl if pv else None,
pi.project if pi else None,
pi.client if pi else None,
pi.operator if pi else None,
pi.sensor_location if pi else None,
ev.sample_rate,
ev.record_type,
rec.get("filename") if rec else None,
rec.get("filesize") if rec else None,
rec.get("a5_pickle_filename") if rec else None,
rec.get("sidecar_filename") if rec else None,
device_family,
serial,
ts,
),
)
log.debug("insert_events serial=%s inserted=%d skipped=%d",
serial, inserted, skipped)
@@ -455,6 +544,75 @@ class SeismoDb:
)
return cur.rowcount > 0
def delete_event(self, event_id: str) -> Optional[dict]:
"""
Hard-delete one event row by id. Returns the deleted row (so the
caller can clean up any on-disk files referenced by it) or None
if no row matched.
"""
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM events WHERE id = ?", (event_id,),
).fetchone()
if row is None:
return None
conn.execute("DELETE FROM events WHERE id = ?", (event_id,))
return dict(row)
def delete_events_bulk(
self,
serial: Optional[str] = None,
from_dt: Optional[datetime.datetime] = None,
to_dt: Optional[datetime.datetime] = None,
false_trigger: Optional[bool] = None,
ids: Optional[list[str]] = None,
) -> list[dict]:
"""
Hard-delete events matching the given filters. Returns the list
of deleted row dicts. Refuses to delete with no filters at all
(would wipe the whole table) raises ValueError.
Filter semantics match query_events: serial / from_dt / to_dt /
false_trigger combine with AND. `ids` is an additional inclusion
list (event_id IN (...)); if supplied alongside other filters,
only rows matching all conditions are deleted.
"""
clauses: list[str] = []
params: list = []
if serial:
clauses.append("serial = ?")
params.append(serial)
if from_dt:
clauses.append("timestamp >= ?")
params.append(from_dt.isoformat())
if to_dt:
clauses.append("timestamp <= ?")
params.append(to_dt.isoformat())
if false_trigger is not None:
clauses.append("false_trigger = ?")
params.append(1 if false_trigger else 0)
if ids:
placeholders = ",".join("?" * len(ids))
clauses.append(f"id IN ({placeholders})")
params.extend(ids)
if not clauses:
raise ValueError(
"delete_events_bulk refuses to delete with no filters "
"(would wipe the entire events table)"
)
where = "WHERE " + " AND ".join(clauses)
with self._connect() as conn:
rows = conn.execute(
f"SELECT * FROM events {where}", params,
).fetchall()
if rows:
conn.execute(f"DELETE FROM events {where}", params)
return [dict(r) for r in rows]
def update_event_review(self, event_id: str, review: dict) -> bool:
"""
Sync derived index columns from a sidecar's `review` block.
@@ -564,21 +722,79 @@ class SeismoDb:
def query_units(self) -> list[dict]:
"""
Return one row per known serial with summary stats:
last_seen, total_events, total_monitor_entries.
Return one row per known serial with summary stats.
Aggregates from BOTH source tables:
- `events` populated by every ingest path
(live ACH, /db/import/blastware_file
from the series3-watcher forwarder, etc.)
- `ach_sessions` only populated by the live ACH server;
empty for events that came in via the
BW-importer route.
Earlier this method only joined on `ach_sessions`, which made
watcher-forwarded units invisible to the SFM webapp's fleet
overview even though their events were correctly populated in
`events`. Now we union the two and surface every serial that
has activity in either table.
Fields:
serial unit serial number (e.g. "BE11529")
last_seen most recent of MAX(events.timestamp)
and MAX(ach_sessions.session_time)
total_events COUNT(*) from `events` (the
authoritative count regardless of
ingest path)
total_monitor_entries from `ach_sessions`, 0 when absent
total_sessions COUNT(*) from `ach_sessions`, 0 when absent
"""
with self._connect() as conn:
rows = conn.execute(
"""
SELECT
s.serial,
MAX(s.session_time) AS last_seen,
SUM(s.events_downloaded) AS total_events,
SUM(s.monitor_entries) AS total_monitor_entries,
COUNT(*) AS total_sessions
FROM ach_sessions s
GROUP BY s.serial
ORDER BY last_seen DESC
"""
).fetchall()
return [dict(r) for r in rows]
event_stats = {
row["serial"]: row
for row in conn.execute(
"""
SELECT serial,
MAX(timestamp) AS last_event_at,
COUNT(*) AS total_events
FROM events
GROUP BY serial
""",
).fetchall()
}
session_stats = {
row["serial"]: row
for row in conn.execute(
"""
SELECT serial,
MAX(session_time) AS last_session_at,
SUM(monitor_entries) AS total_monitor_entries,
COUNT(*) AS total_sessions
FROM ach_sessions
GROUP BY serial
""",
).fetchall()
}
all_serials = set(event_stats) | set(session_stats)
units = []
for serial in all_serials:
e = event_stats.get(serial)
s = session_stats.get(serial)
last_event_at = e["last_event_at"] if e else None
last_session_at = s["last_session_at"] if s else None
# Prefer whichever timestamp is more recent
last_seen = max(
(t for t in (last_event_at, last_session_at) if t),
default=None,
)
units.append({
"serial": serial,
"last_seen": last_seen,
"total_events": e["total_events"] if e else 0,
"total_monitor_entries": s["total_monitor_entries"] if s else 0,
"total_sessions": s["total_sessions"] if s else 0,
})
# Sort by last_seen desc; serials with no timestamp at all sink to the bottom.
units.sort(key=lambda u: u.get("last_seen") or "", reverse=True)
return units
+1
View File
@@ -166,6 +166,7 @@ def main(argv: list[str] | None = None) -> int:
{ev._waveform_key.hex(): rec}
if ev._waveform_key else None
),
device_family="series3",
)
tag = "OK " if ins else ("SKIP" if sk else "OK ")
print(f" [{tag}] {path.name}{rec['filename']} "
+933 -18
View File
File diff suppressed because it is too large Load Diff
+36 -12
View File
@@ -2285,13 +2285,16 @@ let sessLoaded = false;
const _unitSerials = new Set();
function _ppvClass(v) {
if (v == null) return '';
if (v >= 2.0) return 'ppv-high';
if (v >= 0.5) return 'ppv-warn';
const n = (v == null) ? null : Number(v);
if (n == null || !isFinite(n)) return '';
if (n >= 2.0) return 'ppv-high';
if (n >= 0.5) return 'ppv-warn';
return 'ppv-ok';
}
function _ppvFmt(v) {
return v != null ? v.toFixed(5) : '—';
if (v == null) return '—';
const n = typeof v === 'number' ? v : Number(v);
return isFinite(n) ? n.toFixed(5) : String(v);
}
function _fmtTs(ts) {
if (!ts) return '—';
@@ -2386,7 +2389,14 @@ async function loadHistory() {
<td class="${_ppvClass(ev.vert_ppv)}">${_ppvFmt(ev.vert_ppv)}</td>
<td class="${_ppvClass(ev.long_ppv)}">${_ppvFmt(ev.long_ppv)}</td>
<td class="${_ppvClass(pvs)}">${_ppvFmt(pvs)}</td>
<td class="td-dim">${ev.mic_ppv != null && ev.mic_ppv > 0 ? (20 * Math.log10(ev.mic_ppv / DBL_REF)).toFixed(1) + ' dBL' : '—'}</td>
<td class="td-dim">${(() => {
const m = ev.mic_ppv == null ? null : Number(ev.mic_ppv);
if (m == null || !isFinite(m) || m <= 0) return '—';
// Series III (MiniMate Plus / BW) stores mic_ppv as psi → convert.
// Series IV (Micromate / Thor) already stores dB(L) → display direct.
if (ev.device_family === 'series4') return m.toFixed(1) + ' dBL';
return (20 * Math.log10(m / DBL_REF)).toFixed(1) + ' dBL';
})()}</td>
<td class="td-text">${ev.project ?? '—'}</td>
<td class="td-text">${ev.client ?? '—'}</td>
<td class="td-dim">${ev.record_type ?? '—'}</td>
@@ -2447,11 +2457,25 @@ function _renderSidecar(data) {
document.getElementById('sc-title').textContent = `Event — ${bw.filename || ev.waveform_key || 'unknown'}`;
const fmtPpv = v => (v == null ? '—' : Number(v).toFixed(5) + ' in/s');
const fmtPpv = v => {
if (v == null) return '—';
const n = Number(v);
return isFinite(n) ? n.toFixed(5) + ' in/s' : String(v);
};
// Map sidecar source.kind → device family (Series IV ingest path is
// "idf-import"; everything else is Series III today). The events-list
// table uses ev.device_family from the DB row, but sidecars don't carry
// that column — source.kind is the equivalent signal here.
const family = ((src.kind || '') === 'idf-import') ? 'series4' : 'series3';
const fmtMic = v => {
if (v == null || v <= 0) return '—';
const dbl = 20 * Math.log10(v / DBL_REF);
return `${dbl.toFixed(1)} dBL (${v.toExponential(2)} psi)`;
if (v == null) return '—';
const n = Number(v);
if (!isFinite(n) || n <= 0) return '—';
// Series IV (Micromate / Thor) stores mic as dB(L); Series III (BW)
// stores it as psi and we render both for cross-reference.
if (family === 'series4') return `${n.toFixed(1)} dBL`;
const dbl = 20 * Math.log10(n / DBL_REF);
return `${dbl.toFixed(1)} dBL (${n.toExponential(2)} psi)`;
};
document.getElementById('sc-f-serial').textContent = ev.serial || '—';
@@ -2746,9 +2770,9 @@ document.getElementById('api-base').value = window.location.origin;
<div class="sc-section">
<h4>Source / files</h4>
<dl class="sc-grid">
<dt>BW filename</dt> <dd id="sc-f-bw"></dd>
<dt>BW filesize</dt> <dd id="sc-f-bwsize"></dd>
<dt>BW sha256</dt> <dd id="sc-f-sha"></dd>
<dt id="sc-l-bw">Event file</dt> <dd id="sc-f-bw"></dd>
<dt id="sc-l-bwsize">File size</dt> <dd id="sc-f-bwsize"></dd>
<dt id="sc-l-sha">File sha256</dt> <dd id="sc-f-sha"></dd>
<dt>Source kind</dt> <dd id="sc-f-src"></dd>
<dt>Captured at</dt> <dd id="sc-f-cap"></dd>
</dl>
+171 -4
View File
@@ -34,7 +34,7 @@ import logging
import pickle
import shutil
from pathlib import Path
from typing import Optional
from typing import Optional, Union
from minimateplus import event_file_io
from minimateplus.blastware_file import blastware_filename, write_blastware_file
@@ -258,6 +258,7 @@ class WaveformStore:
source_path: Path,
*,
serial_hint: Optional[str] = None,
bw_report_text: Optional[Union[str, bytes]] = None,
) -> tuple[Event, dict]:
"""
Ingest a Blastware event file produced by an external tool
@@ -267,10 +268,17 @@ class WaveformStore:
Workflow:
1. Parse the bytes via event_file_io.read_blastware_file (writes
a temp file to do that, since the parser takes a path).
2. Resolve serial from BW filename (`<P><serial3>...`) or use
2. Optionally parse a paired BW ASCII event report (the .TXT
file BW writes alongside the binary). When supplied, its
decoded fields land in the sidecar's `bw_report` block AND
overlay the device-authoritative peak values into the
top-level `peak_values` block. This is the right path for
the ACH-forwarder daemon use case where Blastware's own
ACH writes both files into the watch folder.
3. Resolve serial from BW filename (`<P><serial3>...`) or use
serial_hint. Falls back to "UNKNOWN".
3. Copy the BW bytes verbatim into <root>/<serial>/<filename>.
4. Write the .sfm.json sidecar with source.kind = "bw-import"
4. Copy the BW bytes verbatim into <root>/<serial>/<filename>.
5. Write the .sfm.json sidecar with source.kind = "bw-import"
and a5_pickle_filename = None. Does NOT write a .a5.pkl
(no A5 source available; byte-for-byte regeneration not
possible the on-disk BW file IS the byte-for-byte source).
@@ -292,6 +300,47 @@ class WaveformStore:
except FileNotFoundError:
pass
# read_blastware_file derives record_type from its path arg, but
# that arg is the tmp file (suffix ".bw") — so override with the
# original filename's encoded type (H/W/M/E/C in the BW AB0T
# scheme). Without this override every BW-imported event lands
# in the DB with record_type="Waveform" regardless of the actual
# type (Histogram, Manual, etc.).
ev.record_type = event_file_io.derive_record_type_from_filename(
source_path.name
)
# Parse the BW ASCII report if one was supplied. Failures here
# are non-fatal: we still write the binary + sidecar without the
# rich derived fields.
bw_report = None
if bw_report_text is not None:
try:
from minimateplus.bw_ascii_report import parse_report
bw_report = parse_report(bw_report_text)
except Exception as exc:
log.warning(
"save_imported_bw: BW report parse failed: %s — continuing without it",
exc,
)
# If we have a report, overlay its device-authoritative fields
# (peaks, project, sample_rate, record_time) onto the Event
# BEFORE handing it to db.insert_events(). Without this overlay
# the DB row gets `peak_values` from _peaks_from_samples(), which
# runs the still-undecoded waveform codec on the BW body and
# produces ±10 in/s saturation values on every channel for every
# event. The sidecar JSON had the correct values via
# event_to_sidecar_dict(bw_report=...) but the DB columns didn't.
if bw_report is not None:
try:
event_file_io.apply_report_to_event(ev, bw_report)
except Exception as exc:
log.warning(
"save_imported_bw: failed to overlay report onto event: %s",
exc,
)
# Resolve serial. blastware_filename derives a 4-char prefix from
# the numeric serial (e.g. BE11529 → M529); we go the other way
# via the source filename if a hint wasn't given.
@@ -345,6 +394,7 @@ class WaveformStore:
source_kind="bw-import",
a5_pickle_filename=None,
review=existing_review,
bw_report=bw_report,
)
event_file_io.write_sidecar(sidecar_path, sidecar)
@@ -360,6 +410,123 @@ class WaveformStore:
"a5_pickle_filename": None,
"hdf5_filename": hdf5_filename,
"sidecar_filename": sidecar_path.name,
"serial": serial,
}
def save_imported_idf(
self,
idf_bytes: bytes,
source_path: Path,
*,
serial_hint: Optional[str] = None,
idf_report_text: Optional[Union[str, bytes]] = None,
) -> tuple[Optional["Event"], dict]:
"""
Ingest a Thor (Micromate Series IV) IDF event file (`.IDFW` or
`.IDFH`) produced by Thor's TXT exporter.
Thor binaries are stored as opaque bytes seismo-relay doesn't
yet decode the proprietary IDF binary format (codec slot lives
at ``micromate/idf_file.py``). Device-authoritative metadata
comes from the paired ``.IDFW.txt`` / ``.IDFH.txt`` sidecar
when supplied.
Workflow:
1. Parse the paired TXT report (when supplied) via
``micromate.parse_idf_report`` dict.
2. Wrap parsed dict + filename into a typed ``micromate.IdfEvent``.
3. Copy bytes verbatim into ``<root>/<serial>/<filename>``.
4. Bridge IdfEvent ``minimateplus.Event`` (for the existing
sidecar / DB insert machinery) via
``IdfEvent.to_minimateplus_event(waveform_key)``.
5. Write the ``.sfm.json`` sidecar with
``source.kind = "idf-import"`` and the full raw IDF report
under ``extensions.idf_report``.
Returns ``(event, record_dict)`` so the endpoint can both insert
into SeismoDb and surface the parsed event.
"""
from micromate import IdfEvent, parse_idf_report
# Parse the .txt sidecar (best-effort; non-fatal on failure).
report_dict: dict = {}
if idf_report_text is not None:
try:
report_dict = parse_idf_report(idf_report_text)
except Exception as exc:
log.warning(
"save_imported_idf: report parse failed: %s — continuing without it",
exc,
)
# Build the typed IdfEvent. Filename is authoritative for
# (serial, timestamp, kind); the report's event_datetime takes
# precedence over the filename timestamp inside from_report().
idf_event = IdfEvent.from_report(report_dict, source_path.name)
# Operator-supplied serial_hint wins over the binary's filename
# prefix when both are present (e.g. callers passing a known-good
# serial that overrides a misnamed export).
serial = serial_hint or idf_event.serial or "UNKNOWN"
# Filesystem write.
filename = source_path.name
bw_path = self._serial_dir(serial) / filename
bw_path.write_bytes(idf_bytes)
filesize = bw_path.stat().st_size
sha256 = event_file_io.file_sha256(bw_path)
# _waveform_key dedups (serial, timestamp) rows in the events
# table. Use the binary's sha256 (first 16 bytes) as a stable
# surrogate — every distinct binary maps to a distinct row.
waveform_key = bytes.fromhex(sha256)[:16]
# Bridge to minimateplus.Event for the existing sidecar / DB
# insert paths. See IdfEvent.to_minimateplus_event() for the
# caveats of this bridge (mic units, missing fields → sidecar).
ev = idf_event.to_minimateplus_event(waveform_key)
# Write the sidecar. Source kind "idf-import" was added to the
# allow-list in event_file_io.event_to_sidecar_dict for this.
sidecar_path = self.sidecar_path_for(serial, filename)
existing_review = None
if sidecar_path.exists():
try:
existing_review = event_file_io.read_sidecar(sidecar_path).get("review")
except Exception:
pass
sidecar = event_file_io.event_to_sidecar_dict(
ev,
serial=serial,
blastware_filename=filename,
blastware_filesize=filesize,
blastware_sha256=sha256,
source_kind="idf-import",
a5_pickle_filename=None,
review=existing_review,
)
# Stash the full parsed IDF report under extensions so downstream
# consumers can recover the rich derived fields that don't fit
# the BW-shaped event model (Peak Acceleration / Displacement,
# Time of Peak, sensor self-check, calibration, firmware).
if report_dict:
sidecar["extensions"]["idf_report"] = report_dict
event_file_io.write_sidecar(sidecar_path, sidecar)
log.info(
"WaveformStore.save_imported_idf serial=%s filename=%s filesize=%d "
"report_attached=%s",
serial, filename, filesize, bool(report_dict),
)
return ev, {
"filename": filename,
"filesize": filesize,
"sha256": sha256,
"a5_pickle_filename": None,
"hdf5_filename": None,
"sidecar_filename": sidecar_path.name,
"serial": serial,
}
def load_a5(self, serial: str, filename: str) -> Optional[list[S3Frame]]:
+407
View File
@@ -0,0 +1,407 @@
"""
test_bw_ascii_report.py parser for Blastware's per-event ASCII export.
Run:
python -m pytest tests/test_bw_ascii_report.py -q
"""
from __future__ import annotations
import datetime
import os
import sys
from pathlib import Path
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from minimateplus.bw_ascii_report import (
BwAsciiReport,
parse_report,
parse_report_file,
)
FIXTURES = Path(__file__).parent.parent / "decode-re" / "5-8-26"
def _fixture(event_name: str) -> Path:
"""Find the .TXT file inside a fixture event folder."""
for p in (FIXTURES / event_name).iterdir():
if p.suffix.lower() == ".txt":
return p
raise FileNotFoundError(f"no .TXT in {FIXTURES / event_name}")
# ── Identity / config ───────────────────────────────────────────────────────
def test_event_c_identity_and_config():
r = parse_report_file(_fixture("event-c"))
assert r.event_type == "Full Waveform"
assert r.serial == "BE11529"
assert r.file_name == "M529LK44.AB0"
assert r.event_datetime == datetime.datetime(2026, 4, 23, 15, 56, 35)
assert r.trigger_channel == "Vert"
assert r.geo_trigger_level_ips == pytest.approx(0.5)
assert r.pretrig_s == pytest.approx(-0.25)
assert r.record_time_s == pytest.approx(1.0)
assert r.record_stop_mode == "Fixed"
assert r.sample_rate_sps == 1024
assert r.battery_volts == pytest.approx(6.8)
assert r.calibration_date == datetime.date(2025, 4, 29)
assert r.calibration_by == "Instantel"
assert r.units == "in/s and dB(L)"
def test_event_c_operator_metadata():
r = parse_report_file(_fixture("event-c"))
# The "Project: : value" pattern (key has its own trailing colon)
# is handled by stripping the colon at lookup time.
assert r.project == "Test4-21-26"
assert r.client == "Test-Client1"
assert r.operator == "Brian and claude"
assert r.sensor_location == "catbed"
def test_event_c_geo_range():
r = parse_report_file(_fixture("event-c"))
assert r.geo_range_ips == pytest.approx(10.0)
# ── Per-channel derived stats ───────────────────────────────────────────────
def test_event_c_per_channel_stats():
r = parse_report_file(_fixture("event-c"))
tran = r.channels["Tran"]
assert tran.ppv_ips == pytest.approx(0.065)
assert tran.zc_freq_hz == pytest.approx(47.0)
assert tran.time_of_peak_s == pytest.approx(0.007)
assert tran.peak_accel_g == pytest.approx(0.066)
assert tran.peak_disp_in == pytest.approx(0.001)
vert = r.channels["Vert"]
assert vert.ppv_ips == pytest.approx(0.610)
assert vert.zc_freq_hz == pytest.approx(16.0)
assert vert.time_of_peak_s == pytest.approx(0.024)
assert vert.peak_accel_g == pytest.approx(0.437)
assert vert.peak_disp_in == pytest.approx(0.006)
long_ = r.channels["Long"]
assert long_.ppv_ips == pytest.approx(0.070)
assert long_.zc_freq_hz == pytest.approx(22.0)
assert long_.time_of_peak_s == pytest.approx(0.019)
assert long_.peak_accel_g == pytest.approx(0.040)
assert long_.peak_disp_in == pytest.approx(0.001)
def test_event_c_micl_stats():
r = parse_report_file(_fixture("event-c"))
# MicL specific block
assert r.mic.weighting == "Linear Weighting"
assert r.mic.pspl_dbl == pytest.approx(88.0)
assert r.mic.zc_freq_hz == pytest.approx(57.0)
assert r.mic.time_of_peak_s == pytest.approx(-0.004)
# Mirrored onto channels["MicL"] for uniform per-channel access
micl_ch = r.channels["MicL"]
assert micl_ch.zc_freq_hz == pytest.approx(57.0)
assert micl_ch.time_of_peak_s == pytest.approx(-0.004)
def test_event_c_vector_sum():
r = parse_report_file(_fixture("event-c"))
assert r.peak_vector_sum_ips == pytest.approx(0.612)
assert r.peak_vector_sum_time_s == pytest.approx(0.024)
# ── Sensor self-check ───────────────────────────────────────────────────────
def test_event_c_sensor_check_geo_channels():
r = parse_report_file(_fixture("event-c"))
for ch_name, expected_freq, expected_ratio in [
("Tran", 7.4, 3.7),
("Vert", 7.6, 3.5),
("Long", 7.5, 3.8),
]:
sc = r.sensor_check[ch_name]
assert sc.test_freq_hz == pytest.approx(expected_freq), ch_name
assert sc.test_ratio == pytest.approx(expected_ratio), ch_name
assert sc.test_results == "Passed", ch_name
# Geo channels don't have an Test Amplitude
assert sc.test_amplitude_mv is None
def test_event_c_sensor_check_micl():
r = parse_report_file(_fixture("event-c"))
sc = r.sensor_check["MicL"]
assert sc.test_freq_hz == pytest.approx(20.1)
assert sc.test_amplitude_mv == pytest.approx(533.0)
assert sc.test_results == "Passed"
# MicL doesn't have a ratio — it has amplitude instead
assert sc.test_ratio is None
# ── Monitor log + tooling ───────────────────────────────────────────────────
def test_event_c_monitor_log_and_pc_version():
r = parse_report_file(_fixture("event-c"))
assert len(r.monitor_log) == 1
e = r.monitor_log[0]
assert e.start_time == datetime.datetime(2026, 4, 23, 15, 46, 16)
assert e.stop_time == datetime.datetime(2026, 4, 23, 15, 56, 36)
assert e.description == "Event recorded."
assert r.pc_sw_version == "V 10.74"
# ── Sample table ─────────────────────────────────────────────────────────────
def test_event_c_sample_table_parsed_when_requested():
r = parse_report_file(_fixture("event-c"), parse_samples=True)
# 1 sec event @ 1024 sps + 0.25 sec pretrig = 1280 samples
assert r.samples is not None
assert len(r.samples) == 1280, f"expected 1280 samples, got {len(r.samples)}"
# First row: "0.000 \t0.005 \t0.005 \t-81.94"
t, v, l, m = r.samples[0]
assert t == pytest.approx(0.000)
assert v == pytest.approx(0.005)
assert l == pytest.approx(0.005)
assert m == pytest.approx(-81.94)
def test_event_c_sample_table_skipped_by_default():
r = parse_report_file(_fixture("event-c"))
assert r.samples is None
# ── Cross-event smoke ───────────────────────────────────────────────────────
@pytest.mark.parametrize("event_name", ["event-a", "event-b", "event-c", "event-d"])
def test_all_fixtures_parse_without_error(event_name):
"""Every fixture in the bundle must parse cleanly with the same parser."""
r = parse_report_file(_fixture(event_name))
# Common invariants: serial, event_datetime, sample rate, all four
# channels surfaced.
assert r.serial == "BE11529"
assert r.event_datetime is not None
assert r.sample_rate_sps in (1024, 2048, 4096)
for ch in ("Tran", "Vert", "Long", "MicL"):
assert ch in r.channels
assert ch in r.sensor_check
# PVS should be present and positive on triggered events
if r.peak_vector_sum_ips is not None:
assert r.peak_vector_sum_ips >= 0
# ── Edge cases / defensive parsing ──────────────────────────────────────────
def test_parse_empty_input():
r = parse_report("")
assert r.serial is None
assert r.event_datetime is None
assert all(cs.ppv_ips is None for cs in r.channels.values())
def test_parse_unknown_keys_ignored():
"""Forward-compat: future BW versions may add fields we don't recognise.
Those should be silently dropped, not raise."""
text = (
'"Serial Number : BE99999"\n'
'"Future Field That Does Not Exist : 42 widgets"\n'
'"Tran PPV : 0.123 in/s"\n'
)
r = parse_report(text)
assert r.serial == "BE99999"
assert r.channels["Tran"].ppv_ips == pytest.approx(0.123)
def test_parse_numeric_with_units_strips_unit():
text = (
'"Vert PPV : 1.275 in/s"\n'
'"Vert ZC Freq : 23 Hz"\n'
'"MicL Test Amplitude : 569 mv"\n'
)
r = parse_report(text)
assert r.channels["Vert"].ppv_ips == pytest.approx(1.275)
assert r.channels["Vert"].zc_freq_hz == pytest.approx(23.0)
assert r.sensor_check["MicL"].test_amplitude_mv == pytest.approx(569.0)
def test_parse_handles_micl_double_space_in_key():
"""BW writes "MicL Time of Peak" with TWO spaces; the parser must
normalise whitespace before key lookup."""
text = (
'"MicL Time of Peak : 0.012 sec"\n'
'"MicL ZC Freq : 51 Hz"\n'
)
r = parse_report(text)
assert r.mic.time_of_peak_s == pytest.approx(0.012)
assert r.mic.zc_freq_hz == pytest.approx(51.0)
# ── Position-based user-notes parsing ───────────────────────────────────────
#
# The 4 user-supplied note slots (Project / Client / User Name / Seis Loc
# by default) have OPERATOR-EDITABLE labels in BW's Compliance Setup →
# Notes tab. An operator could rename them to "Building:", "Site:",
# "Address:", etc. and the ASCII export would write those labels
# verbatim. We parse by POSITION between the `Units :` and `Geo Range :`
# anchors, NOT by matching the label text.
def _wrap_user_notes(*lines: str) -> str:
"""Helper: wrap N user-note lines in the minimal context the parser
needs (`Units :` opens the block, `Geo Range :` closes it)."""
body = ['"Units : in/s and dB(L)"']
body.extend('"' + l + '"' for l in lines)
body.append('"Geo Range : 10.000 in/s"')
return "\n".join(body) + "\n"
def test_user_notes_default_labels_populate_by_position():
"""The BW-default labels (Project / Client / User Name / Seis Loc)
populate the four canonical slots in order."""
r = parse_report(_wrap_user_notes(
"Project: : Test4-21-26",
"Client: : Acme Inc",
"User Name: : Brian",
"Seis Loc: : Catbed",
))
assert r.project == "Test4-21-26"
assert r.client == "Acme Inc"
assert r.operator == "Brian"
assert r.sensor_location == "Catbed"
assert r.user_note_labels == {
"project": "Project:",
"client": "Client:",
"operator": "User Name:",
"sensor_location": "Seis Loc:",
}
def test_user_notes_operator_renamed_labels_still_populate():
"""If the operator renames the labels in BW's UI (e.g. "Seis Loc:"
"Building:"), the values STILL populate the canonical slots by
position and the operator's labels are preserved in
`user_note_labels` for terra-view to display."""
r = parse_report(_wrap_user_notes(
"Building : Main Office",
"Project Manager : Brian",
"Inspector : Claude",
"Site Address : 123 Main St",
))
assert r.project == "Main Office"
assert r.client == "Brian"
assert r.operator == "Claude"
assert r.sensor_location == "123 Main St"
assert r.user_note_labels == {
"project": "Building",
"client": "Project Manager",
"operator": "Inspector",
"sensor_location": "Site Address",
}
def test_user_notes_with_histogram_label_spelling():
"""Histogram exports use 'Seis. Location:' (with period and colon)
instead of 'Seis Loc:'. Position-based parsing handles both."""
r = parse_report(_wrap_user_notes(
"Project: : Plum Cont.- Rainbow Run",
"Client: : Plum Contracting In.c",
"User Name: : Terra-Mechanics Inc.",
"Seis. Location: : Loc #1 - 2652 Hepner",
))
assert r.project == "Plum Cont.- Rainbow Run"
assert r.client == "Plum Contracting In.c"
assert r.operator == "Terra-Mechanics Inc."
assert r.sensor_location == "Loc #1 - 2652 Hepner"
# And the histogram's specific label spelling is preserved
assert r.user_note_labels["sensor_location"] == "Seis. Location:"
def test_user_notes_outside_block_are_ignored():
"""Lines that look like user-notes but appear OUTSIDE the
UnitsGeo Range range don't get assigned to user-note slots."""
# No Units anchor — these lines shouldn't populate user-note slots
text = (
'"Serial Number : BE11529"\n'
'"Project: : SHOULD NOT POPULATE"\n'
)
r = parse_report(text)
assert r.serial == "BE11529"
assert r.project is None
def test_user_notes_partial_block_only_fills_present_slots():
"""If BW writes fewer than 4 user-notes (e.g. operator disabled
Extended Notes mid-block), only the present positions populate;
later slots stay None."""
r = parse_report(_wrap_user_notes(
"Project: : Just-a-project",
"Client: : Just-a-client",
))
assert r.project == "Just-a-project"
assert r.client == "Just-a-client"
assert r.operator is None
assert r.sensor_location is None
def test_user_notes_extra_lines_beyond_four_are_dropped():
"""If somehow more than 4 lines appear in the user-notes block
(e.g. BW adds an Extended Notes line), only the first 4 are
captured slots 5+ have nowhere to go."""
r = parse_report(_wrap_user_notes(
"L1 : v1",
"L2 : v2",
"L3 : v3",
"L4 : v4",
"L5 : v5", # ignored — no fifth slot
))
assert r.project == "v1"
assert r.client == "v2"
assert r.operator == "v3"
assert r.sensor_location == "v4"
# 5th label not captured
assert "L5" not in r.user_note_labels.values()
def test_real_histogram_fixture_populates_sensor_location():
"""End-to-end: the histogram fixture uses 'Seis. Location:' — must
successfully populate sensor_location via position-based parsing."""
fixture_dir = (
Path(__file__).parent.parent / "example-events" / "histogram"
)
if not fixture_dir.exists():
pytest.skip("histogram fixtures not present")
txt = next(fixture_dir.glob("*_ASCII.TXT"), None)
if txt is None:
pytest.skip("no histogram TXT in fixture dir")
r = parse_report_file(txt)
assert r.sensor_location is not None
assert len(r.sensor_location) > 0
assert r.user_note_labels.get("sensor_location") is not None
# Sanity: other shared fields still parse correctly
assert r.serial is not None
assert r.serial.startswith("BE")
assert r.geo_range_ips is not None
+112
View File
@@ -294,6 +294,114 @@ def test_read_blastware_file_round_trip(tmp_path: Path):
assert parsed.peak_values.peak_vector_sum == 0.0
def test_save_imported_bw_with_paired_report(tmp_path: Path):
"""save_imported_bw + a paired BW ASCII report fold the report's
rich derived fields into the sidecar. This is the daemon-forwarded
ACH workflow: BW writes <event>.AB0 and <event>.AB0.TXT side by side;
the daemon ships both; we overlay the report-decoded values onto the
sidecar (peaks, project, plus the rich `bw_report` block)."""
from minimateplus.blastware_file import write_blastware_file, blastware_filename
from sfm.waveform_store import WaveformStore
ev, frames = _make_synthetic_event()
fname = blastware_filename(ev, "BE11529")
src = tmp_path / fname
write_blastware_file(ev, frames, src)
# Use one of the real BW ASCII exports as the paired report.
report_path = (
Path(__file__).parent.parent
/ "decode-re" / "5-8-26" / "event-c" / "M529LK44.AB0.TXT"
)
if not report_path.exists():
import pytest as _pt
_pt.skip("decode-re fixtures not present")
report_bytes = report_path.read_bytes()
store = WaveformStore(tmp_path / "waveforms")
parsed_ev, rec = store.save_imported_bw(
src.read_bytes(),
source_path=src,
bw_report_text=report_bytes,
)
sc = store.load_sidecar("BE11529", fname)
assert sc is not None
# ── bw_report block populated with the rich fields ──────────────────
assert "bw_report" in sc
br = sc["bw_report"]
assert br["available"] is True
assert br["event_type"] == "Full Waveform"
assert br["recording"]["sample_rate_sps"] == 1024
assert br["recording"]["geo_range_ips"] == 10.0
# Per-channel derived stats
assert br["peaks"]["tran"]["ppv_ips"] == 0.065
assert br["peaks"]["vert"]["ppv_ips"] == 0.610
assert br["peaks"]["long"]["ppv_ips"] == 0.070
assert br["peaks"]["vert"]["peak_accel_g"] == 0.437
assert br["peaks"]["vert"]["peak_disp_in"] == 0.006
assert br["peaks"]["tran"]["zc_freq_hz"] == 47.0
assert br["peaks"]["vector_sum"]["ips"] == 0.612
assert br["peaks"]["vector_sum"]["time_s"] == 0.024
# Sensor self-check per channel
assert br["sensor_check"]["tran"]["freq_hz"] == 7.4
assert br["sensor_check"]["tran"]["ratio"] == 3.7
assert br["sensor_check"]["tran"]["result"] == "Passed"
assert br["sensor_check"]["mic"]["amplitude_mv"] == 533.0
# Mic block
assert br["mic"]["weighting"] == "Linear Weighting"
assert br["mic"]["pspl_dbl"] == 88.0
# Monitor log roundtripped
assert len(br["monitor_log"]) == 1
assert "2026-04-23T15:46:16" in br["monitor_log"][0]["start"]
assert br["pc_sw_version"] == "V 10.74"
# ── Overlay onto canonical peak_values ──────────────────────────────
# Report values win over the broken-codec samples-derived peaks.
assert sc["peak_values"]["transverse"] == 0.065
assert sc["peak_values"]["vertical"] == 0.610
assert sc["peak_values"]["longitudinal"] == 0.070
assert sc["peak_values"]["vector_sum"] == 0.612
# Mic PSPL converted to psi (dbl=88 → 10^(88/20) * 2.9e-9)
assert sc["peak_values"]["mic_psi"] is not None
assert 1e-5 < sc["peak_values"]["mic_psi"] < 1e-3
# ── Overlay onto project_info ───────────────────────────────────────
assert sc["project_info"]["project"] == "Test4-21-26"
assert sc["project_info"]["client"] == "Test-Client1"
assert sc["project_info"]["operator"] == "Brian and claude"
assert sc["project_info"]["sensor_location"] == "catbed"
# ── Event timestamp overlaid from report ───────────────────────────
assert sc["event"]["timestamp"] == "2026-04-23T15:56:35"
def test_save_imported_bw_without_report_works_unchanged(tmp_path: Path):
"""Calling save_imported_bw with no bw_report_text behaves exactly
as before no `bw_report` block, peak_values come from samples."""
from minimateplus.blastware_file import write_blastware_file, blastware_filename
from sfm.waveform_store import WaveformStore
ev, frames = _make_synthetic_event()
fname = blastware_filename(ev, "BE11529")
src = tmp_path / fname
write_blastware_file(ev, frames, src)
store = WaveformStore(tmp_path / "waveforms")
store.save_imported_bw(src.read_bytes(), source_path=src)
sc = store.load_sidecar("BE11529", fname)
assert sc is not None
assert "bw_report" not in sc # block is absent without a report
# Synthetic event has zero samples → peaks all zero (was true before this change)
assert sc["peak_values"]["transverse"] == 0.0
def test_save_imported_bw_round_trip(tmp_path: Path):
"""save_imported_bw stores a copy + sidecar with source.kind = bw-import."""
from minimateplus.blastware_file import write_blastware_file, blastware_filename
@@ -310,6 +418,10 @@ def test_save_imported_bw_round_trip(tmp_path: Path):
assert rec["filename"] == fname
assert rec["a5_pickle_filename"] is None # no A5 source for BW imports
# The serial decoded from the BW filename surfaces on the record so
# the import endpoint can use it when calling SeismoDb.insert_events()
# (otherwise forwarded events would all bucket into serial="UNKNOWN").
assert rec["serial"] == "BE11529"
sc = store.load_sidecar("BE11529", fname)
assert sc is not None
assert sc["source"]["kind"] == "bw-import"
+234
View File
@@ -0,0 +1,234 @@
"""
test_idf_ascii_report.py parser for Thor's per-event IDF ASCII export.
Run:
python -m pytest tests/test_idf_ascii_report.py -q
Tests use real Thor sample data shipped under
`thor-watcher/example-data/THORDATA_example/`. When that path is not
available (e.g. running from a checkout where the watcher repo isn't
sibling), tests gracefully skip.
"""
from __future__ import annotations
import datetime
import os
import sys
from pathlib import Path
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from micromate.idf_ascii_report import (
parse_event_filename,
parse_idf_report,
serial_from_filename,
)
# ── Sample data ──────────────────────────────────────────────────────────────
SAMPLE_REPO = Path("/home/serversdown/thor-watcher/example-data/"
"THORDATA_example/THORDATA_example")
def _sample_path(rel: str) -> Path:
return SAMPLE_REPO / rel
@pytest.fixture
def upmc_waveform_txt() -> str:
p = _sample_path("UPMC Presby/UM11719/TXT/UM11719_20231219162723.IDFW.txt")
if not p.exists():
pytest.skip(f"sample missing: {p}")
return p.read_text()
@pytest.fixture
def upmc_histogram_txt() -> str:
p = _sample_path("UPMC Presby/UM11719/TXT/UM11719_20231219163444.IDFH.txt")
if not p.exists():
pytest.skip(f"sample missing: {p}")
return p.read_text()
# ── Filename parsing ─────────────────────────────────────────────────────────
def test_parse_event_filename_waveform():
parsed = parse_event_filename("UM11719_20231219163444.IDFW")
assert parsed is not None
serial, ts, kind = parsed
assert serial == "UM11719"
assert ts == datetime.datetime(2023, 12, 19, 16, 34, 44)
assert kind == "IDFW"
def test_parse_event_filename_histogram():
parsed = parse_event_filename("BE9439_20200713124251.IDFH")
assert parsed is not None
serial, ts, kind = parsed
assert serial == "BE9439"
assert kind == "IDFH"
def test_parse_event_filename_case_insensitive():
parsed = parse_event_filename("um11719_20231219163444.idfw")
assert parsed is not None
assert parsed[0] == "UM11719"
assert parsed[2] == "IDFW"
def test_parse_event_filename_rejects_invalid():
for name in [
"UM11719_20231219163444.MLG",
"UM11719.IDFW",
"UM11719_20231219163444.IDFW.txt", # report sidecar — not a binary
"UM11719_2023121916344X.IDFW",
"garbage",
"",
]:
assert parse_event_filename(name) is None, name
def test_serial_from_filename():
assert serial_from_filename("UM11719_20231219163444.IDFW") == "UM11719"
assert serial_from_filename("BE9439_20200713124251.IDFH") == "BE9439"
# Works on the .txt sidecar name too — handy in pairing code paths
assert serial_from_filename("UM11719_20231219163444.IDFW.txt") == "UM11719"
assert serial_from_filename("not_a_thor_file.bin") is None
# ── Report parsing — derived fields against real Thor sample ─────────────────
def test_waveform_report_derives_serial_event_type_and_datetime(upmc_waveform_txt):
r = parse_idf_report(upmc_waveform_txt)
assert r["serial_number"] == "UM11719"
assert r["event_type"] == "Full Waveform"
assert r["event_datetime"] == "2023-12-19T16:27:23"
assert r["filename"] == "UM11719_20231219162723.IDFW"
def test_waveform_report_parses_peak_velocities(upmc_waveform_txt):
r = parse_idf_report(upmc_waveform_txt)
assert r["tran_ppv"] == pytest.approx(0.0251)
assert r["vert_ppv"] == pytest.approx(0.2119)
assert r["long_ppv"] == pytest.approx(0.0282)
assert r["peak_vector_sum"] == pytest.approx(0.2131)
def test_waveform_report_parses_zc_freq_and_mic(upmc_waveform_txt):
r = parse_idf_report(upmc_waveform_txt)
assert r["tran_zc_freq"] == pytest.approx(6.5)
assert r["vert_zc_freq"] == pytest.approx(73.1)
assert r["long_zc_freq"] == pytest.approx(85.3)
assert r["mic_ppv"] == pytest.approx(99.4)
def test_waveform_report_parses_record_and_pretrigger_durations(upmc_waveform_txt):
r = parse_idf_report(upmc_waveform_txt)
assert r["record_time_sec"] == pytest.approx(2.0)
assert r["pre_trigger_sec"] == pytest.approx(0.25)
def test_waveform_report_parses_sample_rate(upmc_waveform_txt):
r = parse_idf_report(upmc_waveform_txt)
assert r["sample_rate"] == 1024
def test_waveform_report_extracts_title_strings(upmc_waveform_txt):
r = parse_idf_report(upmc_waveform_txt)
# TitleString1 (location) → project
assert r["project"] == "UPMC Presby-Loc 3-Level1-1R Elevator Rm"
# TitleString2 → client
assert r["client"] == "Whiting-Turner - PJ Dick - Joint Venture"
# TitleString3 → operator (company)
assert r["operator"] == "Terra-Mechanics, Inc. - D. Harrsion"
def test_waveform_report_extracts_setup_version_and_calibration(upmc_waveform_txt):
r = parse_idf_report(upmc_waveform_txt)
assert r["setup"] == "UPMC Loc 3.mmb"
assert r["version"] == "Micromate ISEE 11.0AK"
assert r["calibration_text"] == "November 22, 2023 by Instantel"
assert r["battery_volts"] == pytest.approx(3.8)
def test_waveform_report_decodes_sensor_self_check(upmc_waveform_txt):
r = parse_idf_report(upmc_waveform_txt)
assert r["tran_test_passed"] is True
assert r["vert_test_passed"] is True
assert r["long_test_passed"] is True
assert r["mic_test_passed"] is True
def test_histogram_report_parses(upmc_histogram_txt):
"""Histogram sidecars have the same shape as waveform — both
decode through the same parser without errors."""
r = parse_idf_report(upmc_histogram_txt)
assert r["serial_number"] == "UM11719"
# IDFH timestamp in the sample
assert r["event_datetime"] == "2023-12-19T16:34:44"
assert r["event_type"] .lower().startswith("full histogram") or \
r["event_type"] .lower().startswith("histogram")
# Sample rate present
assert "sample_rate" in r
# ── Edge cases ───────────────────────────────────────────────────────────────
def test_parses_bytes_input():
text = (
'"SerialNumber : UM11719"\n'
'"TranPPV : 0.0251 in/s"\n'
)
r = parse_idf_report(text.encode("utf-8"))
assert r["serial_number"] == "UM11719"
assert r["tran_ppv"] == pytest.approx(0.0251)
def test_parses_latin1_fallback():
"""Garbled non-UTF8 bytes fall back to latin-1 instead of crashing."""
text = b'"SerialNumber : UM11719"\n"Operator : Caf\xe9"\n'
r = parse_idf_report(text)
assert r["serial_number"] == "UM11719"
assert r["operator"] == "Café"
def test_stops_at_waveform_data_marker():
"""Lines after the 'Waveform Data Channels' marker are not parsed
as key/value pairs they're tabular sample data."""
text = (
'"SerialNumber : UM11719"\n'
'"TranPPV : 0.0251 in/s"\n'
'Waveform Data Channels\n'
' Tran Vert Long MicL\n'
' 0.0003 -0.0003 0.0003 0.00013\n'
)
r = parse_idf_report(text)
assert r["serial_number"] == "UM11719"
assert r["tran_ppv"] == pytest.approx(0.0251)
# No spurious entries from the table body
assert "tran" not in r
assert "0.0003" not in r
def test_missing_event_time_omits_datetime():
r = parse_idf_report('"SerialNumber : UM11719"\n')
assert r["serial_number"] == "UM11719"
assert "event_datetime" not in r
def test_handles_empty_input():
r = parse_idf_report("")
assert r == {
"project": None,
"client": None,
"operator": None,
"notes": None,
}