Classroom Glossary Public page

Lab 3: Full-Duplex Receiver Chain on ANT-SDR E200 -- SNR Budget

460 words

Week: 4 -- Software-Defined Receivers and Transmitters
Points: 20
Time estimate: 90 min lab + 2 hr independent
Deliverable: lab-3-report.md


Objectives

  1. Measure the noise figure of the ANT-SDR E200 receive chain at 900 MHz using the Y-factor method.
  2. Measure the ADC dynamic range and I/Q imbalance.
  3. Construct a complete receiver chain SNR budget from antenna to digital samples.
  4. Demonstrate full-duplex operation via digital loopback; measure self-interference level.

Prerequisites

  • ANT-SDR E200 connected via USB or Ethernet
  • iio_attr and Python iio library installed (from RF-201 kit)
  • Python + numpy + matplotlib in the GNU Radio conda environment

Part A: Noise Figure via Y-Factor Method (30 min)

The Y-factor method uses two known noise sources to measure the device under test's (DUT's) noise figure.

Setup: Connect a known 50Ω termination (room temperature, ~290 K) to the E200 input, measure noise power. Then connect a noise source (or estimate from a calibrated signal generator).

#!/usr/bin/env python3
"""Lab 3 Part A: Y-Factor noise figure measurement."""
import iio
import numpy as np
import time

# Connect to ANT-SDR E200
ctx = iio.create_context("ip:192.168.2.1")  # adjust IP if needed
phy = ctx.find_device("ad9361-phy")
rx = ctx.find_device("cf-ad9361-lpc")

def configure_rx(center_freq_hz, sample_rate_hz, bandwidth_hz, gain_dB):
    """Configure E200 receive chain."""
    phy.find_channel("voltage0", is_output=False).attrs["rf_port_select"].value = "A_BALANCED"
    phy.find_channel("altvoltage0", is_output=True).attrs["frequency"].value = str(int(center_freq_hz))
    phy.find_channel("voltage0", is_output=False).attrs["sampling_frequency"].value = str(int(sample_rate_hz))
    phy.find_channel("voltage0", is_output=False).attrs["rf_bandwidth"].value = str(int(bandwidth_hz))
    phy.find_channel("voltage0", is_output=False).attrs["hardwaregain_available"].value = str(gain_dB)

def capture_samples(n_samples):
    """Capture IQ samples from E200."""
    cap = iio.Block(rx, n_samples, False)
    rx_i = rx.find_channel("voltage0")
    rx_q = rx.find_channel("voltage1")
    rx_i.enabled = True
    rx_q.enabled = True
    cap.refill()
    i_data = np.frombuffer(cap.buf[rx_i.id], dtype=np.int16).astype(np.float32)
    q_data = np.frombuffer(cap.buf[rx_q.id], dtype=np.int16).astype(np.float32)
    return (i_data + 1j * q_data) / 2048.0  # normalize to ±1

def measure_noise_power(n_samples=65536):
    """Measure average power with no input signal (terminated input)."""
    samples = capture_samples(n_samples)
    return np.mean(np.abs(samples)**2)

# ---- Y-Factor Noise Figure Measurement ----
# Setup: terminate input with 50Ω (room temp = T_cold = 290 K)
# Y-factor method requires two temperatures; use switched attenuator or noise source
# For lab without dedicated noise source: use Y-factor with external hot/cold sources

T0 = 290.0     # reference temperature (K)
k_B = 1.38e-23 # Boltzmann's constant

# Practical approach: measure with two gain settings at calibrated input
# (uses the E200's hardware gain steps as a controlled attenuation change)

configure_rx(900e6, 1e6, 800e3, gain_dB=0)   # low gain
time.sleep(0.5)
P_low_gain = measure_noise_power()

configure_rx(900e6, 1e6, 800e3, gain_dB=50)  # high gain (50 dB more)
time.sleep(0.5)
P_high_gain = measure_noise_power()

# The gain difference should be ~50 dB (power ratio = 10^5)
# Noise figure estimate from the difference (approximate method)
gain_step_linear = 10**(50/10)  # 50 dB gain step
Y_factor = P_high_gain / (P_low_gain * gain_step_linear)  # should be ≈ 1 if NF dominated by high-gain stage

# More practical: measure against a signal of known power
print(f"Low-gain noise floor:  {10*np.log10(P_low_gain):.1f} dBFS")
print(f"High-gain noise floor: {10*np.log10(P_high_gain):.1f} dBFS")
print(f"Expected gain step: 50 dB")
print(f"Measured power ratio: {10*np.log10(P_high_gain/P_low_gain):.1f} dB")

# Estimate system noise temperature from thermal noise reference
B = 1e6     # 1 MHz bandwidth
P_thermal_dBm = 10*np.log10(k_B * T0 * B * 1e3)  # dBm
print(f"\nThermal noise floor at {B/1e6:.0f} MHz BW, 290 K: {P_thermal_dBm:.1f} dBm")
print("Subtract from measured noise floor (dBm) to get noise figure estimate")
print("(Requires absolute power calibration from E200 datasheet)")

Manual approach (if iio interface unavailable): Use GNU Radio with a File Sink to capture samples, then analyze in Python. Use the documented sensitivity floor from the AD9361 datasheet (NF ≈ 3-4 dB at 900 MHz with max hardware gain) as the reference baseline.

Record:

  1. Noise power at low gain and high gain settings
  2. Measured gain step vs. expected 50 dB
  3. Estimated system noise figure (compare to AD9361 datasheet typical: 3-4 dB at 900 MHz)

Part B: I/Q Imbalance Measurement (20 min)

#!/usr/bin/env python3
"""Lab 3 Part B: I/Q imbalance measurement."""
import numpy as np
import matplotlib.pyplot as plt

# Approach: inject a single tone at f_offset from the LO
# I/Q imbalance produces a mirror image at -f_offset
# Image rejection ratio (IRR) quantifies the imbalance

# For hardware measurement: tune LO to 900 MHz, inject tone at 900.1 MHz
# In simulation: model the AD9361's typical I/Q imbalance and verify estimation

def iq_imbalance_model(iq_signal, gain_imb_db, phase_deg):
    """Apply I/Q imbalance."""
    A = 10**(gain_imb_db/20)
    phi = np.deg2rad(phase_deg)
    I = iq_signal.real
    Q = iq_signal.imag
    I_out = I
    Q_out = A * np.cos(phi) * Q + A * np.sin(phi) * I
    return I_out + 1j * Q_out

def estimate_iq_imbalance(x):
    """Estimate I/Q imbalance from signal statistics."""
    I = x.real
    Q = x.imag
    E_II = np.mean(I**2)
    E_QQ = np.mean(Q**2)
    E_IQ = np.mean(I*Q)
    gain_ratio = np.sqrt(E_II/E_QQ)
    gain_db = 20*np.log10(gain_ratio)
    phase_rad = np.arcsin(2*E_IQ/(E_II+E_QQ))
    phase_deg = np.degrees(phase_rad)
    return gain_db, phase_deg

def irr_db(gain_imb_db, phase_deg):
    """Compute image rejection ratio from I/Q imbalance."""
    A = 10**(gain_imb_db/20)
    phi = np.deg2rad(phase_deg)
    eps = (A * np.exp(1j*phi) - 1) / (A * np.exp(1j*phi) + 1)
    return -20*np.log10(np.abs(eps))

# Either use captured samples from Part A, or simulate with typical AD9361 values
# AD9361 typical I/Q imbalance: ~0.1 dB gain, ~1° phase (with digital correction)
# Without correction: ~0.5 dB gain, ~2-3° phase

# Simulate typical uncorrected AD9361 imbalance
np.random.seed(42)
N = 100000
fs = 1e6
f_tone = 100e3  # 100 kHz offset tone

t = np.arange(N) / fs
ideal_tone = np.exp(2j * np.pi * f_tone * t)  # complex tone at +100 kHz

# Apply typical uncorrected imbalance
gain_imb_db = 0.5    # 0.5 dB gain imbalance
phase_deg = 2.5      # 2.5° phase imbalance

imbalanced = iq_imbalance_model(ideal_tone, gain_imb_db, phase_deg)

# Add noise
noise = 0.01 * (np.random.randn(N) + 1j*np.random.randn(N))
rx = imbalanced + noise

# Estimate imbalance
g_est, p_est = estimate_iq_imbalance(rx)
irr = irr_db(g_est, p_est)

print(f"Estimated I/Q imbalance: gain={g_est:.3f} dB (true: {gain_imb_db}), "
      f"phase={p_est:.2f}° (true: {phase_deg})")
print(f"Image rejection ratio: {irr:.1f} dBc")

# Frequency domain: show tone and its image
NFFT = 65536
f_axis = np.fft.fftfreq(NFFT, 1/fs) / 1e3
S = 20*np.log10(np.abs(np.fft.fft(rx[:NFFT])/NFFT) + 1e-10)
S_shifted = np.fft.fftshift(S)
f_shifted = np.fft.fftshift(f_axis)

plt.figure(figsize=(10, 5))
plt.plot(f_shifted, S_shifted)
plt.axvline(f_tone/1e3, color='g', linestyle='--', label=f'Tone at +{f_tone/1e3:.0f} kHz')
plt.axvline(-f_tone/1e3, color='r', linestyle='--', label=f'Image at -{f_tone/1e3:.0f} kHz')
plt.xlabel('Frequency (kHz)')
plt.ylabel('Power (dBFS)')
plt.title(f'I/Q Imbalance: Tone at +{f_tone/1e3:.0f} kHz; IRR={irr:.1f} dBc')
plt.legend()
plt.xlim([-200, 200])
plt.ylim([-80, 0])
plt.grid(True)
plt.savefig('lab3/iq_imbalance.png', dpi=150)

print(f"\nFor hardware measurement:")
print(f"Tune LO to 900 MHz; inject tone at 900.1 MHz from signal generator")
print(f"Measure power at +100 kHz and -100 kHz in the spectrum")
print(f"IRR [dBc] = P_tone [dBFS] - P_image [dBFS]")

Part C: Complete SNR Budget (20 min)

Fill in the receiver chain SNR budget for the ANT-SDR E200:

#!/usr/bin/env python3
"""Lab 3 Part C: ANT-SDR E200 receiver chain SNR budget."""
import numpy as np

def friis_nf(NF_dB, G_dB):
    """Cascaded noise figure via Friis formula."""
    F = [10**(n/10) for n in NF_dB]
    G = [10**(g/10) for g in G_dB]
    F_total = F[0]
    G_cum = G[0]
    for i in range(1, len(F)):
        F_total += (F[i]-1) / G_cum
        G_cum *= G[i]
    return 10*np.log10(F_total)

# ANT-SDR E200 receive chain (900 MHz, from AD9361 datasheet + typical SDR chain)
# Fill in measured or datasheet values
stages = {
    'RF port + SMA': {'NF': 0.5, 'G': -0.5},    # connector/cable loss
    'LNA (internal)': {'NF': 3.5, 'G': 14.0},    # AD9361 LNA; NF ~3-4 dB typ
    'Mixer': {'NF': 12.0, 'G': 0.0},              # effective; combined with LNA
    'VGA (hardware gain)': {'NF': 8.0, 'G': 50.0}, # hardware gain = 0..73 dB
    'ADC (12-bit)': {'NF': 15.0, 'G': 0.0},       # quantization noise contribution
}

NF_list = [s['NF'] for s in stages.values()]
G_list = [s['G'] for s in stages.values()]

NF_cascaded = friis_nf(NF_list, G_list)
print("ANT-SDR E200 Receiver Chain Budget (900 MHz, 50 dB hardware gain)")
print("-" * 60)
for name, params in stages.items():
    print(f"  {name:30s}: NF={params['NF']:.1f} dB, G={params['G']:.1f} dB")
print("-" * 60)
print(f"  Cascaded noise figure: {NF_cascaded:.2f} dB")

# Sensitivity
B_hz = 1e6         # 1 MHz channel bandwidth
SNR_min_dB = 10.0  # for QPSK with BER 1e-3
sensitivity_dBm = -174 + 10*np.log10(B_hz) + NF_cascaded + SNR_min_dB
print(f"\nReceiver sensitivity: {sensitivity_dBm:.1f} dBm")
print(f"  (1 MHz BW, SNR_min = {SNR_min_dB:.0f} dB)")

# SFDR
IIP3_dBm = -10.0   # AD9361 typical input IIP3 (conservative)
noise_floor_dBm = sensitivity_dBm - SNR_min_dB  # noise floor
SFDR_dB = (2/3) * (IIP3_dBm - noise_floor_dBm)
print(f"\nSFDR: {SFDR_dB:.1f} dB")
print(f"  (IIP3={IIP3_dBm:.0f} dBm, noise floor={noise_floor_dBm:.0f} dBm)")

# ADC dynamic range
ADC_bits = 12
SQNR_dB = 6.02 * ADC_bits + 1.76
print(f"\nADC theoretical SQNR: {SQNR_dB:.1f} dB (12-bit ideal)")
print(f"ENOB constraint: SFDR ≤ {SQNR_dB:.1f} dB (ADC does not limit)")

Part D: Digital Loopback Self-Interference (20 min)

Demonstrate self-interference using the E200's digital loopback mode (TX signal internally looped back to RX without going through the antenna).

# Enable digital loopback via iio attribute
iio_attr -u ip:192.168.2.1 -d ad9361-phy loopback 1

# Capture with loopback enabled
python3 - <<'EOF'
import iio, numpy as np
ctx = iio.create_context("ip:192.168.2.1")
# ... capture samples with loopback
# Measure the power of the looped-back signal vs. noise floor
# Self-interference = loopback signal power - noise floor
EOF

# Disable loopback
iio_attr -u ip:192.168.2.1 -d ad9361-phy loopback 0

Record: Self-interference level (dBFS) above noise floor in loopback mode. This is the baseline for what analog/digital self-interference cancellation must reduce to approach true full-duplex.


Lab Report

Create lab-3-report.md with:

  1. Noise figure estimation: measured values and comparison to AD9361 datasheet (3-4 dB typical at 900 MHz)
  2. I/Q imbalance measurement: estimated gain and phase imbalance, computed IRR
  3. Receiver chain budget table (with measured values filled in):
Stage NF (dB) Gain (dB) Notes
RF port
LNA
Mixer
VGA
ADC frontend
Cascaded
  1. Sensitivity and SFDR from the budget
  2. Self-interference level in dB above noise floor from loopback
  3. Analysis: "What cancellation depth (dB) would be required to make the ANT-SDR E200 operate as a true full-duplex radio at the sensitivity computed above?"

Grading

Component Points
Part A: Noise figure measured; compared to datasheet 5
Part B: I/Q imbalance measured; IRR computed 5
Part C: SNR budget complete; sensitivity and SFDR computed 6
Part D: Self-interference measured in loopback 2
Analysis: cancellation requirement quantified 2
Total 20