From e7b3f75c500a43f21251d818567d7844badd7914 Mon Sep 17 00:00:00 2001 From: siebc <226531417+siebc@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:21:31 +0000 Subject: [PATCH] feat(galileo_e5a): back-patch almanac reference epochs from a later WT5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WN_a/t_0a are shared by every almanac of a given IOD_a but are broadcast only in WT5, while a WT6 can carry an otherwise-complete SVID-3. If the paired WT5 was missed (mid-stream acquisition or IOD cutover), that SVID-3 was left without its reference epoch. Add `backpatch_almanac_epochs` so a later WT5 back-fills WN_a/t_0a into any such stranded record, completing a one-shot WT6 orbit even if that WT6 never reappears. This keeps the decode-everything policy: a WT6 SVID-3 without its epoch is still stored (WN_a/t_0a `nothing`) rather than discarded. A decoded almanac may therefore be incomplete — any field can be `nothing`, and a record's reference epoch in particular may be absent until a matching WT5 arrives. The `almanacs` field docs spell this out so callers check the fields they need before use. Also hoist the per-page `soft_page` (488 Float32) and `crc_bits` (238 Bool) scratch into `GalileoE5aCache`, reused across synced pages rather than reallocated, mirroring the CNAV preallocation convention (consistency; runs once per page, so the runtime impact is small). New tests cover the partial-record and epoch back-patch cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/galileo/e5a.jl | 72 ++++++++++++++++++++++++++++++++++++++++----- test/galileo_e5a.jl | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/src/galileo/e5a.jl b/src/galileo/e5a.jl index 4f535f9..e62d0ce 100644 --- a/src/galileo/e5a.jl +++ b/src/galileo/e5a.jl @@ -160,9 +160,18 @@ the decoder multiplies by π), matching the convention used by [`GalileoE1BData` 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`. + once WT6 completes it with a consistent `IOD_a`. SVID-3, though fully carried + in WT6, inherits its reference epoch (`WN_a`/`t_0a`) from the paired WT5; when + that partial is missing (mid-stream acquisition or an IOD cutover), the SVID-3 + record is still stored with `WN_a`/`t_0a` left `nothing` — the decoder keeps + whatever it can decode rather than discarding it. Because the epoch is shared + by every almanac of a given `IOD_a`, a later WT5 back-fills it into any such + partial record, so a one-shot WT6 orbit becomes usable even if that WT6 never + reappears. **An almanac may therefore be incomplete**: any field can be + `nothing`, and in particular a record's reference epoch (`WN_a`/`t_0a`) may be + absent until a matching WT5 arrives — check the fields you need before using a + record. F/NAV almanacs carry the E5a health (`signal_health_e5a`); the E5b/E1-B + almanac-health fields are left `nothing`. # Reference @@ -438,6 +447,15 @@ struct GalileoE5aCache <: AbstractGNSSCache AFF3CT K=7 NSC Viterbi decoder, built once and reused across pages. """ viterbi_decoder::Aff3ct.ConvViterbiDecoder + """ + 488 encoded symbols copied out of `soft_buffer` (with the 180° polarity + applied) for each synced page, reused rather than reallocated per page. + """ + soft_page::Vector{Float32} + """ + 238 decoded bits unpacked for the CRC-24Q check, reused across pages. + """ + crc_bits::Vector{Bool} end GalileoE5aCache() = GalileoE5aCache( @@ -449,6 +467,8 @@ GalileoE5aCache() = GalileoE5aCache( GALILEO_E5A_VITERBI_N, GALILEO_VITERBI_POLY, ), + Vector{Float32}(undef, GALILEO_E5A_VITERBI_N), + Vector{Bool}(undef, GALILEO_E5A_VITERBI_K), ) function GalileoE5aCache( @@ -457,12 +477,16 @@ function GalileoE5aCache( almanac_chain_partial = cache.almanac_chain_partial, almanac_chain_omega0_msb = cache.almanac_chain_omega0_msb, viterbi_decoder = cache.viterbi_decoder, + soft_page = cache.soft_page, + crc_bits = cache.crc_bits, ) GalileoE5aCache( soft_buffer, almanac_chain_partial, almanac_chain_omega0_msb, viterbi_decoder, + soft_page, + crc_bits, ) end @@ -597,6 +621,32 @@ function combine_almanac_omega0(msb::Int, lsb::Int, PI::Float64) return get_twos_complement_num(raw, 16, 1, 16) * PI / (1 << 15) end +# IOD-keyed epoch back-patch. WN_a/t_0a are shared by every almanac of a given +# IOD_a, so a freshly decoded WT5 (which carries them) can complete any earlier +# record left without its reference epoch — most notably an SVID-3 decoded from a +# WT6 whose paired WT5 was missed (mid-stream acquisition / IOD cutover). This +# lets a one-shot WT6 orbit become usable once a later WT5 arrives, even if that +# WT6 never reappears; without it the WT6's orbital block would be stranded. +# Returns `almanacs` unchanged when nothing matches, else a patched copy (the +# input dictionary, shared with `raw_data`, is never mutated in place). +function backpatch_almanac_epochs( + almanacs::Union{Nothing,Dictionary{Int,GalileoAlmanac}}, + IOD_a::Int, + WN_a::Int, + t_0a::Int, +) + isnothing(almanacs) && return almanacs + patched = almanacs + for SVID in keys(almanacs) + alm = almanacs[SVID] + if alm.IOD_a == IOD_a && (isnothing(alm.WN_a) || isnothing(alm.t_0a)) + patched === almanacs && (patched = copy(almanacs)) + set!(patched, SVID, GalileoAlmanac(alm; WN_a, t_0a)) + end + end + return patched +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 @@ -605,7 +655,7 @@ function decode_syncro_sequence(state::GNSSDecoderState{<:GalileoE5aData}, buffe # 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) + soft_page = state.cache.soft_page @inbounds for i = 1:GALILEO_E5A_VITERBI_N soft_page[i] = sign * deque[state.constants.preamble_length+i] end @@ -621,7 +671,7 @@ function decode_syncro_sequence(state::GNSSDecoderState{<:GalileoE5aData}, buffe # 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) + crc_bits = state.cache.crc_bits @inbounds for i = 1:GALILEO_E5A_VITERBI_K crc_bits[i] = get_bit(bits, GALILEO_E5A_VITERBI_K, i) end @@ -816,7 +866,10 @@ function decode_syncro_sequence(state::GNSSDecoderState{<:GalileoE5aData}, buffe ) omega0_msb = Int(get_bits(bits, 238, 211, 4)) - almanacs = state.raw_data.almanacs + # Complete any earlier record still missing its reference epoch (e.g. an + # SVID-3 from a WT6 whose paired WT5 was missed) — WN_a/t_0a are shared by + # all almanacs of this IOD_a. + almanacs = backpatch_almanac_epochs(state.raw_data.almanacs, IOD_a, WN_a, t_0a) if SVID1 >= 1 almanacs = isnothing(almanacs) ? Dictionary{Int,GalileoAlmanac}() : copy(almanacs) @@ -857,7 +910,12 @@ function decode_syncro_sequence(state::GNSSDecoderState{<:GalileoE5aData}, buffe 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. + # type 5. Inherit them from the cached WT5 partial when its IOD_a matches; + # otherwise the SVID-3 record is still stored — with WN_a/t_0a left + # `nothing` — so nothing decodable is discarded. This happens on mid-stream + # acquisition or an IOD cutover that lands WT6 without its paired WT5; the + # epoch is filled in later by `backpatch_almanac_epochs` when the matching + # WT5 arrives (or wholesale by the next full WT5→WT6 cycle). SVID3 = Int(get_bits(bits, 238, 81, 6)) if SVID3 >= 1 shared_wn_a, shared_t_0a = diff --git a/test/galileo_e5a.jl b/test/galileo_e5a.jl index 5821bfe..658d9cc 100644 --- a/test/galileo_e5a.jl +++ b/test/galileo_e5a.jl @@ -276,6 +276,57 @@ end ) end +@testset "Galileo E5a almanac WT6 without preceding WT5" begin + pages = read_e5a_fnav_pages(GALILEO_E5A_FNAV_PAGES_PATH) + # Broadcast schedule: WT5 at pages 5/15/25, WT6 at pages 10/20/30. Feed pages + # 1-24 to lock the decoder — page 24 is a WT4, so the SVID-2 chain partial is + # empty at that point — then append the WT6 at page 30 while omitting its + # paired WT5 at page 25. This is the mid-stream acquisition / IOD-cutover case: + # the WT6 carries SVID-3 in full, but its reference epoch (WN_a/t_0a) lived + # only in the missing WT5. Per the decode-everything policy the record is still + # stored — nothing decodable is discarded — with the epoch left `nothing`. (The + # completion of the SVID-2 chain is likewise skipped, so this WT6 stores exactly + # one almanac.) + partial_stream = e5a_symbol_stream(vcat(pages[1:24], pages[30])) + decoder = GalileoE5aDecoderState(21) + decoder = decode(decoder, partial_stream, length(partial_stream)) + almanacs = decoder.raw_data.almanacs + @test !isnothing(almanacs) + partial = only(almanacs) + @test !isnothing(partial.Δsqrt_A) # orbit decoded from the WT6 + @test isnothing(partial.WN_a) # but the reference epoch is absent + @test isnothing(partial.t_0a) + + # The full run (WT5 then WT6, pages 1-31) instead yields the epoch too. + full_stream = e5a_symbol_stream(pages[1:31]) + full_decoder = GalileoE5aDecoderState(21) + full_decoder = decode(full_decoder, full_stream, length(full_stream)) + full = full_decoder.data.almanacs[21] + @test full.WN_a == 2 + @test full.t_0a == 259200 +end + +@testset "Galileo E5a almanac epoch back-patch from a later WT5" begin + pages = read_e5a_fnav_pages(GALILEO_E5A_FNAV_PAGES_PATH) + # Same lock prefix + lone WT6 (page 30) as above, so SVID-21 lands with its + # orbit/clock/health decoded but WN_a/t_0a still `nothing`. Then append a WT5 + # (page 25) with a matching IOD_a — but *never* re-send the WT6. Because the + # reference epoch is shared across every almanac of that IOD_a, the WT5 + # back-fills it into the stranded SVID-21 record, completing an almanac whose + # only WT6 was seen once. A "store only when complete" policy would have + # discarded that WT6 and could never reassemble SVID-21 from the WT5 alone. + stream = e5a_symbol_stream(vcat(pages[1:24], pages[30], pages[25])) + decoder = GalileoE5aDecoderState(21) + decoder = decode(decoder, stream, length(stream)) + almanacs = decoder.raw_data.almanacs + @test !isnothing(almanacs) + @test haskey(almanacs, 21) + completed = almanacs[21] + @test !isnothing(completed.Δsqrt_A) # orbit still from the one-shot WT6 + @test completed.WN_a == 2 # epoch back-filled by the later WT5 + @test completed.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])