559caead36
Public community release. The complete working system — DSP + decoders (scripts/), web dashboard + API (dashboard/), container stack (docker/), config templates (config/) — plus APOCALYPSE-EDITION.md, the full build guide for a human or a coding agent. GPLv3 (PyEOT dependency). Scrubbed of all secrets, internal IPs, hostnames, and location detail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
113 lines
5.6 KiB
Python
113 lines
5.6 KiB
Python
#!/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()
|