Current section

Files

Jump to
sidereon lib sidereon gnss observables.ex
Raw

lib/sidereon/gnss/observables.ex

defmodule Sidereon.GNSS.Observables do
@moduledoc """
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
}
"""
alias Sidereon.Constants, as: SidereonConstants
alias Sidereon.GNSS.{Broadcast, PreciseEphemeris, SP3, Time}
alias Sidereon.GNSS.Core.Constants
alias Sidereon.GNSS.Core.Types
alias Sidereon.GNSS.PreciseEphemeris.Interpolant
alias Sidereon.NIF
@type vec3 :: {float(), float(), float()}
@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()
}
@doc """
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.
"""
@spec predict(SP3.t() | Broadcast.t(), String.t(), vec3() | map(), NaiveDateTime.t(), keyword()) ::
{:ok, observables()} | {:error, term()}
def predict(source, satellite_id, receiver_ecef, epoch, opts \\ [])
def predict(%SP3{} = source, satellite_id, receiver_ecef, %NaiveDateTime{} = epoch, opts)
when is_binary(satellite_id) do
do_predict(source, satellite_id, receiver_ecef, epoch, opts)
end
def predict(%Broadcast{} = source, satellite_id, receiver_ecef, %NaiveDateTime{} = epoch, opts)
when is_binary(satellite_id) do
do_predict(source, satellite_id, receiver_ecef, epoch, opts)
end
defp do_predict(source, satellite_id, receiver_ecef, epoch, opts) do
carrier_hz = Keyword.get(opts, :carrier_hz, Constants.gps_l1_hz())
light_time? = Keyword.get(opts, :light_time, true)
sagnac? = Keyword.get(opts, :sagnac, true)
with {:ok, receiver} <- Types.normalize_ecef(receiver_ecef),
{:ok, system_letter, prn} <- Types.parse_sat_id(satellite_id),
:ok <- validate_source_coverage(source, epoch, opts),
{:ok, result} <-
core_predict(
source,
system_letter,
prn,
receiver,
epoch,
carrier_hz,
light_time?,
sagnac?
) do
{:ok, to_observables_map(result, epoch)}
end
end
@doc """
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`.
"""
@spec predict_all(SP3.t(), vec3() | map(), NaiveDateTime.t(), keyword()) ::
%{optional(String.t()) => {:ok, observables()} | {:error, term()}}
def predict_all(%SP3{} = sp3, receiver_ecef, %NaiveDateTime{} = epoch, opts \\ []) do
sp3
|> SP3.satellite_ids()
|> Map.new(fn sat_id -> {sat_id, predict(sp3, sat_id, receiver_ecef, epoch, opts)} end)
end
@doc """
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`.
"""
@spec predict_batch(SP3.t(), [{String.t(), vec3() | map(), NaiveDateTime.t()}], keyword()) ::
[{:ok, observables()} | {:error, term()}]
def predict_batch(%SP3{handle: handle}, requests, opts \\ []) when is_list(requests) do
carrier_hz = Keyword.get(opts, :carrier_hz, Constants.gps_l1_hz())
light_time? = Keyword.get(opts, :light_time, true)
sagnac? = Keyword.get(opts, :sagnac, true)
prepared = Enum.map(requests, &prepare_batch_request/1)
nif_requests = for {:ok, {tuple, _epoch}} <- prepared, do: tuple
nif_results =
case nif_requests do
[] -> []
_ -> NIF.sp3_predict_batch(handle, nif_requests, carrier_hz, light_time?, sagnac?)
end
stitch_batch(prepared, nif_results)
rescue
e in ErlangError -> Enum.map(requests, fn _ -> {:error, e.original} end)
end
# Normalize one batch request into the NIF tuple plus the epoch (kept for the
# transmit-time reconstruction), or surface the per-request error.
defp prepare_batch_request({satellite_id, receiver_ecef, %NaiveDateTime{} = epoch}) when is_binary(satellite_id) do
with {:ok, receiver} <- Types.normalize_ecef(receiver_ecef),
{:ok, system_letter, prn} <- Types.parse_sat_id(satellite_id) do
{jd_whole, jd_fraction} = Time.epoch_to_split_jd(epoch)
{:ok, {{system_letter, prn, jd_whole, jd_fraction, receiver}, epoch}}
end
end
defp prepare_batch_request(_request), do: {:error, :invalid_request}
# Walk the prepared requests, consuming one core result per valid request so
# the returned list stays index-aligned with the input.
defp stitch_batch([], _results), do: []
defp stitch_batch([{:error, _reason} = err | rest], results), do: [err | stitch_batch(rest, results)]
defp stitch_batch([{:ok, {_tuple, epoch}} | rest], [result | results]) do
decoded =
case result do
{:ok, raw} -> {:ok, to_observables_map(raw, epoch)}
{:error, _reason} = err -> err
end
[decoded | stitch_batch(rest, results)]
end
@type range_request :: {String.t(), vec3() | map(), number()}
@type range_result :: %{
geometric_range_m: float(),
sat_clock_s: float() | nil,
transmit_time_j2000_s: float(),
sat_pos_ecef_m: vec3()
}
@typedoc "One emission-media request, `{satellite_id, emission_epoch_j2000_s}`."
@type emission_media_request :: {String.t(), number()}
@typedoc "Index-aligned emission-state and media-delay arrays."
@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]
}
@doc """
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`.
"""
@spec predict_ranges(SP3.t() | PreciseEphemeris.t() | Interpolant.t(), [range_request()], keyword()) ::
{:ok, [range_result()]} | {:error, term()}
def predict_ranges(source, requests, opts \\ []) when is_list(requests) do
light_time? = Keyword.get(opts, :light_time, true)
sagnac? = Keyword.get(opts, :sagnac, true)
with {:ok, handle} <- source_handle(source),
{:ok, nif_requests} <- prepare_range_requests(requests) do
case NIF.predict_ranges_batch(handle, nif_requests, light_time?, sagnac?) do
{:ok, rows} -> {:ok, Enum.map(rows, &to_range_map/1)}
{:error, _} = err -> err
other -> {:error, other}
end
end
rescue
e in ErlangError -> {:error, e.original}
end
defp source_handle(%SP3{handle: handle}), do: {:ok, handle}
defp source_handle(%PreciseEphemeris{handle: handle}), do: {:ok, handle}
defp source_handle(%Interpolant{handle: handle}), do: {:ok, handle}
defp source_handle(_source), do: {:error, :invalid_source}
defp prepare_range_requests(requests) do
requests
|> Enum.reduce_while({:ok, []}, fn request, {:ok, acc} ->
case prepare_range_request(request) do
{:ok, tuple} -> {:cont, {:ok, [tuple | acc]}}
{:error, _} = err -> {:halt, err}
end
end)
|> case do
{:ok, tuples} -> {:ok, Enum.reverse(tuples)}
{:error, _} = err -> err
end
end
defp prepare_range_request({satellite_id, receiver_ecef, t_rx_j2000_s})
when is_binary(satellite_id) and is_number(t_rx_j2000_s) do
with {:ok, {x, y, z}} <- Types.normalize_ecef(receiver_ecef),
{:ok, system_letter, prn} <- Types.parse_sat_id(satellite_id) do
{:ok, {system_letter, prn, {x, y, z}, t_rx_j2000_s * 1.0}}
end
end
defp prepare_range_request(_request), do: {:error, :invalid_request}
defp to_range_map({geometric_range_m, sat_clock_s, transmit_time_j2000_s, sat_pos_ecef_m}) do
%{
geometric_range_m: geometric_range_m,
sat_clock_s: sat_clock_s,
transmit_time_j2000_s: transmit_time_j2000_s,
sat_pos_ecef_m: sat_pos_ecef_m
}
end
@doc """
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.
"""
@spec emission_media_batch(
SP3.t() | PreciseEphemeris.t() | Interpolant.t(),
[emission_media_request()],
vec3() | map(),
keyword()
) :: {:ok, emission_media_batch()} | {:error, term()}
def emission_media_batch(source, requests, receiver_ecef, opts \\ []) when is_list(requests) do
with {:ok, handle} <- source_handle(source),
{:ok, nif_requests} <- prepare_emission_requests(requests),
{:ok, receiver} <- Types.normalize_ecef(receiver_ecef),
{:ok, carrier_hz} <- emission_number(Keyword.get(opts, :carrier_hz, Constants.gps_l1_hz()), :carrier_hz),
{:ok, troposphere} <- emission_troposphere(Keyword.get(opts, :troposphere, false), opts),
{:ok, ionosphere} <- emission_ionosphere(Keyword.get(opts, :ionosphere)),
{:ok, min_elevation_rad} <- min_elevation_rad(Keyword.get(opts, :min_elevation_deg)) do
case NIF.emission_media_batch(
handle,
nif_requests,
receiver,
carrier_hz / 1.0,
troposphere,
ionosphere,
min_elevation_rad
) do
{:ok, tuple} -> {:ok, emission_media_map(tuple)}
{:error, _} = err -> err
other -> {:error, other}
end
end
rescue
e in ErlangError -> {:error, e.original}
end
defp prepare_emission_requests(requests) do
requests
|> Enum.reduce_while({:ok, []}, fn request, {:ok, acc} ->
case prepare_emission_request(request) do
{:ok, tuple} -> {:cont, {:ok, [tuple | acc]}}
{:error, _} = err -> {:halt, err}
end
end)
|> case do
{:ok, tuples} -> {:ok, Enum.reverse(tuples)}
{:error, _} = err -> err
end
end
defp prepare_emission_request({satellite_id, emission_epoch_j2000_s})
when is_binary(satellite_id) and is_number(emission_epoch_j2000_s) do
with {:ok, system_letter, prn} <- Types.parse_sat_id(satellite_id) do
{:ok, {system_letter, prn, emission_epoch_j2000_s / 1.0}}
end
end
defp prepare_emission_request(_request), do: {:error, :invalid_request}
defp emission_troposphere(false, _opts), do: {:ok, nil}
defp emission_troposphere(nil, _opts), do: {:ok, nil}
defp emission_troposphere(true, opts) do
emission_met_tuple(opts)
end
defp emission_troposphere(tropo_opts, _opts) when is_list(tropo_opts) do
emission_met_tuple(tropo_opts)
end
defp emission_troposphere(_other, _opts), do: {:error, {:invalid_option, :troposphere}}
defp emission_met_tuple(opts) do
with {:ok, pressure_hpa} <-
emission_number(Keyword.get(opts, :pressure_hpa, SidereonConstants.surface_met_pressure_hpa()), :troposphere),
{:ok, temperature_k} <-
emission_number(
Keyword.get(opts, :temperature_k, SidereonConstants.surface_met_temperature_k()),
:troposphere
),
{:ok, relative_humidity} <-
emission_number(
Keyword.get(opts, :relative_humidity, SidereonConstants.surface_met_relative_humidity()),
:troposphere
) do
{:ok, {pressure_hpa, temperature_k, relative_humidity}}
end
end
defp emission_ionosphere(nil), do: {:ok, nil}
defp emission_ionosphere(false), do: {:ok, nil}
defp emission_ionosphere({:klobuchar, %{alpha: alpha, beta: beta}}),
do: emission_ionosphere({:klobuchar, alpha, beta})
defp emission_ionosphere({:klobuchar, alpha, beta}) do
with {:ok, alpha} <- emission_tuple4(alpha, :ionosphere),
{:ok, beta} <- emission_tuple4(beta, :ionosphere) do
{:ok, {"klobuchar", alpha, beta}}
end
end
defp emission_ionosphere({:ionex, handle}) when is_reference(handle), do: {:ok, {"ionex", handle}}
defp emission_ionosphere(_other), do: {:error, {:invalid_option, :ionosphere}}
defp min_elevation_rad(nil), do: {:ok, nil}
defp min_elevation_rad(deg) when is_number(deg), do: {:ok, deg / 1.0 * :math.pi() / 180.0}
defp min_elevation_rad(_other), do: {:error, {:invalid_option, :min_elevation_deg}}
defp emission_number(value, _option) when is_number(value), do: {:ok, value / 1.0}
defp emission_number(_value, option), do: {:error, {:invalid_option, option}}
defp emission_media_map({positions, clocks, ionosphere_slant_delays, troposphere_delays, statuses, element_errors}) do
%{
positions_ecef_m: positions,
clocks_s: clocks,
ionosphere_slant_delays_m: ionosphere_slant_delays,
troposphere_delays_m: troposphere_delays,
statuses: statuses,
element_errors: element_errors
}
end
defp core_predict(%SP3{handle: handle}, system_letter, prn, receiver, epoch, carrier_hz, light_time?, sagnac?) do
{jd_whole, jd_fraction} = Time.epoch_to_split_jd(epoch)
case NIF.sp3_observables(
handle,
system_letter,
prn,
jd_whole,
jd_fraction,
receiver,
carrier_hz,
light_time?,
sagnac?
) do
{:ok, result} -> {:ok, result}
{:error, _} = err -> err
other -> {:error, other}
end
rescue
e in ErlangError -> {:error, e.original}
end
defp core_predict(%Broadcast{handle: handle}, system_letter, prn, receiver, epoch, carrier_hz, light_time?, sagnac?) do
with {:ok, t_j2000_s} <- Time.epoch_to_j2000_seconds_fractional(epoch) do
case NIF.broadcast_observables(
handle,
system_letter,
prn,
t_j2000_s,
receiver,
carrier_hz,
light_time?,
sagnac?
) do
{:ok, result} -> {:ok, result}
{:error, _} = err -> err
other -> {:error, other}
end
end
rescue
e in ErlangError -> {:error, e.original}
end
defp to_observables_map(
{[
range,
range_rate,
doppler_hz,
sat_clock_s,
elevation_deg,
azimuth_deg,
transmit_offset_us,
_transmit_time_j2000_s
], [los, sat_pos, sat_velocity]},
epoch
) do
transmit_time =
if transmit_offset_us == 0 do
epoch
else
NaiveDateTime.add(epoch, -transmit_offset_us, :microsecond)
end
%{
geometric_range_m: range,
range_rate_m_s: range_rate,
doppler_hz: doppler_hz,
sat_clock_s: sat_clock_s,
elevation_deg: elevation_deg,
azimuth_deg: azimuth_deg,
transmit_time: transmit_time,
los_unit: los,
sat_pos_ecef_m: sat_pos,
sat_velocity_m_s: sat_velocity
}
end
defp validate_source_coverage(%SP3{} = sp3, epoch, opts) do
if extrapolate?(opts) or SP3.covers_epoch?(sp3, epoch) do
:ok
else
{:error, :outside_coverage}
end
end
defp validate_source_coverage(_source, _epoch, _opts), do: :ok
defp extrapolate?(opts) when is_list(opts), do: Keyword.get(opts, :extrapolate, false) == true
defp extrapolate?(_opts), do: false
defp emission_tuple4({a, b, c, d}, option), do: emission_tuple4([a, b, c, d], option)
defp emission_tuple4([a, b, c, d], _option) when is_number(a) and is_number(b) and is_number(c) and is_number(d),
do: {:ok, {a / 1.0, b / 1.0, c / 1.0, d / 1.0}}
defp emission_tuple4(_value, option), do: {:error, {:invalid_option, option}}
end