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

Carrier-phase precise-positioning primitives.

This is the first precise-positioning layer above the code and carrier-phase
combinations in `Sidereon.GNSS.IonosphereFree` / `Sidereon.GNSS.CarrierPhase`. It
solves one SP3-backed epoch from dual-frequency ionosphere-free code and phase
observations:

    P_IF_i = rho_i(x) + b - c * dt_sat_i + T_i
    L_IF_i = rho_i(x) + b - c * dt_sat_i + T_i + N_i

where `x` is the receiver ECEF position, `b` is the receiver clock in metres,
`T_i` is the optional a-priori slant tropospheric delay plus any estimated
residual zenith delay mapped to the line of sight, and `N_i` is one float
carrier-phase ambiguity per satellite, also in metres. The single-epoch state
is linearized and iterated over `[x, y, z, b, N_1, N_2, ...]`.

`solve_float/4` solves one epoch. `solve_float_epochs/3` solves a static
multi-epoch arc with one receiver position, one receiver clock per epoch, and
one ambiguity per satellite held constant across the arc. That multi-epoch
model is the first step where carrier phase can tighten position instead of
being absorbed entirely by one ambiguity per epoch. Multi-epoch and fixed
solves can also estimate one residual zenith troposphere delay over the arc
(`estimate_ztd: true`) after the a-priori Saastamoinen/Niell correction.

`solve_fixed_epochs/3` starts from the same multi-epoch float model, runs
LAMBDA/MLAMBDA integer least-squares on an explicit caller-supplied wavelength
grid, then re-solves position and per-epoch clocks with those ambiguities held
fixed.

## Observation format

Observations may be maps or tuples:

    %{satellite_id: "G05", code_m: 24_000_000.0, phase_m: 24_012_345.0}
    {"G05", 24_000_000.0, 24_012_345.0}

`code_m` and `phase_m` should normally be ionosphere-free combinations. Use
`Sidereon.GNSS.IonosphereFree.iono_free/4` and
`Sidereon.GNSS.IonosphereFree.iono_free_phase_cycles/4` to form them from raw
dual-frequency RINEX observations.

# `epoch_observations`

```elixir
@type epoch_observations() ::
  %{epoch: NaiveDateTime.t(), observations: [observation()]}
  | {NaiveDateTime.t(), [observation()]}
```

A set of code/phase observations for one epoch.

# `observation`

```elixir
@type observation() ::
  %{satellite_id: String.t(), code_m: number(), phase_m: number()}
  | {String.t(), number(), number()}
```

A dual-frequency ionosphere-free code/phase observation.

# `receiver`

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

A receiver ECEF position in metres.

# `solve_fixed_epochs`

```elixir
@spec solve_fixed_epochs(Sidereon.GNSS.SP3.t(), [epoch_observations()], keyword()) ::
  {:ok, Sidereon.GNSS.PrecisePositioning.FixedSolution.t()} | {:error, term()}
```

Solve a static multi-epoch position with integer-fixed ambiguities.

The function first solves the float multi-epoch model (`solve_float_epochs/3`),
converts each float ambiguity from metres to cycles using the explicit
`:ambiguity_wavelength_m` option, runs the LAMBDA/MLAMBDA integer
least-squares search, and re-solves the receiver position and per-epoch clocks
with the best integer ambiguities held fixed.

## Required option

  * `:ambiguity_wavelength_m` - either a positive scalar wavelength in metres
    for every satellite, or a map `%{"G05" => wavelength_m, ...}`.

## Additional options

  * `:integer_ratio_threshold` - minimum second-best / best weighted-score
    ratio for `metadata.integer_status == :fixed` (default `3.0`).
  * `:integer_search_radius_cycles` / `:integer_candidate_limit` - retained and
    still validated for backward compatibility, but no longer bound the search:
    integer resolution uses the LAMBDA method (decorrelation + reduction +
    MLAMBDA search), which finds the true integer-least-squares optimum for any
    geometry with no search box, so it cannot return
    `{:error, {:too_many_integer_candidates, ...}}`.
  * `:ambiguity_offset_m` - optional scalar or `%{"G05" => offset_m, ...}` map
    subtracted from each float ambiguity before converting to cycles and added
    back after fixing (default `0.0`). This is mainly for affine carrier-phase
    combinations such as wide-lane/narrow-lane fixing.

The fixed solution is returned even when the ratio test is not met; in that
case `metadata.integer_status` is `:not_fixed` so callers can reject it.

# `solve_float`

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

Solve a float-ambiguity carrier-phase position for one SP3-backed epoch.

`source` is a loaded `Sidereon.GNSS.SP3` product. `observations` is a list of
ionosphere-free code/phase pairs for one epoch. `epoch` is interpreted in the
SP3 product's time scale.

## Options

  * `:initial_guess` - `{x_m, y_m, z_m, clock_m}`. If omitted, the code
    observations are first passed through `Sidereon.GNSS.Positioning.solve/4`
    with ionosphere/troposphere disabled, and that code-only solution seeds
    the float solve.
  * `:spp_initial_guess` - code-only SPP seed used only when `:initial_guess`
    is omitted (default `{0, 0, 0, 0}`).
  * `:code_sigma_m` - code row standard deviation (default `1.0` m).
  * `:phase_sigma_m` - phase row standard deviation (default `0.01` m).
  * `:elevation_weighting` - when `true`, scale both code and phase row
    standard deviations by `1 / sin(elevation)` so low-elevation
    observations contribute less to the float, fixed, and ambiguity-covariance
    solves (default `false`).
  * `:max_iterations` - maximum nonlinear iterations (default `8`).
  * `:position_tolerance_m` - position-update convergence threshold
    (default `1.0e-4` m).
  * `:clock_tolerance_m` - receiver-clock update threshold (default
    `1.0e-4` m).
  * `:troposphere` - apply an a-priori Saastamoinen/Niell slant
    tropospheric delay to both code and phase (default `false`).
  * `:pressure_hpa` - surface pressure in hPa when `:troposphere` is true
    (default `1013.25`).
  * `:temperature_k` - surface temperature in kelvin when `:troposphere` is
    true (default `288.15`).
  * `:relative_humidity` - relative humidity fraction when `:troposphere` is
    true (default `0.5`).
  * `:tropo_mapping` - the tropospheric mapping function when `:troposphere`
    is true: `:niell` (the climatological default) or `{:vmf1, samples}` where
    `samples` is a non-empty, ascending list of `%{mjd:, ah:, aw:}` site-wise
    VMF1 `a`-coefficient samples (the Saastamoinen zenith delays are unchanged;
    only the mapping differs).
  * `:estimate_ztd` - on multi-epoch/fixed solves only, estimate one residual
    zenith troposphere delay in metres over the whole static arc, mapped with
    the Niell wet mapping factor. Requires `troposphere: true` (default
    `false`).
  * `:ztd_tolerance_m` - residual-ZTD update convergence threshold when
    `:estimate_ztd` is true (default `1.0e-4` m).

Returns `{:ok, %Solution{}}` or `{:error, reason}`. Reasons include
`:no_observations`, `{:too_few_satellites, used, 4}`,
`{:duplicate_observation, sat}`, `{:invalid_observation, entry}`,
`:invalid_initial_guess`, `{:invalid_sigma, key}`, `{:invalid_option, key}`,
`{:code_seed_failed, reason}`, `{:no_ephemeris, sat, reason}`,
`{:troposphere_failed, sat, reason}`, and `:singular_geometry`. If the
iteration limit is reached after a valid solve step, the function returns a
solution with `metadata.converged == false` and
`metadata.status == :max_iterations` so callers can inspect the residuals and
decide whether to reject it.

# `solve_float_epochs`

```elixir
@spec solve_float_epochs(Sidereon.GNSS.SP3.t(), [epoch_observations()], keyword()) ::
  {:ok, Sidereon.GNSS.PrecisePositioning.MultiEpochSolution.t()}
  | {:error, term()}
```

Solve a static multi-epoch float-ambiguity carrier-phase position.

`epoch_observations` is a list of `%{epoch: epoch, observations: obs}` maps or
`{epoch, obs}` tuples. The receiver position is static across the whole arc,
each epoch gets its own receiver clock, and each satellite gets one ambiguity
held constant across every epoch where that satellite appears.

This model is still float ambiguity only. It does not fix integer ambiguities
or estimate a stochastic PPP process, but it lets changing geometry across the
arc separate position from carrier ambiguities.

Options are the same as `solve_float/4`, plus:

  * `:ambiguity_tolerance_m` - maximum ambiguity-update convergence threshold
    (default `1.0e-4` m).

Returns `{:ok, %MultiEpochSolution{}}` or `{:error, reason}`. Reasons include
`:no_epochs`, `{:too_few_epochs, used, 2}`, `{:duplicate_epoch, epoch}`,
`{:too_few_epoch_observations, epoch, used, 4}`,
`{:too_few_equations, equations, unknowns}`, and the same observation,
option, ephemeris, seeding, and geometry errors as `solve_float/4`.

# `solve_ppp_auto_init_fixed`

```elixir
@spec solve_ppp_auto_init_fixed(Sidereon.GNSS.SP3.t(), list(), keyword()) ::
  {:ok, map()} | {:error, term()}
```

Solve a static multi-epoch integer-fixed PPP arc with SPP auto-initialization,
delegating the whole driver (auto-init seed, the float solve, the LAMBDA
integer fix, and the ambiguity-conditioned re-solve) to the `sidereon-core`
`solve_ppp_auto_init_fixed` kernel.

Options match `solve_fixed_epochs/3` plus the auto-init options documented on
`solve_ppp_auto_init_float/3`. Returns `{:ok, solution}` or `{:error, reason}`.

# `solve_ppp_auto_init_float`

```elixir
@spec solve_ppp_auto_init_float(Sidereon.GNSS.SP3.t(), list(), keyword()) ::
  {:ok, map()} | {:error, term()}
```

Solve a static multi-epoch float PPP arc with SPP auto-initialization,
delegating the whole driver (the SPP code seed, the mean-position and per-epoch
clock seeds, the phase-minus-code float ambiguity seeds, and the static float
solve) to the `sidereon-core` `solve_ppp_auto_init_float` kernel.

This is a thin delegation to the core auto-init driver: the seed the existing
`solve_float_epochs/3` builds in Elixir is now formed inside the kernel. The
`epoch_observations` and the measurement/tropo/correction options are the same
as `solve_float_epochs/3`. Auto-init specific options:

  * `:initial_guess` - `%{position: {x, y, z}, clock_m: c}` to bypass the SPP
    seed entirely, or absent to run the per-epoch SPP auto-init
  * `:spp_initial_guess` - `{x, y, z, b}` SPP cold-start (default all-zero)
  * `:spp_troposphere` - apply the troposphere in the SPP seed (default `false`)
  * `:spp_met` - `%{pressure_hpa:, temperature_k:, relative_humidity:}` for the
    SPP seed troposphere (default standard atmosphere)

Returns `{:ok, solution}` or `{:error, reason}`.

# `solve_ppp_fixed`

```elixir
@spec solve_ppp_fixed(
  Sidereon.GNSS.SP3.t(),
  list(),
  Sidereon.GNSS.PrecisePositioning.MultiEpochSolution.t(),
  keyword()
) ::
  {:ok, Sidereon.GNSS.PrecisePositioning.FixedSolution.t()} | {:error, term()}
```

Solve a static multi-epoch integer-fixed PPP arc from an existing float solution.

# `solve_ppp_float`

```elixir
@spec solve_ppp_float(Sidereon.GNSS.SP3.t(), list(), map(), keyword()) ::
  {:ok, Sidereon.GNSS.PrecisePositioning.MultiEpochSolution.t()}
  | {:error, term()}
```

Solve a static multi-epoch float PPP arc from an explicit initial state.

---

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