Orbis.GNSS.RTK (Orbis v0.28.0)

Copy Markdown View Source

RTK-facing carrier/code double-difference primitives.

A base receiver and a rover receiver observing the same satellites have receiver-clock terms that differ by station but are common to every satellite. A single difference subtracts base from rover for the same satellite; a double difference subtracts a reference satellite's single difference:

DD_s = (rover_s - base_s) - (rover_ref - base_ref)

The receiver clocks cancel in the second subtraction. Satellite-clock, ephemeris, and short-baseline atmosphere errors that are common between base and rover also cancel in the receiver single difference. The remaining carrier-phase double differences are the measurement surface used by RTK baseline estimation and integer ambiguity fixing.

double_differences/3 returns the normalized measurements. The float solver, solve_float_baseline_epochs/3, estimates one static base-to-rover baseline from code and carrier-phase double differences, keeping one float carrier ambiguity per non-reference double-difference arc across the data. Clean arcs use the physical satellite id (for example "G05") as the ambiguity id; split arcs use explicit ids so a cycle slip resets the ambiguity without pretending the satellite disappeared. solve_fixed_baseline_epochs/3 adds bounded integer least-squares ambiguity fixing on top of the same correlated double-difference covariance and re-solves the baseline with the selected integers held fixed.

Example

iex> base = [
...>   {"G01", 20_100.0, 20_103.0},
...>   {"G02", 21_105.0, 21_110.0}
...> ]
iex> rover = [
...>   {"G01", 20_040.0, 20_044.0},
...>   {"G02", 21_060.0, 21_066.0}
...> ]
iex> {:ok, result} = Orbis.GNSS.RTK.double_differences(base, rover, reference_satellite_id: "G01")
iex> result.double_differences
[%{satellite_id: "G02", reference_satellite_id: "G01", ambiguity_id: "G02", code_m: 15.0, phase_m: 17.0}]

Summary

Types

One RTK epoch carrying paired base/rover observations and satellite positions.

One non-reference satellite's double-difference observation.

One RTK epoch carrying raw dual-frequency base/rover observations.

Raw dual-frequency code/carrier observation for wide-lane/narrow-lane RTK.

ECEF position in metres.

Code and carrier-phase observation in metres.

Double-difference result with deterministic satellite ordering.

Satellite ECEF position keyed by satellite id.

Functions

Build code and carrier-phase double differences from base and rover observations.

Run a sequential static RTK baseline filter with per-epoch ambiguity fixing.

Solve a static RTK baseline with integer-fixed double-difference ambiguities.

Solve a static float RTK baseline from multi-epoch double differences.

Run a dual-frequency (L1/L2) sequential per-epoch fix-and-hold RTK filter.

Solve a static RTK baseline from raw dual-frequency observations by fixing wide-lane then narrow-lane double-difference ambiguities.

Types

baseline_epoch()

@type baseline_epoch() :: %{
  :base_observations => [observation()],
  :rover_observations => [observation()],
  :satellite_positions_m => satellite_positions(),
  optional(:base_satellite_positions_m) => satellite_positions(),
  optional(:rover_satellite_positions_m) => satellite_positions(),
  optional(:velocity_mps) => ecef_input(),
  optional(:epoch) => term()
}

One RTK epoch carrying paired base/rover observations and satellite positions.

:epoch is preserved in residual diagnostics; it is not interpreted by this first solver layer because satellite positions are supplied by the caller. :satellite_positions_m is used for satellite selection and elevation weighting. When the caller has receiver-specific transmit-time positions, it may also provide :base_satellite_positions_m and :rover_satellite_positions_m; otherwise both default to :satellite_positions_m.

double_difference()

@type double_difference() :: %{
  satellite_id: String.t(),
  reference_satellite_id: String.t(),
  ambiguity_id: String.t(),
  code_m: float(),
  phase_m: float()
}

One non-reference satellite's double-difference observation.

dual_frequency_baseline_epoch()

@type dual_frequency_baseline_epoch() :: %{
  :base_observations => [dual_frequency_observation()],
  :rover_observations => [dual_frequency_observation()],
  :satellite_positions_m => satellite_positions(),
  optional(:base_satellite_positions_m) => satellite_positions(),
  optional(:rover_satellite_positions_m) => satellite_positions(),
  optional(:epoch) => term()
}

One RTK epoch carrying raw dual-frequency base/rover observations.

dual_frequency_observation()

@type dual_frequency_observation() :: %{
  :satellite_id => String.t(),
  :p1_m => number(),
  :p2_m => number(),
  :phi1_cyc => number(),
  :phi2_cyc => number(),
  :f1_hz => number(),
  :f2_hz => number(),
  optional(:ambiguity_id) => String.t(),
  optional(:lli1) => integer() | nil,
  optional(:lli2) => integer() | nil
}

Raw dual-frequency code/carrier observation for wide-lane/narrow-lane RTK.

p1_m / p2_m are code pseudoranges in metres, phi1_cyc / phi2_cyc are carrier phases in cycles, and f1_hz / f2_hz are the corresponding carrier frequencies. :ambiguity_id is normally omitted; the wide-lane solver sets it internally when :on_cycle_slip is :split_arc.

ecef_input()

@type ecef_input() ::
  {number(), number(), number()}
  | %{x_m: number(), y_m: number(), z_m: number()}

ECEF position in metres.

observation()

@type observation() ::
  %{
    :satellite_id => String.t(),
    :code_m => number(),
    :phase_m => number(),
    optional(:ambiguity_id) => String.t(),
    optional(:lli) => integer() | nil,
    optional(:loss_of_lock_indicator) => integer() | nil
  }
  | {String.t(), number(), number()}

Code and carrier-phase observation in metres.

Map observations may optionally carry :ambiguity_id to identify a carrier arc and :lli (or :loss_of_lock_indicator) for single-frequency loss-of-lock handling. Tuple observations use the satellite id as the ambiguity id and have no LLI.

result()

@type result() :: %{
  reference_satellite_id: String.t(),
  double_differences: [double_difference()],
  dropped_sats: [String.t()]
}

Double-difference result with deterministic satellite ordering.

satellite_positions()

@type satellite_positions() :: %{required(String.t()) => ecef_input()}

Satellite ECEF position keyed by satellite id.

Functions

double_differences(base_observations, rover_observations, opts \\ [])

@spec double_differences([observation()], [observation()], keyword()) ::
  {:ok, result()} | {:error, term()}

Build code and carrier-phase double differences from base and rover observations.

Observations can be maps with :satellite_id, :code_m, and :phase_m, or {satellite_id, code_m, phase_m} tuples. Satellites are paired by id; any satellite not present at both receivers is reported in :dropped_sats.

Options:

  • :reference_satellite_id - reference satellite for the second difference: a satellite id binary (single-system data only) or a per-system map covering every observed system. When omitted, each system's lexicographically first common satellite is selected deterministically. Non-reference satellites difference against their own system's reference.

Returns {:ok, result} or a tagged error. At least two common satellites are required so one non-reference double difference can be produced.

solve_filter_baseline_epochs(base_position, epochs, opts \\ [])

@spec solve_filter_baseline_epochs(ecef_input(), [baseline_epoch()], keyword()) ::
  {:ok, Orbis.GNSS.RTK.FilterBaselineSolution.t()} | {:error, term()}

Run a sequential static RTK baseline filter with per-epoch ambiguity fixing.

This is the RTKLIB-style real-time path: it carries one static baseline and one single-difference ambiguity state per satellite arc across epochs, performs a correlated double-difference measurement update at each epoch, attempts integer fixing from the posterior covariance of the corresponding double-difference ambiguity combinations, and holds accepted integers as tight pseudo-measurements on those combinations in later epochs.

Options are the fixed-baseline options plus the filter parameters below. :partial_ambiguity_resolution is deliberately rejected for this entry point: the sequential filter only holds a full-set fix until partial sequential AR has post-fix validation against real data.

  • :baseline_prior_sigma_m - initial baseline prior sigma in metres (default 100.0).
  • :ambiguity_prior_sigma_m - initial ambiguity prior sigma in metres (default 1.0e3).
  • :hold_sigma_m - pseudo-measurement sigma for fixed ambiguity holds (default 0.0001).
  • :dynamics_model - :constant_position (default) keeps the carried baseline mean fixed between epochs. :velocity_propagated advances the prediction mean by each epoch's optional :velocity_mps times elapsed seconds; process-noise meaning is unchanged.
  • :ar_arming_sigma_m - optional convergence arming gate. When set, the per-epoch integer search is attempted only after the baseline-block posterior standard deviation (sqrt of the trace of the 3x3 position covariance) has converged to at most this value; below it the epoch stays float for the unfixed arcs. This stops premature commitment on poor-early-geometry arcs (for example PASA/SCOA L1, where it converts a confident-wrong fixed population to the oracle class). The gate is OPT-IN by design and defaults to nil (always armed). It is deliberately not default-on: the proxy keys on formal covariance convergence, not on truth accuracy, so a wavelength-tied default suppresses dozens of correct early fixes on clean, fast-converging arcs whose baseline is already truth-accurate from epoch 0. Measured on the Wettzell static and kinematic GPS L1 arcs (arming-default-measurement-2026-06.md), a quarter to half L1 wavelength default pushes first-fix from epoch 0 to 42 and drops fixed epochs from 120/120 to 78/120 (static) with no accuracy gain, so a single global default cannot serve both arc classes. Set this explicitly on arcs that need the protection.
  • :innovation_screen_sigma - optional predicted-residual screen in the Rust kernel. When set, epoch rows with abs(innovation * weight) above this value are rejected before the measurement update.
  • :innovation_screen_min_rows - minimum accepted row count for the innovation screen. If fewer rows survive, the epoch coasts on the predicted state (default 8).
  • :filter_kernel - :rust (default) or :elixir — the Elixir reference implementation, bit-identical to the kernel; every kernel capability is gated by === trace tests against it. The kernel carries the per-system references and honors :float_only_systems.

Returns {:ok, %FilterBaselineSolution{}} or a tagged error.

solve_fixed_baseline_epochs(base_position, epochs, opts \\ [])

@spec solve_fixed_baseline_epochs(ecef_input(), [baseline_epoch()], keyword()) ::
  {:ok, Orbis.GNSS.RTK.FixedBaselineSolution.t()} | {:error, term()}

Solve a static RTK baseline with integer-fixed double-difference ambiguities.

The function first runs solve_float_baseline_epochs/3, converts the float double-difference ambiguities from metres to cycles using :ambiguity_wavelength_m, runs the shared bounded integer least-squares search with the correlated float ambiguity covariance, and then re-solves the baseline with the selected integer ambiguities held fixed.

Required option:

  • :ambiguity_wavelength_m - either a positive scalar wavelength in metres for every non-reference satellite, or a map %{"G05" => wavelength_m, ...}.
  • :ambiguity_offset_m - optional fixed ambiguity offset in metres, either a scalar or a map keyed by ambiguity id / physical satellite id. The fixed carrier ambiguity model is offset_m + integer * wavelength_m. Defaults to zero and is useful for dual-frequency wide-lane/narrow-lane workflows where the wide-lane integer contributes a known ionosphere-free offset.

Integer search options mirror Orbis.GNSS.PrecisePositioning:

  • :integer_search_radius_cycles - default 1.
  • :integer_ratio_threshold - default 3.0.
  • :integer_candidate_limit - default 200000.
  • :partial_ambiguity_resolution - when true, a rejected full-set integer fix is followed by confidence-ranked subset searches. A passing subset is held fixed while the remaining ambiguities stay in the re-solve as float states (default false).
  • :partial_min_ambiguities - minimum subset size for partial ambiguity resolution (default 4).
  • :float_only_systems - list of constellation letters (for example ["R"]) whose double-difference ambiguities are never entered into the integer search; they contribute float measurement rows only. GLONASS is the canonical use: FDMA inter-channel biases break the clean DD integer assumption (default []).
  • :residual_threshold_sigma - optional normalized-residual gate. When set, the float solve is checked before integer search; the worst offending satellite is excluded and the solve retried up to :max_residual_exclusions.
  • :max_residual_exclusions - maximum satellites the residual gate may exclude (default 1 when the residual gate is enabled).

The fixed solution is returned even when the ratio test fails; in that case metadata.integer_status is :not_fixed.

solve_float_baseline_epochs(base_position, epochs, opts \\ [])

@spec solve_float_baseline_epochs(ecef_input(), [baseline_epoch()], keyword()) ::
  {:ok, Orbis.GNSS.RTK.FloatBaselineSolution.t()} | {:error, term()}

Solve a static float RTK baseline from multi-epoch double differences.

base_position is the surveyed base ECEF position. Each epoch supplies base and rover code/carrier observations plus satellite ECEF positions at that receive epoch:

epoch = %{
  epoch: ~N[2026-01-01 00:00:00],
  satellite_positions_m: %{"G01" => {21.0e6, 14.0e6, 20.0e6}, ...},
  # Optional when base/rover transmit-time satellite positions differ:
  base_satellite_positions_m: %{"G01" => {21.0e6, 14.0e6, 20.0e6}, ...},
  rover_satellite_positions_m: %{"G01" => {21.0e6, 14.0e6, 20.0e6}, ...},
  base_observations: [%{satellite_id: "G01", code_m: p_base, phase_m: l_base}, ...],
  rover_observations: [%{satellite_id: "G01", code_m: p_rover, phase_m: l_rover}, ...]
}

{:ok, sol} = Orbis.GNSS.RTK.solve_float_baseline_epochs(base_position, [epoch])

The model is

DD = [rho_rover(s) - rho_base(s)] - [rho_rover(ref) - rho_base(ref)]

for code, and the same geometry plus one float carrier ambiguity per non-reference satellite for phase. Receiver clocks and any satellite-common short-baseline errors cancel before the solve.

The normal equations use the full double-difference covariance block for each epoch and measurement kind. Double-difference rows sharing the reference satellite are therefore correlated, and the returned metadata.ambiguity_float contains the resulting float ambiguity covariance and inverse covariance in metres.

The reference satellite must be available in every epoch. Other satellites may appear in only part of the arc; each available non-reference observation contributes a row, and split cycle-slip fragments become independent ambiguity ids.

Options:

  • :reference_satellite_id - fixed double-difference reference. Accepts a satellite id binary (single-system data only) or a per-system map such as %{"G" => "G04", "E" => "E11"} covering every observed system. When omitted, each system uses its highest-average-elevation satellite common to every epoch in which the system appears, with a lexicographic tie-break. Every non-reference satellite forms its double difference against its own system's reference; there are no cross-system double differences.
  • :initial_baseline_m - initial base-to-rover ECEF vector, default {0.0, 0.0, 0.0}.
  • :code_sigma_m / :phase_sigma_m - undifferenced receiver measurement sigmas in metres. The solver propagates them into the non-diagonal double-difference covariance where rows sharing the reference satellite are correlated. Defaults are 1.0 and 0.02.
  • :stochastic_model - :simple (default) uses constant sigmas, optionally scaled by :elevation_weighting. :rtklib uses RTKLIB's floor-plus- elevation single-difference variance shape, treating :code_sigma_m and :phase_sigma_m as the model's constant/elevation coefficients in metres.
  • :on_cycle_slip - what to do when a base or rover observation carries an LLI loss-of-lock bit: :error returns {:error, {:cycle_slip_detected, receiver, sat, epoch, [:lli]}} (default); :drop_satellite removes that satellite from the arc; :split_arc starts a new ambiguity arc at the slipped epoch.
  • :elevation_weighting - when true, scales each undifferenced measurement sigma by 1 / max(sin(elevation), 0.05) before propagating the double-difference covariance. Default false preserves the constant-sigma, transcendental-free solve path.
  • :sagnac - when true (default), applies the standard first-order Earth-rotation correction to each receiver-satellite range before forming double differences. Set false only for synthetic fixtures whose observations were generated from plain Euclidean range.
  • :elevation_mask_deg - optional elevation mask in degrees. Satellites below the mask at the base station are removed before reference selection and ambiguity construction.
  • :code_smoothing - when true, applies per-receiver/per-ambiguity-arc Hatch carrier smoothing to code observations before forming double differences. Default false.
  • :hatch_window_cap - maximum Hatch smoothing window when :code_smoothing is enabled (default 100).
  • :receiver_antenna_corrections - optional receiver antenna PCO/PCV corrections by station. Expected format: %{base: corr, rover: corr} where corr is %{antenna: %Antex.Antenna{}, frequency: "G01"} or %{antenna: {antex, "TYPE"}, frequency: "G01"}. Missing or malformed values return {:error, {:invalid_option, :receiver_antenna_corrections}}. Omitted correction leaves behavior unchanged.
  • :max_iterations, :position_tolerance_m, :ambiguity_tolerance_m.

Returns {:ok, %FloatBaselineSolution{}} or a tagged error.

solve_widelane_filter_baseline_epochs(base_position, dual_epochs, opts \\ [])

@spec solve_widelane_filter_baseline_epochs(
  ecef_input(),
  [dual_frequency_baseline_epoch()],
  keyword()
) :: {:ok, Orbis.GNSS.RTK.FilterBaselineSolution.t()} | {:error, term()}

Run a dual-frequency (L1/L2) sequential per-epoch fix-and-hold RTK filter.

This is the sequential sibling of solve_widelane_fixed_baseline_epochs/3. The wide-lane double-difference integers are estimated per arc up front by Melbourne-Wubbena averaging (identical to the batch path); the narrow-lane single observable per satellite (wavelength c/(f1+f2), offset beta*lambda2*N_wl with the wide-lane integer baked in) is then carried through the existing single-frequency sequential filter (solve_filter_baseline_epochs/3) with the per-ambiguity narrow-lane wavelength and offset maps. Removing the ionosphere via the iono-free combination eliminates the residual double-difference ionosphere that biased the single-frequency sequential path.

Wide-lane fixing remains an arc batch pre-step (wide-lane double-difference ambiguities are arc-constant and Melbourne-Wubbena averaged, which is standard practice and what the oracle config implies). Per-epoch sequential carry of the wide-lane ambiguity as a separate filter state is not implemented here.

Options are the same as solve_filter_baseline_epochs/3 (including :ar_arming_sigma_m, :hold_sigma_m, :baseline_prior_sigma_m, :filter_kernel, ...), except :ambiguity_wavelength_m and :ambiguity_offset_m are derived internally, plus the wide-lane options of solve_widelane_fixed_baseline_epochs/3 (:wide_lane_min_epochs, :wide_lane_tolerance_cycles, :on_cycle_slip, ...).

This path is intentionally limited to one constellation at a time; multiple constellation letters return {:error, {:unsupported_widelane, :multi_gnss}} before wide-lane estimation.

Returns {:ok, %FilterBaselineSolution{}} with wide_lane_ambiguities_cycles reported in metadata, or a tagged error.

solve_widelane_fixed_baseline_epochs(base_position, dual_epochs, opts \\ [])

@spec solve_widelane_fixed_baseline_epochs(
  ecef_input(),
  [dual_frequency_baseline_epoch()],
  keyword()
) :: {:ok, Orbis.GNSS.RTK.FixedBaselineSolution.t()} | {:error, term()}

Solve a static RTK baseline from raw dual-frequency observations by fixing wide-lane then narrow-lane double-difference ambiguities.

This is the dual-frequency convenience layer above solve_fixed_baseline_epochs/3. Each base and rover observation must carry two code and phase measurements:

%{
  satellite_id: "G05",
  p1_m: 20_200_000.0,
  p2_m: 20_200_004.0,
  phi1_cyc: 106_000_000.0,
  phi2_cyc: 82_000_000.0,
  f1_hz: 1_575_420_000.0,
  f2_hz: 1_227_600_000.0,
  lli1: 0,
  lli2: 0
}

For every non-reference double-difference arc the function estimates the Melbourne-Wubbena wide-lane integer first. It then forms ionosphere-free code and phase double differences and fixes the remaining narrow-lane integer with bounded integer least-squares. The returned fixed_ambiguities_cycles are the narrow-lane integers; wide_lane_ambiguities_cycles reports the fixed wide-lane integers.

This path is intentionally limited to one constellation at a time. If the normalized dual-frequency observations contain multiple constellation letters, it returns {:error, {:unsupported_widelane, :multi_gnss}} before wide-lane estimation.

Options are the same as solve_fixed_baseline_epochs/3, except :ambiguity_wavelength_m and :ambiguity_offset_m are derived internally. Additional wide-lane options:

  • :wide_lane_min_epochs - minimum Melbourne-Wubbena epochs per double-difference arc (default 2).
  • :wide_lane_tolerance_cycles - maximum absolute distance between the averaged wide-lane float value and the nearest integer (default 0.5 cycles).
  • :on_cycle_slip - :error (default), :drop_satellite, or :split_arc. Split arcs get fresh ambiguity ids and are fixed independently.
  • :partial_ambiguity_resolution - when true, a rejected full narrow-lane integer fix is followed by confidence-ranked subset searches (and, when the greedy ranking finds nothing, a bounded largest-first exhaustive combination search). Holding the wide-lane integers fixed collapses the per-ambiguity bias for most satellites, so the dual-frequency partial fix can safely cover a larger subset than the single-frequency partial. The ratio threshold is never weakened (default false).
  • :partial_min_ambiguities - minimum subset size for partial ambiguity resolution (default 4).

Returns {:ok, %FixedBaselineSolution{}} or a tagged error.