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
@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.
@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. When omitted, the lexicographically first common satellite is selected deterministically.
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).:filter_kernel-:elixir(default) or:rust; the Rust kernel path is opt-in while its trace parity gates mature.
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).: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 reference satellite. When omitted, the highest-average-elevation satellite common to every epoch is used, with a lexicographic tie-break.: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).:max_iterations,:position_tolerance_m,:ambiguity_tolerance_m.
Returns {:ok, %FloatBaselineSolution{}} 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.
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.