Dual-frequency carrier-phase linear combinations and the precise-positioning prep tooling built on them: geometry-free and wide-lane phase, the narrow-lane code, Melbourne-Wubbena, cycle-slip detection, and Hatch carrier-smoothed code.
This is pure Elixir over the existing observation primitives. The phase and
code observations come from Orbis.GNSS.RINEX.Observations (values/3,
phases/3) and the carrier frequencies from
Orbis.GNSS.RINEX.Observations.band_frequency_hz/2; a clean synthetic arc for
testing can be built from Orbis.GNSS.Observables.predict/5 over an
Orbis.GNSS.SP3 product.
Notation
For a satellite, band i has carrier frequency f_i (Hz), wavelength
lambda_i = c / f_i, carrier-phase observation phi_i (cycles) so the phase
in metres is L_i = lambda_i * phi_i, and code pseudorange P_i (metres),
with c = 299_792_458 m/s.
Combinations
Geometry-free phase
L_GF = L1 - L2(metres). The geometry and the receiver/satellite clocks are common to both bands and cancel, leaving the (dispersive) ionospheric delay plus a constant ambiguity. On a continuous arcL_GFvaries only with the slow ionosphere, so a cycle slip on either band makes it jump by (close to) an integer number of band wavelengths.Wide-lane wavelength
lambda_WL = c / (f1 - f2)(about 0.8619 m for GPS L1/L2). The long wavelength is why the wide-lane combination is robust.Wide-lane phase
phi_WL = phi1 - phi2(cycles),L_WL = lambda_WL * phi_WL(metres).Narrow-lane code
P_NL = (f1*P1 + f2*P2) / (f1 + f2)(metres). The frequency-weighted code combination paired with the wide-lane phase in Melbourne-Wubbena.Melbourne-Wubbena
MW = L_WL - P_NL = lambda_WL*(phi1 - phi2) - (f1*P1 + f2*P2)/(f1 + f2)(metres). Sign convention: wide-lane phase minus narrow-lane code. MW is geometry-, clock-, and ionosphere-free, so on a continuous arc it equals the constant wide-lane ambiguity timeslambda_WLplus noise (it is roughly constant); a cycle slip shifts it by an integer number of wide-lane cycles, i.e. byk * lambda_WLmetres.
Arc shape
detect_cycle_slips/2 and smooth_code/2 take an arc: a time-ordered list
of per-epoch maps for one satellite. Each epoch map is a convenient subset
of
%{
epoch: term(), # opaque, passed through to the output
phi1: float | nil, # band-1 carrier phase, cycles
phi2: float | nil, # band-2 carrier phase, cycles
p1: float | nil, # band-1 code, metres
p2: float | nil, # band-2 code, metres
lli1: integer | nil, # band-1 LLI (bit 0 = loss of lock)
lli2: integer | nil, # band-2 LLI
f1: float | nil, # band-1 carrier frequency, Hz (nil => skip)
f2: float | nil # band-2 carrier frequency, Hz
}A GLONASS satellite (FDMA, so band_frequency_hz/2 returns nil) or any
epoch with an unknown band frequency is skipped and reported, never
raised: such epochs come back with skipped: true and no slip flags.
Non-goals
Out of scope here (documented so the boundary is explicit): integer ambiguity resolution (LAMBDA), double differencing / RTK, the ionosphere-free phase positioning combination, triple-frequency combinations, GLONASS FDMA channel-dependent wavelengths (those satellites are skipped), and any change to the underlying native primitives.
Summary
Functions
Detect cycle slips on a single-satellite arc.
Geometry-free phase combination L_GF = l1_m - l2_m (metres).
Melbourne-Wubbena combination (metres).
Narrow-lane code P_NL = (f1*p1 + f2*p2) / (f1 + f2) (metres).
Single-frequency Hatch carrier-smoothed code on band 1.
Wide-lane wavelength lambda_WL = c / (f1 - f2) (metres).
Types
@type slip_reason() :: :lli | :geometry_free | :melbourne_wubbena
@type smooth_result() :: %{ epoch: term(), p_smooth: float() | nil, window: non_neg_integer(), reset: boolean() }
Functions
@spec detect_cycle_slips( [epoch_map()], keyword() ) :: [slip_result()]
Detect cycle slips on a single-satellite arc.
arc is a time-ordered list of per-epoch maps (see the module docs for the
shape). One output map is produced per input epoch, in order:
%{epoch: term(), slip: boolean(), reasons: [reason],
gf: float | nil, mw: float | nil, skipped: boolean()}with reason in [:lli, :geometry_free, :melbourne_wubbena]. A slip at epoch
k is flagged when any of:
- an LLI loss-of-lock bit is set — bit 0 of
lli1orlli2(:lli, parameter-free); - the epoch-to-epoch geometry-free step exceeds
:gf_threshold_m(:geometry_free); - the epoch-to-epoch Melbourne-Wubbena step, expressed in wide-lane cycles,
exceeds
:mw_threshold_cycles(:melbourne_wubbena).
The GF and MW steps require a usable predecessor; the first epoch (and any
epoch whose predecessor lacked the needed observations) cannot flag :gf /
:mw but can still flag :lli.
An epoch with an unknown band frequency (f1 or f2 nil, e.g. GLONASS) is
reported with skipped: true, slip: false, reasons: [], gf: nil,
mw: nil, and never breaks the arc for its neighbours' LLI check.
Options / default thresholds
:gf_threshold_m(default0.05m): flags:geometry_freewhenabs(L_GF(k) - L_GF(k-1)) > gf_threshold_m. At a typical 30 s cadence the epoch-to-epoch ionospheric change is sub-cm, whereas a single L1 cycle slip jumpsL_GFby ~0.19 m, so 5 cm separates the two cleanly.:mw_threshold_cycles(default4.0wide-lane cycles): flags:melbourne_wubbenawhenabs(MW(k) - MW(k-1)) / lambda_WL > mw_threshold_cycles. MW noise is dominated by code noise (~0.3-0.5 wide-lane cycles 1-sigma on raw code), so 4 wide-lane cycles is a robust gate.
Both thresholds must be non-negative numbers (a negative threshold would flag
every epoch); any other value raises ArgumentError.
Geometry-free phase combination L_GF = l1_m - l2_m (metres).
Both inputs are carrier phase already expressed in metres (L_i = lambda_i * phi_i). The result cancels geometry and clocks, leaving the ionosphere plus a
constant ambiguity.
@spec melbourne_wubbena(number(), number(), number(), number(), number(), number()) :: {:ok, float()} | {:error, :equal_frequencies}
Melbourne-Wubbena combination (metres).
MW = L_WL - P_NL
= lambda_WL*(phi1 - phi2) - (f1*P1 + f2*P2)/(f1 + f2)Sign convention: wide-lane phase minus narrow-lane code. Inputs are the
band carrier phases in cycles (phi1, phi2), the band codes in metres
(p1_m, p2_m), and the band frequencies in Hz. Geometry-, clock-, and
ionosphere-free.
Returns {:ok, MW} or {:error, :equal_frequencies} (when f1 == f2, so the
wide-lane wavelength is undefined, or f1 + f2 is degenerate).
@spec narrow_lane_code(number(), number(), number(), number()) :: {:ok, float()} | {:error, :equal_frequencies}
Narrow-lane code P_NL = (f1*p1 + f2*p2) / (f1 + f2) (metres).
The frequency-weighted code combination. Returns {:ok, P_NL} or
{:error, :equal_frequencies} when f1 + f2 is degenerate (zero within
epsilon).
@spec smooth_code( [epoch_map()], keyword() ) :: [smooth_result()]
Single-frequency Hatch carrier-smoothed code on band 1.
arc is the same per-epoch list as detect_cycle_slips/2; this filter uses
p1, phi1, lli1, and f1 (for lambda_1 = c / f1). The recursion is
P_smooth(1) = P(1), N(1) = 1
N(k) = min(N(k-1) + 1, N_cap)
P_smooth(k) = P(k)/N(k)
+ ((N(k)-1)/N(k)) * (P_smooth(k-1) + (L1(k) - L1(k-1)))with L1 = lambda_1 * phi1. The window N grows to a cap (:hatch_window_cap,
default 100), which must be a positive integer
(ArgumentError otherwise). The slip-threshold options are validated as in
detect_cycle_slips/2.
Reset rule. On a detected cycle slip / LLI loss-of-lock at epoch k, or
when the previous usable sample is missing (start of arc, a gap, or missing
code/phase), the filter restarts: N(k) = 1, P_smooth(k) = P(k), and no
smoothing carries across the discontinuity. Cycle slips are detected with the
same logic as detect_cycle_slips/2; pass :gf_threshold_m /
:mw_threshold_cycles to tune it.
Returns one map per epoch:
%{epoch: term(), p_smooth: float | nil, window: non_neg_integer,
reset: boolean()}p_smooth is nil (with window: 0) when the epoch lacks the band-1 code or
phase, or is skipped (unknown frequency).
Divergence note
Single-frequency Hatch smoothing accumulates ionospheric divergence: the
ionospheric delay has opposite sign on code and carrier, so as the window N
grows the smoothed code is pulled away from the true range by roughly twice the
ionospheric change accumulated over the window. Capping N bounds this bias;
the dual-frequency (divergence-free) variant is out of scope here.
Wide-lane wavelength lambda_WL = c / (f1 - f2) (metres).
Returns {:ok, lambda_WL} or {:error, :equal_frequencies} when the two
band frequencies are equal (within a small epsilon), which would divide by
zero. For GPS L1/L2 this is about 0.8619 m.