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

Predict the GNSS observables a receiver at a known ECEF position would see for
a satellite, from a precise (SP3) or broadcast ephemeris source.

This is the forward model behind the question "is this measurement physically
plausible?": given a receiver position, a satellite, and a receive epoch, it
computes the geometric range, the line-of-sight range rate, the L1 Doppler,
the topocentric azimuth/elevation, the satellite clock offset, and the signal
transmit time. The Rust core evaluates the loaded SP3 or broadcast ephemeris
handle and applies standard textbook GNSS geometry; this module keeps only the
Elixir API shape and result mapping. It never solves the inverse
(positioning) problem.

## Algorithm (standard GNSS geometry)

* **Light-time / transmit-time correction.** The signal seen at the receive
  epoch `t_rx` left the satellite earlier, at
  `t_tx = t_rx - |r_sat(t_tx) - r_rx| / c`. This is solved by fixed-point
  iteration starting from `t_tx = t_rx`; a couple of iterations converge to
  sub-millimetre level for a coarse receiver position. The satellite state is
  evaluated at the fractional epoch `t_tx` (the SP3 spline is sampled at
  sub-second precision).

* **Sagnac / Earth-rotation correction.** During the travel time `tau` the
  Earth-fixed (ECEF) frame rotates by `omega_e * tau`. The satellite position
  computed in the ECEF frame at `t_tx` is rotated about the Z axis by
  `Rz(omega_e * tau)` into the receive-epoch ECEF frame before differencing,
  with `omega_e = 7.2921151467e-5 rad/s`. This is the Sagnac (Earth-rotation)
  correction.

* **Geometric range** is `|r_sat_rot - r_rx|` in metres, and the
  line-of-sight unit vector points from the receiver to the satellite.

* **Range rate.** The satellite velocity at `t_tx` is obtained by central
  finite difference of `Sidereon.GNSS.SP3.position/3` (+/- 0.5 s). For a static
  receiver (`v_rx = 0`) the range rate is the LOS projection
  `los . (v_sat - v_rx)`, which equals `d(range)/dt`.

* **Doppler (IS-GPS-200 L1 carrier).** `doppler_hz = -range_rate * f / c`
  with the L1 carrier `f = 1575.42 MHz` and `c = 299792458 m/s`.

## Sign conventions

`range_rate_m_s` is the time derivative of the geometric range: it is
**negative when the satellite is approaching** (range decreasing) and positive
when receding. The Doppler shift is the negative of the (scaled) range rate, so
an **approaching satellite gives a positive Doppler** and a receding satellite
a negative one.

## Result map

    %{
      geometric_range_m: float(),    # metres
      range_rate_m_s:    float(),    # d(range)/dt; negative = approaching
      doppler_hz:        float(),    # = -range_rate * carrier / c; + = approaching
      sat_clock_s:       float() | nil,  # SP3 clock offset at transmit time
      elevation_deg:     float(),    # topocentric elevation
      azimuth_deg:       float(),    # topocentric azimuth, [0, 360)
      transmit_time:     NaiveDateTime.t(),  # t_tx
      los_unit:          {float(), float(), float()},  # receiver -> satellite, ECEF unit
      sat_pos_ecef_m:    {float(), float(), float()},  # Sagnac-rotated sat position
      sat_velocity_m_s:  {float(), float(), float()}   # Sagnac-rotated sat velocity
    }

# `emission_media_batch`

```elixir
@type emission_media_batch() :: %{
  positions_ecef_m: [vec3() | nil],
  clocks_s: [float() | nil],
  ionosphere_slant_delays_m: [float() | nil],
  troposphere_delays_m: [float() | nil],
  statuses: [:valid | :gap | :below_elevation_cutoff | :error],
  element_errors: [term() | nil]
}
```

Index-aligned emission-state and media-delay arrays.

# `emission_media_request`

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

One emission-media request, `{satellite_id, emission_epoch_j2000_s}`.

# `observables`

```elixir
@type observables() :: %{
  geometric_range_m: float(),
  range_rate_m_s: float(),
  doppler_hz: float(),
  sat_clock_s: float() | nil,
  elevation_deg: float(),
  azimuth_deg: float(),
  transmit_time: NaiveDateTime.t(),
  los_unit: vec3(),
  sat_pos_ecef_m: vec3(),
  sat_velocity_m_s: vec3()
}
```

# `range_request`

```elixir
@type range_request() :: {String.t(), vec3() | map(), number()}
```

# `range_result`

```elixir
@type range_result() :: %{
  geometric_range_m: float(),
  sat_clock_s: float() | nil,
  transmit_time_j2000_s: float(),
  sat_pos_ecef_m: vec3()
}
```

# `vec3`

```elixir
@type vec3() :: {float(), float(), float()}
```

# `emission_media_batch`

```elixir
@spec emission_media_batch(
  Sidereon.GNSS.SP3.t()
  | Sidereon.GNSS.PreciseEphemeris.t()
  | Sidereon.GNSS.PreciseEphemeris.Interpolant.t()
  | Sidereon.GNSS.PreciseEphemeris.InterpolantArtifact.t(),
  [emission_media_request()],
  vec3() | map(),
  keyword()
) :: {:ok, emission_media_batch()} | {:error, term()}
```

Predict emission-epoch satellite states and media delays in one batch call.

Each request is `{satellite_id, emission_epoch_j2000_s}`. The source is a
parsed SP3 product, a sample-built precise source, or any
`Sidereon.GNSS.PreciseEphemeris.Interpolant`, including one opened from
artifact bytes. The output is a map of index-aligned arrays:

    %{
      positions_ecef_m: [vec3() | nil],
      clocks_s: [float() | nil],
      ionosphere_slant_delays_m: [float() | nil],
      troposphere_delays_m: [float() | nil],
      statuses: [:valid | :gap | :below_elevation_cutoff | :error],
      element_errors: [term() | nil]
    }

Options:

  * `:carrier_hz` - carrier frequency for ionospheric group delay, default
    GPS L1.
  * `:troposphere` - `false`, `true`, or a keyword list with `:pressure_hpa`,
    `:temperature_k`, and `:relative_humidity`.
  * `:ionosphere` - `nil`, `{:klobuchar, alpha, beta}`,
    `{:klobuchar, %{alpha: alpha, beta: beta}}`, or `{:ionex, handle}`.
  * `:min_elevation_deg` - optional minimum receiver elevation. Rows below
    the cutoff keep state and clock outputs but have nil media delays.

# `predict`

```elixir
@spec predict(
  Sidereon.GNSS.SP3.t() | Sidereon.GNSS.Broadcast.t(),
  String.t(),
  vec3() | map(),
  NaiveDateTime.t(),
  keyword()
) :: {:ok, observables()} | {:error, term()}
```

Predict the observables for `satellite_id` seen from `receiver_ecef` at `epoch`.

`receiver_ecef` is the static receiver position in ITRF/ECEF metres, given as
`{x_m, y_m, z_m}` or `%{x_m: _, y_m: _, z_m: _}`. `epoch` is the receive epoch,
a `NaiveDateTime` (interpreted in the ephemeris source's own time scale).

## Options

  * `:carrier_hz` - carrier frequency for the Doppler, default the L1 carrier
    `1575.42 MHz`.
  * `:light_time` - apply the light-time / transmit-time correction, default
    `true`. When `false`, the satellite is evaluated at `epoch`.
  * `:sagnac` - apply the Sagnac / Earth-rotation correction, default `true`.
  * `:extrapolate` - for SP3 sources, allow evaluation outside the parsed
    product coverage. Default `false`.

Returns `{:ok, observables}`, `{:error, :invalid_receiver}` for a malformed
receiver position, or propagates any ephemeris position error (e.g. an unknown
satellite or a malformed satellite token) verbatim as
`{:error, reason}`. Never raises.

# `predict_all`

```elixir
@spec predict_all(Sidereon.GNSS.SP3.t(), vec3() | map(), NaiveDateTime.t(), keyword()) ::
  %{
    optional(String.t()) =&gt; {:ok, observables()} | {:error, term()}
  }
```

Predict observables for every satellite in the product, seen from `receiver_ecef`.

Returns a map `satellite_id => {:ok, observables} | {:error, reason}`, so one
satellite failing (e.g. no estimate at this epoch) does not sink the batch.
Options are the same as `predict/5`.

# `predict_batch`

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

Predict observables for many `{satellite_id, receiver_ecef, epoch}` requests
against one loaded SP3 product in a single NIF call.

Each request is fully independent (its own satellite, receiver, and epoch), so
one batch can mix many satellites, receivers, and epochs. The result list is
index-aligned with `requests`: element `i` is `{:ok, observables}` or
`{:error, reason}` for `requests[i]`, so one bad request does not sink the
batch. The valid requests are predicted as a batch inside the core (one
boundary crossing); options are the same as `predict/5`.

# `predict_ranges`

```elixir
@spec predict_ranges(
  Sidereon.GNSS.SP3.t()
  | Sidereon.GNSS.PreciseEphemeris.t()
  | Sidereon.GNSS.PreciseEphemeris.Interpolant.t()
  | Sidereon.GNSS.PreciseEphemeris.InterpolantArtifact.t(),
  [range_request()],
  keyword()
) :: {:ok, [range_result()]} | {:error, term()}
```

Predict geometry-only ranges for many `{satellite_id, receiver_ecef, t_rx_j2000_s}`
requests against one precise-ephemeris source in a single NIF call.

`source` is a loaded `Sidereon.GNSS.SP3` product, a
`Sidereon.GNSS.PreciseEphemeris` sample-built source, or a
`Sidereon.GNSS.PreciseEphemeris.Interpolant` cached source. Each request
carries its own satellite token, static receiver ECEF position
(`{x_m, y_m, z_m}` or `%{x_m: _, y_m: _, z_m: _}`), and receive epoch as
**seconds since J2000 in the source's own time scale**.

This is the transmit-time geometry a range-only consumer needs, without the
Doppler / topocentric fields of `predict/5`. On success returns
`{:ok, results}` where each result is a map:

    %{
      geometric_range_m:      float(),         # metres, after light-time + Sagnac
      sat_clock_s:            float() | nil,   # satellite clock at transmit time
      transmit_time_j2000_s:  float(),         # transmit epoch, seconds since J2000
      sat_pos_ecef_m:         {float(), float(), float()}  # Sagnac-transported sat position
    }

The core range batch aborts on the first failing request, so a malformed
request or an ephemeris error (unknown satellite, epoch out of coverage)
returns `{:error, reason}` for the whole call. Never raises.

## Options

  * `:light_time` - apply the light-time / transmit-time correction, default
    `true`. When `false`, the satellite is evaluated at the receive epoch.
  * `:sagnac` - apply the Sagnac / Earth-rotation correction, default `true`.

---

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