Tutorial on Radio Maps
In this notebook, you will learn how to
Compute and configure radio maps
Visualize different radio map metrics, such as path gain, received signal strength (RSS), and signal-to-interference-plus-noise ratio (SINR)
Interpret radio map-based user-to-transmitter association
Understand the effects of precoding vectors on radio maps
Sample user positions from a radio map according to various criteria
Generate channel impulse responses for sampled user positions
Imports
[1]:
import numpy as np
import drjit as dr
import mitsuba as mi
import matplotlib as mpl
import matplotlib.pyplot as plt
from matplotlib import colormaps
# Import or install Sionna
try:
import sionna.rt
except ImportError as e:
import os
os.system("pip install sionna-rt")
import sionna.rt
from sionna.rt import LambertianPattern, DirectivePattern, BackscatteringPattern,\
load_scene, Camera, Transmitter, Receiver, PlanarArray,\
PathSolver, RadioMapSolver, cpx_abs, cpx_convert
no_preview = True # Toggle to False to use the preview widget
# instead of rendering for scene visualization
from sionna.rt import load_scene, PlanarArray, Transmitter, Receiver, Camera, watt_to_dbm,\
RadioMapSolver, PathSolver
Understanding radio maps
A radio map assigns a metric, such as path gain, received signal strength (RSS), or signal-to-interference-plus-noise ratio (SINR), for a specific transmitter to every point on a plane. In other words, for a given transmitter, it associates every point on a surface with the channel gain, RSS, or SINR, that a receiver with a specific orientation would observe at this point.
A radio map depends on the transmit and receive arrays and their respective antenna patterns, the transmitter and receiver orientations, as well as the transmit precoding and receive combining vectors. Moreover, a radio map is not continuous but discrete, as the plane must be quantized into small rectangular bins, which we refer to as cells.
As a first example, we load an empty scene, place a single transmitter in it, and compute a coverage map.
[2]:
scene = load_scene() # Load empty scene
# Configure antenna arrays for all transmitters and receivers
scene.tx_array = PlanarArray(num_rows=1,
num_cols=1,
pattern="iso",
polarization="V")
scene.rx_array = scene.tx_array
# Define and add a first transmitter to the scene
tx0 = Transmitter(name='tx0',
position=[150, -100, 20],
orientation=[np.pi*5/6, 0, 0],
power_dbm=44)
scene.add(tx0)
# Compute radio map
rm_solver = RadioMapSolver()
rm = rm_solver(scene,
max_depth=5, # Maximum number of ray scene interactions
samples_per_tx=10**7 , # If you increase: less noise, but more memory required
cell_size=(5, 5), # Resolution of the radio map
center=[0, 0, 0], # Center of the radio map
size=[400, 400], # Total size of the radio map
orientation=[0, 0, 0]) # Orientation of the radio map, e.g., could be also vertical
Metrics
There are several ways to visualize a radio map. The simplest option is to call the class method show() for the desired metric.
[3]:
# Visualize path gain
rm.show(metric="path_gain");
# Visualize received signal strength (RSS)
rm.show(metric="rss");
# Visulaize SINR
rm.show(metric="sinr");



The RSS depends on the transmit power which can be modified for each transmitter as shown below.
[4]:
tx0.power_dbm = 24
rm = rm_solver(scene,
max_depth=5,
samples_per_tx=10**7,
cell_size=(5, 5),
center=[0, 0, 0],
size=[400, 400],
orientation=[0, 0, 0])
rm.show(metric="rss");

Compared to the previous cell, the RSS is now 20dB smaller.
The SINR depends not only on the RSS from other transmitters in the scene but also on the thermal noise power. The noise power is configured indirectly via the scene properties bandwidth and temperature.
Note that neither parameter affects the ray tracing process; they are only used for the computation of the noise power.
[5]:
print(f"Bandwidth: ", scene.bandwidth.numpy(), "[Hz]")
print(f"Temperature: ", scene.temperature.numpy(), "[K]")
print(f"Thermal noise power: ", watt_to_dbm(scene.thermal_noise_power).numpy(), "[dBm]")
Bandwidth: [1000000.] [Hz]
Temperature: [293.] [K]
Thermal noise power: [-113.9305] [dBm]
All metrics of a radio map can be directly accessed as tensors as shown in the next cell. This can be useful to define new metrics or visualize metrics in a different form, such as CDF plots, etc.
[6]:
# Metrics have the shape
# [num_tx, num_cells_y, num_cells_x]
print(f'{rm.path_gain.shape=}') # Path gain
print(f'{rm.rss.shape=}') # RSS
print(f'{rm.sinr.shape=}') # SINR
# The location of all cell centers in the global coordinate system of the scene
# can be accessed via:
# [num_cells_y, num_cells_x, 3]
print(f'{rm.cell_centers.shape=}')
rm.path_gain.shape=(1, 80, 80)
rm.rss.shape=(1, 80, 80)
rm.sinr.shape=(1, 80, 80)
rm.cell_centers.shape=(80, 80, 3)
Multiple transmitters
To make things more interesting, let us add two more transmitters to the scene and recompute the radio map.
[7]:
# Remove transmitters here so that the cell can be executed multiple times
scene.remove("tx1")
scene.remove("tx2")
tx1 = Transmitter(name='tx1',
position=[-150, -100, 20],
orientation=[np.pi/6, 0, 0],
power_dbm=21)
scene.add(tx1)
tx2 = Transmitter(name='tx2',
position=np.array([0, 150 * np.tan(np.pi/3) - 100, 20]),
orientation=[-np.pi/2, 0, 0],
power_dbm=27)
scene.add(tx2)
rm = rm_solver(scene,
max_depth=5,
samples_per_tx=10**7,
cell_size=(5, 5),
center=[0, 0, 0],
size=[400, 400],
orientation=[0, 0, 0])
As soon as there are multiple transmitters in a scene, we can either visualize a metric for specific transmitter or visualize the maximum matric across all transmitters. The latter option is relevant if we want to inspect, e.g., the SINR across a large scene, assuming that a receiver always connects to the transmitter providing the best SINR.
[8]:
# Show SINR for tx0
rm.show(metric="sinr", tx=0, vmin=-25, vmax=20);
# Show maximum SINR across all transmitters
rm.show(metric="sinr", tx=None, vmin=-25, vmax=20);
# Experiment: Change the metric to "path_gain" or "rss"
# and play around with the parameters vmin/vmax
# that determine the range of the colormap


We can also visualize the cumulative distribution function (CDF) of the metric of interest:
[9]:
# CDF of the SINR for transmitter 0
rm.cdf(metric="sinr", tx=0);
# CDF of the SINR if always the transmitter providing the best SINR is selected
rm.cdf(metric="sinr");


Note that, at every position, the highest SINR across all transmitters is always more favorable than the SINR offered by a specific transmitter (in math terms, the former stochastically dominates the latter). This is clearly reflected in the shape of the two distributions.
User association
It is also interesting to investigate which regions of a radio map are “covered” by each transmitter, i.e., where a transmitter provides the strongest metric. You can obtain this information either as a tensor from the class method tx_association() or visualize it using show_association().
[10]:
# Get for every cell the tx index providing the strongest value
# of the chosen metric
# [num_cells_y, num_cells_x]
print(f'{rm.tx_association("sinr").shape=}')
rm.show_association("sinr");
rm.tx_association("sinr").shape=(80, 80)

Sampling of random user positions
In some cases, one may want to drop receivers at random positions in a scene while ensuring that the chosen positions have sufficient signal quality (e.g., SINR) and/or or are located within a certain range of a transmitter. The class method sample_positions() is designed for this purpose, and you will see in the next cell how it can be used.
You are encouraged to understand why the two different criteria used for sampling lead to the observed results.
[11]:
pos, cell_ids = rm.sample_positions(
num_pos=100, # Number of random positions per receiver
metric="sinr", # Metric on which constraints and TX association will be applied
min_val_db=3, # Mininum value for the chosen metric
max_val_db=20, # Maximum value for the chosen metric
min_dist=10, # Minimum distance from transmitter
max_dist=200, # Maximum distance from transmitter
tx_association=True, # If True, only positions associated with a transmitter are chosen,
# i.e., positions where the chosen metric is the highest among all TXs
center_pos=False) # If True, random positions correspond to cell centers,
# otherwise a random offset within each cell is applied
fig = rm.show(metric="sinr");
plt.title("Random positions based on SINR, distance, and association")
# Visualize sampled positions
for tx, ids in enumerate(cell_ids.numpy()):
fig.axes[0].plot(ids[:,1], ids[:,0],
marker='x',
linestyle='',
color=mpl.colormaps['Dark2'].colors[tx])
pos, cell_ids = rm.sample_positions(
num_pos=100, # Number of random positions per receiver
metric="path_gain", # Metric on which constraints will be applied
min_val_db=-85, # Mininum value for the chosen metric
min_dist=50, # Minimum distance from transmitter
max_dist=200, # Maximum distance from transmitter
tx_association=False, # If False, then a user located in a sampled position
# for a specific TX may perceive a higher metric from another TX!
center_pos=False) # If True, random positions correspond to cell centers,
# otherwise a random offset within each cell is applied
fig = rm.show(metric="path_gain");
plt.title("Random positions based on path gain and distance")
# Visualize sampled positions
for tx, ids in enumerate(cell_ids.numpy()):
fig.axes[0].plot(ids[:,1], ids[:,0],
marker='x',
linestyle='',
color=mpl.colormaps['Dark2'].colors[tx])


Directional antennas and precoding vectors
As mentioned above, radio maps heavily depend on the chosen antenna patterns and precoding vectors. In the next cell, we will study how their impact on a radio map via several visualizations.
Let us start by assigning a single antenna to all transmitters and computing the corresponding radio map:
[12]:
scene.tx_array = PlanarArray(num_rows=1,
num_cols=1,
vertical_spacing=0.5,
horizontal_spacing=0.5,
pattern="tr38901", # Change to "iso" and compare the results
polarization="V")
rm = rm_solver(scene,
max_depth=5,
samples_per_tx=10**7,
cell_size=(5, 5),
center=[0, 0, 0],
size=[400, 400],
orientation=[0, 0, 0])
rm.show(metric="rss", tx=2);
rm.show(metric="sinr");


We now add more antennas to the antenna array of the transmitters and apply a precoding vector chosen from a Discrete Fourier Transform (DFT) beam grid.
[13]:
# Number of elements of the rectangular antenna array
num_rows = 2
num_cols = 4
# Configure all transmitters to have equal power
tx0.power_dbm = 23
tx1.power_dbm = 23
tx2.power_dbm = 23
# Configure tr38901 uniform rectangular antenna array for all transmitters
scene.tx_array = PlanarArray(num_rows=num_rows,
num_cols=num_cols,
pattern="tr38901",
polarization="V")
# Create a common precoding vector used by all transmitters
# It is also possible to assign individual
precoding_vec = [1, -1]*4 / np.sqrt(8)
# Convert to tuple of Mitsuba vectors
precoding_vec = (mi.TensorXf(precoding_vec.real),
mi.TensorXf(precoding_vec.imag))
# Compute the radio map
rm = rm_solver(scene,
max_depth=5,
samples_per_tx=10**7,
precoding_vec=precoding_vec,
cell_size=(5, 5),
center=[0, 0, 0],
size=[400, 400],
orientation=[0, 0, 0])
rm.show(metric="sinr");
rm.show_association(metric="sinr");


The use of antenna arrays and precoding vectors leads to complicated, even artistic looking, radio maps with sometimes counter-intuitive regions of user association. Nevertheless, we can still sample user positions for each transmitter.
[14]:
pos, cell_ids = rm.sample_positions(
num_pos=500,
metric="sinr",
min_val_db=3,
min_dist=10,
tx_association=True)
fig = rm.show(metric="sinr");
# Visualize sampled positions
for tx, ids in enumerate(cell_ids.numpy()):
fig.axes[0].plot(ids[:,1], ids[:,0],
marker='x',
linestyle='',
color=mpl.colormaps['Dark2'].colors[tx])
plt.title("Random positions based on SINR, distance, and association");

Radio map for a realistic scene
Until now, we have only looked at radio maps in an empty scene. Let’s spice things up a little bit and load a more interesting scene, place transmitters, and inspect the results.
[15]:
def config_scene(num_rows, num_cols):
"""Load and configure a scene"""
scene = load_scene(sionna.rt.scene.etoile)
scene.bandwidth=100e6
# Configure antenna arrays for all transmitters and receivers
scene.tx_array = PlanarArray(num_rows=num_rows,
num_cols=num_cols,
pattern="tr38901",
polarization="V")
scene.rx_array = PlanarArray(num_rows=1,
num_cols=1,
pattern="iso",
polarization="V")
# Place transmitters
positions = np.array(
[[-150.3, 21.63, 42.5],
[-125.1, 9.58, 42.5],
[-104.5, 54.94, 42.5],
[-128.6, 66.73, 42.5],
[172.1, 103.7, 24],
[232.8, -95.5, 17],
[80.1, 193.8, 21]
])
look_ats = np.array(
[[-216, -21,0],
[-90, -80, 0],
[-16.5, 75.8, 0],
[-164, 153.7, 0],
[247, 92, 0],
[211, -180, 0],
[126.3, 194.7, 0]
])
power_dbms = [23, 23, 23, 23, 23, 23, 23]
for i, position in enumerate(positions):
scene.add(Transmitter(name=f'tx{i}',
position=position,
look_at=look_ats[i],
power_dbm=power_dbms[i]))
return scene
[16]:
# Load and configure scene
num_rows=8
num_cols=2
scene_etoile = config_scene(num_rows, num_cols)
# Compute the SINR map
rm_etoile = rm_solver(scene_etoile,
max_depth=5,
samples_per_tx=10**7,
cell_size=(1, 1))
To get a global view of the coverage, let us visualize the radio map in the preview (or rendered image). These are alternatives to the show() method we have used until now, which also visualizes the objects in a scene.
[17]:
if no_preview:
# Render an image
cam = Camera(position=[0,0,1000],
orientation=np.array([0,np.pi/2,-np.pi/2]))
scene_etoile.render(camera=cam,
radio_map=rm_etoile,
rm_metric="sinr",
rm_vmin=-10,
rm_vmax=60);
else:
# Show preview
scene_etoile.preview(radio_map=rm_etoile,
rm_metric="sinr",
rm_vmin=-10,
rm_vmax=60)

Channel impulse responses for random user locations
With a radio map at hand, we can now sample random positions at which we place actual receivers and then compute channel impulse responses.
[18]:
rm_etoile.show_association("sinr");
pos, cell_ids = rm_etoile.sample_positions(
num_pos=4,
metric="sinr",
min_val_db=3,
min_dist=10,
max_dist=200,
tx_association=True)
fig = rm_etoile.show(metric="sinr", vmin=-10);
# Visualize sampled positions
for tx, ids in enumerate(cell_ids.numpy()):
fig.axes[0].plot(ids[:,1], ids[:,0],
marker='x',
linestyle='',
color=mpl.colormaps['Dark2'].colors[tx])


[19]:
[scene_etoile.remove(rx.name) for rx in scene_etoile.receivers.values()]
for i in range(rm_etoile.num_tx):
for j in range(pos.shape[1]):
scene_etoile.add(Receiver(name=f"rx-{i}-{j}",
position=pos[i,j]))
p_solver = PathSolver()
paths = p_solver(scene_etoile, max_depth=5)
# Channel impulse response
a, tau = paths.cir()
[20]:
if no_preview:
scene_etoile.render(camera=cam,
paths=paths,
clip_at=5);
else:
scene_etoile.preview(paths=paths,
radio_map=rm_etoile,
rm_metric="sinr",
rm_vmin=-10,
clip_at=5)

Conclusions
Radio maps are a highly versatile feature of Sionna RT. They are particularly useful for defining meaningful areas for random user drops that meet certain constraints on RSS or SINR, or for investigating the placement and configuration of transmitters in a scene.
However, we have barely scratched the surface of their potential. For example, observe that the metrics of a radio map are differentiable with respect to most scene parameters, such as transmitter orientations, transmit power, antenna patterns, precoding vectors, and so on. This opens up a wide range of possibilities for gradient-based optimization.
We hope you found this tutorial on radio maps in Sionna RT informative. We encourage you to get your hands on it, conduct your own experiments and deepen your understanding of ray tracing. There’s always more to learn, so be sure to explore our other tutorials as well!