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

baseline_epoch()

@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.

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(: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. 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.

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 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 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 50000.

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}, ...},
  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 are 1.0 and 0.02.
  • :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.
  • :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 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 (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.

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