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

Satellite-geometry and mission-planning layer above the GNSS observables:
from a static receiver position and a precise (SP3) ephemeris, answer the
three planning questions: which satellites are visible, how good is the
geometry (dilution of precision), and when does each satellite rise and set.

This module solves no positioning problem; it reads satellite states through
`Sidereon.GNSS.Observables` and applies standard textbook GNSS geometry.

## Visibility

A satellite is *visible* when its topocentric elevation is at or above an
elevation mask. Azimuth and elevation come from `Sidereon.GNSS.Observables`, which
rotates the receiver-to-satellite line of sight into the local east-north-up
(ENU) frame at the receiver's geodetic latitude/longitude.

## Dilution of precision (DOP)

Dilution of precision summarises how the receiver-to-satellite geometry maps
range-measurement noise into solution uncertainty. From a design (geometry)
matrix `G` whose rows are the line-of-sight unit vectors plus a receiver-clock
column, and an optional diagonal weight matrix `W`, the cofactor matrix is

    Q = (G^T W G)^-1

a 4x4 symmetric matrix ordered `[x, y, z, clock]`. The position block is in
ECEF metres and the clock state is in the same length unit as the ranges.

### Sign and column convention

Each row is `[-e_x, -e_y, -e_z, 1]`, where `e` is the **ECEF** receiver-to-
satellite unit line of sight (the partial derivative of the predicted range
with respect to the receiver position is `-e`; the clock column is `+1`). The
geometry matrix is therefore built in ECEF, exactly as
`Sidereon.GNSS.Positioning` builds it; the horizontal/vertical split is taken
*after* inverting, by rotating the 3x3 position block into the local ENU frame
at the receiver's geodetic latitude/longitude:

    R = [[-sin l,         cos l,        0   ],
         [-sin p cos l,  -sin p sin l,  cos p],
         [ cos p cos l,   cos p sin l,  sin p]]

with `p` the geodetic latitude and `l` the longitude (radians); the rotated
block is `Q_enu = R Q_pos R^T`. The DOP scalars are then

  * `pdop = sqrt(qE + qN + qU)` (the ENU position block),
  * `hdop = sqrt(qE + qN)`,
  * `vdop = sqrt(qU)`,
  * `tdop = sqrt(Q[3][3])` (the clock variance),
  * `gdop = sqrt(Q[0][0] + Q[1][1] + Q[2][2] + Q[3][3])` (the cofactor trace,
    which is rotation invariant, so it equals the ENU-frame trace).

### Weights

The default is the unweighted geometric DOP (`W = I`), the standard textbook
cofactor `(G^T G)^-1`. An elevation weighting (`weights: :elevation`, with
`w = sin^2(elevation)`) is also available; it reproduces the weighting that a
least-squares positioning solve applies, and is what lets the DOP here be
cross-checked component-for-component against `Sidereon.GNSS.Positioning`'s
reported DOP for the same satellite set and epoch.

### Limitation

This is a single-receiver-clock (single-system) DOP. A mixed-constellation
geometry with one receiver clock per system (extra clock columns) is out of
scope; restrict the visible set to one system (e.g. `systems: ["G"]`) for a
well-posed DOP.

## Passes

A *pass* is a contiguous interval over which a satellite stays above the mask.
Rise and set are detected by threshold-crossing on the sampled elevation, so
they are resolved only to the sampling step `step_seconds`: a finer step gives
finer rise/set epochs.

# `dop_result`

```elixir
@type dop_result() :: %{
  gdop: float(),
  pdop: float(),
  hdop: float(),
  vdop: float(),
  tdop: float(),
  n_satellites: non_neg_integer(),
  satellites: [String.t()]
}
```

# `geometry_error`

```elixir
@type geometry_error() ::
  :invalid_receiver
  | :outside_coverage
  | :too_few_satellites
  | :singular_geometry
  | option_error()
```

# `option_error`

```elixir
@type option_error() :: {:invalid_option, atom()}
```

# `receiver`

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

# `visible_sat`

```elixir
@type visible_sat() :: %{
  satellite_id: String.t(),
  elevation_deg: float(),
  azimuth_deg: float()
}
```

# `dop`

```elixir
@spec dop(Sidereon.GNSS.SP3.t(), receiver(), NaiveDateTime.t(), keyword()) ::
  dop_result() | {:error, geometry_error()}
```

Dilution of precision for the visible satellites at `epoch`.

Builds the geometry matrix from the visible satellites' ECEF line-of-sight
unit vectors (rows `[-e_x, -e_y, -e_z, 1]`), forms `Q = (G^T W G)^-1`, rotates
the position block into ENU, and returns all five DOP scalars plus the
satellite count and ids.

## Options

In addition to the `visible/4` options (`:elevation_mask_deg`, `:systems`):

  * `:weights` - `:unit` (default, `W = I`, the standard geometric DOP) or
    `:elevation` (`w = sin^2(elevation)`, the least-squares weighting).
  * `:light_time` - apply the light-time / Sagnac line-of-sight corrections
    when forming the geometry (default `false`, the planning value). Set
    `true` to match a converged positioning geometry exactly.
  * `:satellites` - an explicit list of satellite ids to use instead of the
    visibility scan (still subject to predicting successfully); useful to pin
    the geometry to a known set.

Returns `%{gdop, pdop, hdop, vdop, tdop, n_satellites, satellites}` or a
tagged error: `{:error, :invalid_receiver}`, `{:error, :too_few_satellites}`
(fewer than four usable directions), `{:error, :singular_geometry}`, or
`{:error, :outside_coverage}`, or `{:error, {:invalid_option, key}}`. Never
raises.

# `dop_series`

```elixir
@spec dop_series(
  Sidereon.GNSS.SP3.t(),
  receiver(),
  {NaiveDateTime.t(), NaiveDateTime.t()},
  pos_integer(),
  keyword()
) :: [map()] | {:error, geometry_error()}
```

Per-epoch dilution of precision over a time window.

Samples `{t0, t1}` (inclusive of `t0`, up to and including `t1`) every
`step_seconds` and computes `dop/4` at each sample. Returns a list of
`%{epoch, gdop, pdop, hdop, vdop, tdop, n_satellites, satellites}` for the
epochs whose geometry yields a finite DOP; epochs with too few satellites or a
singular geometry are skipped. An empty or inverted window returns `[]`.

`opts` are the `dop/4` options. Malformed receivers, options, or windows
outside the SP3 coverage return tagged errors before calling the native
geometry code.

# `dop_with_convention`

```elixir
@spec dop_with_convention(
  [{{number(), number(), number()}, number()}],
  number(),
  number(),
  :geodetic_normal | :geocentric_radial
) ::
  {:ok,
   %{gdop: float(), pdop: float(), hdop: float(), vdop: float(), tdop: float()}}
  | {:error, atom()}
```

Dilution of precision from explicit line-of-sight rows, with an explicit
east-north-up convention for the horizontal/vertical split.

`rows` is a list of `{{e_x, e_y, e_z}, weight}` entries: `e` is the ECEF
receiver-to-satellite unit line of sight (at least four, unit length) and
`weight` the non-negative diagonal of `W`. `receiver_lat_rad`/
`receiver_lon_rad` are the receiver geodetic latitude/longitude in radians.

`convention` is `:geodetic_normal` (the default DOP path) or
`:geocentric_radial`, which rotates the position block into the
geocentric-radial ENU instead, changing only HDOP/VDOP (GDOP/PDOP/TDOP are
identical between conventions).

Returns `{:ok, %{gdop:, pdop:, hdop:, vdop:, tdop:}}` or `{:error, reason}`.

# `inv4`

```elixir
@spec inv4(tuple()) :: {:ok, tuple()} | :singular
```

Explicit 4x4 cofactor (adjugate / determinant) inverse.

`a` is a 4x4 matrix as a tuple of four 4-tuples. Returns `{:ok, inverse}` (same
shape) or `:singular` when the determinant is exactly zero. The `(i, j)`
cofactor is `(-1)^(i+j)` times the `(i, j)` minor; the inverse is the transpose
of the cofactor matrix over the determinant, so `inv[j][i] = cofactor(i, j) / det`.

# `passes`

```elixir
@spec passes(
  Sidereon.GNSS.SP3.t(),
  receiver(),
  {NaiveDateTime.t(), NaiveDateTime.t()},
  pos_integer(),
  keyword()
) :: [map()] | {:error, :invalid_receiver | :outside_coverage | option_error()}
```

Rise / peak / set passes for each satellite over a time window.

Samples `{t0, t1}` every `step_seconds` and, for each satellite, splits the
samples into contiguous runs above the elevation mask. Each run is one pass:

    %{
      satellite_id: String.t(),
      rise_epoch: NaiveDateTime.t(),
      set_epoch: NaiveDateTime.t(),
      peak_elevation_deg: float(),
      peak_epoch: NaiveDateTime.t()
    }

`rise_epoch` is the first sample above the mask and `set_epoch` the last; both
are resolved only to the sampling step (the true crossing lies within one
`step_seconds` of the reported epoch). A satellite already above the mask at
`t0`, or still above it at `t1`, yields a pass clamped to the window. `opts`
are the `visible/4` options. Malformed receivers, options, or windows outside
the SP3 coverage return tagged errors before calling the native geometry code.

# `visibility_series`

```elixir
@spec visibility_series(
  Sidereon.GNSS.SP3.t(),
  receiver(),
  {NaiveDateTime.t(), NaiveDateTime.t()},
  pos_integer(),
  keyword()
) ::
  [%{epoch: NaiveDateTime.t(), n_visible: non_neg_integer()}]
  | {:error, :invalid_receiver | :outside_coverage | option_error()}
```

Per-epoch count of visible satellites over a time window.

Samples `{t0, t1}` every `step_seconds` and returns a list of
`%{epoch, n_visible}`. An empty or inverted window returns `[]`. `opts` are the
`visible/4` options. Malformed receivers or options return tagged errors
before calling the native geometry code. Windows outside the SP3 coverage
return `{:error, :outside_coverage}` unless `extrapolate: true` is set.

# `visible`

```elixir
@spec visible(Sidereon.GNSS.SP3.t(), receiver(), NaiveDateTime.t(), keyword()) ::
  [visible_sat()]
  | {:error, :invalid_receiver | :outside_coverage | option_error()}
```

List the satellites visible from `receiver` at `epoch`, above the elevation
mask, sorted by elevation descending.

## Options

  * `:elevation_mask_deg` - minimum elevation in degrees (default `5.0`); a
    satellite is included iff its elevation is at or above this value.
  * `:systems` - keep only these constellations, given as leading-letter
    strings (e.g. `["G"]` for GPS, `["G", "E"]` for GPS + Galileo). Default:
    keep all systems.
  * `:extrapolate` - allow evaluation outside the parsed SP3 coverage.
    Default `false`.

Returns a list of `%{satellite_id, elevation_deg, azimuth_deg}` or a tagged
error for malformed input. Never raises.

---

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