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

GNSS single-point positioning (SPP): recover a receiver position, clock bias,
and geometry diagnostics from one epoch of pseudorange observations against a
precise SP3 ephemeris or a broadcast navigation product.

This is the Elixir surface over the `sidereon-core` SPP solver. Given an
ephemeris source, an `Sidereon.GNSS.SP3` product or an `Sidereon.GNSS.Broadcast`
handle (GPS / Galileo / BeiDou / GLONASS), a set of single-frequency
pseudoranges, the receive epoch, and the broadcast/atmosphere parameters, it
runs the transmit-time iteration and trust-region least-squares solve in the
crate and returns an `Sidereon.GNSS.Positioning.Solution`. A mixed-constellation
set is solved together with one receiver clock per system. No positioning math
lives on the Elixir side; this module marshals units and epoch arguments and
decodes the result.

## Units at the boundary

  * pseudoranges and the initial guess position/clock are **meters**;
  * the recovered `position` is ITRF/IGS ECEF **meters**, matching the SP3 frame;
  * `geodetic` latitude/longitude are **radians** and height is **meters**;
  * `rx_clock_s` is **seconds**;
  * pressure is **hPa**, temperature is **kelvin**, relative humidity is a
    fraction in `[0, 1]`;
  * the Klobuchar `alpha`/`beta` coefficients are passed in their broadcast
    units.

The epoch is interpreted in the SP3 product's own time scale (typically GPST);
no leap-second shifting is applied. The seconds-since-J2000, second-of-day, and
fractional day-of-year arguments the crate needs are derived from the supplied
epoch via `Sidereon.GNSS.Time`.

## Example

    {:ok, sp3} = Sidereon.GNSS.SP3.load("igs.sp3")

    observations = [{"G01", 2.41e7}, {"G02", 2.49e7}, {"G05", 2.05e7}, {"G07", 2.30e7}]

    {:ok, solution} =
      Sidereon.GNSS.Positioning.solve(sp3, observations, ~N[2020-06-24 12:00:00],
        ionosphere: true,
        troposphere: true,
        klobuchar_alpha: {1.0e-8, 2.2e-8, -6.0e-8, -1.2e-7},
        klobuchar_beta: {96_256.0, 131_072.0, -65_536.0, -589_824.0}
      )

    solution.position.x_m
    solution.rx_clock_s

# `batch_request`

```elixir
@type batch_request() ::
  {[observation()], epoch()} | {[observation()], epoch(), keyword()}
```

One epoch's request in a `solve_batch/3` call: a `{observations, epoch}` pair,
or a `{observations, epoch, epoch_opts}` triple whose `epoch_opts` are merged
over the batch-wide options for that epoch only.

# `doppler_observation`

```elixir
@type doppler_observation() ::
  {String.t(), number()}
  | {String.t(), number(), number()}
  | {String.t(), number(), number(), number()}
```

A Doppler observation as `{satellite_id, doppler_hz}`, or
`{satellite_id, doppler_hz, carrier_hz}`, or
`{satellite_id, doppler_hz, carrier_hz, sat_clock_drift_s_s}`.

# `epoch`

```elixir
@type epoch() :: NaiveDateTime.t() | tuple()
```

An epoch as a `NaiveDateTime` or `{{y, m, d}, {h, min, s}}` tuple.

# `observation`

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

A `{satellite_id, pseudorange_m}` pseudorange observation.

# `solve`

```elixir
@spec solve(
  Sidereon.GNSS.SP3.t() | Sidereon.GNSS.Broadcast.t(),
  [observation()],
  epoch(),
  keyword()
) ::
  {:ok, Sidereon.GNSS.Positioning.Solution.t()} | {:error, term()}
```

Solve single-point positioning for one receive epoch.

`source` is a loaded ephemeris product, an `Sidereon.GNSS.SP3` precise product or an
`Sidereon.GNSS.Broadcast` broadcast-navigation product. `observations` is a
list of `{satellite_id, pseudorange_m}` pairs (ids like `"G01"`, pseudoranges
in meters), and `epoch` is a `NaiveDateTime` or `{{y, m, d}, {h, min, s}}`
tuple in the product's time scale.

## Options

  * `:ionosphere` - apply the broadcast Klobuchar ionosphere correction (default
    `false`); the L1 delay is scaled to each satellite's carrier by `(f_L1/f)^2`,
    covering GPS L1, Galileo E1, and BeiDou B1I. A GLONASS satellite's FDMA
    carrier is resolved per satellite from `:glonass_channels`; a GLONASS
    observation with the ionosphere requested but no (or out-of-range) channel
    is rejected with `{:ionosphere_unsupported, sat}`.
  * `:glonass_channels` - the GLONASS FDMA channel map `%{slot => channel}`
    (default `%{}`), where `slot` is the GLONASS satellite slot/PRN and
    `channel` is its FDMA frequency channel `k` (valid `[-7, +6]`), as carried
    in the broadcast nav `freq_channel` field or a RINEX `GLONASS SLOT / FRQ #`
    header. Used only to resolve the GLONASS carrier for the `(f_L1/f_k)^2`
    ionosphere scaling, so it matters only when `:ionosphere` is `true` and the
    set contains GLONASS observations; an empty map leaves every other
    constellation bit-identical. A value that is not a `%{integer => integer}`
    map returns `{:error, {:invalid_option, :glonass_channels}}`.
  * `:troposphere` - apply the Saastamoinen/Niell troposphere correction (default `false`)
  * `:klobuchar_alpha` - broadcast alpha coefficients, 4-tuple (default zeros)
  * `:klobuchar_beta` - broadcast beta coefficients, 4-tuple (default zeros)
  * `:pressure_hpa` - surface pressure, hPa (default `1013.25`)
  * `:temperature_k` - surface temperature, kelvin (default `288.15`)
  * `:relative_humidity` - relative humidity fraction `[0, 1]` (default `0.5`)
  * `:initial_guess` - `{x_m, y_m, z_m, b_m}` start point (default all zeros)
  * `:with_geodetic` - also return the geodetic position (default `true`)
  * `:max_pdop` - optional positive PDOP ceiling. When set, a fix whose
    geometry is rank-deficient or whose PDOP exceeds the ceiling is refused
    with `{:error, {:degenerate_geometry, pdop}}` (a non-positive value is
    `{:error, {:invalid_option, :max_pdop}}`); default unset.
  * `:robust` - run the core robust SPP FDE driver. Requires an explicit
    `:weights` map unless `:unsafe_unit_weights` is set.

### Optional convergence aids

  * `:coarse_search` - cold-start convergence-basin widening for a degraded or
    absent position prior (default `nil` = off, exact single solve from
    `:initial_guess`). The crate freezes its elevation mask and weights at the
    seed geometry, so a seed far from the true surface point (the `{0,0,0,0}`
    earth-center default, an antipodal last-known fix) either starves on the
    horizon or is refused by the integrity gates. When set, the solve runs
    once from each of a deterministic golden-spiral lattice of near-surface
    seeds (plus the caller's `:initial_guess`), routes every per-seed candidate
    through the same integrity gates, and selects the best redundant
    (`redundancy >= 1`) converged fix, so no hardcoded prior is needed.
    Accepts `true` (the default seed count), a positive integer seed count, or
    a keyword list `[seeds: n]`. Each seed is one extra crate solve, so the
    cost scales with the seed count; leave it off on the hot path where a good
    prior is known. Composes with the integrity gates (including `:max_pdop`)
    per candidate. A non-positive or non-integer value returns
    `{:error, {:invalid_option, :coarse_search}}`.
  * `:huber` - when `true` (default `false`), apply opt-in crate-layer
    Huber/IRLS robust reweighting: the per-satellite weight is recomputed each
    outer iteration from the post-fit residual. Tuning, used only when
    `:huber` is set: `:huber_k` (Huber constant, default `1.345`),
    `:huber_sigma` (MAD scale floor in metres, default `1.0`, the canonical
    core robust scale floor), `:huber_max_iter` (outer-loop cap,
    default `5`). A malformed `:huber`/`:huber_k`/`:huber_sigma`/`:huber_max_iter`
    value returns `{:error, {:invalid_option, key}}` before any solve.

Regardless of options, a fix that did not converge to a physical receiver
position is refused rather than returned: one whose geocentric radius is
outside the plausible band (for example from the earth-center default seed, or
a wrong-root least-squares fix) gives `{:error, {:implausible_position, radius_m}}`,
and a converged-flagged fix whose post-fit residual RMS is physically
implausible gives `{:error, {:no_convergence, rms_m}}`.

A mixed GPS+Galileo+BeiDou+GLONASS observation set is solved together with one
receiver clock per GNSS (an inter-system bias is the difference between a
system's clock and the reference system's), and dilution of precision is
reported for the combined geometry as well.

Returns `{:ok, %Sidereon.GNSS.Positioning.Solution{}}` or `{:error, reason}`,
where `reason` is one of `{:too_few_satellites, used, required}` (`required` is
`3 + n_systems`), `:singular_geometry`, `{:duplicate_observation, sat}`,
`{:ephemeris_lost, sat}`, `{:ionosphere_unsupported, sat}` (the ionosphere
correction was requested for a system with no modeled single-frequency
carrier), `{:degenerate_geometry, reason}` (the geometry is rank-deficient, so
`reason` is `:rank_deficient`, or exceeds the optional `:max_pdop` ceiling, so
`reason` is the PDOP), `{:implausible_position, radius_m}` (the fix is outside
the plausible geocentric-radius band), `{:no_convergence, rms_m}` (a
converged-flagged fix with physically implausible post-fit residual RMS),
`{:invalid_option, :max_pdop}`, `{:invalid_option, :coarse_search}`,
`{:robust_requires_noise_model, :no_weights}`, or `{:invalid_option, key}` for a
malformed `:huber`, `:huber_k`, `:huber_sigma`, or `:huber_max_iter`.

# `solve_batch`

```elixir
@spec solve_batch(Sidereon.GNSS.SP3.t(), [batch_request()], keyword()) ::
  {:ok, ok: Sidereon.GNSS.Positioning.Solution.t(), error: term()}
  | {:error, term()}
```

Solve many independent SPP epochs against one shared SP3 product in a single
call.

Each epoch is solved exactly as `solve/4` would solve it (the crate's reference
trust-region path), independently: the receiver clock and position are
re-estimated per epoch, so the epochs share only the immutable ephemeris and
policy. `requests` is a list of `t:batch_request/0` (a `{observations, epoch}`
pair, or a `{observations, epoch, epoch_opts}` triple whose `epoch_opts`
override the batch-wide options for that epoch). The result list is in request
order, one `{:ok, %Solution{}} | {:error, reason}` per epoch with the same
reasons `solve/4` returns; a per-epoch solve failure does not fail the batch.

## Options

The correction and geometry options match `solve/4` and apply to every epoch
unless a request's `epoch_opts` overrides them: `:ionosphere`, `:troposphere`,
`:klobuchar_alpha`, `:klobuchar_beta`, `:pressure_hpa`, `:temperature_k`,
`:relative_humidity`, `:initial_guess`, `:glonass_channels`, `:with_geodetic`,
`:max_pdop`, `:coarse_search`, `:robust`, and the `:huber` reweighting levers
(`:huber`, `:huber_k`, `:huber_sigma`, `:huber_max_iter`).

  * `:parallel` - fan the independent per-epoch solves across the crate's thread
    pool (default `true`). Element `i` is byte-for-byte identical to the serial
    result; this only changes throughput. Set `false` to force the serial path.

A batch-wide configuration error (a malformed `:huber`, `:coarse_search`,
`:max_pdop`, `:parallel`, `:glonass_channels`, or an unparseable epoch) fails
the whole call with `{:error, reason}` rather than producing a partial list.

Returns `{:ok, [per_epoch_result]}` or `{:error, reason}`.

# `solve_broadcast`

```elixir
@spec solve_broadcast(
  Sidereon.GNSS.Broadcast.t(),
  [observation()],
  epoch(),
  keyword()
) ::
  {:ok, Sidereon.GNSS.Positioning.Solution.t()} | {:error, term()}
```

Solve single-point positioning from broadcast ephemeris ALONE.

The explicit, named broadcast-only entry point: the supported real-time /
offline mode, where the satellite states come from a parsed broadcast
navigation product (`Sidereon.GNSS.Broadcast`) rather than a precise SP3
product. Broadcast ephemeris is decoded from the navigation message a receiver
already tracks, so it needs no network.

This is a thin wrapper over `solve/4` with a `Sidereon.GNSS.Broadcast` source,
bit-for-bit identical to calling `solve/4` directly; it makes the
broadcast-only contract explicit in the call site. `observations`, `epoch`, and
`opts` are exactly as in `solve/4`. Returns
`{:ok, %Sidereon.GNSS.Positioning.Solution{}}` or `{:error, reason}`.

# `solve_with_doppler`

```elixir
@spec solve_with_doppler(
  Sidereon.GNSS.SP3.t() | Sidereon.GNSS.Broadcast.t(),
  [observation()],
  [doppler_observation()],
  epoch(),
  keyword()
) :: {:ok, Sidereon.GNSS.Positioning.DopplerSolution.t()} | {:error, term()}
```

Solve receiver position from pseudoranges and attach a Doppler velocity solve.

`doppler_observations` accepts `{satellite_id, doppler_hz}` rows. A row may
also include `carrier_hz` and `sat_clock_drift_s_s`; omitted carrier defaults
to GPS L1 and omitted satellite clock drift defaults to zero.

Options match the atmospheric, initial-guess, geodetic, GLONASS-channel, and
Huber options from `solve/4`. This entry calls the core SPP+Doppler primitive
directly and does not run the `:max_pdop` or `:coarse_search` policy layer.

# `solve_with_fallback`

```elixir
@spec solve_with_fallback(
  [Sidereon.GNSS.SP3.t()],
  Sidereon.GNSS.Broadcast.t(),
  [observation()],
  epoch(),
  keyword()
) :: {:ok, Sidereon.GNSS.Positioning.SourcedSolution.t()} | {:error, term()}
```

Solve preferring precise SP3 products, falling back to broadcast ephemeris,
reporting which source produced the fix and how stale it is.

The precise path is tried first through the product-staleness selection layer
(`Sidereon.GNSS.Staleness`) at the receive `epoch`:

  * if a precise product covers the epoch it is used, and the result's source is
    `{:precise, metadata}` with `:exact` (zero staleness) metadata. The solve is
    bit-for-bit identical to `solve/4` on that SP3, and a solve failure here is a
    genuine error returned as `{:error, {:precise, reason}}`, never masked by a
    silent broadcast re-solve;
  * if a stale-but-within-cap precise product is selected and produces a fix,
    the source is `{:precise, metadata}` with `:nearest_prior` (nonzero
    staleness) metadata;
  * if the selected stale product cannot serve the epoch, or the precise
    selection is declined outright, broadcast produces the fix and the source is
    `{:broadcast, reason}` recording why precise was not used.

`precise` is a list of `Sidereon.GNSS.SP3` products (it may be empty, forcing
broadcast); `broadcast` is a `Sidereon.GNSS.Broadcast` product.

## Options

  * `:policy` - a `Sidereon.GNSS.Staleness.Policy` bounding how stale a precise
    product may be before broadcast is preferred (default: a three-day cap). A
    zero-second cap forces broadcast whenever no product covers the exact epoch.

The remaining options match `solve/4`: `:ionosphere`, `:troposphere`,
`:klobuchar_alpha`, `:klobuchar_beta`, `:pressure_hpa`, `:temperature_k`,
`:relative_humidity`, `:initial_guess`, `:with_geodetic`, and
`:glonass_channels`. The `:huber` and `:coarse_search` convergence aids are
not part of the fallback entry, which uses the reference solve on both paths.
The broadcast fallback solve applies the
broadcast NAV header's BeiDou and Galileo ionosphere coefficients, exactly as
`solve_broadcast/4`.

Returns `{:ok, %Sidereon.GNSS.Positioning.SourcedSolution{}}` or
`{:error, {:precise, reason}}` / `{:error, {:broadcast, reason}}` where `reason`
is the SPP solve error from the path that failed.

---

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