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:

\[\sigma_T = \sqrt{\frac {N_{FFT} \sigma_x^2} {K \cdot SNR}}\]

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:

\[\sigma_F = \sqrt{\frac {\sigma_X^2} {SNR}}\]

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:

\[\sigma_T = \sqrt{\frac{1}{N_{FFT}}} \; \sigma_F\]

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:

\[\sigma_X^2 = \frac{1}{N_r}\]

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
[ ]: