Source code for sionna.rt.ris

#
# SPDX-FileCopyrightText: Copyright (c) 2021-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
"""
Classes and functions relating to reconfigurable intelligent surfaces
"""

from abc import ABC
from abc import abstractmethod
import tensorflow as tf
import matplotlib.pyplot as plt

from .radio_device import RadioDevice
from .scene_object import SceneObject
from . import scene
from .utils import rotate, normalize, outer,\
                   expand_to_rank

[docs] class CellGrid(): # pylint: disable=line-too-long r""" Class defining a cell grid that determines the physical structure of a RIS The cell grid specifies the location of unit cells within the y-z plane assuming a homogenous spacing of 0.5. The actual positions are computed by multiplying the cell positions by the wavelength and rotating them according to the RIS' orientation. A cell grid must have at least three columns and rows to ensure that discrete phase and amplitude profiles of the RIS can be interpolated. Parameters ---------- num_rows : int Number of rows. Must at least be equal to three. num_cols : int Number of columns. Must at least be equal to three. dtype : tf.complex Datatype to be used in internal calculations. Defaults to `tf.complex64`. """ def __init__(self, num_rows, num_cols, dtype=tf.complex64): if dtype not in (tf.complex64, tf.complex128): raise ValueError("`dtype` must be tf.complex64 or tf.complex128`") self._dtype = dtype self._rdtype = dtype.real_dtype if num_rows < 3 or num_cols < 3: raise ValueError("num_rows and num_cols must be >= 3") self._num_rows = int(num_rows) self._num_cols = int(num_cols) self._cell_y_positions = tf.range(self.num_cols, dtype=self._rdtype) self._cell_y_positions -= tf.cast((self.num_cols-1.)/2., self._rdtype) self._cell_z_positions = tf.range(self.num_rows-1, -1, -1, dtype=self._rdtype) self._cell_z_positions -= tf.cast((self.num_rows-1.)/2., self._rdtype) z, y = tf.meshgrid(self.cell_z_positions, self.cell_y_positions) self._cell_positions = tf.stack([tf.reshape(y, [-1]), tf.reshape(z, [-1])], -1) @property def num_rows(self): r""" int : Number of rows """ return self._num_rows @property def num_cols(self): r""" int : Number of columns """ return self._num_cols @property def num_cells(self): r""" int : Number of cells """ return self.num_rows * self.num_cols @property def cell_positions(self): r""" [num_cells, 2], tf.float : Cell positions ordered from top-to-bottom left-to-right """ return self._cell_positions @property def cell_y_positions(self): r""" [num_cols], tf.float : y-coordinates of cells ordered from left-to-right """ return self._cell_y_positions @property def cell_z_positions(self): r""" [num_rows], tf.float : z-coordinates of cells ordered from top-to-bottom """ return self._cell_z_positions
class Profile(ABC): # pylint: disable=line-too-long r"""Abstract class defining a phase/amplitude profile of a RIS A Profile instance is a callable that returns the profile values, gradients and Hessians at given points. Parameters ---------- dtype : tf.complex Datatype to be used in internal calculations. Defaults to `tf.complex64`. Input ----- points : tf.float, [num_samples, 2] Tensor of 2D coordinates defining the points on the RIS at which the profile should be evaluated. Defaults to `None`. In this case, the values for all unit cells are returned. mode : int | `None` Reradiation mode to be considered. Defaults to `None`. In this case, the values for all modes are returned. return_grads : bool If `True`, also the first- and second-order derivatives are returned. Defaults to `False`. Output ------ values : [num_modes, num_samples] or [num_samples], tf.float Interpolated profile values at the sample positions grads : [num_modes, num_samples, 3] or [num_samples, 3], tf.float Gradients of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. hessians : [num_modes, num_samples, 3, 3] or [num_samples, 3, 3] , tf.float Hessians of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. """ def __init__(self, dtype=tf.complex64): if dtype not in (tf.complex64, tf.complex128): raise ValueError("`dtype` must be tf.complex64 or tf.complex128`") self._dtype = dtype self._rdtype = dtype.real_dtype @property @abstractmethod def num_modes(self): r""" int : Number of reradiation modes """ pass @abstractmethod def __call__(self, points, mode=None, return_grads=False): r""" Returns the profile values, gradients and Hessians at given points Input ----- points : tf.float, [num_samples, 2] Tensor of 2D coordinates defining the points on the RIS at which the profile should be evaluated. Defaults to `None`. In this case, the values for all unit cells are returned. mode : int | `None` Reradiation mode to be considered. Defaults to `None`. In this case, the values for all modes are returned. return_grads : bool If `True`, also the first- and second-order derivatives are returned. Defaults to `False`. Output ------ values : [num_modes, num_samples] or [num_samples], tf.float Interpolated profile values at the sample positions grads : [num_modes, num_samples, 3] or [num_samples, 3], tf.float Gradients of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. hessians : [num_modes, num_samples, 3, 3] or [num_samples, 3, 3] , tf.float Hessians of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. """ pass
[docs] class AmplitudeProfile(Profile): # pylint: disable=line-too-long r"""Abstract class defining an amplitude profile of a RIS An AmplitudeProfile instance is a callable that returns the profile values, gradients and Hessians at given points. Parameters ---------- dtype : tf.complex Datatype to be used in internal calculations. Defaults to `tf.complex64`. Input ----- points : tf.float, [num_samples, 2] Tensor of 2D coordinates defining the points on the RIS at which the profile should be evaluated. Defaults to `None`. In this case, the values for all unit cells are returned. mode : int | `None` Reradiation mode to be considered. Defaults to `None`. In this case, the values for all modes are returned. return_grads : bool If `True`, also the first- and second-order derivatives are returned. Defaults to `False`. Output ------ values : [num_modes, num_samples] or [num_samples], tf.float Interpolated profile values at the sample positions grads : [num_modes, num_samples, 3] or [num_samples, 3], tf.float Gradients of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. hessians : [num_modes, num_samples, 3, 3] or [num_samples, 3, 3] , tf.float Hessians of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. """ @property @abstractmethod def mode_powers(self): r""" [num_modes], tf.float: Relative power of reradiation modes """ pass
[docs] class PhaseProfile(Profile): # pylint: disable=line-too-long r"""Abstract class defining a phase profile of a RIS A PhaseProfile instance is a callable that returns the profile values, gradients and Hessians at given points. Parameters ---------- dtype : tf.complex Datatype to be used in internal calculations. Defaults to `tf.complex64`. Input ----- points : tf.float, [num_samples, 2] Tensor of 2D coordinates defining the points on the RIS at which the profile should be evaluated. Defaults to `None`. In this case, the values for all unit cells are returned. mode : int | `None` Reradiation mode to be considered. Defaults to `None`. In this case, the values for all modes are returned. return_grads : bool If `True`, also the first- and second-order derivatives are returned. Defaults to `False`. Output ------ values : [num_modes, num_samples] or [num_samples], tf.float Interpolated profile values at the sample positions grads : [num_modes, num_samples, 3] or [num_samples, 3], tf.float Gradients of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. hessians : [num_modes, num_samples, 3, 3] or [num_samples, 3, 3] , tf.float Hessians of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. """ pass
class DiscreteProfile(Profile): # pylint: disable=line-too-long r"""Class defining a discrete phase/amplitude profile of a RIS A DiscreteProfile instance is a callable that returns the profile values, gradients and Hessians at given points. Parameters ---------- cell_grid : :class:`~sionna.rt.CellGrid` Defines the physical structure of the RIS num_modes : int Number of reradiation modes. Defaults to 1. values : tf.float or tf.Variable, [num_modes, num_rows, num_cols] Values of the discrete profile for each reradiation mode and unit cell. `num_rows` and `num_cols` are defined by the `cell_grid`. Defaults to `None`. interpolator : :class:`~sionna.rt.ProfileInterpolator` Instance of a `ProfileInterpolator` that interpolates the discrete values of the profile to a continuous profile which is defined at any point on the RIS. Defaults to `None`. In this case, the :class:`~sionna.rt.LagrangeProfileInterpolator` will be used. dtype : tf.complex Datatype to be used in internal calculations. Defaults to `tf.complex64`. Input ----- points : tf.float, [num_samples, 2] Tensor of 2D coordinates defining the points on the RIS at which the profile should be evaluated. Defaults to `None`. In this case, the values for all unit cells are returned. mode : int | `None` Reradiation mode to be considered. Defaults to `None`. In this case, the values for all modes are returned. return_grads : bool If `True`, also the first- and second-order derivatives are returned. Defaults to `False`. Output ------ values : [num_modes, num_samples] or [num_samples], tf.float Interpolated profile values at the sample positions grads : [num_modes, num_samples, 3] or [num_samples, 3], tf.float Gradients of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. hessians : [num_modes, num_samples, 3, 3] or [num_samples, 3, 3] , tf.float Hessians of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. """ def __init__(self, cell_grid, num_modes=1, values=None, interpolator=None, dtype=tf.complex64): super().__init__(dtype=dtype) self._cell_grid = cell_grid self._num_modes = tf.cast(num_modes, tf.int32) if values is None: self._values = None else: self.values = values if interpolator is None: self._interpolator = LagrangeProfileInterpolator(self) else: self._interpolator = interpolator @property def shape(self): r""" tf.TensorShape : Shape of the tensor holding the values of the discrete profile """ return tf.TensorShape([self.num_modes, self.cell_grid.num_rows, self.cell_grid.num_cols]) @property def values(self): r""" [shape], tf.float : Set/get the discrete values of the profile for each reradiation mode """ return self._values @values.setter def values(self, v): if not v.shape == self.shape: raise ValueError(f"`values` must have shape {self.shape}") if isinstance(v, tf.Variable): if v.dtype != self._rdtype: msg = f"`values` must have dtype={self._rdtype}" raise TypeError(msg) else: self._values = v else: self._values = tf.cast(v, dtype=self._rdtype) @property def num_modes(self): r""" int : Number of reradiation modes """ return self._num_modes @property def cell_grid(self): r""" :class:`~sionna.rt.CellGrid` : Defines the physical structure of the RIS """ return self._cell_grid @property def spacing(self): r""" tf.float: Element spacing [m] corresponding to half a wavelength """ if hasattr(scene.Scene(), "wavelength"): wavelength = tf.cast(scene.Scene().wavelength, self._rdtype) return wavelength/tf.cast(2, self._rdtype) else: # Scene is not initialized return tf.cast(0.5, self._rdtype) def show(self, mode=0): r"""Visualizes the profile as a 3D plot Input ------ mode : int | `None` Reradation mode to be shown. Defaults to 0. Output ------ : :class:`matplotlib.pyplot.Figure` 3D plot of the profile """ fig = plt.figure() ax = fig.add_subplot(111, projection='3d') y, z = tf.meshgrid(self.cell_grid.cell_y_positions*self.spacing, self.cell_grid.cell_z_positions*self.spacing) ax.plot_surface(y, z, self.values[mode], cmap='viridis') ax.set_xlabel("y") ax.set_ylabel("z") if isinstance(self, PhaseProfile): plt.title(r"Phase profile $\chi(y, z)$") if isinstance(self, AmplitudeProfile): plt.title(r"Amplitude profile $A(y, z)$") return fig def __call__(self, points=None, mode=None, return_grads=False): r""" Returns the profile values, gradients and Hessians at given points Input ----- points : tf.float, [num_samples, 2] Tensor of 2D coordinates defining the points on the RIS at which the profile should be evaluated. Defaults to `None`. In this case, the values for all unit cells are returned. mode : int | `None` Reradiation mode to be considered. Defaults to `None`. In this case, the values for all modes are returned. return_grads : bool If `True`, also the first- and second-order derivatives are returned. Only available if `points` is not `None`. Defaults to `False`. Output ------ values : [num_modes, num_samples] or [num_samples], tf.float Interpolated profile values at the sample positions grads : [num_modes, num_samples, 3] or [num_samples, 3], tf.float Gradients of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. hessians : [num_modes, num_samples, 3, 3] or [num_samples, 3, 3] , tf.float Hessians of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. """ if points is None: if mode is not None: values = tf.transpose(self.values[mode]) values = tf.reshape(values, [-1]) else: values = tf.transpose(self.values, perm=[0,2,1]) values = tf.reshape(values, [self.num_modes, -1]) return values else: return self._interpolator(points, mode, return_grads)
[docs] class ProfileInterpolator(ABC): r""" Abstract class defining an interpolator of a discrete profile A ProfileInterpolator instance is a callable that interpolate the discrete profile to specified points. Optionally, the gradients and Hessians are returned. Parameters ---------- discrete_profile : :class:`~sionna.rt.DiscreteProfile` Discrete profile to be interpolated Input ----- points : [num_samples, 2], tf.float Positions at which to interpolate the profile mode : int | `None` Mode of the profile to interpolate. If `None`. all modes are interpolated. Defaults to `None`. return_grads : bool If `True`, gradients and Hessians are computed. Defaults to `False`. Output ------ values : [num_modes, num_samples] or [num_samples], tf.float Interpolated profile values at the sample positions grads : [num_modes, num_samples, 3] or [num_samples, 3], tf.float Gradients of the interpolated profile values at the sample positions hessians : [num_modes, num_samples, 3, 3] or [num_samples,3,3], tf.float Hessians of the interpolated profile values at the sample positions """ def __init__(self, discrete_profile): self._discrete_profile = discrete_profile self._dtype = discrete_profile._dtype self._rdtype = discrete_profile._rdtype @property def spacing(self): r""" tf.float: Element spacing [m] corresponding to half a wavelength """ if hasattr(scene.Scene(), "wavelength"): wavelength = tf.cast(scene.Scene().wavelength, self._rdtype) return wavelength/tf.cast(2, self._rdtype) else: # Scene is not initialized return tf.cast(0.5, self._rdtype) @property def cell_y_positions(self): r""" [num_cols], tf.float : y-coordinates of cells ordered from left-to-right """ return self._discrete_profile.cell_grid.cell_y_positions*self.spacing @property def cell_z_positions(self): r""" [num_rows], tf.float : z-coordinates of cells ordered from top-to-bottom """ return self._discrete_profile.cell_grid.cell_z_positions*self.spacing @property def num_rows(self): r""" int : Number of rows """ return self._discrete_profile.cell_grid.num_rows @property def num_cols(self): r""" int : Number of columns """ return self._discrete_profile.cell_grid.num_cols @property def values(self): r""" [shape], tf.float : Discrete values of the profile for each reradiation mode and unit cell """ return self._discrete_profile.values @abstractmethod def __call__(self, points, mode=None, return_grads=False): r""" Interpolates the discrete profile to specified points Optionally, the gradients and Hessians are returned. Input ----- points : [num_samples, 2], tf.float Positions at which to interpolate the profile mode : int | `None` Mode of the profile to interpolate. If `None`. all modes are interpolated. Defaults to `None`. return_grads : bool If `True`, gradients and Hessians are computed. Defaults to `False`. Output ------ values : [num_modes, num_samples] or [num_samples], tf.float Interpolated profile values at the sample positions grads : [num_modes, num_samples, 3] or [num_samples, 3], tf.float Gradients of the interpolated profile values at the sample positions hessians : [num_modes, num_samples, 3, 3] or [num_samples,3,3], tf.float Hessians of the interpolated profile values at the sample positions """ pass
[docs] class LagrangeProfileInterpolator(ProfileInterpolator): # pylint: disable=line-too-long r""" Class defining a :class:`~sionna.rt.ProfileInterpolator` using Lagrange polynomials The class instance is a callable that interpolates a discrete profile at arbitrary positions using two-dimensional 2nd-order Lagrange interpolation. A discrete profile :math:`P(y_i,z_j)\in\mathbb{R}` defined on a grid of points :math:`y_i,z_j` for :math:`i,j \in [1,2,3]` is interpolated to position :math:`y,z` as .. math:: \begin{align} P(y,z) &= \sum_{i,j} P(y_i,z_j) \ell_{i,y}(y) \ell_{j,z}(z) \end{align} where :math:`\ell_{i,y}(y)`, :math:`\ell_{j,z}(z)` are the one-dimensional 2nd-order Lagrange polynomials, defined as .. math:: \begin{align} \ell_{i,y}(y) &= \prod_{j \ne i} \frac{y-y_j}{y_i-y_j} \\ \ell_{j,z}(z) &= \prod_{i \ne j} \frac{z-z_i}{z_j-z_i}. \end{align} Note that the formulation above assumes for simplicity only a 3x3 grid of points. However, the implementation finds for every position the closest 3x3 grid points of the discrete profile which are used for interpolation. In order to compute spatial gradients and Hessians, we extend the the profile with a dummy :math:`x` dimension, i.e., :math:`P(x,y,z)=P(y,z)`, such that .. math:: \begin{align} \nabla P(x,y,z) &= \begin{bmatrix} 0, \frac{\partial P(x,y,z)}{\partial y}, \frac{\partial P(x,y,z)}{\partial z} \end{bmatrix}^{\textsf{T}}\\ H_P(x,y,z) &= \begin{bmatrix} 0 & 0 & 0 \\ 0 & \frac{\partial^2 P(x,y,z)}{\partial y^2} & \frac{\partial^2 P(x,y,z)}{\partial y \partial z} \\ 0 & \frac{\partial^2 P(x,y,z)}{\partial z \partial y} & \frac{\partial^2 P(x,y,z)}{\partial z^2} \end{bmatrix} \end{align}. Parameters ---------- discrete_profile : :class:`~sionna.rt.DiscreteProfile` Discrete profile to be interpolated Input ----- points : [num_samples, 2], tf.float Positions at which to interpolate the profile mode : int | `None` Mode of the profile to interpolate. If `None`, all modes are interpolated. Defaults to `None`. return_grads : bool If `True`, gradients and Hessians are computed. Defaults to `False`. Output ------ values : [num_modes, num_samples] or [num_samples], tf.float Interpolated profile values at the sample positions grads : [num_modes, num_samples, 3] or [num_samples, 3], tf.float Gradients of the interpolated profile values at the sample positions hessians : [num_modes, num_samples, 3, 3] or [num_samples, 3, 3] , tf.float Hessians of the interpolated profile values at the sample positions """
[docs] @staticmethod def lagrange_polynomials(x, x_i, return_derivatives=True): # pylint: disable=line-too-long r""" Compute the 2nd-order Lagrange polynomials Optionally, the first- and second-order derivatives are returned. The 2nd-order Lagrange polynomials :math:`\ell_j(x)`, :math:`j=1,2,3`, for position :math:`x\in\mathbb{R}` are computed using three distinct support positions :math:`x_i` for :math:`i=1,2,3`: .. math:: \begin{align} \ell_j(x) &= \prod_{\substack{1\leq i \leq 3 \\ i \ne j}} \frac{x-x_i}{x_j-x_i}. \end{align} Their first- and second-order derivatives are then respectively given as .. math:: \begin{align} \ell'_j(x) &= \left(\sum_{i \ne j} x-x_i\right) \left(\prod_{i \ne j} x_j-x_i\right)^{-1} \\ \ell''_j(x) &= 2 \left(\prod_{i \ne j} x_j-x_i\right)^{-1}. \end{align} Input ----- x : [batch_size], tf.float Sample positions x_i : [batch_size, 3], tf.float Support positions for every sample position return_derivatives : bool If `True`, also the first- and second-order derivatives of the Lagrange polynomials are returned. Defaults to `True`. Output ------ l_i : [batch_size, 3], tf.float Lagrange polynomials for each sample position deriv_1st : [batch_size, 3], tf.float First-order derivatives for each sample position. Only returned if `return_derivatives` is `True`. deriv_2nd : [batch_size, 3], tf.float Second-order derivatives for each sample position. Only returned if `return_derivatives` is `True`. """ # Compute products of differences of the sample and support points sample_diff = tf.expand_dims(x, 1) - x_i sample_prod_0 = sample_diff[:,1]*sample_diff[:,2] sample_prod_1 = sample_diff[:,0]*sample_diff[:,2] sample_prod_2 = sample_diff[:,0]*sample_diff[:,1] sample_prods = tf.stack([sample_prod_0, sample_prod_1, sample_prod_2], -1) # Compute products of differences of support points support_diffs = tf.expand_dims(x_i, -1) - tf.expand_dims(x_i, -2) support_diffs = tf.where(support_diffs==0, 1., support_diffs) support_prods = tf.reduce_prod(support_diffs, axis=-1) # Compute Lagrange polynomials lagrange = sample_prods/support_prods if not return_derivatives: return lagrange else: # Compute sums of differences sample_sum_0 = sample_diff[:,1] + sample_diff[:,2] sample_sum_1 = sample_diff[:,0] + sample_diff[:,2] sample_sum_2 = sample_diff[:,0] + sample_diff[:,1] sample_sums = tf.stack([sample_sum_0, sample_sum_1, sample_sum_2], -1) # Compute first-order derivatives deriv_1st = sample_sums/support_prods # Compute second-order derivatives deriv_2nd = tf.cast(2, support_prods.dtype)/support_prods return lagrange, deriv_1st, deriv_2nd
def __call__(self, points, mode=None, return_grads=False): # pylint: disable=line-too-long r""" Interpolates a discrete profile at arbitrary position via 2D 2nd-order Lagrange interpolation. A discrete profile :math:`P(y_i,z_j)\in\mathbb{R}` defined on a grid of points :math:`y_i,z_j` for :math:`i,j \in [1,2,3]` is interpolated to position :math:`y,z` as .. math:: \begin{align} P(y,z) &= \sum_{i,j} P(y_i,z_j) \ell_{i,y}(y) \ell_{j,z}(z) \end{align} where :math:`\ell_{i,y}(y)`, :math:`\ell_{j,z}(z)` are the one-dimensional 2nd-order Lagrange polynomials, defined as .. math:: \begin{align} \ell_{i,y}(y) &= \prod_{j \ne i} \frac{y-y_j}{y_i-y_j} \\ \ell_{j,z}(z) &= \prod_{i \ne j} \frac{z-z_i}{z_j-z_i}. \end{align} In order to compute spatial gradients and Hessians, we extend the the profile with a dummy :math:`x` dimension, i.e., :math:`P(x,y,z)=P(y,z)`, such that .. math:: \begin{align} \nabla P(x,y,z) &= \begin{bmatrix} 0, \frac{\partial P(x,y,z)}{\partial y}, \frac{\partial P(x,y,z)}{\partial z} \end{bmatrix}^{\textsf{T}}\\ H_P(x,y,z) &= \begin{bmatrix} 0 & 0 & 0 \\ 0 & \frac{\partial^2 P(x,y,z)}{\partial y^2} & \frac{\partial^2 P(x,y,z)}{\partial y \partial z} \\ 0 & \frac{\partial^2 P(x,y,z)}{\partial z \partial y} & \frac{\partial^2 P(x,y,z)}{\partial z^2} \end{bmatrix} \end{align}. Input ----- points : [num_samples, 2], tf.float Positions at which to interpolate the profile mode : int | `None` Mode of the profile to interpolate. If `None`, all modes are interpolated. Defaults to `None`. return_grads : bool If `True`, gradients and Hessians are computed. Defaults to `False`. Output ------ values : [num_modes, num_samples] or [num_samples], tf.float Interpolated profile values at the sample positions grads : [num_modes, num_samples, 3] or [num_samples, 3], tf.float Gradients of the interpolated profile values at the sample positions hessians : [num_modes, num_samples, 3, 3] or [num_samples, 3, 3] , tf.float Hessians of the interpolated profile values at the sample positions """ num_samples = tf.shape(points)[0] # Compute absolute distances in y/z directions y_dist = tf.abs(tf.expand_dims(points[:,0], axis=1) - tf.expand_dims(self.cell_y_positions, axis=0)) z_dist = tf.abs(tf.expand_dims(points[:,1], axis=1) - tf.expand_dims(self.cell_z_positions, axis=0)) # Compute indices of three closest support points y_ind = tf.sort(tf.math.top_k(-y_dist, k=3, sorted=False)[1], -1) z_ind = tf.sort(tf.math.top_k(-z_dist, k=3, sorted=False)[1], -1) # Get support points in y and z dimensions y_i = tf.gather(self.cell_y_positions, y_ind, axis=0, batch_dims=1) z_i = tf.gather(self.cell_z_positions, z_ind, axis=0, batch_dims=1) # Compute indices of all support points support_ind = tf.reshape(tf.expand_dims(z_ind, 1) + tf.expand_dims(y_ind, 2)*self.num_rows, [num_samples, -1]) # Compute support values for all modes vals = tf.transpose(self.values, perm=[2, 1, 0]) if mode is not None: # Filter relevant mode vals = tf.expand_dims(vals[...,mode], -1) num_modes = tf.shape(vals)[-1] vals = tf.reshape(vals, [-1, num_modes]) support_values = tf.gather(vals, support_ind, axis=0, batch_dims=1) support_values = tf.transpose(support_values, perm=[2,0,1]) if not return_grads: # Compute Lagrange polynomials l_y = self.lagrange_polynomials(points[:,0], y_i, False) l_z = self.lagrange_polynomials(points[:,1], z_i, False) l_z_y = tf.reshape(tf.expand_dims(l_y, axis=-1) * tf.expand_dims(l_z, axis=-2), [num_samples, -1]) # Compute interpolated values values = tf.reduce_sum(support_values*l_z_y, axis=-1) return tf.squeeze(values) # Compute Lagrange polynomials and derivatives l_y, d1_y, d2_y = self.lagrange_polynomials(points[:,0], y_i, True) l_z, d1_z, d2_z = self.lagrange_polynomials(points[:,1], z_i, True) l_z_y = tf.reshape(tf.expand_dims(l_y, axis=-1) * tf.expand_dims(l_z, axis=-2), [num_samples, -1]) # Compute interpolated values values = tf.reduce_sum(support_values*l_z_y, axis=-1) # Compute gradients l_z_d_y = tf.reshape(tf.expand_dims(d1_y, axis=-1) * tf.expand_dims(l_z, axis=-2), [num_samples, -1]) d_values_dy = tf.reduce_sum(support_values*l_z_d_y, axis=-1) l_d_z_y = tf.reshape(tf.expand_dims(l_y, axis=-1) * tf.expand_dims(d1_z, axis=-2), [num_samples, -1]) d_values_dz = tf.reduce_sum(support_values*l_d_z_y, axis=-1) grads = tf.stack([tf.zeros_like(d_values_dy), d_values_dy, d_values_dz ], -1) # Compute Hessians # 1: Compute 2nd-order partial derivatives l_z_d2_y = tf.reshape(tf.expand_dims(d2_y, axis=-1) * tf.expand_dims(l_z, axis=-2), [num_samples, -1]) d2_values_d2_y = tf.reduce_sum(support_values*l_z_d2_y, axis=-1) l_d2_z_y = tf.reshape(tf.expand_dims(l_y, axis=-1) * tf.expand_dims(d2_z, axis=-2), [num_samples, -1]) d2_values_d2_z = tf.reduce_sum(support_values*l_d2_z_y, axis=-1) l_d_z_d_y = tf.reshape(tf.expand_dims(d1_y, axis=-1) * tf.expand_dims(d1_z, axis=-2), [num_samples, -1]) d2_values_d_y_d_z = tf.reduce_sum(support_values*l_d_z_d_y, axis=-1) # 2: Construct rows of the Hessians row_2 = tf.stack([tf.zeros_like(d2_values_d2_y), d2_values_d2_y, d2_values_d_y_d_z], -1) row_3 = tf.stack([tf.zeros_like(d2_values_d2_z), d2_values_d_y_d_z, d2_values_d2_z], -1) row_1 = tf.zeros_like(row_2) # 3: Combine rows full Hessian matrices hessians = tf.stack([row_1, row_2, row_3], axis=2) return (values, grads, hessians)
[docs] class DiscreteAmplitudeProfile(DiscreteProfile, AmplitudeProfile): # pylint: disable=line-too-long r"""Class defining a discrete amplitude profile of a RIS A discrete amplitude profile :math:`A_m` assigns to each of its units cells a possibly different amplitude value. Multiple reradiation modes can be obtained by super-positioning of profiles. The relative power of reradiation modes can be controlled via the reradiation coefficients :math:`p_m`. See :ref:`ris_primer` for more details. A class instance is a callable that returns the profile values, gradients and Hessians at given points. Parameters ---------- cell_grid : :class:`~sionna.rt.CellGrid` Defines the physical structure of the RIS num_modes : int Number of reradiation modes. Defaults to 1. values : tf.float or tf.Variable, [num_modes, num_rows, num_cols] Amplitude values for each reradiation mode and unit cell. `num_rows` and `num_cols` are defined by the `cell_grid`. Defaults to `None`. mode_powers : tf.float, [num_modes] Relative powers or reradition coefficients of reradiation modes. Defaults to `None`. In this case, all reradiation modes get an equal fraction of the total power. interpolator : :class:`~sionna.rt.ProfileInterpolator` Determines how the discrete values of the profile are interpolated to a continuous profile which is defined at any point on the RIS. Defaults to `None`. In this case, the :class:`~sionna.rt.LagrangeProfileInterpolator` will be used. dtype : tf.complex Datatype to be used in internal calculations. Defaults to `tf.complex64`. Input ----- points : tf.float, [num_samples, 2] Tensor of 2D coordinates defining the points on the RIS at which the profile should be evaluated. Defaults to `None`. In this case, the values for all unit cells are returned. mode : int | `None` Reradiation mode to be considered. Defaults to `None`. In this case, the values for all modes are returned. return_grads : bool If `True`, also the first- and second-order derivatives are returned. Defaults to `False`. Output ------ values : [num_modes, num_samples] or [num_samples], tf.float Interpolated profile values at the sample positions grads : [num_modes, num_samples, 3] or [num_samples, 3], tf.float Gradients of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. hessians : [num_modes, num_samples, 3, 3] or [num_samples, 3, 3] , tf.float Hessians of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. """ def __init__(self, cell_grid, num_modes=1, values=None, mode_powers=None, interpolator=None, dtype=tf.complex64): super().__init__(cell_grid=cell_grid, num_modes=num_modes, values=values, interpolator=interpolator, dtype=dtype) if values is None: self.values = tf.ones(self.shape, self._rdtype) if mode_powers is None: mode_powers = 1/tf.cast(self.num_modes, self._rdtype) * \ tf.ones([self.num_modes], dtype=self._rdtype) self.mode_powers = mode_powers @property def mode_powers(self): return self._mode_powers @mode_powers.setter def mode_powers(self, v): if isinstance(v, tf.Variable): if v.dtype != self._rdtype: msg = f"`mode_powers` must have dtype={self._rdtype}" raise TypeError(msg) else: v = tf.cast(v, dtype=self._rdtype) if not v.shape==[self.num_modes]: msg = f"`mode_powers` must have shape [{self.num_modes}]" raise ValueError(msg) self._mode_powers = v
[docs] class DiscretePhaseProfile(DiscreteProfile, PhaseProfile): # pylint: disable=line-too-long r"""Class defining a discrete phase profile of a RIS A discrete phase profile :math:`\chi_m` assigns to each of its units cells a possibly different phase value. Multiple reradiation modes can be created by super-positioning of phase profiles. See :ref:`ris_primer` in the Primer on Electromagnetics for more details. A class instance is a callable that returns the profile values, gradients and Hessians at given points. Parameters ---------- cell_grid : :class:`~sionna.rt.CellGrid` Defines the physical structure of the RIS num_modes : int Number of reradiation modes. Defaults to 1. values : tf.float or tf.Variable, [num_modes, num_rows, num_cols] Phase values [rad] for each reradiation mode and unit cell. `num_rows` and `num_cols` are defined by the `cell_grid`. Defaults to `None`. interpolator : :class:`~sionna.rt.ProfileInterpolator` Determines how the discrete values of the profile are interpolated to a continuous profile which is defined at any point on the RIS. Defaults to `None`. In this case, the :class:`~sionna.rt.LagrangeProfileInterpolator` will be used. dtype : tf.complex Datatype to be used in internal calculations. Defaults to `tf.complex64`. Input ----- points : tf.float, [num_samples, 2] Tensor of 2D coordinates defining the points on the RIS at which the profile should be evaluated. Defaults to `None`. In this case, the values for all unit cells are returned. mode : int | `None` Reradiation mode to be considered. Defaults to `None`. In this case, the values for all modes are returned. return_grads : bool If `True`, also the first- and second-order derivatives are returned. Defaults to `False`. Output ------ values : [num_modes, num_samples] or [num_samples], tf.float Interpolated profile values at the sample positions grads : [num_modes, num_samples, 3] or [num_samples, 3], tf.float Gradients of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. hessians : [num_modes, num_samples, 3, 3] or [num_samples, 3, 3] , tf.float Hessians of the interpolated profile values at the sample positions. Only returned if `return_grads` is `True`. """ def __init__(self, cell_grid, num_modes=1, values=None, interpolator=None, dtype=tf.complex64): super().__init__(cell_grid=cell_grid, num_modes=num_modes, values=values, interpolator=interpolator, dtype=dtype) if values is None: self.values = tf.zeros(self.shape, self._rdtype)
[docs] class RIS(RadioDevice, SceneObject): # pylint: disable=line-too-long r""" Class defining a reconfigurable intelligent surface (RIS) A RIS consists of a planar arrangement of unit cells with :math:`\lambda/2` spacing. It's :class:`~sionna.rt.PhaseProfile` :math:`\chi_m` and :class:`~sionna.rt.AmplitudeProfile` :math:`A_m` can be configured after the RIS is instantiated. Both together define the spatial modulation coefficient :math:`\Gamma` which determines how the RIS reflects electro-magnetic waves. See :ref:`ris_primer` in the Primer on Electromagnetics for more details or have a look at the `tutorial notebook <https://nvlabs.github.io/sionna/examples/Sionna_Ray_Tracing_RIS.html>`_. An RIS instance is a callable that computes the spatial modulation coefficient and gradients/Hessians of the underlying phase profile for provided points on the RIS' surface. Parameters ---------- name : str Name position : [3], float Position :math:`(x,y,z)` as three-dimensional vector num_rows : int Number of rows. Must at least be equal to three. num_cols : int Number of columns. Must at least be equal to three. num_modes : int Number of reradiation modes. Defaults to 1. orientation : [3], float Orientation :math:`(\alpha, \beta, \gamma)` specified through three angles corresponding to a 3D rotation as defined in :eq:`rotation`. This parameter is ignored if ``look_at`` is not `None`. Defaults to [0,0,0]. In this case, the normal vector of the RIS points towards the positive x-axis. velocity : [3], float Velocity vector [m/s]. Used for the computation of path-specific Doppler shifts. look_at : [3], float | :class:`~sionna.rt.Transmitter` | :class:`~sionna.rt.Receiver` | :class:`~sionna.rt.RIS` | :class:`~sionna.rt.Camera` | `None` A position or the instance of a :class:`~sionna.rt.Transmitter`, :class:`~sionna.rt.Receiver`, :class:`~sionna.rt.RIS`, or :class:`~sionna.rt.Camera` to look at. If set to `None`, then ``orientation`` is used to orientate the device. color : [3], float Defines the RGB (red, green, blue) ``color`` parameter for the device as displayed in the previewer and renderer. Each RGB component must have a value within the range :math:`\in [0,1]`. Defaults to `[0.862,0.078,0.235]`. dtype : tf.complex Datatype to be used in internal calculations. Defaults to `tf.complex64`. Input ----- points : tf.float, [num_samples, 2] Tensor of 2D coordinates defining the points on the RIS at which the spatial modulation profile should be evaluated. Defaults to `None`. In this case, the values for all unit cells are returned. mode : int | `None` Reradiation mode to be considered. Defaults to `None`. In this case, the values for all modes are returned. return_grads : bool If `True`, also the first- and second-order derivatives are returned. Defaults to `False`. Output ------ gamma : [num_modes, num_samples] or [num_samples], tf.complex Spatial modulation coefficient at the sample positions grads : [num_modes, num_samples, 3] or [num_samples, 3], tf.float Gradients of the interpolated phase profile values at the sample positions. Only returned if `return_grads` is `True`. hessians : [num_modes, num_samples, 3, 3] or [num_samples, 3, 3] , tf.float Hessians of the interpolated phase profile values at the sample positions. Only returned if `return_grads` is `True`. """ def __init__(self, name, position, num_rows, num_cols, num_modes=1, orientation=(0.,0.,0.), velocity=(0.,0.,0.), look_at=None, color=(0.862,0.078,0.235), dtype=tf.complex64): # Initialize the parent classes # RadioDevice and SceneObject inherit from Object # Python will initialize in the following order: # RadioDevice->SceneObject->Object super().__init__(name=name, position=position, orientation=orientation, look_at=look_at, radio_material=None, color=color, dtype=dtype) # Set velocity vector self.velocity = tf.cast(velocity, dtype=dtype.real_dtype) if num_rows < 3 or num_cols < 3: raise ValueError("num_rows and num_cols must be >= 3") # Set immutable properties self._num_modes = int(num_modes) self._cell_grid = CellGrid(num_rows, num_cols, self._dtype) # Init amplitude profile self.amplitude_profile = DiscreteAmplitudeProfile(self.cell_grid, num_modes=self.num_modes, dtype=self._dtype) # Init phase profile self.phase_profile = DiscretePhaseProfile(self.cell_grid, num_modes=self.num_modes, dtype=self._dtype) @property def cell_grid(self): r""" :class:`~sionna.rt.CellGrid` : Defines the physical structure of the RIS """ return self._cell_grid @property def cell_positions(self): r""" [num_cells, 2], tf.float : Cell positions in the local coordinate system (LCS) of the RIS, ordered from top-to-bottom left-to-right. """ return self.cell_grid.cell_positions*self.spacing @property def cell_world_positions(self): r""" [num_cells, 3], tf.float : Cell positions in the global coordinate system (GCS) of the RIS, ordered from top-to-bottom left-to-right. """ x_coord = tf.zeros([self.num_cells, 1], self._rdtype) pos = tf.concat([x_coord, self.cell_positions], axis=-1) pos = rotate(pos, self.orientation) pos += tf.expand_dims(self.position, 0) return pos @property def world_normal(self): r""" [3], tf.float : Normal vector of the RIS in the global coordinate system (GCS) """ n_hat = tf.constant([1,0,0], self._rdtype) return rotate(n_hat, self.orientation) @property def num_rows(self): r""" int : Number of rows """ return self.cell_grid.num_rows @property def num_cols(self): r""" int : Number of columns """ return self.cell_grid.num_cols @property def num_cells(self): r""" int : Number of cells """ return self.num_rows*self.num_cols @property def num_modes(self): r""" int : Number of reradiation modes """ return self._num_modes @property def spacing(self): r""" tf.float: Element spacing [m] corresponding to half a wavelength """ if hasattr(scene.Scene(), "wavelength"): wavelength = tf.cast(scene.Scene().wavelength, self._rdtype) return wavelength/tf.cast(2, self._rdtype) else: # Scene is not initialized return tf.cast(0.5, self._rdtype) @property def size(self): """ [2], tf.float : Size of the RIS (width, height) [m] """ return tf.stack([self.spacing * self.num_cols, self.spacing * self.num_rows], axis=0) @property def velocity(self): """ [3], tf.float : Get/set the velocity vector [m/s] """ return self._velocity @velocity.setter def velocity(self, v): if not tf.shape(v)==3: raise ValueError("`velocity` must have shape [3]") self._velocity = tf.cast(v, self._dtype.real_dtype) @property def amplitude_profile(self): r""" :class:`~sionna.rt.AmplitudeProfile` : Set/get amplitude profile """ return self._amplitude_profile @amplitude_profile.setter def amplitude_profile(self, v): if not isinstance(v, AmplitudeProfile): raise ValueError("Not a valid AmplitudeProfile") self._amplitude_profile = v @property def phase_profile(self): r""" :class:`~sionna.rt.PhaseProfile` : Set/get phase profile """ return self._phase_profile @phase_profile.setter def phase_profile(self, v): if not isinstance(v, PhaseProfile): raise ValueError("Not a valid PhaseProfile") self._phase_profile = v
[docs] def phase_gradient_reflector(self, sources, targets): # pylint: disable=line-too-long r""" Configures the RIS as ideal phase gradient reflector For an incoming direction :math:`\hat{\mathbf{k}}_i` and desired outgoing direction :math:`\hat{\mathbf{k}}_r`, the necessary phase gradient along the RIS with normal :math:`\hat{\mathbf{n}}` can be computed as (e.g., Eq.(12) [Vitucci24]_): .. math:: \nabla\chi_m = k_0\left( \mathbf{I}- \hat{\mathbf{n}}\hat{\mathbf{n}}^\textsf{T} \right) \left(\hat{\mathbf{k}}_i - \hat{\mathbf{k}}_r \right). The phase profile is obtained by assigning zero phase to the first unit cell and evolving the other phases linearly according to the gradient across the entire RIS. Multiple reradiation modes can be configured. The amplitude profile is set to one everywhere with a uniform relative power allocation across modes. Input ----- sources : tf.float, [3] or [num_modes, 3] Tensor defining for every reradiation mode a source from which the incoming wave originates. targets : tf.float, [3] or [num_modes, 3] Tensor defining for every reradiation mode a target towards which the incoming wave should be reflected. """ # Convert inputs to tensors sources = tf.cast(sources, self._rdtype) targets = tf.cast(targets, self._rdtype) sources = expand_to_rank(sources, 2, 0) targets = expand_to_rank(targets, 2, 0) shape = [self.num_modes, 3] # Ensure the desired shape [num_modes, 3] for i, x in enumerate([sources, targets]): if not (tf.shape(x)==shape).numpy().all(): msg = f"Wrong shape of input {i+1}. " + \ f"Expected {shape}, got {x.shape}" raise ValueError(msg) # Compute incoming and outgoing directions # [num_modes, 3] k_i, _ = normalize(self.position[tf.newaxis] - sources) k_r, _ = normalize(targets - self.position[tf.newaxis]) # Tangent projection operator - Eq.(10) # [1, 3] normal = self.world_normal[tf.newaxis] # [1, 3, 3] p = tf.eye(3, dtype=self._rdtype) - outer(normal,normal) # Compute phase gradient - Eq.(12) # [num_modes, 3] grad = self.scene.wavenumber * tf.linalg.matvec(p, k_i-k_r) # Rotate phase gradient to LCS of the RIS and keep y/z components # [num_modes, 1, 1, 2] grad = rotate(grad, self.orientation, inverse=True)[:,1:] grad = tf.reshape(grad, [self.num_modes, 1, 1, 2]) # Using the top-left cell as reference, compute the offsets # [1, num_rows, num_cols, 2] offsets = self.cell_positions - self.cell_positions[:1] offsets = tf.reshape(offsets, [self.num_cols, self.num_rows, 2]) offsets = tf.transpose(offsets, perm=[1,0,2]) offsets = tf.expand_dims(offsets, 0) # Compute phase profile based on the constant gradient assumption # [num_modes, num_rows, num_cols] phases = tf.reduce_sum(offsets*grad, axis=-1) self.phase_profile.values = phases # Set a neutral amplitude profile self.amplitude_profile.values = tf.ones_like(phases) mode_powers = 1/tf.cast(self.num_modes, self._rdtype) * \ tf.ones([self.num_modes], dtype=self._rdtype) self.amplitude_profile.mode_powers = mode_powers
[docs] def focusing_lens(self, sources, targets): # pylint: disable=line-too-long r""" Configures the RIS as focusing lens The phase profile is configured in such a way that the fields of all rays add up coherently at a specific point. In other words, the phase profile undoes the distance-based phase shift of every ray connecting a source to a target via a specific unit cell. For a source and target at positions :math:`\mathbf{s}` and :math:`\mathbf{t}`, the phase :math:`\chi_m(\mathbf{x})` of a unit cell located at :math:`\mathbf{x}` is computed as (e.g., Sec. IV-2 [Degli-Esposti22]_) .. math:: \chi_m(\mathbf{x}) = k_0 \left(\lVert\mathbf{s}-\mathbf{x}\rVert + \lVert\mathbf{s}-\mathbf{t}\rVert\right). Multiple reradiation modes can be configured. The amplitude profile is set to one everywhere with a uniform relative power allocation across modes. Input ----- sources : tf.float, [3] or [num_modes, 3] Tensor defining for every reradiation mode a source from which the incoming wave originates. targets : tf.float, [3] or [num_modes, 3] Tensor defining for every reradiation mode a target towards which the incoming wave should be reflected. """ # Convert inputs to tensors sources = tf.cast(sources, self._rdtype) targets = tf.cast(targets, self._rdtype) sources = expand_to_rank(sources, 2, 0) targets = expand_to_rank(targets, 2, 0) shape = [self.num_modes, 3] # Ensure the desired shape [num_modes, 3] for i, x in enumerate([sources, targets]): if not (tf.shape(x)==shape).numpy().all(): msg = f"Wrong shape of input {i+1}. " + \ f"Expected {shape}, got {x.shape}" raise ValueError(msg) # Compute incoming and outgoing distances # [num_modes, num_cells] d_i = normalize(self.cell_world_positions[tf.newaxis] - sources[:,tf.newaxis])[1] d_o = normalize(self.cell_world_positions[tf.newaxis] - targets[:,tf.newaxis])[1] # Compute phases such that the total phase shifts for all cells # are equal phases = self.scene.wavenumber * (d_i+d_o) phases = tf.reshape(phases, [self.num_modes, self.num_cols, self.num_rows]) phases = tf.transpose(phases, perm=[0,2,1]) self.phase_profile.values = phases # Set a neutral amplitude profile self.amplitude_profile.values = tf.ones_like(phases) mode_powers = 1/tf.cast(self.num_modes, self._rdtype) * \ tf.ones([self.num_modes], dtype=self._rdtype) self.amplitude_profile.mode_powers = mode_powers
def __call__(self, points=None, mode=None, return_grads=False): # pylint: disable=line-too-long r""" Computes the spatial modulation coefficient and gradients/Hessians of phase profile Input ----- points : tf.float, [num_samples, 2] Tensor of 2D coordinates defining the points on the RIS at which the spatial modulation profile should be evaluated. Defaults to `None`. In this case, the values for all unit cells are returned. mode : int | `None` Reradiation mode to be considered. Defaults to `None`. In this case, the values for all modes are returned. return_grads : bool If `True`, also the first- and second-order derivatives are returned. Defaults to `False`. Output ------ gamma : [num_modes, num_samples] or [num_samples], tf.complex Spatial modulation coefficient at the sample positions grads : [num_modes, num_samples, 3] or [num_samples, 3], tf.float Gradients of the interpolated phase profile values at the sample positions. Only returned if `return_grads` is `True`. hessians : [num_modes, num_samples, 3, 3] or [num_samples, 3, 3] , tf.float Hessians of the interpolated phase profile values at the sample positions. Only returned if `return_grads` is `True`. """ # Get amplitudes a = self.amplitude_profile(points, mode) # Get mode powers p = self.amplitude_profile.mode_powers # Get phases and (optionally) phase gradients and Hessians if return_grads and points is not None: chi, grads, hessians = self.phase_profile(points, mode, True) else: chi = self.phase_profile(points, mode, False) # Compute spatial modulation coefficient zero = tf.cast(0, self._rdtype) gamma = tf.complex(a, zero) chi = tf.complex(zero, chi) p = tf.complex(tf.sqrt(p), zero) gamma *= tf.exp(chi) if mode is None: gamma*= tf.reshape(p, [-1, 1]) else: gamma *= p[mode] if return_grads and points is not None: return gamma, grads, hessians else: return gamma