Skip to content

Advanced

joergsflow edited this page Jun 11, 2026 · 1 revision

🛰 Sun-Moon Transit Predictor wiki — Home · Setup · Usage · Advanced · Reference · ↩ README

⚙️ Advanced — watchlist, triggers, internals

Contents


Predictive watchlist (24 h preview)

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.

Schedule augmentation (OpenSky, optional)

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.js

Output:

[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.

SharpCap capture trigger (optional)

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 (sharpcap block). 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 .ser to 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.

Candidate lifecycle (planned → radio → candidate → imminent → stale)

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 (or imminent) on the first sighting and does not retroactively emit radio. Each stage fires at most once per (icao, body) per detection cycle, then dedupes for 5 min before forgetting state.
  • Subscription control. pushover.minStage (default radio) is the earliest stage that may push. Set to candidate to silence the wide-net early-warning stage if it gets too chatty; imminent for "alert me only at the last 30 s".
  • What goes into SQLite. Only radio, candidate and imminent are persisted to transit_history. planned is regenerated from the watchlist each tick; stale is 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 for lifecycle.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). A stale entry is dropped once it is older than lifecycle.staleGraceMs (default 1 800 000 ms = 30 min) or, on a busy minute, when the cap displaces it (oldest stale first, FIFO by lastUpdateMs; active rows are always kept). Set staleGraceMs: 0 to 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.

Tuning the live look-ahead

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 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.

How the prediction works

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?

  1. 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 minElevationDeg since v0.30.37, hard-floored at 5°) are flagged observable: false and skipped — the floor keeps obstructions, haze, and refraction residuals out of the budget.
  2. Aircraft position. Each aircraft from dump1090-fa's aircraft.json is converted WGS84 → ECEF → ENU into Az/El relative to the same observer, using alt_geom (fallback alt_baro) as MSL. ADS-B seen_pos latency is back-stamped onto the actual fix time, so the projection starts from when the position was sampled, not from "now".
  3. 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] via tracker.horizonS).
  4. 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.
  5. 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.

End-to-end pipeline reference

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.

1. Data sources

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

2. The 2-second tick (service.js → tick())

The main heartbeat. Each pass executes the following in order:

a) Coarse Sun/Moon trajectorytracker.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 vectortracker.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 vectortracker.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 samplingtracker.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 lookupadsbdb.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 mergelifecycle.js → updateLifecycle. Three inputs are folded into a single Map<key, LifecycleEntry>:

  1. Live tracker candidates (highest signal — actual ADS-B geometry)
  2. Watchlist (predictor.js, the flight-schedule source — see below)
  3. Previous tick's map (so stale entries linger — coasting through brief ADS-B gaps — and the FIFO 10-cap / 30-min stale grace age out displaced rows in order of lastUpdateMs)

Per-row status is derived from (level, time-to-closest, presence):

  • imminentlevel=candidate AND closest-approach within ±lifecycle.imminentWindowMs (default 30 s)
  • candidatelevel=candidate outside the imminent window
  • radiolevel=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 dispatchnotifier.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.

3. Slower periodic processes

Job Cadence Code
Watchlist rebuild (the "flight schedule" source) hourly predictor.js → buildWatchlist reading transit_history
Lifecycle snapshotdata/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.timerscripts/auto-update.sh
Click-to-update (version badge) on demand stp-update.path watches data/update.requeststp-update.service
Daily ISS TLE refresh once per day (05:40 ±20 min) stp-tle.timerscripts/refresh-tle.js
OpenSky schedule augmentation (optional) at watchlist-rebuild time opensky.js + scripts/refresh-schedule.js

4. The watchlist (flight-schedule source) in detail

There is no external schedule API. Instead transit_history itself is the input:

  1. The last predictor.daysBack days (default 14) of dispatched events are reduced to {flight, body, timestampMs} tuples.
  2. Tuples are bucketed by (flight, body, time-of-day) at predictor.bucketMinutes granularity (default 60 min).
  3. A bucket graduates to a watchlist entry once it has hit at least predictor.minRepeats distinct UTC days (default 2) — i.e. the pattern has repeated.
  4. The median time-of-day inside each bucket becomes the predicted expectedTimeOfDayMs; the standard deviation across observations is surfaced as stdevMs (confidence marker — small spread = tight schedule, wide spread = ad-hoc).
  5. upcomingExpected() filters the watchlist to "next occurrence inside predictor.lookAheadMs" (default 24 h). The lifecycle merge then promotes anything inside ±lifecycle.plannedWindowMs (default 1 h) to a planned row in the tracking panel.

5. Persistence + outcome classification

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:

  • radio AND (candidate OR imminent) → graduated (early warning paid off)
  • radio only → faded (false positive of the early stage)
  • candidate OR imminent with no prior radiosurprise (we missed the build-up)

6. Frontend poll cadences

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.

7. Design principles

  • 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 in service.json can'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 stepS from 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.