NeoRadium 0.5.1
  • What is NeoRadium?

Getting Started

  • Installation
  • Migration Guide
  • Playground
    • Antenna
    • Stochastic Channel Models
    • Trajectory-based Channel Model
      • Working with DeepMIMO Scenarios
      • Applying a Trajectory-Based Channel in the Frequency and Time Domains
      • End-to-End PDSCH Simulation with a Trajectory-Based Channel Model
      • Animating a UE Trajectory in a DeepMIMO Scenario
      • Generating Random Channel Matrices from a DeepMIMO Scenario
      • Creating Temporally and Spatially Consistent Channel-Matrix Sequences
      • Animating BER Along a UE Trajectory
      • Animating Channel Condition Number Along a UE Trajectory
      • Ray-tracing BER at Various TX/RX Antenna Bearing Angles
      • Beam Sweeping in a DeepMIMO Scenario
      • Beam Sweeping and Probing Along a UE Trajectory
      • Link Adaptation with CSI Feedback, HARQ, and OLLA Along a UE Trajectory
    • Channel State Information Reference Signals (CSI-RS)
    • CSI-Feedback
    • DM-RS and PT-RS
    • Physical Downlink Shared Channel (PDSCH)
    • Channel Coding
    • Hybrid Automatic Repeat reQuest (HARQ)
    • A Deep Learning Case Study
    • Research Papers
    • Other Examples
    • Comparing with MATLAB

API

  • Carriers and Bandwidth Parts
  • Resource Grid
  • Waveform
  • Modulation
  • Reference Signals
  • CSI Reporting
  • Physical Channels
  • Channel Coding
  • HARQ
  • Antenna
  • Channel Models
  • Random Number Generator
  • DeepMIMO and UE Trajectories
  • SnrScheduler
  • Other Utilitiy Functions
NeoRadium 0.5.1
  • Playground
  • Link Adaptation with CSI Feedback, HARQ, and OLLA Along a UE Trajectory
  • View page source

Link Adaptation with CSI Feedback, HARQ, and OLLA Along a UE Trajectory

This notebook demonstrates several NeoRadium features in a compact end-to-end link-adaptation example. It uses a DeepMIMO scenario and shows how channel conditions along a UE trajectory affect beam selection, CQI, BLER, and throughput.

The workflow starts by configuring the bandwidth part, antenna panels, trajectory-based channel, and CSI-RS resources. The receiver processes CSI-RS measurements and feeds back beam-sweeping, beam-probing, and RI/PMI/CQI reports. The transmitter then uses this feedback to select the precoder, number of layers, modulation, and code rate for PDSCH transmission.

The example also includes incremental-redundancy HARQ and OLLA. HARQ handles retransmissions after failed transport blocks, while OLLA adjusts the reported CQI through a link-adaptation offset based on decoding outcomes. Together, these mechanisms illustrate how feedback and error statistics can be used to adapt the downlink transmission as the UE moves.

For clarity, the simulation is intentionally simplified. It uses a fixed thermal-noise model and disables per-location channel-gain normalization so that large-scale channel variations along the trajectory remain visible. The final animation summarizes the UE movement and plots the adjusted CQI, BLER, and throughput over the trajectory. This notebook is intended as an API-focused demonstration rather than a standards-complete model of 5G NR scheduling, beam management, or CSI reporting.

[1]:
import numpy as np
import time
import matplotlib
from IPython.display import HTML, Markdown, display

from neoradium import DeepMimoData, TrjChannel, BandwidthPart, AntennaPanel, PDSCH, random
from neoradium import CsiRs, CsiRsSet, CsiRsConfig, CsiReport, CsiReportMan, OLLA
from neoradium.utils import toDb, toLinear
[2]:
# Replace this with the folder on your computer where you store DeepMIMO scenarios
dataFolder = "/data/RayTracing/DeepMIMO/Scenarios/V4/"
DeepMimoData.setScenariosPath(dataFolder)

# Create a DeepMimoData object
dmData = DeepMimoData("asu_campus_3p5")
dmData.print()

DeepMimoData Properties:
  Scenario:                   asu_campus_3p5
  Version:                    4.0.0a3
  UE Grid:                    rx_grid
  Grid Size:                  411 x 321
  Base Station:               BS (at [166. 104.  22.])
  Total Grid Points:          131,931
  UE Spacing:                 [1. 1.]
  UE bounds (xyMin, xyMax)    [-225.55 -160.17], [184.45 159.83]
  UE Height:                  1.50
  Carrier Frequency:          3.5 GHz
  Num. paths (Min, Avg, Max): 0, 6.21, 10
  Num. total blockage:        46,774
  LOS percentage:             19.71%

[3]:
txBearingAngle = -135                           # Point the TX panel toward the center of the map

bwp = BandwidthPart(numRbs=12, spacing=15)      # Create a bandwidth part

trjPoints = np.array([[44, 77], [32, 52], [31, 32], [38, 20], [49, 18],
                      [62, 17], [85, 16], [103, 16], [128, 17], [144, 32]])
# trjPoints = np.array([[44, 77], [32, 52], [31, 32], [38, 20], [49, 18]])

trjPoints = np.array([[-50, -83], [-47, -51], [-54, -29], [-70, -16], [-88, -14],
                      [-104, -18], [-114, -29], [-116, -42], [-114, -63], [-115, -81]])
trajectory = dmData.trajectoryFromPoints(trjPoints, bwp, speedMps=15)

trajectory.print()                              # Print the trajectory information
ax = dmData.drawMap("LOS-NLOS", trajectory)     # Draw the map with the trajectory
dmData.drawBsPanel(ax, txBearingAngle)          # Draw the base station antenna panel

Trajectory Properties:
  start (x,y,z):          (-49.55, -83.17, 1.50)
  No. of points:          12,378
  curIdx:                 0 (0.00%)
  curSpeed:               [ 0.   14.93  0.  ]
  Total distance:         185.23 meters
  Total time:             12.377 seconds
  Average Speed:          14.965 m/s
  Carrier Frequency:      3.5 GHz
  Paths (Min, Avg, Max):  4, 9.04, 10
  Totally blocked:        0
  LOS percentage:         0.00%

../../../../_images/source_Playground_Notebooks_RayTracing_LinkAdaptation_3_1.png
[4]:
random.setSeed(123)                 # Make results reproducible
numSlots = trajectory.numPoints     # Total number of slots = number of points on the trajectory
prgSize = 0                         # Set to 0 for wideband, 2 or 4 for subband precoding

# Note:
# Since our goal is to show that different UE locations along a DeepMIMO trajectory experience different
# BLER/throughput, we should not normalize the channel gain independently at each location. Per-location
# gain normalization would remove exactly the large-scale variation we want to observe. So, we set
# the 'normalizeGains' to 'False' in our channel model below and use a fixed noise power.

# Calculating the noise power:
k = 1.380649e-23        # Boltzmann constant (joules per kelvin)
tempK = 290.0           # Temperature in kelvin
nf = 8                  # Receiver noise figure (dB)
noiseVarFreq = k*tempK*bwp.spacing*1000*toLinear(nf)

# Creating a trajectory-based channel model:
channel = TrjChannel(bwp, trajectory,
                     normalizeGains = False,
                     txAntenna = AntennaPanel([1,4], polarization='x'),     # 8 TX antennas
                     txOrientation = [txBearingAngle,0,0],                  # BS antenna orientation
                     rxAntenna = AntennaPanel([1,1], polarization='x',      # 2 RX antennas
                                              beamWidth=[65,360]))          # Omnidirectional

# Create a typical CSI-RS configuration
csiRsConfig = CsiRsConfig.beamformingConfig(bwp, channel.txAntenna.numPorts, sweepsPerSlot=4)
# csiRsConfig.print()                   # Uncomment to print CSI-RS configuration details

# Get the CSI resource sets for beam sweeping, beam probing, and RI/PMI/CQI feedback
sweepSet, probeSet, pmiSet = csiRsConfig.csiRsSetList

# Create CSI reports and a CsiReportMan object for the CSI-RS configuration
csiReportMan = CsiReportMan.beamformingReports(csiRsConfig, channel.txAntenna,
                                               prgSize=prgSize, allowedRanks=[1,2], cqiTable=2)
# csiReportMan.print()                  # Uncomment to print CSI report configuration details

# Get the CSI report objects for beam sweeping, beam probing, and RI/PMI/CQI feedback
sweepRep, probeRep, pmiRep = csiReportMan.csiReports

# Initialize beam sweeping/probing parameters
numPhi = len(sweepSet)                  # Number of horizontal beams
numTheta = 1                            # Number of vertical beams (restrict sweeping to azimuth)
sweepWs, sweepBeams = channel.txAntenna.getSweepingBeams(numTheta, numPhi)
probeWs, probeBeams = None, None                # Probing weight vectors and beam angles
sweepCri, probeCri = None, None                 # Most recent CRI for sweeping and probing
probePhiLocal, probePhiGlobal = None, None      # Best probing beam azimuth angle
rawCqi = None                                   # Most recent CQI feedback (before OLLA adjustment)

pdsch = None                            # PDSCH is created when a CQI report is available
harq = None                             # HARQ is created only once when the first CQI is received
olla = None                             # OLLA is created only once when the first CQI is received
precoder = None                         # The precoder based on the most recently received PMI
pmiW = None                             # The steering vector used by the most recent RI/PMI/CQI CSI-RS resources
pdschW = None                           # The steering vector used by PDSCH (consistent with precoder from PMI)

# Historical values:
beamAngles = []                         # Azimuth beam angles (probePhiGlobal) for each slot
cqiRep = []                             # Reported CQI values
cqiAdj = []                             # Adjusted CQI values
ris = []                                # RI (number of layers)
blockErrorFlags = []                    # 1 for each transport block error and 0 otherwise
rxBits = []                             # Number of received bits for each transmission
minMse, maxMse = 100, 0

t0 = time.monotonic()
print(f"\nSimulating end-to-end communication for {numSlots} slots on the trajectory ...")
print(" Slot   TX Blk  RX Blk   BLER   Beam Ang  OLLA Ofst  CQI(Rep)  RI    Mod.   Coderate  Trj. Time  Exe. Time")
print("------  ------  ------  ------  --------  ---------  --------  ---  ------  --------  ---------  ---------")
for slotNo in range(numSlots):
    # Process CSI feedback
    csiReportInfo = csiReportMan.getFeedback()              # Get all available CSI reports from CsiReport objects
    for reportId, csiFeedback in csiReportInfo.items():     # Process the CSI feedback for each report
        if reportId == sweepRep.reportId:                   # Beam-sweeping report
            sweepCri = csiFeedback.cri.cri                  # CSI-RS resource ID of the best beam
            probeSet.trigger()                              # Trigger the probing CSI-RS resource set
            probeRep.trigger()                              # Trigger the probing report

        elif reportId == probeRep.reportId:                 # Beam-probing report
            probeCri = csiFeedback.cri.cri                  # CSI-RS resource ID of the best beam
            beamIdx = probeCri - len(sweepSet) - 1          # Index of the best beam
            pmiSet.active=True                              # Activate RI/PMI/CQI CSI-RS resources
            pmiRep.active=True                              # Activate RI/PMI/CQI measurements

            # Update the best probing beam azimuth angle in local and global coordinates
            probePhiLocal = probeBeams[1][beamIdx]
            _, probePhiGlobal = AntennaPanel.local2Global( probeBeams[0][beamIdx],
                                                           probePhiLocal,
                                                           channel.txOrientation)

        elif reportId == pmiRep.reportId:                   # RI/PMI/CQI report
            precoder = csiFeedback.pmi.wbW if csiFeedback.pmi.sbWs is None else csiFeedback.pmi.sbWs
            pdschW = pmiW
            numLayers = csiFeedback.ri.ri                   # Set number of layers based on RI feedback
            if "cqi" in pmiRep.quantity.lower():    rawCqi = csiFeedback.cqi.cqi
        else:
            assert False, f"Unknown report: {reportId}"

    # Create a transmitted resource grid
    txGrid = bwp.createGrid(channel.txAntenna.numEl)

    # If we have a CQI report, transmit PDSCH data
    if rawCqi is not None:
        cqi = rawCqi if olla is None else olla.adjustCqi(rawCqi)    # Adjust the raw CQI received in the report
        modulation, coderateX1024 = pmiRep.getModRate(cqi)          # Get modulation and code rate based on CQI
        coderate = coderateX1024/1024
        pdsch = PDSCH(bwp, numLayers=numLayers, csiRsConfig=csiRsConfig, modulation=modulation, prgSize=prgSize)
        pdsch.setDMRS(additionalPos=2)                              # DMRS configuration
        if harq is None:
            # Create HARQ and OLLA objects the first time
            harq = pdsch.getHarq(coderates = coderate,              # Code rate used by the LDPC codec
                                 harqType="IR",                     # Use Incremental Redundancy
                                 numProc=16)                        # Number of HARQ processes
            olla = OLLA(harq, pmiRep.cqiTable, fixedOffset=-2)      # Create the link adaptation object
        else:
            # We already have a HARQ and OLLA. Update HARQ's LDPC codec if needed.
            ldpcModulation = harq.ldpcCodec.modulations[0]
            ldpcCoderate =  harq.ldpcCodec.coderates[0]
            ldpcNumLayers = harq.ldpcCodec.cwCodecs[0].numLayers
            if (numLayers != ldpcNumLayers) or (modulation != ldpcModulation) or (coderate != ldpcCoderate):
                harq.setLdpc( pdsch.getLdpcCodec(coderates=coderate) )  # Update HARQ's LDPC codec

        pdsch.initGrid()                                        # Create and initialize PDSCH's internal grid
        numBits = pdsch.getBitCapacity()[0]                     # Number of bits available in the resource grid
        tbs = harq.ldpcCodec.txBlockSizes[0]                    # Transport block size (for the first codeword)
        if harq.needNewData[0]: txBlock = random.bits(tbs)      # Create random bits for new transmissions
        else:                   txBlock = None                  # Set to None indicating a retransmission

        rateMatchedCBs = harq.encode(txBlock, numBits)          # LDPC encode the transport block
        pdsch.setPdschData(rateMatchedCBs)                      # Map/modulate the data to the resource grid
        pdsch.precodeTo(txGrid, precoder, pdschW)               # Precode PDSCH data into the txGrid

    # Process and add CSI-RS to the txGrid
    csiRsResources = csiRsConfig.getResources()
    for csiSetId, setResources in csiRsResources.items():
        if csiSetId == sweepSet.rsId:                           # CSI-RS for beam sweeping
            for resourceId, (lIdx, kIdx, sweepReValues) in setResources.items():
                b = resourceId-1                                # Beam index
                w = sweepWs[:,b:b+1]                            # nt x 1
                # sweepReValues is a 1 x numCsiRsRE matrix. nt x 1 * 1 x numCsiRsRE = nt x numCsiRsRE
                txGrid[:,lIdx, kIdx] = (w * sweepReValues, "CSIRS_NZP", resourceId)

        elif csiSetId == probeSet.rsId:                         # CSI-RS for beam probing
            b = sweepCri-1                                      # Index of best sweeping beam
            theta0, phi0 = sweepBeams[0][b], sweepBeams[1][b]   # Best sweeping beam angles
            probeWs, probeBeams = channel.txAntenna.getProbingBeams(theta0, phi0, len(probeSet), polStrategy='equal')
            for resourceId, (lIdx, kIdx, probeReValues) in setResources.items():
                b = resourceId - len(sweepSet) - 1              # Beam index
                w = probeWs[:,b:b+1]                            # nt x 1
                # probeReValues is a 1 x numCsiRsRE matrix. nt x 1 * 1 x numCsiRsRE = nt x numCsiRsRE
                txGrid[:,lIdx, kIdx] = (w * probeReValues, "CSIRS_NZP", resourceId)

        elif csiSetId == pmiSet.rsId:                           # CSI-RS for RI/PMI/CQI
            # Use the beamforming vector corresponding to the best probed beam. This will also be
            # saved to pdschW when the report for this CSI-RS is received. pdschW is then combined with
            # the PMI procoder to precode PDSCH.
            wIdx = probeCri - len(sweepSet) - 1             # Index of the best probing beam from CRI
            pmiW = probeWs[:,wIdx:wIdx+1].copy()            # shape: nt x 1
            for resourceId, (lIdx, kIdx, pmiReValues) in setResources.items():
                txGrid[:,lIdx, kIdx] = (pmiW * pmiReValues, "CSIRS_NZP", resourceId)

    channelMatrix = channel.getChannelMatrix()                  # Get channel matrix from the channel model
    rxGrid = txGrid.applyChannel(channelMatrix)                 # Apply the channel in the frequency domain
    noisyRxGrid = rxGrid.addNoise(noiseVar=noiseVarFreq)        # Add noise

    # Receiver side:
    if pdsch is not None:
        effChannelMatrix = channel.getEffChannel(channelMatrix, precoder, pdschW)   # Perfect channel knowledge
        eqGrid, llrScales = pdsch.equalize(noisyRxGrid, effChannelMatrix)   # Equalize received PDSCH data
        llrs = pdsch.getLLRs(eqGrid, llrScales)                             # Demodulate and get LLRs
        decodedTxBlocks, crcMatches = harq.decode(llrs)                     # Use HARQ to decode the LLRs

        beamAngles += [ probePhiGlobal ]                                    # Record the best probe beam azimuth
        cqiRep += [ rawCqi ]                                                # Record the reported CQI
        cqiAdj += [ cqi ]                                                   # Record the adjusted CQI
        ris += [ numLayers ]                                                # Record number of layers
        rxBits += [ tbs if crcMatches[0][0] else 0 ]                        # Record successfully received bits
        blockErrorFlags += [ 0 if crcMatches[0][0] else 1 ]                 # Record the block-error flag
    else:   # No PDSCH yet -> use zeros
        beamAngles += [ 0 ]
        cqiRep += [ 0 ]
        cqiAdj += [ 0 ]
        ris += [0]
        rxBits += [ 0 ]
        blockErrorFlags += [ 0 ]

    csiReportMan.processRxGrid(noisyRxGrid, csiRsResources) # Process the CSI-RS resources in the 'noisyRxGrid'
    dt = time.monotonic()-t0                                # Total time spent so far

    if pdsch is None:
        print(f"\r{slotNo+1:^6d}  {0:^6d}  {0:^6d}  {'N/A':^6s}  "
              f"{'N/A':^8s}  {"N/A":^9s}  {'N/A':^8s}  {'N/A'}  {'N/A':^6s}  {'N/A':^8s}  "
              f"{channel.trajectory.cur.time:^9.2f}  {dt:^9.2f}", end='')
    else:
        # Note: Using bler1st, which is the first-transmission BLER
        print(f"\r{slotNo+1:^6,d}  {harq.totalTxBlocks:^6,d}  {harq.totalRxBlocks:^6,d}  {harq.bler1st:^6.3f}  "
              f"{probePhiLocal:^8.1f}  {olla.cqiOffset[0]:^9.2f}  {f'{cqi}({rawCqi})':^8s}  {numLayers:^3d}  "
              f"{modulation:^6s}  {f'{coderateX1024}/1024':^8s}  {channel.trajectory.cur.time:^9.2f}  {dt:^9.2f}",
              end='')

    channel.goNext()                                                # Move to the next slot and trajectory point
    if harq is not None: harq.goNext()                              # Go to the next HARQ process


Simulating end-to-end communication for 12378 slots on the trajectory ...
 Slot   TX Blk  RX Blk   BLER   Beam Ang  OLLA Ofst  CQI(Rep)  RI    Mod.   Coderate  Trj. Time  Exe. Time
------  ------  ------  ------  --------  ---------  --------  ---  ------  --------  ---------  ---------
12,378  12,364  11,187  9.717    -15.5      1.63       6(6)     1   16QAM   616/1024    12.38     3190.75
[5]:
prevCqiRep = None
prevCqiAdj = None
prevRi = None
prevBler = None
prevBps = None
arrow = None

# Since we are averaging the angles, we need to make sure they are continuous:
# Local angle range: -90 to 90
# Global angle range: 135 .. -45
# Adjusted global angle range: 135 .. 315  (Add 360 to the negative values)
beamAngles = np.array(beamAngles)
beamAngles[beamAngles<0] += 360     # Range: 135 .. 315

# Callback used to initialize and update the scenario map and the graphs below it
def handleGraph(request, ax, trajectory, points=None):
    global arrow, prevCqiRep, prevCqiAdj, prevRi, prevBler, prevBps
    if request=="Config":
        # CQI
        ax[0].set_xlim(0,trajectory.numPoints)
        ax[0].set_ylim(1,16)
        ax[0].set_title("CQI")
        ax[0].set_xlabel("Trajectory points")
        ax[0].grid()

        # RI
        ax[1].set_xlim(0,trajectory.numPoints)
        ax[1].set_ylim(0.8,2.2)
        ax[1].set_title("RI (Number of layers)")
        ax[1].set_xlabel("Trajectory points")
        ax[1].grid()

        # Use the following to show BLER in the 3rd graph
        # ax[2].set_xlim(0,trajectory.numPoints)
        # ax[2].set_ylim(0,22)
        # ax[2].set_title("BLER (%)")
        # ax[2].set_xlabel("Trajectory points")
        # ax[2].grid()

        # Use the following to show throughput in the 3rd graph
        ax[2].set_xlim(0,trajectory.numPoints)
        ax[2].set_ylim(0.1e7,2.5e7)
        ax[2].set_title("Throughput (bps)")
        ax[2].set_xlabel("Trajectory points")
        ax[2].grid()

    elif request=="ConfigMap":
        ax.set_title("CSI Feedback and OLLA Link Adaptation along a UE Trajectory")
        dmData.drawBsPanel(ax, txBearingAngle)   # Draw TX antenna panel

        # Create the arrow patch
        arrow = dmData.drawBeamArrow(ax, txBearingAngle, color="cyan", length=50)
        arrow.set_animated(True)

    elif request=="Draw":
        p0, p1 = points
        # cqi = np.round(np.mean(cqiRep[p0: p1]))
        cqi = np.mean(cqiRep[p0: p1])
        if prevCqiRep is None:  prevCqiRep = cqi
        ax[0].plot([p0,p1], [prevCqiRep,  cqi], 'red', markersize=1, label="Reported")
        prevCqiRep = cqi

        # cqi = np.round(np.mean(cqiAdj[p0: p1]))
        cqi = np.mean(cqiAdj[p0: p1])
        if prevCqiAdj is None:  prevCqiAdj = cqi
        ax[0].plot([p0,p1], [prevCqiAdj,  cqi], 'orange', markersize=1, label="Adjusted")
        if p0==0: ax[0].legend(fontsize=8)         # First time: add the legend
        prevCqiAdj = cqi

        ri = np.mean(ris[p0:p1])
        if prevRi is None:      prevRi = ri
        ax[1].plot([p0,p1], [prevRi,  ri], 'blue', markersize=1)
        prevRi = ri

        # Use the following to show BLER in the 3rd graph
        # bler = np.mean(blockErrorFlags[p0: p1])*100
        # if prevBler is None:    prevBler = bler
        # ax[2].plot([p0,p1], [prevBler,  bler], 'green', markersize=1)
        # prevBler = bler

        # Use the following to show throughput in the 3rd graph
        bps = np.sum(rxBits[p0:p1])/((p1-p0)/bwp.avgSlotDuration)
        if prevBps is None:     prevBps = bps
        ax[2].plot([p0,p1], [prevBps,  bps], 'green', markersize=1)
        prevBps = bps

    elif request=="DrawOnMap":
        p0, p1 = points
        if p0>0:
            meanPhi = np.mean(beamAngles[p0:p1])
            # Now that we have computed the mean angle, we can remove the 360 to get
            # the angle back in the correct range
            if meanPhi>180: meanPhi -= 360
            # Update the arrow direction
            dmData.drawBeamArrow(ax, meanPhi, color="cyan", length=50, arrow=arrow)
            return (arrow,)

# Create the animation and display it below
anim = dmData.animateTrajectory(trajectory, numGraphs=3, pointsPerFrame=200,
                                graphCallback=handleGraph, fileName='AnimateLA.gif',
                                lastFrameDur=3000)  # Freeze on last frame for 3 seconds
display(Markdown("![demo](AnimateLA.gif)"))

# Alternatively, the following code provides better control over running
# the animation. Note that for this method to work, you should not pass a
# 'fileName' to the 'animateTrajectory' function.
#    # Increase the animation memory limit for HTML-based animation display
#    matplotlib.rcParams['animation.embed_limit'] = 100000000
#    anim = dmData.animateTrajectory(trajectory, numGraphs=3, pointsPerFrame=100,
#                                    graphCallback=handleGraph)
#    HTML(anim.to_jshtml())

demo

[ ]:

Previous Next

© Copyright 2025, InterDigital, Inc. All Rights Reserved.

Built with Sphinx using a theme provided by Read the Docs.