From 452eb2acb2ae9eac48def5792b1af955e0568e2e Mon Sep 17 00:00:00 2001 From: siebc <226531417+siebc@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:21:14 +0000 Subject: [PATCH] feat(gpsl2c): add GPS L2C (CNAV) decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPS L2C broadcasts the same 300-bit CNAV message as GPS L5I (IS-GPS-200N §30 ≡ IS-GPS-705J §20.3.3): identical preamble, rate-1/2 K=7 continuous FEC, CRC-24Q, per-message-type layouts, π, and TOW×6. The 25 bps→50 sps / 12-second framing is time-domain only and leaves the symbol-domain decoder unchanged (600-symbol message, 616-symbol sync window). Cross-checked against gnss-sdr and pocketsdr, which both reuse one CNAV decoder for the two signals. Extract the shared CNAV core (FEC, window-Viterbi sync, CRC gate, all parse_mt*, and the data container) into src/gps/cnav.jl; l5.jl and the new l2c.jl are thin signal layers. The only decode difference is that is_sat_healthy reports the L2 health bit (MT10 bit 53) for L2C vs the L5 bit (54), dispatched on the constants type — one parametric GPSCNAVConstants{S} aliased as GPSL5IConstants / GPSL2CMConstants. The shared CNAV data container and its records are named signal-neutrally (GPSCNAVData, GPSCNAVReducedAlmanac, GPSCNAVMidiAlmanac, GPSCNAVClock/ EphemerisDifferentialCorrection, GPSCNAVIntegritySupportMessage); the former GPSL5I* names remain as exported deprecated aliases, to be removed at the next major version. Internal helpers likewise use neutral gps_cnav_* / GPSCNAV* / CNAV_* names. Relocate the signal-agnostic GPS primitives UInt320 (packed-word type) and _merge_keyed (dictionary helper) out of gps/l1ca.jl and gps/l1c_d.jl into the shared gnss.jl, so the CNAV core depends only on shared utilities and not on sibling signal files. The L2 signal types (GPSL2CM/GPSL2CL) come from GNSSSignals v2.6.0, so [compat] requires GNSSSignals 2.6. Tests: add a synthetic round-trip suite plus a Spirent-derived golden fixture (test/data/gps_l2c_prn25_nav_bits.bin, PRN 25, one full 24-message broadcast cycle) whose decoded parameters match Spirent's own reference decode across every message type. Factor the shared CNAV transmit-chain test helpers into test/cnav_test_utils.jl, reused by the L5I and L2C tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 15 + Project.toml | 2 +- README.md | 1 + docs/src/api.md | 23 +- docs/src/index.md | 27 +- src/GNSSDecoder.jl | 29 +- src/gnss.jl | 22 + src/gps/cnav.jl | 1544 ++++++++++++++++++++++++++ src/gps/l1c_d.jl | 9 - src/gps/l1ca.jl | 5 - src/gps/l2c.jl | 120 ++ src/gps/l5.jl | 1524 +------------------------ test/cnav_test_utils.jl | 105 ++ test/data/gps_l2c_prn25_nav_bits.bin | Bin 0 -> 988 bytes test/gps_l2cm.jl | 367 ++++++ test/gps_l5i.jl | 117 +- test/runtests.jl | 3 + 17 files changed, 2274 insertions(+), 1639 deletions(-) create mode 100644 src/gps/cnav.jl create mode 100644 src/gps/l2c.jl create mode 100644 test/cnav_test_utils.jl create mode 100644 test/data/gps_l2c_prn25_nav_bits.bin create mode 100644 test/gps_l2cm.jl diff --git a/CONTEXT.md b/CONTEXT.md index c8299ef..aea2ad9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,6 +15,18 @@ code; this file is for naming and meaning only. non-systematic convolutional code (G1 = 171₈, G2 = 133₈), convolved *continuously* across message boundaries (no tail bits). Message = 300 bits = 600 channel symbols at 100 sps = 6 seconds (IS-GPS-705). +- **GPS L2C** (`GPSL2CM`) — 25 bps CNAV broadcast on the L2 CM (civil-moderate) + code; the time-multiplexed L2 CL code is a dataless pilot. The CNAV message + is *bit-for-bit identical* to GPS L5I's (IS-GPS-200N §30 ≡ IS-GPS-705J + §20.3.3): same preamble, FEC, CRC-24Q, message-type layouts, π and `TOW × 6`. + Only the signal layer differs — 25 bps → 50 sps, 300-bit message = 600 + symbols = 12 seconds — which is purely time-domain, so the symbol-domain + decoder is the same 600-symbol message / 616-symbol sync window. Shares the + GPS CNAV core (`src/gps/cnav.jl`) and the `GPSCNAVData` container with L5I; + the only decode difference is that `is_sat_healthy` reports the L2 health bit + (MT10 bit 53) instead of the L5 bit (54). `GNSSDecoderState(::GPSL2CM, prn)` + maps here (`GPSL2CL` is the pilot); `GPSL2CMDecoderState(prn)` is the + equivalent direct constructor. - **Galileo E1B** (`GalileoE1B`) — 250 sps I/NAV nominal pages over a K=7 rate-1/2 convolutional code plus 30×8 block interleaver. Page = 250 channel symbols = 1 second. Two consecutive pages (even+odd) carry one word. @@ -112,3 +124,6 @@ lives in the `cache`. domain (the FEC runs continuously across messages), so each sync attempt Viterbi-decodes the buffered 616-symbol window and requires the preamble at both ends of the decoded 308-bit window plus a clean CRC-24Q. +- **L2C**: identical to L5I — the same shared GPS CNAV `try_sync` + Viterbi-decodes the 616-symbol window and gates on preamble + CRC-24Q. (The + symbol rate, 50 vs 100 sps, does not enter the symbol-domain sync.) diff --git a/Project.toml b/Project.toml index 41ea067..b13e72f 100755 --- a/Project.toml +++ b/Project.toml @@ -20,7 +20,7 @@ DataStructures = "0.18, 0.19" Dictionaries = "0.4" DocStringExtensions = "0.6, 0.7, 0.8, 0.9" Downloads = "1" -GNSSSignals = "2.3" +GNSSSignals = "2.6" Random = "1" Test = "1" Tracking = "2" diff --git a/README.md b/README.md index d0dd14b..acb6cda 100755 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ magnitudes need not be normalized. Currently implemented: * GPS L1 C/A (LNAV) * GPS L1C-D (CNAV-2) + * GPS L2C (CNAV) * GPS L5I (CNAV) * Galileo E1B (I/NAV) * Galileo E5a (F/NAV) diff --git a/docs/src/api.md b/docs/src/api.md index 7f71d16..7ff370c 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -14,6 +14,7 @@ GalileoE1BDecoderState GalileoE5aDecoderState GPSL1C_DDecoderState GPSL5IDecoderState +GPSL2CMDecoderState ``` ## Decoding @@ -84,14 +85,22 @@ GPSL1C_DMidiAlmanac GPSL1C_DDifferentialCorrection ``` -### GPS L5I +### GPS CNAV (shared by L5I and L2C) + +GPS L5I and GPS L2C carry the identical CNAV message, so they share the decoded +[`GPSCNAVData`](@ref) container (and its almanac/correction records) and one +constants struct, [`GPSCNAVConstants`](@ref GNSSDecoder.GPSCNAVConstants). The +per-signal constants are type aliases that fix its signal tag; they differ only +in which signal-health bit [`is_sat_healthy`](@ref) reports. ```@docs +GNSSDecoder.GPSCNAVConstants GNSSDecoder.GPSL5IConstants -GPSL5IData -GPSL5IReducedAlmanac -GPSL5IMidiAlmanac -GPSL5IClockDifferentialCorrection -GPSL5IEphemerisDifferentialCorrection -GPSL5IIntegritySupportMessage +GNSSDecoder.GPSL2CMConstants +GPSCNAVData +GPSCNAVReducedAlmanac +GPSCNAVMidiAlmanac +GPSCNAVClockDifferentialCorrection +GPSCNAVEphemerisDifferentialCorrection +GPSCNAVIntegritySupportMessage ``` diff --git a/docs/src/index.md b/docs/src/index.md index d85c03b..3a5be30 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -6,6 +6,7 @@ A Julia package for decoding GNSS (Global Navigation Satellite System) navigatio - **GPS L1 C/A**: Decodes the 50 bps LNAV data stream from GPS L1 civil signals - **GPS L1C-D**: Decodes the 100 sps CNAV-2 data stream from the modernized GPS L1C signal's data component +- **GPS L2C**: Decodes the 50 sps CNAV data stream from the GPS L2 CM (civil-moderate) signal component - **GPS L5I**: Decodes the 100 sps CNAV data stream from the GPS L5 in-phase signal component - **Galileo E1B**: Decodes the 250 bps I/NAV data stream from Galileo E1B Open Service signals @@ -128,7 +129,31 @@ julia> state.prn 1 julia> typeof(state) -GNSSDecoderState{GPSL5IData, GNSSDecoder.GPSL5IConstants, GNSSDecoder.GPSL5ICache} +GNSSDecoderState{GPSCNAVData, GNSSDecoder.GPSCNAVConstants{:GPSL5I}, GNSSDecoder.GPSCNAVCache} + +julia> state = decode(state, Float32[+1, -1, +1, +1, -1, -1, -1, -1, -1, +1], 10); # Decode 10 soft symbols + +julia> GNSSDecoder.num_bits_buffered(state) +10 +``` + +### GPS L2C Decoding + +GPS L2C broadcasts the *same* CNAV message as GPS L5I (IS-GPS-200N §30), on the +L2 CM component at 50 sps. Decoding therefore reuses the shared GPS CNAV core, +and decoded fields land in the same [`GPSCNAVData`](@ref) container; only the +health check differs (it reports the L2 signal-health bit): + +```jldoctest l2c_example +julia> using GNSSDecoder + +julia> state = GPSL2CMDecoderState(1); # Initialize decoder for PRN 1 + +julia> state.prn +1 + +julia> typeof(state) +GNSSDecoderState{GPSCNAVData, GNSSDecoder.GPSCNAVConstants{:GPSL2CM}, GNSSDecoder.GPSCNAVCache} julia> state = decode(state, Float32[+1, -1, +1, +1, -1, -1, -1, -1, -1, +1], 10); # Decode 10 soft symbols diff --git a/src/GNSSDecoder.jl b/src/GNSSDecoder.jl index 4ef33b6..7bf1203 100755 --- a/src/GNSSDecoder.jl +++ b/src/GNSSDecoder.jl @@ -11,12 +11,13 @@ export decode, GPSL1C_DMidiAlmanac, GPSL1C_DDifferentialCorrection, GPSL5IDecoderState, - GPSL5IData, - GPSL5IReducedAlmanac, - GPSL5IMidiAlmanac, - GPSL5IClockDifferentialCorrection, - GPSL5IEphemerisDifferentialCorrection, - GPSL5IIntegritySupportMessage, + GPSCNAVData, + GPSCNAVReducedAlmanac, + GPSCNAVMidiAlmanac, + GPSCNAVClockDifferentialCorrection, + GPSCNAVEphemerisDifferentialCorrection, + GPSCNAVIntegritySupportMessage, + GPSL2CMDecoderState, GalileoE1BDecoderState, GalileoE5aDecoderState, is_sat_healthy, @@ -66,9 +67,17 @@ include("galileo/e5a.jl") # `decode` framework hooks (`try_sync`, `decode_syncro_sequence`, …). include("gps/l1c_d.jl") -# GPS L5I (CNAV) decoder. Included after `gps/l1ca.jl` (reuses its `UInt320` -# packed-word type) and after `gps/l1c_d.jl` (reuses the `_deque_slice` and -# `_merge_keyed` helpers), and consumes `crc24q` from the shared utilities -# above. +# Shared GPS CNAV core (the 300-bit message broadcast identically on GPS L5I, +# IS-GPS-705J §20.3, and GPS L2C, IS-GPS-200N §30): FEC, window-Viterbi sync, +# CRC-24Q, and all per-message-type parsing, plus the shared `GPSCNAVData` +# container. Consumes only shared utilities included above — `crc24q` and the +# `UInt320` / `_merge_keyed` primitives in `gnss.jl` — so it is independent of +# the sibling signal files. +include("gps/cnav.jl") + +# GPS L5I and GPS L2C signal layers — each a thin wrapper over `gps/cnav.jl` +# (their own `*Constants` type, decoder-state constructor, and `is_sat_healthy` +# health-bit selection). Included after the shared core they consume. include("gps/l5.jl") +include("gps/l2c.jl") end diff --git a/src/gnss.jl b/src/gnss.jl index d9437b4..8e0f89f 100644 --- a/src/gnss.jl +++ b/src/gnss.jl @@ -357,3 +357,25 @@ function decode( end return state end + +# ---- Shared GPS decoder primitives ------------------------------------------ +# +# Signal-agnostic primitives used by more than one GPS signal decoder. They +# live here (a shared file included before every signal) rather than in a +# per-signal file so that no signal decoder has to be included after another +# just to borrow them. + +# Packed-word integer type shared across the GPS decoders: a GPS L1 C/A +# subframe, a GPS L1C-D subframe, and a GPS CNAV message (GPS L5I / L2C) each +# pack into a single `UInt320` — 300 data bits plus up to 8 trailing sync bits. +# `BitIntegers.@define_integers` also defines the signed companion `Int320`. +BitIntegers.@define_integers 320 + +""" +Insert/overwrite `value` keyed by `key` in a (possibly `nothing`) `Dictionary`, returning the updated copy. +""" +function _merge_keyed(dict::Union{Nothing,Dictionary{Int,V}}, key::Int, value::V) where {V} + out = isnothing(dict) ? Dictionary{Int,V}() : copy(dict) + set!(out, key, value) + return out +end diff --git a/src/gps/cnav.jl b/src/gps/cnav.jl new file mode 100644 index 0000000..399593f --- /dev/null +++ b/src/gps/cnav.jl @@ -0,0 +1,1544 @@ +# Shared GPS CNAV core — IS-GPS-200N §30 ≡ IS-GPS-705J §20.3.3. +# +# The CNAV navigation message is defined once and broadcast identically on two +# signals: GPS L2C (on the L2 CM code, IS-GPS-200N §30) and GPS L5I (on the L5 +# in-phase component, IS-GPS-705J §20.3). Both carry the *same* 300-bit +# message — same 8-bit preamble `10001011`, same rate-1/2 constraint-length-7 +# (K=7) non-systematic convolutional (NSC) FEC (G1 = 0o171, G2 = 0o133), same +# 24-bit CRC-24Q over the whole message, same per-message-type bit layouts +# (MT10/11, 12–15, 30–37, 40), same π and the same `TOW × 6` scaling. The only +# differences are in the *signal* layer (L2C: 25 bps → 50 sps, 12-second +# messages; L5I: 50 bps → 100 sps, 6-second messages) and in which health bit +# `is_sat_healthy` reports — neither of which affects this symbol-domain +# decoder (the message is always 600 symbols, the sync window 616). This file +# therefore holds everything shared; `gps/l5.jl` and `gps/l2c.jl` are thin +# signal layers (their own `*Constants` type, decoder-state constructor, and +# `is_sat_healthy`). gnss-sdr and pocketsdr likewise reuse one CNAV decoder +# for both signals. +# +# Field offsets/scales are cited to IS-GPS-705J figures/tables below; the +# identical definitions appear in IS-GPS-200N §30 (e.g. Fig 30-1 ≡ Fig 20-1). +# +# Unlike Galileo E1B pages, the CNAV FEC is convolved *continuously* across +# message boundaries — there are no tail bits and no encoder reset — so the +# preamble is only visible after FEC decoding and AFF3CT's tail-terminated +# `ConvViterbiDecoder` does not apply. Message sync therefore follows the +# generic streaming framework in `src/gnss.jl` with a window-decoding +# `try_sync`: the soft-symbol `CircularDeque` is sized to one full message +# (600 symbols) plus the 16-symbol encoding of the *next* message's start +# (616 total); each sync attempt Viterbi-decodes the whole window on the raw +# `Float32` LLRs and requires the preamble at *both ends* of the decoded +# window (in either polarity, mirroring `find_preamble`) plus a clean +# CRC-24Q before locking. Symbol-pair phase ambiguity needs no special +# handling — a misaligned window simply fails to sync and slides one symbol. + +""" + GPSCNAVConstants{S} <: AbstractGNSSConstants + +WGS 84 constants and CNAV message structure parameters shared by the GPS CNAV +signals. The CNAV message is identical on GPS L5I and GPS L2C, so the field +values are identical too; the phantom signal tag `S` (`:GPSL5I` / `:GPSL2CM`) +exists only so [`is_sat_healthy`](@ref) can dispatch on the signal (L5I reports +the L5 health bit, L2C the L2 health bit). The per-signal aliases +[`GPSL5IConstants`](@ref) and [`GPSL2CMConstants`](@ref) fix `S`. + +The message is modelled through the generic streaming framework: +`syncro_sequence_length` is the 600-symbol message that is drained once +decoded, and `preamble_length` is the 16-symbol encoding of the *next* +message's preamble retained at the tail of the sync window. + +# Fields + +$(TYPEDFIELDS) + +# Reference + +IS-GPS-705J §20.3.3 / §20.3.4.3 (L5I) ≡ IS-GPS-200N §30.3.2 / §3.3.3.1 (L2C). +""" +Base.@kwdef struct GPSCNAVConstants{S} <: AbstractGNSSConstants + """ + Message length drained after each decoded message (600 symbols) + """ + syncro_sequence_length::Int = CNAV_MESSAGE_SYMBOLS + """ + Trailing next-message preamble segment retained for sync (16 symbols) + """ + preamble_length::Int = CNAV_PREAMBLE_SYMBOLS + """ + Mathematical constant π (IS-GPS-705J Table 20-II / IS-GPS-200N §30.3.3) + """ + PI::Float64 = GNSS_PI + """ + WGS 84 Earth rotation rate (rad/s) + """ + Ω_dot_e::Float64 = EARTH_ROTATION_RATE + """ + Speed of light (m/s) + """ + c::Float64 = SPEED_OF_LIGHT + """ + WGS 84 Earth gravitational parameter (m³/s²) + """ + μ::Float64 = 3.986005e14 + """ + Relativistic correction constant (s/√m) + """ + F::Float64 = -4.442807633e-10 +end + +""" +One CNAV message: 300 bits = 600 channel symbols (rate 1/2) at 100 sps. +""" +const CNAV_MESSAGE_BITS = 300 +const CNAV_MESSAGE_SYMBOLS = 2 * CNAV_MESSAGE_BITS +""" +Trailing window: the next message's first 8 bits (16 symbols) confirm sync. +""" +const CNAV_PREAMBLE_BITS = 8 +const CNAV_PREAMBLE_SYMBOLS = 2 * CNAV_PREAMBLE_BITS +const CNAV_WINDOW_SYMBOLS = CNAV_MESSAGE_SYMBOLS + CNAV_PREAMBLE_SYMBOLS # 616 +const CNAV_WINDOW_BITS = CNAV_MESSAGE_BITS + CNAV_PREAMBLE_BITS # 308 + +""" +CNAV preamble `10001011` (IS-GPS-705J §20.3.3). +""" +const CNAV_PREAMBLE = 0b10001011 + +# Semi-major axis reference (IS-GPS-705J Table 20-I, meters). +const CNAV_A_REF = 26_559_710.0 +# Rate-of-right-ascension reference (IS-GPS-705J Table 20-I, semi-circles/sec). +const CNAV_OMEGA_DOT_REF = -2.6e-9 + +""" + GPSCNAVReducedAlmanac + +One satellite's reduced-almanac packet from CNAV message types 12 or 31 +(IS-GPS-705J Figure 20-16, Table 20-VI). + +The reduced almanac gives a very coarse ephemeris for satellite selection. +Message type 12 carries seven 31-bit packets, message type 31 four; each +packet is complete in itself, so `GPSCNAVData.reduced_almanacs` entries are +inserted whole, keyed by `PRN_a` (mirrors [`GPSL1C_DReducedAlmanac`](@ref)). + +Reference values to apply (Table 20-VI footnotes): `e = 0`, +`δi = +0.0056 semi-circles` (so `i = 55°`), +`Ω̇ = -2.6e-9 semi-circles/s`, `A = A_ref + δA` with `A_ref = 26 559 710 m`, +`Φ₀ = M₀ + ω`. Semi-circle fields are converted to radians on decode. + +# Fields + + - `PRN_a::Int`: Almanac satellite PRN (1-63; 0 marks an empty packet). + - `WN_a::Int`: Almanac reference week number (mod 8192). + - `t_oa::Int`: Almanac reference time of week (seconds). + - `δA::Float64`: Semi-major-axis delta from `A_ref` (meters). + - `Ω_0::Float64`: Longitude of ascending node at weekly epoch (rad). + - `Φ_0::Float64`: Argument of latitude at reference time, `M₀+ω` (rad). + - `l1_health::Bool`, `l2_health::Bool`, `l5_health::Bool`: per-band health + (false = OK, true = some/all signals bad). + +# Reference + +IS-GPS-705J, Figures 20-4 / 20-11 / 20-16, Table 20-VI. +""" +Base.@kwdef struct GPSCNAVReducedAlmanac + PRN_a::Int + WN_a::Int + t_oa::Int + δA::Float64 + Ω_0::Float64 + Φ_0::Float64 + l1_health::Bool + l2_health::Bool + l5_health::Bool +end + +""" + GPSCNAVMidiAlmanac + +One satellite's Midi almanac from CNAV message type 37 (IS-GPS-705J +Figure 20-10, Table 20-V). + +The Midi almanac is a medium-precision single-SV almanac, complete in a +single message, so `GPSCNAVData.midi_almanacs` entries are inserted whole, +keyed by `PRN_a` (mirrors [`GPSL1C_DMidiAlmanac`](@ref)). Inclination is `δi` +relative to `i₀ = 0.30 semi-circles` (54°); semi-circle fields are converted +to radians on decode. + +# Fields + + - `PRN_a::Int`: Almanac satellite PRN. + - `WN_a::Int`: Almanac reference week number (mod 8192). + - `t_oa::Int`: Almanac reference time of week (seconds). + - `e::Float64`: Eccentricity (dimensionless). + - `δi::Float64`: Inclination delta from `i₀ = 0.30 sc` (rad); add the reference. + - `Ω_dot::Float64`: Rate of right ascension (rad/s). + - `sqrt_A::Float64`: Square root of the semi-major axis (√m). + - `Ω_0::Float64`: Longitude of ascending node at weekly epoch (rad). + - `ω::Float64`: Argument of perigee (rad). + - `M_0::Float64`: Mean anomaly at reference time (rad). + - `a_f0::Float64`, `a_f1::Float64`: Clock bias / drift (s, s/s). + - `l1_health::Bool`, `l2_health::Bool`, `l5_health::Bool`: per-band health. + +# Reference + +IS-GPS-705J, Figure 20-10, Table 20-V. +""" +Base.@kwdef struct GPSCNAVMidiAlmanac + PRN_a::Int + WN_a::Int + t_oa::Int + e::Float64 + δi::Float64 + Ω_dot::Float64 + sqrt_A::Float64 + Ω_0::Float64 + ω::Float64 + M_0::Float64 + a_f0::Float64 + a_f1::Float64 + l1_health::Bool + l2_health::Bool + l5_health::Bool +end + +""" + GPSCNAVClockDifferentialCorrection + +One satellite's clock differential-correction (CDC) packet from CNAV message +types 13 or 34, keyed by `PRN_a` (IS-GPS-705J Figure 20-17, Table 20-X). + +`dc_data_type` selects the data the corrections apply to: `false` ⇒ CNAV +(message types 30-37), `true` ⇒ legacy NAV. A `PRN ID` of all-ones marks an +empty packet (not stored). + +# Fields + + - `PRN_a::Int`: PRN the corrections apply to. + - `t_op_D::Int`: DC data predict time of week (seconds). + - `t_OD::Int`: Time of DC data (seconds). + - `dc_data_type::Bool`: false ⇒ CNAV, true ⇒ legacy NAV. + - `δa_f0::Float64`, `δa_f1::Float64`: Clock bias / drift corrections (s, s/s). + - `UDRA_index::Int`: UDRA index (signed). + +# Reference + +IS-GPS-705J, Figures 20-7 / 20-12 / 20-17, Table 20-X. +""" +Base.@kwdef struct GPSCNAVClockDifferentialCorrection + PRN_a::Int + t_op_D::Int + t_OD::Int + dc_data_type::Bool + δa_f0::Float64 + δa_f1::Float64 + UDRA_index::Int +end + +""" + GPSCNAVEphemerisDifferentialCorrection + +One satellite's ephemeris differential-correction (EDC) packet from CNAV +message types 14 or 34, keyed by `PRN_a` (IS-GPS-705J Figure 20-17, +Table 20-X). Semi-circle fields are converted to radians on decode. + +# Fields + + - `PRN_a::Int`: PRN the corrections apply to. + - `t_op_D::Int`: DC data predict time of week (seconds). + - `t_OD::Int`: Time of DC data (seconds). + - `dc_data_type::Bool`: false ⇒ CNAV, true ⇒ legacy NAV. + - `Δα::Float64`, `Δβ::Float64`: Ephemeris α/β corrections (dimensionless). + - `Δγ::Float64`: Ephemeris γ correction (rad). + - `Δi::Float64`, `ΔΩ::Float64`: Inclination / right-ascension corrections (rad). + - `ΔA::Float64`: Semi-major-axis correction (meters). + - `UDRA_dot_index::Int`: Rate-of-UDRA index (signed). + +# Reference + +IS-GPS-705J, Figures 20-7 / 20-13 / 20-17, Table 20-X. +""" +Base.@kwdef struct GPSCNAVEphemerisDifferentialCorrection + PRN_a::Int + t_op_D::Int + t_OD::Int + dc_data_type::Bool + Δα::Float64 + Δβ::Float64 + Δγ::Float64 + Δi::Float64 + ΔΩ::Float64 + ΔA::Float64 + UDRA_dot_index::Int +end + +""" + GPSCNAVIntegritySupportMessage + +Integrity Support Message from CNAV message type 40 (ARAIM), complete in a +single message (IS-GPS-705J Figure 20-14a, Table 20-XIa). + +# Fields + + - `GNSS_ID::Int`: GNSS identifier the ISM applies to. + - `WN_ISM::Int`, `TOW_ISM::Int`: ISM reference week / time-of-week counts. + - `t_correl::Int`, `b_nom::Int`, `γ_nom::Int`, `R_sat::Int`, `P_const::Int`, + `MFD::Int`, `service_level::Int`: encoded ARAIM parameter indices. + - `mask::UInt64`: 63-bit SV mask (MSB = PRN 1). + +# Reference + +IS-GPS-705J, Figure 20-14a, Table 20-XIa. +""" +Base.@kwdef struct GPSCNAVIntegritySupportMessage + GNSS_ID::Int + WN_ISM::Int + TOW_ISM::Int + t_correl::Int + b_nom::Int + γ_nom::Int + R_sat::Int + P_const::Int + MFD::Int + service_level::Int + mask::UInt64 +end + +""" + GPSCNAVData + +Decoded GPS CNAV navigation message data, shared by GPS L5I and GPS L2C (the +CNAV message is identical on both signals). + +Holds the parameters decoded from CNAV message types 10, 11, 12, 13, 14, 15, +30-37, and 40 (IS-GPS-705J §20.3.3 ≡ IS-GPS-200N §30.3.3). The decoder fills +fields incrementally as the corresponding message types are received. +Field-naming follows [`GPSL1C_DData`](@ref) (CNAV-2 broadcasts nearly the same +parameter set): semi-circle quantities are converted to radians on decode +(multiplied by π), all `Union{Nothing,…}` until first decoded. + +# Header (every message) + + - `last_message_id::Int`: Most recently decoded message type (0 until then). + - `TOW::Int64`: SV time in seconds at the start of the *next* message + (message TOW count × 6; the next message is 6 s away on L5I, 12 s on L2C). + - `alert_flag::Bool`: Raised when the signal URA may be worse than indicated. + +# Health / accuracy (message types 10, 30-37) + + - `l1_health::Bool`, `l2_health::Bool`, `l5_health::Bool`: per-band signal + health (false = OK). + - `ura_ed_index::Int64`: Ephemeris URA index (signed). + - `ura_ned0_index::Int64`, `ura_ned1_index::Int64`, `ura_ned2_index::Int64`: + Clock URA indices. + +# Ephemeris (message types 10 + 11, Table 20-I) + + - `WN::Int64`: Transmission week number, modulo-8192. + - `t_op::Int64`: Data predict time of week (seconds). + - `t_0e::Int64`: Ephemeris data reference time of week (seconds). + - `ΔA::Float64`: Semi-major axis difference at reference time (meters). + - `A_dot::Float64`: Change rate in semi-major axis (m/s). + - `Δn_0::Float64`: Mean motion difference from computed value (rad/s). + - `Δn_0_dot::Float64`: Rate of mean motion difference (rad/s²). + - `M_0::Float64`: Mean anomaly at reference time (rad). + - `e::Float64`: Eccentricity (dimensionless). + - `ω::Float64`: Argument of perigee (rad). + - `Ω_0::Float64`: Reference right ascension angle (rad). + - `i_0::Float64`: Inclination angle at reference time (rad). + - `ΔΩ_dot::Float64`: Rate of right ascension difference (rad/s). + - `i_dot::Float64`: Rate of inclination angle (rad/s). + - `C_is::Float64`, `C_ic::Float64`: Sine/cosine inclination harmonic corrections (rad). + - `C_rs::Float64`, `C_rc::Float64`: Sine/cosine orbit-radius harmonic corrections (m). + - `C_us::Float64`, `C_uc::Float64`: Sine/cosine argument-of-latitude harmonic corrections (rad). + - `integrity_status_flag::Bool`, `l2c_phasing::Bool`: message type 10 flags. + +# Clock (message types 30-37, Table 20-III) + + - `t_0c::Int64`: Clock data reference time of week (seconds). + - `a_f0::Float64`, `a_f1::Float64`, `a_f2::Float64`: Clock bias / drift / drift-rate. + +# Group delay / ISC + ionosphere (message type 30, Tables 20-III / 20-IV) + + - `T_GD::Float64`: L1/L2 P(Y) inter-signal correction (seconds). + - `ISC_L1CA,ISC_L2C,ISC_L5I5,ISC_L5Q5::Float64`: inter-signal corrections (s). + - `α_0,α_1,α_2,α_3,β_0,β_1,β_2,β_3::Float64`: Klobuchar ionospheric coefficients. + - `WN_op::Int64`: Data predict week number (mod 256). + +# EOP (message type 32, Table 20-VII) + + - `t_EOP::Int64`: EOP reference time of week (s). + - `PM_X,PM_X_dot,PM_Y,PM_Y_dot::Float64`: polar-motion values/rates (arcsec, arcsec/day). + - `ΔUT_GPS,ΔUT_GPS_dot::Float64`: UT1-GPS difference (s) and rate (s/day). + +# UTC (message type 33, Table 20-IX) + + - `A0_UTC,A1_UTC,A2_UTC::Float64`: UTC polynomial (s, s/s, s/s²). + - `Δt_LS,Δt_LSF::Int64`: current/past and future leap-second counts (s). + - `t_ot::Int64`: UTC reference time of week (s). + - `WN_ot,WN_LSF::Int64`: UTC and leap-second reference week numbers. + - `DN::Int64`: leap-second reference day number (1-7). + +# GGTO (message type 35, Table 20-XI) + + - `A0_GGTO,A1_GGTO,A2_GGTO::Float64`: GPS/GNSS time-offset polynomial. + - `t_GGTO::Int64`, `WN_GGTO::Int64`: GGTO reference time/week. + - `GNSS_ID::Int64`: 0 none, 1 Galileo, 2 GLONASS, 3 BeiDou, 4-7 reserved. + +# Almanacs / corrections / text — keyed dictionaries (`nothing` until first decoded) + + - `reduced_almanacs::Dictionary{Int,GPSCNAVReducedAlmanac}` (message types 12, 31). + - `midi_almanacs::Dictionary{Int,GPSCNAVMidiAlmanac}` (message type 37). + - `clock_corrections::Dictionary{Int,GPSCNAVClockDifferentialCorrection}` + (message types 13, 34). + - `ephemeris_corrections::Dictionary{Int,GPSCNAVEphemerisDifferentialCorrection}` + (message types 14, 34). + - `text_mt15::String`, `text_page_mt15::Int64`: message type 15 text page + (29 ASCII characters, control chars stripped). + - `text_mt36::String`, `text_page_mt36::Int64`: message type 36 text page + (18 ASCII characters). + - `ism::GPSCNAVIntegritySupportMessage`: message type 40 Integrity Support Message. + +# Reference + +IS-GPS-705J, Figures 20-1 through 20-17 and Tables 20-I through 20-XIa. +""" +Base.@kwdef struct GPSCNAVData <: AbstractGNSSData + last_message_id::Int = 0 + TOW::Union{Nothing,Int64} = nothing + alert_flag::Union{Nothing,Bool} = nothing + + l1_health::Union{Nothing,Bool} = nothing + l2_health::Union{Nothing,Bool} = nothing + l5_health::Union{Nothing,Bool} = nothing + ura_ed_index::Union{Nothing,Int64} = nothing + ura_ned0_index::Union{Nothing,Int64} = nothing + ura_ned1_index::Union{Nothing,Int64} = nothing + ura_ned2_index::Union{Nothing,Int64} = nothing + + WN::Union{Nothing,Int64} = nothing + t_op::Union{Nothing,Int64} = nothing + t_0e::Union{Nothing,Int64} = nothing + ΔA::Union{Nothing,Float64} = nothing + A_dot::Union{Nothing,Float64} = nothing + Δn_0::Union{Nothing,Float64} = nothing + Δn_0_dot::Union{Nothing,Float64} = nothing + M_0::Union{Nothing,Float64} = nothing + e::Union{Nothing,Float64} = nothing + ω::Union{Nothing,Float64} = nothing + Ω_0::Union{Nothing,Float64} = nothing + i_0::Union{Nothing,Float64} = nothing + ΔΩ_dot::Union{Nothing,Float64} = nothing + i_dot::Union{Nothing,Float64} = nothing + C_is::Union{Nothing,Float64} = nothing + C_ic::Union{Nothing,Float64} = nothing + C_rs::Union{Nothing,Float64} = nothing + C_rc::Union{Nothing,Float64} = nothing + C_us::Union{Nothing,Float64} = nothing + C_uc::Union{Nothing,Float64} = nothing + integrity_status_flag::Union{Nothing,Bool} = nothing + l2c_phasing::Union{Nothing,Bool} = nothing + + t_0c::Union{Nothing,Int64} = nothing + a_f0::Union{Nothing,Float64} = nothing + a_f1::Union{Nothing,Float64} = nothing + a_f2::Union{Nothing,Float64} = nothing + + T_GD::Union{Nothing,Float64} = nothing + ISC_L1CA::Union{Nothing,Float64} = nothing + ISC_L2C::Union{Nothing,Float64} = nothing + ISC_L5I5::Union{Nothing,Float64} = nothing + ISC_L5Q5::Union{Nothing,Float64} = nothing + α_0::Union{Nothing,Float64} = nothing + α_1::Union{Nothing,Float64} = nothing + α_2::Union{Nothing,Float64} = nothing + α_3::Union{Nothing,Float64} = nothing + β_0::Union{Nothing,Float64} = nothing + β_1::Union{Nothing,Float64} = nothing + β_2::Union{Nothing,Float64} = nothing + β_3::Union{Nothing,Float64} = nothing + WN_op::Union{Nothing,Int64} = nothing + + t_EOP::Union{Nothing,Int64} = nothing + PM_X::Union{Nothing,Float64} = nothing + PM_X_dot::Union{Nothing,Float64} = nothing + PM_Y::Union{Nothing,Float64} = nothing + PM_Y_dot::Union{Nothing,Float64} = nothing + ΔUT_GPS::Union{Nothing,Float64} = nothing + ΔUT_GPS_dot::Union{Nothing,Float64} = nothing + + A0_UTC::Union{Nothing,Float64} = nothing + A1_UTC::Union{Nothing,Float64} = nothing + A2_UTC::Union{Nothing,Float64} = nothing + Δt_LS::Union{Nothing,Int64} = nothing + t_ot::Union{Nothing,Int64} = nothing + WN_ot::Union{Nothing,Int64} = nothing + WN_LSF::Union{Nothing,Int64} = nothing + DN::Union{Nothing,Int64} = nothing + Δt_LSF::Union{Nothing,Int64} = nothing + + A0_GGTO::Union{Nothing,Float64} = nothing + A1_GGTO::Union{Nothing,Float64} = nothing + A2_GGTO::Union{Nothing,Float64} = nothing + t_GGTO::Union{Nothing,Int64} = nothing + WN_GGTO::Union{Nothing,Int64} = nothing + GNSS_ID::Union{Nothing,Int64} = nothing + + reduced_almanacs::Union{Nothing,Dictionary{Int,GPSCNAVReducedAlmanac}} = nothing + midi_almanacs::Union{Nothing,Dictionary{Int,GPSCNAVMidiAlmanac}} = nothing + clock_corrections::Union{Nothing,Dictionary{Int,GPSCNAVClockDifferentialCorrection}} = + nothing + ephemeris_corrections::Union{ + Nothing, + Dictionary{Int,GPSCNAVEphemerisDifferentialCorrection}, + } = nothing + + text_mt15::Union{Nothing,String} = nothing + text_page_mt15::Union{Nothing,Int64} = nothing + text_mt36::Union{Nothing,String} = nothing + text_page_mt36::Union{Nothing,Int64} = nothing + + ism::Union{Nothing,GPSCNAVIntegritySupportMessage} = nothing +end + +function GPSCNAVData( + data::GPSCNAVData; + last_message_id = data.last_message_id, + TOW = data.TOW, + alert_flag = data.alert_flag, + l1_health = data.l1_health, + l2_health = data.l2_health, + l5_health = data.l5_health, + ura_ed_index = data.ura_ed_index, + ura_ned0_index = data.ura_ned0_index, + ura_ned1_index = data.ura_ned1_index, + ura_ned2_index = data.ura_ned2_index, + WN = data.WN, + t_op = data.t_op, + t_0e = data.t_0e, + ΔA = data.ΔA, + A_dot = data.A_dot, + Δn_0 = data.Δn_0, + Δn_0_dot = data.Δn_0_dot, + M_0 = data.M_0, + e = data.e, + ω = data.ω, + Ω_0 = data.Ω_0, + i_0 = data.i_0, + ΔΩ_dot = data.ΔΩ_dot, + i_dot = data.i_dot, + C_is = data.C_is, + C_ic = data.C_ic, + C_rs = data.C_rs, + C_rc = data.C_rc, + C_us = data.C_us, + C_uc = data.C_uc, + integrity_status_flag = data.integrity_status_flag, + l2c_phasing = data.l2c_phasing, + t_0c = data.t_0c, + a_f0 = data.a_f0, + a_f1 = data.a_f1, + a_f2 = data.a_f2, + T_GD = data.T_GD, + ISC_L1CA = data.ISC_L1CA, + ISC_L2C = data.ISC_L2C, + ISC_L5I5 = data.ISC_L5I5, + ISC_L5Q5 = data.ISC_L5Q5, + α_0 = data.α_0, + α_1 = data.α_1, + α_2 = data.α_2, + α_3 = data.α_3, + β_0 = data.β_0, + β_1 = data.β_1, + β_2 = data.β_2, + β_3 = data.β_3, + WN_op = data.WN_op, + t_EOP = data.t_EOP, + PM_X = data.PM_X, + PM_X_dot = data.PM_X_dot, + PM_Y = data.PM_Y, + PM_Y_dot = data.PM_Y_dot, + ΔUT_GPS = data.ΔUT_GPS, + ΔUT_GPS_dot = data.ΔUT_GPS_dot, + A0_UTC = data.A0_UTC, + A1_UTC = data.A1_UTC, + A2_UTC = data.A2_UTC, + Δt_LS = data.Δt_LS, + t_ot = data.t_ot, + WN_ot = data.WN_ot, + WN_LSF = data.WN_LSF, + DN = data.DN, + Δt_LSF = data.Δt_LSF, + A0_GGTO = data.A0_GGTO, + A1_GGTO = data.A1_GGTO, + A2_GGTO = data.A2_GGTO, + t_GGTO = data.t_GGTO, + WN_GGTO = data.WN_GGTO, + GNSS_ID = data.GNSS_ID, + reduced_almanacs = data.reduced_almanacs, + midi_almanacs = data.midi_almanacs, + clock_corrections = data.clock_corrections, + ephemeris_corrections = data.ephemeris_corrections, + text_mt15 = data.text_mt15, + text_page_mt15 = data.text_page_mt15, + text_mt36 = data.text_mt36, + text_page_mt36 = data.text_page_mt36, + ism = data.ism, +) + GPSCNAVData( + last_message_id, + TOW, + alert_flag, + l1_health, + l2_health, + l5_health, + ura_ed_index, + ura_ned0_index, + ura_ned1_index, + ura_ned2_index, + WN, + t_op, + t_0e, + ΔA, + A_dot, + Δn_0, + Δn_0_dot, + M_0, + e, + ω, + Ω_0, + i_0, + ΔΩ_dot, + i_dot, + C_is, + C_ic, + C_rs, + C_rc, + C_us, + C_uc, + integrity_status_flag, + l2c_phasing, + t_0c, + a_f0, + a_f1, + a_f2, + T_GD, + ISC_L1CA, + ISC_L2C, + ISC_L5I5, + ISC_L5Q5, + α_0, + α_1, + α_2, + α_3, + β_0, + β_1, + β_2, + β_3, + WN_op, + t_EOP, + PM_X, + PM_X_dot, + PM_Y, + PM_Y_dot, + ΔUT_GPS, + ΔUT_GPS_dot, + A0_UTC, + A1_UTC, + A2_UTC, + Δt_LS, + t_ot, + WN_ot, + WN_LSF, + DN, + Δt_LSF, + A0_GGTO, + A1_GGTO, + A2_GGTO, + t_GGTO, + WN_GGTO, + GNSS_ID, + reduced_almanacs, + midi_almanacs, + clock_corrections, + ephemeris_corrections, + text_mt15, + text_page_mt15, + text_mt36, + text_page_mt36, + ism, + ) +end + +# The default struct `==` falls back to `===` (reference equality), which fails +# for the mutable `Dictionary` fields even when their contents match. Compare +# field-by-field (mirrors `GPSL1C_DData`). +function Base.:(==)(a::GPSCNAVData, b::GPSCNAVData) + for f in fieldnames(GPSCNAVData) + getfield(a, f) == getfield(b, f) || return false + end + return true +end + +""" +$(TYPEDEF) + +Per-decoder cache shared by the GPS CNAV signals (GPS L5I and L2C). + +Holds the soft-symbol `CircularDeque{Float32}` (capacity = 616 = 600 message +symbols + 16 next-message preamble symbols) plus reusable scratch for the +window-decoding Viterbi that undoes the K=7 NSC FEC in `try_sync`. Because +`try_sync` runs a full 616-symbol decode on *every* incoming symbol until it +locks, the window copy and the Viterbi metric/decision/output buffers are +preallocated here and reused rather than reallocated (~20 KB) per symbol. + +# Fields + +$(TYPEDFIELDS) +""" +struct GPSCNAVCache <: AbstractGNSSCache + """ + Soft-symbol buffer (616 = 600 message + 16 next-message preamble) + """ + soft_buffer::CircularDeque{Float32} + """ + 616-symbol window copied out of `soft_buffer` for each sync attempt + """ + viterbi_window::Vector{Float32} + """ + Viterbi forward-pass path metrics, one per trellis state + """ + viterbi_metrics::Vector{Float32} + """ + Scratch for the next step's path metrics during the forward pass + """ + viterbi_next_metrics::Vector{Float32} + """ + Survivor decisions, `[state, step]`, for the traceback + """ + viterbi_decisions::Matrix{Bool} + """ + Decoded 308-bit window the traceback writes into + """ + viterbi_bits::Vector{Bool} +end + +GPSCNAVCache() = GPSCNAVCache( + CircularDeque{Float32}(CNAV_WINDOW_SYMBOLS), + Vector{Float32}(undef, CNAV_WINDOW_SYMBOLS), + Vector{Float32}(undef, CNAV_VITERBI_NUM_STATES), + Vector{Float32}(undef, CNAV_VITERBI_NUM_STATES), + Matrix{Bool}(undef, CNAV_VITERBI_NUM_STATES, CNAV_WINDOW_BITS), + Vector{Bool}(undef, CNAV_WINDOW_BITS), +) + +function Base.:(==)(a::GPSCNAVCache, b::GPSCNAVCache) + deques_equal(a.soft_buffer, b.soft_buffer) +end + +function is_ephemeris_decoded(data::GPSCNAVData) + # Message type 10 + !isnothing(data.WN) && + !isnothing(data.ΔA) && + !isnothing(data.A_dot) && + !isnothing(data.Δn_0) && + !isnothing(data.M_0) && + !isnothing(data.e) && + !isnothing(data.ω) && + # Message type 11 + !isnothing(data.t_0e) && + !isnothing(data.Ω_0) && + !isnothing(data.i_0) && + !isnothing(data.ΔΩ_dot) && + !isnothing(data.i_dot) && + !isnothing(data.C_is) && + !isnothing(data.C_ic) && + !isnothing(data.C_rs) && + !isnothing(data.C_rc) && + !isnothing(data.C_us) && + !isnothing(data.C_uc) +end + +function is_clock_correction_decoded(data::GPSCNAVData) + # Message types 30-37 shared clock block + message type 30 group delay + !isnothing(data.t_0c) && + !isnothing(data.a_f0) && + !isnothing(data.a_f1) && + !isnothing(data.a_f2) && + !isnothing(data.T_GD) +end + +function is_decoding_completed_for_positioning(data::GPSCNAVData) + !isnothing(data.TOW) && is_ephemeris_decoded(data) && is_clock_correction_decoded(data) +end + +""" +$(TYPEDSIGNATURES) + +Reset a GPS CNAV decoder state after a signal loss or reacquisition. + +Clears the soft-symbol buffer and the time-of-week (TOW) field while +preserving the remaining decoded ephemeris and clock data in `raw_data`, so a +`GNSSReceiver` can re-use the satellite after reacquisition without +re-decoding all message types. Mirrors the semantics of the GPS L1 C/A and +Galileo E1B implementations. + +# Arguments + + - `state::GNSSDecoderState{<:GPSCNAVData}`: Current GPS CNAV decoder state + (GPS L5I or L2C — both share the CNAV `GPSCNAVData`) + +# Returns + + - `GNSSDecoderState{<:GPSCNAVData}`: Reset decoder state with cleared buffers + +# See Also + + - [`GPSL5IDecoderState`](@ref), [`GPSL2CMDecoderState`](@ref): Create a fresh decoder state + - [`decode`](@ref): Continue decoding after reset +""" +function reset_decoder_state(state::GNSSDecoderState{<:GPSCNAVData}) + empty!(state.cache.soft_buffer) + GNSSDecoderState( + state; + raw_data = GPSCNAVData(state.raw_data; TOW = nothing), + data = GPSCNAVData(), + num_bits_after_valid_syncro_sequence = nothing, + is_shifted_by_180_degrees = false, + ) +end + +# ---- Viterbi ---------------------------------------------------------------- +# +# The CNAV FEC is the rate-1/2, K=7 NSC code with G1 = 0o171, G2 = 0o133 +# (IS-GPS-705J §3.3.3.1.1, Figure 3-7) — the same code Galileo E1B uses, but +# convolved continuously across message boundaries with no tail bits. The +# window decoder below therefore starts from an unknown trellis state (all +# path metrics equal), runs a full add-compare-select forward pass over the +# 616-symbol window, and chains back from the best final state. Noise-free +# the maximum-likelihood path is exact at both window edges; under noise the +# preamble + CRC gate rejects any residual edge errors. + +const CNAV_VITERBI_NUM_STATES = 64 # 2^(K-1) for K=7 + +# Encoder output (G1, G2) for every (state, input_bit) pair. State numbering: +# bits [s1 s2 s3 s4 s5 s6] where s1 (MSB) is the most recent past input (one +# period ago) and s6 (LSB) is the oldest (six periods ago); the new input bit u +# shifts in as the next s1. Reading the generator octals MSB-first as the tap on +# the current input down to the six-periods-ago stage: +# y1 = u ⊕ s1 ⊕ s2 ⊕ s3 ⊕ s6 (G1 = 0o171 = 0b1111001) +# y2 = u ⊕ s2 ⊕ s3 ⊕ s5 ⊕ s6 (G2 = 0o133 = 0b1011011) +# Reference: IS-GPS-705J Figure 3-7. +function _cnav_encoder_output(state::UInt8, u::UInt8) + s1 = (state >> 5) & 0x01 + s2 = (state >> 4) & 0x01 + s3 = (state >> 3) & 0x01 + s5 = (state >> 1) & 0x01 + s6 = state & 0x01 + y1 = u ⊻ s1 ⊻ s2 ⊻ s3 ⊻ s6 + y2 = u ⊻ s2 ⊻ s3 ⊻ s5 ⊻ s6 + return (y1, y2) +end + +const _CNAV_VITERBI_G1 = [ + Bool(_cnav_encoder_output(UInt8(s), UInt8(u))[1]) for + s = 0:(CNAV_VITERBI_NUM_STATES-1), u = 0:1 +] +const _CNAV_VITERBI_G2 = [ + Bool(_cnav_encoder_output(UInt8(s), UInt8(u))[2]) for + s = 0:(CNAV_VITERBI_NUM_STATES-1), u = 0:1 +] + +# Soft branch penalty for one expected encoder output bit `y` given the +# received LLR (positive ⇒ bit 0): charge `abs(llr)` iff the hard slice +# disagrees with `y`. Summed over a path this equals the maximum-likelihood +# correlation metric up to a path-independent constant, so the decoder is +# exact ML on soft inputs and degrades gracefully to hard-decision Hamming +# distance for ±1 inputs. +@inline _cnav_branch_penalty(llr::Float32, y::Bool) = + hard_slice(llr) != y ? abs(llr) : 0.0f0 + +""" + gps_cnav_viterbi(soft_window) -> Vector{Bool} + +Viterbi-decode a window of FEC soft symbols (LLR convention, length must be +even) into `length(soft_window) ÷ 2` bits. Starts from an unknown encoder +state (the CNAV FEC runs continuously across message boundaries, so the +window's initial state is the tail of the previous message) and traces back +from the best final state. + +The metric/decision/output buffers default to fresh allocations but may be +supplied (sized for the window) so a hot caller like `try_sync` can reuse a +single preallocated set across calls; the decoded bits are written into +`bits`, which is also returned. +""" +function gps_cnav_viterbi( + soft_window::AbstractVector{Float32}; + metrics::Vector{Float32} = Vector{Float32}(undef, CNAV_VITERBI_NUM_STATES), + next_metrics::Vector{Float32} = Vector{Float32}(undef, CNAV_VITERBI_NUM_STATES), + decisions::Matrix{Bool} = Matrix{Bool}( + undef, + CNAV_VITERBI_NUM_STATES, + length(soft_window) ÷ 2, + ), + bits::Vector{Bool} = Vector{Bool}(undef, length(soft_window) ÷ 2), +) + num_steps = length(soft_window) ÷ 2 + # Unknown initial encoder state ⇒ every path metric starts equal. + fill!(metrics, 0.0f0) + # decisions[s+1, t]: whether the survivor into state s at step t came from + # the odd predecessor (predecessor LSB = 1). + @inbounds for t = 1:num_steps + llr1 = soft_window[2t-1] + llr2 = soft_window[2t] + for sp = 0:(CNAV_VITERBI_NUM_STATES-1) + # Predecessors of `sp` share their top 5 bits with sp's bottom 5; + # both are reached via the same input bit u (top bit of sp). + ps0 = UInt8((sp & 0x1f) << 1) + ps1 = ps0 | 0x01 + u = ((sp >> 5) & 0x01) + 1 + m0 = + metrics[ps0+1] + + _cnav_branch_penalty(llr1, _CNAV_VITERBI_G1[ps0+1, u]) + + _cnav_branch_penalty(llr2, _CNAV_VITERBI_G2[ps0+1, u]) + m1 = + metrics[ps1+1] + + _cnav_branch_penalty(llr1, _CNAV_VITERBI_G1[ps1+1, u]) + + _cnav_branch_penalty(llr2, _CNAV_VITERBI_G2[ps1+1, u]) + from_odd = m1 < m0 + decisions[sp+1, t] = from_odd + next_metrics[sp+1] = from_odd ? m1 : m0 + end + # Renormalize so the accumulated Float32 penalties stay small and + # precision is preserved regardless of the caller's LLR magnitudes. + min_metric = minimum(next_metrics) + for i = 1:CNAV_VITERBI_NUM_STATES + metrics[i] = next_metrics[i] - min_metric + end + end + # Chain back from the best final state; the decoded bit at step t is the + # input bit that produced that step's state (its top bit). + best_state = argmin(metrics) - 1 + @inbounds for t = num_steps:-1:1 + bits[t] = (best_state >> 5) & 0x01 == 0x01 + best_state = ((best_state & 0x1f) << 1) | (decisions[best_state+1, t] ? 1 : 0) + end + return bits +end + +# ---- Sync ------------------------------------------------------------------ +# +# Override the generic packed-buffer sync: the CNAV preamble only exists in +# the *decoded* bit domain, so each sync attempt Viterbi-decodes the whole +# 616-symbol window into 308 bits and mirrors `find_preamble` on the result — +# the preamble must be visible at both the start of this message (bits 1-8) +# and the start of the next (bits 301-308), either both upright or both +# inverted (the NSC generators have odd weight, so a 180°-inverted symbol +# stream decodes to the complemented bit stream). The 300-bit message must +# then pass CRC-24Q; preamble matches with a failing CRC are treated as no +# sync, which lets the window slide on by one symbol. + +""" +Sync result for a GPS CNAV message: the polarity-resolved 300-bit message and the detected polarity. +""" +struct GPSCNAVSync + """ + Polarity-resolved message, packed MSB-first (bit 1 of the message at the MSB) + """ + word::UInt320 + """ + Whether the symbol stream is 180-degrees phase shifted + """ + polarity_flipped::Bool +end + +""" +Pack `bits[start:start+7]` MSB-first into a `UInt8`. +""" +function _pack_preamble(bits::Vector{Bool}, start::Int) + candidate = 0x00 + @inbounds for i = 0:(CNAV_PREAMBLE_BITS-1) + candidate = UInt8(candidate << 1) | UInt8(bits[start+i]) + end + return candidate +end + +""" + try_sync(state::GNSSDecoderState{<:GPSCNAVData}) -> Union{Nothing,GPSCNAVSync} + +Per-signal sync hook for the GPS CNAV signals (L5I / L2C). Viterbi-decodes the buffered 616-symbol +window, requires the CNAV preamble at both ends of the decoded 308-bit window +(in either polarity) and a clean CRC-24Q over the polarity-resolved 300-bit +message. Returns the [`GPSCNAVSync`](@ref) on a match (carrying the message +bits and the detected polarity flip) or `nothing`. +""" +function try_sync(state::GNSSDecoderState{<:GPSCNAVData}) + cache = state.cache + deque = cache.soft_buffer + window = cache.viterbi_window + @inbounds for i = 1:CNAV_WINDOW_SYMBOLS + window[i] = deque[i] + end + bits = gps_cnav_viterbi( + window; + metrics = cache.viterbi_metrics, + next_metrics = cache.viterbi_next_metrics, + decisions = cache.viterbi_decisions, + bits = cache.viterbi_bits, + ) + leading = _pack_preamble(bits, 1) + trailing = _pack_preamble(bits, CNAV_MESSAGE_BITS + 1) + if leading == CNAV_PREAMBLE && trailing == CNAV_PREAMBLE + polarity_flipped = false + elseif leading == ~CNAV_PREAMBLE && trailing == ~CNAV_PREAMBLE + polarity_flipped = true + # Inverted symbols decode to complemented bits; un-complement the + # message portion before the CRC check and field extraction. + @inbounds for i = 1:CNAV_MESSAGE_BITS + bits[i] = !bits[i] + end + else + return nothing + end + crc24q(view(bits, 1:CNAV_MESSAGE_BITS)) == 0 || return nothing + word = UInt320(0) + @inbounds for i = 1:CNAV_MESSAGE_BITS + word = (word << 1) | UInt320(bits[i]) + end + return GPSCNAVSync(word, polarity_flipped) +end + +""" + complement_buffer_if_necessary(state::GNSSDecoderState{<:GPSCNAVData}, sync) + +Resolve the 180° polarity ambiguity for a GPS CNAV message. `sync::GPSCNAVSync` already +carries the polarity-resolved message bits from `try_sync`; record the +polarity on the state and pass the sync object through unchanged for +`decode_syncro_sequence`. +""" +function complement_buffer_if_necessary( + state::GNSSDecoderState{<:GPSCNAVData}, + sync::GPSCNAVSync, +) + GNSSDecoderState(state; is_shifted_by_180_degrees = sync.polarity_flipped), sync +end + +# ---- Message pipeline ------------------------------------------------------- + +""" + decode_syncro_sequence(state::GNSSDecoderState{<:GPSCNAVData}, sync::GPSCNAVSync) + +Process one CRC-validated 300-bit CNAV message: parse the common header +(message type ID at bits 15-20, message TOW count at 21-37, alert flag at +38 — IS-GPS-705J §20.3.3), then dispatch to the per-message-type parser. +Unknown or reserved message types keep the decoded header but no further +fields. +""" +function decode_syncro_sequence(state::GNSSDecoderState{<:GPSCNAVData}, sync::GPSCNAVSync) + word = sync.word + word_length = CNAV_MESSAGE_BITS + PI = state.constants.PI + + message_id = Int(get_bits(word, word_length, 15, 6)) + # The 17-bit message TOW count, in units of 6 seconds, is the SV time at the + # start of the *next* message — the next message is 6 s away on L5I and 12 s + # on L2C, but the count's LSB is 6 s on both (IS-GPS-705J §20.3.3 / + # IS-GPS-200N §30.3.3), so the ×6 scaling is shared. + TOW = Int64(get_bits(word, word_length, 21, 17)) * 6 + alert_flag = get_bit(word, word_length, 38) + raw = GPSCNAVData(state.raw_data; last_message_id = message_id, TOW, alert_flag) + + raw = if message_id == 10 + parse_mt10(raw, word, PI) + elseif message_id == 11 + parse_mt11(raw, word, PI) + elseif message_id == 12 + parse_mt12(raw, word, PI) + elseif message_id == 13 + parse_mt13(raw, word) + elseif message_id == 14 + parse_mt14(raw, word, PI) + elseif message_id == 15 + parse_mt15(raw, word) + elseif message_id == 30 + parse_mt30(raw, word) + elseif message_id == 31 + parse_mt31(raw, word, PI) + elseif message_id == 32 + parse_mt32(raw, word) + elseif message_id == 33 + parse_mt33(raw, word) + elseif message_id == 34 + parse_mt34(raw, word, PI) + elseif message_id == 35 + parse_mt35(raw, word) + elseif message_id == 36 + parse_mt36(raw, word) + elseif message_id == 37 + parse_mt37(raw, word, PI) + elseif message_id == 40 + parse_mt40(raw, word) + else + raw # unknown/reserved message type: header only + end + GNSSDecoderState(state; raw_data = raw) +end + +# ---- Per-message-type bit-field extraction (IS-GPS-705J §20.3.3) ------------ +# +# `word` is the 300-bit message packed MSB-first into a `UInt320` (bit 1 = the +# leftmost preamble bit). Fields are read by 1-based start bit and length +# through the shared `get_bits` / `get_twos_complement_num` / `get_bit` +# helpers, matching the other decoders (#48). + +""" +Message type 10 — ephemeris 1 + health (IS-GPS-705J Fig 20-1, Table 20-I). +""" +function parse_mt10(raw::GPSCNAVData, word::UInt320, PI::Float64) + word_length = CNAV_MESSAGE_BITS + GPSCNAVData( + raw; + WN = Int64(get_bits(word, word_length, 39, 13)), + l1_health = get_bit(word, word_length, 52), + l2_health = get_bit(word, word_length, 53), + l5_health = get_bit(word, word_length, 54), + t_op = Int64(get_bits(word, word_length, 55, 11)) * 300, + ura_ed_index = get_twos_complement_num(word, word_length, 66, 5), + t_0e = Int64(get_bits(word, word_length, 71, 11)) * 300, + ΔA = get_twos_complement_num(word, word_length, 82, 26) * 2.0^-9, + A_dot = get_twos_complement_num(word, word_length, 108, 25) * 2.0^-21, + Δn_0 = get_twos_complement_num(word, word_length, 133, 17) * 2.0^-44 * PI, + Δn_0_dot = get_twos_complement_num(word, word_length, 150, 23) * 2.0^-57 * PI, + M_0 = get_twos_complement_num(word, word_length, 173, 33) * 2.0^-32 * PI, + e = Int(get_bits(word, word_length, 206, 33)) * 2.0^-34, + ω = get_twos_complement_num(word, word_length, 239, 33) * 2.0^-32 * PI, + integrity_status_flag = get_bit(word, word_length, 272), + l2c_phasing = get_bit(word, word_length, 273), + ) +end + +""" +Message type 11 — ephemeris 2 (IS-GPS-705J Fig 20-2, Table 20-I). +""" +function parse_mt11(raw::GPSCNAVData, word::UInt320, PI::Float64) + word_length = CNAV_MESSAGE_BITS + GPSCNAVData( + raw; + t_0e = Int64(get_bits(word, word_length, 39, 11)) * 300, + Ω_0 = get_twos_complement_num(word, word_length, 50, 33) * 2.0^-32 * PI, + i_0 = get_twos_complement_num(word, word_length, 83, 33) * 2.0^-32 * PI, + ΔΩ_dot = get_twos_complement_num(word, word_length, 116, 17) * 2.0^-44 * PI, + i_dot = get_twos_complement_num(word, word_length, 133, 15) * 2.0^-44 * PI, + C_is = get_twos_complement_num(word, word_length, 148, 16) * 2.0^-30, + C_ic = get_twos_complement_num(word, word_length, 164, 16) * 2.0^-30, + C_rs = get_twos_complement_num(word, word_length, 180, 24) * 2.0^-8, + C_rc = get_twos_complement_num(word, word_length, 204, 24) * 2.0^-8, + C_us = get_twos_complement_num(word, word_length, 228, 21) * 2.0^-30, + C_uc = get_twos_complement_num(word, word_length, 249, 21) * 2.0^-30, + ) +end + +""" +Shared clock block of message types 30-37, bits 39-127 (IS-GPS-705J Fig 20-3, Table 20-III). +""" +function parse_clock_block(raw::GPSCNAVData, word::UInt320) + word_length = CNAV_MESSAGE_BITS + GPSCNAVData( + raw; + t_op = Int64(get_bits(word, word_length, 39, 11)) * 300, + ura_ned0_index = get_twos_complement_num(word, word_length, 50, 5), + ura_ned1_index = Int64(get_bits(word, word_length, 55, 3)), + ura_ned2_index = Int64(get_bits(word, word_length, 58, 3)), + t_0c = Int64(get_bits(word, word_length, 61, 11)) * 300, + a_f0 = get_twos_complement_num(word, word_length, 72, 26) * 2.0^-35, + a_f1 = get_twos_complement_num(word, word_length, 98, 20) * 2.0^-48, + a_f2 = get_twos_complement_num(word, word_length, 118, 10) * 2.0^-60, + ) +end + +""" +Message type 30 — clock, iono & group delay (IS-GPS-705J Fig 20-3, Tables 20-III/20-IV). +""" +function parse_mt30(raw::GPSCNAVData, word::UInt320) + word_length = CNAV_MESSAGE_BITS + raw = parse_clock_block(raw, word) + GPSCNAVData( + raw; + T_GD = get_twos_complement_num(word, word_length, 128, 13) * 2.0^-35, + ISC_L1CA = get_twos_complement_num(word, word_length, 141, 13) * 2.0^-35, + ISC_L2C = get_twos_complement_num(word, word_length, 154, 13) * 2.0^-35, + ISC_L5I5 = get_twos_complement_num(word, word_length, 167, 13) * 2.0^-35, + ISC_L5Q5 = get_twos_complement_num(word, word_length, 180, 13) * 2.0^-35, + α_0 = get_twos_complement_num(word, word_length, 193, 8) * 2.0^-30, + α_1 = get_twos_complement_num(word, word_length, 201, 8) * 2.0^-27, + α_2 = get_twos_complement_num(word, word_length, 209, 8) * 2.0^-24, + α_3 = get_twos_complement_num(word, word_length, 217, 8) * 2.0^-24, + β_0 = get_twos_complement_num(word, word_length, 225, 8) * 2.0^11, + β_1 = get_twos_complement_num(word, word_length, 233, 8) * 2.0^14, + β_2 = get_twos_complement_num(word, word_length, 241, 8) * 2.0^16, + β_3 = get_twos_complement_num(word, word_length, 249, 8) * 2.0^16, + WN_op = Int64(get_bits(word, word_length, 257, 8)), + ) +end + +""" +Decode one 31-bit reduced-almanac packet starting at 1-based bit `start` (IS-GPS-705J Fig 20-16). +""" +function _cnav_reduced_almanac_packet( + word::UInt320, + start::Int, + WN_a::Int, + t_oa::Int, + PI::Float64, +) + word_length = CNAV_MESSAGE_BITS + PRN_a = Int(get_bits(word, word_length, start, 6)) + PRN_a == 0 && return nothing # empty packet ⇒ no further packets follow + GPSCNAVReducedAlmanac(; + PRN_a, + WN_a, + t_oa, + δA = get_twos_complement_num(word, word_length, start + 6, 8) * 2.0^9, + Ω_0 = get_twos_complement_num(word, word_length, start + 14, 7) * 2.0^-6 * PI, + Φ_0 = get_twos_complement_num(word, word_length, start + 21, 7) * 2.0^-6 * PI, + l1_health = get_bit(word, word_length, start + 28), + l2_health = get_bit(word, word_length, start + 29), + l5_health = get_bit(word, word_length, start + 30), + ) +end + +""" +Message type 12 — seven reduced-almanac packets (IS-GPS-705J Fig 20-11). +""" +function parse_mt12(raw::GPSCNAVData, word::UInt320, PI::Float64) + word_length = CNAV_MESSAGE_BITS + WN_a = Int(get_bits(word, word_length, 39, 13)) + t_oa = Int(get_bits(word, word_length, 52, 8)) * 2^12 + almanacs = raw.reduced_almanacs + # Seven 31-bit packets at bits 60, 91, 122, 153, 184, 215, 246. + for start in (60, 91, 122, 153, 184, 215, 246) + packet = _cnav_reduced_almanac_packet(word, start, WN_a, t_oa, PI) + isnothing(packet) && break # PRN_a==0 ⇒ remaining packets are filler + almanacs = _merge_keyed(almanacs, packet.PRN_a, packet) + end + GPSCNAVData(raw; reduced_almanacs = almanacs) +end + +""" +Message type 31 — clock & four reduced-almanac packets (IS-GPS-705J Fig 20-4). +""" +function parse_mt31(raw::GPSCNAVData, word::UInt320, PI::Float64) + word_length = CNAV_MESSAGE_BITS + raw = parse_clock_block(raw, word) + WN_a = Int(get_bits(word, word_length, 128, 13)) + t_oa = Int(get_bits(word, word_length, 141, 8)) * 2^12 + almanacs = raw.reduced_almanacs + # Four 31-bit packets at bits 149, 180, 211, 242. + for start in (149, 180, 211, 242) + packet = _cnav_reduced_almanac_packet(word, start, WN_a, t_oa, PI) + isnothing(packet) && break # PRN_a==0 ⇒ remaining packets are filler + almanacs = _merge_keyed(almanacs, packet.PRN_a, packet) + end + GPSCNAVData(raw; reduced_almanacs = almanacs) +end + +""" +Message type 32 — clock & EOP (IS-GPS-705J Fig 20-5, Table 20-VII). +""" +function parse_mt32(raw::GPSCNAVData, word::UInt320) + word_length = CNAV_MESSAGE_BITS + raw = parse_clock_block(raw, word) + GPSCNAVData( + raw; + t_EOP = Int64(get_bits(word, word_length, 128, 16)) * 16, + PM_X = get_twos_complement_num(word, word_length, 144, 21) * 2.0^-20, + PM_X_dot = get_twos_complement_num(word, word_length, 165, 15) * 2.0^-21, + PM_Y = get_twos_complement_num(word, word_length, 180, 21) * 2.0^-20, + PM_Y_dot = get_twos_complement_num(word, word_length, 201, 15) * 2.0^-21, + ΔUT_GPS = get_twos_complement_num(word, word_length, 216, 31) * 2.0^-23, + ΔUT_GPS_dot = get_twos_complement_num(word, word_length, 247, 19) * 2.0^-25, + ) +end + +""" +Message type 33 — clock & UTC (IS-GPS-705J Fig 20-6, Table 20-IX). +""" +function parse_mt33(raw::GPSCNAVData, word::UInt320) + word_length = CNAV_MESSAGE_BITS + raw = parse_clock_block(raw, word) + GPSCNAVData( + raw; + A0_UTC = get_twos_complement_num(word, word_length, 128, 16) * 2.0^-35, + A1_UTC = get_twos_complement_num(word, word_length, 144, 13) * 2.0^-51, + A2_UTC = get_twos_complement_num(word, word_length, 157, 7) * 2.0^-68, + Δt_LS = get_twos_complement_num(word, word_length, 164, 8), + t_ot = Int64(get_bits(word, word_length, 172, 16)) * 16, + WN_ot = Int64(get_bits(word, word_length, 188, 13)), + WN_LSF = Int64(get_bits(word, word_length, 201, 13)), + DN = Int64(get_bits(word, word_length, 214, 4)), + Δt_LSF = get_twos_complement_num(word, word_length, 218, 8), + ) +end + +""" +Message type 35 — clock & GGTO (IS-GPS-705J Fig 20-8, Table 20-XI). +""" +function parse_mt35(raw::GPSCNAVData, word::UInt320) + word_length = CNAV_MESSAGE_BITS + raw = parse_clock_block(raw, word) + GPSCNAVData( + raw; + t_GGTO = Int64(get_bits(word, word_length, 128, 16)) * 16, + WN_GGTO = Int64(get_bits(word, word_length, 144, 13)), + GNSS_ID = Int64(get_bits(word, word_length, 157, 3)), + A0_GGTO = get_twos_complement_num(word, word_length, 160, 16) * 2.0^-35, + A1_GGTO = get_twos_complement_num(word, word_length, 176, 13) * 2.0^-51, + A2_GGTO = get_twos_complement_num(word, word_length, 189, 7) * 2.0^-68, + ) +end + +""" +Message type 37 — clock & one Midi almanac (IS-GPS-705J Fig 20-10, Table 20-V). +""" +function parse_mt37(raw::GPSCNAVData, word::UInt320, PI::Float64) + word_length = CNAV_MESSAGE_BITS + raw = parse_clock_block(raw, word) + PRN_a = Int(get_bits(word, word_length, 149, 6)) + PRN_a == 0 && return raw # empty almanac + alm = GPSCNAVMidiAlmanac(; + PRN_a, + WN_a = Int(get_bits(word, word_length, 128, 13)), + t_oa = Int(get_bits(word, word_length, 141, 8)) * 2^12, + l1_health = get_bit(word, word_length, 155), + l2_health = get_bit(word, word_length, 156), + l5_health = get_bit(word, word_length, 157), + e = Int(get_bits(word, word_length, 158, 11)) * 2.0^-16, + δi = get_twos_complement_num(word, word_length, 169, 11) * 2.0^-14 * PI, + Ω_dot = get_twos_complement_num(word, word_length, 180, 11) * 2.0^-33 * PI, + sqrt_A = Int(get_bits(word, word_length, 191, 17)) * 2.0^-4, + Ω_0 = get_twos_complement_num(word, word_length, 208, 16) * 2.0^-15 * PI, + ω = get_twos_complement_num(word, word_length, 224, 16) * 2.0^-15 * PI, + M_0 = get_twos_complement_num(word, word_length, 240, 16) * 2.0^-15 * PI, + a_f0 = get_twos_complement_num(word, word_length, 256, 11) * 2.0^-20, + a_f1 = get_twos_complement_num(word, word_length, 267, 10) * 2.0^-37, + ) + GPSCNAVData(raw; midi_almanacs = _merge_keyed(raw.midi_almanacs, PRN_a, alm)) +end + +# All-ones PRN ID in a CDC/EDC packet ⇒ no DC data in the remainder of the +# data block (IS-GPS-705J §20.3.3.7.2.3). +const CNAV_DC_EMPTY_PRN = 0xff + +""" +Decode one 34-bit CDC packet starting at 1-based bit `start` (IS-GPS-705J Fig 20-17). +""" +function _cnav_cdc_packet( + word::UInt320, + start::Int, + t_op_D::Int, + t_OD::Int, + dc_data_type::Bool, +) + word_length = CNAV_MESSAGE_BITS + PRN_a = Int(get_bits(word, word_length, start, 8)) + PRN_a == CNAV_DC_EMPTY_PRN && return nothing + GPSCNAVClockDifferentialCorrection(; + PRN_a, + t_op_D, + t_OD, + dc_data_type, + δa_f0 = get_twos_complement_num(word, word_length, start + 8, 13) * 2.0^-35, + δa_f1 = get_twos_complement_num(word, word_length, start + 21, 8) * 2.0^-51, + UDRA_index = get_twos_complement_num(word, word_length, start + 29, 5), + ) +end + +""" +Decode one 92-bit EDC packet starting at 1-based bit `start` (IS-GPS-705J Fig 20-17). +""" +function _cnav_edc_packet( + word::UInt320, + start::Int, + t_op_D::Int, + t_OD::Int, + dc_data_type::Bool, + PI::Float64, +) + word_length = CNAV_MESSAGE_BITS + PRN_a = Int(get_bits(word, word_length, start, 8)) + PRN_a == CNAV_DC_EMPTY_PRN && return nothing + GPSCNAVEphemerisDifferentialCorrection(; + PRN_a, + t_op_D, + t_OD, + dc_data_type, + Δα = get_twos_complement_num(word, word_length, start + 8, 14) * 2.0^-34, + Δβ = get_twos_complement_num(word, word_length, start + 22, 14) * 2.0^-34, + Δγ = get_twos_complement_num(word, word_length, start + 36, 15) * 2.0^-32 * PI, + Δi = get_twos_complement_num(word, word_length, start + 51, 12) * 2.0^-32 * PI, + ΔΩ = get_twos_complement_num(word, word_length, start + 63, 12) * 2.0^-32 * PI, + ΔA = get_twos_complement_num(word, word_length, start + 75, 12) * 2.0^-9, + UDRA_dot_index = get_twos_complement_num(word, word_length, start + 87, 5), + ) +end + +""" +Message type 13 — clock differential correction (IS-GPS-705J Fig 20-12). +""" +function parse_mt13(raw::GPSCNAVData, word::UInt320) + word_length = CNAV_MESSAGE_BITS + t_op_D = Int(get_bits(word, word_length, 39, 11)) * 300 + t_OD = Int(get_bits(word, word_length, 50, 11)) * 300 + corrections = raw.clock_corrections + # Six 35-bit packets (1 DC data type bit + 34-bit CDC) at bits 61, 96, + # 131, 166, 201, 236. + for start in (61, 96, 131, 166, 201, 236) + dc_data_type = get_bit(word, word_length, start) + packet = _cnav_cdc_packet(word, start + 1, t_op_D, t_OD, dc_data_type) + # All-ones PRN ends the data block (like PRN 0 in the reduced almanac); + # `continue` over it — any trailing packets are filler too. + isnothing(packet) && continue + corrections = _merge_keyed(corrections, packet.PRN_a, packet) + end + GPSCNAVData(raw; clock_corrections = corrections) +end + +""" +Message type 14 — ephemeris differential correction (IS-GPS-705J Fig 20-13). +""" +function parse_mt14(raw::GPSCNAVData, word::UInt320, PI::Float64) + word_length = CNAV_MESSAGE_BITS + t_op_D = Int(get_bits(word, word_length, 39, 11)) * 300 + t_OD = Int(get_bits(word, word_length, 50, 11)) * 300 + corrections = raw.ephemeris_corrections + # Two 93-bit packets (1 DC data type bit + 92-bit EDC) at bits 61, 154. + for start in (61, 154) + dc_data_type = get_bit(word, word_length, start) + packet = _cnav_edc_packet(word, start + 1, t_op_D, t_OD, dc_data_type, PI) + # All-ones PRN ends the data block (like PRN 0 in the reduced almanac); + # `continue` over it — any trailing packets are filler too. + isnothing(packet) && continue + corrections = _merge_keyed(corrections, packet.PRN_a, packet) + end + GPSCNAVData(raw; ephemeris_corrections = corrections) +end + +""" +Message type 34 — clock & one CDC+EDC pair (IS-GPS-705J Fig 20-7). +""" +function parse_mt34(raw::GPSCNAVData, word::UInt320, PI::Float64) + word_length = CNAV_MESSAGE_BITS + raw = parse_clock_block(raw, word) + t_op_D = Int(get_bits(word, word_length, 128, 11)) * 300 + t_OD = Int(get_bits(word, word_length, 139, 11)) * 300 + dc_data_type = get_bit(word, word_length, 150) + cdc = _cnav_cdc_packet(word, 151, t_op_D, t_OD, dc_data_type) + edc = _cnav_edc_packet(word, 185, t_op_D, t_OD, dc_data_type, PI) + clock_corrections = + isnothing(cdc) ? raw.clock_corrections : + _merge_keyed(raw.clock_corrections, cdc.PRN_a, cdc) + ephemeris_corrections = + isnothing(edc) ? raw.ephemeris_corrections : + _merge_keyed(raw.ephemeris_corrections, edc.PRN_a, edc) + GPSCNAVData(raw; clock_corrections, ephemeris_corrections) +end + +""" +Decode `num_chars` 8-bit ASCII characters starting at 1-based bit `start`, stripping control chars. +""" +function _cnav_text(word::UInt320, start::Int, num_chars::Int) + word_length = CNAV_MESSAGE_BITS + chars = Char[] + for k = 0:(num_chars-1) + code = Int(get_bits(word, word_length, start + 8k, 8)) + # Keep printable ASCII; skip NUL/control padding so the message is clean. + (code >= 0x20 && code < 0x7f) && push!(chars, Char(code)) + end + return String(chars) +end + +""" +Message type 15 — 29 ASCII characters at bits 39-270 (IS-GPS-705J Fig 20-14). +""" +function parse_mt15(raw::GPSCNAVData, word::UInt320) + word_length = CNAV_MESSAGE_BITS + GPSCNAVData( + raw; + text_mt15 = _cnav_text(word, 39, 29), + text_page_mt15 = Int64(get_bits(word, word_length, 271, 4)), + ) +end + +""" +Message type 36 — clock & 18 ASCII characters at bits 128-271 (IS-GPS-705J Fig 20-9). +""" +function parse_mt36(raw::GPSCNAVData, word::UInt320) + word_length = CNAV_MESSAGE_BITS + raw = parse_clock_block(raw, word) + GPSCNAVData( + raw; + text_mt36 = _cnav_text(word, 128, 18), + text_page_mt36 = Int64(get_bits(word, word_length, 272, 4)), + ) +end + +""" +Message type 40 — Integrity Support Message (IS-GPS-705J Fig 20-14a). +""" +function parse_mt40(raw::GPSCNAVData, word::UInt320) + word_length = CNAV_MESSAGE_BITS + # 63-bit SV mask (MSB = PRN 1) at bits 89-151, read as one contiguous + # field; IS-GPS-705J Figure 20-14a only *draws* it across a row boundary. + mask = UInt64(get_bits(word, word_length, 89, 63)) + ism = GPSCNAVIntegritySupportMessage(; + GNSS_ID = Int(get_bits(word, word_length, 39, 4)), + WN_ISM = Int(get_bits(word, word_length, 43, 13)), + TOW_ISM = Int(get_bits(word, word_length, 56, 6)), + t_correl = Int(get_bits(word, word_length, 62, 4)), + b_nom = Int(get_bits(word, word_length, 66, 4)), + γ_nom = Int(get_bits(word, word_length, 70, 4)), + R_sat = Int(get_bits(word, word_length, 74, 4)), + P_const = Int(get_bits(word, word_length, 78, 4)), + MFD = Int(get_bits(word, word_length, 82, 4)), + service_level = Int(get_bits(word, word_length, 86, 3)), + mask, + ) + GPSCNAVData(raw; ism) +end + +""" + validate_data(state::GNSSDecoderState{<:GPSCNAVData}) + +Promote `raw_data` to `data` once the minimum positioning set (message types +10 + 11 ephemeris and the message type 30 clock + group delay block) has been +decoded. Every CNAV message carries its own TOW, so each validated message +re-arms the streaming counter: the TOW refers to the start of the *next* +message, whose first `preamble_length` symbols are already buffered. +""" +function validate_data(state::GNSSDecoderState{<:GPSCNAVData}) + if is_decoding_completed_for_positioning(state.raw_data) + return GNSSDecoderState( + state; + data = state.raw_data, + num_bits_after_valid_syncro_sequence = state.constants.preamble_length, + ) + end + return state +end + +# ---- Deprecated aliases ----------------------------------------------------- +# +# The shared CNAV data container and its records were originally named +# `GPSL5I*` (GPS L5I was the first CNAV signal implemented, IS-GPS-705J). GPS +# L2C (IS-GPS-200N §30) broadcasts the identical CNAV message and reuses these +# types, so they were renamed to the signal-neutral `GPSCNAV*`. The old names +# are kept as exported deprecated aliases for backward compatibility. +# +# DESIGN NOTE: remove these aliases at the next breaking change / major version +# bump (they were public since v2.1.0). Track alongside any future field +# renames so downstream users migrate `GPSL5I*` → `GPSCNAV*` once. +Base.@deprecate_binding GPSL5IData GPSCNAVData true +Base.@deprecate_binding GPSL5IReducedAlmanac GPSCNAVReducedAlmanac true +Base.@deprecate_binding GPSL5IMidiAlmanac GPSCNAVMidiAlmanac true +Base.@deprecate_binding GPSL5IClockDifferentialCorrection GPSCNAVClockDifferentialCorrection true +Base.@deprecate_binding GPSL5IEphemerisDifferentialCorrection GPSCNAVEphemerisDifferentialCorrection true +Base.@deprecate_binding GPSL5IIntegritySupportMessage GPSCNAVIntegritySupportMessage true diff --git a/src/gps/l1c_d.jl b/src/gps/l1c_d.jl index c89c121..74bb6b0 100644 --- a/src/gps/l1c_d.jl +++ b/src/gps/l1c_d.jl @@ -1002,15 +1002,6 @@ end # implemented: page 1 carries the four ISC fields that pre-IRN-J recordings # lack, so older recordings are out of scope. -""" -Insert/overwrite `value` keyed by `key` in a (possibly `nothing`) `Dictionary`, returning the updated copy. -""" -function _merge_keyed(dict::Union{Nothing,Dictionary{Int,V}}, key::Int, value::V) where {V} - out = isnothing(dict) ? Dictionary{Int,V}() : copy(dict) - set!(out, key, value) - return out -end - function decode_subframe3(state::GNSSDecoderState{<:GPSL1C_DData}, sf3_symbols) word = ldpc_decode_word(state.cache.sf3_decoder, sf3_symbols, L1C_D_SF3_INFO_BITS, UInt288) diff --git a/src/gps/l1ca.jl b/src/gps/l1ca.jl index 05461ff..56753d3 100755 --- a/src/gps/l1ca.jl +++ b/src/gps/l1ca.jl @@ -1,8 +1,3 @@ -# UInt320 buffer for GPS L1 (more efficient than UInt312) -# which holds at least a complete GPS L1 subframe plus -# 8 extra syncronization bíts -BitIntegers.@define_integers 320 - """ GPSL1CAConstants diff --git a/src/gps/l2c.jl b/src/gps/l2c.jl new file mode 100644 index 0000000..30339ef --- /dev/null +++ b/src/gps/l2c.jl @@ -0,0 +1,120 @@ +# GPS L2C (CNAV) signal layer — IS-GPS-200N §30. +# +# GPS L2C broadcasts the civil navigation (CNAV) message on the L2 CM code +# (the time-multiplexed L2 CL code is a dataless pilot, IS-GPS-200N §3.2.1.4 / +# §3.3.3.1). The CNAV message on L2C is *bit-for-bit identical* to the one on +# GPS L5I: same 8-bit preamble `10001011`, same rate-1/2 K=7 continuous +# convolutional FEC (G1 = 0o171, G2 = 0o133), same CRC-24Q, same +# per-message-type bit layouts, same π and `TOW × 6` scaling (IS-GPS-200N §30 +# ≡ IS-GPS-705J §20.3.3). The signal-layer differences — 25 bps → 50 sps and +# 12-second messages (vs L5's 50 bps → 100 sps, 6-second messages) — are +# purely in the time domain and do not affect this symbol-domain decoder: the +# message is always 600 symbols and the sync window 616. The TOW count is in +# 6-second units on both signals (IS-GPS-200N §30.3.3: "multiplied by 6 … +# start of the next 12-second message"), so the shared `decode_syncro_sequence` +# (`TOW × 6`) applies unchanged. +# +# Consequently this file reuses the entire shared GPS CNAV core in +# `gps/cnav.jl` (FEC, sync, message parsing, and the `GPSCNAVData` container). +# The only L2C-specific behaviour is `is_sat_healthy`, which reports the L2 +# signal-health bit (IS-GPS-200N §30.3.3.1.1.2) instead of the L5 bit. This +# mirrors how gnss-sdr and pocketsdr drive L2C and L5 through one CNAV decoder. + +""" + GPSL2CMConstants + +GPS L2C specialization of [`GPSCNAVConstants`](@ref GNSSDecoder.GPSCNAVConstants) (`GPSCNAVConstants{:GPSL2CM}`). +Same field values as the GPS L5I constants — the CNAV message is identical on +both signals; the distinct tag only selects the L2 health bit in +[`is_sat_healthy`](@ref). The data-bearing L2C component is the L2 CM code +(`GPSL2CM`); the L2 CL code is a dataless pilot. Reference: IS-GPS-200N +§30.3.2 / §3.3.3.1. +""" +const GPSL2CMConstants = GPSCNAVConstants{:GPSL2CM} + +""" +$(TYPEDSIGNATURES) + +Create a decoder state for GPS L2C CNAV navigation messages. + +Initializes a [`GNSSDecoderState`](@ref) configured for decoding GPS L2C civil +navigation (CNAV) messages from the FEC-encoded 50 sps soft symbols of the L2 +CM component. The CNAV message is identical to GPS L5I's, so decoding reuses +the shared GPS CNAV core: each sync attempt Viterbi-decodes the buffered +616-symbol window, locates the 8-bit preamble (`0b10001011`) at both ends of +the decoded bit window, validates the 300-bit message with CRC-24Q, and +dispatches it to per-type parsers (message types 10-15, 30-37, and 40, +IS-GPS-200N §30.3.3). Decoded fields land in a [`GPSCNAVData`](@ref) (the shared +CNAV container). + +# Arguments + + - `prn::Int`: Pseudo-Random Noise code identifier (1-63 for GPS satellites) + +# Returns + + - `GNSSDecoderState{GPSCNAVData}`: Initialized decoder state for GPS L2C + +# Example + +```julia +state = GPSL2CMDecoderState(1) # PRN 1 +state = decode(state, soft_symbols, num_symbols) +if is_sat_healthy(state) + # Use state.data for positioning +end +``` + +# See Also + + - [`GNSSDecoderState`](@ref): The underlying state structure + - [`GPSL5IDecoderState`](@ref): The GPS L5I decoder sharing this CNAV core + - [`decode`](@ref): Decode soft symbols using this state + - [`reset_decoder_state`](@ref): Reset after signal loss + - [`is_sat_healthy`](@ref): Check satellite health status +""" +function GPSL2CMDecoderState(prn) + GNSSDecoderState( + prn, + GPSCNAVData(), + GPSCNAVData(), + GPSL2CMConstants(), + GPSCNAVCache(), + nothing, + false, + ) +end + +# Dispatch from a GNSSSignals system type, mirroring `GNSSDecoderState(::GPSL5I, …)`. +# CNAV rides on the L2 CM (data) component — `GPSL2CM` — while `GPSL2CL` is the +# dataless pilot (IS-GPS-200N §3.3.3.1), so only `GPSL2CM` maps to a decoder. +function GNSSDecoderState(system::GPSL2CM, prn) + GPSL2CMDecoderState(prn) +end + +""" +$(TYPEDSIGNATURES) + +Check if the GPS L2C satellite is healthy and usable for positioning. + +Examines the L2 signal health bit decoded from the most recent message +type 10 (IS-GPS-200N §30.3.3.1.1.2): a satellite is healthy iff the L2 health +bit is 0 (some or all codes and data on the L2 carrier are OK). This is the +only decode-level difference from GPS L5I, which reports the L5 health bit. + +!!! warning + + Requires message type 10 to have been decoded and the positioning set to + have been validated; returns `false` until then. + +# Arguments + + - `state::GNSSDecoderState{<:GPSCNAVData,<:GPSL2CMConstants}`: GPS L2C decoder state. + +# Returns + + - `Bool`: `true` iff the L2 signal-health bit indicates OK. +""" +function is_sat_healthy(state::GNSSDecoderState{<:GPSCNAVData,<:GPSL2CMConstants}) + state.data.l2_health === false +end diff --git a/src/gps/l5.jl b/src/gps/l5.jl index 2024d35..9b1e7d4 100644 --- a/src/gps/l5.jl +++ b/src/gps/l5.jl @@ -1,757 +1,22 @@ -# GPS L5I (CNAV) decoder — IS-GPS-705J. +# GPS L5I (CNAV) signal layer — IS-GPS-705J §20.3. # -# The L5 in-phase data channel carries the CNAV message as 300-bit messages at -# 50 bps, FEC-encoded with the rate-1/2, constraint-length-7 (K=7) -# non-systematic convolutional (NSC) code (G1 = 0o171, G2 = 0o133, -# IS-GPS-705J §3.3.3.1.1) to 600 channel symbols at 100 sps. Every message -# starts with the 8-bit preamble `10001011` and ends with a 24-bit CRC-24Q -# over the whole message (IS-GPS-705J §20.3.5). -# -# Unlike Galileo E1B pages, the CNAV FEC is convolved *continuously* across -# message boundaries — there are no tail bits and no encoder reset — so the -# preamble is only visible after FEC decoding and AFF3CT's tail-terminated -# `ConvViterbiDecoder` does not apply. Message sync therefore follows the -# generic streaming framework in `src/gnss.jl` with a window-decoding -# `try_sync`: the soft-symbol `CircularDeque` is sized to one full message -# (600 symbols) plus the 16-symbol encoding of the *next* message's start -# (616 total); each sync attempt Viterbi-decodes the whole window on the raw -# `Float32` LLRs and requires the preamble at *both ends* of the decoded -# window (in either polarity, mirroring `find_preamble`) plus a clean -# CRC-24Q before locking. Symbol-pair phase ambiguity needs no special -# handling — a misaligned window simply fails to sync and slides one symbol. - -""" -One CNAV message: 300 bits = 600 channel symbols (rate 1/2) at 100 sps. -""" -const L5I_MESSAGE_BITS = 300 -const L5I_MESSAGE_SYMBOLS = 2 * L5I_MESSAGE_BITS -""" -Trailing window: the next message's first 8 bits (16 symbols) confirm sync. -""" -const L5I_PREAMBLE_BITS = 8 -const L5I_PREAMBLE_SYMBOLS = 2 * L5I_PREAMBLE_BITS -const L5I_WINDOW_SYMBOLS = L5I_MESSAGE_SYMBOLS + L5I_PREAMBLE_SYMBOLS # 616 -const L5I_WINDOW_BITS = L5I_MESSAGE_BITS + L5I_PREAMBLE_BITS # 308 - -""" -CNAV preamble `10001011` (IS-GPS-705J §20.3.3). -""" -const L5I_PREAMBLE = 0b10001011 - -# Semi-major axis reference (IS-GPS-705J Table 20-I, meters). -const L5I_A_REF = 26_559_710.0 -# Rate-of-right-ascension reference (IS-GPS-705J Table 20-I, semi-circles/sec). -const L5I_OMEGA_DOT_REF = -2.6e-9 +# The GPS L5 in-phase component carries the CNAV message at 50 bps, FEC-encoded +# to 100 sps over 6-second 300-bit messages (IS-GPS-705J §3.3.3.1.1, §20.3). +# The message format, FEC, CRC, sync, and per-message-type parsing are the +# shared GPS CNAV core in `gps/cnav.jl` (the very same message is broadcast on +# GPS L2C, IS-GPS-200N §30). This file holds only what is specific to L5I: the +# constants type, the decoder-state constructor, and the health check (which +# reports the L5 signal-health bit). """ GPSL5IConstants -WGS 84 constants and CNAV message structure parameters for GPS L5I decoding. - -The message is modelled through the generic streaming framework: -`syncro_sequence_length` is the 600-symbol message that is drained once -decoded, and `preamble_length` is the 16-symbol encoding of the *next* -message's preamble retained at the tail of the sync window. - -# Fields - -$(TYPEDFIELDS) - -# Reference - -IS-GPS-705J, Sections 20.3.3 and 20.3.4.3. -""" -Base.@kwdef struct GPSL5IConstants <: AbstractGNSSConstants - """ - Message length drained after each decoded message (600 symbols) - """ - syncro_sequence_length::Int = L5I_MESSAGE_SYMBOLS - """ - Trailing next-message preamble segment retained for sync (16 symbols) - """ - preamble_length::Int = L5I_PREAMBLE_SYMBOLS - """ - Mathematical constant π (IS-GPS-705J Table 20-II) - """ - PI::Float64 = GNSS_PI - """ - WGS 84 Earth rotation rate (rad/s) - """ - Ω_dot_e::Float64 = EARTH_ROTATION_RATE - """ - Speed of light (m/s) - """ - c::Float64 = SPEED_OF_LIGHT - """ - WGS 84 Earth gravitational parameter (m³/s²) - """ - μ::Float64 = 3.986005e14 - """ - Relativistic correction constant (s/√m) - """ - F::Float64 = -4.442807633e-10 -end - -""" - GPSL5IReducedAlmanac - -One satellite's reduced-almanac packet from CNAV message types 12 or 31 -(IS-GPS-705J Figure 20-16, Table 20-VI). - -The reduced almanac gives a very coarse ephemeris for satellite selection. -Message type 12 carries seven 31-bit packets, message type 31 four; each -packet is complete in itself, so `GPSL5IData.reduced_almanacs` entries are -inserted whole, keyed by `PRN_a` (mirrors [`GPSL1C_DReducedAlmanac`](@ref)). - -Reference values to apply (Table 20-VI footnotes): `e = 0`, -`δi = +0.0056 semi-circles` (so `i = 55°`), -`Ω̇ = -2.6e-9 semi-circles/s`, `A = A_ref + δA` with `A_ref = 26 559 710 m`, -`Φ₀ = M₀ + ω`. Semi-circle fields are converted to radians on decode. - -# Fields - - - `PRN_a::Int`: Almanac satellite PRN (1-63; 0 marks an empty packet). - - `WN_a::Int`: Almanac reference week number (mod 8192). - - `t_oa::Int`: Almanac reference time of week (seconds). - - `δA::Float64`: Semi-major-axis delta from `A_ref` (meters). - - `Ω_0::Float64`: Longitude of ascending node at weekly epoch (rad). - - `Φ_0::Float64`: Argument of latitude at reference time, `M₀+ω` (rad). - - `l1_health::Bool`, `l2_health::Bool`, `l5_health::Bool`: per-band health - (false = OK, true = some/all signals bad). - -# Reference - -IS-GPS-705J, Figures 20-4 / 20-11 / 20-16, Table 20-VI. -""" -Base.@kwdef struct GPSL5IReducedAlmanac - PRN_a::Int - WN_a::Int - t_oa::Int - δA::Float64 - Ω_0::Float64 - Φ_0::Float64 - l1_health::Bool - l2_health::Bool - l5_health::Bool -end - -""" - GPSL5IMidiAlmanac - -One satellite's Midi almanac from CNAV message type 37 (IS-GPS-705J -Figure 20-10, Table 20-V). - -The Midi almanac is a medium-precision single-SV almanac, complete in a -single message, so `GPSL5IData.midi_almanacs` entries are inserted whole, -keyed by `PRN_a` (mirrors [`GPSL1C_DMidiAlmanac`](@ref)). Inclination is `δi` -relative to `i₀ = 0.30 semi-circles` (54°); semi-circle fields are converted -to radians on decode. - -# Fields - - - `PRN_a::Int`: Almanac satellite PRN. - - `WN_a::Int`: Almanac reference week number (mod 8192). - - `t_oa::Int`: Almanac reference time of week (seconds). - - `e::Float64`: Eccentricity (dimensionless). - - `δi::Float64`: Inclination delta from `i₀ = 0.30 sc` (rad); add the reference. - - `Ω_dot::Float64`: Rate of right ascension (rad/s). - - `sqrt_A::Float64`: Square root of the semi-major axis (√m). - - `Ω_0::Float64`: Longitude of ascending node at weekly epoch (rad). - - `ω::Float64`: Argument of perigee (rad). - - `M_0::Float64`: Mean anomaly at reference time (rad). - - `a_f0::Float64`, `a_f1::Float64`: Clock bias / drift (s, s/s). - - `l1_health::Bool`, `l2_health::Bool`, `l5_health::Bool`: per-band health. - -# Reference - -IS-GPS-705J, Figure 20-10, Table 20-V. +GPS L5I specialization of [`GPSCNAVConstants`](@ref GNSSDecoder.GPSCNAVConstants) (`GPSCNAVConstants{:GPSL5I}`). +Same field values as the GPS L2C constants — the distinct tag only selects the +L5 health bit in [`is_sat_healthy`](@ref). Reference: IS-GPS-705J §20.3.3 / +§20.3.4.3. """ -Base.@kwdef struct GPSL5IMidiAlmanac - PRN_a::Int - WN_a::Int - t_oa::Int - e::Float64 - δi::Float64 - Ω_dot::Float64 - sqrt_A::Float64 - Ω_0::Float64 - ω::Float64 - M_0::Float64 - a_f0::Float64 - a_f1::Float64 - l1_health::Bool - l2_health::Bool - l5_health::Bool -end - -""" - GPSL5IClockDifferentialCorrection - -One satellite's clock differential-correction (CDC) packet from CNAV message -types 13 or 34, keyed by `PRN_a` (IS-GPS-705J Figure 20-17, Table 20-X). - -`dc_data_type` selects the data the corrections apply to: `false` ⇒ CNAV -(message types 30-37), `true` ⇒ legacy NAV. A `PRN ID` of all-ones marks an -empty packet (not stored). - -# Fields - - - `PRN_a::Int`: PRN the corrections apply to. - - `t_op_D::Int`: DC data predict time of week (seconds). - - `t_OD::Int`: Time of DC data (seconds). - - `dc_data_type::Bool`: false ⇒ CNAV, true ⇒ legacy NAV. - - `δa_f0::Float64`, `δa_f1::Float64`: Clock bias / drift corrections (s, s/s). - - `UDRA_index::Int`: UDRA index (signed). - -# Reference - -IS-GPS-705J, Figures 20-7 / 20-12 / 20-17, Table 20-X. -""" -Base.@kwdef struct GPSL5IClockDifferentialCorrection - PRN_a::Int - t_op_D::Int - t_OD::Int - dc_data_type::Bool - δa_f0::Float64 - δa_f1::Float64 - UDRA_index::Int -end - -""" - GPSL5IEphemerisDifferentialCorrection - -One satellite's ephemeris differential-correction (EDC) packet from CNAV -message types 14 or 34, keyed by `PRN_a` (IS-GPS-705J Figure 20-17, -Table 20-X). Semi-circle fields are converted to radians on decode. - -# Fields - - - `PRN_a::Int`: PRN the corrections apply to. - - `t_op_D::Int`: DC data predict time of week (seconds). - - `t_OD::Int`: Time of DC data (seconds). - - `dc_data_type::Bool`: false ⇒ CNAV, true ⇒ legacy NAV. - - `Δα::Float64`, `Δβ::Float64`: Ephemeris α/β corrections (dimensionless). - - `Δγ::Float64`: Ephemeris γ correction (rad). - - `Δi::Float64`, `ΔΩ::Float64`: Inclination / right-ascension corrections (rad). - - `ΔA::Float64`: Semi-major-axis correction (meters). - - `UDRA_dot_index::Int`: Rate-of-UDRA index (signed). - -# Reference - -IS-GPS-705J, Figures 20-7 / 20-13 / 20-17, Table 20-X. -""" -Base.@kwdef struct GPSL5IEphemerisDifferentialCorrection - PRN_a::Int - t_op_D::Int - t_OD::Int - dc_data_type::Bool - Δα::Float64 - Δβ::Float64 - Δγ::Float64 - Δi::Float64 - ΔΩ::Float64 - ΔA::Float64 - UDRA_dot_index::Int -end - -""" - GPSL5IIntegritySupportMessage - -Integrity Support Message from CNAV message type 40 (ARAIM), complete in a -single message (IS-GPS-705J Figure 20-14a, Table 20-XIa). - -# Fields - - - `GNSS_ID::Int`: GNSS identifier the ISM applies to. - - `WN_ISM::Int`, `TOW_ISM::Int`: ISM reference week / time-of-week counts. - - `t_correl::Int`, `b_nom::Int`, `γ_nom::Int`, `R_sat::Int`, `P_const::Int`, - `MFD::Int`, `service_level::Int`: encoded ARAIM parameter indices. - - `mask::UInt64`: 63-bit SV mask (MSB = PRN 1). - -# Reference - -IS-GPS-705J, Figure 20-14a, Table 20-XIa. -""" -Base.@kwdef struct GPSL5IIntegritySupportMessage - GNSS_ID::Int - WN_ISM::Int - TOW_ISM::Int - t_correl::Int - b_nom::Int - γ_nom::Int - R_sat::Int - P_const::Int - MFD::Int - service_level::Int - mask::UInt64 -end - -""" - GPSL5IData - -Decoded GPS L5I CNAV navigation message data. - -Holds the parameters decoded from CNAV message types 10, 11, 12, 13, 14, 15, -30-37, and 40 (IS-GPS-705J §20.3.3). The decoder fills fields incrementally -as the corresponding message types are received. Field-naming follows -[`GPSL1C_DData`](@ref) (CNAV-2 broadcasts nearly the same parameter set): -semi-circle quantities are converted to radians on decode (multiplied by π), -all `Union{Nothing,…}` until first decoded. - -# Header (every message) - - - `last_message_id::Int`: Most recently decoded message type (0 until then). - - `TOW::Int64`: SV time in seconds at the start of the *next* 6-second - message (message TOW count × 6). - - `alert_flag::Bool`: Raised when the signal URA may be worse than indicated. - -# Health / accuracy (message types 10, 30-37) - - - `l1_health::Bool`, `l2_health::Bool`, `l5_health::Bool`: per-band signal - health (false = OK). - - `ura_ed_index::Int64`: Ephemeris URA index (signed). - - `ura_ned0_index::Int64`, `ura_ned1_index::Int64`, `ura_ned2_index::Int64`: - Clock URA indices. - -# Ephemeris (message types 10 + 11, Table 20-I) - - - `WN::Int64`: Transmission week number, modulo-8192. - - `t_op::Int64`: Data predict time of week (seconds). - - `t_0e::Int64`: Ephemeris data reference time of week (seconds). - - `ΔA::Float64`: Semi-major axis difference at reference time (meters). - - `A_dot::Float64`: Change rate in semi-major axis (m/s). - - `Δn_0::Float64`: Mean motion difference from computed value (rad/s). - - `Δn_0_dot::Float64`: Rate of mean motion difference (rad/s²). - - `M_0::Float64`: Mean anomaly at reference time (rad). - - `e::Float64`: Eccentricity (dimensionless). - - `ω::Float64`: Argument of perigee (rad). - - `Ω_0::Float64`: Reference right ascension angle (rad). - - `i_0::Float64`: Inclination angle at reference time (rad). - - `ΔΩ_dot::Float64`: Rate of right ascension difference (rad/s). - - `i_dot::Float64`: Rate of inclination angle (rad/s). - - `C_is::Float64`, `C_ic::Float64`: Sine/cosine inclination harmonic corrections (rad). - - `C_rs::Float64`, `C_rc::Float64`: Sine/cosine orbit-radius harmonic corrections (m). - - `C_us::Float64`, `C_uc::Float64`: Sine/cosine argument-of-latitude harmonic corrections (rad). - - `integrity_status_flag::Bool`, `l2c_phasing::Bool`: message type 10 flags. - -# Clock (message types 30-37, Table 20-III) - - - `t_0c::Int64`: Clock data reference time of week (seconds). - - `a_f0::Float64`, `a_f1::Float64`, `a_f2::Float64`: Clock bias / drift / drift-rate. - -# Group delay / ISC + ionosphere (message type 30, Tables 20-III / 20-IV) - - - `T_GD::Float64`: L1/L2 P(Y) inter-signal correction (seconds). - - `ISC_L1CA,ISC_L2C,ISC_L5I5,ISC_L5Q5::Float64`: inter-signal corrections (s). - - `α_0,α_1,α_2,α_3,β_0,β_1,β_2,β_3::Float64`: Klobuchar ionospheric coefficients. - - `WN_op::Int64`: Data predict week number (mod 256). - -# EOP (message type 32, Table 20-VII) - - - `t_EOP::Int64`: EOP reference time of week (s). - - `PM_X,PM_X_dot,PM_Y,PM_Y_dot::Float64`: polar-motion values/rates (arcsec, arcsec/day). - - `ΔUT_GPS,ΔUT_GPS_dot::Float64`: UT1-GPS difference (s) and rate (s/day). - -# UTC (message type 33, Table 20-IX) - - - `A0_UTC,A1_UTC,A2_UTC::Float64`: UTC polynomial (s, s/s, s/s²). - - `Δt_LS,Δt_LSF::Int64`: current/past and future leap-second counts (s). - - `t_ot::Int64`: UTC reference time of week (s). - - `WN_ot,WN_LSF::Int64`: UTC and leap-second reference week numbers. - - `DN::Int64`: leap-second reference day number (1-7). - -# GGTO (message type 35, Table 20-XI) - - - `A0_GGTO,A1_GGTO,A2_GGTO::Float64`: GPS/GNSS time-offset polynomial. - - `t_GGTO::Int64`, `WN_GGTO::Int64`: GGTO reference time/week. - - `GNSS_ID::Int64`: 0 none, 1 Galileo, 2 GLONASS, 3 BeiDou, 4-7 reserved. - -# Almanacs / corrections / text — keyed dictionaries (`nothing` until first decoded) - - - `reduced_almanacs::Dictionary{Int,GPSL5IReducedAlmanac}` (message types 12, 31). - - `midi_almanacs::Dictionary{Int,GPSL5IMidiAlmanac}` (message type 37). - - `clock_corrections::Dictionary{Int,GPSL5IClockDifferentialCorrection}` - (message types 13, 34). - - `ephemeris_corrections::Dictionary{Int,GPSL5IEphemerisDifferentialCorrection}` - (message types 14, 34). - - `text_mt15::String`, `text_page_mt15::Int64`: message type 15 text page - (29 ASCII characters, control chars stripped). - - `text_mt36::String`, `text_page_mt36::Int64`: message type 36 text page - (18 ASCII characters). - - `ism::GPSL5IIntegritySupportMessage`: message type 40 Integrity Support Message. - -# Reference - -IS-GPS-705J, Figures 20-1 through 20-17 and Tables 20-I through 20-XIa. -""" -Base.@kwdef struct GPSL5IData <: AbstractGNSSData - last_message_id::Int = 0 - TOW::Union{Nothing,Int64} = nothing - alert_flag::Union{Nothing,Bool} = nothing - - l1_health::Union{Nothing,Bool} = nothing - l2_health::Union{Nothing,Bool} = nothing - l5_health::Union{Nothing,Bool} = nothing - ura_ed_index::Union{Nothing,Int64} = nothing - ura_ned0_index::Union{Nothing,Int64} = nothing - ura_ned1_index::Union{Nothing,Int64} = nothing - ura_ned2_index::Union{Nothing,Int64} = nothing - - WN::Union{Nothing,Int64} = nothing - t_op::Union{Nothing,Int64} = nothing - t_0e::Union{Nothing,Int64} = nothing - ΔA::Union{Nothing,Float64} = nothing - A_dot::Union{Nothing,Float64} = nothing - Δn_0::Union{Nothing,Float64} = nothing - Δn_0_dot::Union{Nothing,Float64} = nothing - M_0::Union{Nothing,Float64} = nothing - e::Union{Nothing,Float64} = nothing - ω::Union{Nothing,Float64} = nothing - Ω_0::Union{Nothing,Float64} = nothing - i_0::Union{Nothing,Float64} = nothing - ΔΩ_dot::Union{Nothing,Float64} = nothing - i_dot::Union{Nothing,Float64} = nothing - C_is::Union{Nothing,Float64} = nothing - C_ic::Union{Nothing,Float64} = nothing - C_rs::Union{Nothing,Float64} = nothing - C_rc::Union{Nothing,Float64} = nothing - C_us::Union{Nothing,Float64} = nothing - C_uc::Union{Nothing,Float64} = nothing - integrity_status_flag::Union{Nothing,Bool} = nothing - l2c_phasing::Union{Nothing,Bool} = nothing - - t_0c::Union{Nothing,Int64} = nothing - a_f0::Union{Nothing,Float64} = nothing - a_f1::Union{Nothing,Float64} = nothing - a_f2::Union{Nothing,Float64} = nothing - - T_GD::Union{Nothing,Float64} = nothing - ISC_L1CA::Union{Nothing,Float64} = nothing - ISC_L2C::Union{Nothing,Float64} = nothing - ISC_L5I5::Union{Nothing,Float64} = nothing - ISC_L5Q5::Union{Nothing,Float64} = nothing - α_0::Union{Nothing,Float64} = nothing - α_1::Union{Nothing,Float64} = nothing - α_2::Union{Nothing,Float64} = nothing - α_3::Union{Nothing,Float64} = nothing - β_0::Union{Nothing,Float64} = nothing - β_1::Union{Nothing,Float64} = nothing - β_2::Union{Nothing,Float64} = nothing - β_3::Union{Nothing,Float64} = nothing - WN_op::Union{Nothing,Int64} = nothing - - t_EOP::Union{Nothing,Int64} = nothing - PM_X::Union{Nothing,Float64} = nothing - PM_X_dot::Union{Nothing,Float64} = nothing - PM_Y::Union{Nothing,Float64} = nothing - PM_Y_dot::Union{Nothing,Float64} = nothing - ΔUT_GPS::Union{Nothing,Float64} = nothing - ΔUT_GPS_dot::Union{Nothing,Float64} = nothing - - A0_UTC::Union{Nothing,Float64} = nothing - A1_UTC::Union{Nothing,Float64} = nothing - A2_UTC::Union{Nothing,Float64} = nothing - Δt_LS::Union{Nothing,Int64} = nothing - t_ot::Union{Nothing,Int64} = nothing - WN_ot::Union{Nothing,Int64} = nothing - WN_LSF::Union{Nothing,Int64} = nothing - DN::Union{Nothing,Int64} = nothing - Δt_LSF::Union{Nothing,Int64} = nothing - - A0_GGTO::Union{Nothing,Float64} = nothing - A1_GGTO::Union{Nothing,Float64} = nothing - A2_GGTO::Union{Nothing,Float64} = nothing - t_GGTO::Union{Nothing,Int64} = nothing - WN_GGTO::Union{Nothing,Int64} = nothing - GNSS_ID::Union{Nothing,Int64} = nothing - - reduced_almanacs::Union{Nothing,Dictionary{Int,GPSL5IReducedAlmanac}} = nothing - midi_almanacs::Union{Nothing,Dictionary{Int,GPSL5IMidiAlmanac}} = nothing - clock_corrections::Union{Nothing,Dictionary{Int,GPSL5IClockDifferentialCorrection}} = - nothing - ephemeris_corrections::Union{ - Nothing, - Dictionary{Int,GPSL5IEphemerisDifferentialCorrection}, - } = nothing - - text_mt15::Union{Nothing,String} = nothing - text_page_mt15::Union{Nothing,Int64} = nothing - text_mt36::Union{Nothing,String} = nothing - text_page_mt36::Union{Nothing,Int64} = nothing - - ism::Union{Nothing,GPSL5IIntegritySupportMessage} = nothing -end - -function GPSL5IData( - data::GPSL5IData; - last_message_id = data.last_message_id, - TOW = data.TOW, - alert_flag = data.alert_flag, - l1_health = data.l1_health, - l2_health = data.l2_health, - l5_health = data.l5_health, - ura_ed_index = data.ura_ed_index, - ura_ned0_index = data.ura_ned0_index, - ura_ned1_index = data.ura_ned1_index, - ura_ned2_index = data.ura_ned2_index, - WN = data.WN, - t_op = data.t_op, - t_0e = data.t_0e, - ΔA = data.ΔA, - A_dot = data.A_dot, - Δn_0 = data.Δn_0, - Δn_0_dot = data.Δn_0_dot, - M_0 = data.M_0, - e = data.e, - ω = data.ω, - Ω_0 = data.Ω_0, - i_0 = data.i_0, - ΔΩ_dot = data.ΔΩ_dot, - i_dot = data.i_dot, - C_is = data.C_is, - C_ic = data.C_ic, - C_rs = data.C_rs, - C_rc = data.C_rc, - C_us = data.C_us, - C_uc = data.C_uc, - integrity_status_flag = data.integrity_status_flag, - l2c_phasing = data.l2c_phasing, - t_0c = data.t_0c, - a_f0 = data.a_f0, - a_f1 = data.a_f1, - a_f2 = data.a_f2, - T_GD = data.T_GD, - ISC_L1CA = data.ISC_L1CA, - ISC_L2C = data.ISC_L2C, - ISC_L5I5 = data.ISC_L5I5, - ISC_L5Q5 = data.ISC_L5Q5, - α_0 = data.α_0, - α_1 = data.α_1, - α_2 = data.α_2, - α_3 = data.α_3, - β_0 = data.β_0, - β_1 = data.β_1, - β_2 = data.β_2, - β_3 = data.β_3, - WN_op = data.WN_op, - t_EOP = data.t_EOP, - PM_X = data.PM_X, - PM_X_dot = data.PM_X_dot, - PM_Y = data.PM_Y, - PM_Y_dot = data.PM_Y_dot, - ΔUT_GPS = data.ΔUT_GPS, - ΔUT_GPS_dot = data.ΔUT_GPS_dot, - A0_UTC = data.A0_UTC, - A1_UTC = data.A1_UTC, - A2_UTC = data.A2_UTC, - Δt_LS = data.Δt_LS, - t_ot = data.t_ot, - WN_ot = data.WN_ot, - WN_LSF = data.WN_LSF, - DN = data.DN, - Δt_LSF = data.Δt_LSF, - A0_GGTO = data.A0_GGTO, - A1_GGTO = data.A1_GGTO, - A2_GGTO = data.A2_GGTO, - t_GGTO = data.t_GGTO, - WN_GGTO = data.WN_GGTO, - GNSS_ID = data.GNSS_ID, - reduced_almanacs = data.reduced_almanacs, - midi_almanacs = data.midi_almanacs, - clock_corrections = data.clock_corrections, - ephemeris_corrections = data.ephemeris_corrections, - text_mt15 = data.text_mt15, - text_page_mt15 = data.text_page_mt15, - text_mt36 = data.text_mt36, - text_page_mt36 = data.text_page_mt36, - ism = data.ism, -) - GPSL5IData( - last_message_id, - TOW, - alert_flag, - l1_health, - l2_health, - l5_health, - ura_ed_index, - ura_ned0_index, - ura_ned1_index, - ura_ned2_index, - WN, - t_op, - t_0e, - ΔA, - A_dot, - Δn_0, - Δn_0_dot, - M_0, - e, - ω, - Ω_0, - i_0, - ΔΩ_dot, - i_dot, - C_is, - C_ic, - C_rs, - C_rc, - C_us, - C_uc, - integrity_status_flag, - l2c_phasing, - t_0c, - a_f0, - a_f1, - a_f2, - T_GD, - ISC_L1CA, - ISC_L2C, - ISC_L5I5, - ISC_L5Q5, - α_0, - α_1, - α_2, - α_3, - β_0, - β_1, - β_2, - β_3, - WN_op, - t_EOP, - PM_X, - PM_X_dot, - PM_Y, - PM_Y_dot, - ΔUT_GPS, - ΔUT_GPS_dot, - A0_UTC, - A1_UTC, - A2_UTC, - Δt_LS, - t_ot, - WN_ot, - WN_LSF, - DN, - Δt_LSF, - A0_GGTO, - A1_GGTO, - A2_GGTO, - t_GGTO, - WN_GGTO, - GNSS_ID, - reduced_almanacs, - midi_almanacs, - clock_corrections, - ephemeris_corrections, - text_mt15, - text_page_mt15, - text_mt36, - text_page_mt36, - ism, - ) -end - -# The default struct `==` falls back to `===` (reference equality), which fails -# for the mutable `Dictionary` fields even when their contents match. Compare -# field-by-field (mirrors `GPSL1C_DData`). -function Base.:(==)(a::GPSL5IData, b::GPSL5IData) - for f in fieldnames(GPSL5IData) - getfield(a, f) == getfield(b, f) || return false - end - return true -end - -""" -$(TYPEDEF) - -Per-decoder cache for the GPS L5I signal. - -Holds the soft-symbol `CircularDeque{Float32}` (capacity = 616 = 600 message -symbols + 16 next-message preamble symbols) plus reusable scratch for the -window-decoding Viterbi that undoes the K=7 NSC FEC in `try_sync`. Because -`try_sync` runs a full 616-symbol decode on *every* incoming symbol until it -locks, the window copy and the Viterbi metric/decision/output buffers are -preallocated here and reused rather than reallocated (~20 KB) per symbol. - -# Fields - -$(TYPEDFIELDS) -""" -struct GPSL5ICache <: AbstractGNSSCache - """ - Soft-symbol buffer (616 = 600 message + 16 next-message preamble) - """ - soft_buffer::CircularDeque{Float32} - """ - 616-symbol window copied out of `soft_buffer` for each sync attempt - """ - viterbi_window::Vector{Float32} - """ - Viterbi forward-pass path metrics, one per trellis state - """ - viterbi_metrics::Vector{Float32} - """ - Scratch for the next step's path metrics during the forward pass - """ - viterbi_next_metrics::Vector{Float32} - """ - Survivor decisions, `[state, step]`, for the traceback - """ - viterbi_decisions::Matrix{Bool} - """ - Decoded 308-bit window the traceback writes into - """ - viterbi_bits::Vector{Bool} -end - -GPSL5ICache() = GPSL5ICache( - CircularDeque{Float32}(L5I_WINDOW_SYMBOLS), - Vector{Float32}(undef, L5I_WINDOW_SYMBOLS), - Vector{Float32}(undef, L5I_VITERBI_NUM_STATES), - Vector{Float32}(undef, L5I_VITERBI_NUM_STATES), - Matrix{Bool}(undef, L5I_VITERBI_NUM_STATES, L5I_WINDOW_BITS), - Vector{Bool}(undef, L5I_WINDOW_BITS), -) - -function Base.:(==)(a::GPSL5ICache, b::GPSL5ICache) - deques_equal(a.soft_buffer, b.soft_buffer) -end - -function is_ephemeris_decoded(data::GPSL5IData) - # Message type 10 - !isnothing(data.WN) && - !isnothing(data.ΔA) && - !isnothing(data.A_dot) && - !isnothing(data.Δn_0) && - !isnothing(data.M_0) && - !isnothing(data.e) && - !isnothing(data.ω) && - # Message type 11 - !isnothing(data.t_0e) && - !isnothing(data.Ω_0) && - !isnothing(data.i_0) && - !isnothing(data.ΔΩ_dot) && - !isnothing(data.i_dot) && - !isnothing(data.C_is) && - !isnothing(data.C_ic) && - !isnothing(data.C_rs) && - !isnothing(data.C_rc) && - !isnothing(data.C_us) && - !isnothing(data.C_uc) -end - -function is_clock_correction_decoded(data::GPSL5IData) - # Message types 30-37 shared clock block + message type 30 group delay - !isnothing(data.t_0c) && - !isnothing(data.a_f0) && - !isnothing(data.a_f1) && - !isnothing(data.a_f2) && - !isnothing(data.T_GD) -end - -function is_decoding_completed_for_positioning(data::GPSL5IData) - !isnothing(data.TOW) && is_ephemeris_decoded(data) && is_clock_correction_decoded(data) -end +const GPSL5IConstants = GPSCNAVConstants{:GPSL5I} """ $(TYPEDSIGNATURES) @@ -771,7 +36,7 @@ parsers (message types 10-15, 30-37, and 40, IS-GPS-705J §20.3.3). # Returns - - `GNSSDecoderState{GPSL5IData}`: Initialized decoder state for GPS L5I + - `GNSSDecoderState{GPSCNAVData}`: Initialized decoder state for GPS L5I # Example @@ -786,6 +51,7 @@ end # See Also - [`GNSSDecoderState`](@ref): The underlying state structure + - [`GPSL2CMDecoderState`](@ref): The GPS L2C decoder sharing this CNAV core - [`decode`](@ref): Decode soft symbols using this state - [`reset_decoder_state`](@ref): Reset after signal loss - [`is_sat_healthy`](@ref): Check satellite health status @@ -793,10 +59,10 @@ end function GPSL5IDecoderState(prn) GNSSDecoderState( prn, - GPSL5IData(), - GPSL5IData(), + GPSCNAVData(), + GPSCNAVData(), GPSL5IConstants(), - GPSL5ICache(), + GPSCNAVCache(), nothing, false, ) @@ -809,756 +75,6 @@ end """ $(TYPEDSIGNATURES) -Reset the GPS L5I decoder state after a signal loss or reacquisition. - -Clears the soft-symbol buffer and the time-of-week (TOW) field while -preserving the remaining decoded ephemeris and clock data in `raw_data`, so a -`GNSSReceiver` can re-use the satellite after reacquisition without -re-decoding all message types. Mirrors the semantics of the GPS L1 C/A and -Galileo E1B implementations. - -# Arguments - - - `state::GNSSDecoderState{<:GPSL5IData}`: Current GPS L5I decoder state - -# Returns - - - `GNSSDecoderState{<:GPSL5IData}`: Reset decoder state with cleared buffers - -# See Also - - - [`GPSL5IDecoderState`](@ref): Create a fresh decoder state - - [`decode`](@ref): Continue decoding after reset -""" -function reset_decoder_state(state::GNSSDecoderState{<:GPSL5IData}) - empty!(state.cache.soft_buffer) - GNSSDecoderState( - state; - raw_data = GPSL5IData(state.raw_data; TOW = nothing), - data = GPSL5IData(), - num_bits_after_valid_syncro_sequence = nothing, - is_shifted_by_180_degrees = false, - ) -end - -# ---- Viterbi ---------------------------------------------------------------- -# -# The CNAV FEC is the rate-1/2, K=7 NSC code with G1 = 0o171, G2 = 0o133 -# (IS-GPS-705J §3.3.3.1.1, Figure 3-7) — the same code Galileo E1B uses, but -# convolved continuously across message boundaries with no tail bits. The -# window decoder below therefore starts from an unknown trellis state (all -# path metrics equal), runs a full add-compare-select forward pass over the -# 616-symbol window, and chains back from the best final state. Noise-free -# the maximum-likelihood path is exact at both window edges; under noise the -# preamble + CRC gate rejects any residual edge errors. - -const L5I_VITERBI_NUM_STATES = 64 # 2^(K-1) for K=7 - -# Encoder output (G1, G2) for every (state, input_bit) pair. State numbering: -# bits [s1 s2 s3 s4 s5 s6] where s1 (MSB) is the most recent past input (one -# period ago) and s6 (LSB) is the oldest (six periods ago); the new input bit u -# shifts in as the next s1. Reading the generator octals MSB-first as the tap on -# the current input down to the six-periods-ago stage: -# y1 = u ⊕ s1 ⊕ s2 ⊕ s3 ⊕ s6 (G1 = 0o171 = 0b1111001) -# y2 = u ⊕ s2 ⊕ s3 ⊕ s5 ⊕ s6 (G2 = 0o133 = 0b1011011) -# Reference: IS-GPS-705J Figure 3-7. -function _l5i_encoder_output(state::UInt8, u::UInt8) - s1 = (state >> 5) & 0x01 - s2 = (state >> 4) & 0x01 - s3 = (state >> 3) & 0x01 - s5 = (state >> 1) & 0x01 - s6 = state & 0x01 - y1 = u ⊻ s1 ⊻ s2 ⊻ s3 ⊻ s6 - y2 = u ⊻ s2 ⊻ s3 ⊻ s5 ⊻ s6 - return (y1, y2) -end - -const _L5I_VITERBI_G1 = [ - Bool(_l5i_encoder_output(UInt8(s), UInt8(u))[1]) for - s = 0:(L5I_VITERBI_NUM_STATES-1), u = 0:1 -] -const _L5I_VITERBI_G2 = [ - Bool(_l5i_encoder_output(UInt8(s), UInt8(u))[2]) for - s = 0:(L5I_VITERBI_NUM_STATES-1), u = 0:1 -] - -# Soft branch penalty for one expected encoder output bit `y` given the -# received LLR (positive ⇒ bit 0): charge `abs(llr)` iff the hard slice -# disagrees with `y`. Summed over a path this equals the maximum-likelihood -# correlation metric up to a path-independent constant, so the decoder is -# exact ML on soft inputs and degrades gracefully to hard-decision Hamming -# distance for ±1 inputs. -@inline _l5i_branch_penalty(llr::Float32, y::Bool) = hard_slice(llr) != y ? abs(llr) : 0.0f0 - -""" - gps_l5i_viterbi(soft_window) -> Vector{Bool} - -Viterbi-decode a window of FEC soft symbols (LLR convention, length must be -even) into `length(soft_window) ÷ 2` bits. Starts from an unknown encoder -state (the CNAV FEC runs continuously across message boundaries, so the -window's initial state is the tail of the previous message) and traces back -from the best final state. - -The metric/decision/output buffers default to fresh allocations but may be -supplied (sized for the window) so a hot caller like `try_sync` can reuse a -single preallocated set across calls; the decoded bits are written into -`bits`, which is also returned. -""" -function gps_l5i_viterbi( - soft_window::AbstractVector{Float32}; - metrics::Vector{Float32} = Vector{Float32}(undef, L5I_VITERBI_NUM_STATES), - next_metrics::Vector{Float32} = Vector{Float32}(undef, L5I_VITERBI_NUM_STATES), - decisions::Matrix{Bool} = Matrix{Bool}( - undef, - L5I_VITERBI_NUM_STATES, - length(soft_window) ÷ 2, - ), - bits::Vector{Bool} = Vector{Bool}(undef, length(soft_window) ÷ 2), -) - num_steps = length(soft_window) ÷ 2 - # Unknown initial encoder state ⇒ every path metric starts equal. - fill!(metrics, 0.0f0) - # decisions[s+1, t]: whether the survivor into state s at step t came from - # the odd predecessor (predecessor LSB = 1). - @inbounds for t = 1:num_steps - llr1 = soft_window[2t-1] - llr2 = soft_window[2t] - for sp = 0:(L5I_VITERBI_NUM_STATES-1) - # Predecessors of `sp` share their top 5 bits with sp's bottom 5; - # both are reached via the same input bit u (top bit of sp). - ps0 = UInt8((sp & 0x1f) << 1) - ps1 = ps0 | 0x01 - u = ((sp >> 5) & 0x01) + 1 - m0 = - metrics[ps0+1] + - _l5i_branch_penalty(llr1, _L5I_VITERBI_G1[ps0+1, u]) + - _l5i_branch_penalty(llr2, _L5I_VITERBI_G2[ps0+1, u]) - m1 = - metrics[ps1+1] + - _l5i_branch_penalty(llr1, _L5I_VITERBI_G1[ps1+1, u]) + - _l5i_branch_penalty(llr2, _L5I_VITERBI_G2[ps1+1, u]) - from_odd = m1 < m0 - decisions[sp+1, t] = from_odd - next_metrics[sp+1] = from_odd ? m1 : m0 - end - # Renormalize so the accumulated Float32 penalties stay small and - # precision is preserved regardless of the caller's LLR magnitudes. - min_metric = minimum(next_metrics) - for i = 1:L5I_VITERBI_NUM_STATES - metrics[i] = next_metrics[i] - min_metric - end - end - # Chain back from the best final state; the decoded bit at step t is the - # input bit that produced that step's state (its top bit). - best_state = argmin(metrics) - 1 - @inbounds for t = num_steps:-1:1 - bits[t] = (best_state >> 5) & 0x01 == 0x01 - best_state = ((best_state & 0x1f) << 1) | (decisions[best_state+1, t] ? 1 : 0) - end - return bits -end - -# ---- Sync ------------------------------------------------------------------ -# -# Override the generic packed-buffer sync: the CNAV preamble only exists in -# the *decoded* bit domain, so each sync attempt Viterbi-decodes the whole -# 616-symbol window into 308 bits and mirrors `find_preamble` on the result — -# the preamble must be visible at both the start of this message (bits 1-8) -# and the start of the next (bits 301-308), either both upright or both -# inverted (the NSC generators have odd weight, so a 180°-inverted symbol -# stream decodes to the complemented bit stream). The 300-bit message must -# then pass CRC-24Q; preamble matches with a failing CRC are treated as no -# sync, which lets the window slide on by one symbol. - -""" -Sync result for GPS L5I: the polarity-resolved 300-bit message and the detected polarity. -""" -struct GPSL5ISync - """ - Polarity-resolved message, packed MSB-first (bit 1 of the message at the MSB) - """ - word::UInt320 - """ - Whether the symbol stream is 180-degrees phase shifted - """ - polarity_flipped::Bool -end - -""" -Pack `bits[start:start+7]` MSB-first into a `UInt8`. -""" -function _pack_preamble(bits::Vector{Bool}, start::Int) - candidate = 0x00 - @inbounds for i = 0:(L5I_PREAMBLE_BITS-1) - candidate = UInt8(candidate << 1) | UInt8(bits[start+i]) - end - return candidate -end - -""" - try_sync(state::GNSSDecoderState{<:GPSL5IData}) -> Union{Nothing,GPSL5ISync} - -Per-signal sync hook for GPS L5I. Viterbi-decodes the buffered 616-symbol -window, requires the CNAV preamble at both ends of the decoded 308-bit window -(in either polarity) and a clean CRC-24Q over the polarity-resolved 300-bit -message. Returns the [`GPSL5ISync`](@ref) on a match (carrying the message -bits and the detected polarity flip) or `nothing`. -""" -function try_sync(state::GNSSDecoderState{<:GPSL5IData}) - cache = state.cache - deque = cache.soft_buffer - window = cache.viterbi_window - @inbounds for i = 1:L5I_WINDOW_SYMBOLS - window[i] = deque[i] - end - bits = gps_l5i_viterbi( - window; - metrics = cache.viterbi_metrics, - next_metrics = cache.viterbi_next_metrics, - decisions = cache.viterbi_decisions, - bits = cache.viterbi_bits, - ) - leading = _pack_preamble(bits, 1) - trailing = _pack_preamble(bits, L5I_MESSAGE_BITS + 1) - if leading == L5I_PREAMBLE && trailing == L5I_PREAMBLE - polarity_flipped = false - elseif leading == ~L5I_PREAMBLE && trailing == ~L5I_PREAMBLE - polarity_flipped = true - # Inverted symbols decode to complemented bits; un-complement the - # message portion before the CRC check and field extraction. - @inbounds for i = 1:L5I_MESSAGE_BITS - bits[i] = !bits[i] - end - else - return nothing - end - crc24q(view(bits, 1:L5I_MESSAGE_BITS)) == 0 || return nothing - word = UInt320(0) - @inbounds for i = 1:L5I_MESSAGE_BITS - word = (word << 1) | UInt320(bits[i]) - end - return GPSL5ISync(word, polarity_flipped) -end - -""" - complement_buffer_if_necessary(state::GNSSDecoderState{<:GPSL5IData}, sync) - -Resolve the 180° polarity ambiguity for GPS L5I. `sync::GPSL5ISync` already -carries the polarity-resolved message bits from `try_sync`; record the -polarity on the state and pass the sync object through unchanged for -`decode_syncro_sequence`. -""" -function complement_buffer_if_necessary( - state::GNSSDecoderState{<:GPSL5IData}, - sync::GPSL5ISync, -) - GNSSDecoderState(state; is_shifted_by_180_degrees = sync.polarity_flipped), sync -end - -# ---- Message pipeline ------------------------------------------------------- - -""" - decode_syncro_sequence(state::GNSSDecoderState{<:GPSL5IData}, sync::GPSL5ISync) - -Process one CRC-validated 300-bit CNAV message: parse the common header -(message type ID at bits 15-20, message TOW count at 21-37, alert flag at -38 — IS-GPS-705J §20.3.3), then dispatch to the per-message-type parser. -Unknown or reserved message types keep the decoded header but no further -fields. -""" -function decode_syncro_sequence(state::GNSSDecoderState{<:GPSL5IData}, sync::GPSL5ISync) - word = sync.word - word_length = L5I_MESSAGE_BITS - PI = state.constants.PI - - message_id = Int(get_bits(word, word_length, 15, 6)) - # The message TOW count is the SV time at the start of the *next* - # 6-second message, in units of 6 seconds. - TOW = Int64(get_bits(word, word_length, 21, 17)) * 6 - alert_flag = get_bit(word, word_length, 38) - raw = GPSL5IData(state.raw_data; last_message_id = message_id, TOW, alert_flag) - - raw = if message_id == 10 - parse_mt10(raw, word, PI) - elseif message_id == 11 - parse_mt11(raw, word, PI) - elseif message_id == 12 - parse_mt12(raw, word, PI) - elseif message_id == 13 - parse_mt13(raw, word) - elseif message_id == 14 - parse_mt14(raw, word, PI) - elseif message_id == 15 - parse_mt15(raw, word) - elseif message_id == 30 - parse_mt30(raw, word) - elseif message_id == 31 - parse_mt31(raw, word, PI) - elseif message_id == 32 - parse_mt32(raw, word) - elseif message_id == 33 - parse_mt33(raw, word) - elseif message_id == 34 - parse_mt34(raw, word, PI) - elseif message_id == 35 - parse_mt35(raw, word) - elseif message_id == 36 - parse_mt36(raw, word) - elseif message_id == 37 - parse_mt37(raw, word, PI) - elseif message_id == 40 - parse_mt40(raw, word) - else - raw # unknown/reserved message type: header only - end - GNSSDecoderState(state; raw_data = raw) -end - -# ---- Per-message-type bit-field extraction (IS-GPS-705J §20.3.3) ------------ -# -# `word` is the 300-bit message packed MSB-first into a `UInt320` (bit 1 = the -# leftmost preamble bit). Fields are read by 1-based start bit and length -# through the shared `get_bits` / `get_twos_complement_num` / `get_bit` -# helpers, matching the other decoders (#48). - -""" -Message type 10 — ephemeris 1 + health (IS-GPS-705J Fig 20-1, Table 20-I). -""" -function parse_mt10(raw::GPSL5IData, word::UInt320, PI::Float64) - word_length = L5I_MESSAGE_BITS - GPSL5IData( - raw; - WN = Int64(get_bits(word, word_length, 39, 13)), - l1_health = get_bit(word, word_length, 52), - l2_health = get_bit(word, word_length, 53), - l5_health = get_bit(word, word_length, 54), - t_op = Int64(get_bits(word, word_length, 55, 11)) * 300, - ura_ed_index = get_twos_complement_num(word, word_length, 66, 5), - t_0e = Int64(get_bits(word, word_length, 71, 11)) * 300, - ΔA = get_twos_complement_num(word, word_length, 82, 26) * 2.0^-9, - A_dot = get_twos_complement_num(word, word_length, 108, 25) * 2.0^-21, - Δn_0 = get_twos_complement_num(word, word_length, 133, 17) * 2.0^-44 * PI, - Δn_0_dot = get_twos_complement_num(word, word_length, 150, 23) * 2.0^-57 * PI, - M_0 = get_twos_complement_num(word, word_length, 173, 33) * 2.0^-32 * PI, - e = Int(get_bits(word, word_length, 206, 33)) * 2.0^-34, - ω = get_twos_complement_num(word, word_length, 239, 33) * 2.0^-32 * PI, - integrity_status_flag = get_bit(word, word_length, 272), - l2c_phasing = get_bit(word, word_length, 273), - ) -end - -""" -Message type 11 — ephemeris 2 (IS-GPS-705J Fig 20-2, Table 20-I). -""" -function parse_mt11(raw::GPSL5IData, word::UInt320, PI::Float64) - word_length = L5I_MESSAGE_BITS - GPSL5IData( - raw; - t_0e = Int64(get_bits(word, word_length, 39, 11)) * 300, - Ω_0 = get_twos_complement_num(word, word_length, 50, 33) * 2.0^-32 * PI, - i_0 = get_twos_complement_num(word, word_length, 83, 33) * 2.0^-32 * PI, - ΔΩ_dot = get_twos_complement_num(word, word_length, 116, 17) * 2.0^-44 * PI, - i_dot = get_twos_complement_num(word, word_length, 133, 15) * 2.0^-44 * PI, - C_is = get_twos_complement_num(word, word_length, 148, 16) * 2.0^-30, - C_ic = get_twos_complement_num(word, word_length, 164, 16) * 2.0^-30, - C_rs = get_twos_complement_num(word, word_length, 180, 24) * 2.0^-8, - C_rc = get_twos_complement_num(word, word_length, 204, 24) * 2.0^-8, - C_us = get_twos_complement_num(word, word_length, 228, 21) * 2.0^-30, - C_uc = get_twos_complement_num(word, word_length, 249, 21) * 2.0^-30, - ) -end - -""" -Shared clock block of message types 30-37, bits 39-127 (IS-GPS-705J Fig 20-3, Table 20-III). -""" -function parse_clock_block(raw::GPSL5IData, word::UInt320) - word_length = L5I_MESSAGE_BITS - GPSL5IData( - raw; - t_op = Int64(get_bits(word, word_length, 39, 11)) * 300, - ura_ned0_index = get_twos_complement_num(word, word_length, 50, 5), - ura_ned1_index = Int64(get_bits(word, word_length, 55, 3)), - ura_ned2_index = Int64(get_bits(word, word_length, 58, 3)), - t_0c = Int64(get_bits(word, word_length, 61, 11)) * 300, - a_f0 = get_twos_complement_num(word, word_length, 72, 26) * 2.0^-35, - a_f1 = get_twos_complement_num(word, word_length, 98, 20) * 2.0^-48, - a_f2 = get_twos_complement_num(word, word_length, 118, 10) * 2.0^-60, - ) -end - -""" -Message type 30 — clock, iono & group delay (IS-GPS-705J Fig 20-3, Tables 20-III/20-IV). -""" -function parse_mt30(raw::GPSL5IData, word::UInt320) - word_length = L5I_MESSAGE_BITS - raw = parse_clock_block(raw, word) - GPSL5IData( - raw; - T_GD = get_twos_complement_num(word, word_length, 128, 13) * 2.0^-35, - ISC_L1CA = get_twos_complement_num(word, word_length, 141, 13) * 2.0^-35, - ISC_L2C = get_twos_complement_num(word, word_length, 154, 13) * 2.0^-35, - ISC_L5I5 = get_twos_complement_num(word, word_length, 167, 13) * 2.0^-35, - ISC_L5Q5 = get_twos_complement_num(word, word_length, 180, 13) * 2.0^-35, - α_0 = get_twos_complement_num(word, word_length, 193, 8) * 2.0^-30, - α_1 = get_twos_complement_num(word, word_length, 201, 8) * 2.0^-27, - α_2 = get_twos_complement_num(word, word_length, 209, 8) * 2.0^-24, - α_3 = get_twos_complement_num(word, word_length, 217, 8) * 2.0^-24, - β_0 = get_twos_complement_num(word, word_length, 225, 8) * 2.0^11, - β_1 = get_twos_complement_num(word, word_length, 233, 8) * 2.0^14, - β_2 = get_twos_complement_num(word, word_length, 241, 8) * 2.0^16, - β_3 = get_twos_complement_num(word, word_length, 249, 8) * 2.0^16, - WN_op = Int64(get_bits(word, word_length, 257, 8)), - ) -end - -""" -Decode one 31-bit reduced-almanac packet starting at 1-based bit `start` (IS-GPS-705J Fig 20-16). -""" -function _l5i_reduced_almanac_packet( - word::UInt320, - start::Int, - WN_a::Int, - t_oa::Int, - PI::Float64, -) - word_length = L5I_MESSAGE_BITS - PRN_a = Int(get_bits(word, word_length, start, 6)) - PRN_a == 0 && return nothing # empty packet ⇒ no further packets follow - GPSL5IReducedAlmanac(; - PRN_a, - WN_a, - t_oa, - δA = get_twos_complement_num(word, word_length, start + 6, 8) * 2.0^9, - Ω_0 = get_twos_complement_num(word, word_length, start + 14, 7) * 2.0^-6 * PI, - Φ_0 = get_twos_complement_num(word, word_length, start + 21, 7) * 2.0^-6 * PI, - l1_health = get_bit(word, word_length, start + 28), - l2_health = get_bit(word, word_length, start + 29), - l5_health = get_bit(word, word_length, start + 30), - ) -end - -""" -Message type 12 — seven reduced-almanac packets (IS-GPS-705J Fig 20-11). -""" -function parse_mt12(raw::GPSL5IData, word::UInt320, PI::Float64) - word_length = L5I_MESSAGE_BITS - WN_a = Int(get_bits(word, word_length, 39, 13)) - t_oa = Int(get_bits(word, word_length, 52, 8)) * 2^12 - almanacs = raw.reduced_almanacs - # Seven 31-bit packets at bits 60, 91, 122, 153, 184, 215, 246. - for start in (60, 91, 122, 153, 184, 215, 246) - packet = _l5i_reduced_almanac_packet(word, start, WN_a, t_oa, PI) - isnothing(packet) && break # PRN_a==0 ⇒ remaining packets are filler - almanacs = _merge_keyed(almanacs, packet.PRN_a, packet) - end - GPSL5IData(raw; reduced_almanacs = almanacs) -end - -""" -Message type 31 — clock & four reduced-almanac packets (IS-GPS-705J Fig 20-4). -""" -function parse_mt31(raw::GPSL5IData, word::UInt320, PI::Float64) - word_length = L5I_MESSAGE_BITS - raw = parse_clock_block(raw, word) - WN_a = Int(get_bits(word, word_length, 128, 13)) - t_oa = Int(get_bits(word, word_length, 141, 8)) * 2^12 - almanacs = raw.reduced_almanacs - # Four 31-bit packets at bits 149, 180, 211, 242. - for start in (149, 180, 211, 242) - packet = _l5i_reduced_almanac_packet(word, start, WN_a, t_oa, PI) - isnothing(packet) && break # PRN_a==0 ⇒ remaining packets are filler - almanacs = _merge_keyed(almanacs, packet.PRN_a, packet) - end - GPSL5IData(raw; reduced_almanacs = almanacs) -end - -""" -Message type 32 — clock & EOP (IS-GPS-705J Fig 20-5, Table 20-VII). -""" -function parse_mt32(raw::GPSL5IData, word::UInt320) - word_length = L5I_MESSAGE_BITS - raw = parse_clock_block(raw, word) - GPSL5IData( - raw; - t_EOP = Int64(get_bits(word, word_length, 128, 16)) * 16, - PM_X = get_twos_complement_num(word, word_length, 144, 21) * 2.0^-20, - PM_X_dot = get_twos_complement_num(word, word_length, 165, 15) * 2.0^-21, - PM_Y = get_twos_complement_num(word, word_length, 180, 21) * 2.0^-20, - PM_Y_dot = get_twos_complement_num(word, word_length, 201, 15) * 2.0^-21, - ΔUT_GPS = get_twos_complement_num(word, word_length, 216, 31) * 2.0^-23, - ΔUT_GPS_dot = get_twos_complement_num(word, word_length, 247, 19) * 2.0^-25, - ) -end - -""" -Message type 33 — clock & UTC (IS-GPS-705J Fig 20-6, Table 20-IX). -""" -function parse_mt33(raw::GPSL5IData, word::UInt320) - word_length = L5I_MESSAGE_BITS - raw = parse_clock_block(raw, word) - GPSL5IData( - raw; - A0_UTC = get_twos_complement_num(word, word_length, 128, 16) * 2.0^-35, - A1_UTC = get_twos_complement_num(word, word_length, 144, 13) * 2.0^-51, - A2_UTC = get_twos_complement_num(word, word_length, 157, 7) * 2.0^-68, - Δt_LS = get_twos_complement_num(word, word_length, 164, 8), - t_ot = Int64(get_bits(word, word_length, 172, 16)) * 16, - WN_ot = Int64(get_bits(word, word_length, 188, 13)), - WN_LSF = Int64(get_bits(word, word_length, 201, 13)), - DN = Int64(get_bits(word, word_length, 214, 4)), - Δt_LSF = get_twos_complement_num(word, word_length, 218, 8), - ) -end - -""" -Message type 35 — clock & GGTO (IS-GPS-705J Fig 20-8, Table 20-XI). -""" -function parse_mt35(raw::GPSL5IData, word::UInt320) - word_length = L5I_MESSAGE_BITS - raw = parse_clock_block(raw, word) - GPSL5IData( - raw; - t_GGTO = Int64(get_bits(word, word_length, 128, 16)) * 16, - WN_GGTO = Int64(get_bits(word, word_length, 144, 13)), - GNSS_ID = Int64(get_bits(word, word_length, 157, 3)), - A0_GGTO = get_twos_complement_num(word, word_length, 160, 16) * 2.0^-35, - A1_GGTO = get_twos_complement_num(word, word_length, 176, 13) * 2.0^-51, - A2_GGTO = get_twos_complement_num(word, word_length, 189, 7) * 2.0^-68, - ) -end - -""" -Message type 37 — clock & one Midi almanac (IS-GPS-705J Fig 20-10, Table 20-V). -""" -function parse_mt37(raw::GPSL5IData, word::UInt320, PI::Float64) - word_length = L5I_MESSAGE_BITS - raw = parse_clock_block(raw, word) - PRN_a = Int(get_bits(word, word_length, 149, 6)) - PRN_a == 0 && return raw # empty almanac - alm = GPSL5IMidiAlmanac(; - PRN_a, - WN_a = Int(get_bits(word, word_length, 128, 13)), - t_oa = Int(get_bits(word, word_length, 141, 8)) * 2^12, - l1_health = get_bit(word, word_length, 155), - l2_health = get_bit(word, word_length, 156), - l5_health = get_bit(word, word_length, 157), - e = Int(get_bits(word, word_length, 158, 11)) * 2.0^-16, - δi = get_twos_complement_num(word, word_length, 169, 11) * 2.0^-14 * PI, - Ω_dot = get_twos_complement_num(word, word_length, 180, 11) * 2.0^-33 * PI, - sqrt_A = Int(get_bits(word, word_length, 191, 17)) * 2.0^-4, - Ω_0 = get_twos_complement_num(word, word_length, 208, 16) * 2.0^-15 * PI, - ω = get_twos_complement_num(word, word_length, 224, 16) * 2.0^-15 * PI, - M_0 = get_twos_complement_num(word, word_length, 240, 16) * 2.0^-15 * PI, - a_f0 = get_twos_complement_num(word, word_length, 256, 11) * 2.0^-20, - a_f1 = get_twos_complement_num(word, word_length, 267, 10) * 2.0^-37, - ) - GPSL5IData(raw; midi_almanacs = _merge_keyed(raw.midi_almanacs, PRN_a, alm)) -end - -# All-ones PRN ID in a CDC/EDC packet ⇒ no DC data in the remainder of the -# data block (IS-GPS-705J §20.3.3.7.2.3). -const L5I_DC_EMPTY_PRN = 0xff - -""" -Decode one 34-bit CDC packet starting at 1-based bit `start` (IS-GPS-705J Fig 20-17). -""" -function _l5i_cdc_packet( - word::UInt320, - start::Int, - t_op_D::Int, - t_OD::Int, - dc_data_type::Bool, -) - word_length = L5I_MESSAGE_BITS - PRN_a = Int(get_bits(word, word_length, start, 8)) - PRN_a == L5I_DC_EMPTY_PRN && return nothing - GPSL5IClockDifferentialCorrection(; - PRN_a, - t_op_D, - t_OD, - dc_data_type, - δa_f0 = get_twos_complement_num(word, word_length, start + 8, 13) * 2.0^-35, - δa_f1 = get_twos_complement_num(word, word_length, start + 21, 8) * 2.0^-51, - UDRA_index = get_twos_complement_num(word, word_length, start + 29, 5), - ) -end - -""" -Decode one 92-bit EDC packet starting at 1-based bit `start` (IS-GPS-705J Fig 20-17). -""" -function _l5i_edc_packet( - word::UInt320, - start::Int, - t_op_D::Int, - t_OD::Int, - dc_data_type::Bool, - PI::Float64, -) - word_length = L5I_MESSAGE_BITS - PRN_a = Int(get_bits(word, word_length, start, 8)) - PRN_a == L5I_DC_EMPTY_PRN && return nothing - GPSL5IEphemerisDifferentialCorrection(; - PRN_a, - t_op_D, - t_OD, - dc_data_type, - Δα = get_twos_complement_num(word, word_length, start + 8, 14) * 2.0^-34, - Δβ = get_twos_complement_num(word, word_length, start + 22, 14) * 2.0^-34, - Δγ = get_twos_complement_num(word, word_length, start + 36, 15) * 2.0^-32 * PI, - Δi = get_twos_complement_num(word, word_length, start + 51, 12) * 2.0^-32 * PI, - ΔΩ = get_twos_complement_num(word, word_length, start + 63, 12) * 2.0^-32 * PI, - ΔA = get_twos_complement_num(word, word_length, start + 75, 12) * 2.0^-9, - UDRA_dot_index = get_twos_complement_num(word, word_length, start + 87, 5), - ) -end - -""" -Message type 13 — clock differential correction (IS-GPS-705J Fig 20-12). -""" -function parse_mt13(raw::GPSL5IData, word::UInt320) - word_length = L5I_MESSAGE_BITS - t_op_D = Int(get_bits(word, word_length, 39, 11)) * 300 - t_OD = Int(get_bits(word, word_length, 50, 11)) * 300 - corrections = raw.clock_corrections - # Six 35-bit packets (1 DC data type bit + 34-bit CDC) at bits 61, 96, - # 131, 166, 201, 236. - for start in (61, 96, 131, 166, 201, 236) - dc_data_type = get_bit(word, word_length, start) - packet = _l5i_cdc_packet(word, start + 1, t_op_D, t_OD, dc_data_type) - # All-ones PRN ends the data block (like PRN 0 in the reduced almanac); - # `continue` over it — any trailing packets are filler too. - isnothing(packet) && continue - corrections = _merge_keyed(corrections, packet.PRN_a, packet) - end - GPSL5IData(raw; clock_corrections = corrections) -end - -""" -Message type 14 — ephemeris differential correction (IS-GPS-705J Fig 20-13). -""" -function parse_mt14(raw::GPSL5IData, word::UInt320, PI::Float64) - word_length = L5I_MESSAGE_BITS - t_op_D = Int(get_bits(word, word_length, 39, 11)) * 300 - t_OD = Int(get_bits(word, word_length, 50, 11)) * 300 - corrections = raw.ephemeris_corrections - # Two 93-bit packets (1 DC data type bit + 92-bit EDC) at bits 61, 154. - for start in (61, 154) - dc_data_type = get_bit(word, word_length, start) - packet = _l5i_edc_packet(word, start + 1, t_op_D, t_OD, dc_data_type, PI) - # All-ones PRN ends the data block (like PRN 0 in the reduced almanac); - # `continue` over it — any trailing packets are filler too. - isnothing(packet) && continue - corrections = _merge_keyed(corrections, packet.PRN_a, packet) - end - GPSL5IData(raw; ephemeris_corrections = corrections) -end - -""" -Message type 34 — clock & one CDC+EDC pair (IS-GPS-705J Fig 20-7). -""" -function parse_mt34(raw::GPSL5IData, word::UInt320, PI::Float64) - word_length = L5I_MESSAGE_BITS - raw = parse_clock_block(raw, word) - t_op_D = Int(get_bits(word, word_length, 128, 11)) * 300 - t_OD = Int(get_bits(word, word_length, 139, 11)) * 300 - dc_data_type = get_bit(word, word_length, 150) - cdc = _l5i_cdc_packet(word, 151, t_op_D, t_OD, dc_data_type) - edc = _l5i_edc_packet(word, 185, t_op_D, t_OD, dc_data_type, PI) - clock_corrections = - isnothing(cdc) ? raw.clock_corrections : - _merge_keyed(raw.clock_corrections, cdc.PRN_a, cdc) - ephemeris_corrections = - isnothing(edc) ? raw.ephemeris_corrections : - _merge_keyed(raw.ephemeris_corrections, edc.PRN_a, edc) - GPSL5IData(raw; clock_corrections, ephemeris_corrections) -end - -""" -Decode `num_chars` 8-bit ASCII characters starting at 1-based bit `start`, stripping control chars. -""" -function _l5i_text(word::UInt320, start::Int, num_chars::Int) - word_length = L5I_MESSAGE_BITS - chars = Char[] - for k = 0:(num_chars-1) - code = Int(get_bits(word, word_length, start + 8k, 8)) - # Keep printable ASCII; skip NUL/control padding so the message is clean. - (code >= 0x20 && code < 0x7f) && push!(chars, Char(code)) - end - return String(chars) -end - -""" -Message type 15 — 29 ASCII characters at bits 39-270 (IS-GPS-705J Fig 20-14). -""" -function parse_mt15(raw::GPSL5IData, word::UInt320) - word_length = L5I_MESSAGE_BITS - GPSL5IData( - raw; - text_mt15 = _l5i_text(word, 39, 29), - text_page_mt15 = Int64(get_bits(word, word_length, 271, 4)), - ) -end - -""" -Message type 36 — clock & 18 ASCII characters at bits 128-271 (IS-GPS-705J Fig 20-9). -""" -function parse_mt36(raw::GPSL5IData, word::UInt320) - word_length = L5I_MESSAGE_BITS - raw = parse_clock_block(raw, word) - GPSL5IData( - raw; - text_mt36 = _l5i_text(word, 128, 18), - text_page_mt36 = Int64(get_bits(word, word_length, 272, 4)), - ) -end - -""" -Message type 40 — Integrity Support Message (IS-GPS-705J Fig 20-14a). -""" -function parse_mt40(raw::GPSL5IData, word::UInt320) - word_length = L5I_MESSAGE_BITS - # 63-bit SV mask (MSB = PRN 1) at bits 89-151, read as one contiguous - # field; IS-GPS-705J Figure 20-14a only *draws* it across a row boundary. - mask = UInt64(get_bits(word, word_length, 89, 63)) - ism = GPSL5IIntegritySupportMessage(; - GNSS_ID = Int(get_bits(word, word_length, 39, 4)), - WN_ISM = Int(get_bits(word, word_length, 43, 13)), - TOW_ISM = Int(get_bits(word, word_length, 56, 6)), - t_correl = Int(get_bits(word, word_length, 62, 4)), - b_nom = Int(get_bits(word, word_length, 66, 4)), - γ_nom = Int(get_bits(word, word_length, 70, 4)), - R_sat = Int(get_bits(word, word_length, 74, 4)), - P_const = Int(get_bits(word, word_length, 78, 4)), - MFD = Int(get_bits(word, word_length, 82, 4)), - service_level = Int(get_bits(word, word_length, 86, 3)), - mask, - ) - GPSL5IData(raw; ism) -end - -""" - validate_data(state::GNSSDecoderState{<:GPSL5IData}) - -Promote `raw_data` to `data` once the minimum positioning set (message types -10 + 11 ephemeris and the message type 30 clock + group delay block) has been -decoded. Every CNAV message carries its own TOW, so each validated message -re-arms the streaming counter: the TOW refers to the start of the *next* -message, whose first `preamble_length` symbols are already buffered. -""" -function validate_data(state::GNSSDecoderState{<:GPSL5IData}) - if is_decoding_completed_for_positioning(state.raw_data) - return GNSSDecoderState( - state; - data = state.raw_data, - num_bits_after_valid_syncro_sequence = state.constants.preamble_length, - ) - end - return state -end - -""" -$(TYPEDSIGNATURES) - Check if the GPS L5 satellite is healthy and usable for positioning. Examines the L5 signal health bit decoded from the most recent message @@ -1572,12 +88,12 @@ bit is 0 (all navigation data on the L5 signal are OK). # Arguments - - `state::GNSSDecoderState{<:GPSL5IData}`: GPS L5I decoder state. + - `state::GNSSDecoderState{<:GPSCNAVData,<:GPSL5IConstants}`: GPS L5I decoder state. # Returns - `Bool`: `true` iff the L5 signal-health bit indicates OK. """ -function is_sat_healthy(state::GNSSDecoderState{<:GPSL5IData}) +function is_sat_healthy(state::GNSSDecoderState{<:GPSCNAVData,<:GPSL5IConstants}) state.data.l5_health === false end diff --git a/test/cnav_test_utils.jl b/test/cnav_test_utils.jl new file mode 100644 index 0000000..2cb4cef --- /dev/null +++ b/test/cnav_test_utils.jl @@ -0,0 +1,105 @@ +using GNSSDecoder: crc24q + +# --------------------------------------------------------------------------- +# Reference CNAV transmit chain (test-only), shared by the GPS L5I and GPS L2C +# decoder tests. +# +# The CNAV message is identical on GPS L5I (IS-GPS-705J §20.3) and GPS L2C +# (IS-GPS-200N §30), so a single reference encoder feeds both. The rate-1/2, +# K=7 convolutional encoder (G1 = 0o171, G2 = 0o133, IS-GPS-705J §3.3.3.1.1 ≡ +# IS-GPS-200N §3.3.3.1.1, Figure 3-14) is intentionally re-implemented here so +# the tests catch regressions in either the decoder's Viterbi or the message +# parsing. The encoder runs *continuously* across message boundaries — no tail +# bits, no reset — exactly like the satellite. Symbols are emitted as ±1 +# Float32 (bit 0 ⇒ +1, bit 1 ⇒ -1), the package-wide LLR convention. +# --------------------------------------------------------------------------- + +""" +Continuous K=7 rate-1/2 FEC encoder state (6-bit register, [s1..s6], s1 most recent). +""" +mutable struct CNAVTestEncoder + register::UInt8 +end +CNAVTestEncoder() = CNAVTestEncoder(0x00) + +""" +Encode one bit; returns the (G1, G2) symbol pair as `Bool`s. +""" +function encode_bit!(enc::CNAVTestEncoder, bit::Bool) + u = UInt8(bit) + s = enc.register + s1 = (s >> 5) & 0x01 + s2 = (s >> 4) & 0x01 + s3 = (s >> 3) & 0x01 + s5 = (s >> 1) & 0x01 + s6 = s & 0x01 + y1 = u ⊻ s1 ⊻ s2 ⊻ s3 ⊻ s6 # G1 = 0o171 + y2 = u ⊻ s2 ⊻ s3 ⊻ s5 ⊻ s6 # G2 = 0o133 + enc.register = ((u << 5) | (s >> 1)) & 0x3f + return (y1 == 0x01, y2 == 0x01) +end + +""" +FEC-encode a bit stream into ±1 Float32 soft symbols (bit 0 ⇒ +1, bit 1 ⇒ -1). +""" +function fec_encode_soft(enc::CNAVTestEncoder, bits::AbstractVector{Bool}) + soft = Vector{Float32}(undef, 2 * length(bits)) + for (i, b) in enumerate(bits) + y1, y2 = encode_bit!(enc, b) + soft[2i-1] = y1 ? -1.0f0 : 1.0f0 + soft[2i] = y2 ? -1.0f0 : 1.0f0 + end + return soft +end + +""" +Write `len` bits of `val` MSB-first into 1-based position `start` of `bits`. +""" +function setbits!(bits::BitVector, start::Int, len::Int, val::Integer) + mask = (UInt64(1) << len) - UInt64(1) + v = UInt64(unsigned(Int64(val)) & mask) + @inbounds for i = 0:(len-1) + bits[start+i] = ((v >> (len - 1 - i)) & UInt64(1)) == UInt64(1) + end + return bits +end + +""" +Build a CNAV message type 10 with chosen field values and a valid CRC. + +Health bits default to OK (0). `l1_health`/`l2_health`/`l5_health` set the +three signal-health bits (52/53/54) — the L2/L5 split is what distinguishes +the GPS L2C and L5I health checks. +""" +function build_mt10(; + prn = 9, + tow_count = 1234, + l1_health = false, + l2_health = false, + l5_health = false, +) + bits = falses(300) + setbits!(bits, 1, 8, 0b10001011) # preamble + setbits!(bits, 9, 6, prn) + setbits!(bits, 15, 6, 10) # message type 10 + setbits!(bits, 21, 17, tow_count) + setbits!(bits, 38, 1, 0) # alert + setbits!(bits, 39, 13, 2345) # WN + setbits!(bits, 52, 1, l1_health) # L1 health + setbits!(bits, 53, 1, l2_health) # L2 health + setbits!(bits, 54, 1, l5_health) # L5 health + setbits!(bits, 55, 11, 100) # t_op (×300 = 30000) + setbits!(bits, 66, 5, 0) # URA_ED index + setbits!(bits, 71, 11, 100) # t_0e (×300 = 30000) + setbits!(bits, 82, 26, 0) # ΔA + setbits!(bits, 108, 25, 0) # A_dot + setbits!(bits, 133, 17, 100) # Δn_0 + setbits!(bits, 150, 23, 0) # Δn_0_dot + setbits!(bits, 173, 33, 12345) # M_0 + setbits!(bits, 206, 33, 1000) # e + setbits!(bits, 239, 33, -54321) # ω + setbits!(bits, 272, 1, 1) # integrity status flag + setbits!(bits, 273, 1, 0) # L2C phasing + setbits!(bits, 277, 24, crc24q(collect(bits[1:276]))) + return collect(bits) +end diff --git a/test/data/gps_l2c_prn25_nav_bits.bin b/test/data/gps_l2c_prn25_nav_bits.bin new file mode 100644 index 0000000000000000000000000000000000000000..89da6470a761687d3d4a2c4243997df11bcbbccb GIT binary patch literal 988 zcmeB9S?VUp?zn+*C&RO51qKH8|3Bkz@O~2iadYO(nX}Coi!b67XMWI~veiv-r@|}i z*IlnJs{8-{aQl(O!$}Jm4lpn*VBkn*P@KS~eCI%S>Qgtv4Ge5g7&pf$S4#h50-6T` ztW5v^tF!$7WyQcHUbCS)<*S>c@4=-fr|!Ja$h&vz)~h?0_O@PJx_9T9MuU?)jTfhQ zZe0Mhaj9D%UUzZ4Ub_I~u0*`t_dU z{)h?PDOcSZH<&SS0A0)ka&`g`3ji@w1CU4nV#Xl14h6f-`sU@w8W^|}m|D!{RruYCbppm?;S2$g zyYQzkeo2u9Aa~(UUktjZ8@kg>-I3E*-Gt@^2seRPYwxHzK)f*@H_@PThIo m(01?Etygz09Xs?w)x_Na> (7 - j)) & 0x01 == 0x01 for byte in block for j = 0:7] + bits[1:300] + end +end + +@testset "GPS L2C (CNAV)" begin + PI = 3.1415926535898 + + @testset "Constructor" begin + state_a = GPSL2CMDecoderState(7) + state_b = GNSSDecoderState(GPSL2CM(), 7) + @test state_a.prn == 7 + @test state_a == state_b + @test state_a.data == GPSCNAVData() # shared CNAV container + @test state_a.constants isa GNSSDecoder.GPSL2CMConstants + @test isnothing(state_a.num_bits_after_valid_syncro_sequence) + @test !state_a.is_shifted_by_180_degrees + end + + @testset "Shares the L5I CNAV decode path" begin + # One message followed by the next message's preamble, prefixed with an + # odd number of idle symbols (arbitrary symbol-pair phase) — identical + # exercise to the L5I synthetic test, here through a GPSL2CM state. + enc = CNAVTestEncoder() + prefix = fec_encode_soft(enc, falses(40))[1:79] # odd-length prefix + stream = vcat( + prefix, + fec_encode_soft(enc, [build_mt10(); build_mt10(; tow_count = 1235)]), + ) + + state = decode(GPSL2CMDecoderState(9), stream, length(stream)) + d = state.raw_data + @test d.last_message_id == 10 + @test d.TOW == 1234 * 6 # TOW count × 6 on L2C too (IS-GPS-200N §30.3.3) + @test d.WN == 2345 + @test d.M_0 ≈ 12345 * 2.0^-32 * PI + @test d.e ≈ 1000 * 2.0^-34 + @test d.ω ≈ -54321 * 2.0^-32 * PI + @test !state.is_shifted_by_180_degrees + end + + @testset "is_sat_healthy reads the L2 health bit (not L5)" begin + # MT10 with L2 OK (bit 53 = 0) but L5 bad (bit 54 = 1). The exact same + # symbol stream must read healthy on L2C and unhealthy on L5I — this is + # the one decode-level difference between the two signals. + enc = CNAVTestEncoder() + msgs = [ + build_mt10(; l2_health = false, l5_health = true) + build_mt10(; tow_count = 1235, l2_health = false, l5_health = true) + ] + stream = fec_encode_soft(enc, msgs) + + state_l2c = decode(GPSL2CMDecoderState(9), stream, length(stream)) + @test state_l2c.raw_data.l2_health == false + @test state_l2c.raw_data.l5_health == true + # Health is reported from validated `data`; the synthetic MT10 alone is + # not a complete positioning set, so promote raw_data to compare the + # bit selection directly. + @test is_sat_healthy( + GNSSDecoder.GNSSDecoderState(state_l2c; data = state_l2c.raw_data), + ) + + state_l5i = decode(GPSL5IDecoderState(9), stream, length(stream)) + @test !is_sat_healthy( + GNSSDecoder.GNSSDecoderState(state_l5i; data = state_l5i.raw_data), + ) + + # And the converse: L2 bad, L5 OK ⇒ L2C unhealthy, L5I healthy. + enc2 = CNAVTestEncoder() + msgs2 = [ + build_mt10(; l2_health = true, l5_health = false) + build_mt10(; tow_count = 1235, l2_health = true, l5_health = false) + ] + stream2 = fec_encode_soft(enc2, msgs2) + s2_l2c = decode(GPSL2CMDecoderState(9), stream2, length(stream2)) + @test !is_sat_healthy(GNSSDecoder.GNSSDecoderState(s2_l2c; data = s2_l2c.raw_data)) + s2_l5i = decode(GPSL5IDecoderState(9), stream2, length(stream2)) + @test is_sat_healthy(GNSSDecoder.GNSSDecoderState(s2_l5i; data = s2_l5i.raw_data)) + end + + @testset "Phase-inverted stream decodes with is_shifted_by_180_degrees" begin + enc = CNAVTestEncoder() + stream = + fec_encode_soft(enc, [falses(20); build_mt10(); build_mt10(; tow_count = 1235)]) + state = decode(GPSL2CMDecoderState(9), -stream, length(stream)) + @test state.is_shifted_by_180_degrees + @test state.raw_data.last_message_id == 10 + @test state.raw_data.WN == 2345 + end + + @testset "reset_decoder_state clears in-flight state, keeps decoded data" begin + enc = CNAVTestEncoder() + stream = fec_encode_soft(enc, [build_mt10(); build_mt10(; tow_count = 1235)]) + state = decode(GPSL2CMDecoderState(9), stream, length(stream)) + @test state.raw_data.WN == 2345 + + state = reset_decoder_state(state) + @test isempty(state.cache.soft_buffer) + @test isnothing(state.raw_data.TOW) + @test isnothing(state.num_bits_after_valid_syncro_sequence) + @test !state.is_shifted_by_180_degrees + @test state.raw_data.WN == 2345 # ephemeris preserved + end + + # --- Spirent-derived recording fixture (committed; always runs) --------- + + """ + Assert the decoded `state` matches Spirent's L2C reference decode (PRN 25). + """ + function assert_l2c_spirent_golden(state) + d = state.data + # Header of the last decoded message (#25 of the recording, message + # type 10, message TOW count 43250). L2C messages are 12 s apart, so the + # count steps by 2 per message (43202, 43204, …); the ×6 scaling holds. + @test d.last_message_id == 10 + @test d.TOW == 43250 * 6 + @test d.alert_flag == false + + # Message type 10 — ephemeris 1 (semicircle fields stored in radians, × π). + @test d.WN == 2106 + @test d.l1_health == false + @test d.l2_health == false + @test d.l5_health == false + @test d.t_op == 259200 + @test d.ura_ed_index == 0 + @test d.t_0e == 264600 + @test d.ΔA ≈ 922.048828125 + @test d.A_dot ≈ 0.0 + @test d.Δn_0 ≈ 3.7252334550430533e-9 * π + @test d.Δn_0_dot ≈ 1.000000082740371e-11 * π + @test d.M_0 ≈ -0.2567902144510299 * π + @test d.e ≈ 0.099999999976716936 + @test d.ω ≈ -0.3913338293787092 * π + @test d.integrity_status_flag == false + @test d.l2c_phasing == false + + # Message type 11 — ephemeris 2. + @test d.Ω_0 ≈ 0.51429651258513331 * π + @test d.i_0 ≈ 0.34116418519988656 * π + @test d.ΔΩ_dot ≈ 3.7252334550430533e-9 * π + @test d.i_dot ≈ 9.9987573776161298e-11 * π + @test d.C_is ≈ 4.0000304579734802e-6 + @test d.C_ic ≈ 2.9997900128364563e-6 + @test d.C_rs ≈ 6.0 + @test d.C_rc ≈ 5.0 + @test d.C_us ≈ 1.9995495676994324e-6 + @test d.C_uc ≈ 1.0002404451370239e-6 + + # Message types 30-37 — shared clock block. + @test d.ura_ned0_index == 0 + @test d.ura_ned1_index == 0 + @test d.ura_ned2_index == 0 + @test d.t_0c == 264600 + @test d.a_f0 ≈ 6.4874766394495964e-6 + @test d.a_f1 ≈ 1.0324008314910316e-9 + @test d.a_f2 ≈ 4.4322184811207421e-16 + + # Message type 30 — group delay + ionosphere. + @test d.T_GD ≈ 9.8953023552894592e-10 + @test d.ISC_L1CA ≈ 0.0 + @test d.ISC_L2C ≈ 0.0 + @test d.ISC_L5I5 ≈ 0.0 + @test d.ISC_L5Q5 ≈ 0.0 + @test d.α_0 ≈ 4.6566128730773926e-9 + @test d.α_1 ≈ 1.4901161193847656e-8 + @test d.α_2 ≈ -5.9604644775390625e-8 + @test d.α_3 ≈ -5.9604644775390625e-8 + @test d.β_0 ≈ 79872.0 + @test d.β_1 ≈ 65536.0 + @test d.β_2 ≈ -65536.0 + @test d.β_3 ≈ -393216.0 + @test d.WN_op == 58 + + # Message type 32 — EOP. Spirent's reference decoder predates the + # 2⁻²³ ΔUT_GPS definition (IS-GPS-200N Table 30-VII) and prints the + # 31-bit field as ΔUT1 with the old 2⁻²⁴ scale (-0.25315952301025391 s); + # our decoder follows the current ICD, i.e. exactly twice that. + @test d.t_EOP == 259200 + @test d.PM_X ≈ 0.10612583160400391 + @test d.PM_X_dot ≈ 0.0013251304626464844 + @test d.PM_Y ≈ 0.4459381103515625 + @test d.PM_Y_dot ≈ 0.00040197372436523438 + @test d.ΔUT_GPS ≈ 2 * -0.25315952301025391 + @test d.ΔUT_GPS_dot ≈ -0.00046199560165405273 # rate scale unchanged + + # Message type 33 — UTC. + @test d.A0_UTC ≈ 9.5364521257579327e-7 + @test d.A1_UTC ≈ 1.8185453143360064e-12 + @test d.A2_UTC ≈ 0.0 + @test d.Δt_LS == 18 + @test d.t_ot == 507904 + @test d.WN_ot == 2106 + @test d.WN_LSF == 2104 + @test d.DN == 2 + @test d.Δt_LSF == 18 + + # Message type 35 — GGTO. + @test d.t_GGTO == 259200 + @test d.WN_GGTO == 2106 + @test d.GNSS_ID == 0 + @test d.A0_GGTO ≈ 0.0 + @test d.A1_GGTO ≈ 0.0 + @test d.A2_GGTO ≈ 0.0 + + # Reduced almanacs: message type 31 broadcasts PRNs 1-4, message + # type 12 PRNs 5-11 (semicircle fields × π). + @test !isnothing(d.reduced_almanacs) && length(d.reduced_almanacs) == 11 + ra1 = d.reduced_almanacs[1] + @test ra1.WN_a == 2106 + @test ra1.t_oa == 507904 + @test ra1.δA ≈ 1024.0 + @test ra1.Ω_0 ≈ 0.296875 * π + @test ra1.Φ_0 ≈ -0.140625 * π + @test !ra1.l1_health && !ra1.l2_health && !ra1.l5_health + ra5 = d.reduced_almanacs[5] + @test ra5.δA ≈ 1024.0 + @test ra5.Ω_0 ≈ -0.765625 * π + @test ra5.Φ_0 ≈ -0.234375 * π + ra11 = d.reduced_almanacs[11] + @test ra11.Ω_0 ≈ -0.125 * π + @test ra11.Φ_0 ≈ 0.171875 * π + + # Midi almanac (message type 37): one SV per message; the recording's + # first cycle carries PRN 26. + @test !isnothing(d.midi_almanacs) && length(d.midi_almanacs) == 1 + ma = d.midi_almanacs[26] + @test ma.WN_a == 2106 + @test ma.t_oa == 507904 + @test ma.e ≈ 0.0 + @test ma.δi ≈ 0.00555419921875 * π + @test ma.Ω_dot ≈ 0.0 + # Spirent's dump prints the truncated "5153"; the transmitted 17-bit + # field is 82459 = 5153.6875. + @test ma.sqrt_A ≈ 5153.6875 + @test ma.Ω_0 ≈ 0.6190185546875 * π + @test ma.ω ≈ 0.308013916015625 * π + @test ma.M_0 ≈ 0.364288330078125 * π + @test ma.a_f0 ≈ 0.0 + @test ma.a_f1 ≈ 0.0 + @test !ma.l1_health && !ma.l2_health && !ma.l5_health + + # Differential corrections: message type 13 carries CDC packets for + # PRNs 1-6, message type 14 EDC packets for PRNs 1-2, and message + # type 34 one CDC (PRN 7) + EDC (PRN 3) pair. + @test !isnothing(d.clock_corrections) && length(d.clock_corrections) == 7 + cdc1 = d.clock_corrections[1] + @test cdc1.t_op_D == 259200 + @test cdc1.t_OD == 259200 + @test cdc1.δa_f0 ≈ 0.0 + @test cdc1.δa_f1 ≈ 0.0 + @test cdc1.UDRA_index == 0 + cdc7 = d.clock_corrections[7] # from message type 34 + @test cdc7.t_op_D == 259200 + @test cdc7.t_OD == 259200 + @test !isnothing(d.ephemeris_corrections) && length(d.ephemeris_corrections) == 3 + edc3 = d.ephemeris_corrections[3] # from message type 34 + @test edc3.t_op_D == 259200 + @test edc3.t_OD == 259200 + @test edc3.Δα ≈ 0.0 + @test edc3.Δβ ≈ 0.0 + @test edc3.Δγ ≈ 0.0 + @test edc3.Δi ≈ 0.0 + @test edc3.ΔΩ ≈ 0.0 + @test edc3.ΔA ≈ 0.0 + @test edc3.UDRA_dot_index == 0 + + # Text messages (the simulator broadcasts "Spirent Communications L2C + # test text message." across pages; the first cycle carries page 1). + @test d.text_mt15 == "Spirent Communications L2C te" + @test d.text_page_mt15 == 1 + @test d.text_mt36 == "Spirent Communicat" + @test d.text_page_mt36 == 1 + + # No message type 40 in the recording. + @test isnothing(d.ism) + + @test GNSSDecoder.is_decoding_completed_for_positioning(d) + @test is_sat_healthy(state) + end + + @testset "Spirent recording fixture (PRN 25)" begin + messages = load_l2c_fixture_messages() + @test all(crc24q(m) == 0 for m in messages) + stream = fec_encode_soft(CNAVTestEncoder(), reduce(vcat, messages)) + @test length(stream) == 26 * 600 + + # Of the 26 encoded messages the last cannot complete (its sync + # window needs the next message's first 16 symbols), so 25 decode. + state = decode(GPSL2CMDecoderState(25), stream, length(stream)) + @test !state.is_shifted_by_180_degrees + assert_l2c_spirent_golden(state) + + # Polarity-inverted stream must decode identically with the + # 180°-flip flag set. + state_inv = decode(GPSL2CMDecoderState(25), -stream, length(stream)) + @test state_inv.is_shifted_by_180_degrees + @test state_inv.data == state.data + + # Mid-stream acquisition at an arbitrary (odd) symbol offset: the + # decoder must slide to the next message boundary and decode the + # remaining messages, losing only the partially received first one. + offset = 351 + state_mid = + decode(GPSL2CMDecoderState(25), stream[(offset+1):end], length(stream) - offset) + assert_l2c_spirent_golden(state_mid) + + # Noisy soft symbols: moderate Gaussian noise on the ±1 LLRs must + # not cost any message at this SNR. + noisy = stream .+ 0.4f0 .* randn(MersenneTwister(7), Float32, length(stream)) + state_noisy = decode(GPSL2CMDecoderState(25), noisy, length(noisy)) + assert_l2c_spirent_golden(state_noisy) + end + + @testset "decode_once stops at the first complete positioning set" begin + messages = load_l2c_fixture_messages() + stream = fec_encode_soft(CNAVTestEncoder(), reduce(vcat, messages)) + state = decode(GPSL2CMDecoderState(25), stream, length(stream); decode_once = true) + # Message types 10, 11, 30 are the first three of the recording; the + # positioning set is complete after message 3 (type 30) and decoding + # stops there (message 4 is type 15 — its text must not have been + # parsed into the validated `data`). + @test GNSSDecoder.is_decoding_completed_for_positioning(state.data) + @test state.data.last_message_id == 30 + @test isnothing(state.data.text_mt15) + @test state.raw_data.text_mt15 == "Spirent Communications L2C te" + end +end diff --git a/test/gps_l5i.jl b/test/gps_l5i.jl index 3f56f50..9cdc57d 100644 --- a/test/gps_l5i.jl +++ b/test/gps_l5i.jl @@ -2,98 +2,11 @@ using Test using Random using GNSSDecoder using GNSSSignals -using GNSSDecoder: crc24q, gps_l5i_viterbi +using GNSSDecoder: crc24q, gps_cnav_viterbi -# --------------------------------------------------------------------------- -# Reference CNAV transmit chain (test-only). -# -# The rate-1/2, K=7 convolutional encoder (G1 = 0o171, G2 = 0o133, -# IS-GPS-705J §3.3.3.1.1, Figure 3-7) is intentionally re-implemented here so -# the tests catch regressions in either the decoder's Viterbi or the -# message parsing. The encoder runs *continuously* across message boundaries -# — no tail bits, no reset — exactly like the satellite. Symbols are emitted -# as ±1 Float32 (bit 0 ⇒ +1, bit 1 ⇒ -1), the package-wide LLR convention. -# --------------------------------------------------------------------------- - -""" -Continuous K=7 rate-1/2 FEC encoder state (6-bit register, [s1..s6], s1 most recent). -""" -mutable struct L5ITestEncoder - register::UInt8 -end -L5ITestEncoder() = L5ITestEncoder(0x00) - -""" -Encode one bit; returns the (G1, G2) symbol pair as `Bool`s. -""" -function encode_bit!(enc::L5ITestEncoder, bit::Bool) - u = UInt8(bit) - s = enc.register - s1 = (s >> 5) & 0x01 - s2 = (s >> 4) & 0x01 - s3 = (s >> 3) & 0x01 - s5 = (s >> 1) & 0x01 - s6 = s & 0x01 - y1 = u ⊻ s1 ⊻ s2 ⊻ s3 ⊻ s6 # G1 = 0o171 - y2 = u ⊻ s2 ⊻ s3 ⊻ s5 ⊻ s6 # G2 = 0o133 - enc.register = ((u << 5) | (s >> 1)) & 0x3f - return (y1 == 0x01, y2 == 0x01) -end - -""" -FEC-encode a bit stream into ±1 Float32 soft symbols (bit 0 ⇒ +1, bit 1 ⇒ -1). -""" -function fec_encode_soft(enc::L5ITestEncoder, bits::AbstractVector{Bool}) - soft = Vector{Float32}(undef, 2 * length(bits)) - for (i, b) in enumerate(bits) - y1, y2 = encode_bit!(enc, b) - soft[2i-1] = y1 ? -1.0f0 : 1.0f0 - soft[2i] = y2 ? -1.0f0 : 1.0f0 - end - return soft -end - -""" -Write `len` bits of `val` MSB-first into 1-based position `start` of `bits`. -""" -function setbits!(bits::BitVector, start::Int, len::Int, val::Integer) - mask = (UInt64(1) << len) - UInt64(1) - v = UInt64(unsigned(Int64(val)) & mask) - @inbounds for i = 0:(len-1) - bits[start+i] = ((v >> (len - 1 - i)) & UInt64(1)) == UInt64(1) - end - return bits -end - -""" -Build a CNAV message type 10 with chosen field values and a valid CRC. -""" -function build_mt10(; prn = 9, tow_count = 1234) - bits = falses(300) - setbits!(bits, 1, 8, 0b10001011) # preamble - setbits!(bits, 9, 6, prn) - setbits!(bits, 15, 6, 10) # message type 10 - setbits!(bits, 21, 17, tow_count) - setbits!(bits, 38, 1, 0) # alert - setbits!(bits, 39, 13, 2345) # WN - setbits!(bits, 52, 1, 0) # L1 health - setbits!(bits, 53, 1, 0) # L2 health - setbits!(bits, 54, 1, 0) # L5 health - setbits!(bits, 55, 11, 100) # t_op (×300 = 30000) - setbits!(bits, 66, 5, 0) # URA_ED index - setbits!(bits, 71, 11, 100) # t_0e (×300 = 30000) - setbits!(bits, 82, 26, 0) # ΔA - setbits!(bits, 108, 25, 0) # A_dot - setbits!(bits, 133, 17, 100) # Δn_0 - setbits!(bits, 150, 23, 0) # Δn_0_dot - setbits!(bits, 173, 33, 12345) # M_0 - setbits!(bits, 206, 33, 1000) # e - setbits!(bits, 239, 33, -54321) # ω - setbits!(bits, 272, 1, 1) # integrity status flag - setbits!(bits, 273, 1, 0) # L2C phasing - setbits!(bits, 277, 24, crc24q(collect(bits[1:276]))) - return collect(bits) -end +# The CNAV transmit-chain helpers (CNAVTestEncoder, fec_encode_soft, setbits!, +# build_mt10) live in `cnav_test_utils.jl`, included by runtests.jl before this +# file (they are shared with the GPS L2C tests — same CNAV message). # --------------------------------------------------------------------------- # Spirent fixture. @@ -134,7 +47,7 @@ end state_b = GNSSDecoderState(GPSL5I(), 13) @test state_a.prn == 13 @test state_a == state_b - @test state_a.data == GPSL5IData() + @test state_a.data == GPSCNAVData() @test isnothing(state_a.num_bits_after_valid_syncro_sequence) @test !state_a.is_shifted_by_180_degrees end @@ -142,21 +55,21 @@ end @testset "Window Viterbi round-trips the continuous FEC" begin Random.seed!(1234) bits = rand(Bool, 308) - soft = fec_encode_soft(L5ITestEncoder(), bits) - @test gps_l5i_viterbi(soft) == bits + soft = fec_encode_soft(CNAVTestEncoder(), bits) + @test gps_cnav_viterbi(soft) == bits # Soft decisions: moderate Gaussian noise must still decode exactly # (rate-1/2 K=7 at this SNR has plenty of margin). noisy = soft .+ 0.4f0 .* randn(MersenneTwister(42), Float32, length(soft)) - @test gps_l5i_viterbi(noisy) == bits + @test gps_cnav_viterbi(noisy) == bits # The encoder runs across message boundaries: a window cut from the # *middle* of a longer stream (unknown initial register) must decode # exactly as well. - enc = L5ITestEncoder() + enc = CNAVTestEncoder() fec_encode_soft(enc, rand(Bool, 100)) # advance the register soft_mid = fec_encode_soft(enc, bits) - @test gps_l5i_viterbi(soft_mid) == bits + @test gps_cnav_viterbi(soft_mid) == bits end @testset "Synthetic message type 10 round trip" begin @@ -166,7 +79,7 @@ end # One message followed by the next message's preamble, with a prefix # of an odd number of idle symbols: sync must cope with an arbitrary # symbol-pair phase. - enc = L5ITestEncoder() + enc = CNAVTestEncoder() prefix = fec_encode_soft(enc, falses(40))[1:79] # odd-length prefix stream = vcat(prefix, fec_encode_soft(enc, [mt10; build_mt10(; tow_count = 1235)])) @@ -199,7 +112,7 @@ end end @testset "Phase-inverted stream decodes with is_shifted_by_180_degrees" begin - enc = L5ITestEncoder() + enc = CNAVTestEncoder() stream = fec_encode_soft(enc, [falses(20); build_mt10(); build_mt10(; tow_count = 1235)]) state = decode(GPSL5IDecoderState(9), -stream, length(stream)) @@ -209,7 +122,7 @@ end end @testset "reset_decoder_state clears in-flight state, keeps decoded data" begin - enc = L5ITestEncoder() + enc = CNAVTestEncoder() stream = fec_encode_soft(enc, [build_mt10(); build_mt10(; tow_count = 1235)]) state = decode(GPSL5IDecoderState(9), stream, length(stream)) @test state.raw_data.WN == 2345 @@ -406,7 +319,7 @@ end @test all(crc24q(m) == 0 for m in messages) # The recording starts at the first message: TOW count 43201 in # message 1 stamps the start of message 2. - stream = fec_encode_soft(L5ITestEncoder(), reduce(vcat, messages)) + stream = fec_encode_soft(CNAVTestEncoder(), reduce(vcat, messages)) @test length(stream) == 26 * 600 # Of the 26 encoded messages the last cannot complete (its sync @@ -438,7 +351,7 @@ end @testset "decode_once stops at the first complete positioning set" begin messages = load_l5i_fixture_messages() - stream = fec_encode_soft(L5ITestEncoder(), reduce(vcat, messages)) + stream = fec_encode_soft(CNAVTestEncoder(), reduce(vcat, messages)) state = decode(GPSL5IDecoderState(25), stream, length(stream); decode_once = true) # Message types 10, 11, 30 are the first three of the recording; the # positioning set is complete after message 3 and decoding stops diff --git a/test/runtests.jl b/test/runtests.jl index 7e7a008..42237c6 100755 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -35,7 +35,10 @@ end include("bit_fiddling.jl") include("gpsl1.jl") include("gps_l1c_d.jl") + # Shared CNAV transmit-chain helpers, consumed by both the L5I and L2C tests. + include("cnav_test_utils.jl") include("gps_l5i.jl") + include("gps_l2cm.jl") include("galileo_e1b.jl") include("galileo_e5a.jl")