Sidereon.GNSS.Observables (Sidereon v3.0.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 the source's own where it defines one: for a broadcast source, the difference of the selected record's positions at t_tx and 1 ms later over 1 ms, as RTKLIB ephpos forms it. For an SP3 source it is the 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

Satellite clock terms

sat_clock_s is the clock the source states at t_tx. A broadcast clock is the one RTKLIB satposs returns: the clock polynomial and the relativistic term, without the broadcast group delay. Two further terms describe what a single-frequency positioning model does with that clock, as Sidereon.GNSS.Positioning.solve/4 applies them:

  • sat_clock_relativity_s - the relativistic term -2 r·v / c² a model adds to a product clock (SP3), as RTKLIB peph2pos forms it for positioning; :not_applicable for a clock that carries its own term (broadcast), and :unavailable where the term cannot be formed, within 1 ms of the end of the product's coverage.
  • single_frequency_group_delay_s - the broadcast group delay (GPS and QZSS TGD, Galileo BGD, BeiDou TGD1) a model subtracts from the clock for a single-frequency pseudorange, as RTKLIB prange does; nil for a precise clock.

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,  # source clock offset at transmit time
  sat_clock_relativity_s: float() | :not_applicable | :unavailable,
  single_frequency_group_delay_s: float() | nil,
  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

Structured core causes. mapping_function_declaration is Absent or Declared; mapping_function_kind is NoMapping, CosZ, QFactor, or Other when declared, and nil when absent.

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

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

Structured error detail returned by the additive detailed prediction API.

The transmit-time geometry of a pseudorange as the positioning models place it. See pseudorange_transmit_geometry/6.

Functions

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

Like emission_media_batch/4, with typed cause maps for each failed row in element_errors. The legacy API keeps its existing error representation.

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 a request batch with one structured result per original request.

Predict observables and retain the structured cause of native prediction errors.

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.

The transmit-time geometry of satellite_id's pseudorange pseudorange_m, time-tagged epoch by a receiver at receiver_ecef, placed as SPP, the static solve, DGNSS and PPP place it (RTKLIB satposs).

Types

core_cause_detail()

@type core_cause_detail() :: %{
  family: String.t(),
  kind: String.t(),
  message: String.t(),
  satellite: String.t() | nil,
  nodes: non_neg_integer() | nil,
  required: non_neg_integer() | nil,
  input_message: String.t() | nil,
  coverage_reason: String.t() | nil,
  earlier_missing_nodes: ionex_missing_nodes_detail() | nil,
  later_missing_nodes: ionex_missing_nodes_detail() | nil,
  refusal_kind: String.t() | nil,
  map_number: non_neg_integer() | nil,
  latitude_index: non_neg_integer() | nil,
  longitude_index: non_neg_integer() | nil,
  mapping_function_declaration: String.t() | nil,
  mapping_function_kind: String.t() | nil,
  mapping_function_code: String.t() | nil
}

Structured core causes. mapping_function_declaration is Absent or Declared; mapping_function_kind is NoMapping, CosZ, QFactor, or Other when declared, and nil when absent.

detailed_emission_media_batch()

@type detailed_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: [prediction_error() | nil]
}

elixir_input_cause_detail()

@type elixir_input_cause_detail() :: %{
  family: String.t(),
  kind: String.t(),
  message: String.t(),
  raw: term()
}

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

ionex_missing_nodes_detail()

@type ionex_missing_nodes_detail() :: %{
  map_number: non_neg_integer(),
  lat_index: non_neg_integer(),
  lon_index: non_neg_integer(),
  lon_index_next: non_neg_integer(),
  missing: [boolean()]
}

Structured error detail returned by the additive detailed prediction API.

observables()

@type observables() :: %{
  geometric_range_m: float(),
  range_rate_m_s: float(),
  doppler_hz: float(),
  sat_clock_s: float() | nil,
  sat_clock_relativity_s: float() | :not_applicable | :unavailable,
  single_frequency_group_delay_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()
}

prediction_error()

@type prediction_error() :: %{
  family: String.t(),
  kind: String.t(),
  message: String.t(),
  field: String.t() | nil,
  reason: term() | nil,
  input_kind: String.t() | nil,
  cause: core_cause_detail() | elixir_input_cause_detail() | nil
}

pseudorange_geometry()

@type pseudorange_geometry() :: %{
  transmit_time_j2000_s: float(),
  signal_flight_time_s: float(),
  transmit_offset_us: integer(),
  sat_clock_s: float() | nil,
  sat_clock_relativity_s: float() | :not_applicable | :unavailable,
  single_frequency_group_delay_s: float() | nil,
  geometric_range_m: float(),
  elevation_deg: float(),
  azimuth_deg: float(),
  sat_pos_ecef_m: vec3(),
  los_unit: vec3()
}

The transmit-time geometry of a pseudorange as the positioning models place it. See pseudorange_transmit_geometry/6.

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.

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

Like emission_media_batch/4, with typed cause maps for each failed row in element_errors. The legacy API keeps its existing error representation.

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_batch_detailed(sp3, requests, opts \\ [])

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

Predict a request batch with one structured result per original request.

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

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

Predict observables and retain the structured cause of native prediction errors.

The result uses the same success map as predict/5; failures from the core include the observable field and validation category or a nested core error detail map. The existing predict/5 return values are unchanged.

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.

pseudorange_transmit_geometry(source, satellite_id, receiver_ecef, epoch, pseudorange_m, opts \\ [])

The transmit-time geometry of satellite_id's pseudorange pseudorange_m, time-tagged epoch by a receiver at receiver_ecef, placed as SPP, the static solve, DGNSS and PPP place it (RTKLIB satposs).

The transmission epoch is t_rx - P / c less the satellite clock read there, from the record selected at the reception epoch, so the receiver clock offset the pseudorange carries places the satellite where the signal left it. The geometry is the source's state at that epoch, not rotated: the range is |r_s - r_r| plus the Sagnac term ω (x_s y_r - y_s x_r) / c (RTKLIB geodist), and the line of sight, elevation and azimuth are those of the unrotated vector. signal_flight_time_s is t_rx - t_tx, which holds both clock offsets with the flight time.

epoch is the receiver's time tag, a NaiveDateTime in the source's time scale, converted to seconds since J2000 as the positioning solves convert it. sat_clock_relativity_s and single_frequency_group_delay_s are the terms a single-frequency model applies to sat_clock_s at the transmission epoch, as in predict/5.

source is an SP3 product, a broadcast store, a precise-sample source or a precise interpolant. pseudorange_m has to be a positive finite distance.

Options

  • :sagnac - add the Sagnac term, default true.