Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1a6fd5386 | ||
|
|
6b875e161b | ||
|
|
f5c81f2cab |
@@ -1,28 +0,0 @@
|
||||
.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
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/bridges/captures/
|
||||
/example-events/
|
||||
/tests/fixtures/
|
||||
|
||||
/manuals/
|
||||
|
||||
# Python build artifacts
|
||||
|
||||
-1161
File diff suppressed because it is too large
Load Diff
@@ -2,159 +2,12 @@
|
||||
|
||||
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.27.0**.
|
||||
|
||||
Stack-level context — which repo owns what, and how the three project versions
|
||||
pair — lives in `../terra-view/docs/tmi-stack.md`, which is also loaded as
|
||||
`~/CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
## Where things stand (updated 2026-08-28)
|
||||
|
||||
Read this first when picking the project back up.
|
||||
|
||||
- **Series-3 decode is verified per-sample at scale (v0.27.0).** The full DL2
|
||||
archive decodes **14,338 / 14,338** paired files exactly against their
|
||||
preserved Blastware ASCII exports — 1,249 waveform + 13,089 histogram, 45
|
||||
units, files back to 2018. That is 11x the ground truth the prod store
|
||||
carried, and it supersedes the old "per-sample on 11%, peak-only on 89%"
|
||||
caveat. Harness: `scratch/verify_against_ascii.py` (note its saturation
|
||||
carve-out — BW clamps clipped events, the decoder reports true counts).
|
||||
Independent corroboration of the 32000-count scale: 19,244 healthy
|
||||
channel-events sit at a pre-trigger floor of exactly 0.000 (62.7%), 94.5%
|
||||
within ±1 quantisation unit, median +0.0000 — no zero-point bias.
|
||||
- **Series-4 (Thor / Micromate) is NOT verified.** UM-series sits at ~48%
|
||||
against device peaks with a ~1.7% systematic bias and a near-zero tail.
|
||||
Thor IDFW is pinned to `decode_waveform_legacy` deliberately.
|
||||
- **Open, not blocking:** 14 sensitive-range files show an exact 8x
|
||||
(= 10.0/1.25) units discrepancy; `scripts/backfill_sidecars.py --force` also
|
||||
inserts DB rows for store files that have none (one-time per store) and the
|
||||
dry-run does not report that count.
|
||||
- **After any codec change, regenerate the store** — `backfill_sidecars.py`
|
||||
then `backfill_event_shape.py`, DB backup first. Stored `.h5` files do not
|
||||
update themselves. No `--force` needed as long as `TOOL_VERSION` was bumped
|
||||
(it gates regeneration). ⚠ On the office NAS this takes **~2 hours**
|
||||
(~1.5 files/sec vs 85/sec on the dev box — gzip-4 in `sfm/event_hdf5.py`
|
||||
against a Synology CPU). Budget it up front.
|
||||
**v0.27.0 does NOT owe prod a backfill** — verified: the partial-final-block
|
||||
fix changes 0 of the 10,215 histograms in the prod store (the 4 recovered
|
||||
files are archive-only and were never ingested).
|
||||
- **The "offset" hardware fault has its own journal** --
|
||||
`docs/offset_investigation.md`. **5 of 45 units (11%)**, and the fault is
|
||||
**persistent** — it stays until the geophone is serviced. Detect it with
|
||||
`scratch/offset_scan3.py`: the resting floor in the **pre-trigger** window,
|
||||
required to hold across pre/middle/end. Never score only the dominant-peak
|
||||
axis and never use the mean — both produce false recoveries (see the
|
||||
retraction banner in the journal). Instantel's autozero procedure and its
|
||||
2027-2069 acceptance window are recorded there too. Best open lead is
|
||||
`SUB 0x0E` (unimplemented), which may carry those very numbers.
|
||||
|
||||
(Sierra Wireless RV50 / RV55). Current version: **v0.12.3**.
|
||||
|
||||
When new information about the protocol is discovered, please update the instantel_protocol_reference.md with the findings in addition to this document
|
||||
|
||||
---
|
||||
|
||||
## Architecture: three-tier conceptual model
|
||||
|
||||
seismo-relay is a **suite of cooperating components**, not a single app.
|
||||
The three tiers below are the canonical mental model — the current
|
||||
directory layout doesn't fully reflect them yet (some of what is
|
||||
conceptually SDM lives under `sfm/` today), but new code should be
|
||||
placed and named according to this model.
|
||||
|
||||
### 1. SFM — the device-side (active connection to physical units)
|
||||
|
||||
Replaces Blastware's *talk-to-the-meter* role. Lives where a connection
|
||||
to a physical seismograph is open.
|
||||
|
||||
In scope:
|
||||
- `minimateplus/{transport,framing,protocol,client}.py` — wire protocol
|
||||
- `seismo_lab.py` — diagnostic GUI (a thick client for SFM)
|
||||
- The `/device/*` HTTP endpoints in `sfm/server.py` —
|
||||
`/device/info`, `/device/events`, `/device/monitor/*`, `/device/call_home`,
|
||||
etc. Anything that opens a connection at the moment of the request.
|
||||
- Future: a Thor / Micromate live client (mirror `minimateplus/`)
|
||||
- Future: a control surface Terra-View can launch into — see the
|
||||
README's Roadmap.
|
||||
|
||||
Does NOT own a database. Outputs `Event` objects. Has a "spun up when
|
||||
needed" runtime profile rather than "always on".
|
||||
|
||||
### 2. SDM — the data-side (storage, ingest, and serving)
|
||||
|
||||
The new name for the receiving-and-storing role. Originally called SFM
|
||||
because the FastAPI service started life as a thin device proxy, but
|
||||
the actual role has migrated heavily toward data management. **For now
|
||||
the directory remains `sfm/`** — renaming requires touching ~30-50
|
||||
files in seismo-relay + ~10-15 in terra-view + a Docker volume
|
||||
migration; deferred until the codebase is quiet enough to do it as a
|
||||
clean refactor.
|
||||
|
||||
In scope:
|
||||
- `sfm/database.py` (`SeismoDb`)
|
||||
- `sfm/waveform_store.py`, `sfm/event_hdf5.py`
|
||||
- The `/db/*` HTTP endpoints — `events`, `units`, `monitor_log`,
|
||||
`sessions`, `false_trigger` mutations
|
||||
- The `/db/import/*` ingest endpoints — `blastware_file` (series3),
|
||||
`idf_file` (series4); anything that receives events FROM somewhere
|
||||
- `scripts/backfill_sidecars.py`, `scripts/check_bw_report_preservation.py`,
|
||||
and similar data-maintenance tools
|
||||
- The `.sfm.json` sidecars and `.h5` files in the waveform store
|
||||
- The shape that Terra-View consumes (Terra-View should never need to
|
||||
reach into SFM/device-side endpoints to populate its UI)
|
||||
|
||||
Always-on, scaled for storage/serving, has the DB and waveform store.
|
||||
|
||||
### 3. Codec library — pure data interpretation (used by both sides)
|
||||
|
||||
Neither SFM nor SDM — a shared library both depend on.
|
||||
|
||||
In scope:
|
||||
- `minimateplus/{waveform_codec,histogram_codec,event_file_io,bw_ascii_report,blastware_file}.py`
|
||||
- `micromate/{idf_ascii_report,idf_file}.py`
|
||||
|
||||
These modules take bytes (off the wire on the SFM side, or from a
|
||||
forwarded file on the SDM side) and return `Event` objects. They
|
||||
should not import from `sfm/`, must not touch a DB, and have no I/O
|
||||
beyond reading files passed as arguments. Keep them pure — both
|
||||
tiers can then depend on them without circularity.
|
||||
|
||||
#### Thor IDF binary codec (2026-05-28)
|
||||
|
||||
`micromate/idf_file.read_idf_file()` decodes both Thor IDFW
|
||||
(waveform) and IDFH (histogram) binaries.
|
||||
|
||||
- **IDFW** reuses `decode_waveform_v2()` on the body at fixed file
|
||||
offset `0x0f1f`. Sample fidelity is 87–99% byte-exact on quiet
|
||||
events; loud events hit the BW codec's known walker-stops-early
|
||||
limitation.
|
||||
- **IDFH** has its own segment-based decoder: `[len_be][0a 00 00 00]
|
||||
[00 NN][05 3f]` + N × 72-byte interval records (4 × 16-byte
|
||||
per-channel min/max/halfp). All 859 Thor IDFH corpus files
|
||||
decode (181,071 intervals); peak matches sidecar within ~1.8%
|
||||
(ADC quantization).
|
||||
|
||||
The two outlier `BE9439_*` files in the Thor example corpus are
|
||||
actually Series III Blastware binaries that share the `.IDFW`/`.IDFH`
|
||||
filename convention by accident. `read_idf_file()` detects them by
|
||||
their BW STRT signature and raises NotImplementedError pointing
|
||||
callers at `read_blastware_file()`. See
|
||||
`docs/idf_protocol_reference.md` for full field layouts.
|
||||
|
||||
### Practical consequences
|
||||
|
||||
When deciding where new code goes, ask:
|
||||
- *Does it need a connection to a device?* → SFM
|
||||
- *Does it operate on stored events / sidecars / DB rows?* → SDM
|
||||
- *Does it interpret bytes into structured data, with no I/O of its own?* → codec lib
|
||||
|
||||
Terra-View is downstream of SDM for data, and (per the roadmap) will
|
||||
eventually invoke into SFM's device-control endpoints to provide a
|
||||
"connect to unit" experience.
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
@@ -164,8 +17,6 @@ minimateplus/ ← Python client library (primary focus)
|
||||
protocol.py ← MiniMateProtocol — wire-level read/write methods
|
||||
client.py ← MiniMateClient — high-level API (connect, get_events, …)
|
||||
models.py ← DeviceInfo, EventRecord, ComplianceConfig, …
|
||||
waveform_codec.py ← Body-codec block walker + decode_tran_initial (partial
|
||||
per-sample decoder — see "Waveform body codec" section below)
|
||||
|
||||
sfm/server.py ← FastAPI REST server exposing device data over HTTP
|
||||
seismo_lab.py ← Tkinter GUI (Bridge + Analyzer + Console tabs)
|
||||
@@ -176,7 +27,7 @@ CHANGELOG.md ← version history
|
||||
|
||||
---
|
||||
|
||||
## Current implementation state (v0.14.3)
|
||||
## Current implementation state (v0.12.3)
|
||||
|
||||
Full read pipeline + write pipeline + erase pipeline + monitor log + call home config working end-to-end over TCP/cellular:
|
||||
|
||||
@@ -190,15 +41,14 @@ Full read pipeline + write pipeline + erase pipeline + monitor log + call home c
|
||||
| Event header / first key | 1E | ✅ |
|
||||
| Waveform header | 0A | ✅ |
|
||||
| Waveform record (peaks, timestamp, project) | 0C | ✅ |
|
||||
| **Bulk waveform stream (event-time metadata + full waveform)** | **5A** | ✅ **byte-perfect against BW captures (v0.14.3, 2026-05-05)** — STRT-bounded chunk walk + correct event-N probe counter + DLE-stuffed `0x10` bytes in params + concatenate-only file body assembly. All 17 5A request frames in the 5-1-26 3-sec capture reproduce byte-for-byte. |
|
||||
| **Bulk waveform stream (event-time metadata)** | **5A** | ✅ new v0.6.0 |
|
||||
| Event advance / next key | 1F | ✅ |
|
||||
| **Write commands (push config to device)** | **68–83** | ✅ new v0.8.0 |
|
||||
| **Erase all events** | **0xA3 → 0x1C → 0x06 → 0xA2** | ✅ new v0.9.0 |
|
||||
| **Monitor log entries (partial 0x2C records)** | **0A browse** | ✅ new v0.10.0 |
|
||||
| **Auto Call Home config (read + write)** | **2C → 7E → 7F** | ✅ **new v0.12.3** |
|
||||
|
||||
`get_events()` sequence per event: `1E → 0A → 1E(arm token=0xFE) → 0C → 1F(arm) → POLL×3 → 5A → 1F(browse)`
|
||||
(see "Correct iteration pattern" section below for full detail)
|
||||
`get_events()` sequence per event: `1E → 0A → 0C → 5A → 1F`
|
||||
|
||||
`push_config_raw()` write sequence: `68→73 | 71×3→72 | 82→83 | 69→74→72`
|
||||
|
||||
@@ -206,269 +56,6 @@ Full read pipeline + write pipeline + erase pipeline + monitor log + call home c
|
||||
|
||||
---
|
||||
|
||||
## Waveform body codec — FULLY DECODED (2026-05-11 late)
|
||||
|
||||
> ### ✅ The codec is fully cracked
|
||||
>
|
||||
> Every block type, every channel, every fixture event decodes byte-exact
|
||||
> against BW's ASCII export. **47,364 ADC samples verified, zero errors.**
|
||||
> The previous int16 LE interpretation was wrong — see the retraction
|
||||
> trail in `docs/instantel_protocol_reference.md §7.6.1`.
|
||||
>
|
||||
> Authoritative implementation: `minimateplus/waveform_codec.py`
|
||||
> (`decode_waveform_v2()`). Clean working notes:
|
||||
> `docs/waveform_codec_re_status.md`.
|
||||
>
|
||||
> **NOTE:** `client.py:_decode_a5_waveform` still uses the broken
|
||||
> legacy int16 LE decoder. Wiring `decode_waveform_v2` into the
|
||||
> `.h5` sidecar path is the obvious next follow-up. Until that lands,
|
||||
> `.h5` samples remain wrong — but the codec itself is fully solved.
|
||||
|
||||
The Blastware waveform-file body (between the 21-byte STRT record and
|
||||
the 26-byte footer) is a tagged variable-length block stream with a
|
||||
custom delta + RLE + variable-width codec.
|
||||
|
||||
### What's solved (2026-05-11)
|
||||
|
||||
- **Block framing** — 5 tag types (`10 NN`, `20 NN`, `00 NN`, `30 NN`,
|
||||
`40 02`) with confirmed lengths. Implementation: `walk_body()` in
|
||||
`minimateplus/waveform_codec.py`.
|
||||
- **Per-channel codec** — preamble bytes [3:7] = `Tran[0]`, `Tran[1]`
|
||||
as int16 BE in **16-count units** (LSB = 0.005 in/s). Then `10 NN`
|
||||
(4-bit nibble deltas), `20 NN` (int8 deltas), and `00 NN` (RLE zero
|
||||
deltas) carry per-channel deltas from sample 2 onward.
|
||||
- **Channel rotation** — segments cycle **Tran → Vert → Long → MicL**
|
||||
per `40 02` segment header. Each segment carries ~512 sample-sets of
|
||||
ONE channel. The initial body (before the first `40 02`) is the
|
||||
implicit Tran segment.
|
||||
- **Segment header layout (20 bytes)** —
|
||||
bytes [0:2] = previous-channel continuation delta #1 (int16 BE);
|
||||
bytes [2:4] = previous-channel continuation delta #2;
|
||||
bytes [6:8] = byte length to next header − 2;
|
||||
bytes [8:12] = monotonic uint32 LE counter;
|
||||
bytes [12:14] = constant `02 00`;
|
||||
bytes [14:16] = THIS segment's channel sample 0 anchor (int16 BE);
|
||||
bytes [16:18] = THIS segment's channel sample 1 anchor.
|
||||
- **`decode_waveform_v2()`** returns full per-channel sample dicts.
|
||||
Byte-exact against BW ASCII export for V70 (all 3 channels × 1 seg
|
||||
each), JQ0 (T/V), and SP0 Long (all 3 segments = 1536 samples).
|
||||
|
||||
- **`30 NN` block** — carries NN 12-bit signed deltas packed as NN/4
|
||||
groups of 6 bytes each. Within each group, bytes [0:2] hold 4 ×
|
||||
4-bit high nibbles (MSB first), bytes [2:6] hold 4 × int8 low bytes.
|
||||
Each delta = `sign_extend_12((high_nibble << 8) | low_byte)`. Block
|
||||
length = `NN × 1.5 + 2` bytes. ✅ confirmed against all 14 `30 NN`
|
||||
blocks in the fixture bundle. 12-bit was chosen because ±2047 in
|
||||
16-count units ≈ ±10 in/s = the geophone's full-scale range at
|
||||
Normal sensitivity.
|
||||
- **Wide-NN blocks (`1X NN`, `2X NN`)** — when a `10 NN` or `20 NN`
|
||||
block's NN would exceed 0xFC, the codec uses a 12-bit NN encoding:
|
||||
the low nibble of the type byte holds the high nibble of NN (so the
|
||||
type byte appears as e.g. `0x11` instead of `0x10`). Effective
|
||||
NN = `((type_byte & 0x0F) << 8) | nn_byte`. Block length follows
|
||||
the same formula as the narrow form (`NN/2 + 2` for nibble blocks,
|
||||
`NN + 2` for int8 blocks). Confirmed 2026-05-11 against SP0 cycle
|
||||
3 V continuation (`11 90` = NN=400 nibble deltas in 202 bytes).
|
||||
|
||||
### ⚠ SUPERSEDED 2026-08-25 — the body is a RECORD CHAIN
|
||||
|
||||
Everything in this section below about `40 NN` segment headers, tagless
|
||||
headers, variable header widths and channel rotation describes a model that
|
||||
is **wrong**. The body is a chain of self-delimiting per-channel records:
|
||||
|
||||
off+2 len uint16 BE -> next_record = off + 2 + len
|
||||
off+4 chan_id 0x46 Tran / 0x47 Vert / 0x48 Long / 0x49 MicL / 0x06 = END
|
||||
off+8 mode 02 00 = deltas+anchors (14B hdr)
|
||||
01 00 = ABSOLUTE values (10B hdr)
|
||||
00 03 = raw 12-bit absolute, NO TAGS (10B hdr)
|
||||
|
||||
`40 NN` is an ordinary int16 BE data block (`2*NN + 2`), never a header. The
|
||||
"variable prefix" of 0/2/4/6/8 bytes was walker drift, exactly
|
||||
`4 - (old_stop - true_record_start)`.
|
||||
|
||||
All four channels now come out equal length in **1388/1388** files (was
|
||||
156/1388); ASCII sample-count exact **75/75**, fully exact **73/75**; device
|
||||
PPV on a live decode **1306/1306** waveform, **4458/4459** histogram.
|
||||
|
||||
The old model survives as `decode_waveform_legacy` because
|
||||
`micromate/idf_file.py` pins it for Thor IDFW body-offset search.
|
||||
|
||||
### Framing cases added 2026-05-11 → 2026-08-25
|
||||
|
||||
Four more block-framing cases, each of which had been causing **silent
|
||||
channel truncation** — `walk_body` ends its loop on an unrecognised tag
|
||||
and `decode_waveform_v2` returns whatever channels it got, so an
|
||||
unhandled tag surfaces as short channels with no error raised. Found by
|
||||
diffing 75 production events against their preserved Blastware ASCII
|
||||
exports (`<store>/<serial>/<file>_ASCII.TXT`).
|
||||
|
||||
- **Wide-NN RLE `0X NN`** — the 12-bit NN encoding documented above for
|
||||
`1X`/`2X` **also applies to the `00 NN` RLE tag**. A narrow run maxes
|
||||
out at NN=0xFC, so a quiet stretch longer than 252 samples must use
|
||||
the wide form (e.g. `01 0c` = 268 repeats).
|
||||
- **`30 NN` is not capped at NN=0x10** — data-section blocks reach at
|
||||
least NN=0x18. The `NN × 1.5 + 2` length formula was already right;
|
||||
only the guard was wrong.
|
||||
- **`40 NN` segment headers are variable width** — NN is the *count of
|
||||
int16 BE continuation deltas for the PREVIOUS channel*, so the header
|
||||
is `2*NN + 16` bytes and every field after the deltas shifts by
|
||||
`2*NN`. `40 01` (18 B) and `40 03` (22 B) both occur alongside the
|
||||
common `40 02` (20 B).
|
||||
- **Tagless segment headers** — a header can appear with **no `40 NN`
|
||||
tag at all**: just the 14-byte tail
|
||||
`[field2:2][len:2][channel_id:4][marker:2][anchors:4]`. This is the
|
||||
NN=0 case (previous channel needed no continuation deltas).
|
||||
|
||||
**The header "counter" is really a channel id.** The 4-byte field long
|
||||
documented as a "monotonic uint32 LE counter" is
|
||||
`[channel_id][00][00][segment_index]`, with `0x46`=Tran `0x47`=Vert
|
||||
`0x48`=Long `0x49`=MicL — verified on **1697/1697** segment headers
|
||||
across the corpus, zero disagreements. `decode_waveform_v2` now takes
|
||||
the channel from this field rather than from rotation position; a single
|
||||
missed or extra header (exactly what tagless headers caused) desyncs
|
||||
rotation and corrupts every channel after it.
|
||||
|
||||
Corpus result, end to end through the production path:
|
||||
**exact 37 → 72, truncated 23 → 3, full-length value errors 15 → 0.**
|
||||
|
||||
### Histogram codec — multi-interval blocks (2026-08-26)
|
||||
|
||||
Sub-minute histogram intervals are packed several to a block, so every
|
||||
block still covers exactly one minute:
|
||||
|
||||
| interval | intervals/block | stride |
|
||||
|---|---|---|
|
||||
| 1 min | 1 | 32 (the standard big-endian block) |
|
||||
| 15 s | 4 | 92 |
|
||||
| 2 s | 30 | 612 |
|
||||
|
||||
`stride = 12 + n * 20`. Block = `[00][segment][ctr uint16 LE][0a][00]`,
|
||||
then n x 20-byte records of 8 x uint16 **LITTLE**-endian values
|
||||
(`T_peak, T_halfp, V_peak, V_halfp, L_peak, L_halfp, M_peak, M_halfp`)
|
||||
plus a 2-word tail whose first word is `0000` on every real interval,
|
||||
then a 6-byte block trailer.
|
||||
|
||||
⚠ The standard 32-byte block is BIG-endian; this variant is LITTLE-endian.
|
||||
|
||||
Recovers **415 files** (216 on BE18193, 199 on BE9440) that decoded to
|
||||
nothing. Ground truth `BE9440/K440L3AQ.T70H` matches its BW ASCII export
|
||||
on every one of 17,130 geo peaks, 22,840 frequencies and 5,710 mic dB(L)
|
||||
values; across all 455 affected files 1,354/1,365 channel peaks (99.2%)
|
||||
match the device-reported PPV.
|
||||
|
||||
### Histogram codec — corrected 2026-08-25
|
||||
|
||||
The histogram block is **uniformly big-endian**, and the stream's final
|
||||
block has its own tail signature. Two long-standing errors:
|
||||
|
||||
- **Peaks and half-periods are `uint16` big-endian**, not `uint8` +
|
||||
an "annotation" byte. `T_peak` is `[5:7]`, `T_halfperiod` `[7:9]`,
|
||||
`V_peak` `[9:11]`, and so on; only `block_ctr` at `[2:4]` is LE.
|
||||
The old model silently **clipped any peak above 1.275 in/s** — the
|
||||
final interval of `BE18193/T193LQ9K.OE0H` reads 8.270 in/s in BW's
|
||||
export and decoded as 0.590. The "annotation" byte was the
|
||||
half-period's high byte, which is why it was non-zero exactly on the
|
||||
sub-Hz intervals BW renders as `<1.0`.
|
||||
- **The marker is `block[4]` alone.** Testing `[4:6]` as a uint16 LE
|
||||
marker forced `block[5] == 0`, which is what capped the peak at one
|
||||
byte in the first place.
|
||||
- **The last block of the stream carries tail `9c 06 00 42`** instead of
|
||||
`1e 0a 00 00`, with arbitrary bytes at `[21:23]`. Rejecting it
|
||||
dropped the final interval of nearly every histogram — frequently the
|
||||
one holding the event peak, so the file's PPV read low.
|
||||
|
||||
Verified against 1211 production histograms paired with their BW ASCII
|
||||
exports: **1211/1211 decode exactly** (interval count plus every
|
||||
per-interval peak), and 842,442 per-interval frequency comparisons match
|
||||
with zero mismatches. Before: 1 of 1196.
|
||||
|
||||
### What's NOT solved
|
||||
|
||||
- **MicL channel conversion to dB(L)** — the codec emits MicL as
|
||||
raw ADC counts (same format as geo channels), but BW's ASCII export
|
||||
shows mic in dB(L) with ~6 dB quantization steps. Need to map
|
||||
ADC counts → dB(L) for direct comparison; likely
|
||||
`dB = 20*log10(|counts|) + offset` or similar.
|
||||
- **Variable-prefix segment descriptors** — 3 of the 75 ground-truth
|
||||
production events still truncate. The walk reaches a segment header
|
||||
whose channel-id field is preceded by a *variable-width* prefix (2, 4
|
||||
or 6 bytes observed; the standard tagless form always has 4), carrying
|
||||
an `01 00` marker instead of `02 00`. The marker is **not** simply an
|
||||
anchor count — `01 00` records appear with both 2- and 4-byte anchor
|
||||
fields in the same file. Examples: `BE12599/N599LPNB.JF0W` @1155,
|
||||
`BE12599/N599LPWJ.980W` @849, `BE9558/K558LOF2.820W` @1485.
|
||||
(The series-3 histogram codec was fixed 2026-08-25 — see below.)
|
||||
|
||||
- **Micromate (UM-series) IDF decode is ~1000× low** — e.g.
|
||||
`UM11402_20260406130113.IDFW` gives a Tran peak of 0.0009 in/s against
|
||||
a device-reported 1.1168. The Thor IDF path decodes sanely, so this
|
||||
is UM-specific.
|
||||
- **Thor IDF per-count LSB** — after the 32000 geo full-scale
|
||||
correction, series-4 Thor peaks sit at a median 0.983 of the
|
||||
device-reported peak (was 0.960 under 32768). Closer but not exact;
|
||||
Thor likely uses its own per-count LSB rather than the BW
|
||||
16-count/0.005 in/s convention.
|
||||
|
||||
### Decoded sample counts (across the fixture bundle)
|
||||
|
||||
| Event | Tran | Vert | Long | Total |
|
||||
|---|---|---|---|---|
|
||||
| event-a | 3328 | 3328 | 3328 | **9984** ← full event |
|
||||
| event-b | 2304 | 2304 | 2304 | **6912** ← full event |
|
||||
| event-c | 1280 | 1280 | 1280 | 3840 ← full event |
|
||||
| event-d | 1280 | 1280 | 1280 | 3840 ← full event |
|
||||
| JQ0 | 3328 | 3328 | 3328 | **9984** ← full event |
|
||||
| V70 | 3328 | 3328 | 3328 | **9984** ← full event |
|
||||
| SP0 | 3328 | 3328 | 3328 | **9984** ← full event |
|
||||
| SS0 | 3078 | 3072 | 3072 | 9222 (1–7 tail samples missing) |
|
||||
| SV0 | 3078 | 3072 | 3072 | 9222 (1–7 tail samples missing) |
|
||||
|
||||
**Total: 72,972 ADC samples verified byte-exact, zero errors.**
|
||||
|
||||
7 of 9 fixture events decode end-to-end across all three geo channels.
|
||||
The remaining two (SS0 / SV0) decode all but the last 1–7 samples per
|
||||
channel — a minor walker edge case.
|
||||
|
||||
### Production-code status (updated 2026-05-11 late)
|
||||
|
||||
`client.py:_decode_a5_waveform` now uses the verified codec via
|
||||
`waveform_codec.decode_a5_frames()` — which calls
|
||||
`blastware_file.extract_body_bytes()` to reconstruct the BW-binary
|
||||
body from A5 frames, then `decode_waveform_v2()` to decode samples,
|
||||
then `decoded_to_adc_counts()` to scale to int16 ADC counts (geos × 16;
|
||||
mic pass-through). The `.h5` sidecars SFM produces now contain
|
||||
correct samples for any event without walker edge cases.
|
||||
|
||||
**Geo full scale is 32000 ADC counts, NOT 32768** (fixed 2026-08-25).
|
||||
One decoder unit = 16 ADC counts = exactly 0.005 in/s, so
|
||||
`10.000 in/s / (0.005/16)` = 32000. Consumers must use
|
||||
`sfm.event_hdf5._GEO_INT16_FS` / `event_file_io._GEO_INT16_FS` (both
|
||||
32000). Dividing by 32768 reads every geophone sample 2.34% low —
|
||||
that was a live bug in both modules until 2026-08-25. Mic is
|
||||
unaffected (it back-solves its scale from the device-reported peak).
|
||||
|
||||
The original int16 LE decoder is preserved as
|
||||
`_decode_a5_waveform_LEGACY` for reference but is not called.
|
||||
|
||||
MicL → dB(L) conversion utility:
|
||||
`waveform_codec.mic_count_to_db(count)` — `count=±1 → ±81.94 dB`;
|
||||
`count=813 → 140.14 dB` (matches BW display).
|
||||
|
||||
### Test fixtures
|
||||
|
||||
`tests/fixtures/decode-re-5-8-26/` and `tests/fixtures/5-11-26/` —
|
||||
nine BW binary + ASCII pairs captured from a live BE11529. The
|
||||
5-11-26 high-amplitude bundle (PPV 6–7 in/s) is what cracked the Tran
|
||||
codec; the V70 (mic-heavy) + JQ0 (Vert-heavy) pair cracked the `00 NN`
|
||||
RLE rule.
|
||||
|
||||
If the user uploads new events for codec RE, they go directly into a
|
||||
dated subdirectory under `tests/fixtures/` (e.g. `tests/fixtures/5-18-26/`).
|
||||
There used to be a separate `decode-re/` upload mirror but it was
|
||||
removed once the fixtures directory became the canonical location.
|
||||
|
||||
---
|
||||
|
||||
## Protocol fundamentals
|
||||
|
||||
### DLE framing
|
||||
@@ -528,203 +115,32 @@ S3→BW (response):
|
||||
section contribute only `XX` to the running sum; lone bytes contribute normally. This
|
||||
differs from the standard SUM8-of-destuffed-payload that all other commands use.
|
||||
|
||||
3. **Params region uses partial DLE stuffing (CONFIRMED 2026-05-05).** The device's
|
||||
de-stuffing rule for bytes inside the params region is:
|
||||
Both differences confirmed by reproducing Blastware's exact wire bytes from the 1-2-26
|
||||
BW TX capture. All 10 frames verified.
|
||||
|
||||
- `10 10` → de-stuffs to `10`
|
||||
- `10 02 / 03 / 04` → kept literal (these are inner-frame markers)
|
||||
- `10 X` for other X → de-stuffs to just `X` (drops the leading `0x10`)
|
||||
### SUB 5A — chunk counter formula (FINAL CORRECTION 2026-04-26)
|
||||
|
||||
Therefore any `0x10` byte in the *logical* params that is followed by a byte NOT in
|
||||
`{0x02, 0x03, 0x04, 0x10}` MUST be doubled on the wire (`10 X` → `10 10 X`) so the
|
||||
device's de-stuffer reproduces the original `10 X` pair. This applies most commonly
|
||||
to counters with `0x10` in the high byte (e.g. counter=`0x1000` produces logical
|
||||
params bytes `... 10 00 ...`, which BW encodes on the wire as `... 10 10 00 ...`).
|
||||
Without this stuffing the device interprets counter=`0x1000` as `0x0000` and returns
|
||||
the probe response (which contains a copy of the file header + STRT record). That
|
||||
STRT block then gets embedded in the assembled file body at offset `0x1016`, and
|
||||
Blastware refuses to open the file — see the v0.14.3 entry in `CHANGELOG.md`.
|
||||
**Chunk counter = `max(key4[2:4], 0x0400) + (chunk_num - 1) * 0x0400` for ALL chunks.**
|
||||
|
||||
`0x10` bytes in `offset_hi` (body[5]) are still written RAW — only the params region
|
||||
has this stuffing requirement. The metadata-page params for counter `0x1002` /
|
||||
`0x1004` survive without stuffing because `10 02` and `10 04` fall in the "kept
|
||||
literal" carve-out.
|
||||
where `key4[2:4] = (key4[2] << 8) | key4[3]` is the event's circular-buffer base offset.
|
||||
|
||||
Both differences (1) and (2) confirmed by reproducing Blastware's exact wire bytes from
|
||||
the 1-2-26 BW TX capture (10 frames). Difference (3) confirmed against the 5-1-26
|
||||
"bwcap3sec" capture (17 frames, all match byte-for-byte after fix).
|
||||
The `max(..., 0x0400)` guard is critical for events at the start of the circular buffer
|
||||
(key4[2:4] == 0x0000, e.g. key `01110000`). Without it, chunk 1 gets counter=0x0000, which
|
||||
is the same address as the probe frame — the device re-returns the STRT record data instead
|
||||
of waveform payload. With the guard, chunk 1 gets counter=0x0400, which is confirmed correct
|
||||
from the empirical live-device test 2026-04-06 (`counter=0x0400 → responds immediately and
|
||||
streams all frames correctly`).
|
||||
|
||||
### SUB 5A — chunk counter formula (REWRITTEN 2026-05-01 — see 5-1-26 captures)
|
||||
|
||||
> ⚠️ **Everything that came before this rewrite was WRONG in important ways.** The previous
|
||||
> formula `max(key4[2:4], 0x0400) + (chunk_num - 1) * 0x0400` happened to *work* for events
|
||||
> at start_key=0 because the device responds to whatever counter you ask for — but it caused
|
||||
> a 5× over-read past the actual event, picking up post-event circular-buffer garbage that
|
||||
> corrupts the reconstructed file for any event > ~1 sec of waveform. The captures in
|
||||
> `bridges/captures/4-27-26/` and `5-1-26/comcheck/` show BW reads only ~12-16 chunks for
|
||||
> the same events SFM was reading 37+ chunks for. See "TERM frame" and "STRT end_offset"
|
||||
> sections below for the actual mechanism.
|
||||
|
||||
**Chunk addressing is just absolute device-buffer addresses.**
|
||||
|
||||
`params[0]=0x00`, `params[1:5]` is a 4-byte absolute device flash-buffer address (= the
|
||||
"key" of that location), `params[5:11]` are zeros. The device returns 0x0200 (= 512) bytes
|
||||
starting at that address. Increments between consecutive chunks are **0x0200 (NOT 0x0400)**
|
||||
— this matches the chunk payload size. The previous "0x0400 step" worked by accident: BW
|
||||
asks for half-size chunks; SFM was asking for double-size chunks, both with the same-named
|
||||
"counter" field, but the value is just an address pointer the device honors as-is.
|
||||
|
||||
**The chunk pattern depends on whether the event sits at start_key=0 or not.**
|
||||
|
||||
#### Event 1 case — start_key[2:4] == 0x0000 (first event after erase / wrap)
|
||||
|
||||
```
|
||||
1. Probe at counter=0x0000 (params[1:5] = full key, returns STRT record)
|
||||
2. Read 2 fixed metadata pages: counter=0x1002, counter=0x1004
|
||||
(these are GLOBAL session metadata — read ONCE per
|
||||
Blastware session, not per event; contain the
|
||||
Project/Client/User Name/Seis Loc strings)
|
||||
3. Sample chunks: counter=0x0600, 0x0800, …, by 0x0200 increment,
|
||||
up to but not including end_offset (rounded down to
|
||||
0x0200 boundary)
|
||||
4. TERM frame (see TERM formula below)
|
||||
```
|
||||
|
||||
The reason `0x0046..0x0600` is skipped for event 1 is unknown — likely some pre-event
|
||||
firmware reserved area for the first slot in a freshly-erased buffer. Harmless to skip.
|
||||
|
||||
#### Event 2+ case — start_key[2:4] != 0x0000 (continuation events)
|
||||
|
||||
```
|
||||
1. First chunk at counter = start_key[2:4] (this IS the probe — response
|
||||
contains STRT at byte 17)
|
||||
2. Sample chunks: counter += 0x0200 each, up to but
|
||||
not including end_offset
|
||||
3. TERM frame
|
||||
```
|
||||
|
||||
**`start_key` here is the off=0x46 WAVEHDR record key returned by 1F** (e.g. `01112238`),
|
||||
NOT the off=0x2C boundary key that immediately precedes it. An earlier draft of this
|
||||
doc described event-N as "probe at start + 0x46" — that formula came from naming the
|
||||
boundary key as `start_key`. In the iteration walk, `cur_key` passed to
|
||||
`read_bulk_waveform_stream` is always the off=0x46 key (the partial-record skip path in
|
||||
`get_events` re-runs 1F to advance past boundary records before invoking 5A), so the
|
||||
probe counter is just `cur_key[2:4]` with no extra offset. **Adding +0x46 caused the
|
||||
probe to overshoot, miss the STRT record at byte 17 of the response, fall back to the
|
||||
`max_chunks=128` cap, and walk ~110 chunks of post-event garbage** — observed in
|
||||
SFM 5-4-26 capture before the fix.
|
||||
|
||||
Confirmed across:
|
||||
- 5-1-26 "copy 2nd address" BW capture: probe counter=0x2238, key=01112238, STRT@17 end=0x417E.
|
||||
- 5-4-26 BW 2-sec event capture: probe counter=0x2238, key=01112238, TERM offset_word=0x0146 → end=0x417E.
|
||||
|
||||
No metadata pages — those have already been read during event 1 in the same Blastware
|
||||
session, and BW caches them. Note that the metadata-page reads happen ONCE per
|
||||
Blastware-session-on-the-device, not once per event, so an SFM session that downloads
|
||||
several events should read 0x1002/0x1004 only once at the start.
|
||||
|
||||
#### History (do not re-derive)
|
||||
The 4-3-26 capture confirms the pattern for a second event (key `0111245a`, key4[2:4]=0x245a):
|
||||
chunk 1 = `0x245A`, chunk 2 = `0x285A`, chunk 3 = `0x2C5A` (each +0x0400).
|
||||
`max(0x245a, 0x0400) = 0x245a` → formula works correctly for non-zero base offset too.
|
||||
|
||||
**History:**
|
||||
- Original: `_CHUNK1_COUNTER = 0x1004` hardcoded (Blastware capture artifact — WRONG).
|
||||
- 2026-04-06: `chunk_num * 0x0400` (worked for key 01110000 only).
|
||||
- 2026-04-24: `key4[2:4] + (chunk_num-1) * 0x0400` (fixed non-zero offsets, broke key 01110000).
|
||||
- 2026-04-26: `max(key4[2:4], 0x0400) + (chunk_num-1) * 0x0400` (broken — over-read past event end).
|
||||
- 2026-05-01: Increments are 0x0200 not 0x0400; absolute addresses inside event range; bounded
|
||||
by STRT end_key, not by `max_chunks` cap or device-side timeout.
|
||||
- 2026-05-04: Removed spurious `+0x0046` from event-N probe counter. `cur_key` from 1F
|
||||
is already the off=0x46 WAVEHDR key, so adding +0x46 would have placed the probe one
|
||||
WAVEHDR past the actual event start. This caused probe responses to lack a STRT
|
||||
record (no `end_offset` parsed → `0xFFFF` fallback → `max_chunks=128` cap), walking
|
||||
~110 chunks of post-event circular-buffer garbage. Fixed in protocol.py
|
||||
`read_bulk_waveform_stream`.
|
||||
|
||||
### SUB 5A — STRT record encodes end_offset (NEW 2026-05-01)
|
||||
|
||||
The first A5 response (probe response, or the first chunk for event 2+) contains a STRT
|
||||
record at byte offset 17 of the `data` field. Layout:
|
||||
|
||||
```
|
||||
data[17:21] "STRT" magic
|
||||
data[21:23] ff fe sentinel
|
||||
data[23:27] end_key ← 4-byte key of where this event ENDS
|
||||
data[27:31] start_key ← 4-byte key of where this event STARTS
|
||||
data[31:33] uint16 BE ?? sample-count or total bytes (varies; not yet decoded)
|
||||
data[33:35] uint16 BE ??
|
||||
data[35] 0x46 record type (waveform full record)
|
||||
…
|
||||
```
|
||||
|
||||
`end_offset = (end_key[2] << 8) | end_key[3]` is **the authoritative event-end pointer**.
|
||||
SFM must extract this from the first A5 response and use it to bound the chunk loop and
|
||||
encode the TERM frame. The device will happily respond to chunk requests past `end_offset`
|
||||
(returning post-event circular-buffer contents) — that's the over-read bug.
|
||||
|
||||
Verified across 3 events:
|
||||
|
||||
| Capture | start_key | end_key | end_offset | event size |
|
||||
|---|---|---|---|---|
|
||||
| 4-27-26 "open 2sec" / "copy event to disk" | `01110000` | `01111ABE` | `0x1ABE` | 6,846 B |
|
||||
| 5-1-26 "copy 3sec" / Download All event 1 | `01110000` | `011121F2` | `0x21F2` | 8,690 B |
|
||||
| 5-1-26 "copy 2nd address" / DA event 2 | `011121F2` | `0111417E` | `0x417E` (event 2 span 0x1F8C = 8,076 B) |
|
||||
|
||||
### SUB 5A — TERM frame formula (FINALIZED 2026-05-01)
|
||||
|
||||
The TERM frame fetches the partial last chunk *and* the file footer. It is **not** a simple
|
||||
"goodbye" frame — its response payload contains the bytes between the last full 0x0200-aligned
|
||||
chunk and `end_offset`, and is required for reconstructing the Blastware file format.
|
||||
|
||||
```
|
||||
last_chunk_counter = address of last full 0x0200-byte chunk read
|
||||
next_boundary = last_chunk_counter + 0x0200
|
||||
TERM offset_word = end_offset - next_boundary
|
||||
TERM params[0] = key[0] (= 0x01 on every observed device)
|
||||
TERM params[1] = key[1] (= 0x11)
|
||||
TERM params[2] = (next_boundary >> 8) & 0xFF
|
||||
TERM params[3] = next_boundary & 0xFF
|
||||
TERM params[4:10] = zeros
|
||||
build_5a_frame(offset_word, params) (10-byte params, NOT 11)
|
||||
```
|
||||
|
||||
The device reconstructs `requested_address = (params[2] << 8) | offset_word = end_offset`
|
||||
and replies with `(end_offset - next_boundary)` bytes from `next_boundary` — the residual
|
||||
between the last 0x0200 boundary and the actual event end. Append the TERM response data
|
||||
to the chunk stream like any other A5 frame; it carries the final waveform tail + footer.
|
||||
|
||||
Verified across 3 events:
|
||||
|
||||
| end_offset | last chunk | next_boundary | TERM offset_word | TERM params[2:4] |
|
||||
|---|---|---|---|---|
|
||||
| `0x1ABE` | `0x1800` | `0x1A00` | `0x00BE` ✓ | `1A 00` ✓ |
|
||||
| `0x21F2` | `0x1E00` | `0x2000` | `0x01F2` ✓ | `20 00` ✓ |
|
||||
| `0x417E` | `0x3E38` | `0x4038` | `0x0146` ✓ | `40 38` ✓ |
|
||||
|
||||
The previous code's hard-coded `offset_word = 0x005A` and `term_counter = last + 0x0400`
|
||||
are wrong; the device's response under that path is a tiny 101-byte device-side terminator
|
||||
(arrived only after we walked the entire post-event buffer), not the proper file footer.
|
||||
|
||||
### SUB 5A — fixed metadata pages 0x1002 and 0x1004 (NEW 2026-05-01)
|
||||
|
||||
Two chunk addresses are GLOBAL device/session metadata, not event-specific:
|
||||
|
||||
- `counter=0x1002` — first metadata page
|
||||
- `counter=0x1004` — second metadata page
|
||||
|
||||
These are at fixed absolute addresses in the device's flash buffer. They contain the
|
||||
session-start compliance setup (Project/Client/User Name/Seis Loc/Extended Notes ASCII
|
||||
strings). Under the v0.14.0+ walk these strings are read directly from the metadata
|
||||
pages, not from the sample-chunk stream.
|
||||
|
||||
BW reads them ONCE per Blastware session (during event 1's download) and caches them.
|
||||
For SFM, that means:
|
||||
- Once per call-home / once per `MiniMateClient.connect()` is enough.
|
||||
- Subsequent events in the same session don't need to re-fetch them.
|
||||
- Their content does not change when iterating events; only when the user opens
|
||||
Compliance Setup → Apply on the device or sends a SUB 71 compliance write.
|
||||
|
||||
The full byte-for-byte layout of the metadata pages has not been mapped — `_decode_a5_metadata_into`
|
||||
locates the ASCII strings via label scans (`Project:`, `Client:`, `User Name:`, `Seis Loc:`,
|
||||
`Extended Notes`) which works correctly across observed captures. Future work could
|
||||
dump the structural layout if more session-global fields need to be extracted.
|
||||
- 2026-04-06: Corrected to `chunk_num * 0x0400` (worked for key 01110000 only).
|
||||
- 2026-04-24: Corrected to `key4[2:4] + (chunk_num-1) * 0x0400` (fixed non-zero offsets,
|
||||
but accidentally broke key 01110000 — counter=0x0000 sends probe address again).
|
||||
- 2026-04-26: Final formula: `max(key4[2:4], 0x0400) + (chunk_num-1) * 0x0400`.
|
||||
|
||||
### SUB 5A — params are 11 bytes for chunk frames, 10 for termination
|
||||
|
||||
@@ -732,11 +148,10 @@ dump the structural layout if more session-global fields need to be extracted.
|
||||
confirmed from the BW wire capture. `bulk_waveform_term_params()` returns 10 bytes.
|
||||
Do not swap them.
|
||||
|
||||
### SUB 5A — event-time metadata source (FINALIZED 2026-05-05)
|
||||
### SUB 5A — event-time metadata lives in A5 frame 7
|
||||
|
||||
The metadata strings come from the two fixed metadata pages at counter `0x1002` and
|
||||
`0x1004` (see "SUB 5A — fixed metadata pages 0x1002 and 0x1004" above). These pages
|
||||
are GLOBAL session metadata — read once per Blastware/SFM session, not per event.
|
||||
The bulk stream sends 9+ A5 response frames. Frame 7 (0-indexed) contains the compliance
|
||||
setup as it existed when the event was recorded:
|
||||
|
||||
```
|
||||
"Project:" → project description
|
||||
@@ -746,71 +161,44 @@ are GLOBAL session metadata — read once per Blastware/SFM session, not per eve
|
||||
"Extended Notes"→ notes
|
||||
```
|
||||
|
||||
**IMPORTANT — these strings are session-start config, NOT per-event:**
|
||||
Project / Client / User Name / Seis Loc reflect the compliance setup from when the
|
||||
*monitoring session first started*, not the individual event's per-event metadata. The
|
||||
authoritative per-event project name is stored in the 210-byte 0C waveform record.
|
||||
`_decode_a5_metadata_into` therefore only sets `project` from the 5A metadata pages
|
||||
when 0C didn't already supply one.
|
||||
**IMPORTANT — 5A "Project:" is session-start config, NOT per-event (confirmed 2026-04-05):**
|
||||
The "Project:" string in the A5 frame 7 payload reflects the compliance setup from when
|
||||
the *monitoring session first started*, not the individual event's project name. The per-
|
||||
event project name is correctly stored in the 210-byte 0C waveform record and must be
|
||||
used as the authoritative source. `_decode_a5_metadata_into` therefore only sets
|
||||
`project` from 5A when 0C didn't already supply one.
|
||||
|
||||
"Client:", "User Name:", "Seis Loc:", and "Extended Notes" are **NOT** present in the 0C
|
||||
record — the metadata pages are the sole source for those fields and they are set
|
||||
unconditionally.
|
||||
record — 5A remains the sole source for those fields and they are set unconditionally.
|
||||
|
||||
#### Deprecated knobs (do not re-introduce)
|
||||
`stop_after_metadata=True` (default) stops the 5A loop as soon as `b"Project:"` appears,
|
||||
then sends the termination frame.
|
||||
|
||||
The `read_bulk_waveform_stream()` function still accepts these legacy kwargs for
|
||||
backward compatibility, but they are **no-ops** under the v0.14.0+ walk:
|
||||
### SUB 5A — end-of-stream signal (confirmed 2026-04-06)
|
||||
|
||||
- `stop_after_metadata=True` — used to scan the chunk stream for `b"Project:"` and stop
|
||||
one chunk later as a workaround for the missing end_offset bound. Obsolete: the loop
|
||||
is now deterministically bounded by `end_offset` parsed from the STRT record at
|
||||
data[17] of the probe response, with the partial tail fetched by the TERM frame.
|
||||
- `extra_chunks_after_metadata` — same era, same reason. No-op.
|
||||
After streaming all waveform chunks, the device sends exactly **1 raw byte** in response to
|
||||
the next chunk request, then goes silent. This is the natural end-of-stream indicator — NOT
|
||||
a complete A5 frame. `S3FrameParser.bytes_fed` will be 1; no frame is assembled.
|
||||
|
||||
If you find code or docs referencing "A5 frame 7" as the source of metadata strings,
|
||||
that's an old-walk artifact (the broken `0x0400`-step formula occasionally caught the
|
||||
0x1002 metadata page at sample-chunk fi=7). Update to reference the dedicated metadata
|
||||
pages instead.
|
||||
Handling: on `TimeoutError`, if `bytes_fed > 0` AND frames were already collected, treat as
|
||||
graceful end-of-stream, break the loop, and proceed to the termination frame. If `bytes_fed
|
||||
== 0` with no prior frames, it is a genuine transport failure — re-raise.
|
||||
|
||||
### SUB 5A — end-of-stream (FINALIZED 2026-05-01)
|
||||
**Chunk recv timeout must be 10 s, not the default 120 s.** Chunks arrive within ~1 s each.
|
||||
Using 120 s causes a ~2-minute stall at every end-of-stream detection. The `_recv_one` call
|
||||
in the chunk loop passes `timeout=10.0` explicitly.
|
||||
|
||||
Under the v0.14.0+ STRT-bounded walk the stream ends cleanly:
|
||||
|
||||
```
|
||||
… last full chunk at counter < end_offset
|
||||
TERM request (offset_word = end_offset - next_boundary,
|
||||
params address (next_boundary))
|
||||
TERM response (page_key = 0x0000 or 0x0001, data = the residual
|
||||
end_offset - next_boundary bytes including the file footer)
|
||||
```
|
||||
|
||||
No timeout-based detection, no "1-byte teaser," no `max_chunks` cap. The chunk loop
|
||||
exits when `counter + 0x0200 > end_offset`; the TERM frame fetches the tail.
|
||||
|
||||
**Chunk recv timeout is 10 s, not the default 120 s.** Chunks arrive within ~1 s each.
|
||||
Using 120 s would cause a ~2-minute stall on any unexpected timeout. The `_recv_one`
|
||||
call in the chunk loop passes `timeout=10.0` explicitly.
|
||||
|
||||
**Typical chunk count under the v0.14.0+ walk (BE11529, 1024 sps over TCP/cellular):**
|
||||
|
||||
| Event duration | Sample chunks | Metadata pages | TERM | Total A5 frames |
|
||||
|---|---|---|---|---|
|
||||
| 2-sec (event 1) | ~12 | 2 | 1 | ~15 |
|
||||
| 3-sec (event 1) | 13 | 2 | 1 | 16 |
|
||||
| 2-sec (continuation) | 15 | 0 | 1 | 16 |
|
||||
| 3-sec (continuation) | ~14 | 0 | 1 | ~15 |
|
||||
|
||||
For comparison, the deprecated `0x0400`-step walk produced ~37 chunks for a 2-sec
|
||||
event with chunks 17-37 containing post-event circular-buffer garbage. Do not
|
||||
re-introduce that walk under any circumstances.
|
||||
**Typical chunk count (BE11529, 1024 sps):** A 9,306-sample event produces 35 chunks before
|
||||
end-of-stream. Chunks with uniform 1,036-byte data are all-zero ADC samples (post-event
|
||||
silence). Only the initial variable-size chunks contain actual signal.
|
||||
|
||||
### SUB 5A — fi==9 hardcoded skip (FIXED 2026-04-06)
|
||||
|
||||
`_decode_a5_waveform()` previously had `elif fi == 9: continue` — a leftover from the
|
||||
9-frame original blast capture where frame 9 was assumed to be a terminator. Removed.
|
||||
TERM detection in the file builder uses `frame.page_key != 0x0010` (sample marker),
|
||||
not frame index — see `blastware_file.py`.
|
||||
9-frame original blast capture where frame 9 was assumed to be a terminator. For current
|
||||
35-frame streams, fi==9 is live waveform data (~133 sample-sets were being dropped).
|
||||
Removed. Terminator detection is via `page_key == 0x0000` in `read_bulk_waveform_stream`,
|
||||
not frame index.
|
||||
|
||||
### SUB 1E / 1F — event iteration null sentinel and token position (FIXED, do not re-introduce)
|
||||
|
||||
@@ -915,55 +303,6 @@ sends token=0xFE and is NOT used by any caller.
|
||||
`advance_event()` returns `(key4, event_data8)`.
|
||||
Callers (`count_events`, `get_events`) loop while `data8[4:8] != b"\x00\x00\x00\x00"`.
|
||||
|
||||
### SUB 0A — WAVEHDR response length distinguishes events from boundaries (NEW 2026-05-01)
|
||||
|
||||
When iterating events with the "Download All" pattern (1E → 0A → 1F → 0A → 1F → …), the
|
||||
DATA_LENGTH at `data_rsp.data[5]` (= the byte BW echoes back as the offset for the data
|
||||
fetch step) takes one of two values:
|
||||
|
||||
| WAVEHDR offset | Meaning |
|
||||
|---|---|
|
||||
| `0x46` (= 70) | Real event start key — there is event data at this address |
|
||||
| `0x2C` (= 44) | Boundary marker between events — this key is the END of the previous event AND the START key for the empty space after it (or is the next event's pre-header) |
|
||||
|
||||
Confirmed from the 5-1-26 "Download All" capture:
|
||||
|
||||
```
|
||||
0A(key=01110000) → off=0x46 ← event 1 real start
|
||||
1F → key=011121F2
|
||||
0A(key=011121F2) → off=0x2C ← event 1 END / event 2 boundary
|
||||
1F → key=01112238
|
||||
0A(key=01112238) → off=0x46 ← event 2 real start (= boundary + 0x46)
|
||||
1F → key=0111417E
|
||||
0A(key=0111417E) → off=0x2C ← event 2 END / next-empty marker
|
||||
1F → null sentinel
|
||||
```
|
||||
|
||||
This is why event 2's first 5A chunk is at `start_key + 0x46` — that's the address of the
|
||||
"real start" 0x46-record, distinct from the `0x2C`-record at the raw boundary. Use the
|
||||
`0x46` keys as the input to `read_bulk_waveform_stream`, not the `0x2C` keys.
|
||||
|
||||
For event 1 only (start_key[2:4] = 0x0000) BW probes at counter=0x0000 directly, which is
|
||||
the `0x46`-keyed start record. Subsequent events use `start_key + 0x46`.
|
||||
|
||||
**Practical iteration pattern (replaces the old 1E/1F walk for downloads):**
|
||||
|
||||
```
|
||||
Setup: SERIAL × 2 → CHCFG → 1E (token=0x00) → key0
|
||||
For each event:
|
||||
0A(cur_key) → DATA_LENGTH = 0x46 (real) or 0x2C (boundary)
|
||||
1F (token=0x00) → next_key
|
||||
if length was 0x46: → cur_key is a real event; queue it for download
|
||||
cur_key = next_key
|
||||
if next_key all-zero null sentinel: stop
|
||||
|
||||
Then for each queued real-event key:
|
||||
download_event(key) → 5A bulk stream with STRT-bounded chunk walk
|
||||
```
|
||||
|
||||
This is what BW does in the 5-1-26 "Download All" capture — it walks the full event chain
|
||||
collecting `(key, length)` tuples first, *then* downloads each event using the `0x46` keys.
|
||||
|
||||
### SUB 1A — compliance config — orphaned send bug (FIXED, do not re-introduce)
|
||||
|
||||
`read_compliance_config()` sends a 4-frame sequence (A, B, C, D) where:
|
||||
@@ -1008,6 +347,36 @@ Do NOT use fixed absolute offsets for sample_rate or record_time.
|
||||
Quiet Mode enabled. Parser handles this — do not strip it manually before feeding to
|
||||
`S3FrameParser`.
|
||||
|
||||
**SUB 5A (bulk waveform) TCP frame splitting — confirmed 2026-04-27:**
|
||||
|
||||
Over TCP via cellular modem, each 5A chunk request that produces a single ~1100-byte
|
||||
A5 response over direct RS-232 may arrive as **two separate, complete S3 frames** of
|
||||
~550 bytes each ("2-frame mode"). The modem's Data Forwarding Timeout (~100-150 ms)
|
||||
can split the RS-232 response into two TCP segments, each parsed as a complete S3 frame.
|
||||
Under different modem/timing conditions the full ~1100-byte response arrives as **one
|
||||
S3 frame** ("1-frame mode").
|
||||
|
||||
**Both modes require `extra_chunks_after_metadata=1`** (the extra chunk at metadata_counter
|
||||
+ 0x0400). The device's waveform footer data lives at circular-buffer address 0x1C00 for
|
||||
this event; the terminator frame must be sent at 0x1C00 (not 0x1800) to receive it.
|
||||
|
||||
Example for a 2-second Continuous event (BE11529, key=01110000) via TCP:
|
||||
- **2-frame mode:** 1 probe frame (554 B) + 5 chunks × 2 frames (556-573 B) + 1 extra chunk × 2 frames + 1 terminator (208 B) = **14 A5 frames** → 6864-byte file
|
||||
- **1-frame mode:** 1 probe frame (~1097 B) + 5 chunks × 1 frame (~1079-1113 B) + 1 extra chunk × 1 frame (smaller, tail of event) + 1 terminator → **8 A5 frames** → 6864-byte file
|
||||
- All frames contribute body data; using all of them gives the correct file.
|
||||
|
||||
**Fix (confirmed 2026-04-27):** `_recv_5a_batch()` in `protocol.py` collects ALL
|
||||
A5 frames per chunk request before the next request is sent, using a 0.5 s batch
|
||||
timeout after the first frame to catch the ~150 ms delayed second frame. `write_blastware_file()`
|
||||
includes ALL body frames without skipping — the extra chunk's frames are part of the
|
||||
body data, NOT padding to be discarded.
|
||||
|
||||
**WRONG earlier hypothesis (do not re-introduce):** An attempt was made to auto-detect
|
||||
1-frame vs 2-frame mode from the probe frame size and skip the extra chunk when
|
||||
`probe_data_len >= 700`. This was wrong — the extra chunk is always needed to advance
|
||||
the device's internal state to the footer address. The `_probe_is_large` branch was
|
||||
removed 2026-04-27.
|
||||
|
||||
### Required ACEmanager settings (Sierra Wireless RV50/RV55)
|
||||
|
||||
| Setting | Value | Why |
|
||||
@@ -1188,8 +557,6 @@ All DB endpoints are read-only except `PATCH /db/events/{id}/false_trigger`.
|
||||
| 3-11-26 | `bridges/captures/3-11-26/` | Full compliance setup write, Aux Trigger capture |
|
||||
| 3-31-26 | `bridges/captures/3-31-26/` | Complete event download cycle (148 BW / 147 S3 frames) — confirmed 1E/0A/0C/1F sequence; only 1 event stored so token=0xFE appeared to work |
|
||||
| 4-3-26 | `bridges/captures/4-3-26/` | Browse-mode S3 capture with 2+ events — confirmed all-zero params for 1F, 1F response layout, null sentinel, 0A context requirement |
|
||||
| 4-27-26 | `bridges/captures/4-27-26/` | BW "open 2sec waveform" + "copy event to disk" + paired SFM "seismo_dl" — first proof that SFM was over-reading 5× past event end. BW reads 14 chunks at 0x0200 increments + TERM at end_offset; SFM was reading 37 chunks at 0x0400 increments. STRT end_key field located. |
|
||||
| 5-1-26 | `bridges/captures/5-1-26/comcheck/` | Three sub-captures: SFM 3-sec download (`seismo_dl_…`), BW comms-check + 3-sec download (`bwcap3sec/`), BW second-event download + "Download All" (`raw_*_170945`/`_171216`). Confirmed: TERM frame formula across 3 events; metadata pages 0x1002/0x1004 are global (read once per session); event-1 vs event-N chunk-pattern split; WAVEHDR length 0x46 vs 0x2C disambiguates real events from boundaries. |
|
||||
|
||||
---
|
||||
|
||||
@@ -1453,7 +820,7 @@ offsets in the raw 1A/E5 payload. Only fields with `✅` have confirmed offsets
|
||||
|
||||
**Notes tab:**
|
||||
- Enable User Notes (bool)
|
||||
- Project, Client, User Name, Seis Loc (ASCII strings) ✅ (sourced from 5A metadata pages at counter 0x1002 / 0x1004 — see "SUB 5A — fixed metadata pages" section)
|
||||
- Project, Client, User Name, Seis Loc (ASCII strings) ✅ (sourced from A5 frame 7 via 5A)
|
||||
- Enable Extended Notes (bool); Extended Notes text; Extended Notes Title
|
||||
- Enable Job Number (bool); Job Number (int)
|
||||
- Enable Scaled Distance (bool); Distance from Blast (float); Charge Weight (float) — Scaled Distance is derived
|
||||
@@ -1765,11 +1132,9 @@ 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>`
|
||||
- **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 working for Continuous mode events (2026-04-23):** SFM-generated file opens in Blastware, shows correct PPV/waveform/timestamp. File is ~200 bytes shorter than BW (missing last ADC tail slice) — all measurements correct. Histogram+Continuous mode deferred (5A stream for those events embeds histogram interval records that create spurious STRT markers in the body). Extension mapping: **CONFIRMED FALSE 2026-04-21** — extensions encode timestamp (AB0T for ACH, AB0 for direct), NOT recording mode. Filename format: `<prefix_letter><serial3><4-char-base36-stem><ext>`
|
||||
|
||||
**Serial encoding (CONFIRMED 2026-04-22):** `prefix_letter = chr(ord('B') + floor(serial_numeric / 1000))`, `serial3 = f"{serial_numeric % 1000:03d}"`. Examples: BE6907→H907, BE11529→M529, BE14036→P036, BE17353→S353, BE18003→T003. The prefix letter encodes the production generation (batch of 1000 units).
|
||||
|
||||
@@ -1805,22 +1170,17 @@ body) because writing a dial string may require DLE escaping for embedded contro
|
||||
|
||||
| Folder / File | Contents |
|
||||
|---|---|
|
||||
| `1-2-26/` | First SUB 5A BW TX capture — established 5A frame format (raw offset_hi, DLE-aware checksum). 10 frames verified. |
|
||||
| `3-11-26/raw_bw_20260311_170151.bin` | Full compliance write + event download (SUBs 68→83 confirmed, frames 102–112) |
|
||||
| `3-31-26/` | Single-event download (148 BW / 147 S3 frames) — 1E/0A/0C/1F sequence confirmed (single event so token=0xFE appeared to work in either branch) |
|
||||
| `4-2-26/` | Download-mode BW TX capture — POLL×3 requirement confirmed (frames 68-73 between 1F and first 5A) |
|
||||
| `4-3-26-multi_event/` | Browse-mode S3 capture with 2+ events — all-zero params for 1F, null sentinel layout, 0A context requirement |
|
||||
| `4-8-26/` | Monitor status read, start/stop monitoring, SESSION_RESET signal, sensor check |
|
||||
| `4-11-26 (mitm/ach_mitm_20260411_001912/)` | Full ACH call-home MITM — erase protocol (0xA3/0x06/0xA2), monitor log partial records confirmed |
|
||||
| `4-20-26/raw_bw_*_recording_mode_*.bin` | Recording mode changes: Continuous→Single Shot, →Histogram, →Histogram+Continuous |
|
||||
| `4-20-26/histogram interval/` | Histogram interval changes: 1min, 5min, 15min, 15sec |
|
||||
| `4-20-26/geo sensitivity/` | Geo sensitivity changes: 1.25 in/s (Sensitive), 10 in/s (Normal) |
|
||||
| `4-20-26/call home settings/` | Call home config read/write captures |
|
||||
| `4-27-26/` | BW "open 2sec waveform" + "copy event to disk" + paired SFM "seismo_dl" — first proof of 5× SFM over-read. STRT end_key field located. |
|
||||
| **`5-1-26/comcheck/`** | **Triplet of captures that nailed the v0.14.0 walk:** SFM 3-sec download (`seismo_dl_…`), BW comms-check + 3-sec download (`bwcap3sec/`), BW second-event download + "Download All" (`raw_*_170945` / `_171216`). Confirmed: TERM frame formula across 3 events, metadata pages 0x1002/0x1004 are global session metadata, event-1 vs event-N chunk pattern split, WAVEHDR off=0x46 vs 0x2C disambiguates real events from boundaries. |
|
||||
| **`5-1-26/comcheck/bwcap3sec/`** | **The byte-perfect reference for v0.14.3.** All 17 BW 5A request frames (probe, 2 metadata, 13 samples, TERM) reproduce byte-for-byte from SFM's framing helpers — including the `10 10 00` DLE-stuffed counter for sample @ 0x1000 that was the long-standing failure mode. |
|
||||
| `5-4-26/` | BW MITM captures of "copy 3sec / 2sec / Download All" + paired SFM session (`seismo_dl_20260504_145701`) showing the +0x46 event-N probe bug producing 110-chunk runaway walk. Cross-references against 5-1-26 confirmed device behavior is identical. |
|
||||
| `4-8-26/` | Monitor status read, start/stop monitoring, SESSION_RESET signal, sensor check |
|
||||
| `4-3-26-multi_event/` | Browse-mode S3 capture with 2+ events (1E/0A/1F iteration confirmed) |
|
||||
| `4-2-26/` | Download-mode BW TX capture (5A bulk stream, POLL×3 requirement confirmed) |
|
||||
| `3-31-26/` | Single-event download (148 BW / 147 S3 frames) |
|
||||
| `mitm/ach_mitm_20260411_001912/` | Full ACH call-home MITM (erase protocol, 0xA3/0x06/0xA2 confirmed) |
|
||||
|
||||
To parse BW TX captures: use `bridges/captures/` scripts or adapt the `find_write_frames()` pattern
|
||||
in `/tmp/analyze_write_payload.py` — it correctly handles `0x10 0x03` DLE-escaped ETX bytes
|
||||
inside write frame data (the naive parser terminates early at the escaped `0x03`).
|
||||
inside write frame data (the naive parser terminates early at the escaped `0x03`). | ||||