Sidereon.GNSS.Observables (Sidereon v0.30.0)

Copy Markdown View Source

Predict the GNSS observables a receiver at a known ECEF position would see for a satellite, from a precise (SP3) or broadcast ephemeris source.

This is the forward model behind the question "is this measurement physically plausible?": given a receiver position, a satellite, and a receive epoch, it computes the geometric range, the line-of-sight range rate, the L1 Doppler, the topocentric azimuth/elevation, the satellite clock offset, and the signal transmit time. The Rust core evaluates the loaded SP3 or broadcast ephemeris handle and applies standard textbook GNSS geometry; this module keeps only the Elixir API shape and result mapping. It never solves the inverse (positioning) problem.

Algorithm (standard GNSS geometry)

  • Light-time / transmit-time correction. The signal seen at the receive epoch t_rx left the satellite earlier, at t_tx = t_rx - |r_sat(t_tx) - r_rx| / c. This is solved by fixed-point iteration starting from t_tx = t_rx; a couple of iterations converge to sub-millimetre level for a coarse receiver position. The satellite state is evaluated at the fractional epoch t_tx (the SP3 spline is sampled at sub-second precision).

  • Sagnac / Earth-rotation correction. During the travel time tau the Earth-fixed (ECEF) frame rotates by omega_e * tau. The satellite position computed in the ECEF frame at t_tx is rotated about the Z axis by Rz(omega_e * tau) into the receive-epoch ECEF frame before differencing, with omega_e = 7.2921151467e-5 rad/s. This is the Sagnac (Earth-rotation) correction.

  • Geometric range is |r_sat_rot - r_rx| in metres, and the line-of-sight unit vector points from the receiver to the satellite.

  • Range rate. The satellite velocity at t_tx is obtained by central finite difference of Sidereon.GNSS.SP3.position/3 (+/- 0.5 s). For a static receiver (v_rx = 0) the range rate is the LOS projection los . (v_sat - v_rx), which equals d(range)/dt.

  • Doppler (IS-GPS-200 L1 carrier). doppler_hz = -range_rate * f / c with the L1 carrier f = 1575.42 MHz and c = 299792458 m/s.

Sign conventions

range_rate_m_s is the time derivative of the geometric range: it is negative when the satellite is approaching (range decreasing) and positive when receding. The Doppler shift is the negative of the (scaled) range rate, so an approaching satellite gives a positive Doppler and a receding satellite a negative one.

Result map

%{
  geometric_range_m: float(),    # metres
  range_rate_m_s:    float(),    # d(range)/dt; negative = approaching
  doppler_hz:        float(),    # = -range_rate * carrier / c; + = approaching
  sat_clock_s:       float() | nil,  # SP3 clock offset at transmit time
  elevation_deg:     float(),    # topocentric elevation
  azimuth_deg:       float(),    # topocentric azimuth, [0, 360)
  transmit_time:     NaiveDateTime.t(),  # t_tx
  los_unit:          {float(), float(), float()},  # receiver -> satellite, ECEF unit
  sat_pos_ecef_m:    {float(), float(), float()},  # Sagnac-rotated sat position
  sat_velocity_m_s:  {float(), float(), float()}   # Sagnac-rotated sat velocity
}

Summary

Types

Index-aligned emission-state and media-delay arrays.

One emission-media request, {satellite_id, emission_epoch_j2000_s}.

Functions

Predict emission-epoch satellite states and media delays in one batch call.

Predict the observables for satellite_id seen from receiver_ecef at epoch.

Predict observables for every satellite in the product, seen from receiver_ecef.

Predict observables for many {satellite_id, receiver_ecef, epoch} requests against one loaded SP3 product in a single NIF call.

Predict geometry-only ranges for many {satellite_id, receiver_ecef, t_rx_j2000_s} requests against one precise-ephemeris source in a single NIF call.

Types

emission_media_batch()

@type emission_media_batch() :: %{
  positions_ecef_m: [vec3() | nil],
  clocks_s: [float() | nil],
  ionosphere_slant_delays_m: [float() | nil],
  troposphere_delays_m: [float() | nil],
  statuses: [:valid | :gap | :below_elevation_cutoff | :error],
  element_errors: [term() | nil]
}

Index-aligned emission-state and media-delay arrays.

emission_media_request()

@type emission_media_request() :: {String.t(), number()}

One emission-media request, {satellite_id, emission_epoch_j2000_s}.

observables()

@type observables() :: %{
  geometric_range_m: float(),
  range_rate_m_s: float(),
  doppler_hz: float(),
  sat_clock_s: float() | nil,
  elevation_deg: float(),
  azimuth_deg: float(),
  transmit_time: NaiveDateTime.t(),
  los_unit: vec3(),
  sat_pos_ecef_m: vec3(),
  sat_velocity_m_s: vec3()
}

range_request()

@type range_request() :: {String.t(), vec3() | map(), number()}

range_result()

@type range_result() :: %{
  geometric_range_m: float(),
  sat_clock_s: float() | nil,
  transmit_time_j2000_s: float(),
  sat_pos_ecef_m: vec3()
}

vec3()

@type vec3() :: {float(), float(), float()}

Functions

emission_media_batch(source, requests, receiver_ecef, opts \\ [])

Predict emission-epoch satellite states and media delays in one batch call.

Each request is {satellite_id, emission_epoch_j2000_s}. The source is a parsed SP3 product, a sample-built precise source, or any Sidereon.GNSS.PreciseEphemeris.Interpolant, including one opened from artifact bytes. The output is a map of index-aligned arrays:

%{
  positions_ecef_m: [vec3() | nil],
  clocks_s: [float() | nil],
  ionosphere_slant_delays_m: [float() | nil],
  troposphere_delays_m: [float() | nil],
  statuses: [:valid | :gap | :below_elevation_cutoff | :error],
  element_errors: [term() | nil]
}

Options:

  • :carrier_hz - carrier frequency for ionospheric group delay, default GPS L1.
  • :troposphere - false, true, or a keyword list with :pressure_hpa, :temperature_k, and :relative_humidity.
  • :ionosphere - nil, {:klobuchar, alpha, beta}, {:klobuchar, %{alpha: alpha, beta: beta}}, or {:ionex, handle}.
  • :min_elevation_deg - optional minimum receiver elevation. Rows below the cutoff keep state and clock outputs but have nil media delays.

predict(source, satellite_id, receiver_ecef, epoch, opts \\ [])

@spec predict(
  Sidereon.GNSS.SP3.t() | Sidereon.GNSS.Broadcast.t(),
  String.t(),
  vec3() | map(),
  NaiveDateTime.t(),
  keyword()
) :: {:ok, observables()} | {:error, term()}

Predict the observables for satellite_id seen from receiver_ecef at epoch.

receiver_ecef is the static receiver position in ITRF/ECEF metres, given as {x_m, y_m, z_m} or %{x_m: _, y_m: _, z_m: _}. epoch is the receive epoch, a NaiveDateTime (interpreted in the ephemeris source's own time scale).

Options

  • :carrier_hz - carrier frequency for the Doppler, default the L1 carrier 1575.42 MHz.
  • :light_time - apply the light-time / transmit-time correction, default true. When false, the satellite is evaluated at epoch.
  • :sagnac - apply the Sagnac / Earth-rotation correction, default true.
  • :extrapolate - for SP3 sources, allow evaluation outside the parsed product coverage. Default false.

Returns {:ok, observables}, {:error, :invalid_receiver} for a malformed receiver position, or propagates any ephemeris position error (e.g. an unknown satellite or a malformed satellite token) verbatim as {:error, reason}. Never raises.

predict_all(sp3, receiver_ecef, epoch, opts \\ [])

@spec predict_all(Sidereon.GNSS.SP3.t(), vec3() | map(), NaiveDateTime.t(), keyword()) ::
  %{
    optional(String.t()) => {:ok, observables()} | {:error, term()}
  }

Predict observables for every satellite in the product, seen from receiver_ecef.

Returns a map satellite_id => {:ok, observables} | {:error, reason}, so one satellite failing (e.g. no estimate at this epoch) does not sink the batch. Options are the same as predict/5.

predict_batch(sp3, requests, opts \\ [])

@spec predict_batch(
  Sidereon.GNSS.SP3.t(),
  [{String.t(), vec3() | map(), NaiveDateTime.t()}],
  keyword()
) ::
  [ok: observables(), error: term()]

Predict observables for many {satellite_id, receiver_ecef, epoch} requests against one loaded SP3 product in a single NIF call.

Each request is fully independent (its own satellite, receiver, and epoch), so one batch can mix many satellites, receivers, and epochs. The result list is index-aligned with requests: element i is {:ok, observables} or {:error, reason} for requests[i], so one bad request does not sink the batch. The valid requests are predicted as a batch inside the core (one boundary crossing); options are the same as predict/5.

predict_ranges(source, requests, opts \\ [])

Predict geometry-only ranges for many {satellite_id, receiver_ecef, t_rx_j2000_s} requests against one precise-ephemeris source in a single NIF call.

source is a loaded Sidereon.GNSS.SP3 product, a Sidereon.GNSS.PreciseEphemeris sample-built source, or a Sidereon.GNSS.PreciseEphemeris.Interpolant cached source. Each request carries its own satellite token, static receiver ECEF position ({x_m, y_m, z_m} or %{x_m: _, y_m: _, z_m: _}), and receive epoch as seconds since J2000 in the source's own time scale.

This is the transmit-time geometry a range-only consumer needs, without the Doppler / topocentric fields of predict/5. On success returns {:ok, results} where each result is a map:

%{
  geometric_range_m:      float(),         # metres, after light-time + Sagnac
  sat_clock_s:            float() | nil,   # satellite clock at transmit time
  transmit_time_j2000_s:  float(),         # transmit epoch, seconds since J2000
  sat_pos_ecef_m:         {float(), float(), float()}  # Sagnac-transported sat position
}

The core range batch aborts on the first failing request, so a malformed request or an ephemeris error (unknown satellite, epoch out of coverage) returns {:error, reason} for the whole call. Never raises.

Options

  • :light_time - apply the light-time / transmit-time correction, default true. When false, the satellite is evaluated at the receive epoch.
  • :sagnac - apply the Sagnac / Earth-rotation correction, default true.