Classroom Glossary Public page

Lab 6: NOAA APT Weather Satellite Reception and GNU Radio Demodulator

342 words

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

  1. Predict, capture, and decode a real NOAA APT satellite pass using RTL-SDR + V-dipole antenna.
  2. Implement the APT demodulation pipeline in GNU Radio from a saved IQ recording.
  3. 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-apt decoder: cargo install noaa-apt or binary from noaa-apt.mbernardi.com.ar
  • predict CLI 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:

  1. Pass prediction: satellite name, AOS time, TCA time (UTC), maximum elevation angle
  2. Decoded APT image (PNG attachment or embedded)
  3. GNU Radio flowgraph screenshot (or Python pipeline output image)
  4. 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)
  1. 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