A terminal application that tracks a satellite overhead in real time, with a world map, a telemetry readout, upcoming passes over your location, and some space-weather and launch numbers on the side. It's a novelty project — but the orbital mechanics under it are the real thing: it runs SGP4/SDP4 locally and only ever fetches an orbit description, never a position.
Nadir is the point on Earth directly beneath a satellite, which is what the centerpiece map tracks.
Note: this project is entirely AI-generated. No line of it was written or reviewed by hand. Treat it accordingly.
Orbital state is computed locally rather than downloaded. Celestrak supplies a
GP/TLE element set — a compact orbit description valid for days, not a position
— and src/orbit/propagate.rs runs SGP4/SDP4 on it at ~4 Hz to get position and
velocity at any instant. Everything derived from that is math with no I/O:
ground track, footprint, sunlit/eclipsed state, look angles, the day/night
terminator and pass predictions.
Only the element set needs the network, and by default at most every 12 hours
(configurable — see Refresh intervals). It is cached at
~/.cache/nadir/tle-<norad>.json and keyed by cache age rather than process
lifetime, so a restart with a cache younger than that interval reads from disk.
A refresh that fails retries on a 1m-to-30m exponential backoff rather than
waiting the full interval, and says so in the activity log.
A propagated position is an estimate, and it gets worse the further the
displayed time is from the element set's epoch — the more so once you start
scrubbing the clock days out. The telemetry panel's ACC row puts a number on
it. A TLE carries no formal error, so this is a model, built from what the
element set does reveal about its own quality: nadir re-propagates with the drag
term B* nudged by 10% and measures how far the position moves, then floors
that against a per-regime growth rate. The result is satellite-specific — a
decaying LEO degrades several times faster than a quiet orbit well above the
drag — and it lands near the long-standing rule of thumb of ~1 km at epoch
growing to tens of km after a week in low orbit. The along-track part is a
timing error, so it also shows as ±N s, and as a footer on the pass list
bounding how far the AOS/LOS times could slip. Past 30 days from epoch the model
stops quoting a figure: SGP4 still returns a position, but nothing local can say
how wrong it is.
The remaining panels do need the network: space weather, the aurora nowcast, and the launch manifest. Each source fails independently — a dead feed degrades its own panel to the last known value, labelled with its age, and leaves the rest of the dashboard alone. Every panel warm-starts from the disk cache at launch, so nothing sits on a placeholder waiting for the session's first fetch.
--offline makes no network requests at all and loads whatever each source last
cached. The map keeps tracking from the last cached TLE. What needs the network
and so is unavailable offline: the catalogue name search, the SatNOGS downlink
lookup, --location, and a --sat name (both of the latter are ignored with a
warning). A NORAD id still switches satellites, since nothing has to be looked
up first — and its own cached element set comes with it, the same warm start
launch gets, so the map keeps drawing a track instead of going blank; a
satellite with nothing on file for it says so in the activity log rather than
sitting on "acquiring…" forever. An already-cached downlink still drives
DOPP — the Doppler shift itself is local math.
Requires Rust 1.88 or newer, declared as rust-version in Cargo.toml. The
floor comes from the dependencies, not from nadir's own code: ratatui 0.30 and
the IDNA stack under reqwest both need 1.88, and time (pulled in by ratatui)
uses let-chains, which do not compile at all on 1.87.
git clone https://github.com/MatKier/nadir
cd nadir
cargo build --releaseThe binary is target/release/nadir, self-contained aside from network access.
cargo run --release -- --location "Munich"
# or
cargo run --release -- --lat 48.137 --lon 11.575A first run with no location guesses one from your IP address and saves it to
~/.config/nadir/config.toml, which you can edit at any time. Needs an 80×24
terminal or larger.
--location <place> ground station by name, e.g. "Munich" or "Springfield, Illinois"
--lat, --lon, --alt ground station by coordinates (alt in metres); not with --location
--sat <name-or-id> satellite to track — a NORAD id, or a catalogue name to search for
--config <path> use a specific config file
--offline make no network requests; use cached data only
--no-geoip never guess location from IP, even with none configured
--no-splash skip the boot splash and go straight to the dashboard
A mission-control splash — nadir's wordmark and version, then ground station,
tracked element set, epoch, propagator, coordinate pipeline and which feeds
this run will poll, filling in over the first third of the display — shows for
up to 3 s on launch before the dashboard takes over; any key skips it early,
and --no-splash skips it every time. The box itself is sized for the full
splash from the very first frame, so filling the rows in doesn't resize or
re-centre it — only their content appears over time. Behind it, a procedural
starfield rings the console and a satellite transits the whole frame once,
comet tail behind it, over the whole splash duration — arcing clear of the
console block rather than crossing it — while the wordmark itself ignites
left to right on power-up. The duration is configurable — see
Presentation timings.
--location is resolved once at startup via Open-Meteo's geocoding API and the
result saved like a geolocated or hand-typed one. An ambiguous name takes the
most prominent match; add a country or state to disambiguate.
How often each network-backed feed refetches is set under [intervals] in
config.toml, as duration strings (s / m / h / d, same vocabulary as
the in-app time prompt). The defaults, written on a first run, are:
[intervals]
tle = "12h" # element set — propagation is local, so this is rarely needed
weather = "5m" # NOAA SWPC space-weather indices
aurora = "15m" # OVATION aurora nowcast
launches = "30m" # Launch Library upcoming-launch manifestLonger is always allowed. Shorter is clamped to a per-feed floor that keeps
nadir a well-behaved client of each upstream — tle ≥ 1h, weather ≥ 1m,
aurora ≥ 5m, launches ≥ 10m (Launch Library's anonymous tier allows only
~15 requests an hour). A value below its floor is raised and noted in the
activity log; the file is left as you wrote it. A value that doesn't parse
("banana", "12", "-5m") is a startup error, not a silent fallback.
The status chips and the ? overlay judge freshness against whatever these are
set to, so a feed refetching on its configured schedule always reads green.
Two more timings — nothing to do with the network — are set under [ui] in
config.toml, in the same duration-string vocabulary as [intervals]:
[ui]
aos_lead = "30s" # how far ahead of AOS a pass counts as imminent
splash = "3s" # how long the boot splash stays up
mouse = true # capture the mouse, so a click can focus a panel or select a row, and the wheel scrollaos_lead is also what n/N (jump to the next pass) land the clock on, so
the two always agree — pressing n always arrives exactly where the "AOS in…"
countdown starts. It's clamped to 5s–10m; splash to 1s–30s. A value outside
its range is raised or lowered and noted in the activity log, the same policy
[intervals] uses.
By default nadir captures the mouse: clicking a panel focuses it, and clicking a
row in Tracked, Passes or Launches selects it, exactly as j/k would;
double-clicking a Tracked entry starts tracking it, like Enter. In the s
search popup, clicking a result highlights it and double-clicking tracks it; in the
T popup, clicking a downlink highlights it and double-clicking tunes to it, like Enter.
The scroll wheel moves three rows a notch, like j/k three times: over Tracked,
Passes or Launches it scrolls that list (focusing the panel first, as a click
would); with the s or T popup or the ? overlay open it scrolls that instead,
wherever the pointer is. Over the map, Telemetry or Space Weather it does nothing.
A list with more rows than its panel shows draws a scrollbar (▲ █ ║ ▼) on its
right border, so you can tell there is more to scroll to.
The cost of capture is that the terminal's own click-drag text selection is off while nadir is
running — hold Shift while dragging to select text anyway, which every modern
terminal honours. If you'd rather never think about it, set mouse = false under
[ui] and nadir won't capture the mouse at all; everything stays reachable from
the keyboard.
nadir tracks the ISS by default. Any object in Celestrak's catalogue can be tracked, by name or NORAD id:
- At startup:
--sat HST, or a NORAD catalogue number such as--sat 48274. A name is resolved against Celestrak at startup and the top-ranked match is used; it has to be the object's official catalogue name (as listed at celestrak.org/satcat/search.php), not a nickname. - In-app: press
s, type a name or NORAD id, pressEnter. A name search lists every match — pick one with↑/↓andEnter. A NORAD id tracks the object directly.
Satellites you have tracked stay in the TRACKED panel (2), sorted by
name, so returning to one does not mean searching again: highlight it and press
Enter. d drops an entry; the satellite currently being tracked
cannot be dropped.
When a satellite is first tracked, nadir does a one-shot lookup on
SatNOGS DB for its downlink frequencies, caching the
result under ~/.cache/nadir/transmitters-<norad>.json. Press T to choose
one from what it found (or to look again); the choice drives the DOPP
telemetry row and is recorded in config.toml. The list itself is never
queried again — edit that cache file by hand when SatNOGS is wrong, out of
date, or has no entry for the object.
SGP4/SDP4 (via the sgp4 crate, which implements both) covers all of Earth
orbit, including geostationary. It does not cover deep space — an object at a
Lagrange point such as JWST, or beyond Earth orbit, needs a different propagator
and will not work here.
| Key | Action |
|---|---|
1–6 |
focus map / tracked / telemetry / passes / weather / launches — each panel's header shows its number |
Tab |
cycle focus |
j / k |
scroll the focused panel's list (tracked, passes, launches) |
Enter |
on Tracked: start tracking the highlighted satellite |
d / Del |
on Tracked: drop the highlighted satellite from the list |
m |
toggle fullscreen map |
f |
follow the satellite (zoom the map on it) |
+ / - |
zoom the follow window in / out (×2–×16); - past the widest drops back to the whole world |
p |
toggle labelled cities and ground stations on the map |
a |
toggle the aurora oval on the map |
o |
toggle the Sun/Moon markers on the map |
b |
toggle an orthographic globe view in place of the flat map |
s |
search Celestrak's catalogue for another object, by name or NORAD id |
r |
refresh the focused panel's feed(s) now |
t |
step to the next configured downlink frequency (drives the DOPP row) |
T |
pick a downlink for the tracked satellite, or look one up on SatNOGS DB |
Space |
pause / resume the simulated clock |
, / . |
step the clock speed down / up (1× … 1800×), past 1× into reverse (< / > too) |
← / → |
step the clock ±1 minute (h / l too); [ / ] step ±1 hour |
n / N |
jump to aos_lead before the next pass / next naked-eye pass, paused there |
g |
go to a time — 2026-09-08 04:30, 04:30, or an offset like +90m |
0 |
snap the clock back to now, running at 1× |
| click | focus the panel under the pointer; on Tracked, Passes or Launches, select the row; double-click a Tracked entry, or a result in the s popup, to track it, or a downlink in the T popup to tune to it (needs mouse capture — see below) |
| scroll wheel | scroll the list under the pointer (Tracked, Passes, Launches), or the s / T popup or ? overlay when one is open — three rows a notch (needs mouse capture) |
? |
help — a scrollable in-app reference to every field, symbol and status chip |
q / Esc / Ctrl+C |
quit — Ctrl+C works from anywhere, including inside a prompt |
Focus affects behaviour as well as appearance. j/k scroll and highlight a row
in whichever panel has focus (Tracked, Passes and Launches have scrollable
lists), and r refetches only what the focused panel shows — focusing Weather
and pressing r forces a fresh Kp/aurora pull without spending one of the launch
feed's limited requests. The bottom bar follows focus too: it shows the focused
panel's own keys — the map keys under Map, t/T under Telemetry — rather than
one crowded list, but every key stays bound from every panel regardless of what
the bar is showing. Highlighting a launch expands its row to a second line
with provider and pad, and marks the pad on the map with ◉ plus the provider,
vehicle name, site and coordinates, for as long as the row stays highlighted.
Highlighting a pass, likewise, turns the map pane into a sky plot of it — a
polar azimuth/elevation chart, zenith at the centre and horizon at the rim,
with the star field, the Sun and the Moon placed as they'll actually be at
that pass's culmination, behind an arc bright while the satellite is sunlit
and dim while it is in the Earth's shadow, and the satellite marked on it
while the pass is under way — for as long as Passes keeps focus.
The clock keys detach the display from wall time so you can watch a pass play
out, step to where the satellite will be, or run the ground track backwards.
Everything geometric follows the simulated clock — the map, telemetry, the pass
list, and the TLE and ACC fields, which is the point: scrub a week out and
ACC climbs into the tens of km so the confident-looking map can't mislead you.
What stays on the real clock: the feed-status chips and
their ages, session uptime, and the launch countdown — those track real events,
not the view. The title-bar clock turns amber with a marker (▸ drifted, ‖
paused, ▸▸60x / ◂◂5x warp) whenever it is not live, so the display can never
quietly claim to be current when it isn't — and, when the bar is wide enough,
the offset from now beside it (Δ+2h14m, Δ-45m, Δ+3d).
While the ? overlay is open, j/k/PgUp/PgDn/Home/End (or the scroll
wheel) scroll it instead, and ? or Esc closes it.
The full glossary — map symbols, every telemetry field and space-weather term,
and what each status chip means — is available in-app by pressing ?. The short
version:
Tracked — satellites you have tracked, sorted by name; ● marks the
current one.
Telemetry — ALT altitude above the WGS-84 ellipsoid · SPD inertial speed
from SGP4, not ground-relative · POS sub-satellite lat/lon · FOOT radius of
the ground circle that can see the satellite above 0° elevation · ORB orbital
regime (LEO/MEO/GEO/GSO/HEO/HIGH) from altitude and eccentricity, plus a -P
(polar) or -S (sun-synchronous) suffix from inclination when it applies, and
period and inclination from the element set · APSIS perigee × apogee altitude
and eccentricity, measured from the WGS-84 equatorial radius rather than
ALT's local ellipsoid, so the two can read up to ~20 km apart away from the
equator · REV approximate
revolution number since launch · SUN sunlit/eclipsed and time to the next
transition · RANGE slant range and elevation from your ground station · RATE
range rate ṙ in km/s — negative while the satellite closes, positive while it
opens, zero at closest approach; the number that drives a rotator or an SDR
correction loop · DOPP the configured downlink and the Doppler shift ṙ implies
for it (Δf = −f₀·ṙ/c), shown once a frequency is on file — looked up once from
SatNOGS DB when a satellite is first tracked (T to choose, t to step between
them) and cached thereafter · TLE
time from the element-set epoch and the epoch itself (UTC), e.g.
18h since epoch 2026-09-06 23:11Z — reads before instead of since when the
clock is scrubbed ahead of the epoch · ACC a
modelled position error and the along-track timing error it implies — see
Accuracy. RANGE, DOPP and TLE spell themselves out in full on
a wide terminal and shorten a rung at a time as the right column narrows — TLE
down to 18h since 09-06 23:11Z, then to a bare 18h old. The title bar
likewise shows the satellite's COSPAR international designator next to its NORAD
id when the terminal is wide enough to fit it. Next Passes rows do the same with
their leading date — Thu 2026-09-11 down to Thu 09-11, then to a bare Thu.
Map — the whole world by default; f zooms to a window centred on the
satellite, wrapping across the dateline to keep it dead centre. + / - step
that window through four magnifications — ×2, ×4, ×8, ×16 the whole-world scale,
shown as MAP ×N in the panel header — and - past the widest drops back to
the whole world. ◆ sub-satellite point · ▲ ground station · ◉ pad of the
launch currently highlighted in Launches, with its provider, site and
coordinates · ☉ the subsolar point · the Moon marker, whose glyph is its own
phase (○ ◔ ◑ ◕ ● ◕ ◑ ◔ new through full and back) — o toggles both, off
by default. p toggles a layer of labelled reference points — · cities and
+ satellite ground stations — drawn as many as fit without overlapping, so
a whole-world map shows only a scattered few and more fill in as you zoom.
a toggles a green aurora oval on the night side, from the same OVATION
nowcast the AUR row reads — off by default, drawing nothing until that feed
has ever returned data, and suppressed whenever the clock isn't live (warped,
stepped, jumped or paused) since the nowcast is a real-world snapshot that
doesn't track the displayed instant; its tiers shimmer slowly rather than
sitting flat, reading as drifting curtains. The ground track behind the
satellite fades with age — brightest where it meets the sub-satellite point,
dimmest at its −35 minute end at ordinary speed — rather than one flat colour.
Warping the clock stretches that trail with it, up to a few revolutions at the
fastest speeds, so a fast warp reads as visible motion instead of the
satellite teleporting frame to frame; running the clock in reverse stretches
the other end instead, since the trail always follows behind whichever way
the satellite is actually travelling. Switching to a different tracked
satellite plays a brief acquisition sweep once its element set arrives — the
ground track draws itself in from the sub-satellite point outward and the
footprint blooms to full radius — rather than the new track and footprint
simply appearing.
While the Passes panel has focus this pane shows a sky plot of the
highlighted pass instead — ▲/▼ its rise and set on the horizon rim, ◇
its culmination, the arc bright where the satellite is sunlit and dim where
it is eclipsed, and ◆ the satellite itself whenever the clock is inside the
pass, so warping the time walks it along the arc. The star field behind it
twinkles — the faintest stars most, the brightest almost not at all — and an
occasional meteor streaks across the disc.
b swaps the map for an orthographic globe instead — Earth as a disc
centred on the sub-satellite point, the ground track curving over the limb,
the night side a real shadowed crescent. A narrower picture on purpose: no
follow zoom, places, pad marker, aurora oval or Sun/Moon, since the
satellite sits dead centre by construction rather than by pressing f.
Next Passes — a pass rising within aos_lead (30s by default, see
Presentation timings) swaps its ordinary AOS–LOS time
range for a live AOS in … countdown; once it's actually overhead that
becomes a countdown to culmination and then to LOS — peak in … before the
best-signal moment, LOS in … after it — with duration, peak elevation and
bearing still alongside it the whole time. n/N (jump to the next pass) land
the clock exactly at the start of that lead-in window, so resuming there starts
the countdown immediately. Either way the panel's own border tints green
(amber for a naked-eye ★ pass) and breathes while the clock reads real
time — paused or warped, it still tints but stops pulsing, the same
real-time-only rule the aurora oval follows.
Space weather (NOAA SWPC) — Kp planetary K-index, 0–9 · WIND solar wind
speed · Bt/Bz interplanetary field magnitude and its north–south component
(strongly negative Bz drives aurora) · STORM R/S/G NOAA's radio-blackout /
radiation-storm / geomagnetic-storm scales, 0–5 · AUR aurora probability
overhead at your ground station · MOON phase, illuminated fraction and
elevation from your ground station — local math, not a feed, so it never goes
stale; it's what decides whether a ★ naked-eye pass in Next Passes is
actually worth walking outside for.
Space Weather and Launches show their feed's age under the panel title once it
has gone stale, e.g. space weather · 1m old.
Status chips (bottom left: TLE SWX AUR LCH) — each shows wait
(nothing fetched yet this session), live, an age such as 5s / 12m since
the last successful fetch, or err (failed with nothing to fall back on).
Colour is judged against how often that feed refetches: green while at most two
refreshes could have been missed, amber up to six, red beyond. So at the default
intervals SWX (every 5m) is green to 10m and amber to 30m, AUR (15m) green to
30m and amber to 90m, LCH (30m) green to 1h and amber to 3h, and TLE (12h)
green to 24h and amber to 72h; change an interval under [intervals] and its
chip's ladder scales with it. A feed whose last fetch attempt failed reads at
least amber whatever its age, with the reason in the Recent activity log.
All keyless; no account or API token is needed.
| Source | Supplies |
|---|---|
| Celestrak | TLE element sets, propagated locally with SGP4 |
| SatNOGS DB | Downlink frequencies — one-shot lookup when a satellite is first tracked, then read from the disk cache; never polled |
| NOAA SWPC | K-index, solar wind, storm scales, aurora nowcast |
| Launch Library 2 | Upcoming launches (rate-guarded client-side; falls back to the public test mirror, lldev.thespacedevs.com, when the primary host throttles) |
| ipapi.co / ip-api.com | One-time IP geolocation on first run only |
| Open-Meteo geocoding | Resolves --location "a place name" to coordinates |
cargo test # unit tests, all hermetic — no network
cargo test -- --ignored # + the live tests, which do hit the networkThe hermetic suite covers the geodesy, the SGP4 pipeline and pass prediction against a fixed element set, plus the config store, catalogue-search ranking and panel formatting.
The live tests are the ones worth running when the orbital mechanics or a live integration change: one propagates a freshly fetched TLE and asserts the result agrees with an independent feed (WhereTheISS.at) to within 25 km and matches its eclipse state — a check that the maths is correct rather than only internally consistent. Two pin how Celestrak actually answers a catalogue search, including the 404 it returns for a name that matches nothing. Two more pin how SatNOGS DB answers a downlink lookup, including that a satellite with no amateur radio comes back as an empty list, not an error.
src/
main.rs CLI parsing only; hands a Config to app::run
geo.rs WGS-84 <-> ECEF, look angles — pure math, no I/O
orbit/ SGP4 propagation, ground tracks, solar geometry, pass prediction
api/ One module per upstream feed, all behind a shared HTTP client
cache.rs Disk cache so a flaky network / --offline still has data
source.rs Source<T>: a value plus how fresh and trustworthy it is
config.rs ~/.config/nadir/config.toml, overridable by CLI flags
app.rs Background fetch tasks, input handling, the render loop
ui/ ratatui: the map, the ? overlay, and panels/ for the rest
Everything but main.rs sits behind a library target, so the integration test
drives the real propagation pipeline rather than a copy of it.
One tokio task per data source, each on its own interval ([intervals] in the
config, with per-feed floors), writing into shared state behind a lock. The
render loop never awaits the network, so a slow or dead feed cannot stall the
frame rate.
MIT. See LICENSE.
