kozai

Version, currently 0.4.01 version
  • 0.4.0latestAug 9, 2026

github.com/VanyaNeytrino/kozai

Headless satellite pass prediction and ground station control daemon. JSON API, offline web UI, rotator and radio control. Zero third-party dependencies, stdlib only.

3 stars
0 dependents
License: MIT

Installation

# Add this to your shard.yml
dependencies:
  kozai:
    github: VanyaNeytrino/kozai
    version: ~> 0.4.0

Then run:

shards install

shard.yml

Crystal
>= 1.21.0
License
MIT
Author
Ivan Ytsenko
Target
  • kozai from src/cli.cr

Dependencies

Development Dependencies

  • ameba1.7.0-devgithub: crystal-ameba/amebadev

README

kozai

Satellite pass prediction and ground station control, as a service.

CI Release License: MIT Image


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.

The web interface

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.

MethodPathWhat it does
GET/api/healthStatus, satellite count, element ages, source outcomes
GET/api/stationThe configured observing position
GET/api/sunSub-solar point, and the Sun's angles at the station
GET/api/transpondersEvery known transponder; /api/satellites/:id/transponders for one satellite
GET/api/scheduleWhat the station plans to work, and what it declined, with reasons
GET/api/settingsEvery editable setting, its value and where that value came from
POST/api/settingsChange settings; applies at once, no restart
GET/api/satellitesThe catalogue; ?q= filters by name or number
GET/api/satellites/:idOne satellite, with its position now
GET/api/satellites/:id/positionPosition at ?time= (RFC 3339 or Unix)
GET/api/satellites/:id/passesPasses; ?hours=, ?min_elevation=
GET/api/satellites/:id/trackGround track; ?minutes=, ?steps=
GET/api/satellites/:id/dopplerDoppler; ?frequency=, ?uplink=
GET/api/passesPasses across the whole catalogue; ?limit=
POST/api/refreshReload elements; ?force=true ignores the cache
GET/api/rotatorRotator and radio state
POST/api/rotator/trackStart tracking; ?sat=, ?transponder=
POST/api/rotator/stopStop tracking
POST/api/rotator/parkStop 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.
  • stop left the antenna where it stood; park drove 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 manual track held the antenna against the scheduler until stop.

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:

  • altitude is metres. Everything internal is kilometres; this one field is metres because that is what a map tells you.
  • cache_ttl_hours defaults 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 = true is 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/health reports bundled_elements, and their age is shown like any other. They are there so that docker compose up on 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 scratch7.4 MB
Static binary, musl, arm646.9 MB
Dynamic binary, release1.9 MB
Memory at rest, 2 satellites1.9 MB
Memory at rest, 97 satellites4.3 MB
Memory after a day of serving, 97 satellites19.3 MB, flat
Build steps before crystal buildnone
Runtime files outside the binarynone

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, glibc8.26 × 10⁻⁸ km — 0.083 mm
Worst position disagreement, musl8.29 × 10⁻⁸ km — 0.083 mm
Worst velocity disagreement8.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 againstWhatWorst disagreement
python-sgp4 (wraps Vallado's reference C++)TEME position and velocity2.0 × 10⁻⁹ km
Skyfield (its own TEME→ITRS chain, with polar motion)azimuth, elevation, range8.0 arcsec, 22 m
Skyfieldrange rate, and so Doppler0.22 m/s — 0.32 Hz on 435 MHz
Skyfield's find_events72 ISS passes over a week, at two horizons0.24 s on AOS and LOS
The SatNOGS Network scheduler, on a second lineage of code149 real observations, 60 satellites, 65 stations0.54° on peak elevation, at SatNOGS's own rounding
Skyfield with JPL DE421the Sun's direction, 108 samples over a year0.0056° — 0.56 of the series' published accuracy
Skyfield's is_sunlit64 eclipse entries and exits, three orbit types3.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 vectors0.08 mmmeasured
Error from treating UT1 as UTC, worst case0.03°derived bound
Atmospheric refraction, not applied by default0.57° at 0°, 0.1° at 10°, 0.03° at 30°see below
SGP4 for a high orbit (GPS), over a week1.0 km median, 5 km worstmeasured against IGS ephemerides
SGP4 for a low orbit (Sentinel-1C, 690 km)0.43 km fresh, 0.77 km at six daysmeasured against ESA precise orbits
What a consumer rotator can resolve~1°hardware
Beamwidth of a 70 cm Yagitens of degreeshardware

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 ageMedian errorWorst
under a day0.43 km1.10 km
2 to 3 days0.53 km1.20 km
5 to 6 days0.77 km1.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:

CatalogueObjectsSearchPasses found
CelesTrak amateur970.30 s546
CelesTrak active16 09335.8 s56 395
active, with three satellites in [prediction] satellites16 0930.06 s15

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:

GeometricApparent
Passes above 0°4143
Passes above 10°3131
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/b removes 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 ageTypical position error
Freshunder a kilometre
1 dayone to a few kilometres
1 weektens of kilometres, seconds to tens of seconds in pass time
1 monthdo 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, predict and 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] transponders at 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 WORKDIR is 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.