Author SHA1 Message Date
serversdownandClaude Opus 5 0a96a34b02 docs(offset): CONFIRMED — BE12599 is a geophone-assembly fault, not the recorder
The first mechanism this investigation has established physically rather than
inferred. Brian pulled BE12599 and bench-tested it; a geophone swap settles it.

Same recorder, twenty minutes apart: Vert overswing 2026.0 with its own
geophone, 3.4 with BE9888's known-good one. Frequency 2.2 -> 7.4 Hz. All three
channels Passed, no offsets, no self-triggering, and a clean damped impulse with
broadband content. The recorder is functional and merely out of calibration.

A bench control -- trigger forced by slapping the microphone, geophone untouched
-- shows Vert producing 0.19 in/s of bipolar wander with no mechanical input at
all. The fault is continuous, not episodic. All three channels are affected:
Tran and Long sit on a small DC while their coils still pass the swing test,
Vert has lost damping entirely. That is leakage on several contacts plus one
gone open, not three elements failing in sequence.

The consequence that reaches past this unit: an autozero adjusts the RECORDER's
zero reference and cannot fix a geophone. If most offsets are geophone-assembly
faults, a re-zero succeeds only on the minority where the recorder really is at
fault -- which is exactly the ~10% rate recorded in section 5 and unexplained
since August. Procedure change: swap the geophone first.

Corrects 8e's title, which called it a connector rather than a geophone: the
swap narrows the fault to the assembly, and element / cable / connector remain
open within it. And reweights the cause -- the geophone is buried, not in the
enclosure with the nest, and the unit sat ~6 ft from I-80 WB, so road salt now
outranks the mice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgTe8CamXAHcAmaQ6QNcog
2026-09-23 02:57:34 +00:00
serversdownandClaude Opus 4.8 154186a6cd Merge feat/event-timestamp-fix: exact waveform trigger time from the binary
read_blastware_file stamped waveforms with footer ts1 (the monitoring-session
start, hours off — vomit-list #3).  The event time is ts2 (recording stop) and
the trigger = ts2 - record time, a float32 in the recording-setup config block,
so the exact Blastware trigger is recovered from the binary alone (no .TXT).
Histograms keep ts1; a paired report's event_datetime stays authoritative.

Needs a re-decode backfill to correct existing stored events' timestamps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
2026-09-20 23:13:19 +00:00
serversdownandClaude Opus 4.8 1765b3300d docs(changelog): waveform event-time fix (exact trigger from binary)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
2026-09-20 22:32:13 +00:00
serversdownandClaude Opus 4.8 1e76d08b37 fix(decode): recover the exact waveform trigger from the binary (no .TXT)
Follow-up to the ts1→ts2 fix: get the trigger to the second from the binary
alone, instead of falling back to the stop time (~record-duration late) for
no-report events.

The configured post-trigger record time is a big-endian float32 in the
recording-setup config block, exactly 30 bytes before the "Standard Recording
Setup" marker.  _parse_record_time_seconds reads it; the waveform branch now
stamps trigger = ts2 - record_time.  Verified: the field reads 1.0 / 2.0 / 3.0 s
across different setups in the corpus, and all 7 BE12844 oracle events now
decode to their exact Blastware trigger (N844LQHB 10:33:29) from the binary,
no paired .TXT needed.  Falls back to ts2 (the stop) if the config block is
absent.  A paired report's event_datetime stays authoritative (clock drift).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
2026-09-20 22:31:22 +00:00
serversdownandClaude Opus 4.8 a84a46e9d4 fix(decode): stamp waveform events with the event time, not the session start
read_blastware_file built ev.timestamp from footer ts1, which for a WAVEFORM is
the monitoring-session start (a unit arming at 06:00 stamps 06:00 on every event
that day) — so every waveform's time was hours off (vomit-list #3, "~4.5 h off").
The event time is footer ts2 (the recording stop); BW's displayed Date/Time is
the trigger = ts2 - record duration.

Root cause proven against the BE12844 oracle set: 5 of 7 events decoded to the
identical 06:00:13 (the shared session start); ts2 gives distinct plausible
event times (N844LQHB ts2 = 10:33:32, BW trigger 10:33:29 = ts2 - 3.0 s rectime).

  * read_blastware_file now uses ts2 for waveforms (discriminated by which codec
    decoded the body, not the filename — save_imported_bw passes a tmp name).
    Histograms keep ts1 (the ~24 h window start, which IS the event time).
  * Binary-only decode can't get the exact trigger: the STRT record-time byte is
    a misparsed record-type marker (0x46=70), so ts2 (the stop, ~record duration
    after the trigger) is the best estimate. A paired BW report carries the exact
    trigger — apply_report_to_event now overlays event.timestamp from
    report.event_datetime, matching the existing build-path override (line ~441).

Tests: waveform → ts2, histogram → ts1 unchanged, report → exact trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXjZCr4RqT2U3QvMDhgzf
2026-09-20 18:21:07 +00:00
5 changed files with 251 additions and 1 deletions
+16
View File
@@ -6,6 +6,22 @@ All notable changes to seismo-relay are documented here.
## Unreleased
### Fixed
- **Waveform event times were the monitoring-session start, not the trigger
(~hours off).** `read_blastware_file` stamped events with footer `ts1`, which
for a waveform is the session start a unit shares across every event that day
(a unit arming at 06:00 stamped 06:00 on all of them — the modal and PDF both
showed it, since it's the stored value). The event time is footer `ts2` (the
recording stop), and Blastware's trigger = `ts2 - record time`. The record
time is a big-endian float32 in the recording-setup config block (30 bytes
before the `Standard Recording Setup` marker), so the **exact trigger is now
recovered from the binary alone** — all 7 BE12844 oracle events decode to
their exact Blastware time (e.g. N844LQHB 10:33:29), no paired `.TXT` needed.
Histograms keep `ts1` (the ~24 h window start). A paired report's
`event_datetime` stays authoritative (unit-clock drift).
⚠ **Needs a re-decode backfill** to correct existing stored events' timestamps.
### Added
- **Diagnostics tab in the SFM standalone webapp.** Surfaces the device
+118
View File
@@ -934,6 +934,120 @@ which looks like a different failure mode wearing the same signature.
---
### 8f. BE12599 — CONFIRMED on the bench: the geophone assembly, not the recorder (2026-09-23)
**The first mechanism in this investigation established physically rather than
inferred.** Brian pulled BE12599 from I-80 Abut 1 West on 2026-09-21 and bench-
tested it. A geophone swap settles it.
#### The swap test
Same recorder, twenty minutes apart, sensor self-check:
| geophone fitted | Tran | **Vert** | Long |
|---|---|---|---|
| BE12599's own (2026-09-22 21:10) | 4.2 | **2026.0** | 4.8 |
| BE9888's known-good (2026-09-22 22:48) | 3.6 | **3.4** | 3.6 |
Vert frequency likewise **2.2 Hz -> 7.4 Hz**. Fleet reference: overswing 3.3–5.1
(median 3.8, max 5.1 across 42,979 passing records), frequency 7.4–7.8 Hz.
With the good geophone the unit shows **no offsets, no self-triggering, all three
channels Passed**, and records a clean damped impulse with broadband 5–100 Hz
content. **BE12599 the recorder is functional** — it is only out of calibration
(due 2025-08-12).
**The fault is in the geophone assembly: element, cable, or its connector.**
The swap does not separate those three, because the cable is permanently glanded
into the geophone.
#### The bench evidence, with ground motion removed
17 events recorded on a workbench with nothing touching the sensor.
**The control** — `N599LRY0.4B0`, trigger forced by slapping the *microphone*,
geophone untouched:
| channel | peak in/s | frac < 0 | mean/peak |
|---|---|---|---|
| Tran | 0.070 | **0.00** | 0.87 |
| **Vert** | **0.190** | **0.35** | 0.13 |
| Long | 0.070 | **0.00** | 0.84 |
Vert produces 0.19 in/s of bipolar wander with **no mechanical input at all**.
The fault is *continuous*, not episodic — every field event was simply the
moment it crossed the trigger.
**All three channels are affected**, consistently across all 17 events:
Tran and Long parked at a small positive DC (`frac<0 = 0.00`, mp 0.64–0.95);
Vert wandering at 0.89–1.78 Hz, peaks 0.14–0.54. Yet the swing test calls Tran
and Long **Passed** (4.2 / 4.8). So their *coils* are healthy and merely sitting
on an offset, while Vert's circuit has lost damping entirely.
That reads as **leakage on several contacts plus one gone open** — which is what
progressive corrosion at a shared connection does. Three independent geophone
elements do not fail in sequence.
**It is still progressing.** Tran's DC offset was ~0.011 in/s on 2026-09-14 and
~0.064 on 2026-09-22 — 6x in eight days. Vert's overswing went 7.5 -> 2026.0 over
the same window.
#### ⚠ This explains the autozero's 10% success rate
The journal has framed this fault around the **recorder** throughout: Instantel's
autozero, the X1/X8 gains, the 2027–2069 window, `SUB 0x0E` as the best lead.
All of that adjusts the recorder's zero reference.
**An autozero cannot fix a geophone.** If most offsets are geophone-assembly
faults, a re-zero would succeed only on the minority where the recorder really is
at fault — which is exactly the ~10% Brian's father reported and which has sat
unexplained in §5 since 2026-08-28.
**Procedure change: swap the geophone first.** It is a five-minute field test
costing nothing. On this unit it would have answered the question in August.
⚠ Scoped to one unit. The other four confirmed units are the obvious check.
#### Correction to §8e
§8e is titled "a connector, not a geophone" and concluded the element was fine
because the fault hopped channels. The channel-hopping observation stands and the
swap test supports the general shape, but **that title overstated what was known**:
the swap narrows the fault to the *assembly*, and element / cable / connector are
still open within it. §8e's reasoning was sound; its confidence was not.
#### Cause — road salt now outranks the mice
The enclosure held a mouse nest on the battery ([[§8e]]), and that framed the
2026-09-22 analysis. But **the geophone is buried in the ground, not in the
enclosure** — they are two independent contamination paths, and the fault is in
the buried half.
The unit sat **~6 feet from I-80 westbound**. Winter road salt spray and highway
runoff put a buried geophone in chloride-rich water for months a year. Chloride
drives exactly this failure: corrosion at terminations, leakage across
insulation, eventually an open circuit.
The mice remain a real finding for the *enclosure* and the recorder end. They are
probably not the cause of this fault.
**Testable consequence:** road salt is a property of the **site**, not the unit.
Units deployed near salted highways should carry higher geophone-assembly failure
rates than units on quiet sites. Nothing in the archive records proximity to a
road, so this needs the deployment records rather than the event data.
#### Open — to be settled by opening the geophone
1. **Meter at the geophone plug** before disassembly: three coil pairs, and each
to shell. Prediction: **Vert open or very high** (that is the 2026 overswing);
Tran and Long matched and normal; **insulation to shell finite on all three**,
Vert worst (that is the leakage putting DC on healthy coils).
2. **Then open it.** Look for a water line, white/green corrosion at the coil
terminations, the state of the damping shunt, and the cable gland from the
inside. Measure the coils again **at the internal terminals** — if Vert reads
fine there, the fault is in the internal wiring or the gland, not the element.
---
### ⚠ Serial prefixes — four of these units are BlastMates, not MiniMates
Corrected 2026-09-06, after Brian queried "BA10895?" against a report that
@@ -985,3 +1099,7 @@ decoder lost the geo threshold along with the serial. See commit `9ceff65`.
| 2026-09-06 | The spread gate is **blind to onsets by construction** — it rejects a moving floor, which is what an onset is. It rejected the very record in which the ramp is visible. |
| 2026-09-07 | **The non-motion test** (Brian: "it doesn't cross zero"). `\|mean\|/peak` is bimodal with a 1.09% dead zone and returns exactly the 5 confirmed units from physics, not a threshold. Independent corroboration of the unit list. **BE11007 settled as NOT an offset** — a one-sided 9 in/s blast. |
| 2026-09-07 | **BE12599 is a connector fault, not a geophone fault.** Unipolar spikes with a 26→118 ms RC tail progressing to a flat pedestal, and the fault MOVES between Long and Tran while the sensor self-check passes on every event. An element cannot hop channels; a connector can. Inspect before reseating. |
| 2026-09-23 | **CONFIRMED: BE12599's fault is the geophone assembly, not the recorder.** Geophone swap on the same unit: Vert overswing **2026.0 -> 3.4**, frequency 2.2 -> 7.4 Hz, all three channels Passed, no self-triggering. First physically established mechanism in this investigation. |
| 2026-09-23 | **This explains the autozero's ~10% success rate** (§5): an autozero adjusts the *recorder's* zero reference and cannot fix a geophone. **Procedure change: swap the geophone first.** |
| 2026-09-23 | Bench control event (mic-slap trigger, geophone untouched) shows Vert wandering **0.19 in/s with no mechanical input** — the fault is continuous, not episodic. All three channels affected; Tran/Long parked on DC while their coils pass the swing test. |
| 2026-09-23 | **Cause: road salt now outranks the mice.** The geophone is buried, not in the enclosure — two independent contamination paths, and the fault is in the buried half. The unit sat ~6 ft from I-80 WB. Salt is a *site* property, so this predicts higher failure rates near salted highways. |
+63 -1
View File
@@ -296,6 +296,16 @@ def apply_report_to_event(event: Event, report: BwAsciiReport) -> None:
event.sample_rate = report.sample_rate_sps
if report.record_time_s is not None:
event.rectime_seconds = report.record_time_s
# The report's event_datetime is Blastware's exact trigger time (parsed
# from Event Time + Event Date). Prefer it over the binary footer's stop
# time so a report-paired import matches BW to the second.
edt = report.event_datetime
if edt is not None:
event.timestamp = Timestamp(
raw=b"", flag=0x10,
year=edt.year, unknown_byte=0, month=edt.month, day=edt.day,
hour=edt.hour, minute=edt.minute, second=edt.second,
)
def apply_bw_report_dict_to_event(event: Event, bw_report: dict) -> None:
@@ -808,6 +818,30 @@ def derive_record_type_from_filename(filename, default: str = "Waveform") -> str
return _RECORD_TYPE_BY_EXT_SUFFIX.get(ext[-1].upper(), default)
# Marker for the recording-setup config block, and the offset of the record-time
# float32 within it. The configured post-trigger record time (seconds) is a
# big-endian float32 exactly 30 bytes before the "Standard Recording Setup"
# label. Verified across the corpus reading 1.0 / 2.0 / 3.0 s on different
# setups — and ts2 - record_time reproduces Blastware's trigger to the second
# (N844LQHB: stop 10:33:32 - 3.0 = 10:33:29).
_RECSETUP_MARKER = b"Standard Recording Setup"
_RECTIME_OFFSET_BEFORE_MARKER = 30
def _parse_record_time_seconds(raw: bytes) -> Optional[float]:
"""The configured post-trigger record time in seconds, from the recording-
setup config block, or None when absent / implausible."""
a = raw.find(_RECSETUP_MARKER)
if a < _RECTIME_OFFSET_BEFORE_MARKER:
return None
off = a - _RECTIME_OFFSET_BEFORE_MARKER
try:
rt = struct.unpack(">f", raw[off:off + 4])[0]
except struct.error:
return None
return rt if 0.05 <= rt <= 600.0 else None
def read_blastware_file(path: Union[str, Path]) -> Event:
"""
Parse a Blastware waveform file into an Event.
@@ -917,6 +951,10 @@ def read_blastware_file(path: Union[str, Path]) -> Event:
# rest of the event (timestamp, waveform_key, project strings) is
# still recoverable and useful.
decoded = decode_waveform_v2(body)
# Discriminator for the timestamp logic below: a waveform (trigger) event
# vs a histogram window. Keyed on the codec, not the filename — the
# save_imported_bw path passes a tmp ".bw" name whose extension lies.
is_waveform_body = decoded is not None
if decoded is None:
decoded = decode_histogram_body(body)
if decoded is None:
@@ -948,7 +986,31 @@ def read_blastware_file(path: Union[str, Path]) -> Event:
ev.total_samples = strt_fields.get("total_samples")
ev.pretrig_samples = strt_fields.get("pretrig_samples")
if ts1 is not None:
# Event timestamp. The footer's two timestamps mean different things by
# record type:
# * Waveform: ts1 = the monitoring-SESSION start (shared across every
# event that day — a unit arming at 06:00 stamps 06:00 on all of them),
# ts2 = THIS event's recording STOP. Blastware's Date/Time is the
# TRIGGER = ts2 - record time, and the record time is a float32 in the
# recording-setup config block (see _parse_record_time_seconds), so the
# exact trigger is recoverable from the binary alone. Falls back to ts2
# (the stop, within the record duration) if the config block is absent.
# (Stamping ts1 showed the session start, hours off.)
# * Histogram / undecodable: ts1 = the window start, which IS the event
# time — keep it.
# Discriminate by ``is_waveform_body`` (the codec), not the filename.
if is_waveform_body and ts2 is not None:
_stop = datetime.datetime(ts2.year, ts2.month, ts2.day,
ts2.hour, ts2.minute, ts2.second)
_rt = _parse_record_time_seconds(raw)
_trig = _stop - datetime.timedelta(seconds=_rt) if _rt is not None else _stop
ev.timestamp = Timestamp(
raw=footer[10:18],
flag=0x10,
year=_trig.year, unknown_byte=0, month=_trig.month, day=_trig.day,
hour=_trig.hour, minute=_trig.minute, second=_trig.second,
)
elif ts1 is not None:
ev.timestamp = Timestamp(
raw=footer[2:10],
flag=0x10,
Binary file not shown.
+54
View File
@@ -0,0 +1,54 @@
"""Event timestamp decode — waveform trigger/stop vs histogram window start.
The Blastware footer holds two timestamps: ts1 = footer[2:10], ts2 = footer[10:18].
Their meaning depends on record type:
* Waveform: ts1 is the monitoring-SESSION start (e.g. 06:00 for a unit that
arms at 06:00 daily — shared across every event that day), and ts2 is THIS
event's recording STOP. read_blastware_file used to stamp events with ts1 →
every waveform showed the session start (~4.5 h off). Binary-only, the best
estimate is ts2 (the stop); the exact trigger BW displays (= ts2 - record
duration) comes from the paired report's event_datetime, since the binary
STRT record-time byte is a misparsed record-type marker.
* Histogram: ts1/ts2 are the ~24 h window [start, stop]; the event time is the
window start = ts1 (unchanged).
"""
import datetime
from pathlib import Path
from minimateplus.event_file_io import read_blastware_file, apply_report_to_event
from minimateplus.bw_ascii_report import BwAsciiReport
from minimateplus.models import Event
FIX = Path(__file__).parent / "fixtures"
WAVEFORM = FIX / "fft-oracle-2026-09-14" / "N844LQHB.ZT0W" # footer ts2 = 2026-08-25 10:33:32
HISTOGRAM = FIX / "ts-fix" / "K441LKZU.C30H" # window start 2026-05-10 19:04:50
def _tuple(ts):
return (ts.year, ts.month, ts.day, ts.hour, ts.minute, ts.second)
def test_waveform_timestamp_is_exact_trigger_from_binary():
ev = read_blastware_file(WAVEFORM)
# The EXACT Blastware trigger, from the binary alone: ts2 (stop 10:33:32)
# minus the config record time (3.0 s) = 10:33:29 — NOT the 06:00:13
# monitoring-session start the old decode used.
assert _tuple(ev.timestamp) == (2026, 8, 25, 10, 33, 29), _tuple(ev.timestamp)
def test_histogram_timestamp_is_window_start_unchanged():
ev = read_blastware_file(HISTOGRAM)
# Histogram event time = the window start (ts1); must NOT get the waveform
# ts2 treatment (that would land ~24 h off).
assert _tuple(ev.timestamp) == (2026, 5, 10, 19, 4, 50), _tuple(ev.timestamp)
def test_report_event_datetime_is_authoritative_over_binary():
# The binary already yields the exact trigger, but a paired report stays
# authoritative (e.g. if the unit clock had drifted) — applying it wins.
ev = read_blastware_file(WAVEFORM)
assert _tuple(ev.timestamp) == (2026, 8, 25, 10, 33, 29) # exact, from binary
apply_report_to_event(ev, BwAsciiReport(
event_datetime=datetime.datetime(2026, 8, 25, 10, 35, 0)))
assert _tuple(ev.timestamp) == (2026, 8, 25, 10, 35, 0) # report wins