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 Orbis.GNSS.SP3 product or an Orbis.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 Orbis.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 Orbis.GNSS.Time.
Example
{:ok, sp3} = Orbis.GNSS.SP3.load("igs.sp3")
observations = [{"G01", 2.41e7}, {"G02", 2.49e7}, {"G05", 2.05e7}, {"G07", 2.30e7}]
{:ok, solution} =
Orbis.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.
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( Orbis.GNSS.SP3.t() | Orbis.GNSS.Broadcast.t(), [observation()], epoch(), keyword() ) :: {:ok, Orbis.GNSS.Positioning.Solution.t()} | {:error, term()}
Solve single-point positioning for one receive epoch.
source is a loaded ephemeris product — an Orbis.GNSS.SP3 precise product or an
Orbis.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 system with no modeled single-frequency carrier, e.g. GLONASS, is rejected):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 theOrbis.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 byOrbis.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); ignored when:robustis not set. SeeOrbis.GNSS.QC.raim/2.: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, %Orbis.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}, 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.