sidereon

Hex.pm HexDocs Run in Livebook

GNSS and astrodynamics for Elixir: propagate satellites, predict passes, solve precise positions (SPP / RTK / PPP / DGNSS), screen for conjunctions, and convert between coordinate frames and time scales.

This is the Elixir interface to sidereon, a GNSS and astrodynamics engine written in Rust. The numerics run in that engine and ship to you as a Rustler precompiled NIF: adding the dependency downloads a prebuilt binary for your platform, so there is no Rust toolchain to install and nothing to compile. You write ordinary Elixir, with plain DateTime and map structures in and typed structs out.

The engine is reference-validated. The SGP4 propagator is a port of David Vallado's reference implementation, bit-exact to it; frames and time are checked against Skyfield and IERS; the positioning stack is checked against IGS products.

Install

Add :sidereon to your dependencies in mix.exs:

def deps do
  [{:sidereon, "~> 3.0"}]
end

Releases ship precompiled NIFs for common Linux, macOS, and Windows targets and download automatically, so no Rust build is needed. On glibc Linux, a portable artifact variant zig-linked against a glibc 2.17 floor is available for old-glibc hosts and zig-based packaging pipelines (e.g. Burrito): opt in at compile time with SIDEREON_PORTABLE_NIF=1 or config :sidereon, portable_nif: true. The musl (Alpine) artifacts are already self-contained; in a bare FROM alpine runtime stage, apk add libgcc is the only system dependency. To force a source build, install a Rust toolchain, set SIDEREON_BUILD=1, and activate Sidereon's optional Rustler dependency in the consuming application:

def deps do
  [
    {:sidereon, "~> 3.0"},
    {:rustler, ">= 0.0.0", optional: true}
  ]
end

musl and cross-libc packaging

rustler_precompiled selects its artifact from the build host's target triple, not from the artifact you are ultimately producing. Publishing musl builds does not protect against this, because selection happens before the published set is consulted.

The trap: a glibc build host (Debian/Ubuntu CI, or the builder stage of a multi-stage Dockerfile) producing a musl output - an Alpine image, a self-extracting release, Nerves firmware - resolves to x86_64-unknown-linux-gnu, downloads that .so, and packages it. The build succeeds. The failure appears later, on the target machine, as an opaque load_nif error that reads like a broken library rather than a build-host libc mismatch.

If you package for a libc other than your build host's, build the NIF from source on a host matching the target:

def deps do
  [
    {:sidereon, "~> 3.0"},
    {:rustler, ">= 0.0.0", optional: true}
  ]
end

with SIDEREON_BUILD=1 set for that build. Sidereon logs an explicit diagnosis at application start when it detects that the packaged artifact's libc does not match the running system, naming this section - but it can only report the mismatch, not prevent it, because by then the wrong artifact is already packaged.

SP3 writer return values in 3.0

Before 3.0, Sidereon.GNSS.SP3.to_sp3_string/2 returned the text directly, so callers could pass it to byte-oriented functions such as :zlib.gzip/1:

text = Sidereon.GNSS.SP3.to_sp3_string(sp3)
compressed = :zlib.gzip(text)

In 3.0, to_sp3_string/2 returns {:ok, binary} or {:error, {tag, fields}}; to_iodata/2 returns {:ok, iodata} or the same typed refusal. Unwrap the success value before using it:

case Sidereon.GNSS.SP3.to_sp3_string(sp3) do
  {:ok, text} -> {:ok, :zlib.gzip(text)}
  {:error, {tag, fields}} -> {:error, {:sp3_write_refused, tag, fields}}
end

The same change applies to to_iodata/2. Before 3.0, a call site could pass its result straight to an iodata consumer:

iodata = Sidereon.GNSS.SP3.to_iodata(sp3)
compressed = :zlib.gzip(iodata)

In 3.0, unwrap the result and handle the typed refusal:

case Sidereon.GNSS.SP3.to_iodata(sp3) do
  {:ok, iodata} -> {:ok, :zlib.gzip(iodata)}
  {:error, {tag, fields}} -> {:error, {:sp3_write_refused, tag, fields}}
end

Other products can still hold a value finer than the SP3 columns and return {:error, {:record_value_not_representable, fields}}, where fields identifies the field, satellite, and epoch. The strict writer refuses that value rather than rounding it. Callers can preserve the named error and its fields as above, or match the tag and fields to handle a particular refusal. See the complete 3.0 release notes.

Explicit rounding in 3.0.5

For a product that needs record-field rounding, use Sidereon.GNSS.SP3.to_sp3_string_with_rounding/2. It returns {:ok, text, report}; the report maps the eight position, velocity and clock fields to maximum absolute changes in SI units. Header fields and epochs retain the strict checks. Sidereon.GNSS.Data.write_sp3_with_rounding/3 writes that text atomically and returns {:ok, path, report}.

Merges from column-grid inputs keep derived record values on the SP3 grid, including mean and median combinations. Accuracy metadata has its own representability requirements, addressed below.

Accuracy metadata in 3.0.6

SP3 accuracy exponents depend on the position and clock bases in the header. Merging products now selects bases that preserve retained standard deviations exactly when possible, including an input with unspecified bases followed by one with known accuracy. Strict writers still refuse incompatible accuracy metadata.

An explicit accuracy conversion is available when exact encoding is impossible:

case Sidereon.GNSS.SP3.to_sp3_string_with_accuracy_rounding(sp3, :conservative) do
  {:ok, text, %{record_values: values, accuracy_sigmas: sigmas}} ->
    {:ok, :zlib.gzip(text), values, sigmas}

  {:error, {:accuracy_not_representable, fields}} ->
    {:error, {:accuracy_not_representable, fields}}

  {:error, reason} ->
    {:error, reason}
end

The required :conservative policy chooses the smallest finite output standard deviation at least as large as each source value. accuracy_sigmas reports the maximum absolute change per field: position and clock range in metres, velocity and clock rate in metres per second. Unknown metadata and sentinels retain their meaning. Values beyond the finite output range remain errors. The source product is unchanged, and the existing eight-field numeric rounding report is unchanged.

For atomic gzip file output, use Sidereon.GNSS.Data.write_sp3_with_accuracy_rounding(sp3, path, :conservative, gzip: true). It returns {:ok, path, report} or a typed error; writer refusal preserves an existing destination file. Higher-precision inputs can still require the opt-in rounding writer. Agreement and dispersion metrics use combined values before final column quantization.

Example: track a satellite

Parse a two-line element set, run SGP4, and take a look angle from a ground station. No data files and no setup: give it the elements and a station, and it returns azimuth, elevation, and slant range.

line1 = "1 25544U 98067A   24001.50000000  .00016717  00000-0  10270-3 0  9009"
line2 = "2 25544  51.6400 208.8657 0002644 250.3037 109.7782 15.49560812999990"
station = %{latitude: 51.5, longitude: -0.1, altitude_m: 10.0}

with {:ok, tle} <- Sidereon.parse_tle(line1, line2),
     {:ok, look} <- Sidereon.look_angle(tle, ~U[2024-01-01 12:00:00Z], station) do
  look.azimuth      # degrees
  look.elevation    # degrees
  look.range_km     # slant range
end

The same parsed elements feed Sidereon.propagate/2 (TEME position and velocity), Sidereon.geodetic/2 (the sub-satellite point), and Sidereon.predict_passes/5 (every pass above a minimum elevation over a window).

Example: solve a position

The positioning engine is the other half of the library. Feed it pseudoranges and a precise-ephemeris product and it returns a least-squares fix with ECEF and geodetic positions plus geometry diagnostics.

# GPS L1 pseudoranges (meters) for the satellites in view at the epoch.
observations = [
  {"G08", 23_825_519.8},
  {"G10", 22_717_690.1},
  {"G16", 20_478_653.4},
  {"G18", 21_768_335.2},
  {"G20", 21_248_327.7},
  {"G21", 20_808_709.8}
]

# `sp3_data` is a precise SP3 ephemeris (a string, or load one with
# `Sidereon.GNSS.SP3.load/1`).
with {:ok, sp3} <- Sidereon.GNSS.SP3.parse(sp3_data),
     {:ok, solution} <-
       Sidereon.GNSS.Positioning.solve(sp3, observations, ~N[2020-06-24 12:00:00],
         initial_guess: [4_500_000.0, 500_000.0, 4_500_000.0, 0.0]) do
  solution.position     # %{x_m, y_m, z_m} ITRF/IGS ECEF meters
  solution.geodetic     # %{lat_rad, lon_rad, height_m}
  solution.rx_clock_s   # receiver clock bias, seconds
  solution.dop.pdop     # position dilution of precision
  solution.used_sats    # satellites that contributed to the fix
end

Sidereon.GNSS.RTK and the PPP and DGNSS solvers follow the same pattern: observations and a product in, a typed solution out.

For post-solve integrity checks, use Sidereon.GNSS.QC.RaimInput.new/2 with Sidereon.GNSS.QC.raim/2 to run residual RAIM from satellite ids and post-fit residuals. RAIM weights must come from per-satellite residual variances; unit weights on metre-scale residuals make fault_detected saturate near 100%:

entries = [
  %{satellite_id: "G01", elevation_deg: 72.0},
  %{satellite_id: "G02", elevation_deg: 42.0},
  %{satellite_id: "G03", elevation_deg: 35.0}
]

weights = Sidereon.GNSS.QC.weight_vector(entries, a_m: 0.8, b_m: 0.8)
Sidereon.GNSS.QC.raim(input, weights: weights)

Use Sidereon.GNSS.ARAIM.Geometry.from_az_el_deg/3 with Sidereon.GNSS.ARAIM.araim/3 to compute HPL/VPL protection levels from azimuth/elevation rows, ISM records, and an integrity allocation.

A runnable sidereon.livemd walks through propagation, positioning, and conjunction screening; more notebooks live under the examples directory.

SP3 interpolation policy and continuity

Precise orbit interpolation fits polynomial stencils over consecutive ephemeris nodes. Sidereon.GNSS.SP3.load/2, Sidereon.GNSS.SP3.parse/2, and Sidereon.GNSS.SP3.parse_exact/3 accept optional :gap_threshold_factor (default 1.5, must be > 1.0) to configure the multiple of nominal node spacing above which consecutive records mark a coverage gap:

# Parse SP3 with a relaxed gap threshold to bridge coverage gaps:
{:ok, sp3} = Sidereon.GNSS.SP3.load("path/to/ephemeris.sp3", gap_threshold_factor: 13.0)
13.0 = Sidereon.GNSS.SP3.gap_threshold_factor(sp3)

The configured policy propagates across the pipeline:

What's in the box

Every result is what the engine computes, returned as plain Elixir structs and maps with {:ok, _} / {:error, _} tuples. Full signatures live on HexDocs.

Other languages

sidereon is one validated engine with first-class interfaces in several languages: Rust (sidereon), Python (sidereon-python), C (sidereon-c), Go (sidereon-go), Elixir (this package), and WebAssembly (sidereon-wasm). The same numbers come out everywhere. See the live demo and docs at sidereon.dev.

License

MIT. The engine's SGP4 propagation is a port of David Vallado's reference implementation (credit: David Vallado, AIAA 2006); see the core sidereon crate for full attribution.