-
Notifications
You must be signed in to change notification settings - Fork 0
Reference
🛰 Sun-Moon Transit Predictor wiki — Home · Setup · Usage · Advanced · Reference · ↩ README
- Configuration
- HTTP API
- Where files live
- Project layout
- Manual run (development / non-Pi)
- Tests
- Assumptions and limitations
- Status
- Trivia & statistical insights
{
"name": "Rheine",
"latitudeDeg": 52.2833,
"longitudeDeg": 7.4406,
"elevationM": 50.0,
"geoidUndulationM": 46.0
}elevationM is the observer's WGS84 ellipsoidal height (a local MSL value
within ~50 m is fine). geoidUndulationM is the EGM2008 N at the observer
location — used only when an aircraft reports alt_baro (pressure
altitude, ≈MSL); the offset is added so the geometric comparison happens in
the right reference frame. Look up your local N at e.g.
unavco.org/software/geodetic-utilities.
Default 0 is fine if you only see GPS-equipped aircraft (alt_geom).
{
"adsb": { "url": "http://localhost:8080/data/aircraft.json", "pollIntervalMs": 2000 },
"tracker": { "horizonS": 300, "stepS": 0.5, "thresholdDeg": 0.3, "looseThresholdDeg": 2.0, "bodies": ["Sun", "Moon"] },
"pushover": { "token": "...", "user": "...", "enabled": true },
"server": { "port": 8081, "host": "0.0.0.0", "publicUrl": "" },
"store": { "path": "./data/history.db" },
"routes": { "enabled": true, "ttlMs": 3600000, "negativeTtlMs": 300000 },
"display": { "enabled": false, "sourceUrl": "", "quickRefreshS": 2, "longRefreshS": 60 }
}display configures the optional e-paper panel
client — edit it from the web Settings panel rather than by hand; the Python
client reads it live from /api/config.
For the complete list of currently-supported fields (sharpcap.targets[],
tracker.minAltitudeM, tracker.minBodyElevationDeg, iss.*,
predictor.*, lifecycle.*, update.*, driftPersist.*,
lifecyclePersist.*, display.*, etc.) refer to
config/service.example.json — that
file is kept in sync with the installer defaults.
thresholdDeg (default 0.3°) is the maximum line-of-sight separation that
triggers a candidate — the Sun's angular radius is ~0.27°, so 0.3° catches
near-misses too. stepS (default 0.5 s) is the sample step the tracker
walks across the look-ahead horizon; the closest-approach time is then
sub-step refined with a parabolic vertex fit, so this only sets the lower
bound on detection coverage, not the time precision of the alert.
The service exposes a small JSON API and serves the web UI on the same
port (default 8081, bind host 0.0.0.0). Replace <host> below with the
Pi's hostname or IP address — for example http://raspberrypi.local:8081/
or http://192.168.1.42:8081/.
| Method & path | Description |
|---|---|
GET / |
Web UI (live state + history table). |
GET /api/state |
Current observer, Sun/Moon Az/El + observability, aircraft count, lifecycle[] (unified per-(icao, body) tracking list with status enum, M11 — primary feed for the new UI), plus candidates[] (live tracker output, backward compat), expected[] (history-based 24 h watchlist, backward compat) and optics (current FOV setup). Refreshed every poll. |
GET /api/history?limit=… |
Past notifications (radio / candidate / imminent stages) from SQLite, newest first. Default 100, max 500. Each row now also carries outcome (graduated / faded / surprise / null) computed across the episode it belongs to — see Alert learning below. |
GET /api/config |
Sanitised view of the runtime config used by the Settings panel: observer, masked Pushover credentials (incl. the notification url), optics, tracker, AirNav. Pushover token + user key come back as ••••<last4> so the page never echoes the secret in plaintext. |
POST /api/config |
Apply a partial config update ({ observer, pushover, optics, tracker, airnav }). Hot-reloads the running service in place and persists changes back to config/observer.json + config/service.json. Masked secret placeholders (••••…) are ignored so a no-op resave never overwrites the real token. |
GET /api/learning?windowDays=… |
Rolling alert-effectiveness stats over the requested window (default 14 days, capped at 90). Returns aggregates (radioFired, radioGraduated, surprises, hitRatePct, surpriseRatePct, …) plus the last 20 classified episodes. |
GET /api/hourstats?sepDeg=…&minElevationDeg=…&windowDays=… |
"Best hours" — 24-bin hour-of-day histogram of the usable hits (imminent-confirmed, sep < sepDeg default 0.5°, elevation ≥ minElevationDeg default 30°), split per body in observatory-local time (hour of closest_at_ms). Returns perBody.{Sun,Moon}[24], total[24], n and peak.{Sun,Moon,all} ({hour,count}/null). Retrospective; windowDays default 3650, capped at 3650. |
GET /api/health |
Liveness probe — always returns { ok: true, time: <ISO> }. |
Responses are Cache-Control: no-store; no authentication, so keep the
service on a trusted LAN or front it with a reverse proxy if you need to
expose it publicly.
# liveness
curl -s http://<host>:8081/api/health
# → {"ok":true,"time":"2026-05-11T12:00:00.000Z"}
# current sky + active candidates
curl -s http://<host>:8081/api/state | jq
# last 20 dispatched notifications
curl -s 'http://<host>:8081/api/history?limit=20' | jq '.events[]'
# open the live UI in a browser
xdg-open http://<host>:8081/ # Linux
open http://<host>:8081/ # macOSobservable: false on a body means it is below the 20° horizon floor — any
aircraft passing in front of it is not reported, by design.
| Path | Purpose | Tracked in git? |
|---|---|---|
<repo>/config/observer.json |
Observer location (lat / lon / elevation, geoid undulation). Personal. | no — gitignored |
<repo>/config/observer.example.json |
Schema reference / template for observer.json. |
yes |
<repo>/config/service.json |
Runtime config (ADS-B URL, intervals, Pushover keys, server, DB, routes). Personal. | no — gitignored |
<repo>/config/service.example.json |
Schema reference / template for service.json. |
yes |
<repo>/data/history.db |
SQLite history of all recorded transit-stage events (created on first run). | no — gitignored |
<repo>/data/lifecycle.json |
Tracking-panel snapshot so a restart doesn't empty the list. | no — gitignored |
<repo>/data/iss.tle |
ISS two-line elements for the offline SGP4 (written by refresh-tle.js). Feature inactive until present. |
no — gitignored |
<repo>/data/update.request |
Transient click-to-update trigger; consumed by stp-update.path. |
no — gitignored |
<repo>/web/ |
Static frontend served at http://<host>:<port>/. |
yes |
<repo>/display/ |
Optional e-paper panel client (Python) + its README. | yes |
<repo>/bin/stp.js |
Service entry point. | yes |
<repo>/scripts/install-pi5.sh |
Idempotent Pi installer (interactive or --non-interactive). |
yes |
<repo>/scripts/auto-update.sh |
Pull + install-deps + restart-on-change. Backs up local config first. | yes |
<repo>/scripts/refresh-tle.js |
Opt-in ISS TLE fetcher (Celestrak); run by stp-tle.timer. |
yes |
<repo>/scripts/test-push.js |
One-shot Pushover sanity check. | yes |
<repo>/systemd/stp.service |
Template for the main systemd unit. | yes |
<repo>/systemd/stp-display.service |
Template for the optional e-paper display client unit. | yes |
<repo>/systemd/stp-update.{service,timer,path} |
Auto-update + click-to-update watcher templates. | yes |
<repo>/systemd/stp-tle.{service,timer} |
Daily ISS TLE refresh templates. | yes |
/etc/systemd/system/stp.service |
Generated unit (paths and user templated by the installer). | n/a (system) |
/etc/systemd/system/stp-update.{service,timer,path} |
Generated auto-update + click-watcher units. | n/a (system) |
/etc/systemd/system/stp-tle.{service,timer} |
Generated ISS-TLE refresh unit + timer. | n/a (system) |
/etc/sudoers.d/stp-update |
Narrow rule: <user> NOPASSWD: /bin/systemctl restart stp.service. |
n/a (system) |
The main service runs sandboxed: ProtectSystem=strict,
ProtectHome=read-only, and the only writable path is <repo>/data/. The
SQLite history file therefore must live inside data/ (the default) —
pointing store.path outside that directory will fail at write time when
running under systemd.
Config preservation contract. observer.json and service.json are
gitignored from the first commit that contains this README. Neither
git pull nor auto-update.sh will ever touch them. The installer only
rewrites them when run with --overwrite. If you ever need to roll back,
copy from the matching *.example.json and re-edit.
.
├── package.json src deps + npm scripts
├── vitest.config.js test runner config
├── bin/stp.js service entry point
├── config/
│ ├── observer.example.json schema reference (real observer.json is gitignored)
│ └── service.example.json schema reference (real service.json is gitignored)
├── src/
│ ├── geometry.js topocentric Az/El + great-circle separation
│ ├── adsb.js fetch + normalise dump1090 aircraft.json
│ ├── tracker.js extrapolation + transit detection (sub-step refined)
│ ├── pushover.js Pushover REST client
│ ├── notifier.js 3-stage dispatch (radio/candidate/imminent) + dedup
│ ├── adsbdb.js callsign → route + hex → airframe, in-memory TTL cache
│ ├── airnav.js AirNav On-Demand API v2 client (server-side, cached)
│ ├── sharpcap.js imminent-transit → SharpCap TCP capture trigger (opt-in)
│ ├── sgp4.js dependency-free SGP4 (ISS), TLE parse, TEME→ECEF
│ ├── iss.js offline ISS transit + visible-pass prediction
│ ├── store.js SQLite history (node:sqlite) + episode stats
│ ├── server.js HTTP server (built-in, no framework)
│ ├── service.js orchestrator (the polling loop)
│ ├── predictor.js history-based 24 h watchlist (M10)
│ ├── opensky.js OpenSky Network REST client (M10, opt-in)
│ ├── lifecycle.js candidate state machine: planned→radio→candidate→imminent→stale (+coasting)
│ ├── config.js loadObserver()
│ └── index.js public re-exports
├── web/
│ ├── index.html Sky-now + LIVE-TRACKING-SIGNALS + History + FOV UI
│ ├── app.js vanilla-JS poller
│ ├── sketch.js FOV transit sketch (SVG, incl. ISS glyph)
│ ├── aircraft-types.js offline ICAO-type → specs table
│ └── style.css dark theme
├── scripts/
│ ├── bootstrap-pi5.sh bare-image one-liner: apt deps + clone + install-pi5.sh
│ ├── install-pi5.sh idempotent installer (interactive or --non-interactive)
│ ├── auto-update.sh git pull → npm install → restart, with config backup
│ ├── refresh-schedule.js OpenSky daily fetcher (M10, opt-in)
│ ├── refresh-tle.js ISS TLE fetcher (Celestrak, opt-in / stp-tle.timer)
│ ├── test-push.js one-shot Pushover sanity check
│ └── sharpcap/ Windows SharpCap trigger: listener, bootstrap, PS installer, README
├── systemd/
│ ├── stp.service main service unit template
│ ├── stp-update.service auto-update oneshot template
│ ├── stp-update.timer nightly schedule (03:30 ±15 min)
│ ├── stp-update.path click-to-update trigger watcher
│ ├── stp-tle.service ISS TLE refresh oneshot template
│ └── stp-tle.timer daily ISS TLE schedule (05:40 ±20 min)
└── test/ 17 vitest files, ~205 cases
Useful for hacking on the code, testing config changes, or running on a
non-Pi machine that already has dump1090-fa (or an equivalent feed)
reachable on the network.
npm install
cp config/service.example.json config/service.json # then edit
node --experimental-sqlite bin/stp.jsThe process logs the listening URL, the resolved ADS-B URL, and whether
Pushover is enabled. Stop it with Ctrl+C — it traps SIGINT / SIGTERM,
closes the HTTP server, flushes SQLite, and exits cleanly.
--experimental-sqlite is needed on Node 22; on Node 24+ the flag becomes a
no-op since node:sqlite is stable.
| Variable | Default | Purpose |
|---|---|---|
STP_OBSERVER |
<repo>/config/observer.json |
Override the observer-config path. |
STP_CONFIG |
<repo>/config/service.json |
Override the service-config path. |
Useful for running multiple observer locations from a single checkout, or for keeping production credentials out of the repo:
STP_OBSERVER=/etc/stp/observer-rheine.json \
STP_CONFIG=/etc/stp/service.prod.json \
node --experimental-sqlite bin/stp.jsnpm test~205 vitest cases across 17 files cover geometry, ADS-B parsing, tracker (including the ADS-B latency back-stamp, sub-step vertex refinement, barometric geoid offset, and the level=candidate/radio split), Pushover client, notifier (3-stage pipeline with minStage filter), route lookup with TTL cache, history store (with stage-rename migration), the HTTP server, the history-based predictor, the OpenSky REST client, the SGP4 propagator (validated against the official Vallado 88888 verification vectors), the SharpCap trigger (dedup / re-arm / busy-vs-network-fail distinction), and the lifecycle state machine (planned / radio / candidate / imminent / stale + coasting + the four stale-reason classifications).
- Geometry: 0° = N, 90° = E. WGS84 → ECEF → ENU for aircraft Az/El. Observer ECEF is computed once per tick and reused for every aircraft × body.
-
Reference frame for the comparison: both aircraft and body are
compared in geometric (un-refracted) coordinates.
/api/statestill exposes the refracted body position via the regularbodyAzElfor display. Differential refraction along two near-coincident lines of sight is well below the search noise. -
Observability:
isObservable(azEl, minElevationDeg)returnstrueonly above the threshold (default 20°). Since v0.30.37 the tracker auto-widens this down to the lowest enabled rig'sminElevationDeg(hard-floored at 5° for refraction sanity), so a clear-horizon site with a 10°-tolerant main rig will see candidates with the body between 10° and 20° elevation that the old hardcoded floor would have hidden. -
Aircraft altitude: prefers
alt_geom, falls back toalt_baro.alt_geomis GPS height above WGS84 ellipsoid (DO-260) and is fed straight in.alt_barois pressure altitude (≈MSL on standard atm.) and is converted to HAE by addingobserver.geoidUndulationM(default 0; ≈+46 m at Rheine). -
Extrapolation: linear, locally-flat tangent plane, 300 s horizon
(default — clamped to
[10, 1800]). Error versus geodesic is well under 1 m at our typical speeds within ~60 s; reasonable through ~5 min in stable cruise. Aircraft are projected from their fix time (receivedAtMs), not fromnow, so aseen_poslag of several seconds does not bias the predicted position. -
Sub-step time precision: after the discrete minimum is located, a
parabolic vertex is fitted through the three samples around it. With the
default
stepS = 0.5 sthis gives sub-100-ms closest-approach time. -
ADS-B liveness: aircraft with
seen_pos > 30 sare dropped during parsing — stale fixes are not extrapolated. - No camera trigger: explicitly out of scope. We push, you arm the camera.
Production-ready and in daily use; ~78 named milestones (M1 → M78,
covering v0.0 → v0.30.39). Detailed history with per-milestone scope
lives in MILESTONES.md. For "what changed in
release X" see git log --oneline.
A mix of physical-geometry math, real prediction statistics from a month-plus of live operation, astronomical curiosities, and the amusing-in-hindsight bugs that shaped the architecture. Numbers are from the running site (Rheine, Germany) unless noted; your data will look broadly similar but specific values drift with traffic patterns and weather.
The Sun and Moon disc both span about 0.53° of sky — angular radius ~0.27°. That radius, multiplied by your slant range to the aircraft, is how many METRES of lateral track error pushes the plane out of the disc:
| Body elevation | Slant range @ 12 km alt | Lateral tolerance for disc-edge |
|---|---|---|
| 90° (overhead) | 12.0 km | ~57 m (≈ half an A320) |
| 60° elev | 13.9 km | ~65 m |
| 45° elev | 17.0 km | ~80 m |
| 30° elev | 24.0 km | ~113 m |
| 20° elev | 35.1 km | ~165 m |
| 10° elev | 69.1 km | ~326 m |
Counterintuitive result: at LOWER body elevation the lateral tolerance GROWS (longer slant range = same 0.27° spans more metres). But low- elevation traffic is overwhelmingly descent / approach aircraft that get ATC-vectored constantly, so they drift 500-1000 m in the last minute — which overwhelms the geometric tolerance. Cruise traffic at high elevation has < 50 m of late-stage drift (autopilot + LNAV + GPS is mathematically exact at this scale), so the system records very tight bullseye transits when the prediction holds.
After ~90 finished episodes the prediction-error stats break down roughly like this (the live panel auto-updates, these are typical ranges):
| Lead at sample | High-elev (≥ 30°, cruise) | Low-elev (< 30°, descent / approach) |
|---|---|---|
| > 90 s | p50 ~0.20° · p95 ~1.5° | p50 ~0.30° · p95 ~2.5° |
| 30–60 s | p50 ~0.08° · p95 ~1° | p50 ~0.10° · p95 ~2° |
| < 10 s | p50 ~0.04° · p95 < 0.3° | p50 ~0.13° · p95 ~2° |
The clean read: high-elev cruise predictions converge to <0.05° median in the last 10 seconds — well inside the 0.27° disc radius. Low-elev approach traffic plateaus at ~0.13° median (still on-disc on average), but the long-tail (P95 ~2°) reflects ATC-vectoring drift that mathematically cannot be predicted from ADS-B alone. The predictor reports it honestly — it does not hide the long tail.
Field calibration (n=1014 episodes). A larger run confirms the shape and sharpens the takeaways:
- Lead time barely matters. Aggregate P50 is 0.09–0.18° and P95 ~0.93° across all lead buckets — waiting from 90 s out to 10 s out buys only ~0.09°. There is no reason to hold out for a late lock-in; the prediction does not meaningfully tighten as the ETA approaches.
- Elevation is the real lever. At ≥ 30° the > 90 s bucket is already 0.09° (n=293); below 30° it sits at 0.21° (n=664) and is what drags the aggregate down. Treat transits ≥ 30° as reliable; keep more margin (or skepticism) below that. This validates the default 30° Pushover gate — it is gating on the genuine accuracy cliff, not an arbitrary line.
-
Aim for the best, not the last. The prediction drifts back out
after its tightest point — median 0.34°, P95 0.97° (
final − best). The best projected moment is more trustworthy than the final one, which is exactly why the live table and SharpCap arming trackbestSepDegrather than the latest value.
Sun vs Moon — no per-body difference, by design. The error budget
is entirely the aircraft trajectory; both body positions are known
to arcsecond precision (topocentric, parallax-corrected), so there is
no physical reason for prediction accuracy to differ between the two
discs. predictionAccuracy() therefore blends both bodies on
purpose — any Sun/Moon gap you could split out would be a traffic-mix
/ time-of-day confound (Moon transits cluster at different hours →
different elevations and traffic), not a property of the body. The
elevation split above is the cut that actually carries signal.
The drift-bias sampler runs every tick over every aircraft above 20° elevation, comparing actual position vs. a constant-velocity extrapolation of the previous fix. The mean residual across many flights = systematic wind / ATC bias for the day. Two observed days worth contrasting:
- Day 1 (light Ostwind): mean drift 0.9 m/s @ 97° E across n=1407 samples. σ_E = 7.5 m/s, SE = 0.20 m/s → 4.5σ above zero, statistically significant.
- Day 2 (calm): mean drift 0.1 m/s @ 108° E across n=4000 samples. σ_E = 8.0 m/s, SE = 0.13 m/s → 0.8σ, indistinguishable from no wind.
The system actually detects real-world wind conditions — and is honest when there isn't a meaningful signal. Standard Error (σ/√n), not raw σ, is the correct test for "is the mean different from zero" (see v0.30.39 fix).
Angular speed of an airliner across the sky is roughly v / range:
| Airliner speed | Slant range | ω (deg/s) | Time to cross 0.53° Sun disc |
|---|---|---|---|
| 250 m/s (≈ 900 km/h) | 12 km | 1.19°/s | ~0.45 s |
| 250 m/s | 25 km | 0.57°/s | ~0.93 s |
| 250 m/s | 60 km | 0.24°/s | ~2.2 s |
For an overhead cruise jet you have less than half a second of disc contact — explains why SharpCap's own Sequencer is too slow and why this whole project exists.
The Sun's angular diameter varies between 0.524° (Aphelion ~July 4) and 0.541° (Perihelion ~January 3) — about 3 % swing. The disc radius therefore ranges from 0.262° to 0.270°. The Moon swings wider: 0.490° (Apogee) to 0.564° (Perigee, a "supermoon"). The predictor uses fixed nominal disc sizes in the search threshold; the real variation rounds inside the 0.3° candidate band so it doesn't affect detection.
From a few days' worth of stats at Rheine, NW Germany (well-served by Amsterdam approach traffic + Frankfurt overflights):
- ~5-15 confirmed-imminent transits per day, mixed Sun + Moon.
- Of those, ~15-20% are on-disc bullseyes (< disc radius). The rest are near-misses 0.3-2° off-centre.
- So about 2-3 actual photographable transits per day for the site — multiply by a typical clear-sky fraction in NW Germany and you get the realistic photographic output.
A few bugs that shaped the current design. All in MILESTONES.md if
you want the full reconstruction.
-
The
→arrow that broke a Windows install (v0.30.15): a single U+2192 in a Python comment crashed the SharpCap bootstrap's cache writer because Windows defaults to cp1252 encoding for text-mode file writes. Forced the listener body to pure ASCII forever after. -
subprocess.runis too new (v0.30.17): some SharpCap builds embed Python 3.4.subprocess.runarrived in 3.5,capture_output=Truein 3.7. Had to drop toPopen + communicateto stay portable. -
The console window that flashed for 1 second (v0.30.18):
CREATE_NO_WINDOWflag is Python 3.7+. Older Python silently fell back to 0 and a CMD window briefly popped on every robocopy invocation. Combine withSTARTUPINFO + STARTF_USESHOWWINDOW + SW_HIDE(in subprocess since Python 2) to cover all versions. -
The 120 s outcome wait (v0.30.34): a 20 GB SSD → NAS copy takes many minutes. The predictor's
_probempty/_confirmedverdict arrives ~60 s after the lifecycle settles, so the LOCAL rename on SSD has to happen FIRST, then the long upload uses the already- tagged filename. Listener blocks onthreading.Event[captureId]for up to 120 s before starting the NAS phase — so the final filename never races the upload. -
TCP-storm on busy listener (v0.30.3): the per-rig dedup was releasing the slot on EVERY failed send. A "busy" response (listener recording for another ICAO) would clear dedup, next tick fired again, repeat 25 times in 60 s. Fix: distinguish JSON-reply-but-
ok:false(= listener said no, keep dedup) from no-reply (= network failed, release dedup). -
Drift sampler's "low signal" flag was using the wrong test (v0.30.39): comparing the mean magnitude to within-population σ is not a significance test — that ratio never converges to <1 even with millions of samples. The right comparison is Standard Error of the mean (σ / √n), which shrinks with n. With n=1407 samples a 0.9 m/s mean against σ=7.5 is 4.5 standard errors above zero — highly significant, even though the population variance suggests noise.
- ~205 vitest cases across 17 files cover the whole pipeline.
- 5-15 confirmed-imminent transits per day at a mid-European site.
- 57-326 m of lateral track-error pushes an aircraft off the disc, depending on body elevation.
- 0.5 - 2 seconds is a typical disc-crossing time for an airliner.
- ~120 s is the worst case for the outcome-verdict pipeline to settle on the final filename (lifecycle-stale + outcome-wait).
-
78 named milestones, M1 → M78, in
MILESTONES.md.
{ "observer": { "name": "Rheine", "latitudeDeg": 52.2833, "longitudeDeg": 7.4406, "elevationM": 50 }, "nowMs": 1762870000000, "lastUpdateMs": 1762869998000, "aircraftCount": 17, "bodies": { "Sun": { "azimuthDeg": 178.4, "elevationDeg": 42.1, "rangeM": 1.5e11, "observable": true }, "Moon": { "azimuthDeg": 65.2, "elevationDeg": -8.7, "rangeM": 3.8e8, "observable": false } }, "candidates": [ { "icao": "3c6589", "callsign": "DLH4PV", "body": "Sun", "minSeparationDeg": 0.18, "closestApproachAtMs": 1762870042000, "transitDurationS": 1.4, "altitudeFt": 37000, "groundSpeedKt": 454, "route": { "iataFlight": "LH123", "origin": "FRA", "destination": "JFK", "airline": "Lufthansa" } } ] }