Sidereon.GNSS.Positioning (Sidereon v0.8.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 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 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 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

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 \\ [])

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 (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 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 %{}), where slot is the GLONASS satellite slot/PRN and channel is its FDMA frequency channel k (valid [-7, +6]), as carried in the broadcast nav freq_channel field or a RINEX GLONASS SLOT / FRQ # header. Used only to resolve the GLONASS carrier for the (f_L1/f_k)^2 ionosphere scaling, so it matters only when :ionosphere is true and 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 (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 Sidereon.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 Sidereon.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); malformed values return {:error, {:invalid_option, :p_fa}}. Ignored when :robust is not set. See Sidereon.GNSS.QC.raim/2.
  • :max_iterations - maximum FDE exclusions before returning {:error, {:fault_unresolved, statistic}} (default max(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 (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, %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.

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

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

solve_with_fallback(precise, broadcast, observations, epoch, opts \\ [])

@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 to solve/4 on 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 - a Sidereon.GNSS.Staleness.Policy bounding 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.