Beam Sweeping and Probing Along a UE Trajectory

This notebook demonstrates how NeoRadium can be used to model beam sweeping and beam probing in a DeepMIMO scenario with a UE moving along a predefined trajectory. It creates a trajectory-based channel, configures CSI-RS resources and CSI reports for sweeping and probing, and visualizes how the selected beam direction evolves as the UE moves through the environment.

The example is intended as an illustrative API demonstration rather than a fully standards-faithful implementation of practical 5G NR beam management. In a real system, beam management procedures, CSI-RS resource design, CSI reporting, triggering, timing relationships, and scheduling are subject to additional 3GPP constraints and implementation-specific details. Here, the workflow is intentionally simplified so the beam sweeping and probing APIs in NeoRadium can be demonstrated clearly and compactly.

[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
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]:
random.setSeed(123)                             # Make results reproducible
txBearingAngle = -135                           # Pointing TX panel to the center of map

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

# Create a trajectory passing through the following points
trjPoints = [[35, 90], [57, 89], [76, 86], [91, 83], [107, 78], [118, 70],
             [128, 62], [139, 49], [147, 38], [149, 22], [151, 2], [154, -14]]
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):          (35.45, 89.83, 1.50)
  No. of points:          12,782
  curIdx:                 0 (0.00%)
  curSpeed:               [14.93  0.    0.  ]
  Total distance:         191.37 meters
  Total time:             12.781 seconds
  Average Speed:          14.973 m/s
  Carrier Frequency:      3.5 GHz
  Paths (Min, Avg, Max):  5, 9.09, 10
  Totally blocked:        0
  LOS percentage:         100.00%

../../../../_images/source_Playground_Notebooks_RayTracing_BeamSweepingTraj_3_1.png
[4]:
def createCsiRS():
    # Create a CSI-RS configuration for beam sweeping and beam probing
    # Beam-sweeping:
    # - 8 beams and 8 CSI-RS objects with resource IDs 1 to 8
    # - CSI-RS resources at symbol 4 and resource elements 2 to 9
    # - One periodic NZP CSI-RS resource set (set ID=1) containing all 8 sweeping resources
    #   with period 20
    # - One periodic CSI report (ID=11) with period 20 and offset=1
    numSweep = 8
    sweepResources = []
    for i in range(numSweep):
        sweepResources += [ CsiRs(resourceId=i+1, numPorts=1, symbols=[4],
                                  freqMap="".join([str(int(x)) for x in np.eye(12)[i+2]])[::-1]) ] # REs 2 to 9
    sweepSet = CsiRsSet("NZP", bwp, resourceType="periodic", rsId=1, period=20, csiRsList=sweepResources)
    sweepReport = CsiReport(sweepSet, reportId=sweepSet.rsId+10, reportType='periodic',
                            period=20, offset=1, quantity="Cri")        # CSI report for beam sweeping

    # Beam-probing:
    # - 4 beams and 4 CSI-RS objects with resource IDs 9 to 12
    # - CSI-RS resources at symbol 4 and resource elements 4 to 7
    # - One aperiodic NZP CSI-RS resource set (set ID=2) containing all 4 probing resources
    #   triggered when sweeping CRI is received.
    # - One aperiodic CSI report (ID=12) triggered when sweeping CRI is received.
    numProbe = 4
    probeResources = []
    for i in range(numProbe):
        probeResources += [ CsiRs(resourceId=i+9, numPorts=1, symbols=[4],
                                  freqMap="".join([str(int(x)) for x in np.eye(12)[i+4]])[::-1]) ] # REs 4 to 7
    probeSet = CsiRsSet("NZP", bwp, resourceType="aperiodic", rsId=2, csiRsList=probeResources)
    probeReport = CsiReport(probeSet, reportId=probeSet.rsId+10,
                            reportType='aperiodic', quantity="Cri")     # CSI report for beam probing

    csiRsConfig = CsiRsConfig([sweepSet, probeSet])             # CSI-RS config
    csiReportMan = CsiReportMan([sweepReport, probeReport])     # CSI report manager
    return csiRsConfig, csiReportMan

def processFeedback(csiReportMan):
    # Process CSI feedback:
    sweepReport, probeReport = csiReportMan.csiReports
    criSweep, criProbe, rsrpProbe = None, None, None

    csiReportInfo = csiReportMan.getFeedback()                  # Get all available CSI reports from CsiReport objects
    for reportId, csiFeedback in csiReportInfo.items():         # Get the CSI feedback for each report
        if reportId == sweepReport.reportId:                    # Sweeping report
            criSweep = csiFeedback.cri.cri                      # CSI-RS resource ID of the best beam
            probeReport.csiRsSets[0].trigger()                  # Trigger Probing CSI-RS resource set
            probeReport.trigger()                               # Trigger Probing report

        elif reportId == probeReport.reportId:                  # Probing report
            criProbe = csiFeedback.cri.cri                      # CSI-RS resource ID of the best beam
            rsrpProbe = csiFeedback.cri.rsrp                    # RSRP of the best beam
        else:
            print(f"Unhandled report: {reportId}")

    return criSweep, criProbe, rsrpProbe
[5]:
random.setSeed(123)                 # Make results reproducible
numSlots = trajectory.numPoints     # Total number of slots = number of points on the trajectory

# Note:
# Since our goal is to show that different UE locations along a trajectory experience different
# beam RSRPs, we should not normalize the channel gain independently at each location. 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 = 9                # 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

csiRsConfig, csiReportMan = createCsiRS()
# csiRsConfig.print()                                       # Uncomment to print the CSI-RS configuration
# csiReportMan.print()                                      # Uncomment to print the CSI reporting information

# Create a simple PDSCH object for end-to-end transmission
pdsch = PDSCH(bwp, numLayers=1, modulation="16QAM", csiRsConfig=csiRsConfig)
pdsch.setDMRS(additionalPos=1)     # DMRS configuration

ldpc = pdsch.getLdpcCodec(coderates=490/1024)

minMse, maxMse = 100, 0
t0 = time.monotonic()
print(f"\nSimulating communication over {numSlots} slots along the trajectory ...")
print(" Slot    Blk Errors    BLER    Beam Ang   RSRP (dB)   Trj. Time   Exe. Time")
print("------   ----------   ------   --------   ---------   ---------   ---------")

txBlockErrors = 0
sweepWs, sweepBeams = channel.txAntenna.getSweepingBeams(numTheta=1, numPhi=8)
curSweepCri, curProbeCri, curProbeRsrp = None, None, None
probePhiLocal, probePhiGlobal = None, None
channel.restart()
blefs = np.zeros(numSlots, dtype=np.int8)                   # Block error flags for each slot
beamAngles = np.zeros((numSlots,2), dtype=np.float32)       # Beam angles for each slot (theta, phi)
beamRsrps = np.zeros(numSlots, dtype=np.float32)            # Beam RSRPs
for slotNo in range(numSlots):

    criSweep, criProbe, rsrpProbe = processFeedback(csiReportMan)      # Process CSI feedback
    if criSweep is not None:    curSweepCri = criSweep      # Update current Sweeping CRI
    if criProbe is not None:
        curProbeCri = criProbe                              # Update current Probing CRI
        curProbeRsrp = rsrpProbe                            # Update current Probing RSRP
        probePhiLocal = probeBeams[1][criProbe-9]
        _, probePhiGlobal = AntennaPanel.local2Global( probeBeams[0][criProbe-9],
                                                       probePhiLocal,
                                                       channel.txOrientation)

    pdsch.initGrid()                                        # Initialize PDSCH's internal resource grid
    numBits = pdsch.getBitCapacity()[0]                     # Capacity of the resource grid for PDSCH data
    txBlock = random.bits(ldpc.txBlockSizes[0])             # Create a random transport block

    rateMatchedCodeBlocks = ldpc.encode(txBlock, numBits)   # LDPC rate-matching and encoding
    pdsch.setPdschData(rateMatchedCodeBlocks)               # Populates the PDSCH's internal grid.

    channelMatrix = channel.getChannelMatrix()              # Get the channel matrix
    precoder = pdsch.getPrecodingMatrix(channelMatrix)      # Get the precoder matrix from PDSCH object

    txGrid = bwp.createGrid(len(channel.txAntenna))         # Create the transmitted resource grid
    pdsch.precodeTo(txGrid, precoder)                       # Perform the precoding and put data in `txGrid`

    # Process CSI-RS:
    csiRsResources = csiRsConfig.getResources()             # Get all CSI-RS resources for the current slot, if any
    for csiSetId, setResources in csiRsResources.items():
        if csiSetId == 1:                                               # Beam Sweeping:
            for resourceId, (lIdx, kIdx, sweepReValues) in setResources.items():
                b = resourceId-1                                        # Sweeping resource ID to beam index
                w = sweepWs[:,b:b+1]                                    # Sweeping vector (nt x 1)
                txGrid[:,lIdx, kIdx] = (w @ sweepReValues,              # Update txGrid with precoded CSI-RS values
                                        "CSIRS_NZP",                    # Set RE's type to NZP CSI-RS
                                        resourceId)                     # Set RE's object ID to CSI-RS resource ID

        elif csiSetId == 2:                                             # Beam probing:
            b = curSweepCri-1                                           # Beam index of current sweeping CRI
            theta0, phi0 = sweepBeams[0][b], sweepBeams[1][b]           # Best beam angles derived from Sweeping CRI
            probeWs, probeBeams = channel.txAntenna.getProbingBeams(theta0, phi0, 4, polStrategy='equal')
            for resourceId, (lIdx, kIdx, probeReValues) in setResources.items():
                b = resourceId - 9                                      # Probing resource ID to beam index
                w = probeWs[:,b:b+1]                                    # Precoding vector for this beam. nt x 1
                txGrid[:,lIdx, kIdx] = (w @ probeReValues,              # Update txGrid with precoded CSI-RS values
                                        "CSIRS_NZP",                    # Set RE's type to NZP CSI-RS
                                        resourceId)                     # Set RE's object ID to CSI-RS resource ID

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

    csiReportMan.processRxGrid(noisyRxGrid, csiRsResources)             # UE-side processing of the received CSI-RS

    effChanMat = channel.getEffChannel(channelMatrix, precoder)         # Get effective channel matrix
    eqGrid, llrScales = pdsch.equalize(noisyRxGrid, effChanMat)         # Equalize the received grid
    llrs = pdsch.getLLRs(eqGrid, llrScales)                             # Demodulate and get LLRs
    decodedTxBlocks, crcMatch = ldpc.decode(llrs)                       # LDCP rate-recovery and decoding
    txBlockErrors += 0 if crcMatch[0][0] else 1                         # crcMatch[0][0] -> CRC match for txBlock

    blefs[slotNo] = 0 if crcMatch[0][0] else 1                          # Block error flag for this slot
    beamAngles[slotNo] = np.nan if probePhiGlobal is None else probePhiGlobal # Current beam angles based on probing CRI
    beamRsrps[slotNo] = np.nan if curProbeRsrp is None else curProbeRsrp
    dt = time.monotonic()-t0                                            # Total time spent so far

    print(f"\r{slotNo+1:^6,d}   {txBlockErrors:^10,d}   {100*txBlockErrors/(slotNo+1):^6.2f}   "
          f"{"N/A" if probePhiLocal is None else str(np.round(probePhiLocal,2)):^8s}   "
          f"{"N/A" if curProbeRsrp is None else str(np.round(curProbeRsrp,2)):^9s}   "
          f"{channel.trajectory.cur.time:^9.2f}   {dt:^9.2f}", end='')
    channel.goNext()                                                    # Move to the next slot and trajectory point


Simulating communication over 12782 slots along the trajectory ...
 Slot    Blk Errors    BLER    Beam Ang   RSRP (dB)   Trj. Time   Exe. Time
------   ----------   ------   --------   ---------   ---------   ---------
12,782       0         0.00     38.21      -55.59       12.78      1560.99
[6]:
arrow, prevRsrp = None, 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, prevRsrp
    if request=="Config":
        ax[0].set_xlim(0,trajectory.numPoints)
        ax[0].set_ylim(-65,-50)
        ax[0].set_title("RSRP of the Best Beam (dB)")
        ax[0].set_xlabel("Trajectory Points")
        ax[0].grid()

    elif request=="ConfigMap":
        ax.set_title("Beam Tracking Along a UE Trajectory Using CSI Feedback")
        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":
        # Draw the RSRP plot below the map
        p0, p1 = points
        segmentRsrps = beamRsrps[p0:p1]
        meanRsrp = segmentRsrps[~np.isnan(segmentRsrps)].mean()
        if prevRsrp is None:    prevRsrp = meanRsrp
        ax[0].plot([p0,p1], [prevRsrp,  meanRsrp], 'blue', markersize=1)
        prevRsrp = meanRsrp

    elif request=="DrawOnMap":
        p0, p1 = points
        segmentAngles = beamAngles[p0:p1]
        meanPhi = segmentAngles[~np.isnan(segmentAngles)].mean()
        # 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=1, pointsPerFrame=200,
                                graphCallback=handleGraph, fileName='AnimateCRI.gif',
                                lastFrameDur=3000)  # Freeze on last frame for 3 seconds
display(Markdown("![demo](AnimateCRI.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=1, pointsPerFrame=200,
#                                    graphCallback=handleGraph)
#    HTML(anim.to_jshtml())

demo

[ ]: