kozai v0.1.0

Headless satellite pass prediction and ground station control. Zero dependencies, one binary, works offline.

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.

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
  • 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, 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/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=
POST /api/rotator/stop Stop tracking
POST /api/rotator/park Stop tracking and park

:id is a catalog number or a name fragment.

Examples

curl 'localhost:8080/api/passes?hours=12&min_elevation=10&limit=3'
{
  "from": "2026-08-04T16:45:00Z",
  "hours": 12.0,
  "minimum_elevation": 10.0,
  "count": 3,
  "truncated": true,
  "passes": [
    {
      "catalog_number": 28654,
      "name": "NOAA 18",
      "aos": "2026-08-04T17:08:47Z",
      "max": "2026-08-04T17:11:15Z",
      "los": "2026-08-04T17:13:43Z",
      "duration_seconds": 296.5,
      "aos_azimuth": 72.86,
      "max_azimuth": 45.68,
      "max_elevation": 13.27,
      "los_azimuth": 18.53,
      "max_range_km": 2239.9,
      "elements_age_days": 0.86,
      "stale": false,
      "clipped": false,
      "clipped_start": false,
      "clipped_end": false
    }
  ]
}

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
downlink_hz = 435000000

Then POST /api/rotator/track?sat=25544, or click a pass in the interface.

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

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

[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

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. Command line flags override both.

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

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

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

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

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.
  • 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.
  • 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 scheduling, SatNOGS integration, SDR, demodulation, telemetry decoding, authentication, multi-user support, orbit determination.

What is planned, including illumination for visual observation and the checks this version left undone, 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.

Repository

kozai

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 1
  • 3 minutes ago
  • August 5, 2026
License

MIT License

Links
Synced at

Wed, 05 Aug 2026 11:04:31 GMT

Languages