Productionizes the validated scratch/offset_scan3.py: a DC offset (baseline
shifted off zero — sensor bumped/settled/drifted) is |median(pre-trigger)| >= 5
counts (0.025 in/s) AND flat across pre/mid/end thirds (spread <= 0.02); a
transient moves one third and is rejected by the spread test.
- shape_metrics: offset_from_samples / offset_from_h5 (reads .h5 samples +
pretrig_samples attr; range-aware via the .h5's in/s float samples)
- events schema: shape_offset / _axis / _pre / _spread (via _SCHEMA + the
_migrate ADD COLUMN loop only; NOT the Migration-1 rebuild), threaded through
insert + upsert mirroring shape_*
- ingest: computed at all three waveform_store save paths alongside shape
- backfill_event_shape: also computes + stores (and stale-clears) offset
- exposed via /db/events automatically (SELECT *)
Gating to waveforms is done downstream in terra-view ft_suspicion (mirrors how
shape is ignored for histograms), not at the SFM call sites. 13 new tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
terra-view's SFM Admin page (/admin/sfm) displays whatever /health returns for
`version`. That was hardcoded to "0.1.0" and never bumped, so the page showed
0.1.0 while the service was actually 0.26.0. Point both /health and the FastAPI
OpenAPI version at the release-bumped TOOL_VERSION (single source of truth), so
they can't drift again. Adds httpx-free regression tests (call health() directly).
Note: minimateplus.__version__ is separately stale at 0.1.0 — left as-is here
(nothing user-facing reads it; touching the package __init__ risks import order).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
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). The two twins routinely differ by
HOURS, so the old ±5-minute window in find_twins silently missed them — which
broke review propagation (flagging one twin left its twin unflagged).
Twins are now matched by: same serial + identical peak_vector_sum + OPPOSITE
record type + the waveform's timestamp falling within the histogram's interval
(bounded by the next same-serial histogram). Matching keys off record timestamps
(not call-in/received times, which drift with field connectivity). window_seconds
is retained but ignored.
Rewrote test_find_twins + test_twin_propagation for the new contract (incl. the
75-min-apart UM12947 case, cross-type exclusion, containing-interval selection,
open-ended latest interval). Full suite: 264 passed; the 16 failures are
pre-existing (missing gitignored fixtures + a v0.26.0 codec case), unchanged
from baseline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
Two body-model rewrites, a systematic scale error affecting every
geophone reading the system ever produced, a recovered file format, and
two artifact-hygiene bugs where stale files outlived the decodes that
made them.
- geo full scale is 32000 ADC counts, not 32768 (every reading 2.34% low)
- the waveform body is a record chain, not a tag stream
- the histogram block is big-endian, with a terminal tail
- sub-minute intervals pack several per block (415 files recovered)
- three more defects found by a full-corpus sweep, each masking the next
- stale .h5 files and stale shape_* columns are now cleared, not left
All 11,603 series-3 binaries in the production snapshot pass every check.
Ground truth: 1,211/1,211 histograms exact per-interval, 75/75 waveform
sample counts exact, multi-interval fixture exact on all 45,680 values.
Also corrects a changelog note that went stale within the same day: the
"3 of 75 events still truncate" item was resolved by the record-chain
rewrite, and the remaining open items are now listed explicitly.
CLAUDE.md gains a "Where things stand" block at the top — the header had
been reading v0.21.0, four releases behind, which is the first thing you
see when picking the project back up.
Tests: 259 passed; the 16 failures are pre-existing (gitignored
fixtures) and unchanged from baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgTe8CamXAHcAmaQ6QNcog
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
set_false_trigger now clears reviewed_real when flagging false_trigger=True
(mirrors update_event_review's exclusivity), and the quick
PATCH /db/events/{id}/false_trigger endpoint now calls
propagate_review_to_twins after the flag write, matching the sidecar PATCH
path's try/except-with-log.warning pattern. Previously the quick path could
leave both flags set and never touched twins.
Also corrects the v0.25.0 CHANGELOG bullet (exclusivity was not actually
enforced on the quick path until this commit) and adds a caveat comment on
find_twins about rare clamped/saturated-PVS false-positive twin matches.
IDFH now synthesises a 1-sample-per-interval array from the binary intervals and writes an .h5 so the existing renderer works unchanged. Each "sample" is the per-interval peak ADC count → h5_value = count × geo_fs/32768 yields the right bar height.
Two related fixes to the per-channel stats block:
1. Pin the stats table's position via an explicit bbox= on
ax.table() so the bottom edge is at a known axes-fraction Y.
The previous loc="upper left" + tbl.scale(1, 1.4) combo let
matplotlib choose row heights based on text size, which made the
table extend further below the axes than the hard-coded PVS line
at y=-0.08 expected. Result was the "Peak Vector Sum X in/s"
string landing horizontally inside the Peak Displacement row.
With bbox=[0, 1-N*0.12, 0.80, N*0.12] the table is pinned to a
precise rectangle (12% axes-fraction per row × N rows tall).
_draw_stats_table now stashes the bottom Y on the axes for the
PVS helper to reference, so the geometry stays in sync.
2. Center PVS horizontally (ha="center" at x=0.5 instead of ha="left"
at x=0). The previous left-edge alignment put PVS at the same
X as the label column, which read as "off-center" once the rest
of the stats data was column-aligned further right.
3. Drop the "NA: Not Applicable" caption. It existed to explain
"—" placeholder cells, but "—" is universally understood and the
caption was always visually squished against the PVS line below.
Less cruft on the page; one fewer position to manage.
Verified against a real BE12599 histogram event (5 data rows) and
a real UM12947 IDFW waveform event (6 data rows) — both layouts
clear the table cleanly with no overlap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug shipped in v0.21.0: save_imported_idf called read_idf_file()
with `source_path` (a bare filename like "UM12947_….IDFW") BEFORE
writing the binary to disk. The codec did Path(path).read_bytes()
which resolved relative to /app and hit FileNotFoundError. The
error was caught + logged as a warning, and ingest fell back to
.txt-only — events still landed in the DB but lost the bw_report
block + .h5 waveform that the codec was supposed to produce.
Observed during a full re-forward from thor-watcher on 2026-05-29:
every Thor event logged "binary codec failed for X: [Errno 2] No
such file or directory" and got binary_decoded=False.
Fix:
- read_idf_file() gains a `data: Optional[bytes]` kwarg. When
supplied, skips the disk read and decodes the provided bytes
directly. `path` stays required (used for filename in error
messages + .IDFH vs .IDFW suffix detection); only the read is
conditional. Backward compatible — existing positional callers
(CLI scripts, tests) continue to work unchanged.
- save_imported_idf passes `data=idf_bytes` since the bytes are
already in memory from the multipart upload. Filesystem write
still happens at step 5 of the existing flow; codec just no
longer depends on it.
Verified end-to-end against UM11719_20231219162723.IDFW from the
example-data corpus: ingest endpoint returns inserted=1, log line
shows binary_decoded=True + h5=...IDFW.h5, no warnings.
Re-forward existing Thor events from thor-watcher after deploy to
backfill the bw_report block — UPSERT preserves review state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches Terra-View's event-modal relabel from the same iteration.
Wording was already clearer here than in Terra-View's "Captured at",
but using identical text across both surfaces means operators see the
same label whether they're in the native modal or the standalone
webapp.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The PDF report shows per-channel ZC Freq alongside PPV in the stats
block, but neither modal exposed it. Now that the sidecar projection
carries zc_freq_hz + zc_freq_above_range, plumb them through:
- sfm_webapp.html: inline suffix on existing Peaks cells, e.g.
"Tran 0.04500 in/s · >100 Hz". Empty suffix when no ZC is
available (legacy events without a preserved .TXT).
- event_browser.html: new ZC Freq column on the per-channel stats
table. Required adding a parallel sidecar fetch in loadEvent()
(waveform.json alone doesn't carry bw_report). Fetch failure is
non-fatal — falls back to "—" in the new column.
Above-range ZC peaks (BW ">100 Hz") render with a literal ">"
prefix mirroring the PDF, so operators don't have to generate the
PDF to see when a channel hit the zero-crossing ceiling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BW writes ">100 Hz" for ZC Freq when the zero-crossing algorithm sees a
peak too fast to count — the device's reporting ceiling is 100 Hz on
V10.72. Our parser fell back to None via _parse_number (which requires
a leading digit), so the PDF rendered "—" where BW shows ">100".
Mirrors the OORANGE/saturated pattern already used for PPV and PSPL:
parser stores the threshold (100.0) on zc_freq_hz + sets a new
zc_freq_above_range flag. Projection carries the flag through to the
sidecar; PDF renderer prepends ">" when set.
Affects both per-channel stats tables (waveform + histogram variants)
and the mic block's ZC Freq row.
Verified on the real T190LD5Q.LK0W fixture: Tran zc_freq_hz=100.0
above_range=True; Vert/Long (normal values) above_range=False; "N/A"
still produces zc_freq_hz=None which renders as "—" (unchanged).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related visual bugs on histogram PDFs:
1. Per-channel auto-scale meant Tran/Vert/Long had different y-axes
(e.g. 0-0.015, 0-0.025, 0-0.020) — bars looked taller on the
channel that happened to be quietest. Not directly comparable.
2. Footer "Amplitude Geo: X in/s/div" was just amax/5 of the FIRST
geo channel with data, with no LSB quantization — producing
nonsense like 0.003 in/s/div when the geophone LSB is 0.005.
Fix: compute a single shared geo y-axis range from max(Tran,Vert,Long),
quantize the per-division step to BW's 1-2-5 sequence rounded to the
0.005 LSB (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, ...), apply the same
ylim + ticks to all three geo subplots, and use that same step for the
footer label. MicL stays on its own auto-scale (different units).
Verified across edge cases including the reported event
(geo max 0.025 → 0.005/div, top 0.025), small PVS events, and large
blast amplitudes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The histogram-interval-times derivation block at line 314 references
rd.histogram_interval_size_s, but the field wasn't declared on the
ReportData dataclass — only the string form histogram_interval_size
was. Result: every PDF render of a histogram event raised
AttributeError → 500 from /db/events/{id}/report.pdf.
Cause: when the histogram aggregation block was inlined into
gather_report_data, the seconds-numeric counterpart that the
projection already carries (bw_report.histogram.interval_size_s) was
never wired into the dataclass. Waveform PDFs weren't affected
because the offending line is gated on is_histogram.
Fix: add the field, read it from the projection alongside the other
histogram keys. No-op for waveform events (the field stays None and
the gate skips it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-reported issue: server logs were timestamped in UTC ("05:36:20"
when local was ~01:36 EDT), and the PDF report's "Created" footer
similarly showed raw UTC. Inconsistent with the modal which already
converts to browser local via toLocaleString.
Solution: standard Linux TZ env var. Set once in the container, and:
- Python's datetime.now() uses local
- Logging module's timestamps use local
- matplotlib renderers + report_pdf formatters use local
- astimezone() conversions resolve to the configured TZ
DB columns stay UTC (created_at uses SQLite's strftime('%Y-...Z', 'now')
which is always UTC, regardless of TZ env var — proper "store UTC,
display local" pattern).
Changes:
- Dockerfile: install tzdata (python:3.11-slim omits the timezone
database), set default TZ=America/New_York
- sfm/report_pdf.py: _fmt_iso_to_bw and _split_iso_to_date_time now
convert UTC inputs (Z-suffixed) to local via astimezone(); naïve
inputs (BW recorded-at, already unit-local) returned as-is.
New _to_display_local helper centralizes the logic.
- "Created" line in the PDF page footer now uses the converted
timestamp.
Override per-deployment via the TZ env var in docker-compose
(separate commit on terra-view side).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
_cleanup_event_files() removes the on-disk artifacts when an event is
hard-deleted (binary, a5_pickle, sidecar, h5). Today's .TXT
preservation feature added a new on-disk file (_ASCII.TXT next to the
binary) but the cleanup didn't know about it — so any event deleted
via /db/events/{id} (single) or /db/events/delete_bulk (or the
Terra-View "SFM Event DB Manager" UI which proxies through to those
endpoints) was leaving orphan .TXT files in the store.
Added "txt" to the cleanup list using the new
WaveformStore.txt_path_for(). Safe for old events without a .TXT —
the exists() check skips the unlink.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues spotted on a histogram event PDF:
1. Footer scale ("Time — /div Amplitude Geo: X in/s/div Mic: Y
psi(L)/div") was overlapping horizontally with the x-axis tick
labels (0, 20, 40, 60...). Both rendered on the same Y row.
Fix: bumped gridspec bottom margin from 0.06 → 0.12, moved the
footer text from y=0.045 → y=0.030 (below the tick labels), moved
the page-bottom Created/Event line from y=0.015 → y=0.005.
Trigger legend on waveforms moved 0.030 → 0.018. Everything
stacks cleanly now without collision.
2. PDF was showing the raw codec output (~150+ bars per histogram)
instead of BW's per-interval aggregation. Why: the aggregation
I'd added to /db/events/{id}/waveform.json wasn't replicated in
the PDF gather path. Now: gather_report_data does the same
max-per-group aggregation when bw_report.histogram.n_intervals is
populated, AND derives per-interval HH:MM:SS labels from the
start time + interval_size_s. Result: histogram PDFs now match
BW's display (one bar per BW interval, x-axis labeled with actual
times) — same fix as the modal chart, applied to the PDF.
For events ingested BEFORE the parser extension (no histogram block
in their sidecar), aggregation is a no-op — they still render with
per-block bars + interval-index x-axis (but the overlap fix applies
to them too). Re-forwarding repopulates the histogram block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quiet histogram events were filling the chart panel even though the
peak was tiny (0.005 in/s rendered as 90% of chart height because
Chart.js auto-scaled to peak * 1.1). Made everything look uniformly
loud regardless of actual amplitude.
BW's solution: a near-fixed scale per channel ("Geo: 0.002 in/s/div"
from the footer). Quiet events render small, loud events render
proportionally tall.
Match the intent without copying BW's "no Y-axis labels at all"
convention. For histogram channels:
Geo (in/s): min Y range 0.05 in/s
Mic in psi: min Y range 0.001 psi
Mic in dBL: unchanged (the 60 dBL floor + peak+5 top already
gives quiet events a sensible baseline)
So a 0.005 in/s geo event renders as ~10% of chart height; a 0.05
event fills it; a 5.0 event still fills it (max(peak*1.1, 0.05) ==
peak*1.1 for any peak > 0.045).
Waveform charts unchanged — they should zoom for shape detail.
Applied to both the modal in sfm_webapp.html and the standalone
/events page in event_browser.html.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues spotted in the modal:
1. Mic dBL chart looked spikey/discontinuous — isolated bars at 80-95
with gaps in between. Cause: _psiToDbl() returns null for zero or
negative samples, and most mic samples on a quiet event sit at the
digitization noise floor where they're effectively zero. Result:
the chart only renders the moments when instantaneous SPL exceeded
the Y-axis bottom — looks like a sound trigger gate.
Fix: new _psiToDblForChart() rectifies the AC waveform (abs), then
converts to dBL, then floors at MIC_DBL_FLOOR=60 dBL. Chart now
has a continuous 60 dBL baseline with peaks above it — matches how
acoustic engineers expect SPL-vs-time. Y-axis bottom pinned to
MIC_DBL_FLOOR, top to peak + 5 dB headroom. Peak label still uses
the unrectified _psiToDbl so the displayed peak value is exact.
2. Filename in Source/Files block was unlinked. Endpoint exists
(/db/events/{id}/blastware_file) — just wasn't wired to the modal.
Made it a clickable download link. Same treatment for the
preserved .TXT — added "(download .TXT)" link next to source kind
when source.txt_filename is populated (events ingested after the
.TXT preservation feature landed; older events show no link).
Applied to both the inline modal in sfm_webapp.html and the
standalone /events page in event_browser.html.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spotted comparing our PDF to BW's reference for T003LLUB.CE0H:
- Finish blank
- Per-channel Date / Time rows all dashes
- MicL PSPL line missing "on May 27, 2026 at 06:19:14"
- Peak Vector Sum missing "on May 27, 2026 At 06:06:14"
Root cause: I'd added these fields to the projection (write side) in
_bw_report_to_dict but never wired them into gather_report_data
(read side). Plus the projection used keys "start"/"stop" while
gather was reading "start_str"/"stop_str" — typo'd lookup.
Fixes:
- gather_report_data now reads bw_report.histogram.start /
.stop / .channel_peak_when (correct keys, matching the projection)
- Per-channel "peak_date" / "peak_time" populated from
channel_peak_when[<channel>] for the histogram stats table
- MicL PSPL line formats as "PSPL 125.7 dB(L) on May 27, 2026
at 06:19:14" (BW style) when channel_peak_when["MicL"] is present;
falls back to the waveform-relative "at 0.012 sec" otherwise
- PVS line formats as "Peak Vector Sum 0.091 in/s on May 27, 2026
At 06:06:14" (BW style) when bw_report.peaks.vector_sum.when is
populated; falls back to the relative time_s for waveforms
- New _split_iso_to_date_time() helper splits ISO timestamps into
BW-formatted ("May 27 /26", "06:06:14") date+time pairs for the
stats table's separate Date and Time rows
Events ingested BEFORE the parser extension landed (most of the
existing prod corpus) still show dashes — their sidecars lack the
histogram block. Re-forwarding repopulates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spotted on the SFM webapp event modal — "Received by server at" was
showing the raw ISO string "2026-05-27T21:59:57.213043Z" because we
were assigning ev.timestamp / src.captured_at directly to the
textContent of the modal fields, bypassing the existing _fmtTs()
helper that wraps them in toLocaleString().
Net effect for operators: confusing "21:59 vs it's 6 PM" mismatch
when the displayed UTC timestamp didn't match wall-clock time. The
values were always correct; the display was just ambiguous.
After this fix:
- "Recorded at" (naive ISO from BW = unit local time) renders
cleanly as the unit wrote it: "5/27/2026, 6:00:13 AM"
- "Received by server at" (UTC with Z suffix) converts to browser
local: "5/27/2026, 5:59:57 PM"
- Timestamp column in the history table already used _fmtTs —
unchanged
- Same fix applied to the standalone /events page (sidebar event
list + meta header) via a new _fmtTsLocal helper
Note: did NOT add file-mtime-on-watcher-PC tracking as a separate
"Called in at" column — discussed and decided created_at is close
enough for schedule-compliance monitoring (worst case lag = watcher
poll interval ~60s, indistinguishable from BW write time at the
operationally-relevant resolution).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three layered changes that together make histogram charts visually
match BW's printout (one bar per interval, not per codec block):
1. bw_ascii_report parser captures histogram fields it previously
dropped:
- Histogram Start/Stop Time + Date → datetime
- Number of Intervals + Interval Size (string + parsed seconds)
- <Channel> Peak Time + Peak Date → datetime (per-channel)
- Peak Vector Sum Date (combined with PVS Time → datetime;
clears the bogus seconds parse that interpreted "22:33:52"
as 22.0)
New _parse_iso_date() handles BW's ISO format for histograms
(waveforms use "May 8, 2026" long form). New _parse_interval_size()
handles "1 minute" / "5 minutes" / "15 seconds" etc.
2. _bw_report_to_dict() projects the new fields into a new
bw_report.histogram block in the sidecar.
3. /db/events/{id}/waveform.json wraps the existing path 1 (HDF5)
output with _maybe_aggregate_histogram(): when the event is a
histogram AND the sidecar has bw_report.histogram.n_intervals,
group the codec's per-block samples into N intervals via
max-per-group and return the aggregated array. time_axis gains
histogram_aggregated / n_intervals / interval_size_s / interval_times
fields.
Frontend (both modal chart in sfm_webapp.html + standalone event
browser) uses interval_times as x-axis labels when provided (BW-style
HH:MM:SS), falls back to interval index.
Defensive: aggregation is no-op when the sidecar lacks the histogram
block (events ingested before this change). Activates automatically
on prod once a watcher re-forward populates new sidecars.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the .TXT was parsed into the sidecar's bw_report projection
and then discarded at ingest time. Now save_imported_bw() writes it
to <store>/<serial>/<filename>_ASCII.TXT permanently.
Rationale: with BW Mail / Forwarding Agent being phased out of the
operator workflow, the XML/PDF/WMF those tools produce won't be
available — the binary + .TXT (created by BW ACH itself) are our
only authoritative inputs going forward. Keeping the raw .TXT
unlocks:
- Parser bug fixes can be applied RETROACTIVELY by re-parsing the
stored .TXT, instead of requiring a re-forward from the watcher
PC (which lost the .TXT after BW ACH cleanup).
- Audit trail of what BW actually sent us, for debugging.
- The five known parser-PPV-miss events will be re-parseable once
the regex fix lands (instead of staying broken indefinitely).
Storage cost: ~15 KB per event × 14k events = ~210 MB on the
existing prod corpus. Negligible.
Implementation:
- WaveformStore gains txt_path_for() + open_txt()
- save_imported_bw() writes the .TXT when bw_report_text is supplied
- sidecar source block records the txt_filename
- backfill_sidecars.py preserves txt_filename across regens
- New GET /db/events/{id}/ascii_report.txt endpoint serves it
- Returns 404 for events ingested before this change (no .TXT in
the store yet) — re-forward to populate
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewed against real Blastware Event Report PDFs (uploaded to
example-events/pdfsnstuff/) for K558LLB7.V20H (histogram) and
K558LLB8.0E0W (waveform). Each event type has its own layout because
BW's printouts genuinely differ:
Waveform header: Date/Time, Trigger Source, Range, Sample Rate
Histogram header: Start, Finish, Intervals At Size, Range, Sample Rate
(no trigger field — histograms aren't triggered)
Waveform stats: PPV, ZC Freq, Time (Rel. to Trig),
Peak Acceleration, Peak Displacement, Sensor Check
Histogram stats: PPV, ZC Freq, Date, Time (of peak), Sensor Check
Waveform plot: 4-channel stacked line, x-axis in SECONDS,
trigger triangle + window markers, symmetric Y
for geo, zero-anchored mic, "0.0" baseline label
on right edge per BW convention
Histogram plot: 4-channel stacked bars, Y-axis 0-to-peak only
(never negative — peaks are magnitudes), 0.0
baseline at the bottom
Waveform footer: USBM chart placeholder upper-right;
"Time X sec/div Amplitude Geo: Y in/s/div Mic: 0.001 psi(L)/div"
"Trigger = ▶━━◀"
Histogram footer: No USBM chart; same scale-info footer with
interval-size as the time unit
Other fixes from the first-pass screenshot review:
- Channel labels (MicL/Long/Vert/Tran) no longer cut off (wider
left margin)
- Histogram bars rise from zero baseline (abs of any signed values)
- ISO timestamp "2026-05-16T22:33:50" → "22:33:50 May 16, 2026"
matching BW's display format
Known gaps (separate work):
- Histogram codec returns per-block granularity (~200 bars for
BW's 4-interval display). XML-driven data source is the planned
fix; the structured BW XML has the per-interval aggregates.
- USBM RI8507 / OSMRE compliance chart still placeholder
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New endpoint GET /db/events/{id}/report.pdf returns a single-page
letter-portrait PDF for any event with waveform data on disk.
Architecture:
sfm/report_pdf.py — gather_report_data() assembles fields from
SeismoDb row + .sfm.json sidecar (bw_report block) + .h5 samples;
render_event_report_pdf() turns that into PDF bytes via matplotlib.
sfm/server.py — new endpoint wires them together, streams PDF back
with Content-Disposition: inline so the browser displays it.
sfm_webapp.html — new "Download PDF" button in the event modal
footer that opens the endpoint in a new tab.
Fields surfaced — same coverage as a Blastware Event Report:
Header metadata (date/time, trigger source, range, sample rate,
project, client, operator, location, serial+firmware,
battery, calibration, file name)
Microphone block (PSPL in dB(L) + psi, ZC freq, channel test)
Per-channel stats (PPV, ZC Freq, Time of Peak, Peak Accel,
Peak Disp, Sensor Check) for Tran/Vert/Long
Peak Vector Sum
Waveform plot (MicL/Long/Vert/Tran stacked, shared time axis,
trigger marker, symmetric Y for geo, zero-anchored
mic) — OR per-interval bar chart for histograms.
Rendering pipeline = matplotlib only (vector PDF, no headless-browser
dep). Adds matplotlib>=3.8 to deps.
Visual layout is approximate until reference PDFs from Instantel land
at docs/reference/instantel/ for iteration. USBM RI8507 / OSMRE
compliance chart is stubbed (placeholder rectangle) — separate work
item.
Smoke-tested on a K558 waveform event: 77 KB valid PDF, all fields
populated correctly from the snapshot DB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sidecar-modal waveform plot was rendering mic in raw psi, while the
rest of SFM (history table column, peaks block, live-device chart,
event detail modal mic field) had already converted to dB(L) — matching
the BW Event Report convention. Unifying.
Both viewers now:
- Default mic chart values + axis title + peak label to dB(L)
- Provide a header toggle ("Mic: dBL" pill) to flip to psi
- Persist the preference via localStorage (sfm_mic_unit)
- Re-render the open chart immediately on toggle
Conversion: dBL = 20 * log10(psi / 2.9e-9), where 2.9e-9 psi is the
20 µPa reference pressure already defined for the rest of the webapp.
Non-positive psi samples (log undefined) render as null; Chart.js
handles them as gaps in line mode and missing bars in histogram mode.
Also fixes event_browser.html's stats table — the MicL row was
hard-coding "<value> psi"; now honors the same toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes from the second screenshot review:
1. Geophone waveform Y-axis now renders SYMMETRIC around zero — zero
line sits in the middle of the chart, signal goes both above and
below. Standard seismograph display convention; matches the
Instantel printout look. Previously Chart.js auto-scaled to the
data range so e.g. Vert showing values from -0.005 to -0.015 had
the zero line completely off-screen.
Mic channel (sound pressure, always positive) keeps the default
auto-scale anchored at zero. Histograms (per-interval peaks, also
always positive) likewise keep bars rising from a zero baseline.
2. Modal labels clarified to remove the 'Timestamp' vs 'Captured at'
ambiguity:
'Timestamp' → 'Recorded at' (when the seismograph
recorded the event —
from BW report's Event
Time field)
'Captured at' → 'Received by server at' (when our sfm-db
inserted the row)
Both have tooltips explaining the distinction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>