# istrain — EOT decode & reap Stage A captures EOT-band bursts (457.9375 MHz, dongle 1001) with `vumon --clips`; Stage B decodes them here. ## What's here - **`eot_scan.py`** — the decoder, TWO passes (2026-07-05): (1) fast/exact — whole file, fixed tone pairs, exact sync (proven against `PyEOT/demo3eot.wav`, 3 valid packets); (2) adaptive/fuzzy escalation for off-frequency emitters (OBSERVATIONS **O-11**) — burst located by FM-quieting, tone pair built on the burst's spectral centroid, sync matched with ≤2 bit errors (BCH still gates). Packets carry `dq: exact|fuzzy`; the card layer corroborates fuzzy singletons before trusting them. `scan_file(path)` returns majority-vote-ordered packets (`[]` = not EOT). numpy required. - **`../reaper.py`** — the scheduled janitor, now **generic/multi-band** (lives in `scripts/`, not here). EOT is wired as a band; BOT/mid are ready slots. See below. - **`PyEOT/`** — vendored parser + BCH + demo recording by **Eric Reuter**, GPL (see `PyEOT/LICENSE`). Source: . We use its packet parser and the reference wav. ## Decode a file by hand ``` python3 eot_scan.py PyEOT/demo3eot.wav # self-test → 3 packets (unit/pressure/motion…) python3 eot_scan.py ~/istrain/eot_clips/eot_*.s16 # our captured bursts ``` ## The reaper (scheduled cleanup) — `../reaper.py` Generic janitor, split out of EOT (2026-06-23) so it can serve BOT/mid later. Per wired band it decodes each finished clip (EOT → `~/istrain/eot_clips/`): - **real** → move the recording to `_clips/confirmed/`, enrich its detection in the band's log - **noise** → delete the clip **and** remove its detection hit from the log (panel only shows real trains) - **oversized (>32 MB)** → quarantine to `_clips/oversized/`, never load (OOM guard) Only scans clips older than 60s (never one mid-capture). `--dry-run` previews; `--band NAME` runs one band. BOT/mid are commented placeholders in `reaper.py` (no decoder yet → nothing to reap). The EOT *decoder* (`eot_scan.py`) stays separate, so it can be paused/iterated independently of the cleanup. ## Deploy the schedule (systemd --user, every 15 min) ``` cp ../../systemd/istrain-eot-reap.{service,timer} ~/.config/systemd/user/ systemctl --user daemon-reload systemctl --user enable --now istrain-eot-reap.timer systemctl --user list-timers istrain-eot-reap.timer # confirm it's scheduled ``` ## DPU decode plan (2026-06-24) — the plan-for-a-plan Mid-train DPU rides UHF 452/457 (NOT ~900 — see `radio/research/railroad-eot-atcs.md` 06-24 correction), 1200-baud FFSK, same modulation family as EOT. Channel = lead loco road-number last digit: 452.925 (3/7) · 452.950 (2/6) · 457.925 (1/5/9) · 457.950 (0/4/8). So most of the chain is **already built**. **We can't write this yet — we have no DPU sample.** This is the staged plan to execute once a live burst is in hand (the rooftop antenna is the gate, same as EOT). Stages 1–2 are shared with EOT; only stage 3 is new: 1. **Isolate the sub-channel (the one genuinely-new RF step).** ✅ **BUILT 2026-06-24** — `../iq_channelize.py`: reads `rtl_sdr` CU8 IQ, NCO-mixes + 151-tap FIR-LPFs + decimates each sub-channel (center + ±12.5 kHz), presence = in-channel power over an adaptive floor, emits a discriminated s16 clip per burst. **Routing + isolation proven on synthetic IQ** (`--selftest`); FM-demod audio can't do this (±12.5 kHz becomes a DC bias, not a separable tone). Opt-in via `HOP_IQ=1` on scan-hop (FM path stays the fallback). This same channelizer is what lets detection *label* a burst DPU-vs-HOT — distinct-ID and decode are one job. **Unvalidated against a real DPU burst** until the antenna's up — same gate as live EOT. 2. **FFSK demod → bits.** REUSE `eot_scan.py`'s tone discriminator + 1200-baud clock recovery unchanged — DPU is the same Bell-202-style FFSK as EOT. 3. **DPU frame parse (the new code).** DPU packets are a **different frame format** than EOT (control vs telemetry — why railfans run SoftDPU separate from SoftEOT). Need to source the DPU packet layout (SoftDPU / open references) and write a parser + its error-check. PyEOT's BCH is EOT-specific; verify whether DPU uses the same FEC before reusing it. **Validation:** no DPU reference recording ships with PyEOT (its `demo3eot.wav` is EOT only). First real captured DPU burst becomes our reference; until then, stage 3 is unverifiable — don't trust it blind. **For the mission, none of this is required** — detection already counts DPU via the 452/457 legs (`../correlate.py`). Decode is the "what is the lead unit telling the mid-consist" luxury, not the commute signal. ## Status (2026-06-23) Decoder proven on the reference. **No real EOT received yet** on the desk 6" whip — a range limit, not a code one; EOT waits on the rooftop **DPD EOTD vertical** (tuned 452+457, the chosen feed for 1001 — `../../ANTENNA-UPGRADE.md`). Reaper split to the generic `../reaper.py` (2026-06-23) and is currently **PAUSED** (timer stopped) to preserve a capture flurry for study — resume with `systemctl --user start istrain-eot-reap.timer`. (Promote-candidate: `eot_scan.py` is generic enough for the radio engine; living here in istrain until it earns the move.)