Files
gps/soc/software/gps_system/docs/RECEIVER_TIMING_AND_REACQUISITION.md
T
onur f59d6ebb43 fix(gps): correct week-number handling across almanac, ephemeris and flash
Persisting navigation data changed its lifetime from one power cycle to
weeks, and every piece of arithmetic that had only ever run over the
shorter lifetime turned out to be wrong. Each fix below reproduces in a
host test that fails against the previous behaviour.

Almanac (gps_almanac.c, gps_lnav_parser.c)

WNa was lifted against gps_ephems.toe.week, a field nothing ever writes,
so any WNa above 128 underflowed to ~4.29e9. The propagation then reduced
tk with a while-loop, turning one visibility refresh into 4.29e9
iterations - hours on the target, inside a 10 s periodic call. WNa is the
week modulo 256, so this armed at GPS week 2433 and then for 127 weeks in
every 256. Now lifted against tow.week in signed arithmetic, refused if
impossible, and the propagation is closed form.

The IS-GPS-200 20.3.3.5.2.3 half-week wrap was also applied to tk
unconditionally. It is a week-crossover correction, not a general
reduction: a GPS week is 14.0384 orbital periods, so aliasing one week
moves a satellite 5161 km, enough to report one 5.1 degrees below the
horizon as 5.7 above. The crossover guess now lives in
gps_almanac_assumed_week(), used only before page 25 arrives, and an
almanac too old to propagate yields no prediction rather than a wrong one.

Ephemeris age (gps_ephemeris_validity.c, gps_lnav_parser.c)

The curve-fit check compared two second-of-week values and never read a
week at all, so a record exactly N weeks old read as current. Harmless
while ephemeris lived in RAM; directly reachable once persisted across a
power-off. gps_resolve_toe_week() now reconstructs Toe's week at commit
time from the subframe 1 week number and the handover word; an
unresolvable week yields no age, which reads as a rejection.

A restored record could also outrank the live broadcast, because the Toe
ordering inverts past half a week. Flash records are now marked restored
and are candidates, never incumbents.

Flash wear (gps_nav_context_store.c, gps_interrupt_handlers.c)

The CRC wear policy never engaged: the payload carried the live WLS
position and a receiver timestamp nothing on the restore path read, so
every 60 s save was an erase - about 139 days to wear-out. The seed is
now held and refreshed only on 5 km of movement, at most hourly: one
erase per day instead of 1440.

Measurement model (NavEquations.c)

The elevation weighting was floored at 10 degrees, capping its term at
17.3 m where the model's own answer is 34.4 m at 5 degrees and 86 m at 2,
so a low satellite was trusted several times more than the model allowed.
Floor lowered to 3 degrees, which is where 1/sin must still be bounded
because a zero weight fails the whole epoch. Measured on a six-satellite
geometry, 30 m of uncorrected delay now moves the fix 4.18 m through a
4-degree satellite against 48.50 m through a 72-degree one.

Both atmospheric corrections switched off below the horizon, so a
satellite crossing it lost 78.8 m of tropospheric delay in one step.
Elevation is now clamped at the horizon for the models, and the
extrapolation is paid for in variance.

Also: gps_pps_select_reference() returned early on the preferred PRN,
leaving later non-finite capture times unscreened.

Checked and found correct, with regressions added: the epoch assembly
under satellite connect/disconnect and reassignment, coalesced captures,
the 32-bit RF sample counter wrap (pseudoranges agree to 3.3 um across
it), the refusal to publish a solvable subset of an incoherent epoch, and
the Klobuchar model against IS-GPS-200 20.3.3.5.2.5.

Tests: 22 host suites, 5 RTL testbenches, sanitizer clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:45:35 +03:00

70 KiB
Raw Blame History

Receiver timing, ephemeris lifetime and warm/hot reacquisition

Reconstructed from the RTL, HAL and firmware in this repository, not from assumption. Every claim below names the file it was read from. Genesys2 is the reference target; nothing here is board specific unless it says so.


1. Architecture map

Concern Where
Firmware entry gps_system.c, src/gps/gps_receiver.c
Acquisition scheduling / PRN state src/gps/gps_acq_manager.c
ACQ + TRCK register access, WLS driver src/gps/gps_ip.c
ISR, event queue, epoch assembly src/gps/gps_interrupt_handlers.c
Navigation epoch algebra src/gps/gps_nav_epoch.c
HOW/sample time anchors src/gps/gps_time_anchor.c
Ephemeris RAM cache src/gps/gps_ephemeris_cache.c
Ephemeris ICD validity src/gps/gps_ephemeris_validity.c
PPS discipline + capture read src/gps/gps_pps.c
WLS / satellite position src/gps/NavEquations.c
Tracking IP hardware/external/gps/rtl/trck_src/
Tracking APB wrapper hardware/external/gps/rtl/apb_wrappers/tracking_wrapper.sv
Nav-solve epoch timer hardware/external/gps/rtl/gps_timer/nav_solve_timer.sv
Receiver sample counter hardware/external/gps/rtl/common/sample_counter.sv
PPS block hardware/external/gps/rtl/pps/pps_wrapper.sv

Board constants: 9 tracking channels, 1 acquisition engine, RF sample rate 16 367 981 Hz (Navigation.h), PPS counter clock 125 MHz (gps_pps.h), CLINT timebase 500 kHz, nav-solve multiplier 10 (GPS_NAV_SOLVE_TIMER_TICKS, so a 200 ms navigation epoch), foreground systick 10 Hz.


2. The receiver timebase

Two independent counters carry receiver time. They are not interchangeable.

ABS_SAMPLE — common/sample_counter.sv instantiates one 32-bit counter for the whole receiver, clocked by the AFE sample strobe, shared by every tracking channel. It is not reset when a channel starts, so it is a continuous receiver-wide timeline. At 16.368 MHz it wraps every 262.4 s; that period is the hard ceiling on any unambiguous sample-domain extrapolation, and it is why GPS_HOW_CONTINUITY_MAX_GAP_SEC is 120 s.

Each channel latches ABS_SAMPLE at its nav-bit boundary (trck_src/bit_gen.sv) and again into the APB register on a subframe-ready or nav-solve pulse (tracking_wrapper.sv:480). REM_CODE_PHASE is latched on the same pulse, so gps_correct_abs_sample() recovers the sub-sample code phase from a coherent pair.

PPS counter — a free-running 64-bit counter in the 125 MHz PPS domain, captured per channel on the nav-solve pulse. This is the epoch identity used by the coalescing logic.

2.1 Is the counter read CDC-safe and atomic? Yes — verified from RTL

This was the open Phase 3 question. The path is safe as built; no RTL change is needed.

  1. The PPS counter is Gray-coded in its own domain (pps_wrapper.sv:100), crossed through a two-stage ASYNC_REG chain (raw_gray_sync1_p/raw_gray_sync2_p) and converted back to binary in PCLK. Gray coding makes the multi-bit transfer coherent by construction: a synchronised sample is always exactly N or N-1, never a mixture.
  2. capture_i[ch] is not a clock crossing. It is nav_solve_timer_reached, produced by nav_solve_timer.sv which already runs in PCLK, and pps_wrapper consumes it in PCLK.
  3. Counter and sequence are written in the same PCLK always_ff (pps_wrapper.sv:236), so the capture register pair is atomic in hardware.
  4. Firmware reads sequence / lo / hi / sequence and rejects the sample if the sequence moved (gps_pps_read_capture, gps_pps.c:277). That is a correct seqlock over three APB transactions.

The ABS_SAMPLE crossing (tracking_wrapper.sv) is a plain 32-bit two-flop synchroniser with no Gray code, which would normally be a defect. It is safe here for a specific reason worth recording: ABS_SAMPLE is quasi-static — it changes once per 20 ms nav bit and holds — and the PCLK capture happens at bit_vld_cc_p[0] & !bit_vld_cc_p[1], which is one PCLK later than the synchroniser output settles. That extra cycle covers a one-cycle skew between individual bits. The margin is real but it is implicit; anyone retiming this wrapper must preserve it.

Contract: a measurement is (PPS capture counter, capture sequence, raw ABS_SAMPLE, REM_CODE_PHASE, PRN, status flags, CLINT timestamp), all latched by one hardware pulse and read in one ISR pass (trck_intr_handler).


3. Navigation epochs

nav_solve_timer.sv does more than divide by N. On leaving IDLE it seeds its counter with (HOW Z-count * 300) mod N, so every channel — whichever subframe it happened to lock onto — produces nav-solve pulses on the same absolute navigation-bit grid. Epochs are globally aligned to GPS time, not to when each channel started.

Firmware collects those pulses in gps_nav_epoch_assembly_push (gps_nav_epoch.c), keyed on the PPS capture counter with an explicit deadline_local_ticks. It already handles duplicate, stale, outlier, restarted and complete outcomes, removes a channel that drops mid-epoch, and never waits past its deadline. This part of the design is sound and was left alone.


4. The 68 ms start offset

STARTOFFSET 68.802 (ms) is used in exactly two places (gps_ip.c:956, gps_ip.c:1253). It is not a measured hardware delay and not a per-channel property. It is a synthetic common-mode datum: pseudoranges are formed as (t_i - t_reference + STARTOFFSET), which makes every pseudorange land near a plausible 68 ms transit time. Because it is identical for all channels it is absorbed entirely by the WLS receiver-clock-bias state; the fitted bias is c*STARTOFFSET - reference_range. It carries no timing information and cannot go stale across reacquisition, because the real timing comes from the HOW/sample anchor.

4.1 Decision: retained, not replaced

The free-running counters do not replace STARTOFFSET, and were never meant to. The two solve different problems:

  • STARTOFFSET concerns absolute receive time, which this receiver never determines. Receive time does not appear in the pseudorange construction at all — gps_ip.c:617 forms a transmit-time difference against a reference satellite plus a constant plausible transit. Identical on every channel, so WLS absorbs it entirely into b.
  • The free-running counters (32-bit ABS_SAMPLE, 64-bit PPS) yield differences, not an absolute datum. They anchor HOW→transmit time, identify epochs, and now drive the warm-start seed and PRN context. None of that produces an absolute receive epoch, so none of it can displace STARTOFFSET.

It is also a per-solve constant, recomputed every epoch rather than carried state, so it cannot go stale across a reacquisition — which is why the warm-start work did not need to touch it.

Live confirmation (2026-08-08, Genesys2): the model predicts a fitted bias of c × STARTOFFSET − reference_range ≈ 20 626 km − ~20 372 km ≈ 254 km. The board reported filtered_bias ≈ 253.686 km. The documented model matches measured data.

Replacing it is out of scope and not planned. Should it ever be wanted, the work is:

  1. Use the PPS-disciplined absolute receive time: pseudorange_i = (t_rx − t_tx_i) × c.
  2. Solve the cold-start bootstrap: PPS discipline needs a fix, and the fix would then need the PPS.
  3. Retune for b collapsing from ~254 km to a genuinely small bias — gps_pps_time_filter (whose header states this datum explicitly), the residual gates, and the ESKF/GPS adapter covariances.

That is a distinct change carrying real regression risk to a working fix.


5. Reacquisition: what actually gates it

Sequence when a tracked PRN drops and returns (gps_interrupt_handlers.c):

  1. Drop IRQ → node deleted, HOW anchor destroyed, channel freed.
  2. Acquisition re-finds the PRN.
  3. assign_tracking_from_result restores the cached ephemeris into the channel's frame, so ephemeris_valid is true immediately.
  4. First nav-bits IRQ (one subframe, ≤ 6 s) creates the node and the HOW anchor.
  5. Nav-solve interrupts are enabled and the channel joins the next epoch.

So the receiver already does not wait for subframes 1–3 — the RAM cache covers that, and the effective wait is one subframe, not a full ephemeris set. The measured floor is therefore ~6 s worst case, ~3 s typical, versus 18–30 s if the cache is missed.

Getting below that is blocked in hardware, not firmware. nav_solve_timer.sv:72 will only leave IDLE on subframe_ready && trck_busy, and it drops back to IDLE whenever trck_busy deasserts — which is exactly what a reacquisition does. Until the channel decodes one valid subframe, the IP emits no nav-solve pulse, so there is no measurement to place in an epoch, no PPS capture, and nothing for firmware to accept. Firmware and hardware are currently in lockstep at the same one-subframe boundary; removing the firmware-side wait alone would buy nothing.

5.1 RTL change — approved and implemented

  1. Problem. After a channel restart, nav_solve_timer cannot resume until a fresh subframe_ready, costing up to 6 s even when the receiver already knows GPS time, has valid ephemeris for that PRN, and the channel is locked and bit-synchronised.
  2. Why firmware/HAL cannot fix it. The gate is internal to the IP. The epoch phase is derived from subframe_count (the HOW Z-count) inside the timer; software has no register that seeds the counter or forces the state machine into COUNT. There is no alternative pulse to timestamp.
  3. Smallest change. Add one write-only APB register to tracking_wrapper, NAV_SOLVE_PHASE_SEED (32-bit value + one arm bit). When armed, nav_solve_timer may also leave IDLE on the armed seed, loading internal_counter_p with the supplied phase instead of computing it from subframe_count. The existing subframe_ready path stays untouched and still overrides the seed on the next real subframe, so the hardware remains self-correcting. Firmware computes the seed from the persisted PRN anchor and the current receiver time.
  4. Affected modules. apb_wrappers/tracking_wrapper.sv (one register, one decode case), gps_timer/nav_solve_timer.sv (one extra IDLE exit and one mux on the counter load). No datapath, no new clock, no change to the measurement or PPS capture path.
  5. CDC/timing. The register is written and consumed in PCLK; the timer already runs in PCLK. The seed is quasi-static and only sampled on the IDLE exit. No new crossing, no new asynchronous path, no effect on the tracking domain. The arm bit must be cleared by hardware on the IDLE exit so a stale seed cannot re-arm.

The correctness precondition is that the reacquired channel's bit grid is the satellite's true grid — which bit_gen.sv guarantees, since it syncs on a real 1→0 navigation-bit transition. Without that, seeding would place measurements on a grid offset by a multiple of 20 ms.

5.2 Seed contract

phase_seed_value is the counter value in effect for the next navigation bit after the timer consumes the seed. The hardware raises an epoch on the bit whose counter value is nav_solve_ticks - 1, so

seed = (absolute navigation-bit index of the next bit) mod nav_solve_ticks

This is identical to the subframe path, where the ALIGN divider produces (Z_count * 300) mod N and Z_count * 300 is exactly the absolute index of the first bit after the subframe boundary. Both paths therefore land on absolute navigation-bit indices b with (b + 1) mod N == 0 — one global grid.

Hardware guarantees, each covered by a Questa test:

  • the seed is answered in one cycle, in every state, so an armed request has a lifetime of one evaluation and cannot be applied to a later reacquisition;
  • it is only applied from IDLE, and a decoded subframe in the same cycle wins;
  • an unreduced seed (>= counter_max_val) is refused rather than aliased, and still clears the arm bit so firmware cannot wedge waiting for an answer;
  • a seeded phase is marked provisional, and the first decoded subframe re-runs the authoritative HOW alignment and clears the flag — a wrong seed self-corrects within one subframe instead of persisting for the whole lock;
  • trck_busy deasserting returns the timer to IDLE and clears provisional.

5.3 Firmware side

State is split by lifetime, as the design called for:

  • PRN context (gps_prn_context[], survives channel reassignment) — the last confirmed nav-solve epoch for that satellite: transmit time, PPS capture counter and RF sample latch, all captured by one hardware pulse and therefore internally coherent.
  • Channel warm-start state (trk_seed_requested, trk_seed_unconfirmed, trk_seed_prn) — cleared on drop, never allowed to outlive the channel.

Flow on reacquisition: cached ephemeris is restored and qualified (section 6); if a PRN context exists, a seed request is raised. The foreground arms it once the channel reports busy+lock. The phase is computed entirely in the PPS counter domain — that counter is 64-bit and never wraps, unlike the 32-bit RF sample counter which repeats every ~262 s, so it is the only timebase that spans a multi-minute outage unambiguously.

Three independent guards stand between a bad seed and a bad fix:

  1. Guard band. The arm is refused within 4 ms of a navigation-bit boundary, so the applied phase cannot be one bit late. A refused attempt simply retries on the next foreground pass.
  2. Grid snap. The first seeded epoch's predicted transmit time is snapped to the known nav-solve grid and the residual must be under 50 ms. The hardware can only raise that epoch on a multiple of the nav-solve period, so a prediction that does not land there proves the context is stale. On failure the channel reverts to waiting for a subframe.
  3. Exclusion until proven. A seeded channel is kept out of gps_nav_ready_channel_mask() until its first epoch validates, so an unproven seed can never hold up a coalescing epoch.

Once the first epoch validates, its transmit time and ABS_SAMPLE become an ordinary HOW/sample anchor and every later epoch uses the existing, already tested sample-anchor path — including its 5 ms snap check. The anchor is committed with gps_how_week_valid clear, because the week still comes from the PRN context rather than a parity-checked subframe 1; the first decoded subframe upgrades it and the existing HOW-continuity check confirms or rejects it.

Diagnostics: warm_seed_armed_total, warm_seed_confirmed_total, warm_seed_rejected_total.

5.4 Resulting latency

A returning PRN with valid cached ephemeris and a live PRN context joins the next nav-solve epoch — 200 ms at the configured multiplier of 10 — instead of waiting up to 6 s for a subframe. The true lower bound is one nav-solve period plus the time for the channel to report lock, because the seed cannot be armed before the tracking loop has locked and the epoch grid is 200 ms coarse.

The fast path is refused, and the receiver falls back to the subframe wait, when there is no cached ephemeris, no PRN context, the context is older than 15 minutes, the ephemeris is outside its fit interval, or any of the three guards above rejects.


6. Ephemeris lifetime (implemented in this change)

Cache key was already PRN, not channel (gps_ephemeris_cache.h), so channel reassignment never destroyed usable ephemeris. What was missing was any GPS-time notion of validity: the only test was "decoded less than two hours of systick ago", which measures decode age, not fit validity, and would happily survive a receiver-time jump.

Added gps_ephemeris_validity.{h,c}:

  • gps_ephemeris_fit_interval_hours() — IS-GPS-200 Table 20-XI/20-XII. Fit flag clear is the nominal 4 h curve fit. Fit flag set uses the tabulated IODC ranges (240–247 → 8 h, 248–255 and 496 → 14 h, 497–503 → 26 h, 504–510 → 50 h, 511 → 74 h). Project assumption, documented in the header: a set fit flag with an IODC outside those ranges falls back to 6 h, which is shorter than the ICD's smallest tabulated long fit and therefore conservative.
  • gps_ephemeris_toe_offset_sec() — signed half-week difference, so Toe near a week boundary and receiver time just past it gives the true small age instead of nearly a week. LNAV does not broadcast a week for Toe, and this avoids needing one.
  • gps_ephemeris_check_static() — committed flag, PRN identity, IODE equal to the low eight bits of IODC, health, URA index < 15, orbital parameter sanity. Runs without receiver time, so a flash record can be qualified at boot.
  • gps_ephemeris_check() — the above plus |t - Toe| <= fit/2.
  • gps_ephemeris_supersedes() — a re-decode of the same data set is not new information; only a changed issue of data with an advancing Toe (or a health or URA change inside one issue) replaces the cached record. This is the write trigger the flash wear policy will need.

Wiring:

  • gps_ephemeris_cache_store_if_new() replaces the unconditional store in the nav-bits path and reports whether content actually changed.
  • gps_ephemeris_cache_restore_at() qualifies a restored record against current receiver time on channel assignment; an age-rejected record is dropped rather than reused.
  • gps_ephemeris_ready_for_position_at() applies the fit window at the epoch's own transmit time in gps_collect_completed_epoch_locked(), so an ephemeris that expires between measurement creation and solve time is excluded.
  • A coarse receiver GPS time datum is maintained from accepted nav-solve epochs and extrapolated with the systick between them. It is only ever used to place an ephemeris inside or outside a multi-hour window; the navigation solution still uses per-channel transmit times.
  • subframe 2 word 10 bit 17 is now decoded into fit_flag.
  • Diagnostics gained ephemeris_cache_miss_total, ephemeris_rejected_total and the last rejecting PRN/reason, surfaced in the nav status line.

Normal operation is unaffected: a broadcast ephemeris is transmitted with Toe at the centre of its fit interval, so |t - Toe| stays well inside 2 h while it is being received.


6.1 Flash persistence

gps_ephemeris_store.{h,c}, on the top two 64 KiB sectors of the 32 MiB S25FL256S (0x01FE0000), clear of the bitstream at 0 and the application at 0x01800000.

What is stored is the three raw LNAV subframes, not the parsed structure. That keeps the format independent of compiler layout and of any later change to gps_ephemeris_t, and it means a restored record is decoded by exactly the decoder that produced it. To make that possible the LNAV decoder was split out of the MMIO-heavy gps_ip.c into gps_lnav_parser.c, so it links into host tests and into the store without dragging in the tracking driver.

Format: two erase sectors as an append-only log pair. Each opens with a header slot carrying a generation number; the higher valid generation is live. Records occupy one 256-byte page and are committed by their CRC-32, so a record torn by power loss simply fails to validate and is skipped. When the active sector fills, the live set is rewritten into the spare at generation+1 and only then is the old sector erased — a power loss at any point leaves a mountable store.

Wear: an erase happens only on first format and on compaction. A record reaches flash only when gps_ephemeris_supersedes() says the data set is genuinely new, so re-decoding the same broadcast costs nothing. With ~8 tracked satellites uploading every ~2 h, a 256-slot sector fills in roughly 2–3 days, so the two sectors see on the order of one erase every few days against a ~100k-cycle endurance.

Boot: records load into the RAM cache as candidates. They are not trusted for navigation until receiver time exists — gps_ephemeris_cache_restore_at() then applies the curve fit window (section 6), and an out-of-fit record is dropped rather than used.

Caveat that matters on the board: a 64 KiB sector erase on this device runs for hundreds of milliseconds. Writes are therefore deferred out of the event path and drained one record per background pass, but a compaction erase will still stall the foreground. The ISR keeps queueing (64-deep queue, ~23 events worst case during an erase) so nothing is lost, but expect two or three degraded nav epochs when a compaction lands. Build with GPS_EPHEMERIS_FLASH_ENABLE=0 for firmware that never writes flash.

Verified on a simulated device that enforces real NOR semantics — programming only clears bits, and writes can be truncated mid-byte-stream: format, remount, supersede, compaction, torn record, power loss at every program of the compaction sequence, corrupt header, duplicate save, unhealthy/wrong-PRN rejection, and a CRC-32 known-answer plus exhaustive single-bit-flip check.

7. Clock bias versus code ambiguity

WLS solves [x, y, z, b] with a common-mode clock bias, so a receiver clock offset that is genuinely common to all channels is absorbed regardless of size. That is not the same as tolerating an arbitrary outage, and the distinction matters:

  • The anchor extrapolation converts an ABS_SAMPLE difference to elapsed time and then snaps to the nav-solve grid with a 5 ms tolerance (gps_time_from_periodic_sample_anchor). Satellite range rate contributes ≈2.7e-6 * Δt; over 262 s that is 0.7 ms — inside the tolerance. Sample clock error at a few ppm over the same interval is of the same order. So the snap is the binding constraint well before the clock bias is.
  • The ABS_SAMPLE counter wraps at 262.4 s. Beyond one wrap the elapsed time is ambiguous by 262.4 s, which is not recoverable from the sample domain alone. Any reuse of a persisted anchor must therefore be bounded below one wrap, or disambiguated by the PPS counter (64-bit, no practical wrap).
  • The 1 ms C/A period bounds anything derived from code phase alone: a receive time uncertain by more than ±0.5 ms cannot be assigned to the correct code period without an independent integer resolution step.

This is why "WLS will fix it" is not a sufficient answer for the 1 min / 5 min outage cases: the clock bias is fine, the epoch identity is not.


8. Build and test

# Host models and unit tests
make -C soc/software/gps_system/tests run
make -C soc/software/gps_system/tests sanitize

# RTL simulation (Questa).  Covers the epoch timer, the seed state machine and
# the APB register contract, plus the pre-existing measurement testbenches.
make -C hardware/external/gps/rtl/trck_src/tb test
make -C hardware/external/gps/rtl/pps/tb test

# Bare-metal firmware
make -C soc/software/gps_system

# Genesys2 image
make FPGA_BOARD=genesys2 APP=gps_system fpga-mcs

RTL testbenches:

Testbench Covers
gps_timer/tb/nav_solve_timer_seed_tb.sv 9 cases: subframe path unchanged, seed starts epochs, seeded grid equals the subframe grid in absolute bit indices, unreduced seed refused, busy/enable required, late seed ignored, subframe overrides a wrong seed, seed cannot survive a drop, subframe wins a same-cycle tie
trck_src/tb/tracking_wrapper_seed_tb.sv APB register read/write, arm bit self-clears, provisional status bit, seeded epochs raise real interrupts and PPS captures, stale seed does not restart after a drop, out-of-range seed refused

Host models:

Test Covers
test_gps_warm_reacquisition_sim.c Behavioural model of nav_solve_timer.sv driven by the real firmware functions, so RTL and firmware are cross-checked rather than each verified alone: outage matrix 20 ms → 900 s at 0/±2 ppm, seeded grid identical to an uninterrupted channel, guard band, wrong-seed correction by subframe, stale seed after a drop, coalescing with a joining channel, drops mid-epoch, stale and duplicate events
test_gps_ephemeris_store.c Simulated NOR device with bit-clearing semantics and power-loss truncation
test_gps_time_anchor.c Seed phase, guard band, epoch snap, drift bounds
test_gps_ephemeris_validity.c Fit interval table, week rollover, supersede

8.1 Board validation, 2026-08-08 (Genesys2, live sky)

Bitstream from the 00:11 build (postdates the RTL change), programmed to FPGA SRAM; firmware loaded over JTAG into DTIM; live antenna.

The RTL register is real. NAV_SOLVE_PHASE_SEED at 0x10021074 reads 0, accepts 0x5A, reads it back, restores. trck0 STATUS = 0xdb decodes to BUSY+LOCK+NAV_BITS_RCV+NAV_SOLVE_FLAG+MEASUREMENT_FLAG with the new NAV_SOLVE_PROVISIONAL bit clear — correct for a subframe-aligned channel.

Cold start unaffected. Full fix, PPS locked, 8 satellites, GDOP 2.38, disciplined at -1.56 ppm. ephemeris_rejected_total = 0 throughout, so the new IS-GPS-200 fit-interval gate excludes nothing it should not.

Warm seed, forced drop of PRN 10 (stop bit written by read-modify-write so the channel's enables were preserved):

Counter before after
satellite_lost_total 1 2
last_lost_prn 12 10
ephemeris_cache_restore_total 0 1
warm_seed_armed_total 0 1
warm_seed_confirmed_total 0 1
warm_seed_rejected_total 0 0

The seed register held the computed phase (1), the first seeded epoch passed the grid snap, and the channel came back with NAV_SOLVE_PROVISIONAL already cleared — a real subframe had re-aligned it, which is the designed self-correction. Navigation improved across the event (GDOP 5.36 -> 2.38).

Flash persistence. Controlled reboot test: six records written, reset, then flash_ephemeris_loaded_total = 6, store scanned = 6 / rejected = 0, ephemeris_cache_restore_total = 3 against only 1 miss (cold boots show 6-9 misses). Steady state after a minute: 7 writes, 5 restores, 2 misses, 0 errors, 0 erases, 0 compactions. Satellites are navigation-eligible on acquisition without re-decoding subframes 1-3.

Diagnostics are only readable at GPS_DEBUG_LEVEL=3, which is unusable on this board — level 3 saturates the 460800 baud UART, starves the foreground and drives every channel stale (tracked=0 stale=5). All the warm-seed and flash results above were read from gps_runtime_diagnostics over JTAG at level 1 instead. That is what the structured counters are for; do not chase these paths with printf.

Unexplained, carried forward: on the first flash-enabled boot the store mounted with generation = 1 and erases = 0, i.e. it found an already formatted store with one record that no flash-enabled run in that session should have written. Blank flash cannot be mistaken for a valid store (magic would read 0xffffffff), and GPS init demonstrably runs once, so this is not yet accounted for. It does not affect the controlled reboot result above, but the next clean run should erase the two sectors first and confirm erases = 2 at format.

8.2 Follow-up work, 2026-08-08

Six items were triaged after the first board session and all were implemented.

Event queue. GPS_EVENT_QUEUE_SIZE 64 -> 128. The 64-entry queue overflowed 6-7 times during the startup burst, and the margin mattered more once flash compaction became possible: a sector erase stalls the foreground for hundreds of ms, during which nine channels contribute ~23 nav-solve events alone. Overflow is safe by itself - tracking events fall back to per-channel shadow buffers - but a lost nav_bits event costs a subframe. Board result: event_overflow_total = 0, previously 6-7.

Split seed-rejection counters. warm_seed_rejected_total now breaks down into warm_seed_refused_total (the tracking IP declined the armed seed, i.e. the channel lost lock between the busy/lock check and the arm) and warm_seed_snap_failed_total (the first seeded epoch missed the nav-solve grid, i.e. the PRN context no longer describes that satellite). The two have entirely different meanings and were previously indistinguishable.

Early issue-of-data cross-check. gps_lnav_broadcast_iode() extracts the IODE from a raw subframe without staging or committing it - subframe 1 via the low byte of IODC, subframes 2 and 3 directly. The nav-bits path compares it against the ephemeris in use before parse_subframe() can replace anything, and counts ephemeris_iod_mismatch_total. This detects within one subframe, rather than a full set, that a record (typically flash restored) has been superseded.

Navigation behaviour deliberately does not change: IS-GPS-200 permits using an older ephemeris inside its curve fit interval, and that window is already enforced per epoch. The value is observability and a faster refresh decision.

Flash test hooks. Two build options, both required to be 0 in production:

  • GPS_EPHEMERIS_FLASH_FORCE_FORMAT=1 erases both sectors at boot.
  • GPS_EPHEMERIS_STORE_MAX_SLOTS=N caps usable slots per sector, so compaction is reachable in seconds instead of after 256 records - while still performing real 64 KiB erases through the same code path.

Board result - format path. With FORCE_FORMAT=1: erases = 2, generation = 1, next_slot = 1, scanned = 0. Two real sector erases via the 4-byte-address 0xDC opcode, header written, store empty. This also settles the earlier unexplained generation = 1: mount from a genuinely blank store is correct, so that observation was leftover state from an earlier run, not a mount defect.

Plumbing defect found twice. Both times a new build flag was added it was silently dropped because targets/fpga/genesys2/Makefile did not forward it, so the board build used the header default and the test appeared to do nothing. GPS_WARM_SEED_ENABLE, GPS_EPHEMERIS_FLASH_ENABLE, GPS_EPHEMERIS_STORE_MAX_SLOTS and GPS_EPHEMERIS_FLASH_FORCE_FORMAT are all forwarded now. Anything added later must be added in three places: the header #ifndef default, soc/software/gps_system/Makefile, and the target Makefile.

8.3 Compaction on hardware, and a destructive bug it exposed

With GPS_EPHEMERIS_STORE_MAX_SLOTS=4 and FORCE_FORMAT=1, compaction ran twice on the real device: generation = 3, erases = 6, compactions = 2 - exactly 2 for the format plus 2 per compaction, with the generation advancing once each time. The compaction path, including real 64 KiB erases, works.

The same run also showed flash_ephemeris_error_total = 2, which turned out to be a genuine defect rather than a test artefact:

Compaction could erase flash indefinitely. When the live record set fills the sector exactly, compaction succeeds but leaves no free slot. The append that follows then fails, the save returns an error, and the next save compacts again - erasing two more sectors. Nothing bounded that loop. On a device rated around 100k erase cycles, a receiver in that state would consume them continuously.

Two things were wrong in the original code. The capacity check only covered the case where the live set does not fit at all, and by then the spare sector has already been erased; it did not cover the exact-fit case, which is the one that actually occurred. Both now latch capacity_exceeded, which refuses all further saves without touching the device, and the firmware disables persistence and clears the pending mask when it sees the latch. A remount clears it, so recovery is possible once the configuration or the live set changes.

Production settings cannot reach this - 32 PRNs against 255 usable slots - so it was only reachable because the slot cap made it reachable. That is precisely what the test hook was for. A regression test drives the store into the condition and asserts the erase count stops changing afterwards.

Event queue and erase stalls. With the queue at 128, a run without flash compaction showed event_overflow_total = 0, against 6-7 at 64: the startup burst is now covered. A run with two compactions showed 6 overflows again, which is the erase stall - an S25FL256S sector erase is specified in hundreds of ms and can reach seconds, and nine channels generate roughly 45 nav-solve events per second. Overflow is not data loss (per-channel shadow buffers hold the newest drop, nav-bits and nav-solve event each), but a second nav-bits event arriving inside one stall is dropped and costs a subframe. Sizing the queue for a worst-case erase is not practical; the honest characterisation is that a compaction costs a few degraded nav epochs, and compaction is rare - once every few days at production slot counts.

8.4 Clean measurement run (no JTAG halts)

Production flags, roughly three minutes of untouched running, then one read. Only the first halt after a load yields usable numbers - see the measurement caveat below:

Counter With JTAG halts Clean run
nav_epoch_incomplete_total 140 1
measurement_missed_total 356 0
flash_ephemeris_error_total 2 0
satellite_lost_total 6 0

So both were entirely artefacts of debugger halts, not firmware defects. The receiver stayed healthy throughout: 6 satellites, PPS in precision mode, GDOP 2.83, locked.

Measurement caveat, quantified. A later attempt to re-read the same counters at 338 s uptime showed how large the effect is. Two reads seconds apart in one session returned measurement_missed of 122 and then 5914: the second halt had inherited the stall of the first. One gdb -batch connect/halt/read/resume cycle therefore costs on the order of 5900 missed measurements, about 13 s of frozen foreground at nine channels and 50 Hz.

Consequences for anyone repeating this:

  • Only the first read after a load is clean. Every subsequent halt pollutes measurement_missed and nav_epoch_snapshot_overwrite.
  • event_overflow_total (4) and nav_epoch_incomplete_total (1) were identical in the clean read and the contaminated one, so those two are robust against halting and can be trusted from a later read.
  • Continuous monitoring needs UART telemetry or a non-halting transport, not JTAG.

Two things remain non-zero and are honest observations rather than resolved items:

  • event_overflow_total = 4. At queue 64 this was consistently 6-7; at 128 we have observed both 0 and 4. Reduced, not eliminated - the startup burst can still exceed 128 when many satellites appear at once. Safe (shadow buffers), but not fixed.
  • nav_epoch_snapshot_overwrite_total = 40 in the clean read. This one is itself inflated by the single halt that read it, so the true steady-state rate is unknown and the figure should not be quoted as a defect rate. Measuring it properly needs UART telemetry.

8.5 Slow recovery after a total outage: two software root causes

Symptom reported from the bench: after pulling the RF cable the receiver took about a minute to print a position again, and position output also stalled for seconds whenever a new satellite joined. Forced total-drop tests reproduced it, with recovery stalls of 14 s, 36 s and, in the worst case, no recovery at all for over two minutes.

Finding these took four attempts. Three hypotheses were wrong - silent channels holding the expected set, the 25 ms epoch collection window, and simply a weak sky - and the reason they survived as long as they did is worth recording.

The counter that caused the misdiagnosis

nav_epoch_incomplete_total was incremented from three places, two of which mean "the epoch assembly failed" and one of which means something completely different: gps_nav_publish_active_snapshot() also incremented it when an epoch assembled perfectly but carried fewer than GPS_NAV_MIN_WLS_SATELLITES.

That third case dominates by an order of magnitude. On the board, 633 total resolved into 563 "below minimum" against 70 real assembly failures - 89% of the counter was a satellite-availability statement, not an epoch fault. Every diagnosis built on it pointed at the epoch machinery, which was healthy all along.

It is now split into nav_epoch_assembly_failed_total and nav_epoch_below_minimum_total; the original is kept for continuity but should not be used. A single ambiguous counter cost three wrong diagnoses; that is the lesson worth keeping from this episode.

Root cause 1: inverted acquisition retry priority

gps_acq_manager_retry_due() served a HOT PRN - one tracked seconds ago and therefore almost certainly still above the horizon - with hot_retry_delay_ticks (30 s) from its first retry, while a COLD PRN that had never been seen was retried every retry_delay_ticks (0.5 s).

The best candidate available was penalised sixty times harder than the worst. After a mass loss the known-good satellites went into a 30 s lockout while the single acquisition engine spent ~0.5 s per attempt sweeping 26 cold PRNs that mostly were not visible. The board log shows it exactly: hot=2, engine cycling cold PRNs, tracked pinned.

Fixed by making recency decay with failures rather than being discarded on the first one: GPS_ACQ_HOT_FAST_ATTEMPTS (6) retries at the fast cadence before the long backoff applies, reset when the PRN tracks again. The regression test in test_gps_acq_manager.c was verified to fail against the old policy and pass with the new one.

Root cause 2: circular dependency in nav-solve enablement

gps_ip.c enabled nav-solve interrupts only inside the else branch of

if(ready_count < GPS_NAV_MIN_WLS_SATELLITES) { ... }
else { gps_enable_nav_solve_for_snapshot_locked(...); }

A channel cannot contribute to a navigation epoch until its nav-solve interrupt is enabled, and the enable only ran once four channels were already contributing. Below four satellites the receiver could not climb back out.

This is why recovery was intermittent rather than consistently broken: the warm-seed path enables nav-solve independently, so whenever a seed was confirmed it masked the deadlock. That intermittency is what made the fault hard to see.

Fixed by hoisting the enable out of the branch. Each channel is already qualified individually by gps_node_ready_for_position() - committed ephemeris, alive, fresh measurement - so enabling one early is safe; epochs formed below the minimum simply complete and are counted as nav_epoch_below_minimum_total.

Verified on hardware: with four channels tracking and no fix, all four read navsolve_irq_en=1. Under the old code that state was unreachable.

Hardware was not the constraint

Capture spread peaked at 12.7 ms against a 25 ms sync window, and epoch restarts were zero. Neither fault needed an RTL change. The single acquisition engine only sets the cost of a wasted attempt (~0.5 s/PRN); it was being pointed at the wrong satellites and its results were being withheld from the solver. Adding engines would have reduced the cost of the waste without removing it.

Not yet validated

The mechanism of both fixes is verified; the outcome is not. Sky conditions degraded through the session - 7-8 satellites early, 4 before the last drop, 2 after - and below four satellites no fix is possible under any code, so those runs cannot distinguish fixed from unfixed. A drop test with 7-8 satellites is still owed, watching nav_epoch_below_minimum_total against nav_epoch_assembly_failed_total.

Root cause 2 carries the larger regression surface, because it changes when nav-solve turns on for every channel including at cold start. It passes the host suite and cold-started correctly on the board, but deserves a good-constellation run before it is trusted.

8.6 Position scatter is geometry, not a measurement regression

Reported symptom: position scatter had grown. It had - but not because anything in the measurement path got worse.

Dividing horizontal scatter by HDOP gives an effective UERE, the per-satellite ranging error. Across every capture on 2026-08-08, before and after the section 8.5 fixes:

capture satellites used HDOP drms UERE
12:08 8.4 1.03 9.2 m 8.9 m
12:24 6.7 1.66 13.5 m 8.1 m
12:42 5.1 4.46 32.6 m 7.3 m
13:29 7.0 2.58 24.0 m 9.3 m
13:40 6.2 1.62 11.6 m 7.2 m
13:50 6.5 1.47 11.0 m 7.5 m

UERE is flat at 7-9 m throughout while scatter varies by 3.5x. The cleanest demonstration is the 13:29 -> 13:40 pair: the same binary, simply restarted, HDOP 2.58 -> 1.62 and scatter 24.0 m -> 11.6 m.

A PCA of the worst run put 89% of the position variance on a single axis. That is the signature of the weakly observed direction under poor geometry, not of one bad channel - and it is consistent with the flat UERE.

Rule for future triage: scatter tracks HDOP. Treat a scatter increase as a geometry story until UERE moves. Chasing it as a measurement regression cost real time here.

8.7 Satellite count was not sky-limited: almanac-directed acquisition

Cross-checking the tracked set against satellite visibility computed from a live USCG YUMA almanac, for the receiver's own position and time:

  • 13 GPS satellites above the horizon, 12 above 5 degrees.
  • The receiver tracked 8. All 8 were in the computed visible set, which also confirmed that the logs print 0-based PRN indices - the real PRN is index + 1.
  • Missed while visible: PRN 1 (34 deg elevation, azimuth 172), 19 (13 deg), 2 (12 deg, azimuth 169), 17 (11 deg), 16 (4 deg).

PRN 1 sat at 34 degrees and was searched 15 times in four minutes without ever being acquired, while PRN 28 at 9 degrees and PRN 7 at 10 degrees were acquired. Sensitivity alone does not fail a high satellite and succeed on low ones. PRN 1 and PRN 2 lie at azimuth 169-172 while PRN 7 at azimuth 199 tracks normally, which points at a local obstruction in a narrow southern sector - antenna siting rather than constellation availability.

The firmware defect: 75% of acquisition effort was wasted

Of 385 failed searches in 240 s, 287 (75%) were against PRNs below the horizon. With ACQ_ENGINE_COUNT = 1 a full 32-PRN sweep takes about 16 s; restricted to the visible set it takes about 4 s.

The cause was that parse_subframe() read subframe 4/5 page headers and kept only the page-18 ionosphere data and the page-25 almanac reference - every almanac orbital element was discarded. The receiver was demodulating the data that says which satellites are up, then throwing it away.

What was implemented

  • src/gps/gps_almanac.c decodes almanac pages (subframe 5 SV IDs 1-24, subframe 4 SV IDs 25-32) and propagates them with the IS-GPS-200 user algorithm to an elevation and azimuth.
  • gps_lnav_parser.c stores every almanac page into a receiver-global almanac, and lifts the 8-bit WNa into the full week using the frame's ephemeris week.
  • gps_interrupt_handlers.c recomputes a visible-PRN mask every 10 s from the almanac, the resolved receiver time and the last WLS position, and publishes it to the scheduler.
  • gps_acq_manager.c retries a PRN predicted below the mask on a 20 s cadence instead of the 0.5 s sweep.

The prediction is advisory by construction, which is the whole design constraint:

  • No almanac, no resolved time, or no position yet -> mask is all ones and acquisition behaves exactly as before.
  • A PRN with no almanac entry yet stays searchable, so a partially collected almanac never hides a satellite.
  • An all-zero predicted-visible mask is treated as "no usable prediction" rather than as silence.
  • An invisible PRN is slowed, never excluded, including one that has never been attempted - so a stale almanac, a stale position or a decode error costs search efficiency and self-corrects, but can never make a genuinely visible satellite unreachable.

Verification

tests/test_gps_almanac.c checks field decoding, page rejection, visibility fallbacks, and propagation against an independent Python implementation of the same user algorithm using a real broadcast almanac for PRN 1.

That cross-check earned its keep immediately: the reference and the decoder disagreed by 1417 km because the Python version omitted the IS-GPS-200 20.3.3.5.2.3 requirement to wrap tk to the half week. The C was correct.

tests/test_gps_acq_manager.c adds three cases covering the slow-but-never- excluded property, the empty-mask fallback, and that a visible PRN keeps the fast cadence.

8.8 Persistent navigation context: almanac, ionosphere, last position

Almanac-directed acquisition (section 8.7) only helps once an almanac exists, and collecting one from the air takes 12.5 minutes because the 25 pages come round one per 30 s frame. Without persistence every reset therefore swept the whole constellation blind for that entire window - which is exactly when fast reacquisition matters most. The same 12.5 minute cycle carries subframe 4 page 18, so the ionosphere model was equally unavailable, leaving every pseudorange with an uncorrected ionospheric delay after a restart.

src/gps/gps_nav_context_store.c persists three things that share a lifetime of weeks rather than hours:

  • the raw LNAV almanac page per PRN,
  • the raw subframe 4 page 18 (ionosphere/UTC),
  • the last converged WLS position, with the receiver time it was taken at.

Why not the ephemeris store

The ephemeris store is an append log keyed per PRN with compaction. That shape buys nothing for a small blob that is rewritten in full and rarely, and it carries the complexity that produced a destructive erase-loop bug once already (section 8.3). The context store is instead an A/B pair of whole sectors at 0x01FC0000, immediately below the ephemeris store: erase the inactive sector, write the payload, write the header last. A save torn by power loss leaves a sector whose header never validates, so mount falls back to the previous generation.

The wear policy

Almanac pages are rebroadcast continuously, so almost every save carries bytes identical to what is already stored. The store compares a CRC before touching the flash and returns without erasing or programming when nothing changed. Erase count therefore follows how often the constellation publishes new data - roughly daily - not how often the save timer fires. The host test runs 50 repeat saves and asserts zero erases and zero programs.

The first implementation of this did not actually achieve it: the payload also carried the live WLS position and the receiver time, so the CRC differed on every save and the flash was erased every 60 s. See section 8.9.3.

Cold start, stale data, and a moved receiver

Nothing restored here is trusted for navigation on its own:

  • Every record is re-parsed through the live decoder, so a corrupt or stale page is rejected exactly as a bad broadcast would be.
  • The almanac only biases how often a PRN is searched. It cannot make a satellite unreachable (section 8.7).
  • The ionosphere model is replaced the moment page 18 is heard again.
  • The position is only a seed for the visibility prediction, used until the first real fix replaces it. While the seed is unconfirmed the elevation mask is widened to -25 degrees so a receiver carried a long way between power cycles still searches what has risen at the new site (section 8.9.5); the mask closes back to -5 degrees at the first fix.
  • An almanac too old to propagate produces no prediction rather than a wrong one, and a health byte is only believed while the orbit it arrived with is still usable (sections 8.9.2 and 8.9.5).

Ephemeris is deliberately not part of this store: it expires in hours and already has its own store with a curve-fit check.

Board results, 2026-08-08

First boot wrote generation=1 then 2, 3 as pages arrived, with iono=1 and position=1. After a reload:

[NAV] navigation context restored: almanac=6 iono=1 position=1 generation=3

searchable fell from 32 to 27 within seconds of boot instead of after 12.5 minutes, and skipped=1 confirmed the no-write path.

A bug this caught on hardware

The first build restored the ionosphere page into the parsed model but not into the raw-word cache. The periodic save rebuilds its payload from that cache, so the next save wrote the page back as absent - wiping the stored model on the first save after every reboot and defeating the persistence entirely. It showed up as iono=0 in the save telemetry one line after a restore had reported iono=1. Fixed in gps_lnav_restore_iono_page(), with a round-trip regression in tests/test_gps_lnav_iode.c covering both the iono and almanac paths.

8.9 Audit of the persistence work, 2026-08-09

Persistence changed the lifetime of the almanac, the ionosphere page and the last position from one power cycle to weeks. Three separate pieces of arithmetic were only ever correct over the shorter lifetime, and one of them was a latent hang with a date on it. All five findings below reproduce in tests/test_gps_nav_context_audit.c, which prints the before and after values in the same run.

8.9.1 A hang armed for GPS week 2433 (severe)

parse_subframe() lifted the broadcast WNa - the eight LSBs of the almanac reference week - against gps_ephems.toe.week. Nothing in the receiver ever writes that field: LNAV broadcasts Toe as a second-of-week only, and the field was read exactly once, here. Lifting against the zero it held put the result 256 weeks below the receiver for every WNa above 128, which in unsigned arithmetic underflowed:

the previous lift gave week 4294967169 for WNa 129 (receiver week 383)

gps_almanac_satellite_ecef() then reduced tk with while (tk < -302400) tk += 604800; - 4.29e9 iterations, measured at 4.2 s per satellite on a 5 GHz x86 and therefore hours on a 50 MHz in-order Rocket core, times 32 satellites, inside a function called every 10 s from the foreground service loop. That loop also drives acquisition scheduling, the flash service and status logging, so the receiver would simply stop.

GPS week 2431 has WNa 127, so this had not fired yet. It arms at week 2433 (2026-08-23) and then for 127 weeks out of every 256. Subframe 5 page 25 arrives once per 12.5 minutes, so it would have hit within 12.5 minutes of every boot - and the poisoned week would have been written to flash.

Three independent fixes, because one is not enough for a fault of this shape:

  1. The lift uses tow.week (the week the receiver actually resolves), in signed arithmetic, with a symmetric comparison; with no week to lift against the reference is left unset rather than guessed.
  2. gps_almanac_set_reference() refuses a week that cannot be a GPS week, so neither the air nor a flash record written by an older build can install one.
  3. The propagation is closed form. There is no loop left to run away.

8.9.2 The half-week wrap made stale almanacs confidently wrong

gps_almanac_satellite_ecef() wrapped tk into the half week unconditionally, citing IS-GPS-200 20.3.3.5.2.3. That paragraph is a week crossover correction - so a toa late in one week and a receiver time early in the next give a small positive age - not a general reduction. Applied as one it aliases, and a GPS week is 14.0384 orbital periods rather than 14, so folding one away moves the satellite about 14 degrees along its orbit.

Checked against an independent Python implementation of Table 20-IV (scratchpad/alm_ref.py reproduced in the test): one week of aliasing displaces the reference satellite by 5161 km, and from a receiver at 40 N a satellite genuinely 5.10 degrees below the horizon is reported 5.67 degrees above it - opposite sides of the -5 degree acquisition mask.

Collecting an almanac live, this was unreachable: a page just received is never half a week old. Reading one back from flash, it is the normal case.

Now: tk is the true elapsed time, the crossover guess lives in gps_almanac_assumed_week() at the one point where a week genuinely has to be guessed (before page 25 has been heard), and anything beyond GPS_ALMANAC_MAX_AGE_SEC (14 days) yields no prediction at all, leaving the PRN fully searchable. Fail-safe direction: a stale almanac costs a full sweep, never a demoted satellite.

The reference vector in tests/test_gps_almanac.c was regenerated - the old one had been produced with the wrap in place, so it had been encoding the bug.

8.9.3 The wear policy never engaged

The store's CRC comparison only helps if the bytes offered are stable while nothing meaningful has changed. They were not: the payload carried the raw WLS position, which moves by metres every epoch, and position_tow_sec / position_week, which move always and which nothing on the restore path ever read. So every periodic save was a fresh CRC and therefore an erase. The board's own log shows it - generation 12 to 17 across one short capture with a single skipped write.

24 h stationary, 1440 saves: taking every fix -> 1440 erases, 0.4 years to 100k cycles
24 h stationary, 1440 saves: held seed      -> 1 erases (1439 skipped), 548 years to 100k cycles

1440 erase cycles a day against 100k cycles shared over two sectors is about 139 days to wear-out, to persist a seed that is read once, at the next boot.

The position is now held: refreshed only when the receiver has moved more than GPS_NAV_CONTEXT_POSITION_EPSILON_M (5 km) and no more often than GPS_NAV_CONTEXT_POSITION_REFRESH_TICKS (1 h). A look angle at 20200 km moves by ~0.003 degrees per kilometre of receiver error, so kilometres are free and metres are noise. A moving platform still updates its seed, at a cadence set by how far it has gone rather than by how long the receiver has been on.

8.9.4 A stale flash ephemeris could outrank the live broadcast

Toe is a second-of-week, so gps_ephemeris_supersedes() orders two records with the signed half-week rule. That is exact between records from the same era and inverted once the gap exceeds half a week. Switch the receiver off on Friday and on again on Tuesday and the four-day-old flash record outranks the live broadcast: the cache refuses the fresh data set, the flash keeps being rewritten with the stale one, and every later cold start restores it again.

Records read back from flash are now marked restored and adopted through gps_ephemeris_cache_store_restored(). A restored record is a candidate, not an incumbent: the first broadcast for that PRN replaces it outright, whatever the Toe arithmetic says, and the flag clears so ordinary Toe ordering resumes. A restored record can never displace something already learned this run.

8.9.5 A moved receiver, and stale health

Two smaller ones, both consequences of the same lifetime change:

  • A health byte from a weeks-old almanac was still demoting satellites. Health only ever demotes, so it must not be trusted further than the orbit it came with; the health test now sits behind the propagation gate.
  • The persisted seed says where the receiver was. While it is still the only position available, the elevation mask widens to GPS_ACQ_VISIBILITY_SEED_MASK_ELEVATION_DEG (-25 degrees), which covers a couple of thousand kilometres of unknown migration and still rules out the far side of the earth. Measured in the test, a 1600 km migration between power cycles demoted 4 of the 16 satellites actually up at the new site under the -5 degree mask, and none under -25.

What was checked and found correct

  • The Klobuchar ionospheric delay in NavEquations.c matches IS-GPS-200 20.3.3.5.2.5 term for term, including the semicircle units, the +/-0.416 latitude clamp, the 72000 s period floor and the |x| < 1.57 cutoff.
  • All almanac field positions, scale factors and sign extensions, including the split af0, verified against the ICD and against the Python reference.
  • The A/B sector layout, the header-last ordering, the power-loss behaviour and the CRC rejection all hold under the simulated NOR (bit-clear-only programming, 256-byte page limit, truncated writes).
  • Flash regions do not overlap: navigation context at 0x01FC0000-0x01FDFFFF, ephemeris store at 0x01FE0000-0x01FFFFFF, both above the application at 0x01800000.
  • Sanitizer clean (ASan + UBSan) across the almanac, LNAV, cache, store and audit suites.

One hardening item came out of the sanitizer build rather than from reading: gps_lnav_almanac_words[sv_id - 1u] was bounded only because gps_almanac_store_page() re-derives the same field from the same words and returns false outside 1..32. GCC was right to object - that is an invariant one refactor away from a 40 byte write past a static array - and the range test is now stated at the call site.

8.10 Cold start, channel churn, and what reaches the WLS, 2026-08-09

Second audit pass, aimed at the measurement path rather than at persistence: does a satellite appearing or disappearing, or a coalesced interrupt, or the 32-bit sample counter wrapping, ever hand the WLS something inconsistent? Regressions in tests/test_gps_pvt_consistency.c.

8.10.1 Cold start does search everything

Confirmed by code path and by test. gps_refresh_acq_visibility() forces the mask to all ones whenever gps_receiver_time_now() fails, and at power-on it always does - receiver time arrives only once a satellite is tracked and its handover word decoded. So the first sweep is all 32 PRNs, and the restored almanac starts aiming only after the first satellite is up. There is no RTC, so this is not improvable: the almanac cannot be evaluated without time.

An empty mask - what a corrupt or absent flash almanac produces - degrades to the same blind sweep rather than to silence.

8.10.2 The ephemeris age check had a whole-week blind spot (serious)

LNAV broadcasts Toe as a second-of-week. gps_ephemeris_toe_offset_sec() compared it against the receiver's second-of-week with the signed half-week rule and never looked at a week number at all - toe.week existed but nothing wrote it. The age was therefore computed modulo one week, so a record exactly N weeks old, evaluated at a similar time of week, read as brand new:

  50 minutes old          -> ok
  2h13m old (past fit)    -> age
  exactly 1 week old      -> ok      <-- accepted as current
  4 weeks old             -> ok      <-- accepted as current

Unreachable while ephemeris lives in RAM and expires in hours. Directly reachable once it is persisted to flash: switch the receiver off and on again a week later at a similar hour and it accepts its own week-old orbits, worth kilometres of position error with nothing to flag it. This is the same class of defect as the almanac week bug in section 8.9.1 - a modulo reduction standing in for a week number - in a second place.

Fixed at the source: gps_resolve_toe_week() reconstructs the week at commit time from the subframe 1 week number and the handover word of the subframe that carried Toe, resolving both week-crossover directions (Toe early in the following week, and a transmission that crossed into it). The age check then uses the true elapsed time with no reduction. A record whose week cannot be resolved gets no age at all, which reads as a rejection everywhere - the safe direction, and what old flash records written before this change will do.

8.10.3 Reference selection screened only part of the set

gps_pps_select_reference() returned as soon as it found the sticky reference PRN, leaving any non-finite capture time after it in the array unexamined. So whether a corrupt measurement was caught depended on where the reference PRN happened to sit. Neither branch could produce a wrong fix - a non-finite pseudorange fails the WLS build and the epoch is lost either way - but the two took different paths with different diagnostics. It now screens the whole set before choosing anything.

8.10.4 Channel churn: verified correct

Each of these is now a regression test.

Situation Behaviour
Satellite acquired mid-epoch expected_mask is frozen when the first event of a period arrives, so the new channel's push returns INVALID and it joins the next epoch. Its measurement never enters the current snapshot.
Freshly seeded channel trk_seed_unconfirmed keeps it out of ready_mask until its first epoch validates, so an unproven phase seed cannot hold up an epoch.
Satellite lost mid-epoch gps_process_tracking_drop() removes it from expected_mask, received_mask and the snapshot mask, and publishes immediately if what remains is complete - rather than stalling to the deadline.
Loss takes the set below four Publication refuses and the whole epoch is discarded. A three-satellite set is never offered.
Channel goes silent without a drop event Excluded after GPS_NAV_SILENT_CHANNEL_EPOCHS misses - but never if that would take the expected set below four, where waiting beats excluding.
Channel reassigned to a new PRN The snapshot holds a copy of the ephemeris and the PRN per channel, and the collect step re-checks eph->SVID == epoch->prn[ip]. A reassigned channel cannot contribute the previous PRN's orbit. Measurements carry their PRN too; a mismatch yields a NaN C/N0 rather than a wrong weight.
Disconnected but still reported locked Not possible: gps_nav_ready_channel_mask() reads BUSY and LOCK from STATUS_REG live at every push, alongside tracking_allocated and the nav-solve interrupt enable.
Coalesced interrupts gps_nav_capture_sequence_consistent() requires the capture-counter delta and the sequence delta to agree. If they do not, the event is refused and only the transport metadata is rebased - it never enters an epoch. Missing epochs are counted, never synthesised.
Duplicate / stale / out-of-window capture Rejected as DUPLICATE, STALE and OUTLIER respectively; none of them touches the snapshot.
Four of five channels coherent Not published as a four-satellite fix. Publication requires the whole frozen set to be coherent, because a solvable subset would make the satellite geometry depend on interrupt timing - the original source of large raw-position jumps.

8.10.5 The 32-bit sample counter wraps every 262 seconds

At 16.368 MHz a uint32_t sample counter wraps every 262 s, in the middle of normal tracking. gps_nav_epoch_unwrap_sample() (moved out of gps_ip.c so it is host-testable) lifts every accepted channel onto the reference's axis by signed modulo-2^32 distance. Building one epoch away from the wrap and again straddling it gives pseudoranges identical to 3.3 micrometres, which is double rounding. The epoch selector's own comparisons are modular too, so a coherent epoch is not rejected once every 262 s.

8.10.6 The remaining exposure, stated plainly

GPS_NAV_ENABLE_FDE is 0: fault detection and exclusion is compiled out. Even enabled it requires seven satellites and only runs when the full solve succeeded but failed chi-square, so a structurally broken row - non-finite, degenerate range, zero weight - can never be excluded; it fails the whole epoch.

With five or more satellites that is safe but costly: solution_valid requires chi_square_pass, so a bad measurement yields no fix rather than a wrong one. With exactly four satellites there are zero residual degrees of freedom, no chi-square statement is possible, and a bad measurement produces a fix that is wrong with nothing to detect it. That is algebraically unavoidable at four satellites and is already documented at the solution_valid assignment.

The most likely source of such a measurement was a satellite low in the sky. That part is now fixed; see section 8.11. There is still no elevation mask anywhere on the navigation path - nothing is excluded on elevation, by design, because with nine channels and a four-satellite minimum excluding is often worse than including a discounted measurement.

8.11 The measurement model: what one satellite is allowed to do, 2026-08-09

Three quantities were being conflated, and only one of them is a bias:

  • the corrections, tropo() and klobuchar_delay_meters(), subtracted from the observation to remove a real physical delay;
  • the sigma, gps_nav_measurement_sigma_m(), which never shifts anything - it only says how much to trust a satellite;
  • the WLS clock bias, the fourth unknown, which is solved for.

Two defects, both in the first two. Regressions in tests/test_gps_measurement_model.c.

8.11.1 The weighting floor stopped the weighting model working

The sigma model itself is sound: variance is sigma_URA^2 + sigma_elevation^2 + sigma_CN0^2, weight is 1/variance, and the carrier-to-noise law is physically right - DLL jitter goes as the inverse square root of C/N0 in linear power, so 20 dB is exactly a factor of ten, which is what 3.0 * 10^((45 - CN0)/20) gives.

The defect was a clamp that stopped it applying:

const double min_elevation_deg = 10.0;
if (bounded_elevation_deg < min_elevation_deg) bounded_elevation_deg = 10.0;

That pinned the elevation term at 3/sin(10 deg) = 17.3 m. The model's own answer at 5 degrees is 34.4 m and at 2 degrees 86 m, so a satellite low enough to be carrying most of the error was trusted 4 to 25 times more than this same model said it deserved. Nothing was ever excluded by that floor - it only made the mapping stop working exactly where it mattered.

The floor is now GPS_NAV_WEIGHT_MIN_ELEVATION_DEG = 3 degrees. It cannot be removed entirely: 1/sin diverges at the horizon, and a zero weight is not a discounted satellite but a failed epoch, because least_squares_build_postfit() refuses a non-positive weight. Three degrees leaves the term at 57 m, some 350 times lighter than zenith.

Measured end to end on a six-satellite geometry, injecting 30 m of uncorrected delay on one satellite and taking the displacement between two solves:

  30 m of uncorrected delay moves the fix by 4.18 m on the 4 deg satellite,
                                            48.50 m on the 72 deg one
  the 10 deg floor gave that satellite sigma 17.9 m instead of 43.3 m:
                                            5.8x the weight

That asymmetry is the point. The solve is now far less sensitive to a bias on a satellite it should not trust than to the same bias on one it should; under the old floor the two were within a factor of six of each other. (A near-zenith satellite moving the fix by more than its own bias is normal: its line of sight is nearly degenerate with the clock-bias column, so the error splits into vertical position and clock.)

8.11.2 The corrections switched themselves off below the horizon

tropo() was called only for elv > 0.0 and klobuchar_delay_meters() returned zero for elevation_deg <= 0.0. So a satellite crossing the horizon - setting, or arriving by reflection - lost its entire atmospheric correction in one step, and that whole magnitude stayed in its measurement as an uncorrected bias. At this site the horizon tropospheric delay alone is 78.8 m:

  troposphere at +0.05/0.00/-0.05 deg: 77.22 / 78.77 / 78.77 m
  -> below the horizon it holds 78.8 m instead of dropping to 0

Both are now evaluated with elevation clamped at the horizon (least_squares_atmosphere_elevation_deg()). That extrapolation under-corrects, because the true path below the horizon is longer still, but it is far closer than zero and it makes the measurement model continuous - which matters more, since a discontinuity means the same satellite changes the fix by tens of metres between consecutive epochs for no physical reason.

The extra uncertainty of extrapolating is charged rather than assumed away: GPS_NAV_BELOW_HORIZON_SIGMA_M (50 m) is added in quadrature at or below the horizon, so such a satellite is discounted for the reflection it probably arrived by as well as for being low.

A non-finite elevation now also takes the floor rather than being silently treated as a 10 degree satellite - unknown is not the same as good.

9. Known remaining gaps

  • Recovery-time validation for the two fixes in section 8.5 is outstanding and needs a 7-8 satellite sky.
  • Almanac-directed acquisition (section 8.7) is verified on the host suite; its effect on reacquisition time on hardware still needs a drop test.
  • Recovery after a total RF outage measured ~43 s on 2026-08-08, of which about 20 s was spent with satellites tracked but not ready - how_reject, eph_reject, incoherent and the assembly counters were all flat, so the gate is gps_node_ready_for_position(). A cold start with a warm cache reaches a fix in ~6 s with eph_miss=0, so the restore path itself is fast. The untested hypothesis is that a known receiver time makes gps_ephemeris_cache_restore_at() apply the full curve-fit window and reject an aged cached ephemeris, forcing a fresh 18-30 s subframe decode, whereas at cold start time is unknown and only static checks apply. eph_hit/eph_miss are now in the epoch funnel; one more drop test settles it.
  • The acquisition peak metric of a failed search is unavailable to firmware. acqusition_wrapper.sv latches it only on success (peak_metric_reg_n = acq_stat_p ? peak_metric_syned : peak_metric_reg_p;) and clears it on every start_acq, so a failure always reads 0. That makes "no satellite there" indistinguishable from "satellite present but below the fixed threshold" - PEAK_THRESHOLD = 512, a peak/second-peak ratio of 2.00, hard-coded in common/definitions.sv with no firmware control. Latching it unconditionally is a one-line RTL change on an existing capture path, and would let the sensitivity limit be measured rather than inferred. Not made: RTL changes need explicit approval.
  • The 68 ms STARTOFFSET datum is retained by decision, not by omission (section 4.1). It is not a defect and not scheduled for replacement.
  • Board validation covers the seed path, flash persistence, the format path and compaction (sections 8.1-8.3). Not yet covered on hardware: the multi-minute outage cases, and any natural drop long enough to age a PRN context out.
  • Firmware and bitstream must be rebuilt together. The RTL change adds one register and one state-machine branch to tracking_wrapper/nav_solve_timer, and the Genesys2 bitstream has not been regenerated, so the current .bit/.mcs in targets/ predate it. Writing NAV_SOLVE_PHASE_SEED against an older bitstream hits the wrapper's default decode case, which asserts PSLVERR; TLToAPB maps that to denied on the write response, and Rocket can take a store access fault. This is not benign. Build with GPS_WARM_SEED_ENABLE=0 to produce firmware for a bitstream without the register — the seeding path is then compiled out and every channel takes the subframe path.
  • Flash persistence now has board evidence (section 8.1): the 4-byte-address opcodes and status polling in bsp/spi_flash.c work against the real S25FL256S. Sector erase has not been exercised on hardware, because no compaction has happened yet and the store was already formatted.
  • Channel generation IDs are not implemented. The FIFO event queue plus foreground-only reassignment closes the ordinary stale-IRQ window, but there is no explicit generation stamp proving a queued event predates a reassignment.
  • The cached ephemeris carries the raw modulo-1024 LNAV week in tow.week until the first nav-solve overwrites it with the extended week. Validity does not depend on it (section 6 uses the half-week rule), but flash persistence will need the extended week stored explicitly.