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 LAMBDA integer 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.
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(: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.
@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(: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_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 LAMBDA 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- default50000.
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}, ...},
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.
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.: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.: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
LAMBDA. 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.
Returns {:ok, %FixedBaselineSolution{}} or a tagged error.