Orbis.GNSS.CarrierPhase (Orbis v0.9.1)

Copy Markdown View Source

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 arc L_GF varies 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 times lambda_WL plus noise (it is roughly constant); a cycle slip shifts it by an integer number of wide-lane cycles, i.e. by k * lambda_WL metres.

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

epoch_map()

@type epoch_map() :: %{optional(atom()) => term()}

slip_reason()

@type slip_reason() :: :lli | :geometry_free | :melbourne_wubbena

slip_result()

@type slip_result() :: %{
  epoch: term(),
  slip: boolean(),
  reasons: [slip_reason()],
  gf: float() | nil,
  mw: float() | nil,
  skipped: boolean()
}

smooth_result()

@type smooth_result() :: %{
  epoch: term(),
  p_smooth: float() | nil,
  window: non_neg_integer(),
  reset: boolean()
}

Functions

detect_cycle_slips(arc, opts \\ [])

@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 lli1 or lli2 (: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 (default 0.05 m): flags :geometry_free when abs(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 jumps L_GF by ~0.19 m, so 5 cm separates the two cleanly.
  • :mw_threshold_cycles (default 4.0 wide-lane cycles): flags :melbourne_wubbena when abs(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(l1_m, l2_m)

@spec geometry_free(number(), number()) :: float()

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.

melbourne_wubbena(phi1_cyc, phi2_cyc, p1_m, p2_m, f1, f2)

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

narrow_lane_code(p1_m, p2_m, f1, f2)

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

smooth_code(arc, opts \\ [])

@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(f1, f2)

@spec wide_lane_wavelength(number(), number()) ::
  {:ok, float()} | {:error, :equal_frequencies}

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.