SNR Calculations in NeoRadium
This notebook demonstrates how NeoRadium calculates noise power from a given SNR value in both time and frequency domains.
Two different SNR interpretations are supported throughout NeoRadium:
Reference-power SNR (default): Noise is computed using a fixed reference signal power, independent of the instantaneous channel realization. This is aligned with common 3GPP-style link-level simulation practice.
Received-power-based SNR: Noise is computed from the actual received signal power, enforcing a fixed post-channel SNR.
The goal of this notebook is to clearly illustrate:
How each method computes noise
Why they produce different results in the presence of a channel
When each method is appropriate
[1]:
import numpy as np
import scipy.io
import time
from neoradium import Carrier, PDSCH, CdlChannel, AntennaPanel, Grid, random, Waveform
from neoradium.utils import toDb, toLinear, getNmse
First, consider a simple case without a channel model.
In this case, the transmitted signal power is controlled and predictable, so both SNR interpretations lead to consistent and comparable results. This serves as a baseline before introducing channel effects.
[2]:
# NOTE: You can find a similar Matlab example at:
# https://www.mathworks.com/help/5g/ug/snr-definition-used-in-link-simulations.html
snrDb = 0
snr = toLinear(snrDb) # SNR in linear scale
carrier = Carrier(numRbs=52, spacing=30)
bwp = carrier.curBwp
nr, nt = 2, 2 # receiver and transmitter antennas
print(bwp)
pdsch = PDSCH(bwp, numLayers=1, modulation='16QAM', nID=carrier.cellId)
pdsch.setDMRS(prgSize=0, configType=2, additionalPos=2)
ldpc = pdsch.getLdpcCodec(coderates=490/1024)
random.setSeed(123) # Make the results reproducible.
pdsch.initGrid() # Create and initialize PDSCH's internal grid
txBlock = random.bits(ldpc.txBlockSizes[0]) # Create random binary data
numBits = pdsch.getBitCapacity() # Actual number of bits available in the resource grid
# Now perform the segmentation, rate-matching, and encoding in one call:
rateMatchedCodeBlocks = ldpc.encode(txBlock, numBits[0])
# Now populate the resource grid with coded data. This includes QAM modulation and resource mapping.
pdsch.setPdschData(rateMatchedCodeBlocks)
# Get the precoding matrix, and precode the resource grid
precoder = np.ones((nt, pdsch.numLayers))/np.sqrt(pdsch.numLayers) # Get the precoder matrix
txGrid = bwp.createGrid(nt) # Create the transmitted resource grid
pdsch.precodeTo(txGrid, precoder) # Perform precoding
print(f"Shape of Resource Grid: {pdsch.grid.shape}")
print(f"Shape of Precoded Resource Grid: {txGrid.shape}")
txWaveform = txGrid.ofdmModulate()
print(f"Shape of txWaveform: {txWaveform.shape}")
rxWaveform = Waveform(txWaveform.waveform/np.sqrt(nr))
print(f"Shape of rxWaveform: {rxWaveform.shape}")
rxGrid = rxWaveform.ofdmDemodulate(bwp)
print(f"Shape of rxGrid: {rxGrid.shape}")
Bandwidth Part Properties:
Resource Blocks: 52 RBs starting at 0 (624 subcarriers)
Subcarrier Spacing: 30 kHz
CP Type: normal
Interleaving: No
Bandwidth: 18.72 MHz
symbolsPerSlot: 14
slotsPerSubFrame: 2
nFFT: 1024
frameNo: 0
slotNo: 0
Shape of Resource Grid: (1, 14, 624)
Shape of Precoded Resource Grid: (2, 14, 624)
Shape of txWaveform: (2, 15360)
Shape of rxWaveform: (2, 15360)
Shape of rxGrid: (2, 14, 624)
The standard deviation of the noise in the time domain is:
where:
\(\sigma_x^2\) is the variance of the time-domain signal (the Waveform object)
\(K\) is the number of active subcarriers
\(N_{FFT}\) is the FFT size
This formulation is consistent with mapping between time-domain noise and frequency-domain SNR defined per resource element (RE).
[3]:
noiseStdTime = rxWaveform.getNoiseStd(snr, bwp)
print(f"Noise STD (Time): {noiseStdTime}")
Noise STD (Time): 0.0220441589451537
The standard deviation of the noise in the frequency domain is:
where \(\sigma_X^2\) is the variance of the frequency-domain signal (the Grid object).
This represents SNR defined per RE and per receive antenna, which is the standard reference used in link-level simulations.
[4]:
noiseStdFreq = rxGrid.getNoiseStd(snr)
print(f"Noise STD (Freq): {noiseStdFreq}")
Noise STD (Freq): 0.705375297931587
Relationship between time-domain and frequency-domain noise:
This follows directly from Parseval’s theorem and ensures consistency between time-domain and frequency-domain noise modeling.
[5]:
print(f"Noise STD (Freq)/sqrt({bwp.nFFT}): {noiseStdFreq/np.sqrt(bwp.nFFT)} = Noise STD (Time)")
Noise STD (Freq)/sqrt(1024): 0.022042978060362095 = Noise STD (Time)
[6]:
# Apply noise in time domain and measure noise in frequency domain:
noisyRxWaveform = rxWaveform.addNoise(noiseStd=noiseStdTime)
noisyRxGrid = noisyRxWaveform.ofdmDemodulate(bwp)
noiseGrid = noisyRxGrid.grid - rxGrid.grid
print(f"Noise Grid STD: {noiseGrid.std()}")
print(f"Noise STD (Freq): {noiseStdFreq}")
Noise Grid STD: 0.7020339000063331
Noise STD (Freq): 0.705375297931587
Reference-power SNR (3GPP-style)
In this mode, the signal power is not taken from the received signal, but instead a fixed reference power is assumed:
This leads to:
Frequency domain:
\[\sigma_F = \frac{1}{\sqrt{N_r \cdot SNR}}\]Time domain:
\[\sigma_T = \frac{1}{\sqrt{N_r \cdot N_{FFT} \cdot SNR}}\]
Key idea
Noise power is independent of the instantaneous channel realization
Channel effects (fading, path loss, beamforming) change the received signal power
The resulting SNR naturally varies with the channel
This is the convention commonly used in:
3GPP-style link-level simulations
Standard BLER vs SNR evaluations
MATLAB 5G Toolbox (as one example)
This mode corresponds to:
useRxPower = False
[7]:
noiseStdTime1 = 1/np.sqrt(nr*bwp.nFFT*snr)
noiseStdFreq1 = 1/np.sqrt(nr*snr)
print(f"Noise STD (Time Domain - RxPower=1/Nr): {noiseStdTime1}")
print(f"Noise STD (Freq Domain - RxPower=1/Nr): {noiseStdFreq1}")
Noise STD (Time Domain - RxPower=1/Nr): 0.022097086912079608
Noise STD (Freq Domain - RxPower=1/Nr): 0.7071067811865475
Effect of Channel Models
Now we repeat the same experiment with a CDL channel model.
This is where the two SNR interpretations start to behave differently.
What changes with a channel?
The channel introduces:
Fading (small-scale variations)
Path loss (large-scale attenuation)
Spatial effects (MIMO, beamforming)
These effects change the received signal power from slot to slot.
Two behaviors emerge
Reference-power SNR (useRxPower=False):
Noise remains fixed
Received power varies → SNR varies
Channel effects are fully visible
Received-power-based SNR (useRxPower=True):
Noise scales with received power
Signal and noise change together
SNR is kept approximately constant
This explains why the two approaches produce different results when a channel model is present.
[8]:
# Now repeat the same process using a CDL channel model with two layers:
snrDb = 0
snr = toLinear(snrDb)
carrier = Carrier(numRbs=52, spacing=30)
bwp = carrier.curBwp
pdsch = PDSCH(bwp, numLayers=2, modulation='16QAM', nID=carrier.cellId)
pdsch.setDMRS(prgSize=0, configType=2, additionalPos=2)
ldpc = pdsch.getLdpcCodec(coderates=490/1024)
random.setSeed(123) # Make the results reproducible.
pdsch.initGrid() # Create and initialize PDSCH's internal grid
txBlock = random.bits(ldpc.txBlockSizes[0]) # Create random binary data
numBits = pdsch.getBitCapacity() # Actual number of bits available in the resource grid
rateMatchedCodeBlocks = ldpc.encode(txBlock, numBits[0]) # LDPC encoding and rate-matching
pdsch.setPdschData(rateMatchedCodeBlocks) # Map encoded data to the resource elements
# Create a CdlChannel object:
channel = CdlChannel(bwp, 'C', delaySpread=300, carrierFreq=4e9, dopplerShift=5,
txAntenna = AntennaPanel([1,4], polarization="|"), # 4 TX antennas
rxAntenna = AntennaPanel([1,2], polarization="|"), # 2 RX antennas
normalizeGains = True, normalizeOutput=True)
nr,nt = channel.nrNt
# Get the precoding matrix, and precode the resource grid
channelMatrix = channel.getChannelMatrix() # Get the channel matrix
precoder = pdsch.getPrecodingMatrix(channelMatrix) # Get the precoder matrix from the PDSCH object
txGrid = bwp.createGrid(nt) # Create the transmitted resource grid
pdsch.precodeTo(txGrid, precoder) # Perform precoding
print(f"Shape of Resource Grid: {pdsch.grid.shape}")
print(f"Shape of Precoded Resource Grid: {txGrid.shape}")
txWaveform = txGrid.ofdmModulate()
print(f"Shape of txWaveform: {txWaveform.shape}")
maxDelay = channel.getMaxDelay()
txWaveform = txWaveform.pad(maxDelay)
print(f"Shape of txWaveform (padded): {txWaveform.shape}")
rxWaveform = channel.applyToSignal(txWaveform)
print(f"Shape of rxWaveform: {rxWaveform.shape}")
offset = channel.getTimingOffset()
syncedWaveform = rxWaveform.sync(offset)
print(f"Timing Offset: {offset}")
rxGrid = syncedWaveform.ofdmDemodulate(bwp)
print(f"Shape of rxGrid: {rxGrid.shape}")
rxGridF = channel.applyToGrid(txGrid)
print(f"Shape of rxGridF: {rxGridF.shape}")
print(f"NMSE(rxGrid,rxGridF): {getNmse(rxGrid.grid, rxGridF.grid)}")
Shape of Resource Grid: (2, 14, 624)
Shape of Precoded Resource Grid: (4, 14, 624)
Shape of txWaveform: (4, 15360)
Shape of txWaveform (padded): (4, 15447)
Shape of rxWaveform: (2, 15447)
Timing Offset: 13
Shape of rxGrid: (2, 14, 624)
Shape of rxGridF: (2, 14, 624)
NMSE(rxGrid,rxGridF): 6.85103515277688e-07
[9]:
noiseStdTime = syncedWaveform.getNoiseStd(snr, bwp)
print(f"Noise STD (Time): {noiseStdTime}")
Noise STD (Time): 0.06340067458970321
[10]:
noiseStdFreq = rxGrid.getNoiseStd(snr)
print(f"Noise STD (Freq): {noiseStdFreq}")
Noise STD (Freq): 2.028813976984786
[11]:
print(f"Noise STD (Freq)/sqrt({bwp.nFFT}): {noiseStdFreq/np.sqrt(bwp.nFFT)} = Noise STD (Time)")
Noise STD (Freq)/sqrt(1024): 0.06340043678077456 = Noise STD (Time)
[12]:
# These values do not match the calculated STD values above.
noiseStdTime1 = 1/np.sqrt(nr*bwp.nFFT*snr)
noiseStdFreq1 = 1/np.sqrt(nr*snr)
print(f"Noise STD (Time Domain - RxPower=1/Nr): {noiseStdTime1}")
print(f"Noise STD (Freq Domain - RxPower=1/Nr): {noiseStdFreq1}")
Noise STD (Time Domain - RxPower=1/Nr): 0.022097086912079608
Noise STD (Freq Domain - RxPower=1/Nr): 0.7071067811865475
[13]:
# Apply noise in the time domain and measure the resulting noise in the frequency domain:
noisyRxWaveform = rxWaveform.addNoise(noiseStd=noiseStdTime)
noisySyncedWaveform = noisyRxWaveform.sync(offset)
noisyRxGrid = noisySyncedWaveform.ofdmDemodulate(bwp)
noiseGrid = noisyRxGrid.grid - rxGrid.grid
print(f"Noise Grid STD: {noiseGrid.std()}")
print(f"Noise STD (Freq): {noiseStdFreq}")
Noise Grid STD: 2.0167616283809484
Noise STD (Freq): 2.028813976984786
[ ]: