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

A compact, fitted mean-element approximation of a satellite's orbit.

`Sidereon.GNSS.ReducedOrbit` fits a list of ECEF position samples into a compact
mean-element model that can be evaluated cheaply. It is **not** orbit
determination and **not** a substitute for SGP4 or precise ephemeris: it
deliberately discards short-period structure and reports the residual it leaves
behind (`rms_m`/`max_m` on the fit, and sample-backed `drift/3`).

## Models

Two models are available, chosen with the `:model` option on `fit/2`:

  * `:circular_secular` (the **default**) - a circular orbit intended for
    near-circular orbits (Galileo).
  * `:eccentric_secular` - adds eccentricity through a nonsingular `(h, k)`
    parameterization, recovering the radial `a·e` signal (~hundreds of km for
    GPS/BeiDou) while degrading smoothly to the circular model as `e -> 0`.

### `circular_secular`

A circular orbit (eccentricity fixed at zero) whose orbital plane precesses at
a constant nodal rate. At an offset `dt = t - t0` from the reference epoch the
angles advance linearly,

    u(t)    = arg_lat0 + n * dt          # argument of latitude
    raan(t) = raan0    + raan_rate * dt
    e       = 0

and the inertial (GCRS) position is the in-plane circle rotated by the node and
inclination, `r = Rz(raan) * Rx(i) * a * [cos u, sin u, 0]`.

The nodal rate `raan_rate` is **fitted**, but seeded from the J2 secular nodal
regression (Vallado, *Fundamentals of Astrodynamics and Applications*):

    raan_rate_j2 = -1.5 * n * J2 * (Re / a)^2 * cos(i)

Both the fitted value (`raan_rate_rad_s`) and the J2 seed (`raan_rate_j2_rad_s`)
are kept; `raan_rate_mode` is `"fitted_j2_seeded"`. The model does not claim to
be a pure J2 propagation.

### `eccentric_secular`

Eight free elements: the four circular plane elements plus `h = e·sin ω`,
`k = e·cos ω`, `L0` (mean argument of latitude at epoch), and `n`. The core
fit returns `e` and `ω`. At an offset `dt` the model advances
`λ = L0 + n·dt`, forms the mean anomaly `M = λ − ω`, solves Kepler's equation
`E − e·sin E = M`, and places the satellite at radius `r = a(1 − e·cos E)` and
argument of latitude `u = ω + ν`. The `(h, k)` form is nonsingular: at `e = 0`
it reproduces `circular_secular` exactly with `arg_lat0 = L0`. The struct then
carries `h`, `k`, `e`, and `arg_perigee_rad` (= ω).

## Frames

Fitting and evaluation run internally in **GCRS**; positions are returned in
**ECEF (ITRF) meters by default**, or GCRS via `frame: :gcrs`. ECEF velocity
includes the Earth-rotation transport term. Sample/query epochs are interpreted
consistently for the Earth-rotation conversion; the ECEF product (the primary
output) is self-consistent across the fit, evaluation, and drift.

## Expected accuracy

Representative drift values depend on the sample span, cadence, and orbit class.
Measure a fitted model with `drift/3` against caller-provided truth samples.

This is a compact approximation for caching or visibility, never a substitute
for precise source data.

# `epoch`

```elixir
@type epoch() ::
  NaiveDateTime.t()
  | {{integer(), integer(), integer()}, {integer(), integer(), number()}}
```

# `t`

```elixir
@type t() :: %Sidereon.GNSS.ReducedOrbit{
  a_m: float(),
  arg_lat_rad: float(),
  arg_perigee_rad: float() | nil,
  e: float(),
  epoch: NaiveDateTime.t(),
  fit: map(),
  frame: String.t(),
  h: float() | nil,
  i_rad: float(),
  k: float() | nil,
  mean_motion_rad_s: float(),
  model: String.t(),
  raan_rad: float(),
  raan_rate_j2_rad_s: float(),
  raan_rate_mode: String.t(),
  raan_rate_rad_s: float(),
  time_scale: String.t(),
  version: pos_integer()
}
```

# `vec3`

```elixir
@type vec3() :: %{x_m: float(), y_m: float(), z_m: float()}
```

# `drift`

```elixir
@spec drift(t(), [{epoch(), {number(), number(), number()}}], keyword()) ::
  {:ok, map()} | {:error, term()}
```

Evaluate the model error against truth samples.

Returns

    {:ok, %{per_epoch: [%{epoch:, error_m:}], max_m:, rms_m:, threshold_horizon:,
            requested:, used:}}

where `threshold_horizon` is the first epoch the ECEF error exceeds
`:threshold_m` (or `nil` if it never does / no threshold given).

# `fit`

```elixir
@spec fit(
  [{epoch(), {number(), number(), number()}}],
  keyword()
) :: {:ok, t()} | {:error, term()}
```

Fit a mean-element model to ECEF samples.

## Options

  * `:model` - `:circular_secular` (**default**) or `:eccentric_secular`. The
    circular model fixes eccentricity at zero (suited to near-circular orbits);
    the eccentric model recovers the `a·e` radial signal for GPS and other
    eccentric orbits. See the moduledoc accuracy table.
  * `:frame` - `:ecef` (default)
  * `:time_scale` - the scale the sample epochs are in (`"UTC"` default)

Epochs are interpreted in the model's time scale (recorded on the result). The
reference epoch `t0` is the earliest sample, so the result is independent of the
caller's sample order.

Returns `{:ok, %Sidereon.GNSS.ReducedOrbit{}}` or a tagged error:
`{:too_few_samples, got, required}`, `:invalid_window`, `:invalid_cadence`,
`:singular_plane_fit`, `:raan_ambiguous`, `{:unsupported_source_frame, frame}`,
`{:unsupported_model, model}`, `:transform_unavailable`, `:fit_did_not_converge`.

# `position`

```elixir
@spec position(t(), epoch(), keyword()) :: {:ok, vec3()} | {:error, term()}
```

Position of the model at `epoch`, ECEF (ITRF) meters by default.

Pass `frame: :gcrs` for the inertial position. Returns
`{:ok, %{x_m:, y_m:, z_m:}}` or `{:error, reason}`.

# `position_velocity`

```elixir
@spec position_velocity(t(), epoch(), keyword()) :: {:ok, map()} | {:error, term()}
```

Position and velocity of the model at `epoch`.

ECEF velocity includes the Earth-rotation transport term. Returns
`{:ok, %{position: %{x_m:, y_m:, z_m:}, velocity: %{vx_m_s:, vy_m_s:, vz_m_s:}}}`.

---

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