Orbis.GNSS.Positioning (Orbis v0.32.0)

Copy Markdown View Source

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 position is ITRF/IGS ECEF meters, matching the SP3 frame;
  • geodetic latitude/longitude are radians and height is meters;
  • rx_clock_s is seconds;
  • pressure is hPa, temperature is kelvin, relative humidity is a fraction in [0, 1];
  • the Klobuchar alpha/beta coefficients 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

epoch()

@type epoch() :: NaiveDateTime.t() | tuple()

An epoch as a NaiveDateTime or {{y, m, d}, {h, min, s}} tuple.

observation()

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

A {satellite_id, pseudorange_m} pseudorange observation.

Functions

solve(source, observations, epoch, opts \\ [])

@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 (default false); 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 (default false)
  • :klobuchar_alpha - broadcast alpha coefficients, 4-tuple (default zeros)
  • :klobuchar_beta - broadcast beta coefficients, 4-tuple (default zeros)
  • :pressure_hpa - surface pressure, hPa (default 1013.25)
  • :temperature_k - surface temperature, kelvin (default 288.15)
  • :relative_humidity - relative humidity fraction [0, 1] (default 0.5)
  • :initial_guess - {x_m, y_m, z_m, b_m} start point (default all zeros)
  • :with_geodetic - also return the geodetic position (default true)
  • :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 - when true (default false), route the solve through the Orbis.GNSS.QC leave-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 :robust composes with the rank, :max_pdop, plausibility, and convergence refusals. The returned Solution carries the FDE summary at solution.metadata.fde as %{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 :robust test, a %{sat => inverse_variance_weight} map (1 / sigma_i^2) as built by Orbis.GNSS.QC.weight_vector/2 from 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. :robust therefore refuses to run without a noise basis: :robust true with no :weights and no :unsafe_unit_weights returns {:error, {:robust_requires_noise_model, :no_weights}} before any solve. Ignored when :robust is not set.
  • :unsafe_unit_weights - explicit escape hatch (default false). Set to true to run :robust FDE 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 :robust is not set.
  • :p_fa - false-alarm probability for the :robust RAIM test (default 1.0e-3); ignored when :robust is not set. See Orbis.GNSS.QC.raim/2.
  • :coarse_search - cold-start convergence-basin widening for a degraded or absent position prior (default nil = 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. Accepts true (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 - when true (default false), 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 :huber is set: :huber_k (Huber constant, default 1.345), :huber_sigma (MAD scale floor in metres, default 5.0, sized to metre-class single-frequency noise), :huber_max_iter (outer-loop cap, default 5). A malformed :huber/:huber_k/:huber_sigma/:huber_max_iter value 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.