From d12a05b4984f181a5ba1b8ff2f8b2dd126b0288f Mon Sep 17 00:00:00 2001 From: siebc <226531417+siebc@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:39:23 +0000 Subject: [PATCH] feat(galileo_e5a): add Galileo E5a F/NAV decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decode the Galileo Open Service F/NAV message on the E5a-I data component (50 sps). Recovers ephemeris, clock correction, ionospheric correction, BGD, signal health / data validity, GST-UTC and GST-GPS conversion, and the word-type-5/6 split-almanac chain, per OS SIS ICD v2.2 §5.1 (word types 1-6). The FEC chain reuses the shared Galileo K=7 NSC Viterbi (G1=0o171, G2=0o133, G2 inverted): 12-symbol sync detection, 61x8 block deinterleave, G2 sign-flip on the soft LLRs, AFF3CT Viterbi (K=238, N=488), then CRC-24Q over the 238-bit page. Consumes soft symbols end-to-end like L1 C/A and E1B, so the public decode API is unchanged. To support this, factor the definitions common to every Galileo signal (SignalHealth / DataValidityStatus enums, GalileoAlmanac, the galileo_viterbi FEC primitive) out of e1b.jl into a new galileo/galileo.jl, and hoist the package-wide physical constants (GNSS_PI, SPEED_OF_LIGHT, EARTH_ROTATION_RATE) into gnss.jl as a single source of truth. Requires GNSSSignals 2.3 (compat bumped from 2.2), which adds the GalileoE5aI / GalileoE5aQ signal types (GNSSSignals.jl PR #83). The GNSSDecoderState(::GalileoE5aI, prn) dispatch resolves that type directly, mirroring the E1B constructor; F/NAV rides only on the E5a-I data component, so E5a-Q (pilot) does not map to this decoder. Non-breaking: purely additive (new GalileoE5aDecoderState export). The e1b.jl/gnss.jl refactor relocates code without changing any public symbol, signature, or constant value. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 14 + Project.toml | 2 +- README.md | 1 + docs/src/api.md | 8 + src/GNSSDecoder.jl | 14 + src/galileo/e1b.jl | 192 +----- src/galileo/e5a.jl | 954 +++++++++++++++++++++++++++ src/galileo/galileo.jl | 203 ++++++ src/gnss.jl | 16 + src/gps/l1c_d.jl | 6 +- src/gps/l1ca.jl | 6 +- src/gps/l5.jl | 6 +- test/data/galileo_e5a_fnav_pages.bin | Bin 0 -> 4712 bytes test/galileo_e5a.jl | 293 ++++++++ test/runtests.jl | 1 + 15 files changed, 1545 insertions(+), 171 deletions(-) create mode 100644 src/galileo/e5a.jl create mode 100644 src/galileo/galileo.jl create mode 100644 test/data/galileo_e5a_fnav_pages.bin create mode 100644 test/galileo_e5a.jl diff --git a/CONTEXT.md b/CONTEXT.md index 79e31fc..c8299ef 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -18,6 +18,17 @@ code; this file is for naming and meaning only. - **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. +- **Galileo E5a** (`GalileoE5aDecoderState`) — 50 sps F/NAV broadcast on the + E5a-I component. Page = 500 channel symbols = 10 seconds = a 12-symbol sync + pattern + 488 encoded symbols. Same K=7 rate-1/2 NSC convolutional code as + E1B (G1 = 0o171, G2 = 0o133, G2 inverted) but a 61×8 block interleaver; one + page decodes to 238 information bits (page type + data + CRC). Unlike I/NAV, + each page is a complete, independently CRC-protected word (no even/odd + stitching). Word types 1-4 carry clock/iono/health (WT1), ephemeris (WT2-3), + and GST-UTC/GGTO + Cic/Cis (WT4); word types 5-6 carry the almanac chain. + F/NAV rides on the E5a-I (data) component, so `GNSSDecoderState(::GalileoE5aI, + prn)` maps here (E5a-Q is the dataless pilot); `GalileoE5aDecoderState(prn)` is + the equivalent direct constructor. ## Frame structure terms @@ -90,6 +101,9 @@ lives in the `cache`. TOW-continuity check across two subframes to confirm. - **Galileo E1B**: fixed 10-bit page-sync pattern `0101100000` at the start of every page (in the encoded symbol stream). +- **Galileo E5a**: fixed 12-symbol F/NAV sync pattern `101101110000` at the + start of every page; matched at both ends of the 500-symbol page window, in + either polarity (180-degree ambiguity), exactly like E1B. - **L1C-D**: no fixed preamble. Sync via BCH match on the 52-symbol TOI fields of *two consecutive subframes*: pick TOI such that subframe N's BCH matches TOI=t and subframe N+1's matches TOI=t+1 (mod 400). Handles polarity diff --git a/Project.toml b/Project.toml index 9ded661..cacf021 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.2" +GNSSSignals = "2.3" Random = "1" Test = "1" Tracking = "2" diff --git a/README.md b/README.md index cafe5ee..d0dd14b 100755 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Currently implemented: * GPS L1C-D (CNAV-2) * GPS L5I (CNAV) * Galileo E1B (I/NAV) + * Galileo E5a (F/NAV) ## Installation diff --git a/docs/src/api.md b/docs/src/api.md index 00e860f..7f71d16 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -11,6 +11,7 @@ GNSSDecoderState ```@docs GPSL1CADecoderState GalileoE1BDecoderState +GalileoE5aDecoderState GPSL1C_DDecoderState GPSL5IDecoderState ``` @@ -66,6 +67,13 @@ GNSSDecoder.SignalHealth GNSSDecoder.DataValidityStatus ``` +### Galileo E5a + +```@docs +GNSSDecoder.GalileoE5aConstants +GNSSDecoder.GalileoE5aData +``` + ### GPS L1C-D ```@docs diff --git a/src/GNSSDecoder.jl b/src/GNSSDecoder.jl index 92e1ea7..4ef33b6 100755 --- a/src/GNSSDecoder.jl +++ b/src/GNSSDecoder.jl @@ -18,6 +18,7 @@ export decode, GPSL5IEphemerisDifferentialCorrection, GPSL5IIntegritySupportMessage, GalileoE1BDecoderState, + GalileoE5aDecoderState, is_sat_healthy, GNSSDecoderState, reset_decoder_state @@ -44,8 +45,21 @@ include("bch_toi.jl") include("deinterleave.jl") include("gps/l1ca.jl") + +# Definitions shared across Galileo signals (the `SignalHealth` / +# `DataValidityStatus` enums, the `GalileoAlmanac` record, the common K=7 NSC +# `galileo_viterbi` FEC primitive). Included before the per-signal Galileo +# decoders, which all consume it — analogous to how `gnss.jl` precedes every +# signal. Itself depends only on the earlier shared utilities (`deinterleave`, +# `bit_fiddling`) and `Aff3ct`. +include("galileo/galileo.jl") include("galileo/e1b.jl") +# Galileo E5a (F/NAV) decoder. Consumes the shared Galileo definitions above +# (`galileo/galileo.jl`) plus `crc24q`, `deinterleave`, and the generic `decode` +# framework hooks. +include("galileo/e5a.jl") + # GPS L1C-D (CNAV-2) decoder (issue #38). Included after the shared utilities # above because it consumes `crc24q`, `sync_bch_toi`, the BCH TOI table, and # the (de)interleaver, and after `gps/l1ca.jl` because it shares the generic diff --git a/src/galileo/e1b.jl b/src/galileo/e1b.jl index a3f3d56..30c1c42 100644 --- a/src/galileo/e1b.jl +++ b/src/galileo/e1b.jl @@ -2,18 +2,23 @@ # (250 syncro + 10 preamble) hard-sliced for the bit-pattern preamble check. BitIntegers.@define_integers 288 -# Galileo E1B uses the rate-1/2, constraint-length-7 (K=7) non-systematic -# convolutional (NSC) FEC with generator polynomials G1 = 0o171, G2 = 0o133 -# (Galileo OS SIS ICD, Issue 2.2, §4.1.4). After the 30×8 block deinterleave a -# page carries 240 encoded symbols which the Viterbi decoder maps back to 120 -# trellis steps: 114 information bits + 6 tail bits. AFF3CT's `ConvViterbiDecoder` -# is configured with K = 114, N = 240 and the same polynomials; it applies the -# trellis termination internally and returns exactly the 114 information bits -# (the 6 tail bits are consumed by termination and never surface), which is -# precisely the per-page payload the I/NAV parser expects. +# Galileo E1B uses the shared K=7 NSC FEC (`GALILEO_VITERBI_POLY`, see +# `galileo.jl`). After the 30×8 block deinterleave a page carries 240 encoded +# symbols which the Viterbi decoder maps back to 120 trellis steps: 114 +# information bits + 6 tail bits. AFF3CT's `ConvViterbiDecoder` is configured with +# K = 114, N = 240 and those polynomials; it applies the trellis termination +# internally and returns exactly the 114 information bits (the 6 tail bits are +# consumed by termination and never surface), which is precisely the per-page +# payload the I/NAV parser expects. The interleaver shape below is passed to the +# shared `galileo_viterbi` helper at decode time. const GALILEO_E1B_VITERBI_K = 114 const GALILEO_E1B_VITERBI_N = 240 -const GALILEO_E1B_VITERBI_POLY = [0o171, 0o133] + +# Block deinterleaver dimensions for E1B: the ICD interleaver is 8 rows × 30 +# columns; `deinterleave`'s first argument is the ICD column count and the second +# the ICD row count, so E1B uses `(30, 8)` (cf. E5a's `(61, 8)`). +const GALILEO_E1B_INTERLEAVER_ROWS = 30 +const GALILEO_E1B_INTERLEAVER_COLS = 8 """ GalileoE1BConstants @@ -43,144 +48,11 @@ Base.@kwdef struct GalileoE1BConstants <: AbstractGNSSConstants syncro_sequence_length::Int = 250 preamble::UInt16 = 0b0101100000 preamble_length::Int = 10 - PI::Float64 = 3.1415926535898 - Ω_dot_e::Float64 = 7.2921151467e-5 - c::Float64 = 2.99792458e8 - μ::Float64 = 3.986004418e14 - F::Float64 = -4.442807309e-10 -end - -""" - SignalHealth - -Galileo signal health status enumeration. - -Indicates the operational status of a Galileo signal component as broadcast in word type 5. - -# Values - - - `signal_ok`: Signal is operating normally (value 0) - - `signal_out_of_service`: Signal is out of service (value 1) - - `signal_in_extended_operations_mode`: Signal is in Extended Operations Mode (value 2) - - `signal_component_currently_in_test`: Signal component is currently in test (value 3) - -# Reference - -Galileo OS SIS ICD, Issue 2.2, Table 84 -""" -@enum SignalHealth begin - signal_ok - signal_out_of_service - signal_in_extended_operations_mode - signal_component_currently_in_test -end - -""" - DataValidityStatus - -Galileo navigation data validity status enumeration. - -Indicates whether the broadcast navigation data should be trusted for positioning. - -# Values - - - `navigation_data_valid`: Navigation data is valid (value 0) - - `working_without_guarantee`: Navigation data is working without guarantee (value 1) - -# Reference - -Galileo OS SIS ICD, Issue 2.2, Table 81 -""" -@enum DataValidityStatus begin - navigation_data_valid - working_without_guarantee -end - -""" - GalileoAlmanac - -Almanac data for one Galileo satellite, decoded from word types 7-10. - -The almanac provides reduced-precision orbital and clock parameters for predicting -satellite positions and selecting satellites for tracking. Differences (`Δsqrt_A`, -`δi`) are relative to nominal Galileo constellation values (`A_nominal = 29600.318 km`, -`i_nominal = 56°`). - -# Fields - - - `SVID::Int`: Satellite identifier (1-36 nominal range; 0 = unused entry) - - `Δsqrt_A::Float64`: Difference of √(semi-major axis) from nominal (√m) - - `e::Float64`: Eccentricity (dimensionless) - - `ω::Float64`: Argument of perigee (semi-circles) - - `δi::Float64`: Inclination delta from nominal (semi-circles) - - `Ω_0::Float64`: Longitude of ascending node at weekly epoch (semi-circles) - - `Ω_dot::Float64`: Rate of change of right ascension (semi-circles/s) - - `M_0::Float64`: Mean anomaly at reference time (semi-circles) - - `a_f0::Float64`: Truncated SV clock bias (seconds) - - `a_f1::Float64`: Truncated SV clock drift (s/s) - - `signal_health_e5b::SignalHealth`: Predicted E5b signal health status - - `signal_health_e1b::SignalHealth`: Predicted E1-B/C signal health status - - `IOD_a::Int`: Almanac IOD - - `WN_a::Int`: Almanac reference Week Number - - `t_0a::Int`: Almanac reference time (seconds) - -# Reference - -Galileo OS SIS ICD, Issue 2.2, Table 86 -""" -Base.@kwdef struct GalileoAlmanac - SVID::Union{Nothing,Int} = nothing - Δsqrt_A::Union{Nothing,Float64} = nothing - e::Union{Nothing,Float64} = nothing - ω::Union{Nothing,Float64} = nothing - δi::Union{Nothing,Float64} = nothing - Ω_0::Union{Nothing,Float64} = nothing - Ω_dot::Union{Nothing,Float64} = nothing - M_0::Union{Nothing,Float64} = nothing - a_f0::Union{Nothing,Float64} = nothing - a_f1::Union{Nothing,Float64} = nothing - signal_health_e5b::Union{Nothing,SignalHealth} = nothing - signal_health_e1b::Union{Nothing,SignalHealth} = nothing - IOD_a::Union{Nothing,Int} = nothing - WN_a::Union{Nothing,Int} = nothing - t_0a::Union{Nothing,Int} = nothing -end - -function GalileoAlmanac( - a::GalileoAlmanac; - SVID = a.SVID, - Δsqrt_A = a.Δsqrt_A, - e = a.e, - ω = a.ω, - δi = a.δi, - Ω_0 = a.Ω_0, - Ω_dot = a.Ω_dot, - M_0 = a.M_0, - a_f0 = a.a_f0, - a_f1 = a.a_f1, - signal_health_e5b = a.signal_health_e5b, - signal_health_e1b = a.signal_health_e1b, - IOD_a = a.IOD_a, - WN_a = a.WN_a, - t_0a = a.t_0a, -) - GalileoAlmanac( - SVID, - Δsqrt_A, - e, - ω, - δi, - Ω_0, - Ω_dot, - M_0, - a_f0, - a_f1, - signal_health_e5b, - signal_health_e1b, - IOD_a, - WN_a, - t_0a, - ) + PI::Float64 = GNSS_PI + Ω_dot_e::Float64 = EARTH_ROTATION_RATE + c::Float64 = SPEED_OF_LIGHT + μ::Float64 = GALILEO_μ + F::Float64 = GALILEO_F end """ @@ -275,7 +147,7 @@ GalileoE1BCache() = GalileoE1BCache( Aff3ct.ConvViterbiDecoder( GALILEO_E1B_VITERBI_K, GALILEO_E1B_VITERBI_N, - GALILEO_E1B_VITERBI_POLY, + GALILEO_VITERBI_POLY, ), ) @@ -802,22 +674,20 @@ order on the soft symbols: The 114 decoded bits are packed MSB-first into the low bits of a `UInt128`, matching the legacy hard-bit `parse(UInt128, ...; base = 2)` layout the parser consumes. + +Thin wrapper over the shared [`galileo_viterbi`](@ref) with E1B's 30×8 interleaver +shape and `UInt128` payload type. """ -function galileo_e1b_viterbi( +galileo_e1b_viterbi( decoder::Aff3ct.ConvViterbiDecoder, soft_page::AbstractVector{Float32}, +) = galileo_viterbi( + decoder, + soft_page, + GALILEO_E1B_INTERLEAVER_ROWS, + GALILEO_E1B_INTERLEAVER_COLS, + UInt128, ) - deinterleaved = deinterleave(soft_page, 30, 8) - @inbounds for i = 2:2:length(deinterleaved) - deinterleaved[i] = -deinterleaved[i] - end - info_bits = Aff3ct.decode(decoder, deinterleaved) - bits = UInt128(0) - @inbounds for b in info_bits - bits = (bits << 1) | UInt128(b) - end - return bits -end function decode_syncro_sequence(state::GNSSDecoderState{<:GalileoE1BData}, buffer) # The 240 encoded symbols are the soft-buffer entries between the leading diff --git a/src/galileo/e5a.jl b/src/galileo/e5a.jl new file mode 100644 index 0000000..4f535f9 --- /dev/null +++ b/src/galileo/e5a.jl @@ -0,0 +1,954 @@ +# UInt512 buffer for Galileo E5a F/NAV sync — holds the 512-symbol page window +# (500 syncro + 12 sync pattern) hard-sliced for the bit-pattern sync check. +BitIntegers.@define_integers 512 + +# Galileo E5a uses the shared K=7 NSC FEC (`GALILEO_VITERBI_POLY`, see +# `galileo.jl`) — the same code as E1B. After the 61×8 block deinterleave a F/NAV +# page carries 488 encoded symbols which the Viterbi decoder maps back to 244 +# trellis steps: 238 information bits + 6 tail bits. AFF3CT's `ConvViterbiDecoder` +# is configured with K = 238, N = 488 and those polynomials; it applies the +# trellis termination internally and returns exactly the 238 information bits (the +# 6 tail bits are consumed by termination), which is the complete F/NAV page +# payload (page type + data + CRC) the parser expects. Cross-checked against +# GNSS-SDR (`Galileo_FNAV.h`) and PocketSDR (`decode_gal_FNAV` / `decode_gal_syms`, +# reshape 8×61, `syms[1::2] ^= 1`). +const GALILEO_E5A_VITERBI_K = 238 +const GALILEO_E5A_VITERBI_N = 488 + +# Block deinterleaver dimensions for F/NAV: the ICD interleaver is 8 rows × 61 +# columns. As with E1B (passed as `(30, 8)`), `deinterleave`'s first argument is +# the ICD column count and the second the ICD row count, so F/NAV uses `(61, 8)`. +const GALILEO_E5A_INTERLEAVER_ROWS = 61 +const GALILEO_E5A_INTERLEAVER_COLS = 8 + +""" + GalileoE5aConstants + +GTRF constants and F/NAV message structure parameters for Galileo E5a signal decoding. + +The physical constants are defined in the Galileo OS SIS ICD (Open Service Signal-In-Space +Interface Control Document) and are used for computing satellite positions and clock +corrections from broadcast ephemeris data. + +# Fields + + - `syncro_sequence_length::Int`: Length of one F/NAV page in channel symbols (500 symbols = 10 s at 50 sps) + - `preamble::UInt16`: F/NAV synchronisation pattern (101101110000 binary) + - `preamble_length::Int`: Length of the sync pattern in symbols (12) + - `PI::Float64`: Mathematical constant π = 3.1415926535898 (Galileo OS SIS ICD Table 68) + - `Ω_dot_e::Float64`: Mean angular velocity of the Earth = 7.2921151467×10⁻⁵ rad/s + - `c::Float64`: Speed of light = 2.99792458×10⁸ m/s + - `μ::Float64`: Geocentric gravitational constant = 3.986004418×10¹⁴ m³/s² + - `F::Float64`: Relativistic correction constant = -4.442807309×10⁻¹⁰ s/√m + +# Reference + +Galileo OS SIS ICD, Issue 2.2, §4.2 and Table 68 +""" +Base.@kwdef struct GalileoE5aConstants <: AbstractGNSSConstants + syncro_sequence_length::Int = 500 + preamble::UInt16 = 0b101101110000 + preamble_length::Int = 12 + PI::Float64 = GNSS_PI + Ω_dot_e::Float64 = EARTH_ROTATION_RATE + c::Float64 = SPEED_OF_LIGHT + μ::Float64 = GALILEO_μ + F::Float64 = GALILEO_F +end + +""" + GalileoE5aData + +Decoded Galileo E5a F/NAV navigation message data. + +Contains ephemeris, clock correction, signal health, group delay, ionospheric +correction, GST-UTC and GST-GPS conversion, and almanac parameters decoded from +the Galileo F/NAV message (the data component broadcast on E5a-I). All parameters +conform to the Galileo OS SIS ICD, Issue 2.2, §5.1. + +Unlike I/NAV (E1B/E5b), F/NAV carries only the E5a signal-health (`E5a_HS`) and +data-validity (`E5a_DVS`) flags and a single broadcast group delay +(`BGD(E1, E5a)`); there is no Reduced CED and no E5b/E1-B field. Angular +quantities are stored in **radians** (the ICD broadcasts them in semi-circles; +the decoder multiplies by π), matching the convention used by [`GalileoE1BData`](@ref). + +# Galileo System Time (GST) Fields + + - `WN::Int64`: Week Number (0-4095) + - `TOW::Int64`: Time of Week at the start of the page (seconds, 0-604799) + +# Satellite Identification (Word Type 1) + + - `SVID::Int`: Satellite Identifier (1-36 nominal range) + +# Ephemeris Parameters (Word Types 2-4) + + - `t_0e::Float64`: Ephemeris reference time (seconds) + - `M_0::Float64`: Mean anomaly at reference time (radians) + - `e::Float64`: Eccentricity (dimensionless) + - `sqrt_A::Float64`: Square root of semi-major axis (√m) + - `Ω_0::Float64`: Longitude of ascending node at weekly epoch (radians) + - `i_0::Float64`: Inclination angle at reference time (radians) + - `ω::Float64`: Argument of perigee (radians) + - `i_dot::Float64`: Rate of change of inclination angle (radians/s) + - `Ω_dot::Float64`: Rate of change of right ascension (radians/s) + - `Δn::Float64`: Mean motion difference from computed value (radians/s) + - `C_uc::Float64`: Cosine harmonic correction to argument of latitude (rad) + - `C_us::Float64`: Sine harmonic correction to argument of latitude (rad) + - `C_rc::Float64`: Cosine harmonic correction to orbit radius (meters) + - `C_rs::Float64`: Sine harmonic correction to orbit radius (meters) + - `C_ic::Float64`: Cosine harmonic correction to inclination (rad) + - `C_is::Float64`: Sine harmonic correction to inclination (rad) + +# Signal-In-Space Accuracy (Word Type 1) + + - `SISA_e1_e5a::Int`: SISA index for dual frequency E1-E5a (Table 91/92; 255 = NAPA) + +# Clock Correction Parameters (Word Type 1) + + - `t_0c::Float64`: Clock correction reference time (seconds) + - `a_f0::Float64`: SV clock bias correction coefficient (seconds) + - `a_f1::Float64`: SV clock drift correction coefficient (s/s) + - `a_f2::Float64`: SV clock drift rate correction coefficient (s/s²) + +# Issue of Data (Word Types 1-4) + + - `IOD_nav1::UInt`: Issue of Data from word type 1 (10-bit) + - `IOD_nav2::UInt`: Issue of Data from word type 2 (10-bit) + - `IOD_nav3::UInt`: Issue of Data from word type 3 (10-bit) + - `IOD_nav4::UInt`: Issue of Data from word type 4 (10-bit) + - `num_pages_after_last_TOW::Int`: Pages decoded since last TOW update + - `num_bits_after_valid_syncro_sequence_after_last_TOW::Int`: Symbols since last TOW sync + +# Signal Health and Data Validity (Word Type 1) + + - `signal_health_e5a::SignalHealth`: E5a signal health status (0=OK, 1=out of service, 2=Extended Operations Mode, 3=in test) + - `data_validity_status_e5a::DataValidityStatus`: E5a data validity (0=valid, 1=working without guarantee) + +# Broadcast Group Delay (Word Type 1) + + - `broadcast_group_delay_e1_e5a::Float64`: E1-E5a group delay correction (seconds) + +# Ionospheric Correction (Word Type 1) + + - `a_i0::Float64`: Effective Ionisation Level 1st-order coefficient (sfu) + - `a_i1::Float64`: Effective Ionisation Level 2nd-order coefficient (sfu/degree) + - `a_i2::Float64`: Effective Ionisation Level 3rd-order coefficient (sfu/degree²) + - `iono_storm_flag_region1..5::Bool`: Ionospheric Disturbance (storm) flags for regions 1-5 + +# GST-UTC Conversion (Word Type 4) + + - `A_0_utc::Float64`: Constant term of polynomial (s) + - `A_1_utc::Float64`: 1st-order term of polynomial (s/s) + - `Δt_LS::Int`: Leap Second count before leap second adjustment (s) + - `t_0t::Int`: UTC data reference Time of Week (s) + - `WN_0t::Int`: UTC data reference Week Number (8-bit, modulo 256) + - `WN_LSF::Int`: Week Number of leap second adjustment (8-bit, modulo 256) + - `DN::Int`: Day Number at end of which leap second becomes effective (1=Sunday … 7=Saturday) + - `Δt_LSF::Int`: Leap Second count after leap second adjustment (s) + +# GST-GPS Conversion / GGTO (Word Type 4) + + - `A_0G::Float64`: Constant term of GST-GPS offset polynomial (s) + - `A_1G::Float64`: Rate of change of GST-GPS offset (s/s) + - `t_0G::Int`: GGTO reference time (s) + - `WN_0G::Int`: GGTO reference Week Number (6-bit) + +# Almanac (Word Types 5-6) + + - `almanacs::Dictionary{Int,GalileoAlmanac}`: Decoded almanacs keyed by SVID. + Galileo broadcasts three almanacs across the word-type-5/6 pair: SVID-1 (full + in WT5), SVID-2 (split across WT5 and WT6), and SVID-3 (full in WT6). The + in-flight SVID-2 partial lives in the decoder cache and is flushed here only + once WT6 completes it with a consistent `IOD_a`. F/NAV almanacs carry the E5a + health (`signal_health_e5a`); the E5b/E1-B almanac-health fields are left + `nothing`. + +# Reference + +Galileo OS SIS ICD, Issue 2.2, §5.1, Tables 75-80 +""" +Base.@kwdef struct GalileoE5aData <: AbstractGNSSData + WN::Union{Nothing,Int64} = nothing + TOW::Union{Nothing,Int64} = nothing + + SVID::Union{Nothing,Int} = nothing + + t_0e::Union{Nothing,Float64} = nothing + M_0::Union{Nothing,Float64} = nothing + e::Union{Nothing,Float64} = nothing + sqrt_A::Union{Nothing,Float64} = nothing + Ω_0::Union{Nothing,Float64} = nothing + i_0::Union{Nothing,Float64} = nothing + ω::Union{Nothing,Float64} = nothing + i_dot::Union{Nothing,Float64} = nothing + Ω_dot::Union{Nothing,Float64} = nothing + Δn::Union{Nothing,Float64} = nothing + C_uc::Union{Nothing,Float64} = nothing + C_us::Union{Nothing,Float64} = nothing + C_rc::Union{Nothing,Float64} = nothing + C_rs::Union{Nothing,Float64} = nothing + C_ic::Union{Nothing,Float64} = nothing + C_is::Union{Nothing,Float64} = nothing + + SISA_e1_e5a::Union{Nothing,Int} = nothing + + t_0c::Union{Nothing,Float64} = nothing + a_f0::Union{Nothing,Float64} = nothing + a_f1::Union{Nothing,Float64} = nothing + a_f2::Union{Nothing,Float64} = nothing + + IOD_nav1::Union{Nothing,UInt} = nothing + IOD_nav2::Union{Nothing,UInt} = nothing + IOD_nav3::Union{Nothing,UInt} = nothing + IOD_nav4::Union{Nothing,UInt} = nothing + num_pages_after_last_TOW::Int = 0 + num_bits_after_valid_syncro_sequence_after_last_TOW::Union{Nothing,Int} = nothing + + signal_health_e5a::Union{Nothing,SignalHealth} = nothing + data_validity_status_e5a::Union{Nothing,DataValidityStatus} = nothing + + broadcast_group_delay_e1_e5a::Union{Nothing,Float64} = nothing + + a_i0::Union{Nothing,Float64} = nothing + a_i1::Union{Nothing,Float64} = nothing + a_i2::Union{Nothing,Float64} = nothing + iono_storm_flag_region1::Union{Nothing,Bool} = nothing + iono_storm_flag_region2::Union{Nothing,Bool} = nothing + iono_storm_flag_region3::Union{Nothing,Bool} = nothing + iono_storm_flag_region4::Union{Nothing,Bool} = nothing + iono_storm_flag_region5::Union{Nothing,Bool} = nothing + + A_0_utc::Union{Nothing,Float64} = nothing + A_1_utc::Union{Nothing,Float64} = nothing + Δt_LS::Union{Nothing,Int} = nothing + t_0t::Union{Nothing,Int} = nothing + WN_0t::Union{Nothing,Int} = nothing + WN_LSF::Union{Nothing,Int} = nothing + DN::Union{Nothing,Int} = nothing + Δt_LSF::Union{Nothing,Int} = nothing + + A_0G::Union{Nothing,Float64} = nothing + A_1G::Union{Nothing,Float64} = nothing + t_0G::Union{Nothing,Int} = nothing + WN_0G::Union{Nothing,Int} = nothing + + almanacs::Union{Nothing,Dictionary{Int,GalileoAlmanac}} = nothing +end + +function GalileoE5aData( + data::GalileoE5aData; + WN = data.WN, + TOW = data.TOW, + SVID = data.SVID, + t_0e = data.t_0e, + M_0 = data.M_0, + e = data.e, + sqrt_A = data.sqrt_A, + Ω_0 = data.Ω_0, + i_0 = data.i_0, + ω = data.ω, + i_dot = data.i_dot, + Ω_dot = data.Ω_dot, + Δn = data.Δn, + C_uc = data.C_uc, + C_us = data.C_us, + C_rc = data.C_rc, + C_rs = data.C_rs, + C_ic = data.C_ic, + C_is = data.C_is, + SISA_e1_e5a = data.SISA_e1_e5a, + t_0c = data.t_0c, + a_f0 = data.a_f0, + a_f1 = data.a_f1, + a_f2 = data.a_f2, + IOD_nav1 = data.IOD_nav1, + IOD_nav2 = data.IOD_nav2, + IOD_nav3 = data.IOD_nav3, + IOD_nav4 = data.IOD_nav4, + num_pages_after_last_TOW = data.num_pages_after_last_TOW, + num_bits_after_valid_syncro_sequence_after_last_TOW = data.num_bits_after_valid_syncro_sequence_after_last_TOW, + signal_health_e5a = data.signal_health_e5a, + data_validity_status_e5a = data.data_validity_status_e5a, + broadcast_group_delay_e1_e5a = data.broadcast_group_delay_e1_e5a, + a_i0 = data.a_i0, + a_i1 = data.a_i1, + a_i2 = data.a_i2, + iono_storm_flag_region1 = data.iono_storm_flag_region1, + iono_storm_flag_region2 = data.iono_storm_flag_region2, + iono_storm_flag_region3 = data.iono_storm_flag_region3, + iono_storm_flag_region4 = data.iono_storm_flag_region4, + iono_storm_flag_region5 = data.iono_storm_flag_region5, + A_0_utc = data.A_0_utc, + A_1_utc = data.A_1_utc, + Δt_LS = data.Δt_LS, + t_0t = data.t_0t, + WN_0t = data.WN_0t, + WN_LSF = data.WN_LSF, + DN = data.DN, + Δt_LSF = data.Δt_LSF, + A_0G = data.A_0G, + A_1G = data.A_1G, + t_0G = data.t_0G, + WN_0G = data.WN_0G, + almanacs = data.almanacs, +) + GalileoE5aData( + WN, + TOW, + SVID, + t_0e, + M_0, + e, + sqrt_A, + Ω_0, + i_0, + ω, + i_dot, + Ω_dot, + Δn, + C_uc, + C_us, + C_rc, + C_rs, + C_ic, + C_is, + SISA_e1_e5a, + t_0c, + a_f0, + a_f1, + a_f2, + IOD_nav1, + IOD_nav2, + IOD_nav3, + IOD_nav4, + num_pages_after_last_TOW, + num_bits_after_valid_syncro_sequence_after_last_TOW, + signal_health_e5a, + data_validity_status_e5a, + broadcast_group_delay_e1_e5a, + a_i0, + a_i1, + a_i2, + iono_storm_flag_region1, + iono_storm_flag_region2, + iono_storm_flag_region3, + iono_storm_flag_region4, + iono_storm_flag_region5, + A_0_utc, + A_1_utc, + Δt_LS, + t_0t, + WN_0t, + WN_LSF, + DN, + Δt_LSF, + A_0G, + A_1G, + t_0G, + WN_0G, + almanacs, + ) +end + +# As with GalileoE1BData, the mutable `almanacs::Dictionary` field makes the +# default struct `==` (which falls back to `===`) too strict. Compare field-by-field. +function Base.:(==)(a::GalileoE5aData, b::GalileoE5aData) + for f in fieldnames(GalileoE5aData) + getfield(a, f) == getfield(b, f) || return false + end + return true +end + +function is_ephemeris_decoded(data::GalileoE5aData) + !isnothing(data.t_0e) && + !isnothing(data.M_0) && + !isnothing(data.e) && + !isnothing(data.sqrt_A) && + !isnothing(data.Ω_0) && + !isnothing(data.i_0) && + !isnothing(data.ω) && + !isnothing(data.i_dot) && + !isnothing(data.Ω_dot) && + !isnothing(data.Δn) && + !isnothing(data.C_uc) && + !isnothing(data.C_us) && + !isnothing(data.C_rc) && + !isnothing(data.C_rs) && + !isnothing(data.C_ic) && + !isnothing(data.C_is) +end + +function is_clock_correction_decoded(data::GalileoE5aData) + !isnothing(data.t_0c) && + !isnothing(data.a_f0) && + !isnothing(data.a_f1) && + !isnothing(data.a_f2) +end + +function is_health_status_decoded(data::GalileoE5aData) + !isnothing(data.signal_health_e5a) && !isnothing(data.data_validity_status_e5a) +end + +function is_decoding_completed_for_positioning(data::GalileoE5aData) + !isnothing(data.TOW) && + !isnothing(data.WN) && + !isnothing(data.broadcast_group_delay_e1_e5a) && + is_ephemeris_decoded(data) && + is_clock_correction_decoded(data) && + is_health_status_decoded(data) +end + +""" +$(TYPEDEF) + +Per-decoder cache for Galileo E5a F/NAV. + +Holds the soft-symbol `CircularDeque{Float32}` (capacity = 500 + 12 = 512), the +in-flight almanac partial used to stitch SVID-2's halves across word types 5 and +6 (plus its 4-bit `Ω_0` MSB, which WT6 completes with a 12-bit LSB), and the +long-lived AFF3CT Viterbi decoder. Unlike I/NAV there is no even/odd page +stitching: each F/NAV page is a complete, independently CRC-protected word. + +The decoder consumes *soft symbols* end-to-end: the sync hook hard-slices the +deque tail only for the 12-symbol sync-pattern match, while the K=7 NSC FEC is +undone on the raw `Float32` LLRs via AFF3CT.jl's `ConvViterbiDecoder`. The +deque-backed input boundary is identical to L1 C/A and E1B so the public API is +uniform. + +# Fields + +$(TYPEDFIELDS) +""" +struct GalileoE5aCache <: AbstractGNSSCache + """ + Soft-symbol buffer (512 = 500 syncro + 12 sync pattern) + """ + soft_buffer::CircularDeque{Float32} + """ + SVID-2 almanac partial decoded from word type 5, completed by word type 6. + """ + almanac_chain_partial::GalileoAlmanac + """ + SVID-2 `Ω_0` most-significant 4 bits from word type 5 (LSB-aligned), combined + with the 12 LSBs in word type 6. `nothing` when no WT5 partial is in flight. + """ + almanac_chain_omega0_msb::Union{Nothing,Int} + """ + AFF3CT K=7 NSC Viterbi decoder, built once and reused across pages. + """ + viterbi_decoder::Aff3ct.ConvViterbiDecoder +end + +GalileoE5aCache() = GalileoE5aCache( + CircularDeque{Float32}(512), + GalileoAlmanac(), + nothing, + Aff3ct.ConvViterbiDecoder( + GALILEO_E5A_VITERBI_K, + GALILEO_E5A_VITERBI_N, + GALILEO_VITERBI_POLY, + ), +) + +function GalileoE5aCache( + cache::GalileoE5aCache; + soft_buffer = cache.soft_buffer, + almanac_chain_partial = cache.almanac_chain_partial, + almanac_chain_omega0_msb = cache.almanac_chain_omega0_msb, + viterbi_decoder = cache.viterbi_decoder, +) + GalileoE5aCache( + soft_buffer, + almanac_chain_partial, + almanac_chain_omega0_msb, + viterbi_decoder, + ) +end + +function Base.:(==)(a::GalileoE5aCache, b::GalileoE5aCache) + deques_equal(a.soft_buffer, b.soft_buffer) && + a.almanac_chain_partial == b.almanac_chain_partial && + a.almanac_chain_omega0_msb == b.almanac_chain_omega0_msb +end + +""" +$(TYPEDSIGNATURES) + +Create a decoder state for Galileo E5a F/NAV navigation messages. + +Initializes a [`GNSSDecoderState`](@ref) configured for decoding Galileo E5a +(Open Service) F/NAV navigation messages. The decoder extracts ephemeris, clock +correction, ionospheric parameters, almanac, and health data from the 50 sps +F/NAV data stream broadcast on the E5a-I component using Viterbi decoding. + +# Arguments + + - `prn::Int`: Pseudo-Random Noise code identifier (1-36 for Galileo satellites) + +# Returns + + - `GNSSDecoderState{GalileoE5aData}`: Initialized decoder state for Galileo E5a + +# Example + +```julia +state = GalileoE5aDecoderState(21) # Create decoder for PRN 21 +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 + - [`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 GalileoE5aDecoderState(prn) + GNSSDecoderState( + prn, + GalileoE5aData(), + GalileoE5aData(), + GalileoE5aConstants(), + GalileoE5aCache(), + nothing, + false, + ) +end + +# Dispatch from a GNSSSignals system type, mirroring `GNSSDecoderState(::GalileoE1B, …)`. +# F/NAV is broadcast on the E5a-I (data) component — `GalileoE5aI` — while `GalileoE5aQ` +# is the dataless pilot, so only E5a-I maps to this decoder. +function GNSSDecoderState(system::GalileoE5aI, prn) + GalileoE5aDecoderState(prn) +end + +""" +$(TYPEDSIGNATURES) + +Reset the Galileo E5a decoder state after a signal loss or reacquisition. + +Clears the soft-symbol buffer and the time-of-week (TOW) field while preserving +other decoded ephemeris and clock data in `raw_data`, mirroring the E1B reset +semantics. The week number (`WN`) is intentionally not reset (it is broadcast +less frequently than TOW). +""" +function reset_decoder_state(state::GNSSDecoderState{<:GalileoE5aData}) + empty!(state.cache.soft_buffer) + GNSSDecoderState( + state; + raw_data = GalileoE5aData( + state.raw_data; + TOW = nothing, + num_bits_after_valid_syncro_sequence_after_last_TOW = nothing, + ), + data = GalileoE5aData(), + num_bits_after_valid_syncro_sequence = nothing, + ) +end + +packed_buffer_type(::GNSSDecoderState{<:GalileoE5aData}) = UInt512 + +""" + galileo_e5a_viterbi(decoder, soft_page) -> UInt256 + +Recover one F/NAV page's 238-bit payload from its `soft_page` — the 488 +polarity-corrected `Float32` LLR soft symbols of a Galileo E5a page (the +`syncro_sequence_length - preamble_length` encoded symbols between the leading +and trailing 12-symbol sync patterns). `decoder` is the cache's long-lived +`Aff3ct.ConvViterbiDecoder`, reused across pages. + +The transmit FEC chain (Galileo OS SIS ICD, Issue 2.2, §4.1.4 / §4.2.5) is undone +in order on the soft symbols: + + 1. **61×8 block deinterleave** of the 488 LLRs (`deinterleave` from `src/deinterleave.jl`). + 2. **Invert every second symbol** — the spec inverts the G2 output of the rate-1/2 + encoder. On soft symbols an inversion is a sign flip (negation), so confidence + magnitudes are preserved. + 3. **K=7 NSC Viterbi** via AFF3CT.jl's `ConvViterbiDecoder`. AFF3CT's LLR sign + convention matches ours (positive ⇒ bit 0), so the LLRs feed in directly. The + decoder returns the 238 information bits (the 6 tail bits are consumed by + trellis termination). + +The 238 decoded bits are packed MSB-first into the low bits of a `UInt256`, ready +for `get_bits`/`get_twos_complement_num` field extraction. + +Thin wrapper over the shared [`galileo_viterbi`](@ref) with E5a's 61×8 interleaver +shape and `UInt256` payload type. +""" +galileo_e5a_viterbi( + decoder::Aff3ct.ConvViterbiDecoder, + soft_page::AbstractVector{Float32}, +) = galileo_viterbi( + decoder, + soft_page, + GALILEO_E5A_INTERLEAVER_ROWS, + GALILEO_E5A_INTERLEAVER_COLS, + UInt256, +) + +# Combine SVID-2's split right-ascension: WT5 carries the 4 MSBs, WT6 the 12 LSBs, +# of a 16-bit two's-complement value scaled by π·2⁻¹⁵ (semicircles → radians). +function combine_almanac_omega0(msb::Int, lsb::Int, PI::Float64) + raw = (UInt16(msb) << 12) | UInt16(lsb) + return get_twos_complement_num(raw, 16, 1, 16) * PI / (1 << 15) +end + +function decode_syncro_sequence(state::GNSSDecoderState{<:GalileoE5aData}, buffer) + # The 488 encoded symbols sit between the leading 12-symbol sync pattern and + # the trailing sync pattern of the next page (deque indices + # preamble_length+1 .. syncro_sequence_length). Resolve the 180-degree + # polarity ambiguity by negating the LLRs when the sync hook flagged the page + # as inverted. + deque = soft_buffer(state) + sign = state.is_shifted_by_180_degrees ? -1.0f0 : 1.0f0 + soft_page = Vector{Float32}(undef, GALILEO_E5A_VITERBI_N) + @inbounds for i = 1:GALILEO_E5A_VITERBI_N + soft_page[i] = sign * deque[state.constants.preamble_length+i] + end + bits = galileo_e5a_viterbi(state.cache.viterbi_decoder, soft_page) + + state = GNSSDecoderState( + state; + raw_data = GalileoE5aData( + state.raw_data; + num_pages_after_last_TOW = state.raw_data.num_pages_after_last_TOW + 1, + ), + ) + + # F/NAV CRC-24Q is computed over the 214-bit (page type + data) prefix and + # appended as bits 215-238; a clean page satisfies crc24q(all 238 bits) == 0. + crc_bits = Vector{Bool}(undef, GALILEO_E5A_VITERBI_K) + @inbounds for i = 1:GALILEO_E5A_VITERBI_K + crc_bits[i] = get_bit(bits, GALILEO_E5A_VITERBI_K, i) + end + crc24q(crc_bits) == 0 || return state + + PI = state.constants.PI + page_type = get_bits(bits, GALILEO_E5A_VITERBI_K, 1, 6) + + if page_type == 1 + SVID = Int(get_bits(bits, 238, 7, 6)) + IOD_nav1 = get_bits(bits, 238, 13, 10) + t_0c = get_bits(bits, 238, 23, 14) * 60 + a_f0 = get_twos_complement_num(bits, 238, 37, 31) / (1 << 34) + a_f1 = get_twos_complement_num(bits, 238, 68, 21) / (1 << 46) + a_f2 = get_twos_complement_num(bits, 238, 89, 6) / Float64(1 << 59) + SISA_e1_e5a = Int(get_bits(bits, 238, 95, 8)) + a_i0 = get_bits(bits, 238, 103, 11) / (1 << 2) + a_i1 = get_twos_complement_num(bits, 238, 114, 11) / (1 << 8) + a_i2 = get_twos_complement_num(bits, 238, 125, 14) / (1 << 15) + iono_storm_flag_region1 = get_bit(bits, 238, 139) + iono_storm_flag_region2 = get_bit(bits, 238, 140) + iono_storm_flag_region3 = get_bit(bits, 238, 141) + iono_storm_flag_region4 = get_bit(bits, 238, 142) + iono_storm_flag_region5 = get_bit(bits, 238, 143) + broadcast_group_delay_e1_e5a = + get_twos_complement_num(bits, 238, 144, 10) / Float64(1 << 32) + signal_health_e5a = SignalHealth(get_bits(bits, 238, 154, 2)) + WN = get_bits(bits, 238, 156, 12) + TOW = get_bits(bits, 238, 168, 20) + data_validity_status_e5a = DataValidityStatus(get_bit(bits, 238, 188)) + state = GNSSDecoderState( + state; + raw_data = GalileoE5aData( + state.raw_data; + SVID, + IOD_nav1, + t_0c, + a_f0, + a_f1, + a_f2, + SISA_e1_e5a, + a_i0, + a_i1, + a_i2, + iono_storm_flag_region1, + iono_storm_flag_region2, + iono_storm_flag_region3, + iono_storm_flag_region4, + iono_storm_flag_region5, + broadcast_group_delay_e1_e5a, + signal_health_e5a, + data_validity_status_e5a, + WN, + TOW, + num_pages_after_last_TOW = 1, + num_bits_after_valid_syncro_sequence_after_last_TOW = state.num_bits_after_valid_syncro_sequence, + ), + ) + elseif page_type == 2 + IOD_nav2 = get_bits(bits, 238, 7, 10) + M_0 = get_twos_complement_num(bits, 238, 17, 32) * PI / (1 << 31) + Ω_dot = get_twos_complement_num(bits, 238, 49, 24) * PI / Float64(1 << 43) + e = get_bits(bits, 238, 73, 32) / Float64(1 << 33) + sqrt_A = get_bits(bits, 238, 105, 32) / (1 << 19) + Ω_0 = get_twos_complement_num(bits, 238, 137, 32) * PI / (1 << 31) + i_dot = get_twos_complement_num(bits, 238, 169, 14) * PI / Float64(1 << 43) + WN = get_bits(bits, 238, 183, 12) + TOW = get_bits(bits, 238, 195, 20) + state = GNSSDecoderState( + state; + raw_data = GalileoE5aData( + state.raw_data; + IOD_nav2, + M_0, + Ω_dot, + e, + sqrt_A, + Ω_0, + i_dot, + WN, + TOW, + num_pages_after_last_TOW = 1, + num_bits_after_valid_syncro_sequence_after_last_TOW = state.num_bits_after_valid_syncro_sequence, + ), + ) + elseif page_type == 3 + IOD_nav3 = get_bits(bits, 238, 7, 10) + i_0 = get_twos_complement_num(bits, 238, 17, 32) * PI / (1 << 31) + ω = get_twos_complement_num(bits, 238, 49, 32) * PI / (1 << 31) + Δn = get_twos_complement_num(bits, 238, 81, 16) * PI / Float64(1 << 43) + C_uc = get_twos_complement_num(bits, 238, 97, 16) / Float64(1 << 29) + C_us = get_twos_complement_num(bits, 238, 113, 16) / Float64(1 << 29) + C_rc = get_twos_complement_num(bits, 238, 129, 16) / (1 << 5) + C_rs = get_twos_complement_num(bits, 238, 145, 16) / (1 << 5) + t_0e = get_bits(bits, 238, 161, 14) * 60 + WN = get_bits(bits, 238, 175, 12) + TOW = get_bits(bits, 238, 187, 20) + state = GNSSDecoderState( + state; + raw_data = GalileoE5aData( + state.raw_data; + IOD_nav3, + i_0, + ω, + Δn, + C_uc, + C_us, + C_rc, + C_rs, + t_0e, + WN, + TOW, + num_pages_after_last_TOW = 1, + num_bits_after_valid_syncro_sequence_after_last_TOW = state.num_bits_after_valid_syncro_sequence, + ), + ) + elseif page_type == 4 + IOD_nav4 = get_bits(bits, 238, 7, 10) + C_ic = get_twos_complement_num(bits, 238, 17, 16) / Float64(1 << 29) + C_is = get_twos_complement_num(bits, 238, 33, 16) / Float64(1 << 29) + A_0_utc = get_twos_complement_num(bits, 238, 49, 32) / Float64(1 << 30) + A_1_utc = get_twos_complement_num(bits, 238, 81, 24) / Float64(1 << 50) + Δt_LS = Int(get_twos_complement_num(bits, 238, 105, 8)) + t_0t = Int(get_bits(bits, 238, 113, 8) * 3600) + WN_0t = Int(get_bits(bits, 238, 121, 8)) + WN_LSF = Int(get_bits(bits, 238, 129, 8)) + DN = Int(get_bits(bits, 238, 137, 3)) + Δt_LSF = Int(get_twos_complement_num(bits, 238, 140, 8)) + t_0G = Int(get_bits(bits, 238, 148, 8) * 3600) + A_0G = get_twos_complement_num(bits, 238, 156, 16) / Float64(1 << 35) + A_1G = get_twos_complement_num(bits, 238, 172, 12) / Float64(1 << 51) + WN_0G = Int(get_bits(bits, 238, 184, 6)) + TOW = get_bits(bits, 238, 190, 20) + state = GNSSDecoderState( + state; + raw_data = GalileoE5aData( + state.raw_data; + IOD_nav4, + C_ic, + C_is, + A_0_utc, + A_1_utc, + Δt_LS, + t_0t, + WN_0t, + WN_LSF, + DN, + Δt_LSF, + t_0G, + A_0G, + A_1G, + WN_0G, + TOW, + num_pages_after_last_TOW = 1, + num_bits_after_valid_syncro_sequence_after_last_TOW = state.num_bits_after_valid_syncro_sequence, + ), + ) + elseif page_type == 5 + IOD_a = Int(get_bits(bits, 238, 7, 4)) + WN_a = Int(get_bits(bits, 238, 11, 2)) + t_0a = Int(get_bits(bits, 238, 13, 10) * 600) + # SVID-1: fully contained in word type 5 → flush immediately. + SVID1 = Int(get_bits(bits, 238, 23, 6)) + almanac1 = GalileoAlmanac(; + SVID = SVID1, + Δsqrt_A = get_twos_complement_num(bits, 238, 29, 13) / (1 << 9), + e = get_bits(bits, 238, 42, 11) / (1 << 16), + ω = get_twos_complement_num(bits, 238, 53, 16) * PI / (1 << 15), + δi = get_twos_complement_num(bits, 238, 69, 11) * PI / (1 << 14), + Ω_0 = get_twos_complement_num(bits, 238, 80, 16) * PI / (1 << 15), + Ω_dot = get_twos_complement_num(bits, 238, 96, 11) * PI / Float64(1 << 33), + M_0 = get_twos_complement_num(bits, 238, 107, 16) * PI / (1 << 15), + a_f0 = get_twos_complement_num(bits, 238, 123, 16) / Float64(1 << 19), + a_f1 = get_twos_complement_num(bits, 238, 139, 13) / Float64(1 << 38), + signal_health_e5a = SignalHealth(get_bits(bits, 238, 152, 2)), + IOD_a, + WN_a, + t_0a, + ) + # SVID-2: first half (orbital shape + Ω_0 MSB) in word type 5; the + # remainder arrives in word type 6. + SVID2 = Int(get_bits(bits, 238, 154, 6)) + almanac2_partial = GalileoAlmanac(; + SVID = SVID2, + Δsqrt_A = get_twos_complement_num(bits, 238, 160, 13) / (1 << 9), + e = get_bits(bits, 238, 173, 11) / (1 << 16), + ω = get_twos_complement_num(bits, 238, 184, 16) * PI / (1 << 15), + δi = get_twos_complement_num(bits, 238, 200, 11) * PI / (1 << 14), + IOD_a, + WN_a, + t_0a, + ) + omega0_msb = Int(get_bits(bits, 238, 211, 4)) + + almanacs = state.raw_data.almanacs + if SVID1 >= 1 + almanacs = + isnothing(almanacs) ? Dictionary{Int,GalileoAlmanac}() : copy(almanacs) + set!(almanacs, SVID1, almanac1) + end + valid_SVID2 = SVID2 >= 1 + state = GNSSDecoderState( + state; + raw_data = GalileoE5aData(state.raw_data; almanacs), + cache = GalileoE5aCache( + state.cache; + almanac_chain_partial = valid_SVID2 ? almanac2_partial : GalileoAlmanac(), + almanac_chain_omega0_msb = valid_SVID2 ? omega0_msb : nothing, + ), + ) + elseif page_type == 6 + IOD_a = Int(get_bits(bits, 238, 7, 4)) + # SVID-2 completion: combine the WT5 Ω_0 MSBs with the WT6 LSBs and add + # the remaining orbital/clock/health terms. Only flush if the WT5 partial + # is intact and its IOD_a matches. + omega0_lsb = Int(get_bits(bits, 238, 11, 12)) + partial = state.cache.almanac_chain_partial + msb = state.cache.almanac_chain_omega0_msb + almanacs = state.raw_data.almanacs + if !isnothing(msb) && !isnothing(partial.SVID) && partial.IOD_a == IOD_a + completed2 = GalileoAlmanac( + partial; + Ω_0 = combine_almanac_omega0(msb, omega0_lsb, PI), + Ω_dot = get_twos_complement_num(bits, 238, 23, 11) * PI / Float64(1 << 33), + M_0 = get_twos_complement_num(bits, 238, 34, 16) * PI / (1 << 15), + a_f0 = get_twos_complement_num(bits, 238, 50, 16) / Float64(1 << 19), + a_f1 = get_twos_complement_num(bits, 238, 66, 13) / Float64(1 << 38), + signal_health_e5a = SignalHealth(get_bits(bits, 238, 79, 2)), + ) + almanacs = + isnothing(almanacs) ? Dictionary{Int,GalileoAlmanac}() : copy(almanacs) + set!(almanacs, completed2.SVID, completed2) + end + # SVID-3: orbital/clock/health are fully contained in word type 6, but its + # almanac reference epoch (WN_a, t_0a) is broadcast only in the paired word + # type 5. Inherit them from the cached WT5 partial when its IOD_a matches. + SVID3 = Int(get_bits(bits, 238, 81, 6)) + if SVID3 >= 1 + shared_wn_a, shared_t_0a = + (!isnothing(partial.SVID) && partial.IOD_a == IOD_a) ? + (partial.WN_a, partial.t_0a) : (nothing, nothing) + almanac3 = GalileoAlmanac(; + SVID = SVID3, + Δsqrt_A = get_twos_complement_num(bits, 238, 87, 13) / (1 << 9), + e = get_bits(bits, 238, 100, 11) / (1 << 16), + ω = get_twos_complement_num(bits, 238, 111, 16) * PI / (1 << 15), + δi = get_twos_complement_num(bits, 238, 127, 11) * PI / (1 << 14), + Ω_0 = get_twos_complement_num(bits, 238, 138, 16) * PI / (1 << 15), + Ω_dot = get_twos_complement_num(bits, 238, 154, 11) * PI / Float64(1 << 33), + M_0 = get_twos_complement_num(bits, 238, 165, 16) * PI / (1 << 15), + a_f0 = get_twos_complement_num(bits, 238, 181, 16) / Float64(1 << 19), + a_f1 = get_twos_complement_num(bits, 238, 197, 13) / Float64(1 << 38), + signal_health_e5a = SignalHealth(get_bits(bits, 238, 210, 2)), + IOD_a, + WN_a = shared_wn_a, + t_0a = shared_t_0a, + ) + almanacs = + isnothing(almanacs) ? Dictionary{Int,GalileoAlmanac}() : copy(almanacs) + set!(almanacs, SVID3, almanac3) + end + state = GNSSDecoderState( + state; + raw_data = GalileoE5aData(state.raw_data; almanacs), + cache = GalileoE5aCache( + state.cache; + almanac_chain_partial = GalileoAlmanac(), + almanac_chain_omega0_msb = nothing, + ), + ) + end + return state +end + +function validate_data(state::GNSSDecoderState{<:GalileoE5aData}) + if is_decoding_completed_for_positioning(state.raw_data) && + state.raw_data.IOD_nav1 == + state.raw_data.IOD_nav2 == + state.raw_data.IOD_nav3 == + state.raw_data.IOD_nav4 + num_bits_after_valid_syncro_sequence = 0 + if state.data.TOW == state.raw_data.TOW + num_bits_after_valid_syncro_sequence = + state.num_bits_after_valid_syncro_sequence + elseif !isnothing( + state.raw_data.num_bits_after_valid_syncro_sequence_after_last_TOW, + ) + # Re-reference the symbol counter to the page that carried the most + # recent TOW. A F/NAV word is exactly one page (syncro_sequence_length + # symbols + the leading sync pattern). + num_bits_after_valid_syncro_sequence = + state.num_bits_after_valid_syncro_sequence - ( + state.raw_data.num_bits_after_valid_syncro_sequence_after_last_TOW - + state.constants.syncro_sequence_length - + state.constants.preamble_length + ) + else # first successful decoding + num_bits_after_valid_syncro_sequence = + state.constants.preamble_length + + state.raw_data.num_pages_after_last_TOW * + state.constants.syncro_sequence_length + end + state = GNSSDecoderState( + state; + data = state.raw_data, + num_bits_after_valid_syncro_sequence, + ) + end + return state +end + +""" +$(TYPEDSIGNATURES) + +Check if the Galileo satellite is healthy and usable for positioning on E5a. + +Examines both the E5a signal-health status (`signal_health_e5a`) and the E5a +data-validity status (`data_validity_status_e5a`) from word type 1. A satellite +is considered healthy only if the signal health is `signal_ok` and the data +validity is `navigation_data_valid`. + +!!! warning + + This requires that word type 1 has been successfully decoded. Check that + `state.data.signal_health_e5a` is not `nothing` before relying on the result. +""" +function is_sat_healthy(state::GNSSDecoderState{<:GalileoE5aData}) + state.data.signal_health_e5a == signal_ok && + state.data.data_validity_status_e5a == navigation_data_valid +end diff --git a/src/galileo/galileo.jl b/src/galileo/galileo.jl new file mode 100644 index 0000000..bd366f7 --- /dev/null +++ b/src/galileo/galileo.jl @@ -0,0 +1,203 @@ +# Definitions shared across Galileo signals (E1B I/NAV and E5a F/NAV): the +# signal-health / data-validity enums, the per-satellite almanac record, and the +# common forward-error-correction primitive. Each individual signal's framing, +# page layout, and parser live in its own file (`e1b.jl`, `e5a.jl`). This mirrors +# how `src/gnss.jl` holds the definitions shared across *all* signals. + +# All Galileo open-service signals share the same rate-1/2, constraint-length-7 +# (K=7) non-systematic convolutional (NSC) FEC: generator polynomials G1 = 0o171, +# G2 = 0o133, with the G2 output inverted (Galileo OS SIS ICD, Issue 2.2, §4.1.4). +# Only the block-interleaver dimensions and codeword length differ per signal. +const GALILEO_VITERBI_POLY = [0o171, 0o133] + +# GTRF constants specific to Galileo (Galileo OS SIS ICD, Issue 2.2, Table 68). +# Only the two that differ from the GPS WGS-84 values live here; π, the speed of +# light, and the Earth rotation rate are shared package-wide (`GNSS_PI`, +# `SPEED_OF_LIGHT`, `EARTH_ROTATION_RATE` in `gnss.jl`). `GALILEO_F` follows from +# μ via F = -2√μ/c², which is why it too differs from GPS. +const GALILEO_μ = 3.986004418e14 # geocentric gravitational constant (m³/s²) +const GALILEO_F = -4.442807309e-10 # relativistic correction constant (s/√m) + +""" + SignalHealth + +Galileo signal health status enumeration. + +Indicates the operational status of a Galileo signal component (E1B word type 5, +E5a F/NAV word type 1, and the per-satellite almanacs of both). + +# Values + + - `signal_ok`: Signal is operating normally (value 0) + - `signal_out_of_service`: Signal is out of service (value 1) + - `signal_in_extended_operations_mode`: Signal is in Extended Operations Mode (value 2) + - `signal_component_currently_in_test`: Signal component is currently in test (value 3) + +# Reference + +Galileo OS SIS ICD, Issue 2.2, Table 84 +""" +@enum SignalHealth begin + signal_ok + signal_out_of_service + signal_in_extended_operations_mode + signal_component_currently_in_test +end + +""" + DataValidityStatus + +Galileo navigation data validity status enumeration. + +Indicates whether the broadcast navigation data should be trusted for positioning. + +# Values + + - `navigation_data_valid`: Navigation data is valid (value 0) + - `working_without_guarantee`: Navigation data is working without guarantee (value 1) + +# Reference + +Galileo OS SIS ICD, Issue 2.2, Table 81 +""" +@enum DataValidityStatus begin + navigation_data_valid + working_without_guarantee +end + +""" + GalileoAlmanac + +Almanac data for one Galileo satellite. + +The almanac provides reduced-precision orbital and clock parameters for predicting +satellite positions and selecting satellites for tracking. Differences (`Δsqrt_A`, +`δi`) are relative to nominal Galileo constellation values (`A_nominal = 29600.318 km`, +`i_nominal = 56°`). The same record is produced by both the I/NAV decoder (word +types 7-10) and the F/NAV decoder (word types 5-6); they differ only in which +signal-health facet they populate (see below). + +# Fields + + - `SVID::Int`: Satellite identifier (1-36 nominal range; 0 = unused entry) + - `Δsqrt_A::Float64`: Difference of √(semi-major axis) from nominal (√m) + - `e::Float64`: Eccentricity (dimensionless) + - `ω::Float64`: Argument of perigee (semi-circles) + - `δi::Float64`: Inclination delta from nominal (semi-circles) + - `Ω_0::Float64`: Longitude of ascending node at weekly epoch (semi-circles) + - `Ω_dot::Float64`: Rate of change of right ascension (semi-circles/s) + - `M_0::Float64`: Mean anomaly at reference time (semi-circles) + - `a_f0::Float64`: Truncated SV clock bias (seconds) + - `a_f1::Float64`: Truncated SV clock drift (s/s) + - `signal_health_e5b::SignalHealth`: Predicted E5b signal health status (Galileo I/NAV word types 7-10) + - `signal_health_e1b::SignalHealth`: Predicted E1-B/C signal health status (Galileo I/NAV word types 7-10) + - `signal_health_e5a::SignalHealth`: Predicted E5a signal health status (Galileo F/NAV word types 5-6; `nothing` for I/NAV-decoded almanacs) + - `IOD_a::Int`: Almanac IOD + - `WN_a::Int`: Almanac reference Week Number + - `t_0a::Int`: Almanac reference time (seconds) + +# Reference + +Galileo OS SIS ICD, Issue 2.2, Table 86 (I/NAV) and Tables 75-76 (F/NAV) +""" +Base.@kwdef struct GalileoAlmanac + SVID::Union{Nothing,Int} = nothing + Δsqrt_A::Union{Nothing,Float64} = nothing + e::Union{Nothing,Float64} = nothing + ω::Union{Nothing,Float64} = nothing + δi::Union{Nothing,Float64} = nothing + Ω_0::Union{Nothing,Float64} = nothing + Ω_dot::Union{Nothing,Float64} = nothing + M_0::Union{Nothing,Float64} = nothing + a_f0::Union{Nothing,Float64} = nothing + a_f1::Union{Nothing,Float64} = nothing + signal_health_e5b::Union{Nothing,SignalHealth} = nothing + signal_health_e1b::Union{Nothing,SignalHealth} = nothing + signal_health_e5a::Union{Nothing,SignalHealth} = nothing + IOD_a::Union{Nothing,Int} = nothing + WN_a::Union{Nothing,Int} = nothing + t_0a::Union{Nothing,Int} = nothing +end + +function GalileoAlmanac( + a::GalileoAlmanac; + SVID = a.SVID, + Δsqrt_A = a.Δsqrt_A, + e = a.e, + ω = a.ω, + δi = a.δi, + Ω_0 = a.Ω_0, + Ω_dot = a.Ω_dot, + M_0 = a.M_0, + a_f0 = a.a_f0, + a_f1 = a.a_f1, + signal_health_e5b = a.signal_health_e5b, + signal_health_e1b = a.signal_health_e1b, + signal_health_e5a = a.signal_health_e5a, + IOD_a = a.IOD_a, + WN_a = a.WN_a, + t_0a = a.t_0a, +) + GalileoAlmanac( + SVID, + Δsqrt_A, + e, + ω, + δi, + Ω_0, + Ω_dot, + M_0, + a_f0, + a_f1, + signal_health_e5b, + signal_health_e1b, + signal_health_e5a, + IOD_a, + WN_a, + t_0a, + ) +end + +""" + galileo_viterbi(decoder, soft_page, interleaver_rows, interleaver_cols, ::Type{T}) -> T + +Recover one Galileo page's information bits from `soft_page` — the +polarity-corrected `Float32` LLR soft symbols between the leading and trailing +page-sync sequences. Shared by E1B I/NAV and E5a F/NAV, which use the same FEC +and differ only in the block-interleaver shape and codeword length. `decoder` is +the caller's long-lived `Aff3ct.ConvViterbiDecoder`, reused across pages. + +The transmit FEC chain (Galileo OS SIS ICD, Issue 2.2, §4.1.4) is undone in order: + + 1. **`interleaver_rows`×`interleaver_cols` block deinterleave** of the LLRs + (`deinterleave` from `src/deinterleave.jl`; the argument order matches the + ICD column/row counts — E1B uses `(30, 8)`, E5a `(61, 8)`). + 2. **Invert every second symbol** — the spec inverts the G2 output of the + rate-1/2 encoder. On soft symbols an inversion is a sign flip (negation), so + confidence magnitudes are preserved. + 3. **K=7 NSC Viterbi** via AFF3CT.jl's `ConvViterbiDecoder`. AFF3CT's LLR sign + convention matches ours (positive ⇒ bit 0), so the LLRs feed in directly. The + decoder returns only the information bits (the 6 tail bits are consumed by + trellis termination). + +The decoded bits are packed MSB-first into the low bits of `T<:Unsigned` (E1B uses +`UInt128` for its 114 bits, E5a `UInt256` for its 238). +""" +function galileo_viterbi( + decoder::Aff3ct.ConvViterbiDecoder, + soft_page::AbstractVector{Float32}, + interleaver_rows::Int, + interleaver_cols::Int, + ::Type{T}, +) where {T<:Unsigned} + deinterleaved = deinterleave(soft_page, interleaver_rows, interleaver_cols) + @inbounds for i = 2:2:length(deinterleaved) + deinterleaved[i] = -deinterleaved[i] + end + info_bits = Aff3ct.decode(decoder, deinterleaved) + bits = T(0) + @inbounds for b in info_bits + bits = (bits << 1) | T(b) + end + return bits +end diff --git a/src/gnss.jl b/src/gnss.jl index af4f1b5..d9437b4 100644 --- a/src/gnss.jl +++ b/src/gnss.jl @@ -2,6 +2,22 @@ abstract type AbstractGNSSConstants end abstract type AbstractGNSSData end abstract type AbstractGNSSCache end +# Physical constants common to every GNSS handled here. Each per-signal +# `*Constants` struct exposes these as fields (so the orbit/clock math reads +# `state.constants.PI` etc.); the defaults are sourced from here to keep a single +# source of truth. Constellation-specific values that genuinely differ by +# reference frame — the Earth gravitational parameter μ and the relativistic +# correction F — are *not* here: GPS uses WGS-84 values and Galileo the GTRF +# values, defined alongside each constellation. +# +# `GNSS_PI` is deliberately the truncated value the ICDs fix for the +# semicircle→radian scaling (IS-GPS-200 Table 20-IV; Galileo OS SIS ICD Table 68), +# *not* `Base.π` — broadcast angular quantities must be scaled with exactly this +# value to reproduce the transmitted numbers bit-for-bit. +const GNSS_PI = 3.1415926535898 +const SPEED_OF_LIGHT = 2.99792458e8 # m/s +const EARTH_ROTATION_RATE = 7.2921151467e-5 # rad/s (WGS-84 and GTRF agree) + """ $(TYPEDEF) diff --git a/src/gps/l1c_d.jl b/src/gps/l1c_d.jl index 31f694f..c89c121 100644 --- a/src/gps/l1c_d.jl +++ b/src/gps/l1c_d.jl @@ -107,15 +107,15 @@ Base.@kwdef struct GPSL1C_DConstants <: AbstractGNSSConstants """ Mathematical constant π (IS-GPS-800G) """ - PI::Float64 = 3.1415926535898 + PI::Float64 = GNSS_PI """ WGS 84 Earth rotation rate (rad/s) """ - Ω_dot_e::Float64 = 7.2921151467e-5 + Ω_dot_e::Float64 = EARTH_ROTATION_RATE """ Speed of light (m/s) """ - c::Float64 = 2.99792458e8 + c::Float64 = SPEED_OF_LIGHT """ WGS 84 Earth gravitational parameter (m³/s²) """ diff --git a/src/gps/l1ca.jl b/src/gps/l1ca.jl index 9ef5c9c..05461ff 100755 --- a/src/gps/l1ca.jl +++ b/src/gps/l1ca.jl @@ -32,9 +32,9 @@ Base.@kwdef struct GPSL1CAConstants <: AbstractGNSSConstants preamble::UInt8 = 0b10001011 preamble_length::Int = 8 word_length::Int = 30 - PI::Float64 = 3.1415926535898 - Ω_dot_e::Float64 = 7.2921151467e-5 - c::Float64 = 2.99792458e8 + PI::Float64 = GNSS_PI + Ω_dot_e::Float64 = EARTH_ROTATION_RATE + c::Float64 = SPEED_OF_LIGHT μ::Float64 = 3.986005e14 F::Float64 = -4.442807633e-10 end diff --git a/src/gps/l5.jl b/src/gps/l5.jl index 39af868..2024d35 100644 --- a/src/gps/l5.jl +++ b/src/gps/l5.jl @@ -73,15 +73,15 @@ Base.@kwdef struct GPSL5IConstants <: AbstractGNSSConstants """ Mathematical constant π (IS-GPS-705J Table 20-II) """ - PI::Float64 = 3.1415926535898 + PI::Float64 = GNSS_PI """ WGS 84 Earth rotation rate (rad/s) """ - Ω_dot_e::Float64 = 7.2921151467e-5 + Ω_dot_e::Float64 = EARTH_ROTATION_RATE """ Speed of light (m/s) """ - c::Float64 = 2.99792458e8 + c::Float64 = SPEED_OF_LIGHT """ WGS 84 Earth gravitational parameter (m³/s²) """ diff --git a/test/data/galileo_e5a_fnav_pages.bin b/test/data/galileo_e5a_fnav_pages.bin new file mode 100644 index 0000000000000000000000000000000000000000..5b90ef14b3441118806c4616cc3e5a39026715b8 GIT binary patch literal 4712 zcmbW)eM}Q)90%~H6tF<)5ZNr^3oylNK`o`56^YgtwhAs|c-P7_0#2P67&rxWpq30X z7``y44vbsSH7cMEhX$uk>r%8V1PdZBidbF+$0FM_Q($htyTe4b{#e5O!*i5`Prmo< z^Lx%I>Y9WI-)i*pY;{~?@eqb2JQr==k>|uPOj&&|5h)Thv1Wn3fVlf?EOtC+3^ho% z3*sgFYAIvUAQ3C5w*x;QWLdi~#lhQANeYQ+Q6_x)9#g)`N<0+Kks|mQJ|F^5ZK!%- z?B8%b>wi}!9=now1(?|X^YTTCxvS(WWj1lLOFI3fIz#|n-tIJIKT8+}`is)5ceK_r~+_Erxbg30V2lNp;%sB0~)_JZOrQP00>(#zE ztxUFU&T>fYnu2gwX9{r%GQg7J?S`nYF)W5AWFjGxCTX139UwwYPL4xY96~z}UDLf;Y#o7irzR~7r8NljqOJ^_ z)+x(3so2!w_>>?uKQ6VI{QZ<0^%{hB3A)B)#RTEBhHo_@N1RrvOTE~O)1J%IdQDtv z(Ohan!Z|C6IMNq%ZUuAm5!z4CHO8SN4yQG)*ZwI&X^mg#pX|dokAd;>+{e9aYI$5r zg1iZ2b_2UqarY$T_~Jr@W`V9T4aZL5w5E{QJ#{FpDMr8NP$`<)fFdxazKTeFRb5?Y=+*2uIcg}nu#blQ5z;5HbWz7 z*%^8_XIf1P5eLHzg(om3QpBaD>#(D^W zuDMwIVBzx6g+gc#plkG1%Tt_oZ-Qi|4W->%!0hd};Iz{+$xRESR>6q~fz)PksrmE2 zm|Tz~MrgmXUE6RCW+?VR@3sG&uR>`pF7(IIOL5xu!1Lv5B8{+-P0a?-Xn7IrYa~4n z)~>TnUI^_mbWJbyhZ%~|T8d*og!3BGwU!!YM&cyC(sseWD z$lViVPBJ^F>~*c5?OJio2wl6sG5!54ly<*Bm;Fm3PJ1d_d;-pEaVoM6PML&=dC?kY zgCIZyYqxRHLH21o33N?o6j<=oY@xL!`6$g6Pe%#8P}<;F*;P-PO^tQH2^dlnbE&y< z_e6QHrwXAtL)RG78?dio544r&9^t9A%Jj$m@UOw3T>gFORxULemztCpL26^T)Ld#i z5)qm^bWL~4s2zb;x#GG4O|A7eUGYT~p4uYHTrXu)OW{mxYE!w?`0f$K-iPeFft{}H z9LIL8tPy4?mRj5X%wriSt?dWB&J+I{46c-y-hdejU5nvTo64sK`tJ@ zH*`(!Tn^_ojMl#5p`!t%wP!L<)~v@$p_t2ukc(Ap>xf#8Ug68j~y> 4 # 248 packed bits → 244 page bits + end + pages +end + +# The 238 information bits (page type + data + CRC) of a 244-bit page. +e5a_info_bits(page244::UInt256) = page244 >> 6 + +# Transmit-side rate-1/2 K=7 NSC convolutional encoder, G1 = 0o171, G2 = 0o133 +# with the G2 output inverted (Galileo OS SIS ICD §4.1.4). Takes the 238 info +# bits, appends 6 zero tail bits, and returns 488 ±1 soft symbols (bit 1 → −1). +function e5a_conv_encode(info::Vector{Bool}) + polys = (0o171, 0o133) + reg = zeros(Bool, 6) + out = Float32[] + for b in vcat(info, zeros(Bool, 6)) + taps = vcat([b], reg) # [current, last-6]; tap j matches poly MSB + for (k, p) in enumerate(polys) + acc = false + for j = 1:7 + ((p >> (7 - j)) & 1 == 1) && (acc ⊻= taps[j]) + end + sym = (k == 2) ? !acc : acc # invert G2 + push!(out, sym ? -1.0f0 : 1.0f0) + end + reg = vcat([b], reg[1:(end-1)]) + end + out +end + +# One F/NAV page → 500 on-air soft symbols: 12-symbol sync + 488 interleaved +# encoded symbols (8 rows × 61 columns block interleaver). +function e5a_page_to_symbols(page244::UInt256) + info = e5a_info_bits(page244) + info_vec = Bool[GNSSDecoder.get_bit(info, 238, k) for k = 1:238] + sync = Float32[b == 1 ? -1.0f0 : 1.0f0 for b in GALILEO_E5A_SYNC] + vcat(sync, GNSSDecoder.interleave(e5a_conv_encode(info_vec), 61, 8)) +end + +# Concatenate the given pages into a soft-symbol stream, plus a trailing sync so +# the final page's window is closed by the next page-sync pattern. +function e5a_symbol_stream(pages) + sync = Float32[b == 1 ? -1.0f0 : 1.0f0 for b in GALILEO_E5A_SYNC] + stream = Float32[] + for page in pages + append!(stream, e5a_page_to_symbols(page)) + end + append!(stream, sync) + stream +end + +@testset "Galileo E5a constructor" begin + decoder = GalileoE5aDecoderState(21) + @test decoder.prn == 21 + @test decoder.data isa GNSSDecoder.GalileoE5aData + @test decoder.constants.syncro_sequence_length == 500 + @test decoder.constants.preamble_length == 12 + @test decoder.constants.preamble == 0b101101110000 + @test GNSSDecoder.num_bits_buffered(decoder) == 0 + @test isnothing(decoder.num_bits_after_valid_syncro_sequence) + + # F/NAV rides on the E5a-I (data) component; dispatch from the GNSSSignals + # system type must match the direct constructor. + @test GalileoE5aDecoderState(21) == GNSSDecoderState(GNSSSignals.GalileoE5aI(), 21) +end + +@testset "Galileo E5a FEC round-trip" begin + # Encoding a page and decoding it back through the production Viterbi path + # must recover the exact 238 info bits. Confirms the deinterleave (61×8), the + # G2 sign flip, and the AFF3CT Viterbi configuration are mutually consistent. + pages = read_e5a_fnav_pages(GALILEO_E5A_FNAV_PAGES_PATH) + decoder = GalileoE5aDecoderState(21) + for idx in (1, 5, 10, 76, 152) + info = e5a_info_bits(pages[idx]) + info_vec = Bool[GNSSDecoder.get_bit(info, 238, k) for k = 1:238] + on_air = GNSSDecoder.interleave(e5a_conv_encode(info_vec), 61, 8) + recovered = GNSSDecoder.galileo_e5a_viterbi(decoder.cache.viterbi_decoder, on_air) + @test recovered == GNSSDecoder.UInt256(info) + end +end + +@testset "Galileo E5a CRC matches Spirent" begin + # Every Spirent page is flagged CRC-CORRECT; our CRC-24Q over the 238 info + # bits must therefore vanish for all 152 pages. Independent of the FEC chain. + pages = read_e5a_fnav_pages(GALILEO_E5A_FNAV_PAGES_PATH) + @test length(pages) == 152 + @test all(pages) do page + info = e5a_info_bits(page) + crc_bits = Bool[GNSSDecoder.get_bit(info, 238, k) for k = 1:238] + crc24q(crc_bits) == 0 + end +end + +@testset "Galileo E5a ephemeris frame decoding" begin + pages = read_e5a_fnav_pages(GALILEO_E5A_FNAV_PAGES_PATH) + + # Expected first-frame navigation data (Spirent PRN 21, IODnav 48, TOW 259230). + # Every value cross-checked against the Spirent text dump (word types 1-4). + expected = GNSSDecoder.GalileoE5aData(; + WN = 1082, + TOW = 259230, + SVID = 21, + t_0e = 259200.0, + M_0 = 2.583259511699057, + e = 9.999994654208422e-5, + sqrt_A = 5440.588203430176, + Ω_0 = 1.4358151884944024, + i_0 = 0.9885709926241616, + ω = 3.1416165752262324e-5, + i_dot = 3.142988060925546e-11, + Ω_dot = 3.141595145762181e-8, + Δn = 3.142988060925545e-10, + C_uc = 1.000240445137024e-6, + C_us = 2.000480890274048e-6, + C_rc = 5.0, + C_rs = 6.0, + C_ic = 3.0007213354110718e-6, + C_is = 3.999099135398865e-6, + SISA_e1_e5a = 25, + t_0c = 259200.0, + a_f0 = 9.999802568927407e-5, + a_f1 = 1.0000036354540498e-9, + a_f2 = 1.734723475976807e-18, + IOD_nav1 = 0x0000000000000030, + IOD_nav2 = 0x0000000000000030, + IOD_nav3 = 0x0000000000000030, + IOD_nav4 = 0x0000000000000030, + num_pages_after_last_TOW = 2, + num_bits_after_valid_syncro_sequence_after_last_TOW = nothing, + signal_health_e5a = GNSSDecoder.signal_ok, + data_validity_status_e5a = GNSSDecoder.navigation_data_valid, + broadcast_group_delay_e1_e5a = -9.313225746154785e-10, + a_i0 = 100.0, + a_i1 = 1.0, + a_i2 = 0.100006103515625, + iono_storm_flag_region1 = true, + iono_storm_flag_region2 = false, + iono_storm_flag_region3 = true, + iono_storm_flag_region4 = false, + iono_storm_flag_region5 = false, + A_0_utc = 1.000240445137024e-6, + A_1_utc = 8.881784197001252e-16, + Δt_LS = 18, + t_0t = 259200, + WN_0t = 58, + WN_LSF = 56, + DN = 2, + Δt_LSF = 18, + A_0G = -2.9103830456733704e-11, + A_1G = -4.440892098500626e-16, + t_0G = 918000, + WN_0G = 63, + ) + + # Feed the first five pages (word types 1-5): word type 4 completes the + # ephemeris/clock set and triggers validation. + stream = e5a_symbol_stream(pages[1:5]) + decoder = GalileoE5aDecoderState(21) + decoder = decode(decoder, stream, length(stream)) + + @test decoder.data == expected + @test decoder.is_shifted_by_180_degrees == false + @test is_sat_healthy(decoder) == true + # 1012 = symbols elapsed since the leading sync edge of word type 4 (the page + # carrying the validated TOW): two further pages (WT4→WT5→trailing sync) of + # 500 symbols each, less the 488 — i.e. preamble (12) + 2·500. + @test decoder.num_bits_after_valid_syncro_sequence == 1012 + + # Inverting the whole stream (180° Costas ambiguity) decodes identically but + # flags the polarity flip. + decoder_inv = GalileoE5aDecoderState(21) + decoder_inv = decode(decoder_inv, -stream, length(stream)) + @test decoder_inv.data == expected + @test decoder_inv.is_shifted_by_180_degrees == true + @test is_sat_healthy(decoder_inv) == true +end + +@testset "Galileo E5a almanac decoding" begin + pages = read_e5a_fnav_pages(GALILEO_E5A_FNAV_PAGES_PATH) + + # Word type 5 (t=240 s) carries SVID-19 (full) + SVID-20 (first half); word + # type 6 (t=290 s) completes SVID-20 and carries SVID-21 (full). The two are + # five pages apart in the broadcast schedule; the partial persists in the + # cache across the intervening WT1-4 pages. WN_a/t_0a are broadcast only in + # WT5 and shared by all three almanacs, including SVID-21 from WT6. + stream = e5a_symbol_stream(pages[1:31]) + decoder = GalileoE5aDecoderState(21) + decoder = decode(decoder, stream, length(stream)) + almanacs = decoder.data.almanacs + @test !isnothing(almanacs) + + # SVID 19 — full almanac from word type 5. + @test almanacs[19] == GNSSDecoder.GalileoAlmanac(; + SVID = 19, + Δsqrt_A = 0.0, + e = 0.0, + ω = 0.0, + δi = 0.0, + Ω_0 = 2.815621736164101, + Ω_dot = 0.0, + M_0 = 0.9006384700873591, + a_f0 = 0.0, + a_f1 = 0.0, + signal_health_e5a = GNSSDecoder.signal_ok, + IOD_a = 0, + WN_a = 2, + t_0a = 259200, + ) + + # SVID 20 — split across word types 5 and 6 (note the stitched Ω_0). + @test almanacs[20] == GNSSDecoder.GalileoAlmanac(; + SVID = 20, + Δsqrt_A = 0.0, + e = 0.0, + ω = 0.0, + δi = 0.0, + Ω_0 = 2.815621736164101, + Ω_dot = 0.0, + M_0 = 1.6860366334848091, + a_f0 = 0.0, + a_f1 = 0.0, + signal_health_e5a = GNSSDecoder.signal_ok, + IOD_a = 0, + WN_a = 2, + t_0a = 259200, + ) + + # SVID 21 — full almanac from word type 6; WN_a/t_0a inherited from WT5. + @test almanacs[21] == GNSSDecoder.GalileoAlmanac(; + SVID = 21, + Δsqrt_A = 0.0, + e = 0.0001068115234375, + ω = 0.0, + δi = 0.011121360712170923, + Ω_0 = 1.4358060174609635, + Ω_dot = 3.1452738704244004e-8, + M_0 = 2.5832236467994254, + a_f0 = 9.918212890625e-5, + a_f1 = 1.000444171950221e-9, + signal_health_e5a = GNSSDecoder.signal_ok, + IOD_a = 0, + WN_a = 2, + t_0a = 259200, + ) +end + +@testset "Galileo E5a reset" begin + pages = read_e5a_fnav_pages(GALILEO_E5A_FNAV_PAGES_PATH) + stream = e5a_symbol_stream(pages[1:5]) + decoder = GalileoE5aDecoderState(21) + decoder = decode(decoder, stream, length(stream)) + @test !isnothing(decoder.data.TOW) + + decoder = reset_decoder_state(decoder) + @test GNSSDecoder.num_bits_buffered(decoder) == 0 + @test isnothing(decoder.raw_data.TOW) + @test isnothing(decoder.data.TOW) + @test isnothing(decoder.num_bits_after_valid_syncro_sequence) + # Ephemeris is retained in raw_data across a reset (fast reacquisition). + @test decoder.raw_data.sqrt_A == 5440.588203430176 +end diff --git a/test/runtests.jl b/test/runtests.jl index 619046f..7e7a008 100755 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -37,6 +37,7 @@ end include("gps_l1c_d.jl") include("gps_l5i.jl") include("galileo_e1b.jl") + include("galileo_e5a.jl") # v2 shared-utility deep-module tests (issue #36) include("crc.jl")