Problem
Every navigation-data field on the published data (e.g. GPSL1CAData.sqrt_A, .e, .a_f0, …) is Union{Nothing, T}, because fields fill in incrementally as the message decodes. That layout is right for the staging phase, but consumers pay for it forever: any code that reads many of these fields in arithmetic chains (Kepler propagation, clock correction) exceeds the compiler's union-splitting budget, so every intermediate is boxed.
Measured in PositionVelocityTime.jl, this cost over half of calc_pvt's runtime and ~70% of its allocations. We worked around it downstream by extracting the fields once per satellite into concrete Float64 structs behind a function barrier — see JuliaGNSS/PositionVelocityTime.jl#52 (branch sc/performance) for the working layout and benchmark numbers (calc_pvt warm solve: 18.6 µs / 622 allocs → 11.3 µs / 183 allocs for 9 GPS satellites).
Suggestion
GNSSDecoder already has the right skeleton: decoders accumulate into raw_data and validate_data promotes to data once is_decoding_completed_for_positioning(raw_data) holds. The promotion point is exactly where a concrete snapshot could be published:
- Keep the
Union{Nothing, T} per-field layout for the staging area (raw_data) — it must exist during decoding anyway.
- At promotion, additionally build concrete immutable substructs, e.g.
data.ephemeris::Union{Nothing, GPSLNAVEphemeris} and data.clock::Union{Nothing, ClockPolynomial}, with all-Float64 fields inside. A single small union at the access point splits fine; everything downstream is concrete.
One design point worth stating explicitly: substructs as modeled by the existing almanac records would not fix this — GalileoAlmanac is itself all Union{Nothing, Float64} fields. The pattern that works is nullable at the top, concrete inside.
Side benefits beyond performance
- Atomic, IOD-consistent publication: the ephemeris becomes one immutable value assembled at a consistency checkpoint, structurally preventing mixed-issue-of-data reads that the per-field model only avoids by care.
is_decoding_completed_for_positioning collapses to a couple of !isnothing checks.
- Downstream packages (PVT) can drop their extraction layers and consume the decoder's types directly.
Costs / open questions
- Breaking change to the public data layout → major version bump; all consumers reading
data.sqrt_A etc. need migration (including PositionVelocityTime's iono/GGTO/week handling and both test suites).
- T_GD and the CNAV/CNAV-2 ISCs can legitimately still be missing on a decoder that is complete for positioning (they ride in less-frequent message types), so they'd stay nullable or zero-defaulted inside the clock substruct — needs an explicit decision.
- Whether to publish broadcast-faithful per-message-type structs (LNAV Keplerian vs. CNAV quasi-Keplerian) or a normalised orbit: PositionVelocityTime.jl#52 normalises at extraction (
orbital_terms), which a decoder package may or may not want to own. Both options preserve the performance win.
🤖 Generated with Claude Code
Problem
Every navigation-data field on the published
data(e.g.GPSL1CAData.sqrt_A,.e,.a_f0, …) isUnion{Nothing, T}, because fields fill in incrementally as the message decodes. That layout is right for the staging phase, but consumers pay for it forever: any code that reads many of these fields in arithmetic chains (Kepler propagation, clock correction) exceeds the compiler's union-splitting budget, so every intermediate is boxed.Measured in PositionVelocityTime.jl, this cost over half of
calc_pvt's runtime and ~70% of its allocations. We worked around it downstream by extracting the fields once per satellite into concreteFloat64structs behind a function barrier — see JuliaGNSS/PositionVelocityTime.jl#52 (branchsc/performance) for the working layout and benchmark numbers (calc_pvtwarm solve: 18.6 µs / 622 allocs → 11.3 µs / 183 allocs for 9 GPS satellites).Suggestion
GNSSDecoder already has the right skeleton: decoders accumulate into
raw_dataandvalidate_datapromotes todataonceis_decoding_completed_for_positioning(raw_data)holds. The promotion point is exactly where a concrete snapshot could be published:Union{Nothing, T}per-field layout for the staging area (raw_data) — it must exist during decoding anyway.data.ephemeris::Union{Nothing, GPSLNAVEphemeris}anddata.clock::Union{Nothing, ClockPolynomial}, with all-Float64fields inside. A single small union at the access point splits fine; everything downstream is concrete.One design point worth stating explicitly: substructs as modeled by the existing almanac records would not fix this —
GalileoAlmanacis itself allUnion{Nothing, Float64}fields. The pattern that works is nullable at the top, concrete inside.Side benefits beyond performance
is_decoding_completed_for_positioningcollapses to a couple of!isnothingchecks.Costs / open questions
data.sqrt_Aetc. need migration (including PositionVelocityTime's iono/GGTO/week handling and both test suites).orbital_terms), which a decoder package may or may not want to own. Both options preserve the performance win.🤖 Generated with Claude Code