Understanding the Paths Object
Sionna RT uses a PathSolver
to compute propagation Paths between the antennas of
transmitters and receivers in a scene. The goal of this developer guide is to
explain the properties of the Paths
object in detail. Let’s start with a
short code snippet that computes propagation paths between a transmitter and receiver:
# Imports
import sionna.rt
from sionna.rt import load_scene, PlanarArray, Transmitter, Receiver, \
PathSolver
# Load scene
scene = load_scene(sionna.rt.scene.munich, merge_shapes=False)
# Configure TX/RX antenna array
scene.tx_array = PlanarArray(num_rows=2,
num_cols=1,
pattern="iso",
polarization="V")
scene.rx_array = scene.tx_array
# Create TX/RX
scene.add(Transmitter(name="tx", position=[8.5,21,27]))
scene.add(Receiver(name="rx", position=[44,95,1.5]))
# Compute propagation paths
p_solver = PathSolver()
# without a synthetic array
paths = p_solver(scene=scene, max_depth=3, synthetic_array=False)
# with a synthetic array
paths_syn = p_solver(scene=scene, max_depth=3, synthetic_array=True)
Depending on the boolean argument synthetic_array
used during the call of the
path solver above, a source/target is either a transmit/receive antenna or a point
located at the center of an antenna array. In our example, there are two sources
and targets (one for each antenna of the array) if synthetic_array
is
False. There is a single source and target (one for each radio device) if synthetic_array
is
True. This can be seen from the properties of the paths objects as shown below:
# Show sources/targets
print("Source coordinates: \n", paths.sources)
print("Target coordinates: \n", paths.targets)
# Show sources/targets with `synthetic_array`
print("Source coordinates (synthetic array): \n", paths_syn.sources)
print("Target coordinates (synthetic array): \n", paths_syn.targets)
Source coordinates:
[[8.5, 21, 27.0214],
[8.5, 21, 26.9786]]
Target coordinates:
[[44, 95, 1.52141],
[44, 95, 1.47859]]
Source coordinates (synthetic array):
[[8.5, 21, 27]]
Target coordinates (synthetic array):
[[44, 95, 1.5]]
Apart from the paths coefficients paths.a
and delays paths.tau
, the paths instance stores a lot of
side information about the propagation paths, such as angles of arrival and
departure, Doppler shifts, interaction types, ids of intersected objects, and
coordinates of intersection points (i.e., vertices).
# Let us inspect a specific path in detail
path_idx = 4 # Try out other values in the range [0, 14]
# For a detailed overview of the dimensions of all properties, have a look at the API documentation
print(f"\n--- Detailed results for path {path_idx} ---")
print(f"Channel coefficient: {paths.a[0].numpy()[0,0,0,0,path_idx] + 1j*paths.a[1].numpy()[0,0,0,0,path_idx]}")
print(f"Propagation delay: {paths.tau[0,0,0,0,path_idx].numpy()*1e6:.5f} us")
print(f"Zenith angle of departure: {paths.theta_t.numpy()[0,0,0,0,path_idx]:.4f} rad")
print(f"Azimuth angle of departure: {paths.phi_t.numpy()[0,0,0,0,path_idx]:.4f} rad")
print(f"Zenith angle of arrival: {paths.theta_r.numpy()[0,0,0,0,path_idx]:.4f} rad")
print(f"Azimuth angle of arrival: {paths.phi_r.numpy()[0,0,0,0,path_idx]:.4f} rad")
print(f"Doppler shift: {paths.doppler.numpy()[0,0,0,0,path_idx]:.4f} Hz")
--- Detailed results for path 4 ---
Channel coefficient: (-1.0185684914176818e-05-9.316545401816256e-06j)
Propagation delay: 0.60660 us
Zenith angle of departure: 1.7115 rad
Azimuth angle of departure: 0.1612 rad
Zenith angle of arrival: 1.4110 rad
Azimuth angle of arrival: -0.9712 rad
Doppler shift: 0.0000 Hz
# Show the interactions undergone by all paths:
# 0 - No interaction, 1 - Specular reflection, 2 - Diffuse reflection, 4 - Refraction
# Note that diffuse reflections are turned off by default.
# Shape [max_depth, num_rx, num_rx_ant, num_tx, num_tx_ant, num_paths]
print("Interactions: \n", paths.interactions.numpy()[:,0,0,0,0,:])
print("Number of paths: ", paths.interactions.shape[-1])
Interactions:
[[4 1 0 1 1 1 1 1 4 4 1 1 1 1 1]
[1 0 0 1 0 1 1 0 1 1 0 1 1 0 1]
[4 0 0 1 0 0 0 0 4 4 0 0 1 0 0]]
Number of paths: 15
We can see that there are in total 15 propagation paths (number of columns) with a maximum number of three interactions (number of rows). There is for example a paths consisting of a refraction (4), followed by a specular reflection (1), and another refraction (4). The line-of-sight (LoS) path has no interactions with the scene, i.e., [0,0,0].
The coordinates for every interaction as well as the corresponding object ids can be extracted in the following way:
# Object ids for the selected path
print("Object IDs: \n", paths.objects.numpy()[:,0,0,0,0,path_idx])
# Coordinates of interaction points of the selected path
print("Vertices: \n", paths.vertices.numpy()[:,0,0,0,0,path_idx])
Object IDs:
[ 2364 4294967295 4294967295]
Vertices:
[[42.107708 91.055534 0. ]
[ 0. 0. 0. ]
[ 0. 0. 0. ]
Note that the second and third object ids equal 4294967295, indicating an invalid shape. This happens because the path under consideration has only a depth of one, consisting of a single specular reflection before the receiver is reached.
We can recover a SceneObject from its id in the following way:
for obj in scene.objects.values():
if obj.object_id == paths.objects.numpy()[0,0,0,0,0,0]:
break
A scene object enriches a Mitsuba shape with additional properties. However, all of the currently implemented algorithms assume that a scene object is constructed from a Mitsuba mesh (which inherits from Mitsuba shape). A mesh is defined by a set of triangles (also called faces or primitives), which is not the case for a shape, which could be, e.g., defined as a sphere of a certain radius.
We can access the shape of a scene object via the property
SceneObject.mi_shape
:
print(obj.mi_shape)
PLYMesh[
name = "element_231-itu_marble.ply",
bbox = BoundingBox3f[
min = [41.2902, 113.299, 0],
max = [109.21, 145.203, 17.7352]
],
vertex_count = 30,
vertices = [360 B of vertex data],
face_count = 30,
faces = [360 B of face data],
face_normals = 1
]
The Paths.primitives
property provides the ids of the faces of the mi_shape
of scene object that paths intersect. One can recover the normal vectors of the
primitives in the following way:
obj.mi_shape.face_normal(paths.primitives.numpy()[:,0,0,0,0,path_idx])
[[-0.316442, -0.948612, 0],
[nan, nan, nan],
[nan, nan, nan]]
In our example, the path only intersects a single object and there is hence also only a single normal vector of interest.
In some cases, there is a different number of valid paths between different
pairs of sources and targets.
An example would be a link between two antenna arrays that is partially occluded,
so that only some antennas have a line-of-sight connection.
Which paths are valid can be seen from the property `Paths.valid`
:
paths.valid
[[[[[True, True, True, True, True, True, True, True, True, True, True, True, True, True, True],
[True, True, True, True, True, True, True, True, True, True, True, True, True, True, True]]],
[[[True, True, True, True, True, True, True, True, True, True, True, True, True, True, True],
[True, True, True, True, True, True, True, True, True, True, True, True, True, True, True]]]]]
This tensor can be useful to mask invalid paths in computations.
The path instance can be used to compute the channel frequency response Paths.cfr(), the baseband equivalent response Paths.cir(), or the discrete complex-baseband equivalent response Paths.taps(). How these functions are used is described in detail in the tutorial Introduction to Sionna RT.