Modulate SCO Channels
In FM downlink mode, the Pre-Modulation Processor replaces the PCM/voice subcarrier system with 9 Subcarrier Oscillators (SCOs). Each SCO encodes a 0—5V sensor voltage as FM deviation around a fixed center frequency. The sco_mod and sco_demod blocks handle the transmit and receive sides of this analog telemetry path.
SCO Channel Table
Section titled “SCO Channel Table”All 9 channels deviate by +/-7.5% of their center frequency:
| SCO | Center Freq | Deviation (+/-7.5%) | Low Freq | High Freq |
|---|---|---|---|---|
| 1 | 14,500 Hz | 1,087.5 Hz | 13,412.5 Hz | 15,587.5 Hz |
| 2 | 22,000 Hz | 1,650 Hz | 20,350 Hz | 23,650 Hz |
| 3 | 30,000 Hz | 2,250 Hz | 27,750 Hz | 32,250 Hz |
| 4 | 40,000 Hz | 3,000 Hz | 37,000 Hz | 43,000 Hz |
| 5 | 52,500 Hz | 3,937.5 Hz | 48,562.5 Hz | 56,437.5 Hz |
| 6 | 70,000 Hz | 5,250 Hz | 64,750 Hz | 75,250 Hz |
| 7 | 95,000 Hz | 7,125 Hz | 87,875 Hz | 102,125 Hz |
| 8 | 125,000 Hz | 9,375 Hz | 115,625 Hz | 134,375 Hz |
| 9 | 165,000 Hz | 12,375 Hz | 152,625 Hz | 177,375 Hz |
The channels are spaced logarithmically, with each roughly 1.35x the previous. This spacing allows them to be frequency-division multiplexed onto a single composite signal without overlap.
Basic SCO Modulation
Section titled “Basic SCO Modulation”-
Create an sco_mod for a single channel
The
sco_modblock accepts a 0—5V float input and produces an FM tone at the selected channel’s center frequency. Here we modulate a constant 3.3V sensor reading onto SCO channel 5 (52.5 kHz):from gnuradio import analog, blocks, grfrom apollo.constants import SAMPLE_RATE_BASEBANDfrom apollo.sco_mod import sco_modtb = gr.top_block()# Simulate a constant 3.3V sensor readingsensor = analog.sig_source_f(SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, 3.3, 0,)# SCO channel 5: 52.5 kHz center, +/-3937.5 Hz deviationmod = sco_mod(sco_number=5, sample_rate=SAMPLE_RATE_BASEBAND)head = blocks.head(gr.sizeof_float, int(SAMPLE_RATE_BASEBAND * 0.01)) # 10 mssnk = blocks.vector_sink_f()tb.connect(sensor, mod, head, snk)tb.run()print(f"Generated {len(snk.data())} samples")print(f"SCO 5 center: {mod.center_freq} Hz")print(f"SCO 5 deviation: {mod.deviation_hz} Hz") -
Verify the output frequency
At 3.3V input, the SCO should be above center. The voltage maps linearly: 0V is center minus 7.5%, 2.5V is center, 5V is center plus 7.5%. For 3.3V:
offset = (3.3 - 2.5) / 2.5 = 0.32 (normalized)freq = 52500 + 0.32 * 3937.5 = 53760 Hz -
Inspect properties at runtime
The block exposes its configuration as read-only properties:
mod = sco_mod(sco_number=5, sample_rate=SAMPLE_RATE_BASEBAND)print(f"Channel: {mod.sco_number}") # 5print(f"Center: {mod.center_freq} Hz") # 52500.0print(f"Deviation: {mod.deviation_hz} Hz") # 3937.5
Round-Trip: Modulate and Demodulate
Section titled “Round-Trip: Modulate and Demodulate”The sco_demod block reverses the modulation, recovering the original sensor voltage from the FM subcarrier tone. Connecting sco_mod to sco_demod creates a complete round-trip:
from gnuradio import analog, blocks, gr
from apollo.constants import SAMPLE_RATE_BASEBANDfrom apollo.sco_demod import sco_demodfrom apollo.sco_mod import sco_mod
tb = gr.top_block()
# Slowly varying sensor: 1 Hz sine wave, 0-5V rangesensor = analog.sig_source_f( SAMPLE_RATE_BASEBAND, analog.GR_SIN_WAVE, 1.0, 2.5, 2.5,)
# Modulate onto SCO 3 (30 kHz center)mod = sco_mod(sco_number=3, sample_rate=SAMPLE_RATE_BASEBAND)
# Demodulate back to voltagedemod = sco_demod(sco_number=3, sample_rate=SAMPLE_RATE_BASEBAND)
head = blocks.head(gr.sizeof_float, int(SAMPLE_RATE_BASEBAND * 2)) # 2 secondssnk = blocks.vector_sink_f()
tb.connect(sensor, mod, demod, head, snk)tb.run()
import numpy as nprecovered = np.array(snk.data())print(f"Samples: {len(recovered)}")print(f"Mean voltage: {np.mean(recovered):.2f} V (expected ~2.5)")print(f"Voltage range: {np.min(recovered):.2f} - {np.max(recovered):.2f} V")Multi-Channel Summing
Section titled “Multi-Channel Summing”In a real FM downlink, multiple SCO channels are summed to form a composite signal that drives the PM modulator. Each SCO encodes a different sensor:
from gnuradio import analog, blocks, gr
from apollo.constants import SAMPLE_RATE_BASEBANDfrom apollo.pm_mod import pm_modfrom apollo.sco_mod import sco_mod
tb = gr.top_block()
# Three sensors with different readingssensor1 = analog.sig_source_f( SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, 1.2, 0,) # 1.2V (cabin pressure)sensor5 = analog.sig_source_f( SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, 3.8, 0,) # 3.8V (fuel cell voltage)sensor9 = analog.sig_source_f( SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, 2.5, 0,) # 2.5V (temperature)
# One sco_mod per channelsco1 = sco_mod(sco_number=1, sample_rate=SAMPLE_RATE_BASEBAND) # 14.5 kHzsco5 = sco_mod(sco_number=5, sample_rate=SAMPLE_RATE_BASEBAND) # 52.5 kHzsco9 = sco_mod(sco_number=9, sample_rate=SAMPLE_RATE_BASEBAND) # 165 kHz
# Sum all SCO subcarrier tonesadder = blocks.add_ff(1)tb.connect(sensor1, sco1, (adder, 0))tb.connect(sensor5, sco5, (adder, 1))tb.connect(sensor9, sco9, (adder, 2))
# PM modulate the composite SCO signalpm = pm_mod(pm_deviation=0.133, sample_rate=SAMPLE_RATE_BASEBAND)
head = blocks.head(gr.sizeof_gr_complex, int(SAMPLE_RATE_BASEBAND * 0.1)) # 100 mssnk = blocks.vector_sink_c()
tb.connect(adder, pm, head, snk)tb.run()
print(f"Generated {len(snk.data())} complex samples")print(f"SCO channels: {sco1.center_freq}, {sco5.center_freq}, {sco9.center_freq} Hz")The SCO center frequencies (14.5 kHz, 52.5 kHz, 165 kHz) are far enough apart that simple bandpass filtering on the receive side can separate them without interference.
Voltage Mapping
Section titled “Voltage Mapping”The sco_mod block maps input voltage to output frequency linearly:
graph LR
A["0V input"]:::data --> B["center - 7.5%\n(low freq)"]:::rf
C["2.5V input"]:::data --> D["center freq\n(nominal)"]:::rf
E["5V input"]:::data --> F["center + 7.5%\n(high freq)"]:::rf
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
Internally, the modulation chain is:
- Subtract 2.5V — center the signal at zero
- Scale to +/-1.0 — divide by 2.5 (half the input range)
- FM modulate — +/-1.0 input produces +/-deviation Hz output
- Upconvert — mix with a local oscillator at the center frequency
- Extract real part — the output is a real-valued float subcarrier tone
The sco_demod block reverses this:
- Bandpass extract at the channel center frequency (bandwidth = 15% of center)
- FM discriminate — quadrature demod recovers the frequency deviation
- Scale by 2.5 — map discriminator output back to voltage swing
- Add 2.5V offset — restore the 0—5V range
The conversion is symmetric: a constant voltage in produces that same voltage out (within the FM discriminator’s noise floor).
Choosing SCO Channels
Section titled “Choosing SCO Channels”The 9 SCO channels span from 14.5 kHz to 165 kHz. When selecting which channels to use, consider:
| Factor | Guidance |
|---|---|
| Bandwidth | Higher channels have wider deviation bands — better for fast-changing signals |
| Filter settling | Lower channels need longer filter settling time due to narrower bandwidth |
| Channel spacing | Adjacent channels can interfere if the composite signal is distorted |
| Sample rate | All channels work at the default 5.12 MHz baseband rate |
For slow-moving measurements (temperature, pressure), use lower SCO channels (1—4). The narrow bandwidth provides better noise rejection:
# Temperature sensor: changes slowly, needs precisiontemp_sco = sco_mod(sco_number=2, sample_rate=SAMPLE_RATE_BASEBAND)# SCO 2: 22 kHz center, +/-1650 Hz deviationFor vibration monitoring or other fast signals, use higher SCO channels (7—9) where the wider deviation band supports higher-frequency content:
# Vibration sensor: needs higher bandwidthvib_sco = sco_mod(sco_number=8, sample_rate=SAMPLE_RATE_BASEBAND)# SCO 8: 125 kHz center, +/-9375 Hz deviationComplete FM Downlink Example
Section titled “Complete FM Downlink Example”Here is a full example that modulates three sensor channels, transmits them as FM downlink, and recovers all three voltages:
from gnuradio import analog, blocks, gr
from apollo.constants import SAMPLE_RATE_BASEBANDfrom apollo.sco_demod import sco_demodfrom apollo.sco_mod import sco_mod
tb = gr.top_block()
# --- TX side: three sensors ---sensor_readings = {3: 1.8, 5: 4.2, 7: 2.5} # {sco_channel: voltage}
mods = {}adder = blocks.add_ff(1)for idx, (ch, voltage) in enumerate(sensor_readings.items()): src = analog.sig_source_f(SAMPLE_RATE_BASEBAND, analog.GR_CONST_WAVE, 0, voltage, 0) mod = sco_mod(sco_number=ch, sample_rate=SAMPLE_RATE_BASEBAND) mods[ch] = mod tb.connect(src, mod, (adder, idx))
# --- RX side: demodulate each channel from composite ---demods = {}sinks = {}duration_samples = int(SAMPLE_RATE_BASEBAND * 0.5) # 500 ms
for ch in sensor_readings: demod = sco_demod(sco_number=ch, sample_rate=SAMPLE_RATE_BASEBAND) head = blocks.head(gr.sizeof_float, duration_samples) snk = blocks.vector_sink_f() tb.connect(adder, demod, head, snk) demods[ch] = demod sinks[ch] = snk
tb.run()
# Compare input and recovered voltagesimport numpy as npprint("SCO | Input | Recovered (mean) | Error")print("-----|--------|------------------|------")for ch, voltage in sensor_readings.items(): data = np.array(sinks[ch].data()) # Skip first 10% for filter settling settled = data[len(data) // 10:] mean_v = np.mean(settled) print(f" {ch} | {voltage:.1f} V | {mean_v:.3f} V | {abs(voltage - mean_v):.3f} V")Expected output:
SCO | Input | Recovered (mean) | Error-----|--------|------------------|------ 3 | 1.8 V | 1.802 V | 0.002 V 5 | 4.2 V | 4.198 V | 0.002 V 7 | 2.5 V | 2.500 V | 0.000 V