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

Code-differential GNSS (DGPS) positioning over single-frequency pseudoranges.

A *base* receiver at a surveyed (known) position turns its raw pseudoranges
into per-satellite **pseudorange corrections (PRC)**. A *rover* applies those
corrections to its own pseudoranges and runs a point-positioning solve. The
subtraction `pr_rover - PRC` forms a single difference between the two
receivers that cancels every error common to both: satellite-clock error,
ephemeris error, and (over a short baseline) the ionospheric and tropospheric
delays, leaving the rover position observable. This is the classical
code-differential / RTCM-style PRC technique.

## Measurement model

A measured single-frequency pseudorange is

    pr = geometric_range + c*rx_clock - c*sat_clock + atmosphere + ephemeris_error + noise

At the known base the modelled value, written to match exactly the terms the
point-positioning estimator removes internally (`geometric_range - c*sat_clock`),
is

    m_base(sat) = geometric_range(base, sat) - c*sat_clock(sat)

The pseudorange correction is the standard DGPS PRC

    PRC(sat) = pr_base(sat) - m_base(sat)

Expanding `pr_base` shows the geometric range and the satellite-clock term
cancel by construction, so

    PRC(sat) = c*rx_clock_base + atmosphere_base(sat) + ephemeris_error(sat) + noise_base(sat)

i.e. everything common to both receivers plus the base receiver clock, which is
a single per-station constant shared by every satellite.

The rover forms

    pr_rover_corrected(sat) = pr_rover(sat) - PRC(sat)

and runs `Sidereon.GNSS.Positioning.solve/4` with `ionosphere: false` and
`troposphere: false` (the differential already removed those delays). The
estimator re-applies its own `geometric_range(rover, sat) - c*sat_clock +
c*rx_clock_rover` model. Because `m_base` subtracted `-c*sat_clock` exactly
once and `pr_rover` still carries `-c*sat_clock` once, the corrected
pseudorange contains the satellite-clock term exactly once and the estimator
removes it exactly once: **the satellite clock is never double-counted.**

### Single-difference cancellation

For a per-satellite additive error `e(sat)` common to base and rover (a
satellite-clock error, an ephemeris error, or a short-baseline atmospheric
delay): `pr_base` gains `+e(sat)`, so `PRC(sat)` gains `+e(sat)`; then
`pr_rover_corrected = (pr_rover + e) - (PRC + e)` cancels `e(sat)` to machine
precision.

### Base receiver clock

PRC contributes the constant `c*rx_clock_base` to every satellite, so after
`pr_rover - PRC` each rover pseudorange carries the same satellite-independent
offset. A constant common to all pseudoranges is indistinguishable from a
receiver-clock bias, so the estimator's recovered rover clock simply absorbs
`rx_clock_rover - rx_clock_base`; the rover position is unaffected.

## Frame consistency

`Sidereon.GNSS.Observables.predict/5` is evaluated with `light_time: true` and
`sagnac: true` so the base modelled range lives in the same transmit-time,
Earth-rotation-corrected frame the estimator uses; this is what makes the PRC
consistent with the estimator's internal model.

## Non-goals

This module covers single-frequency code-differential positioning only.
Carrier-phase double differences, RTK / integer-ambiguity resolution, RTCM
wire-format message encoding/decoding, network/VRS corrections, and a moving
base are out of scope. A range-rate correction (RRC) is also a non-goal: the
static single-epoch design carries no base/rover time offset over which to
propagate it, even though `Sidereon.GNSS.Observables` exposes `range_rate_m_s`.

# `corrections`

```elixir
@type corrections() :: %{optional(sat()) =&gt; float()}
```

Per-satellite pseudorange corrections in meters.

# `observation`

```elixir
@type observation() :: {sat(), number()}
```

A `{satellite_id, pseudorange_m}` pseudorange observation.

# `position`

```elixir
@type position() ::
  {number(), number(), number()}
  | %{x_m: number(), y_m: number(), z_m: number()}
```

An ECEF position as `{x_m, y_m, z_m}` or `%{x_m, y_m, z_m}`.

# `sat`

```elixir
@type sat() :: String.t()
```

A satellite id string, e.g. `"G01"`.

# `apply`

```elixir
@spec apply([observation()], corrections()) :: {[observation()], [sat()]}
```

Apply corrections to rover observations, pairing by satellite.

Returns `{corrected, dropped}` where `corrected` is the list of
`{satellite_id, pr_corrected_m}` for every satellite present in **both** the
rover observations and the corrections (`pr_corrected = pr_rover - PRC(sat)`),
and `dropped` is the list of rover satellite ids that have no correction.
Corrections without a matching rover observation are ignored. The pairing is
order-independent (it is keyed on the satellite id).

This shadows `Kernel.apply/2` inside the module; call it qualified as
`Sidereon.GNSS.DGNSS.apply/2`.

# `corrections`

```elixir
@spec corrections(
  Sidereon.GNSS.SP3.t(),
  position(),
  [observation()],
  NaiveDateTime.t(),
  keyword()
) ::
  {:ok, corrections()} | {:error, term()}
```

Compute per-satellite pseudorange corrections (PRC) from the surveyed base.

`source` is a loaded `Sidereon.GNSS.SP3` product; `base_position` is the known base
ECEF position; `base_observations` is a list of `{satellite_id,
pseudorange_m}` pairs; `epoch` is the receive epoch (`NaiveDateTime`, in the
product's time scale).

For each base observation the modelled value
`m_base = geometric_range(base, sat) - c*sat_clock(sat)` is taken from
`Sidereon.GNSS.Observables.predict/5` (light-time and Sagnac on) and the
correction is `PRC = pr_base - m_base`. A satellite whose ephemeris cannot be
evaluated at this epoch is dropped from the result (it cannot be corrected)
rather than failing the batch.

Returns `{:ok, %{sat => prc_m}}`, or a tagged error:
`{:error, :invalid_base_position}` for a malformed base position,
`{:error, :empty_base_observations}`, or
`{:error, {:invalid_base_observations, term}}` for a bad shape. Never raises.

# `position`

```elixir
@spec position(
  Sidereon.GNSS.SP3.t(),
  position(),
  [observation()],
  [observation()],
  NaiveDateTime.t(),
  keyword()
) :: {:ok, map()} | {:error, term()}
```

Differential position solve for the rover from base + rover pseudoranges.

Delegates the whole workflow to the `sidereon_core` DGNSS driver: it computes
the base corrections, applies them to the rover observations, runs the
corrected-pseudorange point-positioning solve, and derives the baseline, all in
the core. The corrected solve always disables the ionosphere and troposphere
terms because the differential has already removed those delays. The
`:initial_guess` and `:with_geodetic` options are passed through; any
meteorology/Klobuchar options have no effect since the atmosphere terms are
disabled. The result is a `Sidereon.GNSS.Positioning.Solution` paired with the
baseline, exactly as a corrected-pseudorange `Sidereon.GNSS.Positioning.solve/4`
would produce.

On success returns

    {:ok, %{
      solution: %Sidereon.GNSS.Positioning.Solution{},
      baseline_vector_m: %{x_m: float(), y_m: float(), z_m: float()},
      baseline_m: float(),
      dropped_sats: [sat()]
    }}

where the baseline vector points from the base position to the solved rover
position and `baseline_m` is its length. Errors from any stage, including a bad
observation shape or a point-positioning error such as
`{:too_few_satellites, used, required}`, are propagated as `{:error,
reason}`. Never raises.

---

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