#!/usr/bin/env python3 # envelope.py — OFFLINE, read-only. Reconstructs a train's signal-strength ENVELOPE from decoded EOT # frames and classifies the passage SHAPE: passed-through / stopped-blocking / departed / set-out / distant. # # WHY THIS IS NEWLY POSSIBLE (O-18): the +10 dB antenna gave the peak-dB trajectory real dynamic range. # Whip-era passages all sat in the flat -40..-47 mud (O-10) with no clean rise/peak/fall, so envelope # shape was unreadable and direction/pass-through detection impossible. Now the curve exists. # # Touches NO radio and NO service — pure log analysis. Read-first, test-gated, bake before it drives anything. # usage: envelope.py [eot.jsonl] [--unit N] [--since TS] import json, sys, time LOG = os.path.expanduser("~/istrain/eot.jsonl") SWAP = 1783433400 # antenna epoch (O-15); envelopes only meaningful after this SESSION_GAP = 1200 # >20 min quiet OR unit change = new session (sessionize per O-8) PLATEAU_DB = 6.0 # "receded" if last smoothed peak is >= this far below the max FLAT_DB = 5.0 # dynamic range below this = flat/distant (no real envelope) def load(path, since=SWAP, unit=None): rows = [] for line in open(path): try: r = json.loads(line) except Exception: continue if r.get("ts", 0) < since: continue if "unit" not in r or r.get("peak") is None: continue if unit and r["unit"] != unit: continue rows.append(r) rows.sort(key=lambda r: r["ts"]) return rows def sessionize(rows): out, cur = [], [] for r in rows: if cur and (r["ts"] - cur[-1]["ts"] > SESSION_GAP or r["unit"] != cur[-1]["unit"]): out.append(cur); cur = [] cur.append(r) if cur: out.append(cur) return out def smooth(vals, k=3): # rolling max over k samples — frames are sparse (~30-60s); rolling-max tracks the crest, not the fades out = [] for i in range(len(vals)): out.append(max(vals[max(0, i - k + 1):i + 1])) return out def classify(s, now): t0 = s[0]["ts"] t = [(r["ts"] - t0) / 60 for r in s] # minutes from first frame pk = [r["peak"] for r in s] sp = smooth(pk) psi = [r.get("pressure") for r in s] mo = [r.get("motion") for r in s] imax = max(range(len(sp)), key=lambda i: sp[i]) peak_db, t_close = sp[imax], t[imax] rise = (sp[imax] - sp[0]) / (t[imax] - t[0]) if t[imax] > t[0] else 0.0 # dB/min approach slope (speed proxy) dyn = max(sp) - min(sp) receded = (sp[imax] - sp[-1]) >= PLATEAU_DB # signal fell back off after the crest psi_vals = [p for p in psi if p is not None] psi_end = next((p for p in reversed(psi) if p is not None), None) psi_start = next((p for p in psi if p is not None), None) psi_min = min(psi_vals) if psi_vals else None # the braked TROUGH, not the arrival pressure mo_end = next((m for m in reversed(mo) if m is not None), None) # departure = a session that STOPPED (braked trough / motion-0 seen) then RECHARGES to ~88 + is rolling # (O-10 signature). Anchor on the trough, NOT psi_start — the first frame is the arrival, still charged. had_stop = any(m == 0 for m in mo if m is not None) or (psi_min is not None and psi_min <= 80) recharged = (had_stop and psi_end is not None and psi_end >= 84 and mo_end == 1) dwell_min = (now - s[-1]["ts"]) / 60 if psi_end is not None and psi_end < 10 and not recharged: verdict = f"SET-OUT / air dumped (psi bled to {psi_end}, held) — car left; crossing state unknown from EOT" elif recharged: verdict = f"DEPARTED — brakes re-aired (psi trough {psi_min}->{psi_end}) + rolling (motion->1); pulled out" elif mo_end == 0 and psi_end is not None and psi_end < 84 and not receded: verdict = (f"STOPPED / BLOCKING — arrived then held (psi {psi_start}->{psi_end}, motion->0), " f"plateau not receded; last frame {dwell_min:.0f}m ago = still present") elif receded and dyn >= FLAT_DB and (mo_end == 1 or (psi_end and psi_end >= 84)): verdict = (f"PASSED THROUGH — closest approach {peak_db:.0f} dB at +{t_close:.0f}m, " f"then receded {sp[imax]-sp[-1]:.0f} dB; rolling. approach slope {rise:+.1f} dB/min (speed proxy)") elif dyn < FLAT_DB and peak_db < -38: verdict = f"DISTANT / weak — flat envelope (dyn {dyn:.0f} dB, peak {peak_db:.0f}); not near the crossing" else: verdict = (f"IN PROGRESS / inconclusive — peak {peak_db:.0f} at +{t_close:.0f}m, dyn {dyn:.0f} dB, " f"psi {psi_start}->{psi_end}, motion->{mo_end}, last {dwell_min:.0f}m ago") return dict(unit=s[0]["unit"], n=len(s), span=t[-1], peak=peak_db, t_close=t_close, dyn=dyn, rise=rise, psi=(psi_start, psi_end), mo_end=mo_end, verdict=verdict) def main(): args = sys.argv[1:] path = LOG unit = None; since = SWAP if args and not args[0].startswith("--"): path = args.pop(0) if "--unit" in args: unit = int(args[args.index("--unit") + 1]) if "--since" in args: since = float(args[args.index("--since") + 1]) now = time.time() sess = sessionize(load(path, since, unit)) print(f"envelope: {len(sess)} session(s) since ts {since:.0f} (antenna epoch = {SWAP})\n") for s in sess: if len(s) < 2: # a lone frame has no envelope print(f" unit {s[0]['unit']:>6} n=1 (singleton — no envelope)"); continue c = classify(s, now) print(f" unit {c['unit']:>6} n={c['n']:>3} span={c['span']:>4.0f}m peak={c['peak']:>5.0f}dB dyn={c['dyn']:>3.0f}dB") print(f" -> {c['verdict']}") if __name__ == "__main__": main()