Skip to content

fix: resolve GPS L1 week-rollover ambiguity via approximate_year#32

Merged
zsoerenm merged 1 commit into
masterfrom
ss/fix-week-rollover
May 7, 2026
Merged

fix: resolve GPS L1 week-rollover ambiguity via approximate_year#32
zsoerenm merged 1 commit into
masterfrom
ss/fix-week-rollover

Conversation

@zsoerenm

@zsoerenm zsoerenm commented May 7, 2026

Copy link
Copy Markdown
Member

Summary

Fixes incorrect PVT timestamps on legacy GPS L1 C/A recordings outside the current 1024-week cycle.

The LNAV message (IS-GPS-200, §20.3.3.3) broadcasts only a 10-bit week number, modulo 1024, so the receiver cannot determine the cycle from the broadcast data alone. Cycle boundaries: 1999-08-22, 2019-04-07, 2038-11-21, 2058-07-08.

Old code hardcoded `2048 + trans_week`, which silently breaks for any recording outside the post-2019 cycle and will break again at the next rollover.

Symptom

Observed in GNSSReceiver's integration test against the ION RTL-SDR sample data, recorded 2017-09-10 in Oegstgeest, NL: PVT timestamp came out as `2037-04-26T22:57:20` — exactly 1024 weeks (~19.6 years) into the future. Position and velocity were correct (the LSQ clock-bias term absorbs the offset).

Fix

Add an `approximate_year::Integer` keyword to `calc_pvt`, threaded into `get_week`. The default is `year(now(UTC))`, which is correct for live signals; archived data passes the rough year. The cycle whose absolute date is closest to the anchor wins — anything within ~9 years of the true observation date works.

```julia

Live signal — default works:

calc_pvt(states)

Post-processing 2017 data:

calc_pvt(states; approximate_year = 2017)
```

The Galileo `get_week` overload also gains the kwarg for API consistency, even though Galileo's 12-bit WN field doesn't realistically need it.

Why not infer it from the data?

Investigated. There's no field in the LNAV broadcast that resolves the cycle:

  • The 10-bit `WN` is the only week number the legacy L1 C/A signal carries.
  • Almanac `WN_a` (8 bits), UTC `WN_t`/`WN_LSF` (8 bits) — also rolling.
  • `t_oc`/`t_oe` are within-week.
  • The LSQ `time_correction` absorbs any cycle error, so the residual gives no signal.
  • CNAV (L2C/L5) carries a 13-bit WN that doesn't roll over for ~157 years, but L1 C/A receivers don't see it.

IS-GPS-200 acknowledges the ambiguity and recommends external information — exactly what `approximate_year` provides.

Test plan

  • New `test/get_week.jl` covers four cycles (0, 1, 2, 3), the boundary case at the 2019 rollover, and the `now()` default.
  • Existing PVT testsets (Galileo + GPS L1, both for the 2021-05-31 fixtures) updated to pin `approximate_year = 2021`. Without pinning they'd silently break in 2030+ once the wall clock starts picking the next cycle.
  • `Pkg.test()` green.

🤖 Generated with Claude Code

@codecov

codecov Bot commented May 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.57%. Comparing base (301f5e9) to head (e7e2191).

Additional details and impacted files
@@            Coverage Diff             @@
##           master      #32      +/-   ##
==========================================
+ Coverage   88.42%   88.57%   +0.14%     
==========================================
  Files           4        4              
  Lines         242      245       +3     
==========================================
+ Hits          214      217       +3     
  Misses         28       28              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

Benchmark Results (Julia v1)

Time benchmarks
master e7e2191... master / e7e2191...
calc_pvt/GPSL1/4sats/cold 15.9 ± 0.38 μs 16.2 ± 0.39 μs 0.981 ± 0.033
calc_pvt/GPSL1/4sats/warm 25 ± 0.41 μs 25.1 ± 0.41 μs 0.997 ± 0.023
calc_pvt/GPSL1/9sats/cold 22.6 ± 0.45 μs 23.3 ± 0.47 μs 0.971 ± 0.028
calc_pvt/GPSL1/9sats/warm 13.5 ± 0.39 μs 13.9 ± 0.43 μs 0.972 ± 0.041
calc_pvt/GalileoE1B/4sats/cold 15.6 ± 0.39 μs 15.8 ± 0.41 μs 0.991 ± 0.036
calc_pvt/GalileoE1B/4sats/warm 8.79 ± 0.32 μs 8.75 ± 0.32 μs 1 ± 0.052
calc_pvt/GalileoE1B/5sats/cold 17.1 ± 0.41 μs 17.3 ± 0.44 μs 0.984 ± 0.035
calc_pvt/GalileoE1B/5sats/warm 9.43 ± 0.35 μs 9.51 ± 0.32 μs 0.992 ± 0.05
time_to_load 2.72 ± 0.0071 s 2.7 ± 0.033 s 1.01 ± 0.012
Memory benchmarks
master e7e2191... master / e7e2191...
calc_pvt/GPSL1/4sats/cold 0.161 k allocs: 8.55 kB 0.161 k allocs: 8.55 kB 1
calc_pvt/GPSL1/4sats/warm 0.243 k allocs: 13.2 kB 0.243 k allocs: 13.2 kB 1
calc_pvt/GPSL1/9sats/cold 0.166 k allocs: 10.4 kB 0.166 k allocs: 10.4 kB 1
calc_pvt/GPSL1/9sats/warm 0.127 k allocs: 8.2 kB 0.127 k allocs: 8.2 kB 1
calc_pvt/GalileoE1B/4sats/cold 0.161 k allocs: 8.55 kB 0.161 k allocs: 8.55 kB 1
calc_pvt/GalileoE1B/4sats/warm 0.122 k allocs: 6.38 kB 0.122 k allocs: 6.38 kB 1
calc_pvt/GalileoE1B/5sats/cold 0.162 k allocs: 8.8 kB 0.162 k allocs: 8.8 kB 1
calc_pvt/GalileoE1B/5sats/warm 0.123 k allocs: 6.62 kB 0.123 k allocs: 6.62 kB 1
time_to_load 0.149 k allocs: 11.2 kB 0.149 k allocs: 11.2 kB 1

The legacy GPS L1 C/A LNAV message (IS-GPS-200, §20.3.3.3) broadcasts
only a 10-bit week number — modulo 1024 — so the receiver cannot
determine which 1024-week cycle the recording is in from the broadcast
data alone. Each cycle is ~19.6 years, with rollover boundaries at
1999-08-22, 2019-04-07, 2038-11-21, and 2058-07-08.

The previous implementation hardcoded `2048 + decoder.data.trans_week`,
which works only for recordings inside the post-2019 cycle. Recordings
from earlier cycles produced PVT timestamps offset by N×19.6 years
(observed: ION RTL-SDR sample data from 2017-09-10 reported as
2037-04-26).

This change adds an `approximate_year::Integer` keyword to `calc_pvt`
(default `year(now(UTC))`) that selects the cycle whose absolute date
is closest to the anchor. Anything within ~9 years of the actual
observation date works.

Live receivers keep working with the default. Post-processing of
archived data passes the rough year of the recording; for example:

    calc_pvt(states; approximate_year = 2017)

Pinning is also necessary on test fixtures that bake in expected TAI
timestamps — without it those tests would silently break in 2030+ when
the wall-clock-derived default starts picking the next cycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@zsoerenm
zsoerenm force-pushed the ss/fix-week-rollover branch from f106854 to e7e2191 Compare May 7, 2026 16:57
@zsoerenm
zsoerenm merged commit 796557a into master May 7, 2026
8 checks passed
@zsoerenm
zsoerenm deleted the ss/fix-week-rollover branch May 7, 2026 17:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant