Orbis.GNSS.RTK (Orbis v0.19.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.

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(: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).
  • :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).
  • :max_iterations, :position_tolerance_m, :ambiguity_tolerance_m.

Returns {:ok, %FloatBaselineSolution{}} 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.