Vector tracking

In scalar tracking every satellite closes its own code and carrier loops from its own discriminators. In vector tracking a navigation filter closes them all at once. Each satellite's accumulated DLL and FLL discriminators become pseudorange and pseudorange-rate measurements. The filter fuses them into a position, velocity and clock state and feeds the predicted line-of-sight dynamics back as per-satellite NCO corrections. Weak or briefly obscured satellites are carried through by the common solution instead of losing lock on their own.

The filter is an unscented Kalman filter over position, velocity, one clock bias per GNSS time system (all driven by the one oscillator's drift) and one inter-frequency bias per band beyond the reference band. It follows the bias model of PositionVelocityTime.jl: which biases a cycle can determine is decided per cycle, and clocks collapse onto a hub system's through the broadcast offset (GGTO, BGTO) when the measurements cannot separate them. With use_pseudorange_rates = true (the default) it measures the pseudorange rates too (VDFLL). With false it is a VDLL, and the carrier corrections come from the filter's prediction alone.

One estimator does it all

VectorPLLAndDLL is a Doppler estimator like any other: a host builds each satellite's state with init_estimator_state and steps it record by record with step_loop. Everything vector tracking needs happens inside that step, from what the records carry. Every satellite stepped with one estimator shares its navigation engine, which

  • syncs to each satellite's navigation bits, decodes them and estimates its C/N₀, on the satellite's own bit clock;
  • snapshots each satellite at every navigation epoch (every cycle_time on the records' time grid): the replica's code phase from the last data-symbol edge, the decoder there, the Dopplers, and the discriminators accumulated since the last epoch;
  • runs the epoch's navigation cycle on the record that brings the last satellite past the epoch: the scalar PVT until the filter is seeded, a filter iteration after that;
  • leaves the cycle's decisions — admission, release, the corrections — for each satellite to take up on its next record, sized for where its command lands.
estimator = VectorPLLAndDLL(GPSL1CA(), GalileoE1B())   # owns decoders, filter and PVT
ts = TrackState(; signal = GPSL1CA(), doppler_estimator = estimator)
for chunk in chunks
    track!(chunk, ts, fs)              # decodes, solves, steers
end
navigation_solution(estimator)         # the PVTSolution; navigation_status(estimator)

Several constellations or bands share one filter: list every ranging signal at construction. Each satellite runs the scalar loop inner — ConventionalAssistedPLLAndDLL() (the default) or NCOReferencedPLLAndDLL() — until the filter takes it over, and again once the filter lets it go. In the vector loop the code filter is frozen, the filter's corrections steer the replica, and the discriminators are accumulated for the filter.

What the records must carry

The engine derives everything from the records except what only the correlator knows, which every LoopRecord handed to the estimator must carry:

  • prn: the satellite;
  • code_phase: the replica's code phase in chips at the record's end, from the CorrelatorOutput. The end sample alone pins it only to within one sample (some 75 m at 4 MHz). Only its part past the nearest code-block boundary is read, so any wrap convention works; without it (NaN) the record is taken to end on a block boundary;
  • a common time grid: sample_index / sampling_frequency must be the time since one origin shared by all satellites. A host whose correlator restarts its sample count passes the offset as sample_offset when it builds the record.

NO_LANDING_SAMPLE keeps meaning that the command computed from a record acts from the record's end; a hardware host passes the landing sample, and each satellite sizes its corrections for that moment.

The lifecycle of a satellite

Vector tracking needs a decoded navigation message and a first position fix, so every satellite starts on its scalar loop:

  1. A satellite joins the engine on its first record. Until vector tracking runs, each cycle solves the scalar PVT over the satellites that are in lock (C/N₀ above lock_cn0_threshold, bit sync found), decoded for positioning and healthy.
  2. The first fix seeds the filter. The filter starts from the fix's position and clock biases, with the covariance the fix's geometry gives them, so a fix of poor geometry (a high DOP) starts the filter as uncertain as it is. The fix's satellites join the vector loop, and take their first corrections, computed at the seeded state, on their next record.
  3. While it runs, a satellite is admitted once it is decoded, healthy, in lock and a degree above the horizon. A member out of lock stays in the loop, unmeasured, and is predicted through the outage. A member that misses an epoch sits that cycle out.
  4. A member is released when it is no longer eligible (VT_INELIGIBLE) — which includes a satellite without a record for two cycles, whose slot is freed — or drops below the horizon (VT_BELOW_HORIZON). Its scalar loop is re-seeded from the replica at its landing.
  5. After insufficient_meas_timeout of unsolvable cycles, or with no member left, every member is released (VT_FALLBACK) and the next cycle solves the scalar PVT again, until a fresh fix seeds the filter anew.

A re-acquired satellite — a fresh state on a PRN seen before — gets its old slot back: its bit clock restarts, and its decoder restarts its sync but keeps the data it had decoded, so it is ready again after the next subframe rather than a whole frame.

Stepping the satellites

A cycle runs once every satellite has reached its epoch, and a satellite that has gone two cycles without a record is dropped. So a host should step every satellite of the estimator at least once per half cycle. Tracking.jl's track! does, for any chunk shorter than that. A satellite that falls behind holds a cycle up only until the others reach the next epoch; then the cycle runs without it.

Under a hardware NCO delay

With a hardware correlator, the command computed from a record only takes effect when it lands at the NCO. Pass the landing sample to step_loop, and the channel's NCOTimeline as its words: each satellite then sizes its corrections for the moment its own command lands, against the replica the timeline predicts there, and a released satellite's scalar loop takes over from that replica. The landing may lie up to 2.5 cycles after the epoch.

Reading the results

Nothing is logged. navigation_solution is the latest solution (the scalar PVT's before the filter is seeded) and navigation_status the VTStatus of the latest cycle: the events (the filter was seeded, fell back, released a satellite) and the filter's position and clock uncertainties, also at hand as position_uncertainty and clock_uncertainty. release_reason says whether and why the latest cycle released a satellite, and member_sats reports every member of the loop, measured and coasted.

navigation_cycle counts the cycles, so a consumer that polls it after each step reads every solution exactly once, and navigation_epoch is the moment the latest solution describes, on the records' time grid. satellite_report hands out what the engine knows of a satellite — its decoder, bit sync, C/N₀, lock, readiness for the PVT and membership — so a receiver neither decodes the bits nor estimates the C/N₀ a second time.

Every estimator answers these: the scalar loops with nothing, so a host can ask whichever estimator it was given.

cycle = navigation_cycle(estimator)
if cycle != last_cycle            # `nothing` for a scalar loop: never a new solution
    last_cycle = cycle
    pvt = navigation_solution(estimator)       # position, velocity, time, DOP, …
    report = satellite_report(estimator, GPSL1CA(), prn)
end

The solution, the per-member report and the satellite reports are the estimator's own objects, reused by the next cycle or call: copy out what is needed later.

Storage

The estimator allocates the slots of max_satellites_per_signal satellites per signal at construction: decoders, bit clocks, C/N₀ estimators and the filter's buffers. A dropped satellite leaves its slot free with all its storage, for the next one to reuse; only past that capacity does a group grow. Once warm, records and cycles allocate nothing, and the whole estimator compiles with juliac --trim=safe.

Known limits

  • The engine decodes the bits of the signal it steps, so a dataless pilot (GPS L1C-P, Galileo E1C) cannot run vector tracking: the constructor rejects it.
  • Lock is a C/N₀ threshold over the bit-synced satellites, not a full lock detector.
  • A host must report the record's prn and code_phase. HardwareLoopCore does not yet.

The estimator

TrackingLoops.SatVectorPLLAndDLL — Type
SatVectorPLLAndDLL

Per-satellite state of a VectorPLLAndDLL: the inner scalar loop's state and, on top, the interface to the vector-tracking filter.

  • vt_on: whether the filter controls this satellite's NCOs. While false the satellite runs the inner loop and nothing is accumulated.
  • code_discr_acc / carrier_discr_acc: (count, sum) of the DLL output (chips) and the raw FLL discriminator (Hz) since the filter last read them.
  • code_freq_update / carrier_freq_update: the corrections the per-record step applies, set from the filter's latest cycle.
  • code_freq_update_history and code_update_landing_lead: the last three code corrections the filter set, newest first, and how long after the filter's cycle epoch the newest reached the replica. The filter's next code measurement needs them to move the mid-cycle mean discriminator to the epoch. They record what the replica was steered by, so a reset_estimator_state, which folds the newest correction into the inner loop's Dopplers, keeps them.
  • slot, registration and cycle_id: where the navigation engine keeps this satellite (0 until its first record), and the latest cycle it has taken up.
source
TrackingLoops.VectorPLLAndDLL — Type
VectorPLLAndDLL(signals...; inner = ConventionalAssistedPLLAndDLL(),
                config = VectorTracking(), cycle_time = 100ms,
                lock_cn0_threshold = 30dBHz, max_satellites_per_signal = 16,
                num_prompts_for_cn0_estimation = 100,
                approximate_year = year(now(UTC)),
                enable_ionospheric_correction = true,
                enable_tropospheric_correction = true)

Vector-tracking Doppler estimator for the ranging signals (one or more AbstractGNSSSignals, each at most once). It does the whole pipeline inside step_loop, from what the records carry: every satellite stepped with this estimator shares one navigation engine, which syncs to the navigation bits, decodes them, estimates the C/N₀, solves the scalar PVT, seeds the navigation filter from its first fix and from then on runs one filter cycle every cycle_time, taking satellites over and handing them back. A host needs no vector-specific code: it builds the satellites' states with init_estimator_state and steps them like any other loop's. Read the results with navigation_solution, navigation_status, release_reason, member_sats, position_uncertainty and clock_uncertainty.

The records must identify their satellite and replica: prn and code_phase on the LoopRecord, and sample_index / sampling_frequency on a time grid shared by all satellites. Records of a signal not among signals, or without a PRN, throw an ArgumentError. The host should step every satellite at least once per cycle_time / 2: a cycle runs once every satellite has reached its epoch, and a satellite that has not stepped for 2 · cycle_time is dropped.

Per satellite, every record runs inner — the scalar loop the satellite uses until the filter takes it over, and again once the filter releases it. While a satellite is in the vector loop:

  • its carrier filter always runs, with the PLL branch fed by the satellite's own phase discriminator and the FLL branch fed by the filter's carrier correction (the filter integrates it as a frequency error; it is not added to the Doppler directly);
  • its code filter is frozen, and the filter's code correction replaces its output: code_doppler = init_code_doppler + code_freq_update + carrier_filter_output · code_center_frequency_ratio;
  • the DLL output (chips) and the raw FLL discriminator (Hz) are accumulated for the filter to read.

Each satellite picks up the corrections of the latest cycle on its next record, sized for where they land: at landing_sample, or at the record's end under NO_LANDING_SAMPLE.

inner must have an FLL-assisted carrier filter, since that is the only input path the carrier correction has into the loop: a ConventionalPLLAndDLL{ThirdOrderAssistedBilinearLF} (what ConventionalAssistedPLLAndDLL builds) or an NCOReferencedPLLAndDLL. With the latter the phase discriminator keeps its prediction to the landing sample in the vector loop too. Anything else throws an ArgumentError, as does a dataless signal (a pilot such as GPS L1C-P or Galileo E1C): the estimator decodes the bits of the signal it steps. config = nothing only ever solves the scalar PVT.

Storage is allocated at construction for max_satellites_per_signal satellites per signal and only grows past that: a satellite that is dropped leaves its storage to the next one.

source
TrackingLoops.init_estimator_state — Method
init_estimator_state(estimator::VectorPLLAndDLL, driver_signal, carrier_doppler, code_doppler)

The inner loop's state with the vector interface empty: out of the vector loop, nothing accumulated, no corrections. The satellite joins the navigation engine on its first record.

source
TrackingLoops.reset_estimator_state — Method
reset_estimator_state(estimator::VectorPLLAndDLL, state, carrier_doppler, code_doppler)

Re-seed the inner loop from the converged Dopplers, zero both accumulators and stop applying the corrections, keeping vt_on and the satellite's place in the navigation engine. The corrections must leave the NCO words with the re-seed: the converged Dopplers already contain the last correction, so keeping it would apply it twice. The replica is still steered by it, though, so the correction history and its landing lead, which the next code measurement is moved to the epoch with, are kept.

source
TrackingLoops.step_loop — Method
step_loop(estimator::VectorPLLAndDLL, state, record::LoopRecord, words, landing_sample)
    -> (state, carrier_doppler, code_doppler)

One record through the vector loop (see VectorPLLAndDLL): the satellite joins the navigation engine on its first record, takes up the latest cycle's admission, release and corrections, snapshots itself when the record crosses a navigation epoch, runs the loop, and feeds the record's prompt to its bit clock, decoder and C/N₀ estimator. The record on which the last satellite reaches an epoch runs that epoch's navigation cycle. Out of the vector loop the Dopplers are step_loop(estimator.inner, …)'s exactly.

source

The navigation cycle

TrackingLoops.SatelliteReport — Type
SatelliteReport

What a VectorPLLAndDLL knows of one satellite (see satellite_report), so a consumer need not decode its bits or estimate its C/N₀ again:

  • prn, and tracked: whether a satellite is stepped on it now (false once it went two cycles without a record; the rest then describes it as it was);
  • decoder: its navigation-message decoder, up to the last record — the ephemeris, health and time of week. It shares its buffers with the one the estimator keeps decoding into, so copy (copy(decoder)) what is needed after the next record;
  • bit_synced: whether its bit clock has found the bit edges;
  • at the latest epoch it was snapshotted at (epoch, on the records' time grid, nothing before the first): cn0_dbhz, in_lock (synced and the C/N₀ above the lock threshold) and pvt_ready (in lock, decoded for positioning and healthy);
  • in_vector_loop: whether the latest cycle has it in the vector loop, and release_reason whether and why that cycle released it.

The report is the estimator's own object, one per satellite slot, refreshed by every satellite_report call, which therefore allocates nothing: copy out what is needed beyond the next call.

source
TrackingLoops.VTReleaseReason — Type
VTReleaseReason

Why the latest navigation cycle handed a satellite back to its scalar loop (see release_reason):

  • VT_NOT_RELEASED: it was not;
  • VT_INELIGIBLE: no longer tracked (no record for two navigation cycles), decoded for positioning, or healthy;
  • VT_BELOW_HORIZON: below the horizon at the updated position. It is not admitted again until it stands a degree above the horizon, so a satellite at the horizon is not admitted and released every cycle;
  • VT_FALLBACK: vector tracking stopped (starvation timeout, or no member left).

A receiver that forces released satellites out of lock does so for the first two, and not for a fallback.

source
TrackingLoops.VTStatus — Type
VTStatus

What the latest navigation cycle of a VectorPLLAndDLL did (see navigation_status).

  • running: vector tracking is running after this cycle;
  • position_std, clock_std: the filter's 1σ 3-D position and primary-clock uncertainties (NaN while not running). A degenerate geometry shows up here rather than in the starvation timer;
  • time_with_insufficient_meas: how long the filter has been coasting on epochs it could not solve, counted against insufficient_meas_timeout; nonzero means the loop is on its way out, paid back at half rate;
  • num_members: satellites in the vector loop after this cycle;
  • the events enabled (a fresh scalar fix seeded the filter), fell_back (vector tracking stopped) and released (some satellite was released; see release_reason).

The per-member report of the latest cycle is member_sats.

source
TrackingLoops.clock_uncertainty — Method
clock_uncertainty(estimator::VectorPLLAndDLL)

The navigation filter's own 1σ uncertainty (m) of the clock bias the solution is referenced to. Meaningful once the filter has been seeded.

source
TrackingLoops.member_sats — Method
member_sats(estimator::VectorPLLAndDLL)

The per-member report of the latest cycle: every member of the vector loop, measured and coasted, keyed (signal_id, prn) as the solution's sats are, each with the satellite position, transmit time and post-fit residuals. Empty while the scalar solve is in control.

source
TrackingLoops.navigation_solution — Method
navigation_solution(estimator::VectorPLLAndDLL) -> PVTSolution

The latest navigation solution: the scalar PVT's until the filter is seeded, the filter's while vector tracking runs. Its containers are reused by the next cycle, so copy out what is needed later.

source
TrackingLoops.position_uncertainty — Method
position_uncertainty(estimator::VectorPLLAndDLL)

The navigation filter's own 1σ uncertainty (m) of the 3-D position. Meaningful once the filter has been seeded.

source
TrackingLoops.release_reason — Method
release_reason(estimator::VectorPLLAndDLL, signal, prn) -> VTReleaseReason

Whether and why the latest navigation cycle released satellite prn of signal from the vector loop.

source
TrackingLoops.VectorTracking — Type
VectorTracking(; use_pseudorange_rates = true,
               motion_model_order = 2, clock_model_order = 2,
               acceleration_noise_std = 5.0m/s^2,
               h0 = 2e-19, hm2 = 2e-20,
               ifb_noise_density = 0.01m/sqrt(1.0s),
               insufficient_meas_timeout = 10.0s)

Configuration of the vector-tracking navigation filter, for a VectorPLLAndDLL: the platform and the receiver's oscillator. The defaults suit a vehicle carrying a consumer-grade front end.

Fields

  • use_pseudorange_rates: with true (VDFLL) the navigation filter measures both the pseudoranges (DLL discriminators) and the pseudorange rates (FLL discriminators on the carrier Doppler); with false (VDLL) only the pseudoranges enter the filter — the carrier NCO corrections are then derived purely from the filter's velocity/clock-drift prediction.
  • motion_model_order: order of the per-axis motion model — 1 position only, 2 position + velocity, 3 position + velocity + acceleration.
  • clock_model_order: order of the receiver clock model — 1 biases only, 2 biases + a common drift. One clock bias is modelled per GNSS time system, all integrating the single oscillator drift.
  • acceleration_noise_std: white-acceleration process-noise standard deviation driving the motion model — how hard the platform manoeuvres. The 5 m/s² default suits automotive / vehicular dynamics (≈0.5 g of manoeuvring); use ~1 m/s² for pedestrian or ship dynamics and tens of m/s² for aircraft or launch vehicles. It means the same thing at every motion_model_order: the process noise always models the platform's first unmodelled derivative, so the orders that do not model the acceleration (1 and 3) derive their velocity and jerk figures from this one through the manoeuvre time constant MANOEUVRE_TIME — see motion_noise_model, which is also where to change that constant for a platform whose manoeuvres are much shorter or longer than the couple of seconds a road vehicle takes.
  • h0, hm2: Allan-variance coefficients $h_0$ (seconds) and $h_{-2}$ (1/seconds) of the receiver oscillator, sizing the clock process noise — smaller coefficients for a more stable oscillator. They follow from the oscillator's Allan deviation: $σ_y²(τ) = h_0 / 2τ$ at short averaging times gives $h_0 = 2τσ_y²(τ)$, and $σ_y²(τ) = (2π²/3)·h_{-2}·τ$ at long ones gives $h_{-2} = 3σ_y²(τ)/(2π²τ)$. The defaults model a TCXO-grade clock (a typical consumer/automotive receiver oscillator); an OCXO is orders of magnitude tighter, a bare crystal looser.
  • ifb_noise_density: random-walk process-noise density of the inter-frequency biases, in m/√s. Per-band RF-chain delays are nearly constant, so this only has to cover thermal drift of the front end: the default lets a bias wander about 0.01 m in a second of elapsed time. Raise it for a front end whose bands are less thermally coupled, lower it for one that is stable or externally calibrated.
  • insufficient_meas_timeout: how long the navigation filter may coast on epochs it cannot solve before vector tracking is abandoned and the satellites fall back to scalar tracking. An epoch counts as unsolvable when it has fewer measurements than the bias layout in force has unknowns. How certain the filter is of the solution it did produce is reported rather than policed — see VTStatus's position_std.
source