Week: 8 -- SATCOM
Points: 20
Time estimate: 90 min lab + 2.5 hr independent
Deliverable: lab-6-report.md + GNU Radio flowgraph + decoded image
Objectives
- Predict, capture, and decode a real NOAA APT satellite pass using RTL-SDR + V-dipole antenna.
- Implement the APT demodulation pipeline in GNU Radio from a saved IQ recording.
- Compute the link budget for the NOAA APT downlink and compare to the received SNR.
Prerequisites
- RTL-SDR (any model)
- V-dipole antenna for 137 MHz (two 53 cm elements at 120° angle; ~$5 in parts or $15-20 pre-made)
noaa-aptdecoder:cargo install noaa-aptor binary from noaa-apt.mbernardi.com.arpredictCLI or Heavens-Above.com for pass prediction- GNU Radio with RTL-SDR source block
Part A: Pass Prediction and Capture (30 min)
# Install predict (orbital predictor)
sudo apt-get install predict
# Find your station's latitude and longitude
# Set in /etc/predict/predict.qth:
# Station name
# Latitude (decimal degrees, positive = North)
# Longitude (decimal degrees, positive = West -- note: West is positive in predict)
# Altitude (meters)
# Predict next NOAA passes (next 24 hours)
predict -t /usr/share/predict/satdata/weather.tle
# NOAA satellite frequencies:
# NOAA-15: 137.620 MHz (APT)
# NOAA-18: 137.9125 MHz (APT)
# NOAA-19: 137.100 MHz (APT)
# Capture pass using RTL-SDR (adjust frequency for your target)
# Record ~15 minutes around predicted AOS (Acquisition of Signal)
TARGET_FREQ="137912500" # NOAA-18 APT
SAMPLE_RATE="2400000" # 2.4 MSPS (sufficient for APT)
FILENAME="noaa18_$(date +%Y%m%d_%H%M%S).cf32"
echo "Starting capture in 60 seconds..."
sleep 60 # wait for AOS
timeout 900 rtl_sdr -f $TARGET_FREQ -s $SAMPLE_RATE -g 40 $FILENAME
echo "Capture complete: $FILENAME"
ls -lh $FILENAME
If weather/schedule prevents live capture: The instructor provides a pre-captured IQ file. Use it for Parts B and C while noting the satellite pass details from the file metadata.
Part B: Decode with noaa-apt (10 min)
# Decode directly from IQ file
# noaa-apt can handle raw IQ or WAV
# First convert to FM-demodulated WAV
# Option 1: Use rtl_fm to demodulate to audio, then noaa-apt
rtl_fm -f 137.9125e6 -s 48000 -r 48000 -g 40 - | \
sox -t raw -r 48000 -e signed -b 16 -c 1 - noaa18_audio.wav rate 11025
noaa-apt noaa18_audio.wav -o noaa18_image.png
display noaa18_image.png # or open in image viewer
# Option 2: From IQ file (if rtl_fm is unavailable)
python3 - << 'EOF'
import numpy as np
from scipy.signal import decimate, lfilter, butter
# Load IQ file
iq = np.fromfile('noaa18_*.cf32', dtype=np.complex64)
fs = 2400000 # sample rate
# FM demodulation
# Instantaneous phase
phase = np.unwrap(np.angle(iq))
# Instantaneous frequency = derivative of phase
fm_audio = np.diff(phase) * fs / (2 * np.pi * 75000) # normalize to ±1
# Decimate from 2.4 MHz to ~48 kHz
# Decimate in stages: 2.4MHz → 480kHz → 48kHz
audio_480 = decimate(fm_audio, 5, ftype='fir')
audio_48k = decimate(audio_480, 10, ftype='fir')
# Save to WAV
import wave, struct
with wave.open('noaa18_decoded.wav', 'w') as wf:
wf.setnchannels(1)
wf.setsampwidth(2) # 16-bit
wf.setframerate(48000)
# Clip and scale to int16
audio_int16 = np.clip(audio_48k * 16384, -32768, 32767).astype(np.int16)
wf.writeframes(audio_int16.tobytes())
print("WAV written: noaa18_decoded.wav")
print("Run: noaa-apt noaa18_decoded.wav -o image.png")
EOF
Part C: GNU Radio Demodulator Implementation (45 min)
Implement the full APT demodulation pipeline in GNU Radio Companion (GRC). Create lab6/noaa_apt_demod.grc:
Flowgraph blocks:
[File Source (IQ cf32)]
→ [Throttle (2.4 MSPS)]
→ [Low Pass Filter: cutoff=40kHz, transition=10kHz, taps=remez(n=128)]
→ [Quadrature Demod: gain = sample_rate / (2π × 17000)]
→ [Low Pass Filter: cutoff=5kHz, transition=1kHz, taps=remez(n=64)]
→ [Multiply Const (amplitude normalize)]
→ [Rational Resampler: interpolation=4160, decimation=48000]
→ [Threshold (0.5)]
→ [File Sink (gray image data)]
Python implementation (equivalent pipeline):
#!/usr/bin/env python3
"""Lab 6 Part C: APT demodulation pipeline (equivalent to GNU Radio flowgraph)."""
import numpy as np
from scipy.signal import remez, lfilter, decimate, resample_poly
from scipy.signal import hilbert
import matplotlib.pyplot as plt
def apt_demodulate(iq_samples, fs=2400000):
"""
Full APT demodulation pipeline.
Returns: decoded APT audio samples at 4160 Sa/s (one sample per pixel)
"""
# Stage 1: Lowpass filter around APT signal (±17 kHz deviation + 2.4 kHz audio BW)
h_rf = remez(129, [0, 30e3/fs, 45e3/fs, 1.0], [1, 0], weight=[1, 100])
iq_filtered = lfilter(h_rf, 1.0, iq_samples)
# Stage 2: FM demodulation
# Instantaneous phase → frequency
phase = np.unwrap(np.angle(iq_filtered))
fm_demod = np.diff(phase) * fs / (2 * np.pi) # Hz
# Normalize: APT deviation is ±17 kHz
fm_normalized = fm_demod / 17000.0 # ±1 range
# Stage 3: Lowpass filter (subcarrier bandwidth ~ 2400 Hz + video BW)
h_audio = remez(65, [0, 3000/fs, 5000/fs, 1.0], [1, 0], weight=[1, 100])
audio = lfilter(h_audio, 1.0, fm_normalized)
# Stage 4: AM envelope detection (the 2400 Hz subcarrier)
# The APT video is AM-modulated on a 2400 Hz subcarrier
# Envelope = |analytic signal|
subcarrier = 2400.0 # Hz
# Bandpass around the subcarrier
h_bp = remez(65, [0, (subcarrier-500)/fs, subcarrier/fs,
(subcarrier+500)/fs, (subcarrier+1500)/fs, 1.0],
[0, 1, 0], weight=[100, 1, 100])
subcarrier_filtered = lfilter(h_bp, 1.0, audio)
# Envelope detect
envelope = np.abs(hilbert(subcarrier_filtered))
# Stage 5: Resample to 4160 Sa/s (APT pixel rate: 4160 pixels/s)
# 4160 / fs is the resample ratio
# Use integer ratio: resample_poly(x, up=4160, down=int(fs))
import math
gcd = math.gcd(4160, int(fs))
pixel_data = resample_poly(envelope, 4160 // gcd, int(fs) // gcd)
return pixel_data
# Load IQ file (or use instructor-provided file)
import glob
iq_files = glob.glob('noaa18_*.cf32')
if iq_files:
iq = np.fromfile(iq_files[0], dtype=np.complex64)
print(f"Loaded {len(iq)/2400000:.1f} seconds of IQ data")
# Demodulate
pixels = apt_demodulate(iq, fs=2400000)
# Reshape into image (APT: 2080 pixels per line at 2 lines/sec)
pixels_per_line = 2080
n_lines = len(pixels) // pixels_per_line
image = pixels[:n_lines * pixels_per_line].reshape(n_lines, pixels_per_line)
# Normalize for display
image_norm = (image - np.min(image)) / (np.max(image) - np.min(image))
plt.figure(figsize=(12, 8))
plt.imshow(image_norm, cmap='gray', aspect='auto')
plt.title('NOAA APT Decoded Image (GNU Radio Equivalent Pipeline)')
plt.xlabel('Pixel (0-2079)')
plt.ylabel('Scan line')
plt.colorbar(label='Normalized intensity')
plt.savefig('lab6/apt_image.png', dpi=150)
print(f"Image shape: {image.shape} ({n_lines} lines × {pixels_per_line} pixels)")
else:
print("No IQ file found. Use instructor-provided file or capture from live pass.")
Part D: Link Budget Analysis (5 min)
#!/usr/bin/env python3
"""Lab 6 Part D: NOAA APT link budget."""
import numpy as np
# NOAA APT link budget at 137.9125 MHz
f = 137.9125e6 # Hz
c = 3e8
wavelength = c / f
# Transmitter (satellite)
P_tx_W = 5.0 # typical NOAA APT TX power
G_tx_dBi = 0.0 # omnidirectional antenna
P_tx_dBW = 10*np.log10(P_tx_W)
EIRP_dBW = P_tx_dBW + G_tx_dBi
# Propagation
altitude_km = 800 # NOAA POES at ~800 km
# Range at zenith pass: altitude km
# Range at 10° elevation: longer path
elevation_deg = 45 # mid-pass elevation (approximation)
slant_range_km = altitude_km / np.sin(np.deg2rad(elevation_deg))
print(f"Slant range at {elevation_deg}° elevation: {slant_range_km:.0f} km")
FSPL_dB = 20*np.log10(4 * np.pi * slant_range_km * 1e3 / wavelength)
L_atm_dB = 0.5 # atmospheric absorption at VHF
# Receiver (V-dipole on RTL-SDR)
G_rx_dBi = 0.0 # V-dipole: roughly 0 dBi (ideal dipole)
T_sky_K = 300 # elevated sky noise at VHF (not as clean as microwave)
T_ant_K = T_sky_K
T_lna_K = 250 # RTL-SDR LNA noise temperature (~8 dB NF → ~1500K; optimistic: 250K)
T_sys_K = T_ant_K + T_lna_K
NF_sys_dB = 10*np.log10(1 + T_sys_K/290)
# Received power
P_rx_dBW = EIRP_dBW + G_rx_dBi - FSPL_dB - L_atm_dB
P_rx_dBm = P_rx_dBW + 30
# Noise
k_B = 1.38e-23
B = 40000 # ~40 kHz receive bandwidth for APT
P_noise_dBW = 10*np.log10(k_B * T_sys_K * B)
P_noise_dBm = P_noise_dBW + 30
SNR_dB = P_rx_dBm - P_noise_dBm
print(f"\nNOAA APT Link Budget (@ {elevation_deg}° elevation):")
print(f" EIRP: {EIRP_dBW:.1f} dBW")
print(f" FSPL: -{FSPL_dB:.1f} dB")
print(f" P_rx: {P_rx_dBm:.1f} dBm")
print(f" System T: {T_sys_K:.0f} K (NF ≈ {NF_sys_dB:.1f} dB)")
print(f" Noise power: {P_noise_dBm:.1f} dBm ({B/1e3:.0f} kHz BW)")
print(f" SNR: {SNR_dB:.1f} dB")
print(f"\nAPT requires SNR > ~8 dB for usable image quality")
Lab Report
Create lab-6-report.md with:
- Pass prediction: satellite name, AOS time, TCA time (UTC), maximum elevation angle
- Decoded APT image (PNG attachment or embedded)
- GNU Radio flowgraph screenshot (or Python pipeline output image)
- Link budget table:
| Parameter | Value |
|---|---|
| Satellite | NOAA-XX |
| Frequency | 137.XXX MHz |
| Slant range (km) at TCA | |
| EIRP (dBW) | |
| FSPL (dB) | |
| P_rx (dBm) | |
| System noise temperature (K) | |
| Receive SNR (dB) |
- Analysis (100 words): "Compare the NOAA APT link at 137 MHz to a hypothetical identical link at 1575 MHz (GPS L1). What is the difference in FSPL? What does this imply about the antenna gain required to maintain the same SNR at L-band?"
Grading
| Component | Points |
|---|---|
| Decoded APT image (recognizable weather image) | 6 |
| GNU Radio / Python pipeline: demodulation stages documented | 6 |
| Link budget: all parameters filled in correctly | 5 |
| Analysis: frequency vs. path loss comparison | 3 |
| Total | 20 |