# 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 ```sh # 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 ```c 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: ```c 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.