Bit and Secondary-Code Sync
Once a satellite is being tracked, the next milestone is locking onto its bit/symbol boundary (and, for pilots like GPS L1C-P, the secondary-code phase). These are what let the receiver:
- Switch from 1-ms coherent integrations to full-symbol coherent integrations (e.g. 20 ms for L1 C/A), driving the PLL phase-noise floor down.
- Hand over a decoded bit stream to a navigation-message parser.
- Reconstruct the absolute code phase modulo the longest secondary-code cycle, so downstream consumers (e.g. PositionVelocityTime.jl) can pseudorange.
This page is a deep dive into the mechanism. The high-level user only needs to know: call has_bit_or_secondary_code_been_found per signal (see the per-signal accessor table) and get_soft_bits once it returns true. The rest of the machinery is internal.
Code-phase wrap period
The shared TrackedSat.code_phase wraps at two distinct values depending on the per-signal sync state:
Before any signal has synced — wrap is the largest primary code length across the sat's signals. For a sat tracking only L1 C/A this is 1023 chips. The wrap is intentionally narrow at this stage because we don't yet know which data bit or secondary-code chip the current primary period belongs to.
After a signal syncs — its contribution to the wrap widens to one full symbol period:
primary × secondary_code_lengthfor a pilot, orprimary × blocks_per_data_bitfor a data-bearing signal. The shared wrap is then themaxacross signals, so the longest synced signal pins it.
Concrete values:
| Sat tracks | Before sync | After full sync |
|---|---|---|
| GPS L1 C/A only | 1023 | 1023 × 20 = 20460 (one 50 Hz data bit) |
| GPS L5I only | 10230 | 10230 × 10 = 102300 (one NH10 cycle / one data bit) |
| GPS L1C-P only | 10230 | 10230 × 1800 ≈ 18.4 M (one overlay-code cycle, ≈ 18 s) |
| L1C-P + L1C-D + L1CA | 10230 (longest primary) | 18.4 M (L1C-P dominates) |
The post-sync widening is what lets downstream consumers (e.g. PositionVelocityTime.jl) distinguish which primary-code period within the symbol they're currently in — mod(code_phase, primary) gives the per-signal replica phase, while div(code_phase, primary) gives the symbol-internal position.
max_code_length returns the upper bound (the post-full-sync value) at compile time. current_code_wrap returns the runtime value honoring the current per-signal sync state — this is what the inner loop's mod actually uses.
Tracking.max_code_length — Function
max_code_length(signals)
Upper bound on the shared sat.code_phase wrap period, in chips — the least common multiple of the per-signal wrap periods once every signal on the sat has synced. For a sat tracking only GPS L1 C/A this is 1023 × 20 = 20460 (one full data bit); for one tracking L1C-P this is 10230 × 1800 ≈ 18.4 M (one full secondary-code cycle). For every shipped multi-signal pairing the shorter wraps divide the longer ones, so the lcm coincides with the longest signal's wrap.
This is the compile-time bound. The actual runtime wrap shrinks to get_code_length(signal) × 1 for any signal whose bit/secondary-code sync hasn't been found yet — see current_code_wrap for the runtime value used by the inner loop.
Implemented via tuple recursion (not @generated) so the heterogeneous walk unrolls at type-inference time; the result folds to a literal in the calling site for any concrete signals tuple type.
Tracking.current_code_wrap — Function
current_code_wrap(signals)
The runtime wrap period for the shared sat.code_phase, in chips — the value the inner loop uses to wrap code_phase modulo each integration step.
Unlike max_code_length (which is the worst-case bound), this honors the current per-signal sync state. For each signal:
- If
bit_buffer.found = true, the signal contributesprimary × max(secondary_code_length, blocks_per_data_bit)— i.e. the full secondary-code period for pilots, or the full data-bit period for data-bearing signals. - If
bit_buffer.found = false, the signal contributes just its primary code length — we don't yet know which bit / secondary chip we're in, so wrapping at the primary length is the most we can legitimately do.
The shared wrap is the least common multiple of the per-signal contributions, so it stays an integer multiple of every signal's own replica wrap — per-signal phases are re-derived as mod(code_phase, replica_wrap), which a non-common-multiple wrap would silently corrupt (issue #129). For all shipped signal pairings the shorter wraps divide the longer ones, so the lcm coincides with the longest synced signal's wrap and shorter signals just ride along.
Bit-sync and secondary-code-sync detection
Each per-signal BitBuffer runs an detect_bit_or_secondary_code_sync detector against the running buffer of primary-code-block signs. The detector returns a SyncResult containing whether sync was found, the secondary-code phase (chip offset within the secondary code, used for code-phase seeding — see below), and the locked polarity (±1).
There are three detector families:
- Soft, maximum-energy CFAR — GPS L1 C/A (bit-edge,
_detect_bit_edge_cfar) and every signal with a short secondary code,1 < N ≤ 100(overlay-rotation,_detect_secondary_code_cfar): GPS L5I/L5Q, Galileo E1C/E5a-I/E5a-Q/E5b-I/E5b-Q/E6-C and BeiDou B1I/B3I/B2a-data/B2a-pilot. These accumulate per-hypothesis, coherently-summed bin energy inPhaseAccumulatorsand lock when the peak hypothesis beats its runner-up with a Student-t confidence (get_bit_edge_detection_confidence, default0.999). They self-pace with C/N₀ and fire only at the winning hypothesis's own boundary — so they reportphase = 0(the upcoming integration starts a fresh data bit / secondary-code period). Selected byuses_soft_bit_edge_detection/uses_soft_secondary_code_detection. - Hard-decision rotation/Hamming sweep (
_secondary_code_search) — among the currently implemented signals, the two 1800-chip overlay pilots: GPS L1C-P and BeiDou B1C-pilot. It matches packed prompt signs against the known overlay at every rotation, accepting the best withinget_bit_edge_or_secondary_code_tolerance; it locks in one full period worst case (matches at any alignment) and reports the recovered secondary-chipphase. - Trivial / none — Galileo E1B and E6-B, GPS L1C-D and L2CM, and BeiDou B2b-I and B1C-data broadcast one channel symbol per primary period, so their detector returns
SyncResult(true, 0, +1)immediately; GPS L2CL is a dataless pilot with no sync.
Min-to-fire is the smallest num_code_blocks the detector accepts before it can lock. The soft CFAR detectors need 2 × period blocks before a runner-up (and hence a decision) exists, then fire at the next winning-hypothesis boundary; the hard sweep needs 1 × period. Buffer width is get_code_block_buffer_type(signal); note it sizes the packed prompt-sign buffer that only the hard sweep consults — the soft detectors read PhaseAccumulators instead, so for them the packed buffer is vestigial.
| Signal | Detector | Min-to-fire | Buffer width | Accept rule | Phase | Blocks per symbol |
|---|---|---|---|---|---|---|
| GPS L1 C/A | soft bit-edge CFAR | 40 blocks (2 × 20) | UInt64 (vestigial) | confidence 0.999 | 0 | 20 |
| Galileo E1B | trivial | n/a | UInt8 (unused) | always | 0 | 1 |
| GPS L5I | soft secondary CFAR | 20 blocks (2 × NH10) | UInt32 (vestigial) | confidence 0.999 | 0 | 10 |
| GPS L5Q | soft secondary CFAR | 40 blocks (2 × NH20) | UInt32 (vestigial) | confidence 0.999 | 0 | 20 (pilot) |
| GPS L1C-D | trivial | n/a | UInt8 (unused) | always | 0 | 1 |
| GPS L1C-P | hard rotation sweep | 1800 blocks | UInt1800 (exact width) | 45 errors (2.5 %) | 0..1799 | n/a (pilot) |
| GPS L2CM | trivial | n/a | UInt8 (unused) | always | 0 | 1 |
| GPS L2CL | never fires | UInt8 (unused) | none (dataless pilot) | n/a | 0 | n/a (pilot) |
| Galileo E1C | soft secondary CFAR | 50 blocks (2 × CS25) | UInt32 (vestigial) | confidence 0.999 | 0 | 25 (pilot) |
| Galileo E5a-I | soft secondary CFAR | 40 blocks (2 × CS20) | UInt32 (vestigial) | confidence 0.999 | 0 | 20 |
| Galileo E5a-Q | soft secondary CFAR | 200 blocks (2 × CS100) | UInt128 (vestigial) | confidence 0.999 | 0 | 100 (pilot) |
| Galileo E5b-I | soft secondary CFAR | 8 blocks (2 × CS4) | UInt32 (vestigial) | confidence 0.999 | 0 | 4 |
| Galileo E5b-Q | soft secondary CFAR | 200 blocks (2 × CS100) | UInt128 (vestigial) | confidence 0.999 | 0 | 100 (pilot) |
| Galileo E6-B | trivial | n/a | UInt8 (unused) | always | 0 | 1 |
| Galileo E6-C | soft secondary CFAR | 200 blocks (2 × CS100) | UInt128 (vestigial) | confidence 0.999 | 0 | 100 (pilot) |
| BeiDou B1I | soft secondary CFAR | 40 blocks (2 × NH20) | UInt32 (vestigial) | confidence 0.999 | 0 | 20 |
| BeiDou B3I | soft secondary CFAR | 40 blocks (2 × NH20) | UInt32 (vestigial) | confidence 0.999 | 0 | 20 |
| BeiDou B2b-I | trivial | n/a | UInt8 (unused) | always | 0 | 1 |
| BeiDou B2a data | soft secondary CFAR | 10 blocks (2 × 5) | UInt32 (vestigial) | confidence 0.999 | 0 | 5 |
| BeiDou B2a pilot | soft secondary CFAR | 200 blocks (2 × 100) | UInt128 (vestigial) | confidence 0.999 | 0 | 100 (pilot) |
| BeiDou B1C data | trivial | n/a | UInt8 (unused) | always | 0 | 1 |
| BeiDou B1C pilot | hard rotation sweep | 1800 blocks | UInt1800 (exact width) | 45 errors (2.5 %) | 0..1799 | n/a (pilot) |
On the BeiDou GEO satellites (PRN 1-5, 59-63) the B1I/B3I overlay column is all-ones — those satellites carry no NH20 (BDS-SIS-ICD-B1I-3.0 §5.2.1) — and they are also the ones broadcasting D2 at 500 sym/s. An all-ones reference is rotation-invariant, so the soft detector's 20 per-rotation bins are separated only by the data transitions they straddle; with 2-block D2 symbols each 20-block bin averages ~10 random symbols and no rotation stands out, so the CFAR test never accepts and those satellites stay pre-sync (they still track and range). The two facts only cancel together: an absent overlay at the D1 rate would leave exactly the bit-edge search GPS L1 C/A uses, locking on the symbol boundary. Either way the signal type reports get_secondary_code_length == 20, so B1I/B3I never route to _detect_bit_edge_cfar itself.
The buffer-width type threads through BitBuffer{B} and TrackedSignal{Sig, B, C, PCF, CN0} as a type parameter. The two 1800-chip overlay pilots (GPS L1C-P, BeiDou B1C-pilot) use an exact-width UInt1800 defined via BitIntegers.@define_integers 1800. The soft secondary CFAR detectors take the ±1 overlay chip directly from get_secondary_code(signal) per rotation, so a new short-secondary-code signal needs no bespoke template — only for uses_soft_secondary_code_detection to return true (secondary length 1 < N ≤ 100).
The soft CFAR confidence is package-wide and adjustable per signal via get_bit_edge_detection_confidence; the hard-sweep Hamming tolerance is adjustable per (hard-path) signal via get_bit_edge_or_secondary_code_tolerance:
julia> using Tracking, GNSSSignals
julia> Tracking.get_bit_edge_or_secondary_code_tolerance(GPSL1C_P()) # default
0.025
julia> # Loosen the L1C-P ceiling to 5 % (= 90 errors over 1800 blocks) for low-C/N₀ work.
Tracking.get_bit_edge_or_secondary_code_tolerance(::GPSL1C_P) = 0.05;
julia> Tracking.get_bit_edge_or_secondary_code_tolerance(GPSL1C_P()) # after override
0.05The hard sweep reads the tolerance at its call site and converts to an integer error budget via floor(Int, tolerance × window_size), so the override picks up the next time detect_bit_or_secondary_code_sync runs — no TrackState rebuild needed. It has no effect on the soft-detector signals (tune their confidence instead); the trivial detectors ignore both.
Lifecycle of a BitBuffer
Two distinct phases, separated by the found::Bool flag:
Pre-sync search (
found = false). Each completed integration folds the prompt into the running state and the detector is called; while it returnsSyncResult(false, ...)the loop keeps integrating one primary code period at a time. The soft CFAR detectors (GPS L1 C/A bit-edge; every short-secondary-code signal's overlay) fold each prompt intoPhaseAccumulatorsand lock only at the winning hypothesis's own boundary — so the call that flipsfoundlands exactly on a data-bit / secondary-code-period boundary and reportsphase = 0(the upcoming integration starts a fresh period). The hard rotation-sweep detector (the 1800-chip overlay pilots GPS L1C-P and BeiDou B1C-pilot) instead shifts each prompt sign intocode_block_buffer::Band locks as soon as one full overlay period has been buffered — at any alignment — reporting the recoveredphase(the upcoming integration's secondary chip); the integration cadence then re-aligns to that boundary (see below).Post-sync accumulation (
found = true). The post-sync branch inTracking.bufferignorescode_block_bufferand instead accumulates the complex prompt intoprompt_accumulator. (Pilot signals such as GPS L1C-P carry no data bits, so for them this branch is a no-op — the buffer just retainsfound/secondary_phase/polarity.) Because each post-sync integration spans to the next secondary-code (data-bit) boundary, bit decoding stays aligned even when sync fired mid-period. Each integration also bumpsprompt_accumulator_integrated_code_blocks. Once that counter reaches the per-signal "blocks per symbol" value above —_calc_num_code_blocks_that_form_a_bit(signal) = get_code_frequency(signal) / (get_code_length(signal) * get_data_frequency(signal))— one decoded bit is committed as a soft bit — the polarity-corrected coherent prompt sum, sign = hard decision, magnitude = confidence — pushed onto the unboundedsoft_bitsvector (a hard decision is just the sign), and the accumulator resets to zero. For Galileo E1B and GPS L1C-D the counter is1, so one symbol commits per integration; for GPS L1 C/A it's20, so the loop counts 20 primary-code periods (≈ 20 × 1023 chips = 20460 chips ofcode_phaseadvance, modulo wrap) per data bit; for GPS L5I it's10. The polarity flag flips the accumulator's sign at commit time when the detector locked at negative polarity, so downstream consumers always see1 = data symbol 0.
Pilot signals (get_data_frequency = 0 Hz, e.g. GPS L1C-P) never enter the post-sync accumulation branch — their bit_buffer carries the recovered secondary-code phase but no decoded bits, and the post-sync work is purely the code-phase seeding described next.
Code-phase seeding from the secondary-code phase
When a signal with a secondary code (secondary_code_length > 1) syncs — any of the short-secondary-code signals, or one of the 1800-chip overlay pilots — its secondary_phase seeds TrackedSat.code_phase so subsequent wrap-mod-current_code_wrap arithmetic gives the absolute position in the longest secondary-code cycle. The seeding follows a fallback chain: the synced signal with the largest (primary × secondary) code length wins. For the soft-CFAR signals secondary_phase is always 0 (they fire on a period boundary, so the upcoming integration is at chip 0); for the hard-sweep overlay pilots it is the recovered chip offset. Either way it is a multiple-of-primary snap into the correct secondary window.
Signals with secondary_code_length == 1 (bit-edge only, e.g. GPS L1 C/A) do not carry an explicit secondary_phase to snap — there's no per-PRN overlay to recover a chip offset from. Instead, their post-sync bit-edge alignment is captured by two complementary mechanisms:
The
BitBuffer.prompt_accumulator_integrated_code_blockscounter tracks "how many of the next N primary periods have I integrated since the last bit commit."reset(bit_buffer)preserves it, so the bit cadence survives intra-call resets without re-syncing.The wrap returned by
current_code_wrapwidens fromprimarytoprimary × blocks_per_data_bit(e.g. 1023 → 20460 for L1 C/A) the momentbit_buffer.foundflips totrue. From that point onmod(code_phase, primary)continues to give the replica phase, whilediv(code_phase, primary)reads off which primary period within the data bit we're in. The transition is one-shot: on the call that flipsfound,code_phaseis implicitly the start of a fresh data bit because the detector only matches at a true bit boundary (the[0, primary)range ofcode_phasethen represents primary period 0 of the new bit), so no explicit snap is needed.