GNSS single-point positioning (SPP): recover a receiver position, clock bias, and geometry diagnostics from one epoch of pseudorange observations against a precise SP3 ephemeris or a broadcast navigation product.
This is the Elixir surface over the astrodynamics-gnss SPP solver. Given an
ephemeris source, an Sidereon.GNSS.SP3 product or an Sidereon.GNSS.Broadcast
handle (GPS / Galileo / BeiDou / GLONASS), a set of single-frequency
pseudoranges, the receive epoch, and the broadcast/atmosphere parameters, it
runs the transmit-time iteration and trust-region least-squares solve in the
crate and returns an Sidereon.GNSS.Positioning.Solution. A mixed-constellation
set is solved together with one receiver clock per system. No positioning math
lives on the Elixir side; this module marshals units and epoch arguments and
decodes the result.
Units at the boundary
- pseudoranges and the initial guess position/clock are meters;
- the recovered
positionis ITRF/IGS ECEF meters, matching the SP3 frame; geodeticlatitude/longitude are radians and height is meters;rx_clock_sis seconds;- pressure is hPa, temperature is kelvin, relative humidity is a
fraction in
[0, 1]; - the Klobuchar
alpha/betacoefficients are passed in their broadcast units.
The epoch is interpreted in the SP3 product's own time scale (typically GPST);
no leap-second shifting is applied. The seconds-since-J2000, second-of-day, and
fractional day-of-year arguments the crate needs are derived from the supplied
epoch via Sidereon.GNSS.Time.
Example
{:ok, sp3} = Sidereon.GNSS.SP3.load("igs.sp3")
observations = [{"G01", 2.41e7}, {"G02", 2.49e7}, {"G05", 2.05e7}, {"G07", 2.30e7}]
{:ok, solution} =
Sidereon.GNSS.Positioning.solve(sp3, observations, ~N[2020-06-24 12:00:00],
ionosphere: true,
troposphere: true,
klobuchar_alpha: {1.0e-8, 2.2e-8, -6.0e-8, -1.2e-7},
klobuchar_beta: {96_256.0, 131_072.0, -65_536.0, -589_824.0}
)
solution.position.x_m
solution.rx_clock_s
Summary
Types
An epoch as a NaiveDateTime or {{y, m, d}, {h, min, s}} tuple.
A {satellite_id, pseudorange_m} pseudorange observation.
Functions
Solve single-point positioning for one receive epoch.
Solve single-point positioning from broadcast ephemeris ALONE.
Solve preferring precise SP3 products, falling back to broadcast ephemeris, reporting which source produced the fix and how stale it is.
Types
@type epoch() :: NaiveDateTime.t() | tuple()
An epoch as a NaiveDateTime or {{y, m, d}, {h, min, s}} tuple.
A {satellite_id, pseudorange_m} pseudorange observation.
Functions
@spec solve( Sidereon.GNSS.SP3.t() | Sidereon.GNSS.Broadcast.t(), [observation()], epoch(), keyword() ) :: {:ok, Sidereon.GNSS.Positioning.Solution.t()} | {:error, term()}
Solve single-point positioning for one receive epoch.
source is a loaded ephemeris product, an Sidereon.GNSS.SP3 precise product or an
Sidereon.GNSS.Broadcast broadcast-navigation product. observations is a
list of {satellite_id, pseudorange_m} pairs (ids like "G01", pseudoranges
in meters), and epoch is a NaiveDateTime or {{y, m, d}, {h, min, s}}
tuple in the product's time scale.
Options
:ionosphere- apply the broadcast Klobuchar ionosphere correction (defaultfalse); the L1 delay is scaled to each satellite's carrier by(f_L1/f)^2, covering GPS L1, Galileo E1, and BeiDou B1I. A GLONASS satellite's FDMA carrier is resolved per satellite from:glonass_channels; a GLONASS observation with the ionosphere requested but no (or out-of-range) channel is rejected with{:ionosphere_unsupported, sat}.:glonass_channels- the GLONASS FDMA channel map%{slot => channel}(default%{}), whereslotis the GLONASS satellite slot/PRN andchannelis its FDMA frequency channelk(valid[-7, +6]), as carried in the broadcast navfreq_channelfield or a RINEXGLONASS SLOT / FRQ #header. Used only to resolve the GLONASS carrier for the(f_L1/f_k)^2ionosphere scaling, so it matters only when:ionosphereistrueand the set contains GLONASS observations; an empty map leaves every other constellation bit-identical. A value that is not a%{integer => integer}map returns{:error, {:invalid_option, :glonass_channels}}.:troposphere- apply the Saastamoinen/Niell troposphere correction (defaultfalse):klobuchar_alpha- broadcast alpha coefficients, 4-tuple (default zeros):klobuchar_beta- broadcast beta coefficients, 4-tuple (default zeros):pressure_hpa- surface pressure, hPa (default1013.25):temperature_k- surface temperature, kelvin (default288.15):relative_humidity- relative humidity fraction[0, 1](default0.5):initial_guess-{x_m, y_m, z_m, b_m}start point (default all zeros):with_geodetic- also return the geodetic position (defaulttrue):max_pdop- optional positive PDOP ceiling. When set, a fix whose geometry is rank-deficient or whose PDOP exceeds the ceiling is refused with{:error, {:degenerate_geometry, pdop}}(a non-positive value is{:error, {:invalid_option, :max_pdop}}); default unset.
Robustness (opt-in, default off)
:robust- whentrue(defaultfalse), route the solve through theSidereon.GNSS.QCleave-one-out fault-detection-and-exclusion (FDE) loop: a chi-square RAIM test on the post-fit residuals flags an inconsistent set, the satellite with the largest normalized residual is excluded, and the position is re-solved, repeating until the set is self-consistent or the geometry runs out of redundancy. A clean set excludes nothing; the cleaned re-solve still runs through every integrity gate above, so:robustcomposes with the rank,:max_pdop, plausibility, and convergence refusals. The returnedSolutioncarries the FDE summary atsolution.metadata.fdeas%{excluded: [{sat, :raim_excluded}], iterations: n}. If the loop hits its iteration cap (or runs out of satellites) while RAIM still flags a fault, the set could not be made self-consistent and the solve returns{:error, {:fault_unresolved, statistic}}rather than a still-faulted fix.:weights- RAIM detection weights for the:robusttest, a%{sat => inverse_variance_weight}map (1 / sigma_i^2) as built bySidereon.GNSS.QC.weight_vector/2from per-satellite elevation and/or C/N0. When given, it must carry a positive weight for every observed satellite; a partial map (missing an observed satellite) is rejected with{:error, {:robust_requires_noise_model, :incomplete_weights}}, and a non-positive, non-finite, or absurdly large observed weight with{:error, {:invalid_option, :weights}}, rather than silently falling back to unit weight or overflowing the detection statistic. A realistic noise model is REQUIRED for:robust: with unit weights the RAIM test reads ordinary code noise (several metres on a phone) as faults and over-excludes, degrading the fix.:robusttherefore refuses to run without a noise basis::robust truewith no:weightsand no:unsafe_unit_weightsreturns{:error, {:robust_requires_noise_model, :no_weights}}before any solve. Ignored when:robustis not set.:unsafe_unit_weights- explicit escape hatch (defaultfalse). Set totrueto run:robustFDE with unit weights (sigma = 1 m assumed), the only route to unit-weight FDE. Named to be self-documenting and grep-able because unit-weight FDE is the harmful mode on noisy real data; it is appropriate only when the measurements are genuinely sigma-1 clean (for example a synthetic oracle). Ignored when:robustis not set.:p_fa- false-alarm probability for the:robustRAIM test (default1.0e-3); malformed values return{:error, {:invalid_option, :p_fa}}. Ignored when:robustis not set. SeeSidereon.GNSS.QC.raim/2.:max_iterations- maximum FDE exclusions before returning{:error, {:fault_unresolved, statistic}}(defaultmax(n_obs - 4, 0)); malformed values return{:error, {:invalid_option, :max_iterations}}.:coarse_search- cold-start convergence-basin widening for a degraded or absent position prior (defaultnil= off, exact single solve from:initial_guess). The crate freezes its elevation mask and weights at the seed geometry, so a seed far from the true surface point (the{0,0,0,0}earth-center default, an antipodal last-known fix) either starves on the horizon or is refused by the integrity gates. When set, the solve runs once from each of a deterministic golden-spiral lattice of near-surface seeds (plus the caller's:initial_guess), routes every per-seed candidate through the same integrity gates, and selects the best redundant (redundancy >= 1) converged fix, so no hardcoded prior is needed. Acceptstrue(the default seed count), a positive integer seed count, or a keyword list[seeds: n]. Each seed is one extra crate solve, so the cost scales with the seed count; leave it off on the hot path where a good prior is known. Composes with the integrity gates (including:max_pdop) per candidate, but is mutually exclusive with:robust: setting both returns{:error, {:incompatible_options, [:coarse_search, :robust]}}. A non-positive or non-integer value returns{:error, {:invalid_option, :coarse_search}}.:huber- whentrue(defaultfalse), apply opt-in crate-layer Huber/IRLS robust reweighting: the per-satellite weight is recomputed each outer iteration from the post-fit residual (down-weighting large residuals) rather than the FDE all-or-nothing exclusion of:robust. On cheap single-frequency phone arcs this improves the fix (see huber-irls-measurement-2026-06.md). Mutually exclusive with:robust({:error, {:incompatible_options, [:robust, :huber]}}). Tuning, used only when:huberis set::huber_k(Huber constant, default1.345),:huber_sigma(MAD scale floor in metres, default5.0, sized to metre-class single-frequency noise),:huber_max_iter(outer-loop cap, default5). A malformed:huber/:huber_k/:huber_sigma/:huber_max_itervalue returns{:error, {:invalid_option, key}}before any solve.
Regardless of options, a fix that did not converge to a physical receiver
position is refused rather than returned: one whose geocentric radius is
outside the plausible band (for example from the earth-center default seed, or
a wrong-root least-squares fix) gives {:error, {:implausible_position, radius_m}},
and a converged-flagged fix whose post-fit residual RMS is physically
implausible gives {:error, {:no_convergence, rms_m}}.
A mixed GPS+Galileo+BeiDou+GLONASS observation set is solved together with one receiver clock per GNSS (an inter-system bias is the difference between a system's clock and the reference system's), and dilution of precision is reported for the combined geometry as well.
Returns {:ok, %Sidereon.GNSS.Positioning.Solution{}} or {:error, reason},
where reason is one of {:too_few_satellites, used, required} (required is
3 + n_systems), :singular_geometry, {:duplicate_observation, sat},
{:ephemeris_lost, sat}, {:ionosphere_unsupported, sat} (the ionosphere
correction was requested for a system with no modeled single-frequency
carrier), {:degenerate_geometry, reason} (the geometry is rank-deficient, so
reason is :rank_deficient, or exceeds the optional :max_pdop ceiling, so
reason is the PDOP), {:implausible_position, radius_m} (the fix is outside
the plausible geocentric-radius band), {:no_convergence, rms_m} (a
converged-flagged fix with physically implausible post-fit residual RMS),
{:invalid_option, :max_pdop}, {:invalid_option, :robust},
{:invalid_option, :coarse_search},
{:incompatible_options, [:coarse_search, :robust]}, or, for :robust,
{:robust_requires_noise_model, :no_weights} / {:robust_requires_noise_model, :incomplete_weights}
(robust was requested with no realistic noise model, or a weights map that does
not cover every observed satellite with a positive weight, and no explicit
unsafe opt-in), {:invalid_option, :p_fa}, {:invalid_option, :weights},
{:invalid_option, :max_iterations}, or {:fault_unresolved, statistic} (the
FDE loop exhausted its iterations with a fault still flagged). For :huber:
{:incompatible_options, [:robust, :huber]}, or
{:invalid_option, key} for a malformed :huber, :huber_k, :huber_sigma,
or :huber_max_iter.
@spec solve_broadcast( Sidereon.GNSS.Broadcast.t(), [observation()], epoch(), keyword() ) :: {:ok, Sidereon.GNSS.Positioning.Solution.t()} | {:error, term()}
Solve single-point positioning from broadcast ephemeris ALONE.
The explicit, named broadcast-only entry point: the supported real-time /
offline mode, where the satellite states come from a parsed broadcast
navigation product (Sidereon.GNSS.Broadcast) rather than a precise SP3
product. Broadcast ephemeris is decoded from the navigation message a receiver
already tracks, so it needs no network.
This is a thin wrapper over solve/4 with a Sidereon.GNSS.Broadcast source,
bit-for-bit identical to calling solve/4 directly; it makes the
broadcast-only contract explicit in the call site. observations, epoch, and
opts are exactly as in solve/4. Returns
{:ok, %Sidereon.GNSS.Positioning.Solution{}} or {:error, reason}.
@spec solve_with_fallback( [Sidereon.GNSS.SP3.t()], Sidereon.GNSS.Broadcast.t(), [observation()], epoch(), keyword() ) :: {:ok, Sidereon.GNSS.Positioning.SourcedSolution.t()} | {:error, term()}
Solve preferring precise SP3 products, falling back to broadcast ephemeris, reporting which source produced the fix and how stale it is.
The precise path is tried first through the product-staleness selection layer
(Sidereon.GNSS.Staleness) at the receive epoch:
- if a precise product covers the epoch it is used, and the result's source is
{:precise, metadata}with:exact(zero staleness) metadata. The solve is bit-for-bit identical tosolve/4on that SP3, and a solve failure here is a genuine error returned as{:error, {:precise, reason}}, never masked by a silent broadcast re-solve; - if a stale-but-within-cap precise product is selected and produces a fix,
the source is
{:precise, metadata}with:nearest_prior(nonzero staleness) metadata; - if the selected stale product cannot serve the epoch, or the precise
selection is declined outright, broadcast produces the fix and the source is
{:broadcast, reason}recording why precise was not used.
precise is a list of Sidereon.GNSS.SP3 products (it may be empty, forcing
broadcast); broadcast is a Sidereon.GNSS.Broadcast product.
Options
:policy- aSidereon.GNSS.Staleness.Policybounding how stale a precise product may be before broadcast is preferred (default: a three-day cap). A zero-second cap forces broadcast whenever no product covers the exact epoch.
The remaining options match solve/4: :ionosphere, :troposphere,
:klobuchar_alpha, :klobuchar_beta, :pressure_hpa, :temperature_k,
:relative_humidity, :initial_guess, :with_geodetic, and
:glonass_channels. The opt-in robustness levers (:robust, :huber,
:coarse_search, :strategy) are not part of the fallback entry, which uses
the reference solve on both paths. The broadcast fallback solve applies the
broadcast NAV header's BeiDou and Galileo ionosphere coefficients, exactly as
solve_broadcast/4.
Returns {:ok, %Sidereon.GNSS.Positioning.SourcedSolution{}} or
{:error, {:precise, reason}} / {:error, {:broadcast, reason}} where reason
is the SPP solve error from the path that failed.