Skip to content

Tune Demodulator Parameters

The gr-apollo demodulation chain has several parameters that control how aggressively each stage tracks the signal. The defaults work well for clean synthetic signals, but real-world recordings and noisy captures often need adjustment.

This guide walks through each tunable parameter, explains what it controls, and gives practical guidance on when and how to change it.

ParameterDefaultBlockWhat It Controls
carrier_pll_bw0.02pm_demodCarrier tracking loop bandwidth (rad/sample)
bpsk_loop_bw0.045bpsk_demodCostas loop + symbol sync bandwidth
subcarrier_bw150,000 Hzsubcarrier_extractBandpass filter width around 1.024 MHz
max_bit_errors3pcm_frame_syncHamming distance threshold for sync word match
bit_rate51,200pcm_frame_syncPCM bit rate (high or low)

All of these are constructor arguments to usb_downlink_receiver, or can be set on individual blocks when building a custom flowgraph.

The carrier_pll_bw parameter controls how quickly the PLL in pm_demod locks onto the residual carrier and tracks frequency drift. It is specified in radians per sample at the 5.12 MHz baseband rate.

ValueBehaviorUse When
0.005Very narrow — slow acquisition, best noise rejectionWeak signal, steady carrier
0.02Default — balanced tracking and noiseGeneral-purpose decoding
0.05Wide — fast acquisition, admits more noiseStrong signal, rapid frequency changes
0.10Very wide — immediate lock, noisy outputInitial acquisition sweep, testing
from apollo.usb_downlink_receiver import usb_downlink_receiver
receiver = usb_downlink_receiver(
carrier_pll_bw=0.01, # Narrow for weak signals
)

The bpsk_loop_bw parameter sets the bandwidth for both the Costas carrier-recovery loop and the Mueller & Muller symbol timing recovery inside bpsk_demod. It affects how well the demodulator tracks phase variations in the 1.024 MHz BPSK subcarrier.

ValueBehaviorUse When
0.01Very tight trackingClean signal, minimal phase noise
0.045DefaultGeneral-purpose, synthetic signals
0.08Loose trackingNoisy signal with phase jitter
0.12Very looseSeverely degraded signal, testing only

The symbol sync loop bandwidth is automatically derived as loop_bw * 0.5:

# Inside bpsk_demod.__init__:
self.sym_sync = digital.symbol_sync_cc(
digital.TED_MUELLER_AND_MULLER,
self._sps, # samples per symbol
loop_bw * 0.5, # timing loop BW = half the Costas BW
1.0, # damping factor
1.0, # TED gain
1.5, # max deviation (samples)
1, # output samples per symbol
bpsk_constellation,
)

The subcarrier_bw parameter sets the passband width of the frequency-translating FIR filter in subcarrier_extract. The default 150 kHz matches the Apollo PCM bandpass filter specification (949—1099 kHz).

ValueBehaviorTrade-off
80,000 HzVery narrowRejects adjacent subcarriers but may clip sideband energy
150,000 HzDefault (spec)Matches the original hardware BPF
200,000 HzWideMore tolerant of frequency offset, but admits more noise
250,000 HzVery wideUse only if the subcarrier frequency is uncertain
from apollo.subcarrier_extract import subcarrier_extract
sc = subcarrier_extract(
center_freq=1_024_000,
bandwidth=200_000, # Wider than default
sample_rate=5_120_000,
)

The max_bit_errors parameter controls how many bits in the 26-bit static portion of the sync word (the 5-bit A field + 15-bit core + 6-bit B field) can differ from the reference and still count as a match. The 6-bit frame ID field is not included in the correlation.

ValueBehaviorTrade-off
0Exact match onlyNo false locks, but very slow acquisition on noisy signals
3DefaultTolerates 3 bit errors in the 26-bit pattern
5PermissiveFaster acquisition, but higher chance of false sync
8Very permissiveTesting only — will produce many false frames
from apollo.pcm_frame_sync import FrameSyncEngine
engine = FrameSyncEngine(
bit_rate=51200,
max_bit_errors=2, # Stricter than default
)

The sync engine uses a three-state machine that provides additional protection against false locks:

stateDiagram-v2
    [*] --> SEARCH
    SEARCH --> VERIFY : Sync match found (Hamming distance <= max_bit_errors)
    VERIFY --> LOCKED : verify_count consecutive hits (default 2)
    VERIFY --> SEARCH : miss_limit consecutive misses (default 3)
    LOCKED --> LOCKED : Continuous sync matches
    LOCKED --> SEARCH : miss_limit consecutive misses

Even with a permissive max_bit_errors, the VERIFY stage requires multiple consecutive sync hits at the expected frame spacing before declaring lock.

Apollo PCM telemetry operates at two bit rates, both derived from the 512 kHz master clock:

ParameterHigh RateLow Rate
Bit rate51,200 bps1,600 bps
Clock divider512 kHz / 10512 kHz / 320
Words per frame128200
Frames per second501
Frame period~20 ms1 s
from apollo.usb_downlink_receiver import usb_downlink_receiver
receiver = usb_downlink_receiver(
bit_rate=51200,
)

Use this table to identify which parameter to adjust based on what you observe:

SymptomLikely CauseParameter to Adjust
No frames decoded at allCarrier PLL not lockingIncrease carrier_pll_bw to 0.05+
Frames appear then disappearBPSK loop tracking noiseDecrease bpsk_loop_bw to 0.02
Low sync confidence valuesBit errors in sync wordIncrease max_bit_errors to 4-5
Noisy decoded voltage valuesToo much noise in passbandDecrease subcarrier_bw to 100,000
PLL acquires then driftsCarrier PLL too narrow for driftIncrease carrier_pll_bw slightly
Rapid false frame emissionsSync threshold too permissiveDecrease max_bit_errors to 1-2
”Frame too short” errorsWrong bit rate selectedSwitch between 51200 and 1600

For a synthetic test signal at 30 dB SNR, the defaults work as-is:

receiver = usb_downlink_receiver(
carrier_pll_bw=0.02,
bpsk_loop_bw=0.045,
subcarrier_bw=150_000,
max_bit_errors=3,
bit_rate=51200,
)

For a weak or noisy real-world recording, start with these and iterate:

receiver = usb_downlink_receiver(
carrier_pll_bw=0.05, # Wider for acquisition
bpsk_loop_bw=0.03, # Tighter to reject noise
subcarrier_bw=120_000, # Narrower to reduce noise floor
max_bit_errors=4, # More tolerant of bit errors
bit_rate=51200,
)