Pendleton, Oregon freight-train information: grade crossings, the UP La Grande Subdivision, wayside defect detectors, historical blocked-crossing patterns, and a history-based estimate of whether a tr
翻译准备中
其他JavaScript · PythonGitHub @lperezmo在线运营者未确认
试用过了吗? 点一下就好,无需登录。
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证Pendleton, Oregon freight-train info: grade crossings, the UP La Grande Subdivision, defect detectors, historical blocked-crossing patterns, and a history-based probability that a train is blocking downtown right now.
翻译准备中
取自仓库 README
正在加载完整 README。
查看完整 README ↗Pendleton, Oregon freight-train information: grade crossings, the UP La Grande Subdivision through downtown, wayside defect detectors, historical blocked-crossing patterns, and a history-based estimate of whether a train is blocking downtown right now.
The Union Pacific La Grande Subdivision runs right through the middle of Pendleton. A single long train can block several downtown street crossings at once and split the town in two. There is no public real-time freight-train feed, so this app focuses on what is knowable without one: a detailed map, six years of blocked-crossing history from the FRA, and a live-alert architecture that is ready to plug in once a local radio receiver is on the ground.
Train Vantage has three layers.
The map tab shows an OpenStreetMap basemap (proxied through /api/tiles) with these
overlays, all toggle-able from the sidebar:
Click any crossing or detector marker for a popup with ID, milepost, type, and detector functions.
The headline of the app. The History tab and the top-of-sidebar gauge are both driven by 317 self-reported blocked-crossing reports from the FRA Blocked Crossing Incident Reporter for Umatilla County, OR, covering 2020-07-24 through 2026-06-17 (approximately 2,155 observation days).
Important framing: these are unverified public reports. They describe historical patterns, not live train position. The app says so clearly in the UI.
Charts rendered from the history:
| Chart | What it shows |
|---|---|
| Most-reported crossings | Top crossings by report count, labeled Downtown / Private / Other |
| Reports by hour of day | 24-bar SVG histogram; peak hour highlighted |
| Reports by day of week | Which days have the most reports |
| Reports by month | Seasonal pattern across all years |
| How long blockages lasted | Duration bucket distribution (0-15 min through 8+ hr) |
| Reported reasons | Top 5 reasons given by reporters |
| When reports cluster (day x hour) | 7x24 likelihood heatmap (Mon-Sun by 0-23 hr) |
The "Chance a train is blocking downtown right now" gauge at the top of the sidebar blends the hourly frequency from the history with a day-of-week factor. It refreshes every five minutes when the page is left open. It is a historical-frequency estimate, not a live observation.
A note on the data: the single most-reported crossing in the dataset is 809043H, a private road crossing east of downtown near Cayuse (211 of 317 reports). The most- reported public crossing west of downtown is 809097N (NW McKennon Rd / Bartsch Rd, 81 reports). The five core downtown at-grade crossings around SE 1st-4th St do not appear in the blocked-crossing dataset, possibly because reports for those crossings are filed under different city values. The charts label crossing categories honestly.
Data refresh: to regenerate the baked JSON from the live FRA endpoint, run the fetch scripts from the repo root:
uv run scripts/fetch_blocked.py
uv run scripts/fetch_geo.py
Both scripts are idempotent and print a summary on completion.
The Live and ETA tab shows the ETA model and the live-detection architecture. By default the tab shows a discreet banner ("no live radio detector is wired in yet") and the heads-up table. No live feed runs unless you configure one.
ETA model. When a wayside detector fires, the model computes:
eta_min = dist_to_downtown_mi / speed_mph * 60
Direction is geometry: detectors with milepost less than 215.5 (west of downtown) catch eastbound trains approaching from the Columbia Plateau; detectors with milepost greater than 215.5 (east of downtown) catch westbound trains coming down the Blue Mountain grade.
The heads-up table brackets each key detector against 25 mph and 40 mph to show a range of expected heads-up time. The instantaneous speed at the detector is the only speed ever available, and trains accelerate and brake significantly on the grade, so the farther detectors carry wider error bars.
The five key detectors and their heads-up ranges to downtown (MP 215.5):
| ID | Location | MP | Direction | Dist (mi) | Heads-up range |
|---|---|---|---|---|---|
| 10701 | Mission | 221.9 | westbound | 6.4 | ~10-15 min |
| 10702 | Rieth | 211.1 | eastbound | 4.4 | ~7-11 min |
| 10703 | Rieth | 207.1 | eastbound | 8.4 | ~13-20 min |
| 10704 | Echo | 194.9 | eastbound | 20.6 | ~31-49 min |
| 10700 | Bonifer | 240.7 | westbound | 25.2 | ~38-60 min |
When a live detection store is configured, the /api/live serverless function
reads the ten most recent rows from a Supabase detections table and returns them to
the front end. The live banner turns green, a recent-detection list appears, and a
green dot is placed on the map at the detector's position.
Supabase detections table schema:
CREATE TABLE detections (
id bigserial PRIMARY KEY,
detected_at timestamptz NOT NULL DEFAULT now(),
detector_id text NOT NULL,
milepost numeric,
speed_mph numeric,
axles integer,
direction text, -- 'eastbound' | 'westbound'
defect boolean,
eta_downtown_min numeric,
raw_text text
);
Environment variables (set as Vercel Project Environment Variables; not committed):
SUPABASE_URL= # e.g. https://xxxxxxxx.supabase.co
SUPABASE_KEY= # Supabase anon or service key (used by /api/live reader)
INGEST_TOKEN= # Shared secret; watchtower must send this to POST /api/ingest
With these unset (the default deployed state), the app returns { configured: false }
from /api/live and the front end shows the subtle "not wired in yet" banner. Nothing
breaks.
The watchtower is a small local pipeline that listens to wayside detector radio broadcasts and posts each detection to the app. You need an RTL-SDR dongle (RTL2832U + R820T2 chipset, general-purpose, roughly $15-$25). Do not use a 1090 MHz ADS-B-only stick; those cannot tune 160 MHz.
The detectors broadcast their automated voice reports on 160.410 MHz, narrowband FM. A typical call, once transcribed, reads:
U P detector milepost two two one point nine track one total axles 412
speed 35 miles per hour no defects
sudo apt install rtl-sdr soxrtl_test should list it without errors.All scripts are in the watchtower/ directory. Run them from there with uv run.
Each script uses PEP 723 inline dependency metadata, so uv run installs any Python
dependencies automatically into an isolated environment on first use.
Step 1: capture (capture.py). Tunes rtl_fm to 160.410 MHz NFM, squelched,
and saves a wav clip per transmission. The underlying command is:
rtl_fm -f 160.410M -M fm -s 16k -l 50 -g 40 | \
sox -t raw -r 16k -e signed -b 16 -c 1 - clip.wav silence 1 0.1 1% 1 2.0 1%
To test without hardware, use --mock with any local wav clip:
uv run watchtower/capture.py --mock sample.wav --out clip.wav
Live capture (needs the dongle):
uv run watchtower/capture.py --out captures/
Step 2: transcribe (transcribe.py). Runs faster-whisper (tiny.en model, CPU,
int8) on the captured wav. The model downloads once on first run and is cached locally.
uv run watchtower/transcribe.py clip.wav
# Optional: use a larger model for better accuracy on noisy audio
uv run watchtower/transcribe.py clip.wav --model small.en
Alternatively, if you prefer to avoid the Python dependency, build whisper.cpp and run:
./main -m models/ggml-tiny.en.bin -f clip.wav -otxt
cat clip.wav.txt
Step 3: parse (parse.py). Converts the transcribed text into a structured
detection (milepost, track, axles, speed, defect flag). Uses stdlib only; no
dependencies to fetch.
uv run watchtower/parse.py
# (self-test mode: runs a set of sample calls through the parser and prints PASS/FAIL)
Step 4: ETA (eta.py). Computes direction and minutes to downtown from milepost
and speed. Mirrors the JavaScript model exactly.
uv run watchtower/eta.py
# (demo mode: prints ETA for four sample detector positions)
Step 5: push (push.py). POSTs the assembled detection payload to the app's
/api/ingest endpoint (recommended) or directly to Supabase. Reads JSON from stdin.
echo '{"detector_id":"10701","milepost":221.9,"speed_mph":35}' | \
INGEST_URL=https://train-vantage.vercel.app/api/ingest \
INGEST_TOKEN=your-secret \
uv run watchtower/push.py
Without env vars, push.py prints what it would send and exits 0 (safe, inert).
End-to-end in one command (run.py). Glues all steps together. Use --mock to
test with a wav clip (skips the radio, runs transcribe through push), or --text to
skip capture and transcribe and feed raw text directly into the parser:
# Test with a local wav clip (no hardware needed):
uv run watchtower/run.py --mock sample.wav --detector-id 10701
# Test the parser directly with raw text:
uv run watchtower/run.py --text "U P detector milepost two two one point nine track one total axles 412 speed 35 miles per hour no defects" --detector-id 10701
For continuous live operation, run capture.py in a loop and hand each clip to run.py:
# Illustrative loop (adapt to your OS and desired restart behavior):
while true; do
uv run watchtower/capture.py --out clip.wav
uv run watchtower/run.py --mock clip.wav --detector-id 10701
done
Set these before running push.py or run.py to route detections to the store:
# Recommended: through the app ingest endpoint
export INGEST_URL="https://train-vantage.vercel.app/api/ingest"
export INGEST_TOKEN="your-shared-secret" # must match INGEST_TOKEN in Vercel env
# Alternative: write directly to Supabase (bypasses the app)
export SUPABASE_URL="https://xxxxxxxx.supabase.co"
export SUPABASE_KEY="your-supabase-key"
There is an opt-in volunteer page at /volunteer.html (live at
https://train-vantage.vercel.app/volunteer.html) that runs Whisper entirely in your
browser via transformers.js (Xenova/whisper-tiny.en).
Point a scanner speaker at your device's microphone, tune to 160.410 MHz, click Start,
then Stop after a detector call. The page transcribes the audio in your browser, parses
the detector call (the same grammar as parse.py), and lets you submit the detection to
/api/ingest.
Honest caveats:
Nothing on the volunteer page is required to use Train Vantage. The main app works fully without it.
Needs Bun 1.4 or newer.
bun install
bun run dev
Open the localhost URL printed by Vite. Vite is still the dev server and bundler; the
scripts run it on the Bun runtime. The /api/tiles, /api/live, and /api/ingest
proxies run as Vite dev middleware locally and as Vercel serverless functions in
production, so behavior is identical in both environments. See vite.config.js for the
middleware wiring.
bun run build # produces static output in dist/
bun run check # bun fmt --check, bun lint and the build
bun fmt ships in Bun 1.4.4, but bun lint only exists on Bun canary for now
(bun upgrade --canary), so CI runs both on canary and keeps the build job on the
pinned Bun release. Generated data
in public/data/ is left out of formatting (see .prettierignore).
Copy .env.example to .env and fill in values to test the live layer locally. Bun
loads .env into process.env for the dev proxies.
This repo is connected to Vercel. Pushing to the default branch auto-deploys. To set
it up fresh: import the repo into Vercel with the Vite framework preset. vercel.json
pins the install (bun install --frozen-lockfile), the build and all three serverless
functions. Set the Supabase and ingest env vars
as Vercel Project Environment Variables when you are ready to wire in live alerts.