-
Notifications
You must be signed in to change notification settings - Fork 0
Advanced
🛰 Sun-Moon Transit Predictor wiki — Home · Setup · Usage · Advanced · Reference · ↩ README
- Predictive watchlist (24 h preview)
- Schedule augmentation (OpenSky, optional)
- SharpCap capture trigger (optional)
- Candidate lifecycle (planned → radio → candidate → imminent → stale)
- How the prediction works
- End-to-end pipeline reference
The live tracker only sees tracker.horizonS seconds into the future
(default 300 s = 5 min since v0.23.4, linear ADS-B extrapolation).
The predictor complements it with a 24 h preview built
from past transits: any (flight, body) pair that hit ≥ 2 distinct days in
the last 14 produces a watchlist entry, and the next expected occurrence is
surfaced in state.expected. The "Expected today" panel in the web UI
renders this list as ETA · Time · Body · Flight · Seen · Days · Spread.
"Spread" is the standard deviation of the observed time-of-day across days
— think of it as a confidence proxy: ±5m means the flight is reliably on
schedule, ±45m means highly variable.
Defaults (override under predictor in config/service.json):
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Master switch. |
daysBack |
14 |
History window scanned for repeats. |
minRepeats |
2 |
Min number of distinct UTC days an entry must hit. |
bucketMinutes |
60 |
Time-of-day binning width — coarse enough to absorb day-to-day jitter, fine enough that the median predicted time is meaningful to ~1 h. |
rebuildIntervalMs |
3600000 (1 h) |
Cadence for re-scanning the history table. |
lookAheadMs |
86400000 (24 h) |
Window into the future the predictor surfaces. |
The predictor is fully local — it reads only data/history.db and
needs no external API. The watchlist warms up over the first 1–2 weeks of
operation as the same scheduled flights repeat. Entries decay automatically
as observations age out of daysBack.
For faster watchlist warm-up, or coverage of flights your local ADS-B receiver missed (offline, low signal, terrain-shadowed), you can pull historical arrivals + departures from OpenSky Network at airports near you and feed them into the predictor as additional observations.
Off by default. Enable with two changes in config/service.json:
"opensky": {
"enabled": true,
"airports": ["EDDF", "EDDL", "EHAM"],
"lookbackDays": 7
}(Or set STP_OPENSKY_AIRPORTS=EDDF,EDDL,EHAM before running the
installer in --non-interactive mode — the script writes the section for
you and flips enabled.)
Then run the fetcher manually to populate data/history.db:
node --experimental-sqlite scripts/refresh-schedule.jsOutput:
[EDDF] arrival day -0: 142 flights
[EDDF] departure day -0: 138 flights
…
refresh-schedule done: inserted=1840 skipped(no body)=120 pruned=0
pruned removes rows older than lookbackDays so the table stays bounded.
The job is idempotent — re-running over the same window inserts zero
new rows (UNIQUE(source, flight, timestamp_ms) constraint).
For nightly automation, drop a unit + timer pair next to the existing
auto-update timer (the runner is node scripts/refresh-schedule.js).
Anonymous OpenSky has a generous 4000 req/day quota; one nightly run for
3–5 nearby airports is well under that limit.
Caveat. OpenSky tells us that a flight existed at a given airport,
not whether it overflew our observer. The predictor groups observations
by (flight, body, time-of-day), so an arriving flight at FRA at 11:00 UTC
becomes a "11:00 ± 1 h Sun watchlist entry" — useful as a heads-up, but
your local ADS-B history remains the ground truth for transit timing.
Don't enable OpenSky if you only fly low priority on accuracy and want
fewer false-positive watchlist entries.
Closes the loop from prediction to imaging. An aircraft transit gives you
seconds, not minutes, of warning — far too tight to arm SharpCap's own
Sequencer by hand. Instead the predictor pushes a trigger to a small listener
running inside SharpCap the moment a transit goes imminent, and SharpCap
records a clip framed around the predicted closest approach.
predictor (Pi/Linux) Windows capture PC
──────────────────── ─────────────────────────────────────
notifier emits 'imminent' ── TCP :9999 ▶ trigger_listener.py (in SharpCap)
src/sharpcap.js sends one JSON line RunCapture() after preRoll,
StopCapture() after the window,
optional auto-copy of the .ser → NAS
-
Predictor side: ⚙ Settings → SharpCap capture trigger — toggle on,
set the Windows host + port, the pre-/post-roll (default −10 s / +10 s around
the transit), the minimum elevation and a "push on trigger" option, then hit
Test trigger (2 s). Hot-reloads and persists to
config/service.json(sharpcapblock). Off by default. -
Windows side: a one-shot PowerShell installer sets up a bootstrap that
pulls the latest listener from GitHub on every SharpCap launch, plus an
optional post-capture copy/move of the
.serto a network drive. The listener uses stdlib only (socket,threading,json,time,subprocess,shutil), runs on whatever Python SharpCap embeds (CPython in 4.x; IronPython in older builds — both work), so no separate Python install is needed.
Multi-rig (v0.24+, fixed v0.30.1). Drive several telescopes/PCs at
once via sharpcap.targets[]. Each entry runs its own listener on its
own host:port, inherits the shared knobs, overrides per-rig fields
(bodies, maxSepDeg, buffers…). A Sun candidate arms only Sun rigs,
a Moon candidate only Moon rigs — independent dedup + re-arm state per
rig. The top-level sharpcap.{host,port,…} block is auto-promoted to
its own implicit "main" rig when targets[] is populated (suppressed
only on host:port collision).
Rich filename tagging (v0.30.32+). The listener renames the source
.ser ON THE LOCAL SSD with all known meta tags before the (often
multi-minute, multi-GB) NAS upload starts:
HH_MM_SS_Sun_p9999_4080e9_BER-LHR_sep021.ser
│ │ │ │ │
│ │ │ │ └─ predicted sep 0.21 deg
│ │ │ └─────────── origin → destination
│ │ └────────────────── ICAO 4080E9 (lowercase)
│ └─────────────────────── port (which rig)
└──────────────────────────── body
Outcome verdict (v0.30.33+). ~60 s after the lifecycle entry
finishes, the predictor sends a type:"outcome" packet. The listener
appends _confirmed (entry reached imminent stage) or _probempty
(entry faded before imminent — recording most likely shows empty sky)
plus _finalsepNNN (drift at end) to the same file ON THE SSD, then
the NAS upload uses the final tagged name. The 60 s wait gives a 20 GB
upload time to settle without racing the rename.
Full Windows install, the one-time SharpCap startup-script wiring, the
machine-local folder config, the wire-format and a hand-test recipe live in
scripts/sharpcap/README.md.
Every (icao, body) entry the service tracks goes through up to four
status transitions during its lifetime. They show up in the UI as a
single dynamic list — the user requested an "approach radar"-style flow
rather than two disjoint tables — and the notifier turns three of them
into Pushover messages:
| Status | Trigger | Push priority | Typical lead time |
|---|---|---|---|
| planned 📅 | predictor watchlist (recurring history) says a flight is expected within lifecycle.plannedWindowMs (default 1 h) |
none (UI only) | minutes to hours |
| radio 📡 | tracker projects [thresholdDeg, looseThresholdDeg] separation (default 0.3°–2°) within horizonS (default 15 min) |
0 | up to ~15 min |
|
candidate |
tracker projects ≤ thresholdDeg separation (default 0.3°) within horizonS, more than imminentWindowMs away |
0 | 30 s – ~15 min |
| imminent 🎯 | closest approach within ±imminentWindowMs (default ±30 s) |
1 | ≤ 30 s |
| stale ❌ | was tracked last tick, gone from the tracker output now — first coasts on its last status for ~25 s (brief ADS-B gap), then held as stale until the 30-min grace (lifecycle.staleGraceMs = 1800000) expires or the panel cap displaces it |
none (UI only) | — |
Stage rules:
-
Subsumption. Higher stages "consume" the lower ones — an aircraft
that appears directly on the line of sight fires
candidate(orimminent) on the first sighting and does not retroactively emitradio. Each stage fires at most once per(icao, body)per detection cycle, then dedupes for 5 min before forgetting state. -
Subscription control.
pushover.minStage(defaultradio) is the earliest stage that may push. Set tocandidateto silence the wide-net early-warning stage if it gets too chatty;imminentfor "alert me only at the last 30 s". -
What goes into SQLite. Only
radio,candidateandimminentare persisted totransit_history.plannedis regenerated from the watchlist each tick;staleis a UI-only display state. -
Coasting. A single missed ADS-B squitter no longer flips a contact
to
stale: it holds its last live status forlifecycle.coastMs(default 25 s) before decaying, so a flight doesn't visibly drop and reappear near the horizon. -
Panel cap & stale grace. The tracking list is capped at
lifecycle.maxEntries(default 10). Astaleentry is dropped once it is older thanlifecycle.staleGraceMs(default 1 800 000 ms = 30 min) or, on a busy minute, when the cap displaces it (oldest stale first, FIFO bylastUpdateMs; active rows are always kept). SetstaleGraceMs: 0to revert to the old cap-only eviction (stale entries persist until pushed off the bottom).
Each notification carries: callsign, IATA flight number (if adsbdb resolves it), airline, origin/destination, altitude (ft), ground speed (kt), minimum separation, transit duration, ETA. Same payload is recorded in the SQLite history table.
tracker.horizonS (default 300 s = 5 min since v0.23.4 / M62; was
900 s before but linear extrapolation degrades fast for descending /
turning approach traffic, so the 15-min horizon produced too many
speculative candidates that fired a radio alert then faded) is the
window the live tracker linearly extrapolates each aircraft over.
Clamped to [10, 1800] in code. Typical settings:
| Use case | horizonS |
What you get |
|---|---|---|
| Maximum precision | 60 | First-detection at ~T-60 s; lowest false-positive rate. |
| Default | 300 | First-detection at ~T-5 min; few false-positives; SharpCap arms ~95 s out anyway, a Pushover 1–2 min ahead is ample warning. |
| Conservative | 600 | First-detection at ~T-10 min; more "faded" false-positives but earlier heads-up. |
| Wide net | 1800 | First-detection at ~T-30 min (upper clamp); maximum lead, most noise. |
tracker.looseThresholdDeg (default 2° since v0.7.4; was 5° in
v0.1–v0.7.3) is the radio band width — anything wider is dropped from
the tracking panel entirely. Editable in the Settings UI under the
Tracker fieldset. Set to the same value as thresholdDeg to disable
the radio stage and fall back to the old two-stage flow. The Pushover
phone-buzz threshold (pushover.radioThresholdDeg, default 1°) is a
separate, tighter filter on top of this.
Every poll cycle (default every 2 s) the service answers one question:
Which aircraft, currently visible to the local ADS-B receiver, will line up between my observer location and the Sun or Moon disc within the next 300 seconds (5 min) — while that body sits above the observability floor?
-
Sky position. Topocentric Az/El of the Sun and Moon are computed for
the configured observer (WGS84, refraction-corrected). Bodies below the
observability floor (default 20°, auto-widened down to the lowest
enabled rig's
minElevationDegsince v0.30.37, hard-floored at 5°) are flaggedobservable: falseand skipped — the floor keeps obstructions, haze, and refraction residuals out of the budget. -
Aircraft position. Each aircraft from
dump1090-fa'saircraft.jsonis converted WGS84 → ECEF → ENU into Az/El relative to the same observer, usingalt_geom(fallbackalt_baro) as MSL. ADS-Bseen_poslatency is back-stamped onto the actual fix time, so the projection starts from when the position was sampled, not from "now". -
Forward projection. Position and velocity are linearly extrapolated
on the local tangent plane in 0.5 s steps across the next 300 s
(5 min default; clamped to
[10, 1800]viatracker.horizonS). -
Separation test. Great-circle angular separation between the
predicted aircraft Az/El and the body's Az/El is computed at each step.
When the minimum separation across the trajectory drops below
thresholdDeg(default 0.3°; the Sun's disc is ~0.27° wide), the aircraft becomes a transit candidate with closest-approach time, minimum separation, and transit duration. -
Three-stage pipeline. Each match is classified by its projected
minimum separation and time-to-closest: radio (inside the wide
panel band,
looseThresholdDeg, default 2° — early warning), candidate (inside the tight band,thresholdDeg, default 0.3°) and imminent (closest approach within ±30 s). A Pushover fires once per stage, deduplicated per(icao, body); the phone has its own tighter filter (pushover.radioThresholdDeg, default 1°) so it stays quiet on the widest band.
The browser UI and the SQLite history give you the same view after the
fact. Since v0.8.0 the History is logged at the full panel band
independent of the Pushover phone filter — the early radio row is
recorded even when the phone deliberately stays silent, so the Lead
column (Transit − Recorded) reflects the true advance warning. Each row
carries callsign, IATA flight, origin / destination, minimum separation,
ETA, altitude and ground speed.
Headless on the Pi. The detection loop runs inside stp.service on
the Pi 24/7 — the polling interval, geometry, transit search, Pushover
dispatch and SQLite write are all server-side. The browser UI is just a
viewer for state the service has already computed; closing the tab does
not pause anything and never causes a missed transit. The Pi can run
without a monitor, keyboard, or any client connected.
This section unpacks the per-tick logic so the "what is computed when" question has a single answer to point at. The five-step summary above is the user-facing version; the layout below is the engineering view.
| Source | What | Refresh | Module |
|---|---|---|---|
dump1090-fa (local) |
aircraft.json (live ADS-B) |
every 2 s | adsb.js |
astronomy-engine |
Sun/Moon ephemerides | recomputed every tick (no cache) | geometry.js |
data/history.db |
dispatched Pushovers | rebuilt hourly into the watchlist |
predictor.js + store.js
|
adsbdb.com |
IATA flight, route, airline | per candidate, 1 h positive cache, 5 min negative | adsbdb.js |
The main heartbeat. Each pass executes the following in order:
a) Coarse Sun/Moon trajectory — tracker.js → sampleBodyTrajectory. Az/El
for each tracked body is computed across the next horizonS seconds
(default 300 s = 5 min look-ahead, lowered from 900 s in v0.23.4 because
linear extrapolation degrades fast past ~5 min) at stepS resolution
(default 0.5 s), yielding 601 Az/El samples per body per tick.
Geometric (no refraction) to match the aircraft side, which is also
un-refracted.
b) Coarse aircraft route vector — tracker.js → extrapolate. Each
ADS-B contact is linearly extrapolated from lat/lon/altMmsl using
groundSpeedMs + trackDeg, anchored at receivedAtMs (the actual sample
time of the position, not "now" — this back-stamps ADS-B latency).
WGS84 → ECEF → ENU → Az/El, same 0.5 s grid over 300 s, 601 Az/El points
per aircraft.
c) Pairwise separation scan. For every (aircraft × body) pair the angular separation is computed at every one of the 1801 sample indices and the minimum is remembered. A candidate is emitted when:
-
min sep ≤ tracker.thresholdDeg(default 0.3°) →level = candidate -
min sep ≤ tracker.looseThresholdDeg(default 2°) →level = radio - otherwise the pair is dropped entirely (never reaches the panel).
d) Sub-step refinement — the fine route vector — tracker.js → parabolicVertex. The grid step is 0.5 s, but a transit can land between
two samples. A parabola is fitted through the three separation values
(i-1, i, i+1) around the minimum; the analytic vertex gives a
fractional-step refinement of both the closest-approach time and the
minimum separation. Net effect: timing is accurate to a few tens of
milliseconds despite the coarser sampling grid — far cheaper than running
the grid at 0.05 s.
e) FOV path sampling — tracker.js → sampleTransitPath. For the
FOV-preview sketch the tracker emits 21 dense samples at
[-5, -4.5, …, +5] s around closest approach. Pre-v0.7.6 used 5 samples at
±60 s which, at typical airliner angular speeds, produced a misleading
V-line through the disc.
v0.30.19+ initial-guess overlay. The lifecycle entry now also stores
initialCandidate — the very first emission's geometry — frozen across
all subsequent ticks. The FOV sketch paints this in grey under the
white current path, so the user can see at a glance how much the
prediction has drifted between first contact and now. A stable cruise
flight shows the two paths overlapping; a drifting approach traffic
shows two distinct paths.
v0.30.21+ prediction-drift mini-chart. Top-right inset in the FOV widget: a small line plot of predicted-sep-over-time, with line segments coloured by sep value (green when the projection is in disc range, red when far). Surfaces the convergence story visually — a healthy prediction's line trends down + green, a drifting one hooks up + red as it approaches ETA.
f) Route lookup — adsbdb.js. Each candidate's callsign is enriched
with flight / origin / destination / airline via adsbdb.com. Hits are
cached for 1 h, misses for 5 min, so a flight is queried at most once per
hour across the entire service lifetime.
g) Lifecycle merge — lifecycle.js → updateLifecycle. Three inputs are
folded into a single Map<key, LifecycleEntry>:
- Live tracker candidates (highest signal — actual ADS-B geometry)
- Watchlist (predictor.js, the flight-schedule source — see below)
-
Previous tick's map (so
staleentries linger — coasting through brief ADS-B gaps — and the FIFO 10-cap / 30-min stale grace age out displaced rows in order oflastUpdateMs)
Per-row status is derived from (level, time-to-closest, presence):
-
imminent—level=candidateAND closest-approach within±lifecycle.imminentWindowMs(default 30 s) -
candidate—level=candidateoutside the imminent window -
radio—level=radio(in the loose band, outside the tight band) -
planned— comes from the watchlist; no live ADS-B match yet -
stale— was active on a previous tick, no longer in tracker output; coasts on its last status for ~25 s, then held until the 30-min stale grace expires or the 10-row cap displaces it (oldest stale first)
h) Notifier dispatch — notifier.js → tick. For each candidate, the
next un-sent stage is evaluated. Stages escalate monotonically
radio → candidate → imminent. Pushover dispatch on radio carries an
extra filter: only fires when projected sep ≤ pushover.radioThresholdDeg
(default 1°). The panel-band knob (tracker.looseThresholdDeg, default 2°)
and the Pushover knob are independent — you can show 2° in the UI but only
buzz the phone at 1°. Per-(icao, body, stage) dedup; state is forgotten
5 min after closest approach. Every dispatched event writes one row to
transit_history.
| Job | Cadence | Code |
|---|---|---|
| Watchlist rebuild (the "flight schedule" source) | hourly |
predictor.js → buildWatchlist reading transit_history
|
Lifecycle snapshot → data/lifecycle.json
|
every 30 s + on SIGTERM
|
service.js → snapshotLifecycle |
| ISS transit + visible-pass recompute | every 10 min (iss.recomputeMs) |
iss.js (SGP4 over data/iss.tle) |
Nightly auto-update (git pull + restart) |
once per night (03:30 ±15 min) |
stp-update.timer → scripts/auto-update.sh
|
| Click-to-update (version badge) | on demand |
stp-update.path watches data/update.request → stp-update.service
|
| Daily ISS TLE refresh | once per day (05:40 ±20 min) |
stp-tle.timer → scripts/refresh-tle.js
|
| OpenSky schedule augmentation (optional) | at watchlist-rebuild time |
opensky.js + scripts/refresh-schedule.js
|
There is no external schedule API. Instead transit_history itself is the
input:
- The last
predictor.daysBackdays (default 14) of dispatched events are reduced to{flight, body, timestampMs}tuples. - Tuples are bucketed by
(flight, body, time-of-day)atpredictor.bucketMinutesgranularity (default 60 min). - A bucket graduates to a watchlist entry once it has hit at least
predictor.minRepeatsdistinct UTC days (default 2) — i.e. the pattern has repeated. - The median time-of-day inside each bucket becomes the predicted
expectedTimeOfDayMs; the standard deviation across observations is surfaced asstdevMs(confidence marker — small spread = tight schedule, wide spread = ad-hoc). -
upcomingExpected()filters the watchlist to "next occurrence insidepredictor.lookAheadMs" (default 24 h). The lifecycle merge then promotes anything inside±lifecycle.plannedWindowMs(default 1 h) to aplannedrow in the tracking panel.
| Artefact | Written when | Used for |
|---|---|---|
transit_history (SQLite) |
when a stage is first entered inside the panel band (v0.8.0: independent of the Pushover phone filter, so the radio-stage row is logged and Lead reflects the true advance warning) |
History panel, watchlist source, episode classification |
lifecycle.json (JSON) |
every 30 s + on SIGTERM
|
Tracking panel survives restarts (entries coast through brief ADS-B gaps, v0.8.0) |
config/observer.json + service.json
|
on Settings save | Hot-reload + survive restart |
Episode classification runs lazily on /api/learning and /api/history
reads — see store.js → episodes(). History rows that share
(icao, body) and whose closest_at_ms values fall within ±5 min are
grouped into one episode. The set of stages it contains determines the
outcome label:
-
radioAND (candidateORimminent) → graduated (early warning paid off) -
radioonly → faded (false positive of the early stage) -
candidateORimminentwith no priorradio→ surprise (we missed the build-up)
The HTTP API is stateless (the service is the source of truth), so the browser is pure pull:
| Endpoint / job | Interval | Why |
|---|---|---|
| Wall-clock readout in the header | 1 s | self-corrects from Date.now() each tick |
GET /api/state (Sky now, Tracking, FOV pane) |
2 s | matches the tick |
GET /api/history (history rows + outcomes) |
15 s | history only grows on Pushover dispatch |
GET /api/learning (stats cards) |
60 s | aggregates change at the rate of new episodes |
Closing the tab pauses nothing — the service keeps running and the next load picks up wherever it left off, including the restored tracking list.
-
Linear aircraft extrapolation stays meter-accurate to ~60 s and is
reasonable through ~10 min in stable cruise; well past 15 min the
assumption breaks (turns, ATC vectoring, wind).
horizonS=900(15 min) is the default — a compromise between catching a flight as it enters ADS-B range and the false-positive ("faded") rate; the upper clamp at 1800 s exists so a typo inservice.jsoncan't blow up the per-tick CPU budget. - Un-refracted geometry on both sides. The tracker compares Az/El of the aircraft (raw ECEF→ENU) against Az/El of the body (geometric, no refraction). Refraction is only applied at the "Sky now" display step so the user sees what they would actually observe through the eyepiece.
-
Parabolic-vertex refinement instead of a finer grid. Halving
stepSfrom 0.5 s to 0.05 s would cost ~5× more samples per tick; the vertex fit gets the same sub-tenth-of-a-second timing precision for a handful of multiplications. -
Geoid offset for barometric altitudes only. ADS-B
alt_geom(GNSS) is already WGS84 ellipsoidal height;alt_baro(pressure altitude) is closer to MSL. The geoid undulation (≈46 m around Rheine) is only added to barometric sources, preventing a systematic 46 m / ~0.05° offset. - Service is single source of truth. The browser UI is a viewer. Tab close, browser crash, or laptop sleep never miss a transit — the pipeline keeps running on the Pi and the next page load reflects the full server state.