Usage
This page closes a tracking loop end to end in one process: the SimulatedDevice stands in for the FPGA, a heap-backed segment stands in for the shared memory, and the test code plays the receiver, sending an arm command and reading the events.
Building the core
A LoopCore needs a driver, the signals it can track, and the segment it publishes into. The segment needs one event ring per hardware channel, and its band table should match the driver's.
using HardwareLoopCore, HardwareLoopProtocol, GNSSSignals
using Random: Xoshiro
const HLP = HardwareLoopProtocol
signal = GPSL1CA()
fs = 4e6
dev = SimulatedDevice(signal; sampling_freq = fs, num_channels = 2)
seg = create_segment(nothing, SegmentConfig(; channel_count = 2, bands = [BandEntry(:L1, fs)]))
core = LoopCore(dev, (signal,), seg)
core.config.epoch_length4000The epoch defaults to one primary code period of the first signal, here 1 ms or 4000 samples. A LoopConfig passed as config overrides it and the other loop-wide settings; the receiver can change them later with a ConfigureCommand.
Arming a channel
The receiver hands over a satellite with an ArmCommand on the command ring: the signal, the PRN, the Dopplers and the code phase it measured at valid_at_sample, and the tap offsets in samples. The core programs the device, and answers once the device has confirmed. Here channel 1 tracks the satellite and channel 2 becomes the band's noise reference (signal_index = 0), which the C/N₀ estimate is measured against.
prn, doppler, code_phase = 7, 1200.0, 100.0
commands = command_ring(seg)
satellite = ArmCommand(;
signal = :GPSL1CA,
prn,
carrier_doppler_hz = doppler - 10, # a handover is never exact
code_doppler_hz = doppler / 1540,
code_phase_chips = code_phase,
valid_at_sample = 0,
tap_sample_shifts = (-2, 0, 2), # early, prompt, late
num_taps = 3,
sampling_freq_hz = fs,
)
noise = ArmCommand(;
signal = :GPSL1CA,
prn = 30,
signal_index = 0,
carrier_doppler_hz = 3000.0,
code_doppler_hz = 0.0,
code_phase_chips = 0.0,
valid_at_sample = 0,
tap_sample_shifts = (-2, 0, 2),
num_taps = 3,
sampling_freq_hz = fs,
)
try_publish!(commands, CommandTag(HLP.COMMAND_ARM, 1, 1), satellite)
try_publish!(commands, CommandTag(HLP.COMMAND_ARM, 2, 2), noise)trueRunning the loop
In a loop process, run! calls service_pass! until a shutdown command arrives, and the driver's wait_records paces it. Here the samples are synthesised one epoch at a time — the satellite's code with a navigation bit that flips every 20 ms, in noise — the device correlates them with correlate_chunk!, and one pass folds what it produced:
rng = Xoshiro(1)
function synthesize!(buf, n0)
for k in eachindex(buf)
t = (n0 + k - 1) / fs
code = get_code(signal, code_phase + (1.023e6 + doppler / 1540) * t, prn)
bit = isodd(div(n0 + k - 1, 80_000)) ? -1 : 1
buf[k] = 0.15 * bit * code * cis(2π * doppler * t) + randn(rng, ComplexF64)
end
buf
end
buf = Vector{ComplexF64}(undef, 4000)
for c in 0:1499
correlate_chunk!(dev, synthesize!(buf, 4000c))
service_pass!(core; wait_ms = 0)
end
(dev.channels[1].carrier_doppler, core.words_committed)(1198.6604007286785, 1499)The loop has pulled the device's carrier NCO from the handover's 1190 Hz to the true 1200 Hz. Once bit synchronisation is found, the loop steps once per primary code period as before, and the bit buffer integrates the whole bit.
Reading the events
Each channel has an event ring: status events answer commands, a record event follows every loop step, a bit event every navigation bit, and an epoch state closes every epoch. The receiver drains it:
function drain!(events)
counts = Dict{UInt8,Int}()
armed_at = nothing
while true
status, view, lost = peek!(events, EventTag)
status === :empty && break
if view.tag.kind == HLP.EVENT_STATUS
ev = payload(StatusEvent, events, view)
ev.code == HLP.STATUS_ARMED && (armed_at = ev.sample)
end
counts[view.tag.kind] = get(counts, view.tag.kind, 0) + 1
commit!(events, view)
end
(armed_at, records = counts[HLP.EVENT_RECORD], bits = get(counts, HLP.EVENT_BIT, 0),
epoch_states = counts[HLP.EVENT_EPOCH_STATE])
end
drain!(event_ring(seg, 1))(armed_at = 4000, records = 1499, bits = 71, epoch_states = 1499)The newest epoch state is also kept in the channel's snapshot slot, for a receiver that only wants the current state:
tag, state = read_snapshot(snapshot_slot(seg, 1))
(doppler = state.carrier_doppler_hz,
cn0_dBHz = 10log10(state.cn0_linear_hz),
synchronised = state.flags & HLP.STATE_SYNC_FOUND != 0)(doppler = 1198.6604007286785, cn0_dBHz = 49.227764846291166, synchronised = true)Diagnostics
The core counts what went wrong instead of throwing on the service path. Among the counters on a LoopCore: stale_dumps (records for a channel that was free, re-armed or not yet confirmed), lost_record_gaps (records that never arrived), words_late and words_rejected, dropped_records (ingest buffer full), and latency_hist, a histogram of the record-to-word latency over LATENCY_EDGES_US.
(core.stale_dumps, core.lost_record_gaps, core.words_late, core.max_record_age_us)(1, 0, 0, 0)