Skip to content

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.

All 9 channels deviate by +/-7.5% of their center frequency:

SCOCenter FreqDeviation (+/-7.5%)Low FreqHigh Freq
114,500 Hz1,087.5 Hz13,412.5 Hz15,587.5 Hz
222,000 Hz1,650 Hz20,350 Hz23,650 Hz
330,000 Hz2,250 Hz27,750 Hz32,250 Hz
440,000 Hz3,000 Hz37,000 Hz43,000 Hz
552,500 Hz3,937.5 Hz48,562.5 Hz56,437.5 Hz
670,000 Hz5,250 Hz64,750 Hz75,250 Hz
795,000 Hz7,125 Hz87,875 Hz102,125 Hz
8125,000 Hz9,375 Hz115,625 Hz134,375 Hz
9165,000 Hz12,375 Hz152,625 Hz177,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.

  1. Create an sco_mod for a single channel

    The sco_mod block 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, gr
    from apollo.constants import SAMPLE_RATE_BASEBAND
    from apollo.sco_mod import sco_mod
    tb = gr.top_block()
    # Simulate a constant 3.3V sensor reading
    sensor = 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 deviation
    mod = sco_mod(sco_number=5, sample_rate=SAMPLE_RATE_BASEBAND)
    head = blocks.head(gr.sizeof_float, int(SAMPLE_RATE_BASEBAND * 0.01)) # 10 ms
    snk = 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")
  2. 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
  3. 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}") # 5
    print(f"Center: {mod.center_freq} Hz") # 52500.0
    print(f"Deviation: {mod.deviation_hz} Hz") # 3937.5

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_BASEBAND
from apollo.sco_demod import sco_demod
from apollo.sco_mod import sco_mod
tb = gr.top_block()
# Slowly varying sensor: 1 Hz sine wave, 0-5V range
sensor = 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 voltage
demod = sco_demod(sco_number=3, sample_rate=SAMPLE_RATE_BASEBAND)
head = blocks.head(gr.sizeof_float, int(SAMPLE_RATE_BASEBAND * 2)) # 2 seconds
snk = blocks.vector_sink_f()
tb.connect(sensor, mod, demod, head, snk)
tb.run()
import numpy as np
recovered = 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")

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_BASEBAND
from apollo.pm_mod import pm_mod
from apollo.sco_mod import sco_mod
tb = gr.top_block()
# Three sensors with different readings
sensor1 = 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 channel
sco1 = sco_mod(sco_number=1, sample_rate=SAMPLE_RATE_BASEBAND) # 14.5 kHz
sco5 = sco_mod(sco_number=5, sample_rate=SAMPLE_RATE_BASEBAND) # 52.5 kHz
sco9 = sco_mod(sco_number=9, sample_rate=SAMPLE_RATE_BASEBAND) # 165 kHz
# Sum all SCO subcarrier tones
adder = 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 signal
pm = 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 ms
snk = 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.

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:

  1. Subtract 2.5V — center the signal at zero
  2. Scale to +/-1.0 — divide by 2.5 (half the input range)
  3. FM modulate — +/-1.0 input produces +/-deviation Hz output
  4. Upconvert — mix with a local oscillator at the center frequency
  5. Extract real part — the output is a real-valued float subcarrier tone

The sco_demod block reverses this:

  1. Bandpass extract at the channel center frequency (bandwidth = 15% of center)
  2. FM discriminate — quadrature demod recovers the frequency deviation
  3. Scale by 2.5 — map discriminator output back to voltage swing
  4. 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).

The 9 SCO channels span from 14.5 kHz to 165 kHz. When selecting which channels to use, consider:

FactorGuidance
BandwidthHigher channels have wider deviation bands — better for fast-changing signals
Filter settlingLower channels need longer filter settling time due to narrower bandwidth
Channel spacingAdjacent channels can interfere if the composite signal is distorted
Sample rateAll 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 precision
temp_sco = sco_mod(sco_number=2, sample_rate=SAMPLE_RATE_BASEBAND)
# SCO 2: 22 kHz center, +/-1650 Hz deviation

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_BASEBAND
from apollo.sco_demod import sco_demod
from 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 voltages
import numpy as np
print("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