Tutorial on Scattering
In this notebook, you will
Learn what scattering is and why it is important
Make various ray tracing experiments to validate some theoretical results
Familiarize yourself with the Sionna RT API
Visualize the impact of scattering on channel impulse responses and radio maps
Imports
[1]:
from math import sqrt, log10
import numpy as np
import drjit as dr
import matplotlib.pyplot as plt
# 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
Scattering Basics
When an electromagnetic wave impinges on a surface, one part of the energy gets reflected while the other part gets refracted, i.e., it propagates into the surface. We distinguish between two types of reflection, specular and diffuse. The latter type is also called diffuse scattering, or just scattering. When a rays hits a diffuse reflection surface, it is not reflected into a single (specular) direction but rather scattered toward many different directions.
One way to think about scattering is that every infinitesimally small surface element
The most important difference between diffuse and specular reflections for ray tracing is that an incoming ray essentially spawns infinitely many scattered rays while there is only a single specular path. In order to computed the scattered field at a particular position, one needs to integrate the scattered field over the entire surface.
Let us have a look at some common scattering patterns that are implemented in Sionna RT:
[2]:
LambertianPattern().show();


[3]:
# The stronger alpha_r, the more the pattern
# is concentrated around the specular direction.
DirectivePattern(alpha_r=10).show(show_directions=True);


In order to develop a feeling for the difference between specular and diffuse reflections, let us load a very simple scene with a single quadratic reflector and place a transmitter and receiver in it.
[4]:
scene = load_scene(sionna.rt.scene.simple_reflector, merge_shapes=False)
# Configure the transmitter and receiver arrays
scene.tx_array = PlanarArray(num_rows=1,
num_cols=1,
vertical_spacing=0.5,
horizontal_spacing=0.5,
pattern="iso",
polarization="V")
scene.rx_array = scene.tx_array
# Add a transmitter and receiver with equal distance from the center of the surface
# at an angle of 45 degrees.
dist = 5
d = dist/sqrt(2)
scene.add(Transmitter(name="tx", position=[-d,0,d]))
scene.add(Receiver(name="rx", position=[d,0,d]))
if no_preview:
# Add a camera for visualization
my_cam = Camera(position=[0, -30, 20], look_at=[0,0,3])
scene.render(camera=my_cam, num_samples=128)
else:
scene.preview()

Next, let us compute the specularly reflected path:
[5]:
p_solver = PathSolver()
paths = p_solver(scene=scene, los=False, specular_reflection=True)
if no_preview:
scene.render(camera=my_cam, paths=paths);
else:
scene.preview(paths=paths)

As expected from geometrical optics (GO), the specular path goes through the center of the reflector and has indentical incoming and outgoing angles with the surface normal.
We can compute the scattered paths in a similar way:
[6]:
paths = p_solver(scene=scene, los=False, specular_reflection=False, diffuse_reflection=True)
print(paths.a)
([[[[[]]]]], [[[[[]]]]])
It might be to your surpise that there is not a single diffusely reflected path. The reason for this is, however, very simple. The radio material of the reflector in the scene is simply not diffusely reflecting at all. We can change this behavior by changing the value of scattering coefficient
[7]:
scene.get("reflector").radio_material.scattering_coefficient = 0.5
paths = p_solver(scene=scene, los=False, specular_reflection=False,
diffuse_reflection=True, samples_per_src=10**6)
print(f"There are {paths.a[0].shape[-1]} scattered paths.")
There are 2252 scattered paths.
The number of rays hitting the surface is proportional to the total number of rays shot and the squared distance between the transmitter and the surface. However, the total received energy across the surface is constant as the transmitted energy is equally divided between all rays. If we double the number of rays that are shot from each source (i.e., transmitting antenna), the number of diffusely reflected paths should also double:
[8]:
paths = p_solver(scene=scene, los=False, specular_reflection=False,
diffuse_reflection=True, samples_per_src=2*10**6)
print(f"There are {paths.a[0].shape[-1]} scattered paths.")
There are 4506 scattered paths.
Scattering Patterns
In order to study the impact of the scattering pattern, let’s replace the perfectly diffuse Lambertian pattern (which all radio materials have by default) by the DirectivePattern. The larger the integer parameter
[9]:
scattering_pattern = DirectivePattern(1)
scene.get("reflector").radio_material.scattering_pattern = scattering_pattern
alpha_rs =[1,2,3,5,10,30,50,100]
received_powers = []
for alpha_r in alpha_rs:
scattering_pattern.alpha_r = alpha_r
paths = p_solver(scene=scene, los=False, specular_reflection=False, diffuse_reflection=True)
received_powers.append(10*log10(dr.sum(cpx_abs(paths.a)**2).numpy()))
plt.figure()
plt.plot(alpha_rs, received_powers)
plt.xlabel(r"$\alpha_r$")
plt.ylabel("Received power (dB)");
plt.title("Impact of the Directivity of the Scattering Pattern");

We can indeed observe that the received energy increases with
[10]:
# Move the receiver closer to the surface, i.e., away from the specular angle theta=45deg
scene.get("rx").position = [d, 0, 1]
received_powers = []
for alpha_r in alpha_rs:
scattering_pattern.alpha_r = alpha_r
paths = p_solver(scene=scene, los=False, specular_reflection=False, diffuse_reflection=True)
received_powers.append(10*log10(dr.sum(cpx_abs(paths.a)**2).numpy()))
plt.figure()
plt.plot(alpha_rs, received_powers)
plt.xlabel(r"$\alpha_r$")
plt.ylabel("Received power (dB)");
plt.title("Impact of the Directivity of the Scattering Pattern");

Validation Against the “Far”-Wall Approximation
If the scattering surface is small compared to the distance from its center to the transmitter and receiver, respectively, it can be approximated by a single scattering source that reradiates parts of the energy it has captured by the entire surface
which simplifies for a perfect reflector (
where
We have constructed our scene such that
Let’s validate for which distances
[11]:
s = 0.7 # Scattering coefficient
# Configure the radio material
scene.get("reflector").radio_material.scattering_pattern = LambertianPattern()
scene.get("reflector").radio_material.scattering_coefficient = s
# Set the carrier frequency
scene.frequency = 3.5e9
wavelength = scene.wavelength
r_is = [0.1, 1, 2, 5, 10] # Varying distances
received_powers = []
theo_powers = []
for r_i in r_is:
# Update the positions of TX and RX
d = r_i/sqrt(2)
scene.get("tx").position = [-d, 0, d]
scene.get("rx").position = [d, 0, d]
paths = p_solver(scene=scene, los=False, specular_reflection=False, diffuse_reflection=True)
received_powers.append(10*log10(dr.sum(cpx_abs(paths.a)**2).numpy()))
# Compute theoretically received power using the far-wall approximation
theo_powers.append(10*log10((wavelength[0]*s/(4*dr.pi*r_i**2))**2/(2*dr.pi)))
plt.figure()
plt.plot(r_is, received_powers)
plt.plot(r_is, theo_powers, "--")
plt.title("Validation of the Scattered Field Power")
plt.xlabel(r"$r_i$ (m)")
plt.ylabel("Received power (dB)");
plt.legend(["Ray tracing", "\"Far\"-wall approximation"]);

We can observe an almost perfect match between the results for ray-tracing and the “far”-wall approximation from a distance of
Radio Maps With Scattering
By now, you have a gained a solid understanding of scattering from a single surface. Let us now make things a bit more interesting by looking at a complex scene with many scattering surfaces. This can be nicely observed with the help of radio maps.
A radio map describes a metric, such as the average received power, from a specific transmitter at every point on a plane. The effects of fast fading, i.e., constructive/destructive interference between different paths, are averaged out by summing the squared amplitudes of all paths. As we cannot compute radio maps with infinitely fine resolution, they are approximated by small rectangular tiles for which average values are computed. For a detailed explanation, have a look at the API Documentation.
Let us now load a slightly more interesting scene containing a couple of rectangular buildings and add a transmitter. Note that we do not need to add any receivers to compute a radio map (we will add one though as we need it later).
[12]:
scene = load_scene(sionna.rt.scene.simple_street_canyon)
scene.frequency = 30e9
scene.tx_array = PlanarArray(num_rows=1,
num_cols=1,
vertical_spacing=0.5,
horizontal_spacing=0.5,
pattern="iso",
polarization="V")
scene.rx_array = scene.tx_array
scene.add(Transmitter(name="tx",
position=[-33,11,32],
orientation=[0,0,0]))
# We add a receiver for later path computations
scene.add(Receiver(name="rx",
position=[27,-13,1.5],
orientation=[0,0,0]))
my_cam = Camera(position=[10,0,300], look_at=[0,0,0])
my_cam.look_at([0,0,0])
Computing and visualizing a radio map is as simple as running the following commands:
[13]:
rm_solver = RadioMapSolver()
rm = rm_solver(scene, cell_size=[1,1], samples_per_tx=int(20e6), max_depth=5, refraction=False)
if no_preview:
scene.render(camera=my_cam, radio_map=rm, rm_vmin=-200, rm_vmax=-90);
else:
scene.preview(radio_map=rm, rm_vmin=-200, rm_vmax=-90);

By default, radio maps are computed without diffuse reflections. We have also explicitly disabled refraction through the building walls in the code above. The parameter cm_cell_size
determines the resolution of the radio map. However, the finer the resolution, the more rays (i.e., num_samples
) must be shot. We can see from the above figure, that there are various regions which have no coverage as they cannot be reached by pure line-of-sight of specularly reflected paths.
Let’s now enable diffuse reflections and see what happens.
[14]:
# Configure radio materials for scattering
# By default the scattering coefficient is set to zero
for rm in scene.radio_materials.values():
rm.scattering_coefficient = 1/sqrt(3) # Try different values in [0,1]
rm.scattering_pattern = DirectivePattern(alpha_r=10) # Play around with different values of alpha_r
rm_scat = rm_solver(scene, cell_size=[1,1], samples_per_tx=int(20e6), max_depth=5,
refraction=False, diffuse_reflection=True)
if no_preview:
scene.render(camera=my_cam, radio_map=rm_scat, rm_vmin=-200, rm_vmax=-90);
else:
scene.preview(radio_map=rm_scat, rm_vmin=-200, rm_vmax=-90);

Thanks to scattering, most regions in the scene have some coverage. However, the scattered field is weak compared to that of the LoS and reflected paths. Also note that the peak signal strength has slightly decreased. This is because the scattering coefficient takes away some of the specularly reflected energy.
Impact on Channel Impulse Response
As a last experiment in our tutorial on scattering, let us have a look at the discrete baseband-equivalent channel impulse responses we obtain with and without scattering. To this end, we will compute the channel impulse response of the single receiver we have configured for the current scene, and then transform it into the complex baseband representation using method Paths.taps().
[15]:
# Change the scattering coefficient of all radio materials
for rm in scene.radio_materials.values():
rm.scattering_coefficient = 1/sqrt(3)
bandwidth=200e6 # bandwidth of the receiver (= sampling frequency)
plt.figure()
# Paths without diffuse reflections
paths = p_solver(scene,
max_depth=5,
samples_per_src=10**6,
diffuse_reflection=False,
refraction=False,
synthetic_array=True)
# Paths with diffuse reflections
paths_diff = p_solver(scene,
max_depth=5,
samples_per_src=10**6,
diffuse_reflection=True,
refraction=False,
synthetic_array=True)
# Compute channel taps without scattering
taps = paths.taps(bandwidth, l_min=0, l_max=100, normalize=True, out_type="numpy")
taps = np.squeeze(taps)
tau = np.arange(taps.shape[0])/bandwidth*1e9
# Compute channel taps wit scattering
taps_diff = paths_diff.taps(bandwidth, l_min=0, l_max=100, normalize=True, out_type="numpy")
taps_diff = np.squeeze(taps_diff)
# Plot results
plt.figure();
plt.plot(tau, 20*np.log10(np.abs(taps)));
plt.plot(tau, 20*np.log10(np.abs(taps_diff)));
plt.xlabel(r"Delay $\tau$ (ns)");
plt.ylabel(r"$|h|^2$ (dB)");
plt.title("Comparison of Channel Impulse Responses");
plt.legend(["No Scattering", "With Scattering"]);
<Figure size 640x480 with 0 Axes>

The discrete channel impulse response looks similar for small values of
Summary
In conclusion, scattering plays an important role for radio propagation modelling. In particular, the higher the carrier frequency, the rougher most surfaces appear compared to the wavelength. Thus, at THz-frequencies diffuse reflections might become the dominating form of radio wave propgation (apart from LoS).
We hope you enjoyed our dive into scattering with this Sionna RT tutorial. Please try out some experiments yourself and improve your grasp of ray tracing. There’s more to discover, so so don’t forget to check out our other tutorials, too.
References
[1] Vittorio Degli-Esposti et al., Measurement and modelling of scattering from buildings, IEEE Trans. Antennas Propag., vol. 55, no. 1, pp.143-153, Jan. 2007.
[2] Vittorio Degli-Esposti et al., An advanced field prediction model including diffuse scattering, IEEE Trans. Antennas Propag., vol. 52, no. 7, pp.1717-1728, Jul. 2004.