# `Sidereon.GNSS.IonosphereFree`
[🔗](https://github.com/neilberkman/sidereon-ex/blob/main/lib/sidereon/gnss/ionosphere_free.ex#L1)

The dual-frequency ionosphere-free linear combination for code and phase.

The first-order ionospheric group delay on a GNSS pseudorange is dispersive: to
first order it scales as `1 / f^2`, so a signal at carrier `f` is delayed by
`I(f) = K / f^2` for a slant-path constant `K` proportional to the total
electron content (TEC). Measuring the same range on two carriers `f1` and `f2`
therefore gives two observations that share the geometry but carry different
ionospheric delays, and a fixed linear combination of the two cancels the
`1 / f^2` term exactly:

    PR_IF = (f1^2 * PR1 - f2^2 * PR2) / (f1^2 - f2^2)

Writing `gamma = f1^2 / (f1^2 - f2^2)` this is the affine combination

    PR_IF = gamma * PR1 - (gamma - 1) * PR2

Substituting `PR_i = R + K / f_i^2` (a true range `R` plus the first-order
ionospheric delay on band `i`) the `K` terms cancel and `PR_IF = R`. A position
solve fed these combined pseudoranges therefore needs no ionosphere model; call
`Sidereon.GNSS.Positioning.solve/4` with `ionosphere: false` (the troposphere term,
which is non-dispersive and does not cancel, still applies).

## Frequency table

The ionosphere-free carrier-pair frequencies used here, in hertz:

| System  | Band  | Frequency (MHz) |
|---------|-------|-----------------|
| GPS     | L1    | 1575.42         |
| GPS     | L2    | 1227.60         |
| Galileo | E1    | 1575.42         |
| Galileo | E5a   | 1176.45         |
| BeiDou  | B1I   | 1561.098        |
| BeiDou  | B3I   | 1268.52         |

The default combination pair per system is GPS `{:l1, :l2}`, Galileo
`{:e1, :e5a}`, BeiDou `{:b1i, :b3i}`.

Carrier phase uses the same first-order cancellation coefficients, but the
carrier ionosphere term has the opposite sign and the combined ambiguity
remains. If `L_i = lambda_i * phi_i` is carrier phase in metres,

    L_IF = gamma * L1 - (gamma - 1) * L2

cancels the first-order ionosphere while preserving a combined float
ambiguity. This is a PPP/RTK input primitive, not ambiguity resolution.

## Noise amplification

The combination is not free: because it is a weighted difference of two noisy
observations, uncorrelated band noise of equal standard deviation `sigma` is
amplified to `sigma * sqrt(gamma^2 + (gamma - 1)^2)`. For GPS L1/L2 this factor
is about `2.978`; for Galileo E1/E5a about `2.588`. See `noise_amplification/2`.

## Non-goals

This module builds only the first-order ionosphere-free code and carrier-phase
combinations. It deliberately does not implement the Melbourne-Wubbena /
wide-lane / geometry-free combinations, ambiguity resolution, the second-order
ionospheric term, or triple-frequency combinations.

# `drop_reason`

```elixir
@type drop_reason() ::
  :missing_band1 | :missing_band2 | :duplicate_observation | :unknown_system
```

A reason a satellite was dropped from the paired set.

# `observation`

```elixir
@type observation() :: {String.t(), float()}
```

A pseudorange observation `{satellite_id, range_m}`.

# `phase_error`

```elixir
@type phase_error() :: :equal_frequencies | :invalid_frequency
```

A reason a carrier-phase combination could not be formed.

# `default_pair`

```elixir
@spec default_pair(String.t()) ::
  {:ok, {atom(), atom()}} | {:error, {:unknown_system, String.t()}}
```

The standard combination band pair for `system`.

Returns `{:ok, {band1, band2}}` or `{:error, {:unknown_system, system}}`.

# `frequencies`

```elixir
@spec frequencies() :: %{required(String.t()) =&gt; %{required(atom()) =&gt; float()}}
```

The ionosphere-free carrier-pair table as `%{system => %{band => f_hz}}`.

For the full fixed-frequency carrier table and GLONASS RINEX band lookups,
use `Sidereon.GNSS.Frequencies`.

# `frequency`

```elixir
@spec frequency(String.t(), atom()) ::
  {:ok, float()} | {:error, {:unknown_band, String.t(), atom()}}
```

The carrier frequency in hertz for `system` (`"G"`, `"E"`, `"C"`) and `band`.

Returns `{:ok, f_hz}` or `{:error, {:unknown_band, system, band}}`.

# `gamma`

```elixir
@spec gamma(float(), float()) :: {:ok, float()} | {:error, :equal_frequencies}
```

The ionosphere-free combination coefficient `gamma = f1^2 / (f1^2 - f2^2)`.

Returns `{:error, :equal_frequencies}` when `f1 == f2` (the combination is
undefined; the denominator vanishes). Never raises.

# `iono_free`

```elixir
@spec iono_free(float(), float(), float(), float()) ::
  {:ok, float()} | {:error, :equal_frequencies}
```

The ionosphere-free pseudorange from two carrier-band pseudoranges.

    PR_IF = (f1^2 * pr1 - f2^2 * pr2) / (f1^2 - f2^2)
          = gamma * pr1 - (gamma - 1) * pr2

`pr1`/`pr2` are in metres on carriers `f1`/`f2` (hertz). Returns `{:ok, pr_if}`,
or `{:error, :equal_frequencies}` when `f1 == f2`. Never raises.

# `iono_free_from_obs`

```elixir
@spec iono_free_from_obs(
  Sidereon.GNSS.RINEX.Observations.t(),
  non_neg_integer() | tuple(),
  keyword()
) ::
  {:ok, {[observation()], [{String.t(), drop_reason()}]}} | {:error, term()}
```

Convenience: pull two carrier bands from a parsed observation handle and combine
them into ionosphere-free pseudoranges for one epoch.

Calls `Sidereon.GNSS.RINEX.Observations.pseudoranges/3` twice, once for each band's code
preference, then `iono_free_pseudoranges/3`. `epoch` is an epoch index or
`{{y, mo, d}, {h, mi, s}}` tuple, exactly as `Observations.pseudoranges/3` accepts.

Returns `{:ok, {combined, dropped}}` or `{:error, reason}` (propagated from
either extraction).

## Options

  * `:codes`: a map `%{system => {band1_codes, band2_codes}}` of the
    observation codes to extract for each band, e.g.
    `%{"G" => {["C1C"], ["C2W", "C2L"]}, "E" => {["C1C"], ["C5Q"]}}`. When
    omitted, the standard band-1/band-2 codes for the systems present in the
    file's `observation_codes/1` are used (GPS C1C/C2W, Galileo C1C/C5Q,
    BeiDou C2I/C6I), restricted to those whose codes the file actually carries.
  * `:pairs`: forwarded to `iono_free_pseudoranges/3`.

# `iono_free_phase`

```elixir
@spec iono_free_phase(number(), number(), number(), number()) ::
  {:ok, float()} | {:error, :equal_frequencies}
```

The ionosphere-free carrier-phase combination from phase measurements in metres.

    L_IF = gamma * L1 - (gamma - 1) * L2

`l1_m`/`l2_m` are carrier phase already expressed in metres (`lambda_i *
phi_i`). The first-order ionosphere cancels; the float ambiguity remains in
the combined phase. Returns `{:ok, l_if_m}` or `{:error, :equal_frequencies}`.
Never raises.

# `iono_free_phase_cycles`

```elixir
@spec iono_free_phase_cycles(number(), number(), number(), number()) ::
  {:ok, float()} | {:error, phase_error()}
```

The ionosphere-free carrier-phase combination from phase measurements in cycles.

The two phase inputs are converted to metres with `L_i = c / f_i * phi_i`, then
combined with `iono_free_phase/4`. Returns:

  * `{:ok, l_if_m}` for a valid frequency pair;
  * `{:error, :equal_frequencies}` when `f1 == f2`;
  * `{:error, :invalid_frequency}` when either frequency is not positive.

Never raises.

# `iono_free_pseudoranges`

```elixir
@spec iono_free_pseudoranges([observation()], [observation()], keyword()) ::
  {[observation()], [{String.t(), drop_reason()}]}
```

Combine two per-satellite pseudorange bands into ionosphere-free pseudoranges.

`band1` and `band2` are `[{satellite_id, range_m}]` lists for the two carriers.
Satellites are paired by id, and each pair is combined with the frequency pair
for that satellite's system (the leading letter of the id), so a mixed
GPS+Galileo+BeiDou set uses each system's own carriers.

Returns `{combined, dropped}` where `combined` is the ascending-by-id list of
`{satellite_id, pr_if}` and `dropped` reports every satellite that could not be
combined as `{satellite_id, reason}`:

  * `:missing_band2`: present in `band1` only;
  * `:missing_band1`: present in `band2` only;
  * `:duplicate_observation`: the satellite appears more than once in a band,
    so which pseudorange to use is ambiguous; it is dropped rather than
    silently collapsed to whichever entry comes last;
  * `:unknown_system`: the system letter has no known frequency pair.

Empty input yields `{[], []}`. Never raises.

## Options

  * `:pairs`: override the band pair per system, e.g.
    `pairs: %{"G" => {:l1, :l2}}`. A system without an override uses its
    standard default pair.

# `noise_amplification`

```elixir
@spec noise_amplification(float(), float()) ::
  {:ok, float()} | {:error, :equal_frequencies}
```

The noise-amplification factor `sqrt(gamma^2 + (gamma - 1)^2)`.

This is the factor by which uncorrelated equal-variance band noise is amplified
into the combined pseudorange. About `2.978` for GPS L1/L2 and `2.588` for
Galileo E1/E5a. Returns `{:error, :equal_frequencies}` when `f1 == f2`. Never
raises.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
