defmodule Sidereon.GNSS.Navigation.LNAV do @moduledoc """ GPS L1 C/A LNAV navigation message synthesis and decoding (subframes 1-3). The legacy navigation (LNAV) message is the data stream modulated onto the GPS L1 C/A signal at 50 bits per second. Its structure is defined in IS-GPS-200 (Section 20.3): the message is organized into 1500-bit *frames*, each frame being five 300-bit *subframes*, and each subframe being ten 30-bit *words*. Every word carries 24 source data bits (most significant first) followed by 6 parity bits. This module covers the clock and ephemeris subframes: * Subframe 1 - SV clock correction and health (IS-GPS-200 Table 20-I). * Subframe 2 - first half of the ephemeris (IS-GPS-200 Table 20-II). * Subframe 3 - second half of the ephemeris (IS-GPS-200 Table 20-III). The first word of every subframe is the telemetry (TLM) word; the second is the hand-over word (HOW). Both are described in IS-GPS-200 Section 20.3.3. ## Words and bits A subframe is represented as a flat list of 300 bits (`0`/`1`), most significant bit first, with the ten words concatenated in transmission order. `word_length/0` is 30 and `subframe_length/0` is 300. ## Parameters and units `encode/2` and `decode/1` exchange an `Sidereon.GNSS.Navigation.LNAV.Ephemeris` struct whose fields hold the natural engineering-unit values (the products of the transmitted integers and their IS-GPS-200 scale factors). See that struct's documentation for the per-field units. Angular ephemeris quantities use *semicircles* (and semicircles/second), the harmonic correction terms use radians, distances use meters, and clock/time quantities use seconds, exactly as tabulated in IS-GPS-200. ## Parity The 6 parity bits of each word are produced by the (32, 26) Hamming code of IS-GPS-200 Section 20.3.5.2 and Table 20-XIV, including the rule that the two trailing parity bits of the previous word (`D29*`, `D30*`) feed the current word and that `D30*` complements the 24 transmitted data bits. The last two data bits of the HOW and of word 10 are solved so that those words' `D29`/`D30` parity bits are zero, per IS-GPS-200 Section 20.3.3.2. At the start of each subframe the previous parity bits are seeded to zero, producing self-consistent stand-alone subframes. The bit packing, parity, scaling, and range validation are implemented in the Rust core (`sidereon_core::navigation::lnav`); this module marshals the parameter struct across the NIF and maps the result back to the public shapes. ## Examples iex> Sidereon.GNSS.Navigation.LNAV.word_length() 30 iex> Sidereon.GNSS.Navigation.LNAV.subframe_length() 300 iex> Sidereon.GNSS.Navigation.LNAV.preamble() 139 """ alias Sidereon.GNSS.Navigation.LNAV.Ephemeris @doc """ Bit length of a single LNAV word (IS-GPS-200 Section 20.3.2). ## Examples iex> Sidereon.GNSS.Navigation.LNAV.word_length() 30 """ @spec word_length() :: 30 def word_length, do: Sidereon.NIF.lnav_word_length() @doc """ Bit length of a single LNAV subframe (IS-GPS-200 Section 20.3.2). ## Examples iex> Sidereon.GNSS.Navigation.LNAV.subframe_length() 300 """ @spec subframe_length() :: 300 def subframe_length, do: Sidereon.NIF.lnav_subframe_length() @doc """ The 8-bit TLM preamble `1000 1011` as an integer (IS-GPS-200 Section 20.3.3.1). ## Examples iex> Sidereon.GNSS.Navigation.LNAV.preamble() 139 """ @spec preamble() :: 139 def preamble, do: Sidereon.NIF.lnav_preamble() @doc """ Extracts the 17-bit time-of-week count from a hand-over word. Accepts either a 30-bit HOW word or a full 300-bit subframe (whose word 2 is the HOW). The returned value is the truncated Z-count carried in the HOW (units of 6 seconds), per IS-GPS-200 Section 20.3.3.2. ## Examples iex> {:ok, sfs} = Sidereon.GNSS.Navigation.LNAV.encode(Sidereon.GNSS.Navigation.LNAV.Ephemeris.example(), tow: 12345) iex> Sidereon.GNSS.Navigation.LNAV.tow(sfs[1]) {:ok, 12345} """ @spec tow([0 | 1]) :: {:ok, non_neg_integer()} | {:error, :bad_length} def tow(bits) when is_list(bits) do case Sidereon.NIF.lnav_tow(bits) do {:ok, value} -> {:ok, value} {:error, :bad_length} -> {:error, :bad_length} end end @doc """ Like `tow/1` but raises on malformed input length. """ @spec tow!([0 | 1]) :: non_neg_integer() def tow!(bits) when is_list(bits) do case tow(bits) do {:ok, value} -> value {:error, :bad_length} -> raise ArgumentError, "expected a 30-bit word or 300-bit subframe" end end @doc """ Extracts the 3-bit subframe ID from a hand-over word. Accepts a 30-bit HOW word or a full 300-bit subframe. Returns the subframe identifier carried in HOW bits 20-22 (IS-GPS-200 Section 20.3.3.2). ## Examples iex> {:ok, sfs} = Sidereon.GNSS.Navigation.LNAV.encode(Sidereon.GNSS.Navigation.LNAV.Ephemeris.example(), tow: 0) iex> Sidereon.GNSS.Navigation.LNAV.subframe_id(sfs[2]) {:ok, 2} """ @spec subframe_id([0 | 1]) :: {:ok, 1..5} | {:error, :bad_length} def subframe_id(bits) when is_list(bits) do case Sidereon.NIF.lnav_subframe_id(bits) do {:ok, value} -> {:ok, value} {:error, :bad_length} -> {:error, :bad_length} end end @doc """ Like `subframe_id/1` but raises on malformed input length. """ @spec subframe_id!([0 | 1]) :: 1..5 def subframe_id!(bits) when is_list(bits) do case subframe_id(bits) do {:ok, value} -> value {:error, :bad_length} -> raise ArgumentError, "expected a 30-bit word or 300-bit subframe" end end @doc """ Computes the 6 parity bits of a word (IS-GPS-200 Table 20-XIV). `data24` is the list of 24 *source* data bits (most significant first, before the `D30*` complementation). `d29_prev` and `d30_prev` are the two trailing parity bits of the previous word. Returns `[D25, D26, D27, D28, D29, D30]`. """ @spec parity([0 | 1], 0 | 1, 0 | 1) :: [0 | 1] def parity(data24, d29_prev, d30_prev) when is_list(data24) and length(data24) == 24 do Sidereon.NIF.lnav_parity(data24, d29_prev, d30_prev) end @doc """ Verifies the parity of a single 30-bit word. `word30` is the 30-bit word as transmitted (data bits possibly complemented by `D30*`, followed by 6 received parity bits). `d29_prev`/`d30_prev` are the previous word's trailing parity bits. Returns `true` when the recomputed parity matches the received parity. """ @spec parity_valid?([0 | 1], 0 | 1, 0 | 1) :: boolean() def parity_valid?(word30, d29_prev, d30_prev) when is_list(word30) and length(word30) == 30 do Sidereon.NIF.lnav_parity_valid(word30, d29_prev, d30_prev) end @doc """ Encodes clock and ephemeris parameters into LNAV subframes 1-3. Returns `{:ok, %{1 => bits, 2 => bits, 3 => bits}}` where each value is a flat list of 300 bits (most significant first). Out-of-range parameters yield `{:error, {:out_of_range, field, value}}`; this function never raises on bad input. ## Options * `:tow` - the 17-bit time-of-week count placed in each HOW (0..131071, default 0). * `:alert` - HOW alert flag (`0`/`1`, default 0). * `:anti_spoof` - HOW anti-spoof flag (`0`/`1`, default 0). * `:integrity` - TLM integrity status flag (`0`/`1`, default 0). * `:tlm_message` - 14-bit TLM message field (default 0). """ @spec encode(Ephemeris.t(), keyword()) :: {:ok, %{1 => [0 | 1], 2 => [0 | 1], 3 => [0 | 1]}} | {:error, term()} def encode(%Ephemeris{} = params, opts \\ []) do # Field order mirrors the Rust `LnavParams` decode order; `nil`-defaults match # the historical clock/ephemeris optional-field semantics. param_values = [ week_number: params.week_number, l2_code: params.l2_code || 0, l2_p_data_flag: params.l2_p_data_flag || 0, ura_index: params.ura_index, sv_health: params.sv_health, iodc: params.iodc || 0, tgd: params.tgd, toc: params.toc, af0: params.af0, af1: params.af1, af2: params.af2, iode: params.iode, crs: params.crs, delta_n: params.delta_n, m0: params.m0, cuc: params.cuc, eccentricity: params.eccentricity, cus: params.cus, sqrt_a: params.sqrt_a, toe: params.toe, fit_interval_flag: params.fit_interval_flag || 0, aodo: params.aodo || 0, cic: params.cic, omega0: params.omega0, cis: params.cis, i0: params.i0, crc: params.crc, omega: params.omega, omega_dot: params.omega_dot, idot: params.idot ] opt_values = [ tow: Keyword.get(opts, :tow, 0), alert: Keyword.get(opts, :alert, 0), anti_spoof: Keyword.get(opts, :anti_spoof, 0), integrity: Keyword.get(opts, :integrity, 0), tlm_message: Keyword.get(opts, :tlm_message, 0) ] encoded = Sidereon.NIF.lnav_encode( Enum.map(param_values, &elem(&1, 1)), Enum.map(opt_values, &elem(&1, 1)) ) case encoded do {:ok, {sf1, sf2, sf3}} -> {:ok, %{1 => sf1, 2 => sf2, 3 => sf3}} {:error, {:out_of_range, field}} -> value = Keyword.fetch!(param_values ++ opt_values, field) {:error, {:out_of_range, field, value}} end end @doc """ Decodes LNAV subframes 1-3 back into the engineering-unit parameter struct. Accepts `%{1 => bits, 2 => bits, 3 => bits}` of 300-bit subframes. Parity is verified on all 30 words first; a failure returns `{:error, {:parity_failed, subframe, word}}` (word is 1-based). On success returns `{:ok, %Sidereon.GNSS.Navigation.LNAV.Ephemeris{}}`. """ @spec decode(%{1 => [0 | 1], 2 => [0 | 1], 3 => [0 | 1]}) :: {:ok, Ephemeris.t()} | {:error, term()} def decode(%{1 => sf1, 2 => sf2, 3 => sf3}) do case Sidereon.NIF.lnav_decode(sf1, sf2, sf3) do {:ok, {ints, floats}} -> [ week_number, l2_code, ura_index, sv_health, iodc, toc, iode, toe, fit_interval_flag, aodo ] = ints [ tgd, af0, af1, af2, crs, delta_n, m0, cuc, eccentricity, cus, sqrt_a, cic, omega0, cis, i0, crc, omega, omega_dot, idot ] = floats {:ok, %Ephemeris{ week_number: week_number, l2_code: l2_code, ura_index: ura_index, sv_health: sv_health, iodc: iodc, tgd: tgd, toc: toc, af0: af0, af1: af1, af2: af2, iode: iode, crs: crs, delta_n: delta_n, m0: m0, cuc: cuc, eccentricity: eccentricity, cus: cus, sqrt_a: sqrt_a, toe: toe, fit_interval_flag: fit_interval_flag, aodo: aodo, cic: cic, omega0: omega0, cis: cis, i0: i0, crc: crc, omega: omega, omega_dot: omega_dot, idot: idot }} {:error, reason} -> {:error, reason} end end end