#
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
"""Utility functions for integrated sensing and communication."""
import math
from typing import Optional, Union
import torch
from sionna._validation import check_one_of, check_tensor_all
from sionna.phy.config import Precision, config, dtypes
from sionna.phy.constants import PI
__all__ = [
"steering_vectors",
"angular_delay_doppler_spectrum",
]
[docs]
def steering_vectors(
positions: torch.Tensor,
theta: Union[float, torch.Tensor],
phi: Union[float, torch.Tensor],
wavelength: Union[float, torch.Tensor],
*,
mode: str = "cartesian",
precision: Optional[Precision] = None,
) -> torch.Tensor:
r"""Generate normalized steering vectors.
For an array with :math:`M` antennas at positions
:math:`\mathbf{d}_m\in\mathbb{R}^3`, this function computes the normalized
steering vector
:math:`\mathbf{a}(\theta,\varphi)\in\mathbb{C}^M` with elements
.. math::
a_m(\theta,\varphi)
= \frac{1}{\sqrt{M}}
\exp\left(
j\frac{2\pi}{\lambda}
\mathbf{d}_m^{\mathsf{T}}
\widehat{\mathbf{r}}(\theta,\varphi)
\right),
\quad m=1,\dots,M,
where :math:`\lambda` is the carrier wavelength and
.. math::
\widehat{\mathbf{r}}(\theta,\varphi)
=
\begin{bmatrix}
\sin(\theta)\cos(\varphi)\\
\sin(\theta)\sin(\varphi)\\
\cos(\theta)
\end{bmatrix}.
This is the array response used by the Sionna channel models, normalized
to unit norm. Beamforming therefore conjugates it, as in
:func:`angular_delay_doppler_spectrum`, and the matched transmit precoder
for direction :math:`(\theta,\varphi)` is
:math:`\mathbf{a}(\theta,\varphi)^*`.
:param positions: Antenna positions :math:`\mathbf{d}_m=(x,y,z)` in
meters, shape [num_ant, 3].
:param theta: Zenith angles in radians. Values must lie in
:math:`[0,\pi]`.
:param phi: Azimuth angles in radians. Values conventionally lie in
:math:`[-\pi,\pi]`, but any finite value is accepted because the
steering vector is :math:`2\pi`-periodic in :math:`\varphi`.
:param wavelength: Scalar carrier wavelength in meters.
:param mode: If ``"cartesian"``, both inputs must be scalar or
one-dimensional and all combinations are generated. If ``"paired"``,
``theta`` and ``phi`` are broadcast and interpreted as angle pairs.
Defaults to ``"cartesian"``.
:param precision: Precision used for internal calculations and outputs.
If set to `None`, :attr:`~sionna.phy.config.Config.precision` is used.
:output a: [..., num_ant], `torch.complex`. Unit-norm steering
vectors. For Cartesian mode, the shape is always
[num_theta, num_phi, num_ant], where scalar angles contribute an axis
of length one. For paired mode, the shape is the broadcast shape of
``theta`` and ``phi`` followed by [num_ant].
.. rubric:: Notes
This function only models the phase shifts caused by antenna positions.
Antenna patterns and polarization-dependent gains are not included.
Co-located polarization components therefore receive identical phases.
.. rubric:: Examples
.. code-block:: python
import torch
from sionna.phy.isac import steering_vectors
positions = torch.tensor([[0., -0.025, 0.],
[0., 0.025, 0.]])
# By default, all combinations of the two angles are generated
theta = torch.tensor([torch.pi/3, torch.pi/2])
phi = torch.tensor([0., torch.pi/4, torch.pi/2])
w = steering_vectors(positions, theta, phi, wavelength=0.1)
# w.shape = torch.Size([2, 3, 2]) # [num_theta, num_phi, num_ant]
# Paired mode instead broadcasts the angles into direction pairs
w = steering_vectors(positions, theta, phi[:2], wavelength=0.1,
mode="paired")
# w.shape = torch.Size([2, 2]) # [num_directions, num_ant]
"""
if not isinstance(positions, torch.Tensor):
raise TypeError("`positions` must be a torch.Tensor.")
if positions.dim() != 2 or positions.shape[-1] != 3:
raise ValueError("`positions` must have shape [num_ant, 3].")
if positions.shape[0] == 0:
raise ValueError("`positions` must contain at least one antenna.")
if positions.is_complex():
raise TypeError("`positions` must be real-valued.")
for value, name in ((theta, "theta"), (phi, "phi"),
(wavelength, "wavelength")):
if isinstance(value, torch.Tensor) and value.is_complex():
raise TypeError(f"`{name}` must be real-valued.")
check_one_of(mode, ("paired", "cartesian"), name="mode")
if precision is None:
rdtype = config.dtype
else:
rdtype = dtypes[precision]["torch"]["dtype"]
device = positions.device
positions = positions.to(dtype=rdtype)
# Validate in the input dtype so a rounded pi endpoint remains valid
# when the requested computation precision is higher.
theta_dtype = theta.dtype if isinstance(theta, torch.Tensor) else rdtype
theta = torch.as_tensor(theta, dtype=theta_dtype, device=device)
phi = torch.as_tensor(phi, dtype=rdtype, device=device)
wavelength = torch.as_tensor(wavelength, dtype=rdtype, device=device)
if theta.numel() == 0 or phi.numel() == 0:
raise ValueError("`theta` and `phi` must not be empty.")
if wavelength.dim() != 0:
raise ValueError("`wavelength` must be scalar.")
check_tensor_all(
torch.isfinite(wavelength) & (wavelength > 0),
name="wavelength",
message="`wavelength` must be finite and strictly positive.",
)
check_tensor_all(
torch.isfinite(theta) & (theta >= 0) & (theta <= PI),
name="theta",
message="`theta` must contain finite values in [0, pi].",
)
theta = theta.to(dtype=rdtype)
check_tensor_all(
torch.isfinite(phi),
name="phi",
message="`phi` must contain only finite values.",
)
if mode == "paired":
try:
theta, phi = torch.broadcast_tensors(theta, phi)
except RuntimeError as err:
raise ValueError(
"`theta` and `phi` must have broadcast-compatible shapes in "
"paired mode."
) from err
else:
if theta.dim() > 1 or phi.dim() > 1:
raise ValueError(
"`theta` and `phi` must be scalar or one-dimensional in "
"cartesian mode."
)
theta = theta.reshape(-1, 1)
phi = phi.reshape(1, -1)
direction = torch.stack(
(
torch.sin(theta) * torch.cos(phi),
torch.sin(theta) * torch.sin(phi),
torch.cos(theta) * torch.ones_like(phi),
),
dim=-1,
)
phase = (
2 * PI / wavelength
* torch.einsum("...c,mc->...m", direction, positions)
)
a = torch.exp(torch.complex(torch.zeros_like(phase), phase))
a = a / torch.sqrt(
torch.as_tensor(positions.shape[0], dtype=rdtype, device=device)
)
return a
[docs]
def angular_delay_doppler_spectrum(
h_dd: torch.Tensor,
rx_steering_vectors: torch.Tensor,
tx_steering_vectors: torch.Tensor,
*,
mode: str = "paired",
) -> torch.Tensor:
r"""Compute a Bartlett-type angular delay-Doppler spectrum.
Let
:math:`\mathbf{H}_{b,r,t,q,\ell}\in\mathbb{C}^{M_\text{R}\times
M_\text{T}}` denote the MIMO channel at Doppler bin :math:`q` and delay
bin :math:`\ell` for transmitter :math:`t`, receiver :math:`r`, and
arbitrary batch index :math:`b`. For receive and transmit steering vectors
:math:`\mathbf{a}_{\text{R},r,i}` and
:math:`\mathbf{a}_{\text{T},t,j}`, the Cartesian spectrum is
.. math::
P_{b,r,i,t,j,q,\ell}
=
\left|
\mathbf{a}_{\text{R},r,i}^{\mathsf{H}}
\mathbf{H}_{b,r,t,q,\ell}
\mathbf{a}_{\text{T},t,j}^*
\right|^2.
Both array responses enter conjugated, which is most apparent in index
notation,
.. math::
P
=
\left|
\sum_{m=1}^{M_\text{R}}\sum_{n=1}^{M_\text{T}}
a_{\text{R},m}^*\,H_{mn}\,a_{\text{T},n}^*
\right|^2.
The Hermitian transpose above merely reflects that
:math:`\mathbf{a}_\text{R}` is contracted from the left, where a row
vector is required, whereas :math:`\mathbf{a}_\text{T}` must stay a
column.
This conjugation pattern differs from the classical Bartlett spectrum
:math:`\mathbf{a}^{\mathsf{H}}\mathbf{R}\mathbf{a}`, which is defined on a
covariance matrix and therefore already carries a conjugation in its own
outer product. A propagation channel does not: a target contributes
:math:`\mathbf{H}=\beta\mathbf{a}_\text{R}\mathbf{a}_\text{T}^{\mathsf{T}}`
with a plain transpose, so both scan vectors must be conjugated for the
phases to cancel. Scanning that target then gives
:math:`\beta\|\mathbf{a}_\text{R}\|^2\|\mathbf{a}_\text{T}\|^2` instead of
a sum of squared phasors.
:param h_dd: MIMO delay-Doppler channel, shape
[..., num_rx, num_rx_ant, num_tx, num_tx_ant, num_doppler_bins,
num_delay_bins]. The last dimension corresponds to the time lags
returned by
:func:`~sionna.phy.channel.ofdm_to_delay_doppler_channel`.
:param rx_steering_vectors: Shared receive steering vectors with shape
[num_rx_directions, num_rx_ant], or receiver-specific vectors with
shape [num_rx, num_rx_directions, num_rx_ant]. Array responses as
returned by :func:`steering_vectors`; they are conjugated internally.
:param tx_steering_vectors: Shared transmit steering vectors with shape
[num_tx_directions, num_tx_ant], or transmitter-specific vectors with
shape [num_tx, num_tx_directions, num_tx_ant]. Array responses as
returned by :func:`steering_vectors`; they are conjugated internally.
:param mode: If ``"paired"``, direction indices are paired. Their counts
must then match or one count must be one. If ``"cartesian"``, all
receive and transmit direction combinations are evaluated. Defaults to
``"paired"``.
:output spectrum: `torch.float`. Linear power spectrum. Paired mode
returns [..., num_rx, num_tx, num_direction_pairs, num_doppler_bins,
num_delay_bins]. Cartesian mode returns
[..., num_rx, num_rx_directions, num_tx, num_tx_directions,
num_doppler_bins, num_delay_bins].
.. rubric:: Notes
The output is an angular delay-Doppler spectrum. Converting delay to range
depends on the sensing geometry. For a monostatic system,
:math:`R=c\tau/2`; for a bistatic system, delay represents the total
transmitter-target-receiver path length divided by :math:`c`.
For the unit-norm steering vectors returned by :func:`steering_vectors`,
no spectrum value exceeds the channel energy
:math:`\|\mathbf{H}_{b,r,t,q,\ell}\|_\text{F}^2`. If the receive and
transmit steering vectors each form an orthonormal basis, the spectrum
summed over all direction pairs equals this energy. For single-antenna
devices, the spectrum reduces to :math:`|H_{b,r,t,q,\ell}|^2`.
The channel and both steering banks are promoted to their common
:func:`torch.promote_types` data type, so the output precision follows the
widest input rather than that of ``h_dd``.
Cartesian mode carries a separate axis for the receive and transmit
directions and therefore scales with their product. Scanning the same
direction at both ends, as in a monostatic system, is much cheaper in
paired mode.
.. rubric:: Examples
.. code-block:: python
import torch
from sionna.phy.isac import (angular_delay_doppler_spectrum,
steering_vectors)
# Half-wavelength uniform linear array with four antennas
wavelength = 0.1
positions = torch.zeros(4, 3)
positions[:, 1] = torch.arange(4)*wavelength/2
# Scan 25 azimuth directions in the horizontal plane, then flatten
# the grid into a list of directions
theta = torch.tensor([torch.pi/2])
phi = torch.deg2rad(torch.linspace(-60., 60., 25))
steering = steering_vectors(positions, theta, phi, wavelength)
steering = steering.reshape(-1, positions.shape[0])
# Delay-Doppler channel as returned by
# sionna.phy.channel.ofdm_to_delay_doppler_channel, with shape
# [batch, num_rx, num_rx_ant, num_tx, num_tx_ant, num_doppler_bins,
# num_delay_bins]
h_dd = torch.randn(1, 1, 4, 1, 4, 32, 64, dtype=torch.complex64)
# Monostatic scan: pair each RX direction with the same TX direction
spectrum = angular_delay_doppler_spectrum(h_dd, steering, steering)
print(spectrum.shape)
# torch.Size([1, 1, 1, 25, 32, 64])
# Cartesian mode evaluates all RX-TX direction combinations instead
spectrum = angular_delay_doppler_spectrum(h_dd, steering, steering,
mode="cartesian")
print(spectrum.shape)
# torch.Size([1, 1, 25, 1, 25, 32, 64])
"""
if not isinstance(h_dd, torch.Tensor):
raise TypeError("`h_dd` must be a torch.Tensor.")
if h_dd.dim() < 6:
raise ValueError(
"`h_dd` must have shape [..., num_rx, num_rx_ant, num_tx, "
"num_tx_ant, num_doppler_bins, num_delay_bins]."
)
if not h_dd.is_complex():
raise TypeError("`h_dd` must be complex-valued.")
if not isinstance(rx_steering_vectors, torch.Tensor):
raise TypeError("`rx_steering_vectors` must be a torch.Tensor.")
if not isinstance(tx_steering_vectors, torch.Tensor):
raise TypeError("`tx_steering_vectors` must be a torch.Tensor.")
check_one_of(mode, ("paired", "cartesian"), name="mode")
num_rx = h_dd.shape[-6]
num_rx_ant = h_dd.shape[-5]
num_tx = h_dd.shape[-4]
num_tx_ant = h_dd.shape[-3]
# `einsum` requires matching data types, so promote to the widest input
# rather than casting the steering banks down to the channel data type
dtype = torch.promote_types(h_dd.dtype, rx_steering_vectors.dtype)
dtype = torch.promote_types(dtype, tx_steering_vectors.dtype)
h_dd = h_dd.to(dtype=dtype)
rx_steering_vectors = _prepare_steering_bank(
rx_steering_vectors,
num_devices=num_rx,
num_ant=num_rx_ant,
name="rx_steering_vectors",
dtype=dtype,
device=h_dd.device,
)
tx_steering_vectors = _prepare_steering_bank(
tx_steering_vectors,
num_devices=num_tx,
num_ant=num_tx_ant,
name="tx_steering_vectors",
dtype=dtype,
device=h_dd.device,
)
# Both banks hold array responses, so beamforming conjugates them
rx_steering_vectors = rx_steering_vectors.conj()
tx_steering_vectors = tx_steering_vectors.conj()
if mode == "cartesian":
beamformed = torch.einsum(
"rim,...rmtnql,tjn->...ritjql",
rx_steering_vectors,
h_dd,
tx_steering_vectors,
)
else:
num_rx_directions = rx_steering_vectors.shape[1]
num_tx_directions = tx_steering_vectors.shape[1]
if (
num_rx_directions != num_tx_directions
and num_rx_directions != 1
and num_tx_directions != 1
):
raise ValueError(
"In paired mode, the RX and TX direction counts must match "
"or one of them must be one."
)
num_directions = max(num_rx_directions, num_tx_directions)
rx_steering_vectors = rx_steering_vectors.expand(
num_rx, num_directions, num_rx_ant
)
tx_steering_vectors = tx_steering_vectors.expand(
num_tx, num_directions, num_tx_ant
)
# Choose the smallest intermediate explicitly. Its common receiver,
# transmitter, and direction axes leave only the antenna counts and
# the number of delay-Doppler bins (including batches) to compare.
num_bins = math.prod(h_dd.shape[:-6]) * math.prod(h_dd.shape[-2:])
if num_bins >= max(num_rx_ant, num_tx_ant):
paired_vectors = torch.einsum(
"rdm,tdn->rtdmn", rx_steering_vectors, tx_steering_vectors
)
beamformed = torch.einsum(
"rtdmn,...rmtnql->...rtdql", paired_vectors, h_dd
)
elif num_rx_ant >= num_tx_ant:
projected = torch.einsum(
"rdm,...rmtnql->...rtdnql", rx_steering_vectors, h_dd
)
beamformed = torch.einsum(
"...rtdnql,tdn->...rtdql", projected, tx_steering_vectors
)
else:
projected = torch.einsum(
"...rmtnql,tdn->...rtdmql", h_dd, tx_steering_vectors
)
beamformed = torch.einsum(
"rdm,...rtdmql->...rtdql", rx_steering_vectors, projected
)
return beamformed.abs().square()
def _prepare_steering_bank(
vectors: torch.Tensor,
*,
num_devices: int,
num_ant: int,
name: str,
dtype: torch.dtype,
device: torch.device,
) -> torch.Tensor:
"""Validate and expand a shared or device-specific steering bank."""
if vectors.dim() == 2:
if vectors.shape[-1] != num_ant:
raise ValueError(
f"`{name}` must have {num_ant} antenna coefficients."
)
vectors = vectors.unsqueeze(0).expand(num_devices, -1, -1)
elif vectors.dim() == 3:
if vectors.shape[0] != num_devices or vectors.shape[-1] != num_ant:
raise ValueError(
f"`{name}` must have shape [{num_devices}, "
f"num_directions, {num_ant}]."
)
else:
raise ValueError(
f"`{name}` must have shape [num_directions, num_ant] or "
f"[num_devices, num_directions, num_ant]."
)
if vectors.shape[1] == 0:
raise ValueError(f"`{name}` must contain at least one direction.")
return vectors.to(dtype=dtype, device=device)