Two independent bugs, both found by diffing 75 production events against
their preserved Blastware ASCII exports (<store>/<serial>/<file>_ASCII.TXT).
1. Geo full scale was wrong — every geophone reading was 2.34% low.
The codec emits geo samples in 16-count units with a documented LSB of
exactly 0.005 in/s, and decoded_to_adc_counts multiplies by 16, so one
ADC count is 0.005/16 in/s and 10.000 in/s is 10.0/(0.005/16) = 32000
counts. sfm/event_hdf5.py and minimateplus/event_file_io.py both
divided by 32768 (2^15), scaling every sample and derived peak down by
1 - 32000/32768. The error scales with amplitude, so it was invisible
on quiet events and worst on the loud ones that matter for compliance.
Mic is unaffected (it back-solves its scale from the device peak).
216 per-channel comparisons: 32768 -> 151/216 exact, worst error 0.238
in/s on a 10 in/s event; 32000 -> 216/216 exact, worst 0.005 = 1 LSB.
2. walk_body silently truncated channels on four unhandled framing cases.
An unrecognised tag ends the walk and decode_waveform_v2 returns
whatever it got, so this surfaced as short channels, never an error:
- wide-NN RLE `0X NN` (runs longer than 252 samples)
- `30 NN` with NN > 0x10 (the old cap was arbitrary)
- variable-width `40 NN` headers: NN counts previous-channel
continuation deltas, so the header is 2*NN + 16 bytes; `40 01`
and `40 03` occur alongside `40 02`
- tagless segment headers: no `40 NN` tag at all, just the 14-byte
tail [field2:2][len:2][channel_id:4][marker:2][anchors:4]
Also: the header field documented as a "monotonic uint32 LE counter" is
really [channel_id][00][00][segment_index], with 0x46=Tran 0x47=Vert
0x48=Long 0x49=MicL — verified on 1697/1697 segment headers, zero
disagreements. decode_waveform_v2 now takes the channel from that field
instead of rotation position, which was fragile: one missed header
desynced every channel after it.
parse_segment_header now returns n_prev_deltas/prev_deltas/marker/
anchors/channel/segment_index; the old fixed_pattern (02 00 00 01)
conflated the 2-byte marker with the first anchor.
Ground-truth corpus, end to end through the production path:
exact 37 -> 72, truncated 23 -> 3, full-length value errors 15 -> 0.
Store-wide, 729 of 1388 series-3 waveform events decode differently and
728 gain samples; the scale fix changes float values on all of them, so
stored .h5 files need regenerating.
Still open: 3 events truncate at a header variant with a variable-width
prefix (2/4/6 bytes) before the channel id and an `01 00` marker.
Documented in docs/instantel_protocol_reference.md with byte offsets.
+20 tests. 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).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgTe8CamXAHcAmaQ6QNcog
335 lines
13 KiB
Python
335 lines
13 KiB
Python
"""
|
|
test_event_hdf5.py — HDF5 codec round-trip + plot.v1 JSON shape sanity.
|
|
|
|
Run:
|
|
python tests/test_event_hdf5.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import pytest
|
|
except ImportError:
|
|
pytest = None # type: ignore
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from minimateplus.framing import S3Frame
|
|
from minimateplus.models import Event, PeakValues, ProjectInfo, Timestamp
|
|
from sfm import event_hdf5
|
|
|
|
|
|
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_event_with_samples(n: int = 256) -> Event:
|
|
"""An Event with synthetic int16 ADC samples on all four channels.
|
|
|
|
Channel content:
|
|
- Tran: ramp from -16384 to +16383 (peak ≈ 5 in/s for Normal range)
|
|
- Vert: full-scale dirac at index n//2 (peak = 10 in/s)
|
|
- Long: zeros
|
|
- MicL: small ramp
|
|
Peak values are set on the event the way the device's 0C record
|
|
would supply them — used by the HDF5 writer for the mic per-count
|
|
factor.
|
|
"""
|
|
tran = [int((i / max(n - 1, 1)) * 32767 - 16384) for i in range(n)]
|
|
vert = [0] * n
|
|
if n:
|
|
vert[n // 2] = 32767
|
|
long_ = [0] * n
|
|
mic = [int((i / max(n - 1, 1)) * 5000) for i in range(n)]
|
|
|
|
ev = Event(index=0)
|
|
ev._waveform_key = bytes.fromhex("01110000")
|
|
ev.timestamp = Timestamp(
|
|
raw=b"", flag=0x10,
|
|
year=2026, unknown_byte=0, month=5, day=7,
|
|
hour=10, minute=0, second=0,
|
|
)
|
|
ev.record_type = "Waveform"
|
|
ev.sample_rate = 1024
|
|
ev.pretrig_samples = n // 4
|
|
ev.total_samples = n
|
|
ev.rectime_seconds = n / 1024.0
|
|
ev.raw_samples = {"Tran": tran, "Vert": vert, "Long": long_, "MicL": mic}
|
|
ev.peak_values = PeakValues(
|
|
tran=5.0, vert=10.0, long=0.0,
|
|
peak_vector_sum=10.0, micl=0.001,
|
|
)
|
|
ev.project_info = ProjectInfo(
|
|
project="TestProj", client="TestClient",
|
|
operator="brian", sensor_location="loc-A",
|
|
)
|
|
return ev
|
|
|
|
|
|
# ── HDF5 round-trip ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_hdf5_round_trip_preserves_metadata(tmp_path: Path):
|
|
ev = _make_event_with_samples()
|
|
h5 = tmp_path / "test.h5"
|
|
event_hdf5.write_event_hdf5(
|
|
h5, ev, serial="BE11529", geo_range="normal",
|
|
)
|
|
|
|
data = event_hdf5.read_event_hdf5(h5)
|
|
a = data["attrs"]
|
|
assert a["schema_version"] == event_hdf5.SCHEMA_VERSION
|
|
assert a["kind"] == event_hdf5.HDF5_KIND
|
|
assert a["serial"] == "BE11529"
|
|
assert a["waveform_key"] == "01110000"
|
|
assert a["sample_rate"] == 1024
|
|
assert a["pretrig_samples"] == 64
|
|
assert a["geo_range"] == "normal"
|
|
assert a["geo_full_scale_ips"] == 10.0
|
|
assert a["project"] == "TestProj"
|
|
assert a["client"] == "TestClient"
|
|
assert a["operator"] == "brian"
|
|
# Float attrs may round-trip with tiny precision noise.
|
|
assert abs(a["peak_tran_ips"] - 5.0) < 1e-6
|
|
assert abs(a["peak_vert_ips"] - 10.0) < 1e-6
|
|
|
|
|
|
def test_hdf5_samples_in_physical_units_normal_range(tmp_path: Path):
|
|
"""Vert hits 32767 ADC counts → with Normal range FS=10 in/s that is
|
|
``10 * 32767/32000`` in/s.
|
|
|
|
Geo full scale is 32000 counts, not 32768 (see
|
|
test_geo_full_scale_count_is_32000), so 32767 counts sits slightly
|
|
ABOVE nominal full scale -- the ADC has headroom past 10.000 in/s,
|
|
which is why Blastware reports peaks like 10.14 in/s. This test
|
|
previously asserted the 32768 scale and was wrong by 2.3%."""
|
|
ev = _make_event_with_samples()
|
|
h5 = tmp_path / "n.h5"
|
|
event_hdf5.write_event_hdf5(h5, ev, serial="BE11529", geo_range="normal")
|
|
data = event_hdf5.read_event_hdf5(h5)
|
|
|
|
vert = data["samples"]["Vert"]
|
|
assert vert.dtype.name == "float32"
|
|
assert max(abs(v) for v in vert) > 9.99 # full-scale ≈ 10.0
|
|
# The dirac was at n//2 → 32767 ADC counts.
|
|
expected_peak = 10.0 * 32767 / 32000
|
|
assert abs(max(vert) - expected_peak) < 1e-3
|
|
|
|
|
|
def test_hdf5_samples_in_physical_units_sensitive_range(tmp_path: Path):
|
|
"""Same fixture but Sensitive range → full-scale 1.250 in/s."""
|
|
ev = _make_event_with_samples()
|
|
h5 = tmp_path / "s.h5"
|
|
event_hdf5.write_event_hdf5(h5, ev, serial="BE11529", geo_range="sensitive")
|
|
data = event_hdf5.read_event_hdf5(h5)
|
|
|
|
vert = data["samples"]["Vert"]
|
|
expected_peak = 1.250 * 32767 / 32000
|
|
assert abs(max(vert) - expected_peak) < 1e-4
|
|
|
|
|
|
def test_hdf5_includes_int16_samples(tmp_path: Path):
|
|
ev = _make_event_with_samples()
|
|
h5 = tmp_path / "i.h5"
|
|
event_hdf5.write_event_hdf5(h5, ev, serial="BE11529")
|
|
data = event_hdf5.read_event_hdf5(h5)
|
|
assert data["samples_int16"] is not None
|
|
assert "Tran" in data["samples_int16"]
|
|
assert data["samples_int16"]["Vert"].dtype.name == "int16"
|
|
|
|
|
|
def test_hdf5_rejects_unsupported_schema(tmp_path: Path):
|
|
"""Round-tripping with a tampered schema_version raises ValueError."""
|
|
import h5py
|
|
h5 = tmp_path / "future.h5"
|
|
with h5py.File(h5, "w") as f:
|
|
f.attrs["schema_version"] = 99
|
|
f.attrs["kind"] = event_hdf5.HDF5_KIND
|
|
try:
|
|
event_hdf5.read_event_hdf5(h5)
|
|
except ValueError as exc:
|
|
assert "schema_version" in str(exc)
|
|
return
|
|
raise AssertionError("read_event_hdf5 should reject unsupported schema_version")
|
|
|
|
|
|
# ── plot.v1 JSON shape ────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_event_to_plot_json_shape():
|
|
ev = _make_event_with_samples()
|
|
j = event_hdf5.event_to_plot_json(ev, serial="BE11529", geo_range="normal")
|
|
assert j["schema"] == "sfm.plot.v1"
|
|
assert j["serial"] == "BE11529"
|
|
assert j["geo_range"] == "normal"
|
|
assert j["geo_full_scale_ips"] == 10.0
|
|
assert j["trigger_ms"] == 0.0
|
|
|
|
t = j["time_axis"]
|
|
assert t["sample_rate"] == 1024
|
|
assert t["pretrig_samples"] == 64
|
|
assert t["n_samples"] == 256
|
|
# t0_ms = -pretrig * dt_ms = -64 * (1000/1024) ≈ -62.5
|
|
assert abs(t["t0_ms"] - (-64 * 1000 / 1024)) < 1e-3
|
|
assert abs(t["dt_ms"] - (1000 / 1024)) < 1e-6
|
|
|
|
chans = j["channels"]
|
|
for name in ("Tran", "Vert", "Long", "MicL"):
|
|
assert name in chans, f"missing channel: {name}"
|
|
assert chans[name]["unit"] in ("in/s", "psi")
|
|
assert "values" in chans[name]
|
|
assert "peak" in chans[name]
|
|
assert "peak_t_ms" in chans[name]
|
|
|
|
# Values are in physical units: Vert peak ≈ 10 in/s.
|
|
assert max(chans["Vert"]["values"]) > 9.99
|
|
|
|
|
|
def test_event_to_plot_json_peak_t_ms_locates_dirac():
|
|
"""The Vert channel's full-scale dirac at sample n//2 should produce
|
|
peak_t_ms = (n//2 - pretrig) * dt_ms."""
|
|
ev = _make_event_with_samples(n=256)
|
|
j = event_hdf5.event_to_plot_json(ev, serial="BE11529")
|
|
expected = (128 - 64) * (1000 / 1024) # = 62.5 ms
|
|
assert abs(j["channels"]["Vert"]["peak_t_ms"] - expected) < 1e-2
|
|
|
|
|
|
def test_plot_json_from_hdf5_round_trip(tmp_path: Path):
|
|
"""plot_json_from_hdf5 produces the same shape as event_to_plot_json."""
|
|
ev = _make_event_with_samples()
|
|
h5 = tmp_path / "rt.h5"
|
|
event_hdf5.write_event_hdf5(h5, ev, serial="BE11529", geo_range="normal")
|
|
|
|
j_disk = event_hdf5.plot_json_from_hdf5(h5, event_id="abc-123")
|
|
j_mem = event_hdf5.event_to_plot_json(ev, serial="BE11529", geo_range="normal", event_id="abc-123")
|
|
|
|
# Top-level shape parity
|
|
for k in ("schema", "serial", "geo_range", "geo_full_scale_ips",
|
|
"trigger_ms", "record_type", "waveform_key", "event_id"):
|
|
assert j_disk.get(k) == j_mem.get(k), f"mismatch on {k}"
|
|
assert j_disk["time_axis"]["sample_rate"] == j_mem["time_axis"]["sample_rate"]
|
|
assert j_disk["time_axis"]["n_samples"] == j_mem["time_axis"]["n_samples"]
|
|
|
|
# Sample values must match within float32 precision.
|
|
for ch in ("Tran", "Vert", "Long", "MicL"):
|
|
a = j_disk["channels"][ch]["values"]
|
|
b = j_mem["channels"][ch]["values"]
|
|
assert len(a) == len(b)
|
|
if a:
|
|
mx = max(abs(x - y) for x, y in zip(a, b))
|
|
assert mx < 1e-3, f"{ch}: max diff {mx}"
|
|
|
|
|
|
# ── WaveformStore integration with HDF5 ───────────────────────────────────────
|
|
|
|
|
|
def _make_synthetic_event_for_save() -> tuple[Event, list[S3Frame]]:
|
|
"""Same flavour as test_event_file_io.py but ensures _make_event_with_samples
|
|
is also wired into the BW write path so we can exercise WaveformStore.save."""
|
|
ev = _make_event_with_samples(n=128)
|
|
# Build a minimum 3-frame A5 stream (probe + sample + term) — same
|
|
# shape used in the other test files. The encoder only really needs
|
|
# the STRT in the probe + a non-zero body and a footer in the term.
|
|
key4 = ev._waveform_key
|
|
rectime = int(ev.rectime_seconds or 0) or 1
|
|
strt = bytearray(21)
|
|
strt[0:4] = b"STRT"
|
|
strt[4:6] = b"\xff\xfe"
|
|
strt[6:10] = key4
|
|
strt[10:14] = key4
|
|
strt[18] = rectime
|
|
probe = S3Frame(sub=0xA5, page_hi=0x10, page_lo=0x00,
|
|
data=bytes(7) + bytes(strt) + bytes(32),
|
|
checksum_valid=True, chk_byte=0x00)
|
|
sample = S3Frame(sub=0xA5, page_hi=0x00, page_lo=0x10,
|
|
data=bytes(7) + bytes(0x0200), checksum_valid=True, chk_byte=0x00)
|
|
footer = (
|
|
b"\x0e\x08"
|
|
+ bytes([7, 5, 0x07, 0xea, 0, 10, 0, 0])
|
|
+ bytes([7, 5, 0x07, 0xea, 0, 10, 0, 1])
|
|
+ b"\x00\x01\x00\x02\x00\x00\x00\x00"
|
|
)
|
|
term = S3Frame(sub=0xA5, page_hi=0x00, page_lo=0x00,
|
|
data=bytes(11) + bytes(38) + footer, checksum_valid=True, chk_byte=0x00)
|
|
ev._a5_frames = [probe, sample, term]
|
|
return ev, [probe, sample, term]
|
|
|
|
|
|
def test_waveform_store_save_emits_hdf5(tmp_path: Path):
|
|
from sfm.waveform_store import WaveformStore
|
|
store = WaveformStore(tmp_path / "waveforms")
|
|
ev, frames = _make_synthetic_event_for_save()
|
|
rec = store.save(ev, serial="BE11529", a5_frames=frames, geo_range="normal")
|
|
|
|
assert rec["hdf5_filename"], "hdf5_filename should be present in save() record"
|
|
h5 = store.hdf5_path_for("BE11529", rec["filename"])
|
|
assert h5.exists(), "WaveformStore.save should produce a .h5 file"
|
|
# The HDF5 round-trip should match the event's metadata.
|
|
data = event_hdf5.read_event_hdf5(h5)
|
|
assert data["attrs"]["serial"] == "BE11529"
|
|
assert data["attrs"]["geo_range"] == "normal"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if pytest is not None:
|
|
pytest.main([__file__, "-v"])
|
|
else:
|
|
import inspect
|
|
import traceback as _tb
|
|
|
|
passed = failed = 0
|
|
for _name, _fn in sorted(globals().items()):
|
|
if not _name.startswith("test_") or not callable(_fn):
|
|
continue
|
|
try:
|
|
_sig = inspect.signature(_fn)
|
|
if "tmp_path" in _sig.parameters:
|
|
with tempfile.TemporaryDirectory() as _td:
|
|
_fn(Path(_td))
|
|
else:
|
|
_fn()
|
|
print(f"PASS {_name}")
|
|
passed += 1
|
|
except Exception:
|
|
print(f"FAIL {_name}")
|
|
_tb.print_exc()
|
|
failed += 1
|
|
print(f"\n{passed} passed, {failed} failed")
|
|
sys.exit(0 if failed == 0 else 1)
|
|
|
|
|
|
# ── Geophone full-scale count ───────────────────────────────────────────────
|
|
|
|
def test_geo_full_scale_count_is_32000():
|
|
"""Geo full scale is 32000 ADC counts, not 32768.
|
|
|
|
The verified body codec emits geo samples in 16-count units with a
|
|
documented LSB of 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.
|
|
|
|
Using 32768 made every geophone reading 2.3% low (1 - 32000/32768).
|
|
Confirmed 2026-08-25 against 216 channel comparisons with preserved
|
|
Blastware ASCII exports: 32000 → 216/216 exact within 1 LSB;
|
|
32768 → 151/216, worst error 0.238 in/s on a 10 in/s event.
|
|
"""
|
|
from sfm.event_hdf5 import _GEO_INT16_FS
|
|
assert _GEO_INT16_FS == 32000.0
|
|
|
|
|
|
def test_samples_to_float_lsb_is_exactly_5_milli_ips():
|
|
"""One decoder unit (= 16 ADC counts) must be exactly 0.005 in/s."""
|
|
from sfm.event_hdf5 import _samples_to_float
|
|
out = _samples_to_float([16], 10.0)
|
|
assert abs(float(out[0]) - 0.005) < 1e-9
|
|
|
|
|
|
def test_samples_to_float_full_scale_count_maps_to_full_scale():
|
|
from sfm.event_hdf5 import _samples_to_float
|
|
assert abs(float(_samples_to_float([32000], 10.0)[0]) - 10.0) < 1e-4
|
|
assert abs(float(_samples_to_float([32000], 1.25)[0]) - 1.25) < 1e-5
|