github.com/VanyaNeytrino/kozai
0.4.0 / published Aug 9, 2026 / repository
Headless satellite pass prediction and ground station control daemon. JSON API, offline web UI, rotator and radio control. Zero third-party dependencies, stdlib only.
kozai
Satellite pass prediction and ground station control, as a service.
<!-- Replace VanyaNeytrino/kozai above with your own repository slug: it is the only place the name appears outside the workflows. -->What this is
A daemon for an amateur satellite ground station. It works out when satellites will be overhead, serves that over a JSON API and a web interface, points your rotator at them, and keeps your receiver on frequency as the Doppler shift drags it around — on its own, if you let it, choosing between overlapping passes rather than taking whichever rose first.
It is one file. It starts instantly, runs on a Raspberry Pi, holds a few megabytes of memory, and works with no internet connection at all.
The niche is a station that lives on a single-board computer in a shed while you sit somewhere else with a laptop or a phone. Gpredict is excellent and is a desktop GTK application; the Python stacks around Skyfield are flexible and want a Python environment maintained on the station; the SatNOGS client is tied to the SatNOGS network. This one is the headless piece: Gpredict without the GTK, as a service.
The predictions are checked against 149 real observations from 65 ground stations, made with the element sets those stations actually held — and against the SatNOGS scheduler, which computes with PyEphem, an implementation descending from the original 1980 Spacetrack Report rather than from Vallado's 2006 revision. Not another copy of the same lineage agreeing with itself. How that was done.

What it does
- Keeps a local cache of orbital elements, in TLE or OMM JSON form
- Propagates with SGP4 and SDP4, verified against the published reference
- Predicts passes: acquisition, culmination, loss of signal, above any elevation
- Reports position, sub-satellite point, azimuth, elevation, range and range rate
- Computes Doppler shift for a downlink and an uplink
- Says which passes can be seen by eye: satellite in sunlight, station in the dark
- Tunes the receiver to each satellite's own transponder, not one frequency for all
- Works passes unattended, resolving overlaps properly rather than first-come-first-served
- Drives a rotator through hamlib's
rotctld, or Easycomm II directly - Tunes a receiver through hamlib's
rigctld - Serves a web interface with an offline map, day–night terminator, pass list and polar plot
Install
With Docker
curl -O https://raw.githubusercontent.com/VanyaNeytrino/kozai/main/docker-compose.yml
# put your coordinates in the environment section
docker compose up
Then open http://localhost:8080.
The image is built FROM scratch: one statically linked executable, a
certificate bundle, and nothing else. No shell, no package manager, no
distribution underneath to track vulnerabilities in.
$ docker images ghcr.io/vanyaneytrino/kozai
REPOSITORY TAG SIZE
ghcr.io/vanyaneytrino/kozai latest 7.41MB
Images are published for linux/amd64 and linux/arm64. The registry name is
lowercase even though the GitHub account is not — Docker requires it.
From a release
curl -LO https://github.com/VanyaNeytrino/kozai/releases/latest/download/kozai-linux-amd64
chmod +x kozai-linux-amd64
./kozai-linux-amd64 --version
Four binaries are attached to every release: kozai-linux-amd64 and
kozai-linux-arm64, each with a -nonet variant that has no HTTPS client and
no OpenSSL in it at all, for a station that reads its elements from disk (see
No dependencies). They are statically linked and have no
runtime requirements whatsoever — download, chmod +x, run, on any Linux from
Alpine to Debian.
SHA256SUMS is attached alongside them.
On macOS
There is no macOS binary in the releases yet, but building takes one command more than downloading one would:
brew install crystal
git clone https://github.com/VanyaNeytrino/kozai
cd kozai
crystal build src/cli.cr --release -o kozai
./kozai serve --lat 52.3676 --lon 4.9041
Everything works: the daemon, the interface, rotator and radio control over TCP. Only the prebuilt binary is missing, and the reason is in the roadmap — macOS cannot link statically, so a released binary would have to be signed and notarised rather than just uploaded.
From source
You need a Crystal compiler, version 1.21 or newer. Nothing else: there is nothing to install first.
git clone https://github.com/VanyaNeytrino/kozai
cd kozai
crystal build src/cli.cr --release -o kozai
./kozai --version
Quick start
Point it at yourself and ask what is coming over:
kozai passes \
--lat 52.3676 --lon 4.9041 \
--url "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle" \
--min-elevation 10 --hours 12
SATELLITE AOS (UTC) MAXEL DURATION AOSAZ LOSAZ AGE
NOAA 18 2026-08-04 17:08:47 13° 296 s 73° 19° 21h
NOAA 18 2026-08-04 18:45:29 55° 638 s 143° 351° 22h
ISS (ZARYA) 2026-08-04 20:27:02 29° 567 s 207° 326° 20h
The last column is the age of the elements each prediction came from. It is there because a prediction from month-old elements looks exactly as confident as one from fresh elements, and is not.
Where something is right now:
kozai position --sat ISS --lat 52.3676 --lon 4.9041 --tle elements.txt
ISS (ZARYA) at 2026-08-04T16:44:43Z
sub-point -7.3993°, -150.2917° altitude 416.1 km
look angles az 326.88° el -64.38° range 11964.6 km rate -2.8617 km/s
elements 22h old
To run the daemon, write a configuration file (start from
examples/kozai.conf.example) and:
kozai serve -c kozai.conf
HTTP API
Everything is JSON, on the same port as the web interface. Every response
about a satellite carries elements_age_seconds and a stale flag, so a
client can always tell how much to believe it.
| Method | Path | What it does |
|---|---|---|
GET | /api/health | Status, satellite count, element ages, source outcomes |
GET | /api/station | The configured observing position |
GET | /api/sun | Sub-solar point, and the Sun's angles at the station |
GET | /api/transponders | Every known transponder; /api/satellites/:id/transponders for one satellite |
GET | /api/schedule | What the station plans to work, and what it declined, with reasons |
GET | /api/settings | Every editable setting, its value and where that value came from |
POST | /api/settings | Change settings; applies at once, no restart |
GET | /api/satellites | The catalogue; ?q= filters by name or number |
GET | /api/satellites/:id | One satellite, with its position now |
GET | /api/satellites/:id/position | Position at ?time= (RFC 3339 or Unix) |
GET | /api/satellites/:id/passes | Passes; ?hours=, ?min_elevation= |
GET | /api/satellites/:id/track | Ground track; ?minutes=, ?steps= |
GET | /api/satellites/:id/doppler | Doppler; ?frequency=, ?uplink= |
GET | /api/passes | Passes across the whole catalogue; ?limit= |
POST | /api/refresh | Reload elements; ?force=true ignores the cache |
GET | /api/rotator | Rotator and radio state |
POST | /api/rotator/track | Start tracking; ?sat=, ?transponder= |
POST | /api/rotator/stop | Stop tracking |
POST | /api/rotator/park | Stop tracking and park |
:id is a catalog number or a name fragment.
Requests that move hardware need a shared secret if one is configured. Set
[server] token and send it as Authorization: Bearer <token> or
X-Kozai-Token; reads stay open. Unset is safe only while the server is bound
to loopback, and kozai says so on the way up if you bind elsewhere without one.
Examples
curl 'localhost:8080/api/passes?hours=12&min_elevation=10&limit=40'
{
"from": "2026-08-09T08:24:46Z",
"hours": 12.0,
"minimum_elevation": 10.0,
"count": 40,
"truncated": true,
"passes": [
{
"catalog_number": 25544,
"name": "ISS (ZARYA)",
"aos": "2026-08-09T08:29:14Z",
"max": "2026-08-09T08:32:11Z",
"los": "2026-08-09T08:35:08Z",
"duration_seconds": 353.6,
"aos_azimuth": 212.8,
"max_azimuth": 151.06,
"max_elevation": 28.85,
"los_azimuth": 89.39,
"max_range_km": 795.5,
"elements_age_days": 0.849,
"stale": false,
"clipped": false,
"clipped_start": false,
"clipped_end": false,
"illumination": "daylight",
"visual": false,
"sun_elevation": 37.26,
"phase_angle": 149.02,
"shadow": "sunlit",
"sunlit": [
{
"from": "2026-08-09T08:29:14Z",
"to": "2026-08-09T08:35:08Z"
}
],
"dark": [],
"visible_spans": []
}
]
}
An ISS pass in the middle of the morning: sunlit, but the station is not, so
illumination is daylight and visible_spans is empty. The three span lists
are when the satellite is lit, when the sky over the station is dark, and the
overlap — the part worth going outside for.
truncated says whether limit cut the list. A list silently stopping at a
round number reads as "there are no more", which is a lie worth avoiding.
clipped_start means the pass was already under way when you asked, so aos
is the moment of the request and duration_seconds is what remains of it.
clipped_end means the pass runs past the end of the window, so the duration
is a lower bound. clipped is either of them.
curl 'localhost:8080/api/satellites/25544/doppler?frequency=435000000'
{
"catalog_number": 25544,
"name": "ISS (ZARYA)",
"elements_age_days": 0.86,
"stale": false,
"range_rate_km_s": -6.214372,
"elevation": 12.41,
"downlink_hz": 435000000.0,
"observed_hz": 435009017.55,
"shift_hz": 9017.55
}
All times are UTC, in and out. Local time exists only in the browser.
Rotator and radio
kozai talks to hamlib over TCP rather than linking libhamlib. That is a
constraint of this project — no C libraries, no FFI — and it turns out to be the
better arrangement anyway: rotctld already knows about every rotator ever
made, it already owns the serial port, and a crash in a rotator driver takes
down a separate process instead of your predictions.
kozai ──TCP 4533──> rotctld ──serial──> rotator
──TCP 4532──> rigctld ──serial──> radio
On the station:
rotctld -m 202 -r /dev/ttyUSB0 -T 127.0.0.1 -t 4533 # Yaesu GS-232B
rigctld -m 3073 -r /dev/ttyUSB1 -T 127.0.0.1 -t 4532 # Icom IC-9700
In kozai.conf:
[rotator]
enabled = true
host = 127.0.0.1
port = 4533
protocol = rotctld # or easycomm
azimuth_max = 450 # see below
deadband = 1.0
interval_seconds = 1.0
[radio]
enabled = true
port = 4532
transponders = /etc/kozai/transponders.conf # which frequency, per satellite
downlink_hz = 435000000 # fallback for anything not in it
Then POST /api/rotator/track?sat=25544, or click a pass in the interface.
Working passes without you
[schedule] enabled = true and the station takes passes on its own: points the
antenna at acquisition, tunes the receiver to that satellite's transponder, and
parks at loss of signal. A manual POST /api/rotator/track takes the antenna
and keeps it until stop hands it back.
Overlapping passes are resolved properly, and that is not a detail. The
obvious rule — take whichever rises first, refuse anything that clashes — fails
in a way an operator notices immediately: a twelve-degree pass that happened to
rise a minute earlier eats an eighty-four-degree one, every night. So the
scheduler weighs each pass by its satellite's priority times its peak elevation
and finds the best non-overlapping set exactly, by dynamic programming over
passes sorted by loss of signal. O(n log n), and optimal rather than
approximately so — spec/schedule_spec.cr checks it
against an exhaustive search of every subset on random overlapping sets.
What it declined, and what beat it, is in GET /api/schedule:
{ "name": "SO-50", "max_elevation": 21.4,
"reason": "overlaps FUNCUBE-1 (AO-73) at 63°, 11:35:32–11:43:01" }
A geostationary satellite is worth knowing about here: its "pass" is the entire lookahead window, so scheduling it means committing the antenna for twelve hours and working nothing else. The arithmetic says so plainly — three AO-73 passes totalling 99 outweigh one QO-100 sit worth 20 — and it declines it. If you want the dish parked on QO-100, point it there by hand rather than through the scheduler.
Which frequency, for which satellite
Until 0.3 the station had one downlink_hz for everything, which meant working
SO-50 on 436.795 and then AO-73 on 145.935 was a configuration edit and a
restart. Now a transponder table says what belongs to whom:
[so-50]
catalog_number = 27607
name = SO-50 FM repeater
uplink_hz = 145850000
downlink_hz = 436795000
mode = FM
default = true
Same format as the configuration file, parsed by the same code. A small table is
compiled into the binary — the nine satellites the fallback element sets cover —
and [radio] transponders points at your own; sections of the same name replace
the built-in ones, new names add to them. Satellites with several transponders
(the ISS has three) are selected per pass with ?transponder=, or the one
marked default is used.
What has actually been driven, and what has not. The specs stand up their
own mock rotctld and rigctld on real TCP sockets, which proves the client
against this project's idea of the protocol and no more. So both paths were also
run against hamlib 4.7.2 itself, rotctld -m 1 and rigctld -m 1 on the
Dummy backends:
- The rotator acquired RS-15 on a 77° pass and held it inside the one-degree deadband for the rest of it — worst offset after acquisition 0.62°, the sawtooth of the deadband rather than of an error.
- The receiver followed the Doppler curve through closest approach, crossing the nominal 435.000000 MHz at the same instant the range rate changed sign.
stopleft the antenna where it stood;parkdrove it to 0°, 0° and stopped.- Selecting SO-50, then AO-73, then the ISS moved the receiver 436.795 → 145.935 → 145.800 MHz and its mode FM → USB → FM, which is the thing one global frequency could not do.
- With
[schedule] enabled, the station took a pass, tuned the receiver and drove the rotator to within the deadband without a single request — and a manualtrackheld the antenna against the scheduler untilstop.
That is hamlib's own protocol implementation, not a mock of it — and it is
still not a rotator. The Dummy backends are software: nothing here has moved a
real antenna or received a real signal. The last metre, from rotctld down the
serial port to a motor, is exercised by every other hamlib client in the world
and by none of this project's tests.
azimuth_max is the setting worth understanding. A pass that crosses due
north asks the antenna to go from 350° to 10°. On a plain 0–360° rotator that is
a 340° unwind back through south, which at six degrees a second takes most of a
minute — most of a low pass, spent pointing at the ground. Many rotators are
built with overlap past north; telling kozai about it (azimuth_max = 450)
lets it command 370° instead of 10° and keep turning the same way.
For near-overhead passes, where the azimuth swings through almost 180° in
seconds, flip = true will instead point 180° round and lean back over the top.
It needs elevation_max above 90°, and it only switches when that clearly saves
travel — flapping between the two representations mid-pass would be worse than
either.
Configuration
From the interface
There is a Settings button in the header. Coordinates, prediction thresholds, the watchlist, the scheduler, the rotator and the receiver can all be set there, and take effect at once — no restart.
It exists for one case in particular, and it is the case this project claims: a single-board computer in a shed and an operator holding a phone. Without it the first run shows the Gulf of Guinea and a banner telling you to edit a file, and editing a file over ssh from a phone is the worst minute in the product.
Two things about it are worth knowing.
Settings written there beat both kozai.conf and the environment, which is what
makes the page useful on a station configured entirely through
docker-compose.yml — and would otherwise leave somebody editing a compose file
that silently does nothing. So it is never silent: the page shows where every
value came from, marks the ones it is covering (set here, overriding $KOZAI_STATION_LATITUDE), and the daemon says the same on the way up.
Only what you actually change is written. A field you never touched stays the
config file's business, so editing kozai.conf afterwards still works for
everything the page was not used for.
Settings live in settings.conf next to the element cache, because that is the
one directory a station is required to have writable — the container image is
read-only everywhere else on purpose. Deleting the file returns every setting to
whatever the file and the environment say. [server] is deliberately not
editable: changing the host or port of the server you are talking to, from the
thing you are talking to it with, cannot end well, and a token editable by
somebody who has not got the token is not a token.
From a file
One file, key = value, sections in brackets, # starts a comment. See
examples/kozai.conf.example for every
setting with its reasoning.
[station]
name = Shed
latitude = 52.3676 # degrees north
longitude = 4.9041 # degrees east
altitude = 5 # METRES above the ellipsoid
[prediction]
minimum_elevation = 10
horizon_hours = 48
twilight = -6 # Sun below this counts as dark
satellites = 25544:10, SO-50, AO-73 # the ones this station works
[tle]
urls = https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle
cache_dir = /var/lib/kozai/cache
cache_ttl_hours = 24
offline = false
[server]
host = 127.0.0.1
port = 8080
token = a-long-random-string # needed for anything that moves hardware
[schedule]
enabled = true # work passes with nobody at the keyboard
lookahead_hours = 12
minimum_elevation = 15 # worth moving the antenna for
minimum_duration_seconds = 120 # shorter than this is not worth the swing
park_at_los = true
Any setting can be overridden by an environment variable named
KOZAI_<SECTION>_<KEY> — KOZAI_STATION_LATITUDE, KOZAI_SERVER_PORT — which
is how the container image is configured.
In full, in order of increasing authority: the file, then the environment, then the settings page, then command line flags. Flags win because they were typed at this start; the settings page beats the environment for the reason above; and nothing about either is left for you to discover.
A few things worth knowing:
altitudeis metres. Everything internal is kilometres; this one field is metres because that is what a map tells you.cache_ttl_hoursdefaults to 24, and should not be shortened. CelesTrak asks clients to cache rather than poll. Element sets are regenerated once or twice a day, so a faster interval gains you nothing and costs someone else bandwidth.offline = trueis a supported way to run, not a degraded one. Local files and the existing cache are used, and if a download ever fails the cached copy is served however old it is — with its age reported rather than hidden.- A first start with no network still shows a working station. Nine element
sets are compiled into the binary — the ISS, a few popular amateur satellites,
one geostationary and three weather satellites — and they are used only when
every configured source has failed and nothing is cached. The daemon says so
on start-up, the interface says so in a banner,
/api/healthreportsbundled_elements, and their age is shown like any other. They are there so thatdocker compose upon a machine with no DNS shows a product rather than an empty screen; they are not a catalogue, and anything configured always wins. - There is no authentication. Do not expose this to the open internet.
No dependencies
This is the part that took the most discipline, so it gets its own section.
shard.yml has an empty dependencies block, and CI fails if that changes.
Nothing third-party is compiled into the binary. Not a web framework, not a JSON
library, not an SGP4 library, not a CSS framework, not a JavaScript bundler.
What that buys, concretely:
Docker image, FROM scratch | 7.4 MB |
| Static binary, musl, arm64 | 6.9 MB |
| Dynamic binary, release | 1.9 MB |
| Memory at rest, 2 satellites | 1.9 MB |
| Memory at rest, 97 satellites | 4.3 MB |
| Memory after a day of serving, 97 satellites | 19.3 MB, flat |
Build steps before crystal build | none |
| Runtime files outside the binary | none |
On that last row. Measured over a 13.8-hour soak against a container under
continuous request load, sampling the live heap after a forced GC.collect
rather than RSS — Boehm does not return pages to the operating system unless
built with USE_MUNMAP, so RSS cannot fall and says nothing either way.
The live heap climbs for the first four hours, from 4.9 MB to 19.2 MB, and then stops: over the following 9.8 hours and 576 samples it stayed between 19.19 and 19.37 MB. A least-squares line through that stretch gives 14 KB/hour, which is 250 times below the fill rate and the same size as the scatter between consecutive samples. RSS over the same period sat at 12.3–14.2 MB.
The rise is not a leak and settling was the expected outcome: Crystal pools the stacks of finished fibres, so a server that has handled a burst of concurrent connections holds more live data than one that has just started, up to the high-water mark of concurrency it has seen. What matters for a station left running is that the curve is flat afterwards, and it is.
The web interface — HTML, CSS, JavaScript, and a 66 KB SVG of the world's coastlines — is compiled into the executable by a macro. There is no Node in this project, no bundler, no transpiler, and no asset directory to lose.
The map has no tiles. Continent outlines come from
Natural Earth (public domain), converted
once by tools/geojson_to_svg.cr and committed. A
web interface that fetches map tiles from a CDN shows a grey rectangle on a
station with no internet, which is exactly when you want to see where the
satellite is.
The offline build
The single optional dependency is OpenSSL, which arrives with the HTTPS client used to download elements. It can be removed entirely:
crystal build src/cli.cr --release -Dno_network -Dwithout_openssl -o kozai
or docker build --build-arg NETWORK=0 .
Both flags are needed, and the compiler will remind you if you forget the
second: -Dno_network removes the HTTPS client, but HTTP::Server links
OpenSSL for its TLS support whether or not TLS is used. The resulting binary
contains no OpenSSL at all, which CI verifies with ldd on every push.
Everything except downloading still works in that build: local element files, the cache, the API, the web interface, the rotator, the radio.
What "no dependencies" does not mean
The Crystal runtime stands on C — Boehm GC, libc, libm — and the standard library brings a few more. That is the language, not your supply chain. A dynamically linked release binary links:
libgc, libc, libm, libz, libiconv, libpcre2 (the runtime and stdlib)
libssl, libcrypto (omitted by the offline build)
libpcre2 is there because the standard library's OptionParser uses regular
expressions internally. This codebase contains none — CI greps for them —
because they are not needed for fixed-column TLE parsing and would be one more C
library for no gain. The static build folds all of the above into the executable
and links nothing at runtime.
Accuracy
What the model is
SGP4 and SDP4, as specified in Spacetrack Report No. 3 and revised in AIAA 2006-6753. It is an analytic model matched to the way NORAD generates two-line element sets. Used with those element sets, as here, it is the right tool.
The propagator is verified against the published reference vectors on every
run of crystal spec, all 33 cases and 666 data points, including the
deep-space branch, the resonance cases, and the pathological ones —
near-critical inclination, negative perigee height, eccentricity above 0.99, and
a satellite propagated three and a half years past its epoch.
| Worst position disagreement, glibc | 8.26 × 10⁻⁸ km — 0.083 mm |
| Worst position disagreement, musl | 8.29 × 10⁻⁸ km — 0.083 mm |
| Worst velocity disagreement | 8.5 × 10⁻¹⁰ km/s |
The two published reference implementations disagree with each other by 7 × 10⁻⁸ km, so this is at the noise floor. The tolerance is stated physically rather than as a bit comparison, deliberately: glibc and musl differ in the last place of their trigonometric functions, and a bitwise test would fail a correct implementation built against the other one. Both are run in CI.
Checked against other people's software
Those reference vectors are positions in the TEME frame, so they say nothing
about the frame conversions, the ellipsoid, or the topocentric geometry — the
parts that decide where an antenna actually points. Those are checked separately,
against implementations that share no code with this one. The fixtures are
committed, so crystal spec needs nothing installed.
| Checked against | What | Worst disagreement |
|---|---|---|
| python-sgp4 (wraps Vallado's reference C++) | TEME position and velocity | 2.0 × 10⁻⁹ km |
| Skyfield (its own TEME→ITRS chain, with polar motion) | azimuth, elevation, range | 8.0 arcsec, 22 m |
| Skyfield | range rate, and so Doppler | 0.22 m/s — 0.32 Hz on 435 MHz |
Skyfield's find_events | 72 ISS passes over a week, at two horizons | 0.24 s on AOS and LOS |
| The SatNOGS Network scheduler, on a second lineage of code | 149 real observations, 60 satellites, 65 stations | 0.54° on peak elevation, at SatNOGS's own rounding |
| Skyfield with JPL DE421 | the Sun's direction, 108 samples over a year | 0.0056° — 0.56 of the series' published accuracy |
Skyfield's is_sunlit | 64 eclipse entries and exits, three orbit types | 3.4 s, against a shadow model deliberately different — see below |
Three real satellites: the ISS, Es'hail 2 in geostationary orbit, and CLUSTER II-FM8 — eccentricity 0.911, a 54-hour period, retrograde.
The SatNOGS row is the one that closes the loop. python-sgp4, Skyfield and this software all descend from Vallado's 2006 code, so agreement between them shows the port is faithful and nothing more. SatNOGS computes its windows with PyEphem, whose SGP4 comes down from the original 1980 Spacetrack Report — a different lineage entirely. Agreeing with it, across sixty satellites and sixty-five ground stations at real coordinates using the element sets those stations actually held, is a different kind of evidence.
The 8 arcseconds are not noise, and the test does more than assert a threshold against them. Skyfield works in UT1; this software treats UT1 and UTC as the same thing. For this date UT1 − UTC is 0.0794 s, and that one number produces both halves of the difference: the earth-fixed frame is rotated by 1.19″, and the station sits 22.5 m from where Skyfield puts it, which subtends 8.5″ at the ISS's 547 km and 0.1″ at geostationary distance. The spec predicts that per row and asserts the measured difference never exceeds it — the largest observed share is 0.98 of the prediction, across ranges from 547 km to 86 000 km. So the residual is the documented simplification and not a second error hiding underneath a loose bound.
Eight arcseconds is a normal day, not the limit. The bound is 0.03°, and it is derived rather than measured — see Simplifications.
The eclipse row is a disagreement on purpose, and measuring it is the test.
Skyfield's is_sunlit uses a cylindrical shadow on a spherical Earth; this
software uses the umbra cone on the WGS-84 ellipsoid, so the two must differ —
by up to 77 seconds over the geometries checked. A single tolerance on
"seconds of disagreement" would pass just as happily with the cone implemented
backwards, so the spec asks two separate questions instead. Adopting Skyfield's
own model, this code lands on Skyfield's crossings to 3.4 s, which is 0.93 of
what the solar ephemeris allows for each geometry. And the remaining
difference between the two models, taken in kilometres where the geometry is
exact, matches the cone in closed form with a residual of 20.9 km against a
flattening term that cannot exceed 21.4 km — 98% of a bound that comes from
the Earth's two radii and nothing else.
The scales that matter
Five numbers, in the order they limit you. Only the first two are properties of this software; the rest are the model and the hardware, and they are much larger.
| This implementation against the reference vectors | 0.08 mm | measured |
| Error from treating UT1 as UTC, worst case | 0.03° | derived bound |
| Atmospheric refraction, not applied by default | 0.57° at 0°, 0.1° at 10°, 0.03° at 30° | see below |
| SGP4 for a high orbit (GPS), over a week | 1.0 km median, 5 km worst | measured against IGS ephemerides |
| SGP4 for a low orbit (Sentinel-1C, 690 km) | 0.43 km fresh, 0.77 km at six days | measured against ESA precise orbits |
| What a consumer rotator can resolve | ~1° | hardware |
| Beamwidth of a 70 cm Yagi | tens of degrees | hardware |
It reads itself: the implementation is far more precise than the model, the model is far more precise than the mechanics, and for amateur work the whole stack has room to spare. Chasing the first two rows further would be effort spent three orders of magnitude below anything you could point an antenna at.
The GPS row is measured, not quoted: 64736 positions over seven days against IGS
precise ephemerides, which come from a ground network tracking the satellites
rather than from any model. Median 1.03 km, worst 5.4 km, and flat with element
age. See spec/fixtures/PROVENANCE.md for the
method, including the leap-second trap that made the first run report 53 km.
The row below it is a different number and is not measured here. A GPS satellite is above the atmosphere and its drag term does nothing; a satellite at 400 km has its error dominated by drag, which SGP4 models most crudely and which responds to solar activity no element set can anticipate. Expect a low orbit to be worse, and to get worse with element age — which is why the API reports that age on every response.
How fast a prediction goes stale
The GPS row is flat with element age; the low-orbit row is not, and that is the one that answers "how often should I refresh my elements". Measured against ESA precise orbit determination for Sentinel-1C, 8953 positions over six days:
| Element age | Median error | Worst |
|---|---|---|
| under a day | 0.43 km | 1.10 km |
| 2 to 3 days | 0.53 km | 1.20 km |
| 5 to 6 days | 0.77 km | 1.55 km |
About 70 metres a day, roughly linear. A week-old element set costs you well under two kilometres on a satellite at 690 km — which is a second or so of pass timing, and nothing an antenna would notice.
One satellite, in a good orbit. Sentinel-1C flies a sun-synchronous orbit that is actively maintained, at an altitude where drag is modest. The ISS at 420 km, in a thicker atmosphere and manoeuvring unpredictably, will be worse. Take this as the optimistic end of the low-orbit range, not the middle of it.
How big a catalogue this is for
Measured on an eight-core laptop, a day of passes over one station:
| Catalogue | Objects | Search | Passes found |
|---|---|---|---|
CelesTrak amateur | 97 | 0.30 s | 546 |
CelesTrak active | 16 093 | 35.8 s | 56 395 |
active, with three satellites in [prediction] satellites | 16 093 | 0.06 s | 15 |
Linear, on one core, and recomputed on every /api/passes — which the interface
polls. Nothing there is broken; a full catalogue is simply not what this is for,
and a station pointed at one would decide the interface had hung.
The cure is [prediction] satellites, and it is a cure rather than a filter: it
cuts the work rather than the output, which is why the third row is six hundred
times faster than the second rather than merely quieter. kozai says so on the
way up when the catalogue is large and no list is set.
Refraction
The atmosphere bends a radio wave downwards, so a satellite appears higher than it geometrically is: 0.57° at the horizon, 0.09° at ten degrees, under 0.03° above thirty. That is the largest effect this software does not apply by default — larger than everything else in the simplification list put together.
It is off by default, and that is a decision:
- It is unpredictable. The bend depends on pressure, temperature and humidity along the path, which a prediction made three days ahead cannot know.
- It is largest exactly where a station cannot use it. At the horizon, terrain and buildings decide what you hear.
- Geometric elevation is unambiguous. Two tools that both apply refraction still disagree unless they assume the same atmosphere; two that report geometry agree exactly.
What it actually changes, measured over a week of ISS passes from one station:
| Geometric | Apparent | |
|---|---|---|
| Passes above 0° | 41 | 43 |
| Passes above 10° | 31 | 31 |
| AOS shift above 10° | — | 0.9 s median, 3.7 s worst |
| Peak elevation above 10° | — | 0.02° higher |
So it is not an accuracy question above ten degrees; it is a question of whether two marginal passes a week exist at all.
AOS and LOS in this software mean the geometric horizon, or whatever
minimum_elevation you set, with no refraction applied unless you ask for it.
That is the definition every number in the API and the interface is computed
against.
Expect disagreement with other tools at low elevations, and expect it to be
this. predict, Gpredict and Heavens-Above all apply refraction, so at a
0° threshold they will report passes kozai does not, and AOS a second or so
earlier. That is not a bug in either; it is two defensible definitions of where
the horizon is. Turn refraction on to compare like with like:
[prediction]
refraction = true
Illumination, for watching passes by eye
A radio pass needs the horizon and nothing else. Seeing one needs two more things at the same instant: the satellite has to be in sunlight, and the sky over the station has to be dark. Neither is visible in an elevation plot, which is why a pass list that does not know about them presents a satellite crossing the Earth's shadow over a station in broad daylight exactly as confidently as one blazing overhead at the end of twilight.
Every pass is therefore classified as visual, daylight or eclipsed, and the API reports the spans within it — when the satellite is lit, when the station is dark, and the overlap, which is the part worth going outside for. The interface marks visual passes in the list, draws the eclipsed stretch of the arc dimmed on the polar plot, and shades the night side of the map.
The shadow is a cone on an ellipsoid. Two refinements over the usual cylinder-on-a-sphere, both of them small and both of them stated because their absence is what a comparison against another tool actually measures:
- The Sun is not a point, so the umbra narrows behind the Earth by
tan(0.264°)per kilometre — 32 km where a low satellite crosses it, 190 km at geostationary. A cylinder puts eclipse entry about four seconds early. - A sphere of the equatorial radius is up to 21.4 km too large towards the
poles. Scaling z by
a/bremoves the term exactly.
Eclipse entry is not an instant. Between full sunlight and the umbra there is a penumbra some 65 km wide at low altitude — measured here as 10 seconds of ISS flight — during which the satellite is dimming rather than gone. It is reported as its own state rather than rounded to one side.
Dark means the Sun below −6°, civil twilight, which is where visual pass predictions are conventionally quoted. Set it stricter if your sky is bright:
[prediction]
twilight = -12
No magnitude estimate, and that is deliberate. How bright a satellite looks depends on its size, shape and surface, none of which an element set carries and none of which an offline daemon can look up. The Sun–satellite–observer phase angle is reported, because it is geometry; turning it into a number of magnitudes would need a figure this software would have to invent.
What the model is not
The limit on accuracy is not the propagator; it is the age of the elements.
| Element age | Typical position error |
|---|---|
| Fresh | under a kilometre |
| 1 day | one to a few kilometres |
| 1 week | tens of kilometres, seconds to tens of seconds in pass time |
| 1 month | do not |
This is why every API response reports the element age, why the interface warns above three days, and why the command line prints it in a column. Refresh your elements.
Simplifications, stated plainly
- Manoeuvres are not modelled. Nothing here can know that a satellite fired a thruster. The next element set will; this one will not.
- UT1 and UTC are treated as the same, and the resulting error is bounded at 0.03° — with the true value normally an order of magnitude inside that. ESA's operational orbit determination put UT1 − UTC at 0.0127 s over the week measured above, which is 0.19″ of frame rotation. Leap seconds hold |UT1 − UTC| below 0.9 s by construction, so the worst case is a 13.5″ rotation of the earth-fixed frame plus a 268 m displacement of the station, which subtends 101″ = 0.028° at a low satellite's closest approach and less at any greater range. A rotator resolves about a degree and a 70 cm Yagi has a beamwidth of tens; this is two orders of magnitude under the mechanics, provably rather than plausibly. Correcting it would mean fetching earth orientation parameters over a network the station may not have. Measured against Skyfield on a normal day it is 8″.
- Polar motion is ignored. The rotation by sidereal time gives the pseudo-earth-fixed frame, not ITRF. The pole moves by under ten metres, which is inside the UT1 bound above and does not widen it.
- Atmospheric refraction is not modelled. It lifts an object near the horizon by up to half a degree, depends on local pressure and temperature, and matters only in the few degrees where terrain dominates anyway.
- The solar position is a short series, not an integration. Good to 0.01° by publication and to 0.0056° as measured, which is worth 2.4 seconds on the time of civil twilight and 1.2 km on the shadow boundary — twenty times narrower than the penumbra it sits inside.
- The Doppler shift is first-order. The relativistic term at 7.5 km/s is 0.13 Hz on a 435 MHz downlink — four orders of magnitude below the frequency error of the oscillator in any amateur satellite.
- Julian dates are single
Float64s, so time resolution is about 40 µs. Measured against python-sgp4, which splits the epoch into a whole part and a fraction, this costs 14 cm of along-track position; the bound over the cross-check set is 0.1 m. It is four orders of magnitude below the element set error, and fixing it would perturb numbers that are currently verified. - The map's footprint circle is drawn in degrees of latitude, which is only correct near the sub-point. It is dashed for that reason.
Limitations of version 0.4
Stated plainly, because a station operator deciding whether to trust this needs the shape of what is and is not established.
- Position, look angles and pass boundaries are cross-checked against two independent implementations, including 149 real observations from the SatNOGS Network across 65 ground stations and 60 satellites, agreeing within that network's own rounding quantum of one degree. Doppler shift is verified arithmetically against range rate, but not against a received radio signal.
- The rotator and receiver have been driven against hamlib itself, on its Dummy backends — acquisition, tracking inside the deadband, the Doppler inflection at closest approach, stop and park. No real antenna has been moved and no signal received. See Rotator and radio.
- Model accuracy is measured on one satellite — Sentinel-1C, a maintained sun-synchronous orbit at 690 km, degrading by about 70 m per day of element age. That is the optimistic end: the spacecraft is held in a narrow orbital tube for interferometry, which keeps it about as close to an unperturbed model as a real satellite gets. Expect noticeably worse on a low orbit like the ISS at 420 km, where drag varies and reboosts are not in any element set.
- Atmospheric refraction is implemented but off by default, and AOS means the
geometric horizon. Disagreements with Gpredict,
predictand Heavens-Above below a few degrees of elevation are expected and are explained by that. - The compiled-in transponder table is eleven entries over seven satellites,
and frequencies move: AO-7 alternates modes on a 24-hour timer, the ISS radio
changes function between crews, and a satellite in safe mode transmits on none
of them. It is a starting point, not a database — point
[radio] transpondersat your own file and the built-in entries give way to it. - The scheduler takes passes whole. A pass that overlaps a better one only at its start is refused outright rather than worked from where the antenna comes free. The arithmetic that chooses between them is exact; what it chooses between is coarser than reality.
- The settings page writes beside the element cache, which is the one
directory a station must have writable — the container image is read-only
everywhere else on purpose, and
WORKDIRis the mounted volume. A station that gives kozai nowhere to write keeps working and says the page cannot save. - amd64 and arm64 are supported. 32-bit ARM is not.
- A tool for amateur radio practice and observation. Not intended for operations where being wrong is expensive.
Not for critical operations
This is a tool for amateur radio practice and satellite observation. It is not qualified for collision avoidance, re-entry prediction, launch operations, or anything else where being wrong matters in a way that costs more than a missed pass.
Not planned
Recording, SatNOGS integration, SDR, demodulation, telemetry decoding, multi-user support, orbit determination.
Authentication is deliberately the smallest thing that works: one shared secret on the requests that move hardware, no accounts and no roles. A station has one operator, and anything more would be a login screen in front of a pass list.
What is planned is in ROADMAP.md.
Platforms
- linux/amd64 and linux/arm64, tested natively in CI on both, on glibc and musl.
- 32-bit ARM is not supported and is not planned. Crystal's support for it is not solid enough to promise, and a Raspberry Pi capable of running a station has a 64-bit operating system available. Use a 64-bit image.
- macOS works for development. The shipped artefacts are Linux.
Development
crystal spec # the whole suite, verification included
crystal tool format --check src spec tools
shards install && crystal build lib/ameba/src/cli.cr -o bin/ameba && ./bin/ameba
crystal docs
See CONTRIBUTING.md for how the code is laid out and which rules are not negotiable.
Changelog
CHANGELOG.md. Planned work is in ROADMAP.md.
Licence
MIT — see LICENSE.
The SGP4 verification data in spec/fixtures/ comes from the software package
accompanying AIAA 2006-6753, distributed by CelesTrak, and is used to validate
this independent implementation; see
spec/fixtures/PROVENANCE.md. The propagator was
written from the published description of the algorithm, not ported from the
reference sources. Coastline data is from Natural Earth, which is public domain.
Contributing
Bug reports and patches are welcome. Please read CONTRIBUTING.md first — in particular the parts about the empty dependency list and the verification suite, which are the two things this project will not trade away.
API
- Kozai
Satellite pass prediction and ground station control.