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_rxleft the satellite earlier, att_tx = t_rx - |r_sat(t_tx) - r_rx| / c. This is solved by fixed-point iteration starting fromt_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 epocht_tx(the SP3 spline is sampled at sub-second precision).Sagnac / Earth-rotation correction. During the travel time
tauthe Earth-fixed (ECEF) frame rotates byomega_e * tau. The satellite position computed in the ECEF frame att_txis rotated about the Z axis byRz(omega_e * tau)into the receive-epoch ECEF frame before differencing, withomega_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_txis the source's own where it defines one: for a broadcast source, the difference of the selected record's positions att_txand 1 ms later over 1 ms, as RTKLIBephposforms it. For an SP3 source it is the central finite difference ofSidereon.GNSS.SP3.position/3(+/- 0.5 s). For a static receiver (v_rx = 0) the range rate is the LOS projectionlos . (v_sat - v_rx), which equalsd(range)/dt.Doppler (IS-GPS-200 L1 carrier).
doppler_hz = -range_rate * f / cwith the L1 carrierf = 1575.42 MHzandc = 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 RTKLIBpeph2posforms it for positioning;:not_applicablefor a clock that carries its own term (broadcast), and:unavailablewhere 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 RTKLIBprangedoes;nilfor 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
@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.
@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] }
@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.
One emission-media request, {satellite_id, emission_epoch_j2000_s}.
@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.
@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() }
@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 }
@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.
Functions
@spec emission_media_batch( Sidereon.GNSS.SP3.t() | Sidereon.GNSS.PreciseEphemeris.t() | Sidereon.GNSS.PreciseEphemeris.Interpolant.t() | Sidereon.GNSS.PreciseEphemeris.InterpolantArtifact.t(), [emission_media_request()], vec3() | map(), keyword() ) :: {:ok, emission_media_batch()} | {:error, term()}
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.
@spec emission_media_batch_detailed( Sidereon.GNSS.SP3.t() | Sidereon.GNSS.PreciseEphemeris.t() | Sidereon.GNSS.PreciseEphemeris.Interpolant.t() | Sidereon.GNSS.PreciseEphemeris.InterpolantArtifact.t(), [emission_media_request()], vec3() | map(), keyword() ) :: {:ok, detailed_emission_media_batch()} | {:error, term()}
Like emission_media_batch/4, with typed cause maps for each failed row in
element_errors. The legacy API keeps its existing error representation.
@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 carrier1575.42 MHz.:light_time- apply the light-time / transmit-time correction, defaulttrue. Whenfalse, the satellite is evaluated atepoch.:sagnac- apply the Sagnac / Earth-rotation correction, defaulttrue.:extrapolate- for SP3 sources, allow evaluation outside the parsed product coverage. Defaultfalse.
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.
@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.
@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.
@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.
@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.
@spec predict_ranges( Sidereon.GNSS.SP3.t() | Sidereon.GNSS.PreciseEphemeris.t() | Sidereon.GNSS.PreciseEphemeris.Interpolant.t() | Sidereon.GNSS.PreciseEphemeris.InterpolantArtifact.t(), [range_request()], keyword() ) :: {:ok, [range_result()]} | {:error, term()}
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, defaulttrue. Whenfalse, the satellite is evaluated at the receive epoch.:sagnac- apply the Sagnac / Earth-rotation correction, defaulttrue.
@spec pseudorange_transmit_geometry( Sidereon.GNSS.SP3.t() | Sidereon.GNSS.Broadcast.t() | Sidereon.GNSS.PreciseEphemeris.t() | Sidereon.GNSS.PreciseEphemeris.Interpolant.t() | Sidereon.GNSS.PreciseEphemeris.InterpolantArtifact.t(), String.t(), Sidereon.GNSS.Core.Types.ecef_input(), NaiveDateTime.t(), number(), keyword() ) :: {:ok, pseudorange_geometry()} | {:error, term()}
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, defaulttrue.