Source code for sionna.phy.channel.tr38901.antenna

#
# SPDX-FileCopyrightText: Copyright (c) 2021-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
"""3GPP TR 38.901 antenna modeling"""

from typing import Optional, Sequence, Tuple, Union

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.markers import MarkerStyle
import torch

from sionna.phy import SPEED_OF_LIGHT, PI
from sionna.phy.object import Object

_ANTENNA_PATTERNS = ("omni", "38.901", "38.901-handheld")


def _spherical_unit_vector(theta: torch.Tensor, phi: torch.Tensor) -> torch.Tensor:
    """Unit vector for TR 38.901 spherical coordinates."""
    return torch.stack(
        [
            torch.sin(theta) * torch.cos(phi),
            torch.sin(theta) * torch.sin(phi),
            torch.cos(theta),
        ],
        dim=-1,
    )


def _spherical_basis(
    theta: torch.Tensor,
    phi: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
    """Theta and phi unit vectors for TR 38.901 spherical coordinates."""
    e_theta = torch.stack(
        [
            torch.cos(theta) * torch.cos(phi),
            torch.cos(theta) * torch.sin(phi),
            -torch.sin(theta),
        ],
        dim=-1,
    )
    e_phi = torch.stack(
        [
            -torch.sin(phi),
            torch.cos(phi),
            torch.zeros_like(phi),
        ],
        dim=-1,
    )
    return e_theta, e_phi


def _radiation_pattern_38901_table(
    theta: torch.Tensor,
    phi: torch.Tensor,
    theta_3db_deg: float,
    phi_3db_deg: float,
    sla_v_db: float,
    a_max_db: float,
    g_e_max_db: float,
) -> torch.Tensor:
    """Radiation power pattern from TR 38.901 Tables 7.3-1/7.3-2."""
    theta_3db = theta_3db_deg / 180 * PI
    phi_3db = phi_3db_deg / 180 * PI
    sla_v = torch.as_tensor(sla_v_db, dtype=theta.dtype, device=theta.device)
    a_max = torch.as_tensor(a_max_db, dtype=theta.dtype, device=theta.device)
    a_v = -torch.minimum(12 * ((theta - PI / 2) / theta_3db) ** 2, sla_v)
    a_h = -torch.minimum(12 * (phi / phi_3db) ** 2, a_max)
    a_db = -torch.minimum(-(a_v + a_h), a_max) + g_e_max_db
    return 10 ** (a_db / 10)


[docs] class AntennaElement(Object): """Antenna element following the :cite:p:`TR38901V1920` specification :param pattern: Radiation pattern. One of ``"omni"``, ``"38.901"``, or ``"38.901-handheld"``. The ``"38.901"`` pattern follows Table 7.3-1 of TR 38.901 :cite:p:`TR38901V1920`. The ``"38.901-handheld"`` pattern follows Table 7.3-2 of :cite:p:`TR38901V1920` for handheld UT antenna elements. :param slant_angle: Polarization slant angle [radian] :param precision: Precision used for internal calculations and outputs. If set to `None`, :attr:`~sionna.phy.config.Config.precision` is used. :param device: Device for computation (e.g., ``"cpu"``, ``"cuda:0"``). If `None`, :attr:`~sionna.phy.config.Config.device` is used. .. rubric:: Examples .. code-block:: python from sionna.phy.channel.tr38901 import AntennaElement import torch # Create an antenna element with 38.901 radiation pattern ant = AntennaElement(pattern="38.901", slant_angle=0.0) # Compute field at zenith angle pi/2 and azimuth angle 0 theta = torch.tensor([1.5708]) phi = torch.tensor([0.0]) f_theta, f_phi = ant.field(theta, phi) """ def __init__( self, pattern: str, slant_angle: float = 0.0, precision: Optional[str] = None, device: Optional[str] = None, ) -> None: super().__init__(precision=precision, device=device) if pattern not in _ANTENNA_PATTERNS: raise ValueError( f"pattern must be one of {list(_ANTENNA_PATTERNS)}" ) self._pattern = pattern # Register as buffer for CUDAGraph compatibility self.register_buffer("_slant_angle", torch.tensor(slant_angle, dtype=self.dtype, device=self.device)) # Select the radiation field corresponding to the requested pattern if pattern == "omni": self._radiation_pattern = self._radiation_pattern_omni elif pattern == "38.901": self._radiation_pattern = self._radiation_pattern_38901 else: self._radiation_pattern = self._radiation_pattern_38901_handheld @property def pattern(self) -> str: """Radiation pattern type.""" return self._pattern @property def slant_angle(self) -> torch.Tensor: """Polarization slant angle [radian]""" return self._slant_angle
[docs] def field(self, theta: torch.Tensor, phi: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: """Field pattern in the vertical and horizontal polarization (7.3-4/5) :param theta: Zenith angle wrapped within (0, pi) [radian] :param phi: Azimuth angle wrapped within (-pi, pi) [radian] """ theta = theta.to(dtype=self.dtype, device=self.device) phi = phi.to(dtype=self.dtype, device=self.device) a = torch.sqrt(self._radiation_pattern(theta, phi)) f_theta = a * torch.cos(self._slant_angle) f_phi = a * torch.sin(self._slant_angle) return (f_theta, f_phi)
[docs] def show(self) -> None: """Shows the field pattern of an antenna element""" theta = torch.linspace(0.0, PI, 361, dtype=self.dtype, device=self.device) phi = torch.linspace(-PI, PI, 361, dtype=self.dtype, device=self.device) a_v = 10 * torch.log10(self._radiation_pattern(theta, torch.zeros_like(theta))) a_h = 10 * torch.log10(self._radiation_pattern(PI / 2 * torch.ones_like(phi), phi)) # Convert to numpy for plotting theta_np = theta.cpu().numpy() phi_np = phi.cpu().numpy() a_v_np = a_v.cpu().numpy() a_h_np = a_h.cpu().numpy() fig = plt.figure() plt.polar(theta_np, a_v_np) fig.axes[0].set_theta_zero_location("N") fig.axes[0].set_theta_direction(-1) plt.title(r"Vertical cut of the radiation pattern ($\phi = 0$)") plt.legend([f"{self._pattern}"]) fig = plt.figure() plt.polar(phi_np, a_h_np) fig.axes[0].set_theta_zero_location("E") plt.title(r"Horizontal cut of the radiation pattern ($\theta = \pi/2$)") plt.legend([f"{self._pattern}"]) theta = torch.linspace(0.0, PI, 50, dtype=self.dtype, device=self.device) phi = torch.linspace(-PI, PI, 50, dtype=self.dtype, device=self.device) phi_grid, theta_grid = torch.meshgrid(phi, theta, indexing='xy') a = self._radiation_pattern(theta_grid, phi_grid) x = a * torch.sin(theta_grid) * torch.cos(phi_grid) y = a * torch.sin(theta_grid) * torch.sin(phi_grid) z = a * torch.cos(theta_grid) # Convert to numpy for 3D plotting x_np = x.cpu().numpy() y_np = y.cpu().numpy() z_np = z.cpu().numpy() fig = plt.figure() ax = fig.add_subplot(1, 1, 1, projection='3d') ax.plot_surface(x_np, y_np, z_np, rstride=1, cstride=1, linewidth=0, antialiased=False, alpha=0.5) ax.view_init(elev=30., azim=-45) plt.xlabel("x") plt.ylabel("y") ax.set_zlabel("z") plt.title(f"Radiation power pattern ({self._pattern})")
def _radiation_pattern_omni(self, theta: torch.Tensor, phi: torch.Tensor) -> torch.Tensor: """Radiation pattern of an omnidirectional 3D radiation pattern :param theta: Zenith angle :param phi: Azimuth angle """ return torch.ones_like(theta) def _radiation_pattern_38901(self, theta: torch.Tensor, phi: torch.Tensor) -> torch.Tensor: """Radiation pattern from TR 38.901 Table 7.3-1 :param theta: Zenith angle wrapped within (0, pi) [radian] :param phi: Azimuth angle wrapped within (-pi, pi) [radian] """ return _radiation_pattern_38901_table( theta, phi, 65.0, 65.0, 30.0, 30.0, 8.0 ) def _radiation_pattern_38901_handheld(self, theta: torch.Tensor, phi: torch.Tensor) -> torch.Tensor: """Handheld UT radiation pattern from TR 38.901 Table 7.3-2 :param theta: Zenith angle wrapped within (0, pi) [radian] :param phi: Azimuth angle wrapped within (-pi, pi) [radian] """ return _radiation_pattern_38901_table( theta, phi, 125.0, 125.0, 22.5, 22.5, 5.3 ) def _compute_gain(self) -> Tuple[torch.Tensor, torch.Tensor]: """Compute antenna gain and directivity through numerical integration""" # Create angular meshgrid theta = torch.linspace(0.0, PI, 181, dtype=self.dtype, device=self.device) phi = torch.linspace(-PI, PI, 361, dtype=self.dtype, device=self.device) phi_grid, theta_grid = torch.meshgrid(phi, theta, indexing='xy') # Compute field strength over the grid f_theta, f_phi = self.field(theta_grid, phi_grid) u = f_theta ** 2 + f_phi ** 2 gain_db = 10 * torch.log10(torch.max(u)) # Numerical integration of the field components dtheta = theta[1] - theta[0] dphi = phi[1] - phi[0] po = torch.sum(u * torch.sin(theta_grid) * dtheta * dphi) # Compute directivity u_bar = po / (4 * PI) # Equivalent isotropic radiator d = u / u_bar # Directivity grid directivity_db = 10 * torch.log10(torch.max(d)) return (gain_db, directivity_db)
[docs] class HandheldUTArray(Object): # pylint: disable=line-too-long r""" Handheld UT antenna array from TR 38.901 Clause 7.3. .. _handheld-ut-array-geometry: .. figure:: ../../../figures/handheld_ut_array.svg :align: center :width: 360px Candidate antenna locations on the handheld UT device in the local :math:`x-y` plane. The local :math:`z`-axis is the reference orientation vector and points out of the shown plane. This class implements the handheld UT placement and field-rotation rules introduced by TR 38.901 V19.2.0 :cite:p:`TR38901V1920`. The element power pattern is selected through :class:`~sionna.phy.channel.tr38901.AntennaElement`; for standard-compliant handheld operation, use ``antenna_pattern="38.901-handheld"``, which follows Table 7.3-2. Antenna ports are placed at the candidate locations of Figure 7.3-2 on a flat device in the local :math:`x-y` plane. The default dimensions are :math:`15\,\mathrm{cm}` along the local :math:`x`-axis and :math:`7\,\mathrm{cm}` along the local :math:`y`-axis. The reference orientation vector is the local :math:`z`-axis, as shown in Figure 7.3-3. Note that this differs from the convention of :class:`~sionna.phy.channel.tr38901.PanelArray`, whose elements lie in the local :math:`y-z` plane and share the boresight :math:`+x`. Here every port has its own boresight, pointing radially outward from the device center and hence lying within the device plane, so :math:`+z` is not a boresight but the reference normal from which the polarization directions are derived. This class implements the same array interface as :class:`~sionna.phy.channel.tr38901.PanelArray` and can be used as the UT array for the :class:`~sionna.phy.channel.tr38901.CDL`, :class:`~sionna.phy.channel.tr38901.UMi`, :class:`~sionna.phy.channel.tr38901.UMa`, :class:`~sionna.phy.channel.tr38901.RMa`, :class:`~sionna.phy.channel.tr38901.InH`, and :class:`~sionna.phy.channel.tr38901.InF` models. The antenna model is not tied to the channel-model ``spec_version`` argument. Selecting ``spec_version="19.2"`` enables the V19.2 propagation parameters, but is not required for this array class to run. TR 38.901 defines this geometry for a handheld UT. The common Sionna array interface also permits the object to be supplied in roles such as a BS array, but such use is not a standardized handheld-UT configuration. :param carrier_frequency: Carrier frequency [Hz]. Used to establish the wavelength; calibration-frequency restrictions are not enforced. :param polarization: Polarization. One of ``"single"`` or ``"dual"``. :param polarization_type: Polarization type. For single polarization, ``"V"`` uses the Clause 7.3 single-field polarization direction and ``"H"`` uses the orthogonal direction. For dual polarization, ``"cross"`` applies the 45 degree rotation described for two field patterns in Clause 7.3, while ``"VH"`` uses the unrotated orthogonal pair. The two-field configuration is not intended for FR1. Defaults to ``"V"`` for single polarization and ``"cross"`` for dual polarization. :param antenna_locations: Candidate antenna locations from Figure 7.3-2; see :numref:`handheld-ut-array-geometry`. This can be a sequence of integers from 1 to 8, ``"tr38901"`` for all eight candidates, or ``"tr38901-4"`` for the four-corner subset ``(1, 7, 3, 5)`` used by several TR 38.901 calibration assumptions. The numbering follows the figure: 1, 2, and 3 are on the left edge of the top-down view from top to bottom, 4 is the bottom edge center, 5, 6, and 7 are on the right edge from bottom to top, and 8 is the top edge center. Defaults to ``"tr38901"``. :param antenna_pattern: Element radiation pattern. One of ``"omni"``, ``"38.901"``, or ``"38.901-handheld"``. Defaults to ``"38.901-handheld"``. :param device_depth: Handheld-device extent along the local :math:`x`-axis [m]. Defaults to 0.15 m. :param device_width: Handheld-device width [m]. Defaults to 0.07 m. :param port_power_offsets_db: Optional per-port attenuation [dB] for antenna imbalance. Positive values reduce the field amplitude. If set to `None`, no imbalance is applied, as specified by default in Table 7.3-2. :param precision: Precision used for internal calculations and outputs. If set to `None`, :attr:`~sionna.phy.config.Config.precision` is used. :param device: Device for computation (e.g., ``"cpu"``, ``"cuda:0"``). If `None`, :attr:`~sionna.phy.config.Config.device` is used. .. rubric:: Examples .. code-block:: python from sionna.phy.channel.tr38901 import HandheldUTArray # Four single-polarized handheld antenna ports at locations 1, 7, 3, 5 ut_array = HandheldUTArray(carrier_frequency=7e9, polarization="single", antenna_locations="tr38901-4") ut_array.show_element_radiation_pattern() # Eight ports: two field patterns at the four corner locations ut_array_dual = HandheldUTArray(carrier_frequency=15e9, polarization="dual", antenna_locations="tr38901-4") ut_array_dual.show() .. figure:: ../../../figures/handheld_ut_array_show.png :align: center :width: 500px Output of :meth:`show` for the dual-polarized array of the example above. Selected locations are drawn in red, unselected candidates in grey. The red segment shows the in-plane polarization direction of the first port of a location. The two polarizations are orthogonal in three dimensions, but their projections onto this top-down view coincide, so the second port is drawn as a perpendicular segment that marks the polarization pair rather than its true orientation. .. figure:: ../../../figures/handheld_ut_radiation_pattern.png :align: center :width: 700px Example output of :meth:`show_element_radiation_pattern` for the handheld UT reference antenna element. """ _CANDIDATE_FRACTIONS = { 1: (-0.5, -0.5), 2: (0.0, -0.5), 3: (0.5, -0.5), 4: (0.5, 0.0), 5: (0.5, 0.5), 6: (0.0, 0.5), 7: (-0.5, 0.5), 8: (-0.5, 0.0), } def __init__( self, carrier_frequency: float, polarization: str = "single", polarization_type: Optional[str] = None, antenna_locations: Union[str, Sequence[int]] = "tr38901", antenna_pattern: str = "38.901-handheld", device_depth: float = 0.15, device_width: float = 0.07, port_power_offsets_db: Optional[Sequence[float]] = None, precision: Optional[str] = None, device: Optional[str] = None, ) -> None: super().__init__(precision=precision, device=device) if polarization not in ("single", "dual"): raise ValueError("polarization must be either 'single' or 'dual'") if polarization_type is None: polarization_type = "V" if polarization == "single" else "cross" if polarization == "single": if polarization_type not in ("V", "H"): raise ValueError( "For single polarization, polarization_type must be 'V' " "or 'H'" ) else: if polarization_type not in ("VH", "cross"): raise ValueError( "For dual polarization, polarization_type must be 'VH' " "or 'cross'" ) candidate_indices = self._parse_antenna_locations(antenna_locations) positions = self._candidate_positions(candidate_indices, device_depth, device_width) port_positions, port_basis, port_components = self._build_ports( positions, polarization, polarization_type ) num_locations = len(candidate_indices) num_ant = port_positions.shape[0] if port_power_offsets_db is None: port_power_offsets_db = torch.zeros(num_ant, dtype=self.dtype, device=self.device) else: port_power_offsets_db = torch.as_tensor( port_power_offsets_db, dtype=self.dtype, device=self.device ) if tuple(port_power_offsets_db.shape) != (num_ant,): raise ValueError( "port_power_offsets_db must have one value per antenna port" ) self._polarization = polarization self._polarization_type = polarization_type self._antenna_locations = tuple(candidate_indices) self._antenna_pattern = antenna_pattern self._num_ant = num_ant self._num_locations = num_locations self._num_panels = 1 self._num_panel_ant = num_ant self.register_buffer("_lambda_0", torch.tensor( SPEED_OF_LIGHT / carrier_frequency, dtype=self.dtype, device=self.device)) self.register_buffer("_device_size", torch.tensor( [device_depth, device_width], dtype=self.dtype, device=self.device)) self.register_buffer("_candidate_indices", torch.tensor( candidate_indices, dtype=torch.int64, device=self.device)) self.register_buffer("_candidate_pos", torch.tensor( positions, dtype=self.dtype, device=self.device)) self.register_buffer("_ant_pos", torch.tensor( port_positions, dtype=self.dtype, device=self.device)) self.register_buffer("_port_basis", torch.tensor( port_basis, dtype=self.dtype, device=self.device)) self.register_buffer("_port_field_component", torch.tensor( port_components, dtype=torch.int64, device=self.device)) self.register_buffer("_port_power_offsets_db", port_power_offsets_db) if polarization == "single": ant_ind_pol1 = np.arange(num_ant) ant_ind_pol2 = np.array([], dtype=np.int64) else: ant_ind_pol1 = np.arange(num_locations) ant_ind_pol2 = np.arange(num_locations, 2*num_locations) self.register_buffer("_ant_ind_pol1", torch.tensor( ant_ind_pol1, dtype=torch.int64, device=self.device)) self.register_buffer("_ant_ind_pol2", torch.tensor( ant_ind_pol2, dtype=torch.int64, device=self.device)) self.register_buffer("_ant_pos_pol1", self._ant_pos[self._ant_ind_pol1]) self.register_buffer("_ant_pos_pol2", self._ant_pos[self._ant_ind_pol2] if polarization == "dual" else torch.tensor([], dtype=self.dtype, device=self.device)) self._ant_pol1 = AntennaElement(antenna_pattern, 0.0, precision=self.precision, device=self.device) self._ant_pol2 = None if polarization == "dual": self._ant_pol2 = AntennaElement(antenna_pattern, PI/2, precision=self.precision, device=self.device) @staticmethod def _parse_antenna_locations( antenna_locations: Union[str, Sequence[int]] ) -> Tuple[int, ...]: """Parse Figure 7.3-2 candidate antenna locations.""" if isinstance(antenna_locations, str): if antenna_locations == "tr38901": candidate_indices = tuple(range(1, 9)) elif antenna_locations == "tr38901-4": candidate_indices = (1, 7, 3, 5) else: raise ValueError( "antenna_locations must be 'tr38901', 'tr38901-4', " "or a sequence of candidate indices" ) else: candidate_indices = tuple(int(i) for i in antenna_locations) if not candidate_indices: raise ValueError("At least one antenna candidate location is required") unsupported = [i for i in candidate_indices if i not in HandheldUTArray._CANDIDATE_FRACTIONS] if unsupported: raise ValueError( "Handheld UT candidate locations must be integers from 1 to 8" ) return candidate_indices @classmethod def _candidate_positions( cls, candidate_indices: Sequence[int], device_depth: float, device_width: float, ) -> np.ndarray: """Return candidate antenna positions in the UT local coordinate system.""" positions = [] for index in candidate_indices: x_frac, y_frac = cls._CANDIDATE_FRACTIONS[index] positions.append([x_frac*device_depth, y_frac*device_width, 0.0]) return np.asarray(positions, dtype=np.float64) @staticmethod def _normalize_np(v: np.ndarray) -> np.ndarray: """Normalize a NumPy vector.""" norm = np.linalg.norm(v) if norm == 0: raise ValueError("Candidate antenna position must not be the origin") return v / norm @staticmethod def _rotate_np(v: np.ndarray, axis: np.ndarray, angle: float) -> np.ndarray: """Rotate vector ``v`` about ``axis`` by ``angle`` using Rodrigues' rule.""" axis = HandheldUTArray._normalize_np(axis) return (v*np.cos(angle) + np.cross(axis, v)*np.sin(angle) + axis*np.dot(axis, v)*(1.0 - np.cos(angle))) @staticmethod def _basis_from_boresight_and_polarization( boresight: np.ndarray, polarization_direction: np.ndarray, ) -> np.ndarray: """Build a right-handed local-to-device rotation matrix.""" x_axis = HandheldUTArray._normalize_np(boresight) p_axis = HandheldUTArray._normalize_np(polarization_direction) z_axis = -p_axis y_axis = HandheldUTArray._normalize_np(np.cross(z_axis, x_axis)) z_axis = HandheldUTArray._normalize_np(np.cross(x_axis, y_axis)) return np.stack([x_axis, y_axis, z_axis], axis=-1) @classmethod def _build_ports( cls, positions: np.ndarray, polarization: str, polarization_type: str, ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """Build per-port positions, local bases, and reference components.""" device_normal = np.asarray([0.0, 0.0, 1.0]) pol1_positions = [] pol1_basis = [] pol2_basis = [] for pos in positions: boresight = cls._normalize_np(pos) single_pol = cls._normalize_np(np.cross(boresight, device_normal)) orthogonal_pol = cls._normalize_np(np.cross(boresight, single_pol)) if polarization == "single": pol = single_pol if polarization_type == "V" else orthogonal_pol pol1_positions.append(pos) pol1_basis.append( cls._basis_from_boresight_and_polarization(boresight, pol) ) else: if polarization_type == "cross": pol1 = cls._rotate_np(single_pol, boresight, PI/4) else: pol1 = single_pol pol1_positions.append(pos) pol1_basis.append( cls._basis_from_boresight_and_polarization(boresight, pol1) ) # The second port uses the same local basis but selects # F_phi' instead of F_theta', which is the orthogonal field # component in the local frame. pol2_basis.append( cls._basis_from_boresight_and_polarization(boresight, pol1) ) if polarization == "single": return (np.asarray(pol1_positions), np.asarray(pol1_basis), np.zeros(len(pol1_positions), dtype=np.int64)) port_positions = np.concatenate([positions, positions], axis=0) port_basis = np.concatenate( [np.asarray(pol1_basis), np.asarray(pol2_basis)], axis=0, ) port_components = np.concatenate( [ np.zeros(len(positions), dtype=np.int64), np.ones(len(positions), dtype=np.int64), ], axis=0, ) return port_positions, port_basis, port_components @property def antenna_locations(self) -> Tuple[int, ...]: """Figure 7.3-2 candidate antenna location indices.""" return self._antenna_locations @property def antenna_pattern(self) -> str: """Element radiation pattern.""" return self._antenna_pattern @property def device_size(self) -> torch.Tensor: """Handheld-device size as ``[depth, width]`` [m].""" return self._device_size @property def candidate_pos(self) -> torch.Tensor: """Candidate antenna positions in the local coordinate system [m].""" return self._candidate_pos @property def port_basis(self) -> torch.Tensor: """Per-port local-to-device rotation matrices.""" return self._port_basis @property def port_field_component(self) -> torch.Tensor: """Reference field component per port, 0 for theta and 1 for phi.""" return self._port_field_component @property def port_power_offsets_db(self) -> torch.Tensor: """Per-port antenna-imbalance attenuation [dB].""" return self._port_power_offsets_db @property def polarization(self) -> str: """Polarization ('single' or 'dual').""" return self._polarization @property def polarization_type(self) -> str: """Polarization type.""" return self._polarization_type @property def num_panels(self) -> int: """Number of panels.""" return self._num_panels @property def num_panels_ant(self) -> int: """Number of antenna elements per panel.""" return self._num_panel_ant @property def num_ant(self) -> int: """Total number of antenna ports.""" return self._num_ant @property def ant_pol1(self) -> AntennaElement: """Reference element for the first polarization direction.""" return self._ant_pol1 @property def ant_pol2(self) -> AntennaElement: """Reference element for the second polarization direction.""" if self._polarization != "dual": raise RuntimeError( "This property is not defined with single polarization" ) return self._ant_pol2 @property def ant_pos(self) -> torch.Tensor: """Positions of the antenna ports [m].""" return self._ant_pos @property def ant_ind_pol1(self) -> torch.Tensor: """Indices of antenna ports with the first polarization direction.""" return self._ant_ind_pol1 @property def ant_ind_pol2(self) -> torch.Tensor: """Indices of antenna ports with the second polarization direction.""" if self._polarization != "dual": raise RuntimeError( "This property is not defined with single polarization" ) return self._ant_ind_pol2 @property def ant_pos_pol1(self) -> torch.Tensor: """Positions of antenna ports with the first polarization direction.""" return self._ant_pos_pol1 @property def ant_pos_pol2(self) -> torch.Tensor: """Positions of antenna ports with the second polarization direction.""" if self._polarization != "dual": raise RuntimeError( "This property is not defined with single polarization" ) return self._ant_pos_pol2
[docs] def element_field( self, theta: torch.Tensor, phi: torch.Tensor, ) -> torch.Tensor: r"""Compute per-port field vectors in the handheld-device LCS. The incoming direction is first rotated into the local coordinate system of each candidate antenna, the reference field :math:`F_{\theta^{\prime\prime}}^{\prime\prime}` or :math:`F_{\phi^{\prime\prime}}^{\prime\prime}` is evaluated, and the field is then rotated back according to (7.3-6)--(7.3-8) of TR 38.901. For the standard single-polarized handheld model, the boresight direction of candidate :math:`u` is .. math:: \hat{\mathbf{b}}_u = \frac{\mathbf{r}_u}{\lVert\mathbf{r}_u\rVert} where :math:`\mathbf{r}_u` is the candidate position relative to the handset center. With the reference handheld normal :math:`\hat{\mathbf{z}}`, the polarization direction is .. math:: \hat{\mathbf{p}}_u = \hat{\mathbf{b}}_u \times \hat{\mathbf{z}} which is parallel to the handheld plane and perpendicular to the direction from the handset center to the candidate location, as shown in Figure 7.3-7 of TR 38.901. The double-primed local basis is chosen such that the positive :math:`F_{\theta^{\prime\prime}}^{\prime\prime}` component at boresight is aligned with :math:`\hat{\mathbf{p}}_u`. :param theta: Zenith angle in the handheld-device LCS [radian]. :param phi: Azimuth angle in the handheld-device LCS [radian]. :output: Tensor of shape ``[num_ant] + theta.shape + [2]``. The last dimension contains :math:`(F_{\theta^\prime}^\prime,F_{\phi^\prime}^\prime)`. """ theta = theta.to(dtype=self.dtype, device=self.device) phi = phi.to(dtype=self.dtype, device=self.device) rho = _spherical_unit_vector(theta, phi) rho_local = torch.einsum("aij,...i->a...j", self._port_basis, rho) theta_local = torch.acos(rho_local[..., 2].clamp(-1.0, 1.0)) phi_local = torch.atan2(rho_local[..., 1], rho_local[..., 0]) power = self._ant_pol1._radiation_pattern(theta_local, phi_local) amplitude = torch.sqrt(power) e_theta_local, e_phi_local = _spherical_basis(theta_local, phi_local) component = self._port_field_component.reshape( [-1] + [1]*theta.dim() + [1] ) field_local = torch.where(component == 0, e_theta_local, e_phi_local) field_local = field_local * amplitude.unsqueeze(-1) field_device = torch.einsum( "aij,a...j->a...i", self._port_basis, field_local ) e_theta, e_phi = _spherical_basis(theta, phi) f_theta = (field_device * e_theta.unsqueeze(0)).sum(dim=-1) f_phi = (field_device * e_phi.unsqueeze(0)).sum(dim=-1) loss = 10 ** (-self._port_power_offsets_db / 20) loss = loss.reshape([-1] + [1]*theta.dim()) f_theta = f_theta * loss f_phi = f_phi * loss return torch.stack([f_theta, f_phi], dim=-1)
[docs] def show(self) -> None: """Show the handheld-device geometry and selected antenna locations.""" depth = float(self._device_size[0].detach().cpu().item()) width = float(self._device_size[1].detach().cpu().item()) all_indices = tuple(self._CANDIDATE_FRACTIONS.keys()) all_positions = self._candidate_positions(all_indices, depth, width) selected_positions = self._candidate_pos.detach().cpu().numpy() selected_basis = self._port_basis.detach().cpu().numpy() selected_components = self._port_field_component.detach().cpu().numpy() num_selected_locations = len(self._antenna_locations) fig, ax = plt.subplots() rect = plt.Rectangle( (-width/2, -depth/2), width, depth, fill=False, linewidth=1.5, edgecolor="black", label="Device outline", ) ax.add_patch(rect) ax.plot( all_positions[:, 1], all_positions[:, 0], marker="o", markerfacecolor="none", markeredgecolor="0.65", markersize="8", linestyle="None", label="Candidate locations", ) for index, pos in zip(all_indices, all_positions): ax.annotate(str(index), (pos[1], pos[0]), xytext=(4, 4), textcoords="offset points", color="0.35") ax.plot( selected_positions[:, 1], selected_positions[:, 0], marker="o", markerfacecolor="none", markeredgecolor="red", markersize="10", linestyle="None", markeredgewidth="2", label="Selected ports", ) segment_length = 0.30 * min(depth, width) def in_plane_direction(port): """Unit direction of a port polarization within the plotted plane""" basis = selected_basis[port] if selected_components[port] == 0: pol_axis = -basis[:, 2] else: pol_axis = basis[:, 1] direction = pol_axis[:2] norm = np.linalg.norm(direction) return direction / norm if norm > 0 else np.array([1.0, 0.0]) def draw_polarization_segment(pos, direction, color, label): start = pos - 0.5 * segment_length * direction stop = pos + 0.5 * segment_length * direction ax.plot([start[1], stop[1]], [start[0], stop[0]], color=color, linewidth=2.0, label=label) label_pol1 = "Polarization 1" label_pol2 = "Polarization 2" for port in range(num_selected_locations): pos = selected_positions[port][:2] direction = in_plane_direction(port) draw_polarization_segment(pos, direction, "red", label_pol1) # Only the first segment of a polarization carries the label label_pol1 = "_nolegend_" if self._polarization == "dual": # The two polarizations are orthogonal in 3D, but their # projections onto this plane coincide. The second one is drawn # perpendicular to the first to mark the pair of ports of a # location; its shown direction is schematic draw_polarization_segment( pos, np.array([-direction[1], direction[0]]), "black", label_pol2) label_pol2 = "_nolegend_" ax.set_aspect("equal", adjustable="box") pad = 0.1 * max(width, depth) ax.set_xlim(-width/2 - pad, width/2 + pad) ax.set_ylim(-depth/2 - pad, depth/2 + pad) ax.invert_yaxis() ax.set_xlabel("y (m)") ax.set_ylabel("x (m)") ax.set_title("Handheld UT Array") # The device outline fills the axes, so an inside legend would hide # candidate locations ax.legend(loc="upper left", bbox_to_anchor=(1.02, 1.0), borderaxespad=0.0) fig.tight_layout()
[docs] def show_element_radiation_pattern(self) -> None: """Show the radiation field of the reference antenna element.""" self._ant_pol1.show()
class AntennaPanel(Object): """Antenna panel following the :cite:p:`TR38901V1920` specification :param num_rows: Number of rows forming the panel :param num_cols: Number of columns forming the panel :param polarization: Polarization. One of ``"single"`` or ``"dual"``. :param vertical_spacing: Vertical antenna element spacing [multiples of wavelength] :param horizontal_spacing: Horizontal antenna element spacing [multiples of wavelength] :param precision: Precision used for internal calculations and outputs. If set to `None`, :attr:`~sionna.phy.config.Config.precision` is used. :param device: Device for computation (e.g., ``"cpu"``, ``"cuda:0"``). If `None`, :attr:`~sionna.phy.config.Config.device` is used. """ def __init__( self, num_rows: int, num_cols: int, polarization: str, vertical_spacing: float, horizontal_spacing: float, precision: Optional[str] = None, device: Optional[str] = None, ) -> None: super().__init__(precision=precision, device=device) if polarization not in ("single", "dual"): raise ValueError("polarization must be either 'single' or 'dual'") self._num_rows = num_rows self._num_cols = num_cols self._polarization = polarization # Register as buffers for CUDAGraph compatibility self.register_buffer("_horizontal_spacing", torch.tensor(horizontal_spacing, dtype=self.dtype, device=self.device)) self.register_buffer("_vertical_spacing", torch.tensor(vertical_spacing, dtype=self.dtype, device=self.device)) # Place the antenna elements of the first polarization direction # on the y-z-plane p = 1 if polarization == 'single' else 2 ant_pos = np.zeros([num_rows * num_cols * p, 3]) for i in range(num_rows): for j in range(num_cols): ant_pos[i + j * num_rows] = [0, j * horizontal_spacing, -i * vertical_spacing] # Center the panel around the origin offset = [0, -(num_cols - 1) * horizontal_spacing / 2, (num_rows - 1) * vertical_spacing / 2] ant_pos += offset # Create the antenna elements of the second polarization direction if polarization == 'dual': ant_pos[num_rows * num_cols:] = ant_pos[:num_rows * num_cols] # Register as buffer for CUDAGraph compatibility self.register_buffer("_ant_pos", torch.tensor(ant_pos, dtype=self.dtype, device=self.device)) @property def ant_pos(self) -> torch.Tensor: """Antenna positions in the local coordinate system""" return self._ant_pos @property def num_rows(self) -> int: """Number of rows""" return self._num_rows @property def num_cols(self) -> int: """Number of columns""" return self._num_cols @property def polarization(self) -> str: """Polarization, either ``"single"`` or ``"dual"``.""" return self._polarization @property def vertical_spacing(self) -> torch.Tensor: """Vertical spacing between elements [multiple of wavelength]""" return self._vertical_spacing @property def horizontal_spacing(self) -> torch.Tensor: """Horizontal spacing between elements [multiple of wavelength]""" return self._horizontal_spacing def show(self) -> None: """Shows the panel geometry""" fig = plt.figure() pos = self._ant_pos[:self._num_rows * self._num_cols].cpu().numpy() plt.plot(pos[:, 1], pos[:, 2], marker="|", markeredgecolor='red', markersize="20", linestyle="None", markeredgewidth="2") for i, p in enumerate(pos): fig.axes[0].annotate(i + 1, (p[1], p[2])) if self._polarization == 'dual': pos = self._ant_pos[self._num_rows * self._num_cols:].cpu().numpy() plt.plot(pos[:, 1], pos[:, 2], marker="_", markeredgecolor='black', markersize="20", linestyle="None", markeredgewidth="1") plt.xlabel(r"y ($\lambda_0$)") plt.ylabel(r"z ($\lambda_0$)") plt.title("Antenna Panel") plt.legend(["Polarization 1", "Polarization 2"], loc="upper right")
[docs] class PanelArray(Object): # pylint: disable=line-too-long r""" Antenna panel array following the :cite:p:`TR38901V1920` specification This class is used to create models of the panel arrays used by the transmitters and receivers and that need to be specified when using the :class:`~sionna.phy.channel.tr38901.CDL`, :class:`~sionna.phy.channel.tr38901.UMi`, :class:`~sionna.phy.channel.tr38901.UMa`, :class:`~sionna.phy.channel.tr38901.RMa`, and :class:`~sionna.phy.channel.tr38901.InH`, and :class:`~sionna.phy.channel.tr38901.InF` models. An array is made of ``num_rows`` :math:`\times` ``num_cols`` panels, and every panel carries ``num_rows_per_panel`` :math:`\times` ``num_cols_per_panel`` elements. All elements lie in the :math:`y-z` plane of the local coordinate system and the array is centered on its origin. Element and panel spacings are specified in multiples of the wavelength that corresponds to ``carrier_frequency``. For dual polarization, the two orthogonally polarized elements of a position are co-located. .. _panel-array-geometry: .. figure:: ../../../figures/panel_array_geometry.svg :align: center :width: 480px Geometry of a panel array. The element spacings ``element_horizontal_spacing`` (:math:`d_\text{H}`) and ``element_vertical_spacing`` (:math:`d_\text{V}`) apply within a panel, whereas the panel spacings ``panel_horizontal_spacing`` (:math:`D_\text{H}`) and ``panel_vertical_spacing`` (:math:`D_\text{V}`) are measured between corresponding elements of adjacent panels. The local :math:`x`-axis points out of the shown plane. :param num_rows_per_panel: Number of rows of elements per panel :param num_cols_per_panel: Number of columns of elements per panel :param polarization: Polarization. One of ``"single"`` or ``"dual"``. :param polarization_type: Type of polarization. For single polarization, must be ``"V"`` or ``"H"``. For dual polarization, must be ``"VH"`` or ``"cross"``. :param antenna_pattern: Element radiation pattern. One of ``"omni"``, ``"38.901"``, or ``"38.901-handheld"``. :param carrier_frequency: Carrier frequency [Hz] :param num_rows: Number of rows of panels. Defaults to 1. :param num_cols: Number of columns of panels. Defaults to 1. :param panel_vertical_spacing: Vertical spacing of panels [multiples of wavelength]. Must be greater than the panel height. If set to `None`, it is set to the panel height + 0.5. :param panel_horizontal_spacing: Horizontal spacing of panels [in multiples of wavelength]. Must be greater than the panel width. If set to `None`, it is set to the panel width + 0.5. :param element_vertical_spacing: Element vertical spacing [multiple of wavelength]. Defaults to 0.5 if set to `None`. :param element_horizontal_spacing: Element horizontal spacing [multiple of wavelength]. Defaults to 0.5 if set to `None`. :param precision: Precision used for internal calculations and outputs. If set to `None`, :attr:`~sionna.phy.config.Config.precision` is used. :param device: Device for computation (e.g., ``"cpu"``, ``"cuda:0"``). If `None`, :attr:`~sionna.phy.config.Config.device` is used. .. rubric:: Examples .. code-block:: python from sionna.phy.channel.tr38901 import PanelArray array = PanelArray(num_rows_per_panel=4, num_cols_per_panel=4, polarization='dual', polarization_type='VH', antenna_pattern='38.901', carrier_frequency=3.5e9, num_cols=2, panel_horizontal_spacing=3.) array.show() .. figure:: ../../../figures/panel_array_show.png :align: center :width: 600px Output of :meth:`show` for the array of the example above. The two panels of 4 :math:`\times` 4 vertically and horizontally polarized elements are separated by three wavelengths, and the annotations are the port indices. """ def __init__( self, num_rows_per_panel: int, num_cols_per_panel: int, polarization: str, polarization_type: str, antenna_pattern: str, carrier_frequency: float, num_rows: int = 1, num_cols: int = 1, panel_vertical_spacing: Optional[float] = None, panel_horizontal_spacing: Optional[float] = None, element_vertical_spacing: Optional[float] = None, element_horizontal_spacing: Optional[float] = None, precision: Optional[str] = None, device: Optional[str] = None, ) -> None: super().__init__(precision=precision, device=device) if polarization not in ("single", "dual"): raise ValueError("polarization must be either 'single' or 'dual'") # Setting default values for antenna and panel spacings if not # specified by the user # Default spacing for antenna elements is half a wavelength if element_vertical_spacing is None: element_vertical_spacing = 0.5 if element_horizontal_spacing is None: element_horizontal_spacing = 0.5 # Default values of panel spacing is the panel size + 0.5 if panel_vertical_spacing is None: panel_vertical_spacing = (num_rows_per_panel - 1) \ * element_vertical_spacing + 0.5 if panel_horizontal_spacing is None: panel_horizontal_spacing = (num_cols_per_panel - 1) \ * element_horizontal_spacing + 0.5 # Check that panel spacing is larger than panel dimensions if not ( panel_horizontal_spacing > (num_cols_per_panel - 1) * element_horizontal_spacing ): raise ValueError( "Panel horizontal spacing must be larger than the panel width" ) if not ( panel_vertical_spacing > (num_rows_per_panel - 1) * element_vertical_spacing ): raise ValueError( "Panel vertical spacing must be larger than panel height" ) self._num_rows = num_rows self._num_cols = num_cols self._num_rows_per_panel = num_rows_per_panel self._num_cols_per_panel = num_cols_per_panel self._polarization = polarization self._polarization_type = polarization_type # Register as buffers for CUDAGraph compatibility self.register_buffer("_panel_vertical_spacing", torch.tensor(panel_vertical_spacing, dtype=self.dtype, device=self.device)) self.register_buffer("_panel_horizontal_spacing", torch.tensor(panel_horizontal_spacing, dtype=self.dtype, device=self.device)) self.register_buffer("_element_vertical_spacing", torch.tensor(element_vertical_spacing, dtype=self.dtype, device=self.device)) self.register_buffer("_element_horizontal_spacing", torch.tensor(element_horizontal_spacing, dtype=self.dtype, device=self.device)) self._num_panels = num_cols * num_rows p = 1 if polarization == 'single' else 2 self._num_panel_ant = num_cols_per_panel * num_rows_per_panel * p # Total number of antenna elements self._num_ant = self._num_panels * self._num_panel_ant # Wavelength (m) # Register as buffer for CUDAGraph compatibility self.register_buffer("_lambda_0", torch.tensor(SPEED_OF_LIGHT / carrier_frequency, dtype=self.dtype, device=self.device)) # Create one antenna element for each polarization direction # polarization must be one of {"V", "H", "VH", "cross"} if polarization == 'single': if polarization_type not in ("V", "H"): raise ValueError( "For single polarization, polarization_type must be 'V' " "or 'H'" ) slant_angle = 0 if polarization_type == "V" else PI / 2 self._ant_pol1 = AntennaElement(antenna_pattern, slant_angle, precision=self.precision, device=self.device) self._ant_pol2 = None else: if polarization_type not in ("VH", "cross"): raise ValueError( "For dual polarization, polarization_type must be 'VH' " "or 'cross'" ) slant_angle = 0 if polarization_type == "VH" else -PI / 4 self._ant_pol1 = AntennaElement(antenna_pattern, slant_angle, precision=self.precision, device=self.device) self._ant_pol2 = AntennaElement(antenna_pattern, slant_angle + PI / 2, precision=self.precision, device=self.device) # Compose array from panels ant_pos = np.zeros([self._num_ant, 3]) panel = AntennaPanel(num_rows_per_panel, num_cols_per_panel, polarization, element_vertical_spacing, element_horizontal_spacing, precision=self.precision, device=self.device) pos = panel.ant_pos.cpu().numpy() count = 0 num_panel_ant = self._num_panel_ant for j in range(num_cols): for i in range(num_rows): offset = [0, j * panel_horizontal_spacing, -i * panel_vertical_spacing] new_pos = pos + offset ant_pos[count * num_panel_ant:(count + 1) * num_panel_ant] = new_pos count += 1 # Center the entire panel array around the origin of the y-z plane offset = [0, -(num_cols - 1) * panel_horizontal_spacing / 2, (num_rows - 1) * panel_vertical_spacing / 2] ant_pos += offset # Scale antenna element positions by the wavelength ant_pos *= self._lambda_0.cpu().numpy() # Register as buffer for CUDAGraph compatibility self.register_buffer("_ant_pos", torch.tensor(ant_pos, dtype=self.dtype, device=self.device)) # Compute indices of antennas for polarization directions ind = np.arange(0, self._num_ant) ind = np.reshape(ind, [self._num_panels * p, -1]) # Register as buffers for CUDAGraph compatibility self.register_buffer("_ant_ind_pol1", torch.tensor(np.reshape(ind[::p], [-1]), dtype=torch.int64, device=self.device)) if polarization == 'single': self.register_buffer("_ant_ind_pol2", torch.tensor(np.array([]), dtype=torch.int64, device=self.device)) else: self.register_buffer("_ant_ind_pol2", torch.tensor(np.reshape( ind[1:self._num_panels * p:2], [-1]), dtype=torch.int64, device=self.device)) # Get positions of antenna elements for each polarization direction self.register_buffer("_ant_pos_pol1", self._ant_pos[self._ant_ind_pol1]) self.register_buffer("_ant_pos_pol2", self._ant_pos[self._ant_ind_pol2] if polarization == 'dual' else torch.tensor([], dtype=self.dtype, device=self.device)) @property def num_rows(self) -> int: """Number of rows of panels""" return self._num_rows @property def num_cols(self) -> int: """Number of columns of panels""" return self._num_cols @property def num_rows_per_panel(self) -> int: """Number of rows of elements per panel""" return self._num_rows_per_panel @property def num_cols_per_panel(self) -> int: """Number of columns of elements per panel""" return self._num_cols_per_panel @property def polarization(self) -> str: """Polarization, either ``"single"`` or ``"dual"``.""" return self._polarization @property def polarization_type(self) -> str: """Polarization type. ``"V"`` or ``"H"`` for single polarization. ``"VH"`` or ``"cross"`` for dual polarization.""" return self._polarization_type @property def panel_vertical_spacing(self) -> torch.Tensor: """Vertical spacing between the panels [multiple of wavelength]""" return self._panel_vertical_spacing @property def panel_horizontal_spacing(self) -> torch.Tensor: """Horizontal spacing between the panels [multiple of wavelength]""" return self._panel_horizontal_spacing @property def element_vertical_spacing(self) -> torch.Tensor: """Vertical spacing between the antenna elements within a panel [multiple of wavelength]""" return self._element_vertical_spacing @property def element_horizontal_spacing(self) -> torch.Tensor: """Horizontal spacing between the antenna elements within a panel [multiple of wavelength]""" return self._element_horizontal_spacing @property def num_panels(self) -> int: """Number of panels""" return self._num_panels @property def num_panels_ant(self) -> int: """Number of antenna elements per panel""" return self._num_panel_ant @property def num_ant(self) -> int: """Total number of antenna elements""" return self._num_ant @property def ant_pol1(self) -> AntennaElement: """Field of an antenna element with the first polarization direction""" return self._ant_pol1 @property def ant_pol2(self) -> AntennaElement: """Field of an antenna element with the second polarization direction. Only defined with dual polarization.""" if self._polarization != 'dual': raise RuntimeError( "This property is not defined with single polarization" ) return self._ant_pol2 @property def ant_pos(self) -> torch.Tensor: """Positions of the antennas""" return self._ant_pos @property def ant_ind_pol1(self) -> torch.Tensor: """Indices of antenna elements with the first polarization direction""" return self._ant_ind_pol1 @property def ant_ind_pol2(self) -> torch.Tensor: """Indices of antenna elements with the second polarization direction. Only defined with dual polarization.""" if self._polarization != 'dual': raise RuntimeError( "This property is not defined with single polarization" ) return self._ant_ind_pol2 @property def ant_pos_pol1(self) -> torch.Tensor: """Positions of the antenna elements with the first polarization direction""" return self._ant_pos_pol1 @property def ant_pos_pol2(self) -> torch.Tensor: """Positions of antenna elements with the second polarization direction. Only defined with dual polarization.""" if self._polarization != 'dual': raise RuntimeError( "This property is not defined with single polarization" ) return self._ant_pos_pol2
[docs] def show(self) -> None: """Show the panel array geometry""" if self._polarization == 'single': if self._polarization_type == 'H': marker_p1 = MarkerStyle("_").get_marker() else: marker_p1 = MarkerStyle("|") else: # 'dual' if self._polarization_type == 'cross': marker_p1 = (2, 0, -45) marker_p2 = (2, 0, 45) else: marker_p1 = MarkerStyle("_").get_marker() marker_p2 = MarkerStyle("|").get_marker() fig = plt.figure() pos_pol1 = self._ant_pos_pol1.cpu().numpy() plt.plot(pos_pol1[:, 1], pos_pol1[:, 2], marker=marker_p1, markeredgecolor='red', markersize="20", linestyle="None", markeredgewidth="2", label="Polarization 1") ant_ind_pol1 = self._ant_ind_pol1.cpu().numpy() for i, p in enumerate(pos_pol1): fig.axes[0].annotate(ant_ind_pol1[i] + 1, (p[1], p[2])) if self._polarization == 'dual': pos_pol2 = self._ant_pos_pol2.cpu().numpy() plt.plot(pos_pol2[:, 1], pos_pol2[:, 2], marker=marker_p2, # pylint: disable=possibly-used-before-assignment markeredgecolor='black', markersize="20", linestyle="None", markeredgewidth="1", label="Polarization 2") plt.xlabel("y (m)") plt.ylabel("z (m)") # Subclasses such as AntennaArray and Antenna share this method plt.title(type(self).__name__) # An inside legend would hide the port labels of the upper-right # elements plt.legend(loc="upper left", bbox_to_anchor=(1.02, 1.0), borderaxespad=0.0) fig.tight_layout()
[docs] def show_element_radiation_pattern(self) -> None: """Show the radiation field of antenna elements forming the panel""" self._ant_pol1.show()
[docs] class Antenna(PanelArray): # pylint: disable=line-too-long r""" Single antenna following the :cite:p:`TR38901V1920` specification This class is a special case of :class:`~sionna.phy.channel.tr38901.PanelArray`, and can be used in lieu of it. :param polarization: Polarization. One of ``"single"`` or ``"dual"``. :param polarization_type: Type of polarization. For single polarization, must be ``"V"`` or ``"H"``. For dual polarization, must be ``"VH"`` or ``"cross"``. :param antenna_pattern: Element radiation pattern. One of ``"omni"``, ``"38.901"``, or ``"38.901-handheld"``. :param carrier_frequency: Carrier frequency [Hz] :param precision: Precision used for internal calculations and outputs. If set to `None`, :attr:`~sionna.phy.config.Config.precision` is used. :param device: Device for computation (e.g., ``"cpu"``, ``"cuda:0"``). If `None`, :attr:`~sionna.phy.config.Config.device` is used. .. rubric:: Examples .. code-block:: python from sionna.phy.channel.tr38901 import Antenna ant = Antenna(polarization='single', polarization_type='V', antenna_pattern='omni', carrier_frequency=3.5e9) print(ant.num_ant) # 1 """ def __init__( self, polarization: str, polarization_type: str, antenna_pattern: str, carrier_frequency: float, precision: Optional[str] = None, device: Optional[str] = None, ) -> None: super().__init__( num_rows_per_panel=1, num_cols_per_panel=1, polarization=polarization, polarization_type=polarization_type, antenna_pattern=antenna_pattern, carrier_frequency=carrier_frequency, precision=precision, device=device, )
[docs] class AntennaArray(PanelArray): # pylint: disable=line-too-long r""" Antenna array following the :cite:p:`TR38901V1920` specification This class is a special case of :class:`~sionna.phy.channel.tr38901.PanelArray`, and can be used in lieu of it. All ``num_rows`` :math:`\times` ``num_cols`` elements form a single panel in the :math:`y-z` plane of the local coordinate system, centered on its origin. Note that ``num_rows`` and ``num_cols`` count antenna elements here, whereas they count panels in :class:`~sionna.phy.channel.tr38901.PanelArray`. .. _antenna-array-geometry: .. figure:: ../../../figures/antenna_array_geometry.svg :align: center :width: 480px Geometry of an antenna array. The element spacings ``horizontal_spacing`` (:math:`d_\text{H}`) and ``vertical_spacing`` (:math:`d_\text{V}`) are specified in multiples of the wavelength that corresponds to ``carrier_frequency``. For dual polarization, the two orthogonally polarized elements of a position are co-located. The local :math:`x`-axis points out of the shown plane. :param num_rows: Number of rows of elements :param num_cols: Number of columns of elements :param polarization: Polarization. One of ``"single"`` or ``"dual"``. :param polarization_type: Type of polarization. For single polarization, must be ``"V"`` or ``"H"``. For dual polarization, must be ``"VH"`` or ``"cross"``. :param antenna_pattern: Element radiation pattern. One of ``"omni"``, ``"38.901"``, or ``"38.901-handheld"``. :param carrier_frequency: Carrier frequency [Hz] :param vertical_spacing: Element vertical spacing [multiple of wavelength]. Defaults to 0.5 if set to `None`. :param horizontal_spacing: Element horizontal spacing [multiple of wavelength]. Defaults to 0.5 if set to `None`. :param precision: Precision used for internal calculations and outputs. If set to `None`, :attr:`~sionna.phy.config.Config.precision` is used. :param device: Device for computation (e.g., ``"cpu"``, ``"cuda:0"``). If `None`, :attr:`~sionna.phy.config.Config.device` is used. .. rubric:: Examples .. code-block:: python from sionna.phy.channel.tr38901 import AntennaArray array = AntennaArray(num_rows=4, num_cols=4, polarization='dual', polarization_type='cross', antenna_pattern='38.901', carrier_frequency=3.5e9) print(array.num_ant) # 32 array.show() .. figure:: ../../../figures/antenna_array_show.png :align: center :width: 600px Output of :meth:`show` for the array of the example above. The 4 :math:`\times` 4 positions each carry two cross-polarized elements, and the annotations are the port indices. """ def __init__( self, num_rows: int, num_cols: int, polarization: str, polarization_type: str, antenna_pattern: str, carrier_frequency: float, vertical_spacing: Optional[float] = None, horizontal_spacing: Optional[float] = None, precision: Optional[str] = None, device: Optional[str] = None, ) -> None: super().__init__( num_rows_per_panel=num_rows, num_cols_per_panel=num_cols, polarization=polarization, polarization_type=polarization_type, antenna_pattern=antenna_pattern, carrier_frequency=carrier_frequency, element_vertical_spacing=vertical_spacing, element_horizontal_spacing=horizontal_spacing, precision=precision, device=device, )