Compare commits
37
Commits
ac67e83bcf
...
v0.27.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d0d12079b | ||
|
|
8e808b09d4 | ||
|
|
ad84a04404 | ||
|
|
1fdc665675 | ||
|
|
c8c4ec2b9f | ||
|
|
5f1ee5ba91 | ||
|
|
4839ddfa0e | ||
|
|
14e997b20c | ||
|
|
75ac610c61 | ||
|
|
5203aab849 | ||
|
|
dedf1f02c9 | ||
|
|
b2ef02ebcc | ||
|
|
a3b69a62a6 | ||
|
|
306104354b | ||
|
|
4c58a532de | ||
|
|
9bb95003e9 | ||
|
|
260bf0bc67 | ||
|
|
ef1e99b0a0 | ||
|
|
e449ac04af | ||
|
|
4f8224a751 | ||
|
|
5d3963b545 | ||
|
|
0f6c9d930f | ||
|
|
b6b6ee0331 | ||
|
|
686ab6e7a6 | ||
|
|
37043a47e9 | ||
|
|
4a581e0e67 | ||
|
|
7aae0208f8 | ||
|
|
5ffa92ab87 | ||
|
|
f73c8eec91 | ||
|
|
23e4f585a8 | ||
|
|
d9cc5f1780 | ||
|
|
5247e78669 | ||
|
|
5b65718b72 | ||
|
|
483762607e | ||
|
|
d0b66368d5 | ||
|
|
2eb1d25028 | ||
|
|
cc821f9ee3 |
+389
@@ -8,6 +8,395 @@ All notable changes to seismo-relay are documented here.
|
||||
|
||||
---
|
||||
|
||||
## v0.27.0 — 2026-08-28
|
||||
|
||||
**Per-sample decoder verification at scale, plus the offset investigation.**
|
||||
The series-3 codec is now verified sample-by-sample against **14,338** preserved
|
||||
Blastware ASCII exports — 1,249 waveform and 13,089 histogram, spanning 45 units
|
||||
and files back to 2018. That is 11x the ground truth the production store
|
||||
carried, and it found one real codec bug (below).
|
||||
|
||||
### Fixed
|
||||
- **Sub-minute histograms with a partial final block decoded to nothing**
|
||||
(`histogram_codec.detect_multi_interval_stride`). The stride search confirmed
|
||||
itself on a third block header whenever the body was long enough to hold one —
|
||||
but a body can exceed two strides and still contain only two real blocks, because
|
||||
a *partial* final block leaves trailing padding. BE18193 `T193L0XM.CI0H` (51
|
||||
intervals at 2 s = one full 30-interval block plus a 21-interval remainder, in a
|
||||
2787-byte body) therefore had its correct stride of 612 discarded and produced an
|
||||
empty decode. A missing third header now means end-of-stream rather than
|
||||
disqualification; the block-counter check, which is what actually prevents the
|
||||
false positives that once mis-dispatched 9,082 files, is unchanged.
|
||||
|
||||
Found by decoding the full DL2 archive against its preserved Blastware ASCII
|
||||
exports. Across **63,535 unique** histogram binaries the fix recovers **4 files** —
|
||||
`K440HJCN.3C0H` and `K557IF1U.8K0H` (stride 252), `T191HVNP.0S0H` (92) and
|
||||
`T193L0XM.CI0H` (612) — with **zero** files regressed. Verification over all
|
||||
14,340 archive pairs goes 14,337 → 14,338 exact, the only remainder being two
|
||||
series-4 IDF files that belong to a different codec.
|
||||
|
||||
(The DL2 export keeps a byte-identical `Sent/` mirror of its root, so a naive
|
||||
walk double-counts every binary — 127,035 paths are 63,535 distinct files. The
|
||||
ASCII exports are *not* mirrored, so the 14,340 pair count is already distinct.)
|
||||
|
||||
⚠ Prod stores hold `.h5` files generated before this fix. Those 4 events stay
|
||||
empty until `backfill_sidecars.py` is re-run — not worth a two-hour prod backfill
|
||||
on its own; fold it into the next one.
|
||||
|
||||
- **Histogram/waveform twin matching is now interval-based** (`find_twins`). A real
|
||||
trigger is recorded twice — as a triggered waveform (stamped at the trigger instant)
|
||||
and inside the scheduled histogram whose interval contains it (stamped at the 7am/7pm
|
||||
interval start) — so the two twins can be **hours apart**. The old ±5-minute window
|
||||
silently missed them, which broke review propagation (flagging one twin didn't flag its
|
||||
twin). Twins are now matched by same serial + identical `peak_vector_sum` + opposite
|
||||
record type + the waveform falling within the histogram's interval (bounded by the next
|
||||
same-serial histogram). `window_seconds` is retained but ignored. Fixes terra-view #102
|
||||
sub-task 2.
|
||||
|
||||
- **`/health` reported a hard-coded `0.1.0`** instead of the real service version.
|
||||
`sfm/server.py` now derives its version from `minimateplus.event_file_io.TOOL_VERSION`,
|
||||
making that constant the single source of truth for the service version and the
|
||||
sidecar stamp alike — one place to bump at release.
|
||||
|
||||
- **`CLAUDE.md` had 793 NUL bytes appended** after its last line, which made `grep`
|
||||
treat the file as binary and silently skip it. Present since at least v0.21.0.
|
||||
Stripped.
|
||||
|
||||
### Added
|
||||
- **`docs/offset_investigation.md`** — a dated journal of the "offset" hardware
|
||||
fault: base rate, detector design, per-unit case files, ruled-out hypotheses
|
||||
(each kept with the evidence that killed it), and Instantel's own autozero
|
||||
procedure with its 2027–2069 acceptance window.
|
||||
- **`scratch/verify_against_ascii.py`** — decodes a corpus of BW binaries and
|
||||
diffs every sample against the paired `_ASCII.TXT`. Includes a saturation
|
||||
carve-out: BW clamps clipped events to the range maximum and writes `OORANGE`,
|
||||
while the decoder faithfully reports counts past nominal full scale.
|
||||
- **`scratch/offset_scan3.py`** — offset detector. Measures the resting floor in
|
||||
the *pre-trigger* window (definitionally quiet) and requires it to hold across
|
||||
pre / middle / end. Result: **5 of 45 units (11%)**, stable across a 2x
|
||||
threshold range. Supersedes `offset_scan.py` and `offset_scan2.py`, both kept
|
||||
as the reasoning trail.
|
||||
|
||||
### Verified
|
||||
- **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 systematic
|
||||
zero-point bias in the decoder — an independent confirmation of the
|
||||
32000-count geo full scale, arrived at from a different direction than the
|
||||
ASCII sample comparisons.
|
||||
|
||||
---
|
||||
|
||||
## v0.26.0 — 2026-08-27
|
||||
|
||||
**Series-3 decode correctness.** Two body-model rewrites, a systematic
|
||||
scale error affecting every geophone reading ever produced, a recovered
|
||||
file format, and two artifact-hygiene bugs where stale files outlived the
|
||||
decodes that made them. All 11,603 series-3 binaries in the production
|
||||
snapshot now pass every check.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Series-3 health sweep: 11,603 / 11,603 binaries now clean on every check.**
|
||||
Swept every series-3 file with the live decoder against five independent
|
||||
checks — decode exceptions, zero samples, unequal geo channel lengths, peaks
|
||||
above range full scale, decoded peak vs the device-reported PPV, and waveform
|
||||
length vs the declared record time. Three real defects surfaced and were
|
||||
fixed:
|
||||
|
||||
- **`block[22]` is not a constant and must not be tested.** It was documented
|
||||
as always `0x00` but carries data on loud blocks, and rejecting those threw
|
||||
away the interval holding the event peak.
|
||||
`BE18350/T350L7HR.NL0H` block 92 has `block[22]=0x26` and a Tran peak of
|
||||
`0x0563` = 1379 counts = **6.895 in/s** — exactly the device-reported PPV —
|
||||
while the file as a whole decoded to 0.015 in/s. `block[0]==0x00`,
|
||||
`block[4]==0x0A` and the 4-byte tail are six bytes of constraint, which is
|
||||
what keeps trailer content out.
|
||||
|
||||
- **Block-model dispatch now goes on signature strength, not on whichever
|
||||
decoder returns first.** A multi-interval body also yields scattered
|
||||
standard-tail blocks by coincidence; dispatching on "first non-empty"
|
||||
handed 193 BE18193 files to the standard walker and produced peaks of
|
||||
149 in/s against a 10 in/s full scale.
|
||||
|
||||
- **Multi-interval stride detection requires the block counter to increment
|
||||
by exactly 1.** Without it the detector false-positives on ordinary
|
||||
standard-block bodies: those carry a header every 32 bytes, and
|
||||
`192 = 12 + 20×9` and `512 = 12 + 20×25` are both multiples of 32, so a
|
||||
stride "fits" while actually skipping 6 or 16 real blocks. That misrouted
|
||||
9,082 files.
|
||||
|
||||
Partial-block garbage is now trimmed within the final block only, stopping at
|
||||
the first slot with a non-zero tail word or a geo peak above full scale.
|
||||
Trimming purely from the end left garbage stranded behind one slot that
|
||||
happened to have a zero tail word; trimming on the tail word alone truncated
|
||||
four BE9440 files by up to 2,800 intervals.
|
||||
|
||||
- **Sub-minute histogram intervals are packed several to a block — 415 files
|
||||
recovered.** The device always writes one minute of data per block, so a
|
||||
shorter interval just means more intervals in a longer block:
|
||||
|
||||
| interval | intervals/block | stride |
|
||||
|---|---|---|
|
||||
| 1 min | 1 | 32 (the standard block) |
|
||||
| 15 s | 4 | 92 |
|
||||
| 2 s | 30 | 612 |
|
||||
|
||||
`stride = 12 + n * 20`. Each 20-byte record carries 8 × uint16
|
||||
**little**-endian values — peak and half-period per channel — plus a 2-word
|
||||
tail whose first word is `0000` on every real interval (a session ending
|
||||
mid-block leaves buffer garbage in the remaining slots, which decoded as
|
||||
peaks thousands of times the real value until that check was added).
|
||||
**The standard 32-byte block is big-endian; this variant is not.**
|
||||
|
||||
These 415 files (216 on BE18193 at 2 s intervals, 199 on BE9440 at 15 s)
|
||||
previously decoded to nothing at all — and before that were being accepted
|
||||
by the *waveform* codec, which returned garbage peaking up to 400× the
|
||||
device-reported PPV.
|
||||
|
||||
Ground truth `BE9440/K440L3AQ.T70H` — 5,710 intervals — matches its
|
||||
Blastware ASCII export on **17,130/17,130** geo peaks, **22,840/22,840**
|
||||
frequencies and **5,710/5,710** mic dB(L) values. Across all 455 affected
|
||||
files, **1,354/1,365 (99.2%)** channel peaks match the device-reported PPV;
|
||||
the 11 that don't are under-reads on BE9440 where the walk stops early.
|
||||
|
||||
- **`backfill_sidecars.py` now removes a stale `.h5` when nothing decodes.**
|
||||
It previously skipped the write "so we don't replace whatever's there with an
|
||||
empty placeholder", which silently preserved output from a superseded
|
||||
decoder. After the record-chain fix, 415 histogram files stopped decoding (an
|
||||
unmapped block variant on BE18193 and BE9440) but kept `.h5` files whose peaks
|
||||
ran up to **400× the device's own reported PPV** — garbage feeding the charts
|
||||
and the false-trigger detector with nothing marking it. Reports a
|
||||
`stale_h5_removed` count.
|
||||
|
||||
- **The series-3 waveform body is a RECORD CHAIN, not a tag stream — this
|
||||
supersedes the segment-header model, including the fixes made earlier the
|
||||
same day.**
|
||||
|
||||
Records are self-delimiting. `off+2` is a `uint16 BE` length and
|
||||
`next_record = off + 2 + len`; the chain ends on a record whose `chan_id` is
|
||||
`0x06`. `off+8` carries a 3-valued mode enum:
|
||||
|
||||
| mode | header | data section |
|
||||
|---|---|---|
|
||||
| `02 00` | 14 B | anchors, then **cumulative deltas** |
|
||||
| `01 00` | 10 B | no anchors, **absolute** values |
|
||||
| `00 03` | 10 B | **no tags at all** — raw 12-bit packed absolute |
|
||||
|
||||
**`40 NN` is an ordinary int16 BE data block** (`2*NN + 2`), never a segment
|
||||
header. Reading it as a `2*NN + 16` header is what made walks drift — and the
|
||||
"variable-prefix segment descriptors" reported earlier today were not a format
|
||||
feature at all, just walker drift of exactly
|
||||
`4 - (old_stop - true_record_start)` on all 25 affected files.
|
||||
|
||||
Measured against the production snapshot:
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| all four channels equal length | 156 / 1388 | **1388 / 1388** |
|
||||
| ASCII sample-count exact | 72 / 75 | **75 / 75** |
|
||||
| ASCII fully exact | 70 / 75 | **73 / 75** |
|
||||
| device PPV, waveform (live decode) | 1288 / 1306 | **1306 / 1306** |
|
||||
| device PPV, histogram (live decode) | 4434 / 4459 | **4458 / 4459** |
|
||||
|
||||
Mean absolute PPV ratio error on waveforms is now 0.00000. The 2 remaining
|
||||
ASCII imperfections differ by exactly 1 LSB on samples sitting at the
|
||||
±10.000 in/s rail.
|
||||
|
||||
**This also eliminated the walker-over-read class.** 24 of those 35 files
|
||||
were histograms that `read_blastware_file` fed to the *waveform* codec first;
|
||||
the old walker accepted them and returned garbage (one yielded 98,923
|
||||
"intervals"), while the record-chain decoder correctly returns `None` so they
|
||||
fall through to `histogram_codec`.
|
||||
|
||||
`00 03` records are decoded rather than skipped. Skipping them does not merely
|
||||
lose samples — it silently shifts the time base of everything after them on
|
||||
that channel (observed on `BE9558/K558LOF2.820W`, MicL displaced by exactly
|
||||
512 samples with nothing marking the gap).
|
||||
|
||||
Footer detection now prefers whichever `0e 08` candidate yields a chain
|
||||
terminating on `0x06`, since the signature can occur inside a sample stream.
|
||||
Blast radius: 1 file of 1,388.
|
||||
|
||||
The superseded model is retained as `decode_waveform_legacy` and pinned by
|
||||
`micromate/idf_file.py`, whose Thor IDFW body-offset search trial-decodes
|
||||
candidate offsets and keeps whichever yields the most samples — the new
|
||||
decoder correctly returns `None` where the old one returned garbage, which
|
||||
changes that heuristic's winner. Switching Thor over is deferred until that
|
||||
search is reworked to use the record chain directly.
|
||||
|
||||
- **Series-3 histogram block is uniformly big-endian, and the stream's final
|
||||
block has its own tail — the codec was clipping large peaks and dropping the
|
||||
last interval of nearly every histogram.**
|
||||
|
||||
- **Peaks and half-periods are `uint16` big-endian**, not `uint8` plus an
|
||||
"annotation" byte: `T_peak` `[5:7]`, `T_halfperiod` `[7:9]`, `V_peak`
|
||||
`[9:11]`, and so on. Only `block_ctr` `[2:4]` is little-endian. 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 Blastware's own 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 each 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, and that interval is
|
||||
frequently the one holding the event peak — so the file's reported PPV
|
||||
came out low.
|
||||
|
||||
Verified against **1211 production histograms** paired with their preserved
|
||||
Blastware ASCII exports, which carry a full per-interval data table:
|
||||
**1211/1211 now decode exactly** (interval count plus every per-interval
|
||||
peak), and 842,442 per-interval frequency comparisons match with zero
|
||||
mismatches. Before this fix: **1 of 1196**.
|
||||
|
||||
`decode_histogram_body_full` records now expose `is_terminal` in place of
|
||||
the removed `annotations` tuple.
|
||||
|
||||
- **Geophone full scale is 32000 ADC counts, not 32768 — every geo reading was
|
||||
2.3% low.** The verified body codec emits geo samples in 16-count units whose
|
||||
documented LSB is exactly 0.005 in/s, and `decoded_to_adc_counts` multiplies
|
||||
by 16, so one ADC count is `0.005/16` in/s and Normal range (10.000 in/s) is
|
||||
`10.0 / (0.005/16)` = **32000** counts. Both `sfm/event_hdf5.py` and
|
||||
`minimateplus/event_file_io.py` divided by 32768, scaling every geophone
|
||||
sample and every derived peak down by `1 - 32000/32768` = **2.34%**.
|
||||
|
||||
Measured against 216 per-channel comparisons with preserved Blastware ASCII
|
||||
exports: **32768 → 151/216 exact** (worst error 0.238 in/s on a 10 in/s
|
||||
event); **32000 → 216/216 exact**, worst error 0.005 in/s (exactly 1 LSB —
|
||||
pure quantization). The error scales with amplitude, so it was invisible on
|
||||
quiet events and worst on the loud ones that matter for compliance.
|
||||
|
||||
The mic path is unaffected — it back-solves its own per-count factor from the
|
||||
device-reported peak.
|
||||
|
||||
**Scope:** the scale lives in `_samples_to_float`, which every event passes
|
||||
through regardless of which codec produced the samples — so this affected
|
||||
**waveforms, histograms and series-4 (Thor IDF) alike**, not just waveforms.
|
||||
Verified after regeneration: series-3 histogram peaks vs their ASCII reports
|
||||
now sit at a median ratio of 1.0000 across 1,137 comparisons (0.9766 under
|
||||
32768); series-4 peaks vs device peaks moved from a median 0.960 to 0.983
|
||||
across 1,468 comparisons. The four block-framing fixes below are
|
||||
waveform-only — histograms decode via `histogram_codec.decode_histogram_body`,
|
||||
which is untouched.
|
||||
|
||||
- **Series-3 waveform codec: four block-framing cases caused silent channel
|
||||
truncation.** `walk_body` hit its unknown-tag `break` mid-stream and every
|
||||
channel decoded after that point came out short — typically Vert/Long/MicL,
|
||||
sometimes at a third of their true length, with no error raised.
|
||||
|
||||
- **Wide-NN RLE `0X NN`** — the 12-bit NN encoding already handled for
|
||||
`1X NN` / `2X NN` also applies to the `00 NN` RLE tag. Runs longer than
|
||||
252 samples must use the wide form (e.g. `01 0c` = 268 repeats).
|
||||
- **`30 NN` with NN > 0x10** — the `0 < NN <= 0x10` guard was arbitrary;
|
||||
data-section `30 NN` blocks reach at least NN = 0x18. The length formula
|
||||
(`NN × 1.5 + 2`) was already correct.
|
||||
- **Variable-width `40 NN` segment headers** — NN is the *count of
|
||||
previous-channel continuation deltas*, so the header is `2 × NN + 16`
|
||||
bytes and every field after the deltas shifts by `2 × NN`. Only `40 02`
|
||||
(20 bytes) was handled; `40 01` (18) and `40 03` (22) both occur.
|
||||
- **Tagless segment headers** — a segment 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 (no continuation deltas needed, so no tag and no delta bytes). It is
|
||||
where the walk stopped in 7 of the 8 events still truncating after the
|
||||
first three fixes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Segment channel now comes from the header's own channel-id byte** rather
|
||||
than from rotation position. The field previously documented as a
|
||||
"monotonic uint32 LE counter" is really `[channel][00][00][segment_index]`
|
||||
with `0x46`=Tran `0x47`=Vert `0x48`=Long `0x49`=MicL — verified on
|
||||
**1697 of 1697** segment headers across the ground-truth corpus with zero
|
||||
disagreements. Rotation-by-position is kept only as a fallback for unknown
|
||||
ids; it was fragile because a single missed or extra header (exactly what
|
||||
tagless headers caused) desynced every channel after it.
|
||||
|
||||
- **`parse_segment_header` return shape** — now `n_prev_deltas`,
|
||||
`prev_deltas`, `marker`, `anchors`, `channel`, `segment_index` in place of
|
||||
the fixed-offset `anchor_bytes` / `fixed_pattern` / `tail` keys. The old
|
||||
`fixed_pattern` (`02 00 00 01`) conflated the 2-byte constant marker with
|
||||
the first anchor. `counter` is retained as the raw uint32 of the id field.
|
||||
|
||||
### Verification
|
||||
|
||||
Against the 75 ground-truth events (BW binary paired with its preserved
|
||||
`_ASCII.TXT` export), decoding end-to-end through the production path:
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| exact (full length, within 1 LSB) | 37 | **72** |
|
||||
| truncated | 23 | **3** |
|
||||
| full length, value error > 2 LSB | 15 | **0** |
|
||||
|
||||
Worst remaining error among the 72: 0.0050 in/s = exactly 1 LSB.
|
||||
|
||||
No regressions — the byte-exact fixture suite still passes, and the full-suite
|
||||
failure list is unchanged from baseline (16 pre-existing failures from
|
||||
gitignored fixtures).
|
||||
|
||||
### Notes
|
||||
|
||||
- **The "DC offset" symptom is _not_ a decode bug.** Events whose geo trace
|
||||
sits at a constant level instead of oscillating around zero
|
||||
(dominant-axis `|mean| / peak` >> 0) reproduce *exactly* in Blastware's own
|
||||
ASCII export — e.g. `BE12599/N599LQD7.8E0W` Tran reads mean +0.345,
|
||||
min +0.335, max +0.355 in both. It is a known recurring hardware fault (the
|
||||
operators call it an "offset"): the affected channel's baseline exceeds the
|
||||
unit's own geo trigger level, so the unit retriggers continuously and floods
|
||||
the ACH queue with garbage events. Store-wide it affects 2 units of 21 across
|
||||
6 episodes; see `scratch/offset_candidates.csv` and the project memory notes.
|
||||
|
||||
- ~~**Still open:** 3 of 75 ground-truth events truncate at a segment-header
|
||||
variant with a variable-width prefix.~~ **Resolved later the same day** — the
|
||||
record-chain rewrite (above) showed there is no variable prefix; it was
|
||||
walker drift. All 75 are now sample-count exact.
|
||||
|
||||
- **Still open after this release:**
|
||||
- **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.
|
||||
- **14 sensitive-range files** show a decoded/truth ratio of exactly 8.0
|
||||
(= 10.0/1.25) — a units bug, not a codec one. Never chased.
|
||||
- **`backfill_sidecars.py --force` also inserts DB rows** for store files
|
||||
that have none (1,286 on the snapshot; one-time per store), and the
|
||||
dry-run does not report that count before you commit to it.
|
||||
- **Verification is uneven:** per-sample proof on the 11% of files with a
|
||||
preserved `_ASCII.TXT`, peak-and-structure consistency on the other 89%.
|
||||
|
||||
---
|
||||
|
||||
## v0.25.0 — 2026-08-25
|
||||
|
||||
**reviewed_real 3-state review flag + twin review-propagation.** The
|
||||
`events` table and the `/db/events` feed now carry `reviewed_real`, a
|
||||
3-state review flag mutually exclusive with `false_trigger`, mirrored from
|
||||
the sidecar review block — plus histogram/waveform twin review-propagation
|
||||
(flagging one flags both, matched by serial + identical PVS + timestamp
|
||||
window).
|
||||
|
||||
### Added
|
||||
|
||||
- **`events` column** `reviewed_real` — `INTEGER NOT NULL DEFAULT 0`, added
|
||||
via the existing incremental `_migrate` ADD COLUMN pass (auto-migrates on
|
||||
`SeismoDb()` construction, no manual migration). `query_events` /
|
||||
`get_event` (and thus `/db/events`) return it automatically (`SELECT *`).
|
||||
- **Mutual exclusivity with `false_trigger`** — setting `reviewed_real=1`
|
||||
clears `false_trigger`, and vice versa, enforced on both review paths:
|
||||
the sidecar review PATCH (`update_event_review`) and the quick
|
||||
`PATCH /db/events/{id}/false_trigger` endpoint (`set_false_trigger`).
|
||||
- **`find_twins`** — matches an event's histogram/waveform twins by serial +
|
||||
identical peak-vector-sum + a timestamp window.
|
||||
- **`propagate_review_to_twins`** — copies an event's `false_trigger`/
|
||||
`reviewed_real` state onto its twins, wired into both the
|
||||
`PATCH /db/events/{id}/sidecar` review path and the quick
|
||||
`PATCH /db/events/{id}/false_trigger` path, so flagging one flags both
|
||||
regardless of which endpoint made the change.
|
||||
|
||||
---
|
||||
|
||||
## v0.24.0 — 2026-08-22
|
||||
|
||||
**Waveform-shape metrics on events.** The `events` table and the `/db/events`
|
||||
|
||||
@@ -2,7 +2,49 @@
|
||||
|
||||
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.21.0**.
|
||||
(Sierra Wireless RV50 / RV55). Current version: **v0.27.0**.
|
||||
|
||||
---
|
||||
|
||||
## 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 owes prod a backfill:** the partial-final-block fix recovers 4
|
||||
histograms that are still empty in the store.
|
||||
- **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.
|
||||
|
||||
|
||||
When new information about the protocol is discovered, please update the instantel_protocol_reference.md with the findings in addition to this document
|
||||
|
||||
@@ -223,6 +265,118 @@ custom delta + RLE + variable-width codec.
|
||||
`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
|
||||
@@ -230,9 +384,25 @@ custom delta + RLE + variable-width codec.
|
||||
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.
|
||||
- **Walker edge cases** — SP0/SS0/SV0 don't walk the full event due
|
||||
to block-length quirks past the first few segments. Every sample
|
||||
reached is correct; the walker just needs robustness improvements.
|
||||
- **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)
|
||||
|
||||
@@ -264,6 +434,14 @@ 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.
|
||||
|
||||
@@ -1640,4 +1818,4 @@ body) because writing a dial string may require DLE escaping for embedded contro
|
||||
|
||||
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`). | ||||