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
@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.
@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.
@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.
@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.
@type ecef_input() :: {number(), number(), number()} | %{x_m: number(), y_m: number(), z_m: number()}
ECEF position in metres.
@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.
@type result() :: %{ reference_satellite_id: String.t(), double_differences: [double_difference()], dropped_sats: [String.t()] }
Double-difference result with deterministic satellite ordering.
@type satellite_positions() :: %{required(String.t()) => ecef_input()}
Satellite ECEF position keyed by satellite id.
Functions
@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.
@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 (default100.0).:ambiguity_prior_sigma_m- initial ambiguity prior sigma in metres (default1.0e3).:hold_sigma_m- pseudo-measurement sigma for fixed ambiguity holds (default0.0001).:dynamics_model-:constant_position(default) keeps the carried baseline mean fixed between epochs.:velocity_propagatedadvances the prediction mean by each epoch's optional:velocity_mpstimes 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 (sqrtof 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 tonil(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 withabs(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 (default8).: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.
@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 isoffset_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- default1.:integer_ratio_threshold- default3.0.:integer_candidate_limit- default200000.:partial_ambiguity_resolution- whentrue, 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 (defaultfalse).:partial_min_ambiguities- minimum subset size for partial ambiguity resolution (default4).: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 (default1when 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.
@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 are1.0and0.02.:stochastic_model-:simple(default) uses constant sigmas, optionally scaled by:elevation_weighting.:rtklibuses RTKLIB's floor-plus- elevation single-difference variance shape, treating:code_sigma_mand:phase_sigma_mas 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::errorreturns{:error, {:cycle_slip_detected, receiver, sat, epoch, [:lli]}}(default);:drop_satelliteremoves that satellite from the arc;:split_arcstarts a new ambiguity arc at the slipped epoch.:elevation_weighting- whentrue, scales each undifferenced measurement sigma by1 / max(sin(elevation), 0.05)before propagating the double-difference covariance. Defaultfalsepreserves the constant-sigma, transcendental-free solve path.:sagnac- whentrue(default), applies the standard first-order Earth-rotation correction to each receiver-satellite range before forming double differences. Setfalseonly 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- whentrue, applies per-receiver/per-ambiguity-arc Hatch carrier smoothing to code observations before forming double differences. Defaultfalse.:hatch_window_cap- maximum Hatch smoothing window when:code_smoothingis enabled (default100).:receiver_antenna_corrections- optional receiver antenna PCO/PCV corrections by station. Expected format:%{base: corr, rover: corr}wherecorris%{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.
@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.
@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 (default2).:wide_lane_tolerance_cycles- maximum absolute distance between the averaged wide-lane float value and the nearest integer (default0.5cycles).:on_cycle_slip-:error(default),:drop_satellite, or:split_arc. Split arcs get fresh ambiguity ids and are fixed independently.:partial_ambiguity_resolution- whentrue, 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 (defaultfalse).:partial_min_ambiguities- minimum subset size for partial ambiguity resolution (default4).
Returns {:ok, %FixedBaselineSolution{}} or a tagged error.