Sidereon.GNSS.SP3 (Sidereon v0.8.0)

Copy Markdown View Source

SP3-c / SP3-d precise-ephemeris products (IGS precise orbits + clocks).

This is the Elixir surface over the astrodynamics-gnss SP3 parser and scipy.interpolate-matched position/clock interpolation. It is not the JPL-SPK reader (Sidereon.Ephemeris): SP3 carries GNSS satellite states in the ITRF/IGS ECEF frame, in meters, tagged by a GNSS satellite id like "G01".

A file is parsed once into a resource handle held by the BEAM; evaluation operates on that handle and never re-reads the file.

Example

{:ok, sp3} = Sidereon.GNSS.SP3.load("/path/to/igs.sp3")
{:ok, state} =
  Sidereon.GNSS.SP3.position(sp3, "G01", ~N[2020-06-24 00:00:00])

state.x_m       # ITRF/IGS ECEF X, meters
state.clock_s   # satellite clock offset, seconds (or nil if no estimate)

Epochs

The query epoch is interpreted in the file's own time scale (read from the SP3 header, typically GPST). Pass a NaiveDateTime or a {{year, month, day}, {hour, minute, second}} tuple; it is converted to the split Julian date with the same midnight-boundary convention the parser uses (no leap-second shifting; the epoch stays in the file's scale).

Summary

Functions

Return a copy of other with its clocks shifted onto reference's clock datum (the clock-datum primitive, applied).

Estimate the per-epoch reference-clock offset of other relative to reference (the clock-datum primitive).

Return the product coverage interval.

Number of parsed epochs held by the SP3 product.

Return the parsed SP3 epoch grid as seconds since J2000.

Load and parse an SP3-c / SP3-d file into a product handle.

Like load/1 but raises on failure.

Merge several SP3 products from different analysis centers into one consistent precise-ephemeris dataset.

Parse an in-memory SP3 byte buffer (already decompressed) into a handle.

Interpolate the state of satellite sat_id at epoch.

Return the SP3/RINEX satellite identifiers declared by the product header.

Return the exact parsed state of sat_id at epoch_index.

Return all exact parsed states at epoch_index.

Serialize the product to standard SP3-c / SP3-d text as iodata. Pure, no I/O.

Types

t()

@type t() :: %Sidereon.GNSS.SP3{
  coverage_end: float(),
  coverage_start: float(),
  handle: reference(),
  time_scale: String.t()
}

Functions

align_clock_reference(sp3, other_sp3, opts \\ [])

@spec align_clock_reference(t(), t(), keyword()) :: {:ok, t()} | {:error, term()}

Return a copy of other with its clocks shifted onto reference's clock datum (the clock-datum primitive, applied).

At every epoch the offset could be estimated, each clocked satellite's offset has the datum subtracted, so the result's clocks are directly comparable to reference's. Positions are untouched. Epochs without an estimate are left unchanged. The returned product interpolates like any other SP3.

Returns {:ok, %Sidereon.GNSS.SP3{}} or {:error, reason}.

Options

  • :min_common: minimum common clocked satellites per epoch (default 5)

clock_reference_offset(sp31, sp32, opts \\ [])

@spec clock_reference_offset(t(), t(), keyword()) :: [map()]

Estimate the per-epoch reference-clock offset of other relative to reference (the clock-datum primitive).

Precise clock products from different centers are referenced to different station/ensemble clocks, so their raw clocks differ by a per-epoch common offset that drifts over the day. This returns that datum: a list of maps %{jd_whole: float, jd_fraction: float, offset_s: float, satellites: integer}, one per epoch where at least :min_common common clocked satellites let the (robust median) offset be estimated. Subtract offset_s from other's clocks to put both products on reference's datum. Orbit positions need no such treatment; every center reports ITRF center-of-mass coordinates.

Options

  • :min_common: minimum common clocked satellites per epoch (default 5)

coverage(sp3)

@spec coverage(t()) :: %{
  start_j2000_s: float(),
  end_j2000_s: float(),
  time_scale: String.t()
}

Return the product coverage interval.

The start and end are the first and last SP3 node epochs, expressed as seconds since J2000 in the product's own time scale. Public evaluators reject epochs outside this interval by default; pass extrapolate: true to the evaluator to opt into the lower-level interpolation behavior.

epoch_count(sp3)

@spec epoch_count(t()) :: non_neg_integer()

Number of parsed epochs held by the SP3 product.

This is the count of actual * epoch nodes parsed from the file, not just the header declaration. The value matches length(epochs_j2000_seconds(sp3)) for ordinary SP3 products.

epochs_j2000_seconds(sp3)

@spec epochs_j2000_seconds(t()) :: [float()]

Return the parsed SP3 epoch grid as seconds since J2000.

Values are in the product's own time scale, ascending, and correspond exactly to the parsed SP3 node epochs. Use this accessor when a caller needs the original sample grid rather than an interpolated state.

load(path)

@spec load(String.t()) :: {:ok, t()} | {:error, term()}

Load and parse an SP3-c / SP3-d file into a product handle.

Returns {:ok, %Sidereon.GNSS.SP3{}} or {:error, reason}. The file is read and parsed exactly once; the parsed product is held as a resource handle.

load!(path)

@spec load!(String.t()) :: t()

Like load/1 but raises on failure.

merge(sources, opts \\ [])

@spec merge(
  [t()],
  keyword()
) :: {:ok, t(), map()} | {:error, term()}

Merge several SP3 products from different analysis centers into one consistent precise-ephemeris dataset.

sources is a list of loaded products in precedence order (earlier wins ties). This is orthogonal to time-stitching: it combines providers at the same epochs on one shared time grid. Mixed-cadence products are rejected unless callers resample before merging; they are never unioned onto a finer grid. For every (epoch, satellite) cell on the shared grid:

  • Union satellite coverage: a satellite present in any input may appear in the merged product, but only on epochs that keep a coherent arc.
  • Consensus: the largest subset of sources agreeing within tolerance is combined; sources outside it are recorded as outliers. A cell with no agreeing subset of :min_agree is quarantined (omitted), never averaged across disagreeing centers. A lone source is carried through.
  • Precedence arcs: with combine: :precedence, source selection is per satellite arc, not per cell. A satellite never alternates centers at adjacent epochs; if the chosen source lacks a cell, that cell is omitted rather than filled from a lower-precedence source.

Returns {:ok, %Sidereon.GNSS.SP3{}, report} or {:error, reason}, where report is a map with :quarantined, :single_source, and :position_outliers lists. Each entry is a map %{satellite: "G03", jd_whole: float, jd_fraction: float, sources: [0, 2]} (sources are zero-based indices into sources).

Options

  • :position_tolerance_m: position agreement tolerance, meters (default 0.5)
  • :clock_tolerance_s: clock agreement tolerance, seconds (default 5.0e-9)
  • :min_agree: agreeing sources required to accept a contested cell (default 2)
  • :clock_min_common: common clocked satellites for the clock-datum estimate (default 5)
  • :combine: :mean (default), :median, or :precedence
  • :epoch_interval_s: require this target epoch interval, seconds
  • :systems: restrict output to systems such as [:gps] or ["G", "E"]

parse(bytes)

@spec parse(binary()) :: {:ok, t()} | {:error, term()}

Parse an in-memory SP3 byte buffer (already decompressed) into a handle.

position(sp3, sat_id, epoch, opts \\ [])

@spec position(t(), String.t(), NaiveDateTime.t() | tuple(), keyword()) ::
  {:ok, Sidereon.GNSS.SP3.State.t()} | {:error, term()}

Interpolate the state of satellite sat_id at epoch.

sat_id is the canonical SP3/RINEX token, e.g. "G01" (GPS PRN 1), "E12", "C30". epoch is a NaiveDateTime or a {{year, month, day}, {hour, minute, second}} tuple, interpreted in the file's own time scale.

By default, epochs outside the parsed SP3 node coverage return {:error, :outside_coverage}. Pass extrapolate: true to opt into the lower-level interpolation behavior near the product edges.

Returns {:ok, %Sidereon.GNSS.SP3.State{}} or {:error, reason}.

satellite_ids(sp3)

@spec satellite_ids(t()) :: [String.t()]

Return the SP3/RINEX satellite identifiers declared by the product header.

These are canonical three-character tokens such as "G01", "E12", or "C30". The list is read from the already-loaded SP3 handle; no file I/O or interpolation is performed.

Examples

{:ok, sp3} = Sidereon.GNSS.SP3.parse(sp3_bytes)
ids = Sidereon.GNSS.SP3.satellite_ids(sp3)
"G01" in ids

state(sp3, sat_id, epoch_index)

@spec state(t(), String.t(), non_neg_integer()) ::
  {:ok, Sidereon.GNSS.SP3.State.t()} | {:error, term()}

Return the exact parsed state of sat_id at epoch_index.

epoch_index is zero-based into epochs_j2000_seconds/1. This accessor does no interpolation: the returned state is the record stored in the SP3 file, including optional velocity, optional clock-rate, and the SP3 status flags. Missing all-zero orbit records are not fabricated; querying such a cell returns {:error, {:unknown_satellite, sat_id}}.

Returns {:ok, %Sidereon.GNSS.SP3.State{}} or {:error, reason}.

states_at(sp3, epoch_index)

@spec states_at(t(), non_neg_integer()) ::
  {:ok, [{String.t(), Sidereon.GNSS.SP3.State.t()}]} | {:error, term()}

Return all exact parsed states at epoch_index.

The result is an ascending satellite-id list of {satellite_id, state} pairs for records actually present at that SP3 epoch. Satellites whose position record is the SP3 missing-orbit sentinel are absent from the list.

Returns {:ok, [{satellite_id, %Sidereon.GNSS.SP3.State{}}]} or {:error, reason}.

to_iodata(sp3, opts \\ [])

@spec to_iodata(
  t(),
  keyword()
) :: iodata()

Serialize the product to standard SP3-c / SP3-d text as iodata. Pure, no I/O.

This is the inverse of load/1 / parse/1: a read → (merge/2) → write pipeline round-trips to a single standard SP3 file any reader consumes. The output is deterministic (same product → identical bytes). Header fields (version, epoch count, satellite list, time system, week / seconds-of-week / MJD / interval) are derived from the product. A satellite absent at an epoch is written as the SP3 missing-orbit sentinel, so a quarantined merge/2 cell re-reads as missing, never a fabricated position.

To write to disk (optionally gzipped, with an atomic commit), use Sidereon.GNSS.Data.write_sp3/3.

Examples

{:ok, sp3} = Sidereon.GNSS.SP3.load("igs.sp3")
iodata = Sidereon.GNSS.SP3.to_iodata(sp3)
{:ok, reparsed} = Sidereon.GNSS.SP3.parse(IO.iodata_to_binary(iodata))
Sidereon.GNSS.SP3.satellite_ids(reparsed) == Sidereon.GNSS.SP3.satellite_ids(sp3)
#=> true