Source code for hermespy.channel.cluster_delay_lines

# -*- coding: utf-8 -*-

from __future__ import annotations
from abc import abstractmethod
from enum import Enum
from functools import cached_property
from math import ceil, sin, cos, sqrt
from typing import Any, Generator, Literal, List, Tuple, Type, TYPE_CHECKING

import matplotlib.pyplot as plt
import numpy as np
from h5py import Group
from scipy.constants import pi, speed_of_light

from hermespy.core import (
    AntennaMode,
    ChannelStateInformation,
    ChannelStateFormat,
    Device,
    Direction,
    Executable,
    HDFSerializable,
    Serializable,
    Signal,
    Visualizable,
)
from hermespy.tools import db2lin
from .channel import Channel, ChannelRealization, InterpolationMode

if TYPE_CHECKING:
    from hermespy.simulation import SimulatedDevice  # pragma: no cover

__author__ = "Jan Adler"
__copyright__ = "Copyright 2023, Barkhausen Institut gGmbH"
__credits__ = ["Jan Adler"]
__license__ = "AGPLv3"
__version__ = "1.2.0"
__maintainer__ = "Jan Adler"
__email__ = "jan.adler@barkhauseninstitut.org"
__status__ = "Prototype"


[docs] class DelayNormalization(Enum): """Normalization routine applied to a set of sampled delays. Configuration option to :class:.ClusterDelayLineBase models. """ ZERO = 0 """Normalize the delays, so that the minimal delay is zero""" TOF = 1 """The minimal delay is the time of flight between two devices""" NONE = 2 """No delay normalization is applied. Only relevant for debugging purposes. """
[docs] class ClusterDelayLineRealization(ChannelRealization, Visualizable): """Realization of a 3GPP Cluster Delay Line channel model.""" __line_of_sight: bool __rice_factor: float __azimuth_of_arrival: np.ndarray __zenith_of_arrival: np.ndarray __azimuth_of_departure: np.ndarray __zenith_of_departure: np.ndarray __cluster_delays: np.ndarray __cluster_delay_spread: float __cluster_powers: np.ndarray __polarization_transformations: np.ndarray __max_delay: float def __init__( self, alpha_device: Device, beta_device: Device, gain: float, line_of_sight: bool, rice_factor: float, azimuth_of_arrival: np.ndarray, zenith_of_arrival: np.ndarray, azimuth_of_departure: np.ndarray, zenith_of_departure: np.ndarray, cluster_delays: np.ndarray, cluster_delay_spread: float, cluster_powers: np.ndarray, polarization_transformations: np.ndarray, interpolation_mode: InterpolationMode = InterpolationMode.NEAREST, ) -> None: # Initialize base class ChannelRealization.__init__(self, alpha_device, beta_device, gain, interpolation_mode) # Initialize class members self.__line_of_sight = line_of_sight self.__rice_factor = rice_factor self.__azimuth_of_arrival = azimuth_of_arrival self.__zenith_of_arrival = zenith_of_arrival self.__azimuth_of_departure = azimuth_of_departure self.__zenith_of_departure = zenith_of_departure self.__cluster_delays = cluster_delays self.__cluster_delay_spread = cluster_delay_spread self.__cluster_powers = cluster_powers self.__polarization_transformations = polarization_transformations # Infer additional parameters self.__num_clusters = cluster_delays.shape[0] self.__num_rays = azimuth_of_arrival.shape[1] self.__max_delay = max( np.max(cluster_delays[:3] + cluster_delay_spread * 2.56), cluster_delays.max() ) @property def line_of_sight(self) -> bool: """Does the realization include direct line of sight between the two devices?""" return self.__line_of_sight @property def rice_factor(self) -> float: """Rice factor.""" return self.__rice_factor @property def azimuth_of_arrival(self) -> np.ndarray: """Azimuth of arrival angles in radians.""" return self.__azimuth_of_arrival @property def zenith_of_arrival(self) -> np.ndarray: """Zenith of arrival angles in radians.""" return self.__zenith_of_arrival @property def azimuth_of_departure(self) -> np.ndarray: """Azimuth of departure angles in radians.""" return self.__azimuth_of_departure @property def zenith_of_departure(self) -> np.ndarray: """Zenith of departure angles in radians.""" return self.__zenith_of_departure @property def cluster_delays(self) -> np.ndarray: """Cluster delays in seconds.""" return self.__cluster_delays @property def cluster_delay_spread(self) -> float: """Cluster delay spread in seconds.""" return self.__cluster_delay_spread @property def cluster_powers(self) -> np.ndarray: """Cluster powers.""" return self.__cluster_powers @property def polarization_transformations(self) -> np.ndarray: """Polarization transformations.""" return self.__polarization_transformations @property def num_clusters(self) -> int: """Number of realized scatter clusters.""" return self.__num_clusters @property def num_rays(self) -> int: """Number of rays within each cluster.""" return self.__num_rays @property def max_delay(self) -> float: """Maximum expected delay in seconds.""" return self.__max_delay def __ray_impulse_generator( self, transmitter: Device, receiver: Device, sampling_rate: float, num_samples: int, center_frequency: float, ) -> Generator[Tuple[np.ndarray, np.ndarray, float], None, None]: """Sequential generation of individual ray impulse responses. Subroutine of :meth:`._propagate`. Args: transmitter (Device): Transmitting device. receiver (Device): Receiving device. sampling_rate (float): Sampling rate in Hertz. num_samples (int): Number of samples to generate. center_frequency (float): Center frequency in Hertz. """ # First summand scaling of equation 7.5-30 rice_factor_lin = db2lin(self.rice_factor) nlos_scale = (1 + rice_factor_lin) ** -0.5 if self.line_of_sight else 1.0 # Compute the number of clusters, considering the first two clusters get split into 3 partitions num_split_clusters = min(2, self.num_clusters) virtual_num_clusters = 3 * num_split_clusters + max(0, self.num_clusters - 2) # Prepare the cluster delays, equation 7.5-26 subcluster_delays: np.ndarray = np.repeat( self.cluster_delays[:num_split_clusters, None], 3, axis=1 ) + self.cluster_delay_spread * np.array([1.0, 1.28, 2.56]) virtual_cluster_delays = np.concatenate( (subcluster_delays.flatten(), self.cluster_delays[num_split_clusters:]) ) # Wavelength factor wavelength_factor = center_frequency / speed_of_light relative_velocity = receiver.velocity - transmitter.velocity fast_fading = wavelength_factor * np.arange(num_samples) / sampling_rate # Compute the cluster propagation parameters for subcluster_idx in range(0, virtual_num_clusters): cluster_idx = int(subcluster_idx / 3) if subcluster_idx < 6 else subcluster_idx - 4 ray_indices = ( ClusterDelayLineBase.subcluster_indices[cluster_idx] if cluster_idx < num_split_clusters else range(self.num_rays) ) delay: float = virtual_cluster_delays[subcluster_idx] for aoa, zoa, aod, zod, jones in zip( self.azimuth_of_arrival[cluster_idx, ray_indices], self.zenith_of_arrival[cluster_idx, ray_indices], self.azimuth_of_departure[cluster_idx, ray_indices], self.zenith_of_departure[cluster_idx, ray_indices], self.polarization_transformations[:, :, cluster_idx, ray_indices].transpose( 2, 0, 1 ), ): # Compute directive unit vectors tx_direction = Direction.From_Spherical(aod, zod) rx_direction = Direction.From_Spherical(aoa, zoa) # Combination of Equation 7.5-23, 7.5.24 and 7.5.28 tx_array_response = transmitter.antennas.cartesian_array_response( center_frequency, tx_direction.view(np.ndarray), "global", AntennaMode.TX ).conj() rx_array_response = receiver.antennas.cartesian_array_response( center_frequency, rx_direction.view(np.ndarray), "global", AntennaMode.RX ) channel: np.ndarray = ( rx_array_response @ jones @ tx_array_response.T * (sqrt(self.cluster_powers[cluster_idx] / self.num_clusters) * nlos_scale) ) wave_vector = np.array( [cos(aoa) * sin(zoa), sin(aoa) * sin(zoa), cos(zoa)], dtype=float ) impulse: np.ndarray = np.exp( np.inner(wave_vector, relative_velocity) * fast_fading * 2j * pi ) yield channel, impulse, delay # If line of sight is enabled, yield an additional parameter touple for the line of sight component if self.line_of_sight: device_vector = receiver.position - transmitter.position los_distance = np.linalg.norm(device_vector, 2) rx_wave_vector = device_vector / los_distance # Equation 7.5-29 tx_array_response = transmitter.antennas.cartesian_array_response( center_frequency, receiver.global_position, "global", AntennaMode.TX ).conj() rx_array_response = receiver.antennas.cartesian_array_response( center_frequency, transmitter.global_position, "global", AntennaMode.RX ) # Second summand of equation 7.5-30 los_delay: float = self.cluster_delays[0] los_channel: np.ndarray = ( rx_array_response @ tx_array_response.T * (rice_factor_lin / 1 + rice_factor_lin) ) los_impulse: np.ndarray = np.exp(-2j * pi * los_distance * wavelength_factor) * np.exp( np.inner(rx_wave_vector, relative_velocity) * fast_fading * 2j * pi ) yield los_channel, los_impulse, los_delay def _propagate( self, signal: Signal, transmitter: Device, receiver: Device, interpolation: InterpolationMode, ) -> Signal: max_delay_in_samples = ceil(self.max_delay * signal.sampling_rate) num_resulting_streams = receiver.antennas.num_receive_antennas propagated_samples = np.zeros( (num_resulting_streams, signal.num_samples + max_delay_in_samples), dtype=np.complex_ ) # Prepare the optimal einsum path ahead of time for faster execution channel = np.zeros( (num_resulting_streams, transmitter.antennas.num_transmit_antennas), dtype=np.complex_ ) impulse = np.zeros(signal.num_samples, dtype=np.complex_) einsum_subscripts = "ij,jk,k->ik" einsum_path = np.einsum_path( einsum_subscripts, channel, signal.samples, impulse, optimize="optimal" )[0] for channel, impulse, delay in self.__ray_impulse_generator( transmitter, receiver, signal.sampling_rate, signal.num_samples, signal.carrier_frequency, ): if interpolation == InterpolationMode.NEAREST: delay_in_samples = int(delay * signal.sampling_rate) propagated_samples[ :, delay_in_samples : delay_in_samples + signal.num_samples ] += np.einsum( einsum_subscripts, channel, signal.samples, impulse, optimize=einsum_path ) return Signal( propagated_samples, signal.sampling_rate, signal.carrier_frequency, signal.delay, signal.noise_power, )
[docs] def state( self, transmitter: Device, receiver: Device, delay_offset: float, sampling_rate: float, num_samples: int, max_num_taps: int, ) -> ChannelStateInformation: carrier_frequency = transmitter.carrier_frequency max_delay_in_samples = min(max_num_taps, ceil(self.max_delay * sampling_rate)) raw_state = np.zeros( ( receiver.antennas.num_receive_antennas, transmitter.antennas.num_transmit_antennas, num_samples, 1 + max_delay_in_samples, ), dtype=np.complex_, ) for channel, impulse, delay in self.__ray_impulse_generator( transmitter, receiver, sampling_rate, num_samples, carrier_frequency ): # Compute the delay in samples delay_tap_index = int(delay * sampling_rate) if delay_tap_index >= max_num_taps: continue # Compute the resulting channel state raw_state[:, :, :, delay_tap_index] += np.einsum( "ij,k->ijk", channel, impulse, optimize=False ) raw_state *= self.gain**0.5 return ChannelStateInformation(ChannelStateFormat.IMPULSE_RESPONSE, raw_state)
[docs] def plot_angles(self, title: str | None = None, center_los: bool = True) -> plt.Figure: delta = self.alpha_device.position - self.beta_device.position los_zenith = np.arccos((-delta[2], delta[2])) los_azimuth = np.arctan((-delta[1] / delta[0], delta[1] / delta[0])) azimuth_offset = -los_azimuth if center_los else np.zeros(2) zenith_offset = -los_zenith if center_los else np.zeros(2) with Executable.style_context(): figure, axes = plt.subplots(1, 2, subplot_kw={"projection": "polar"}) num_colors = len(plt.rcParams["axes.prop_cycle"].by_key()["color"]) markers = ["x", "+", "o"] for i, (azimuth_clusters, zenith_clusters) in enumerate( [ [self.azimuth_of_arrival, self.zenith_of_arrival], [self.azimuth_of_departure, self.zenith_of_departure], ] ): for azimuth_rays, zenith_rays in zip(azimuth_clusters, zenith_clusters): azimuth_points = azimuth_rays + azimuth_offset[0] zenith_points = abs(180 * (zenith_rays + zenith_offset[0]) / pi) % 180 # azimuth_points[zenith_points > 90] -= pi zenith_points[zenith_points > 90] = 90 - zenith_points[zenith_points > 90] % 90 axes[i].scatter(azimuth_points, zenith_points, marker=markers[int(i / num_colors)]) # Plot line of sight indicators for axis_index, (azimuth, zenith) in enumerate( zip(los_azimuth + azimuth_offset, los_zenith + zenith_offset) ): axes[axis_index].scatter(azimuth, abs(180 * zenith / pi), marker="o", color="b") axes[axis_index].set_rmax(90) axes[axis_index].grid(True) axes[axis_index].set_xlabel("Azimuth") axes[axis_index].set_ylabel("Zenith") axes[axis_index].set_rlabel_position(22.5) figure.suptitle("Angle Realizations" if title is None else title) axes[0].set_title("Angles of Arrival") axes[1].set_title("Angles of Departure") return figure
[docs] def plot_power_delay(self, title: str | None = None) -> plt.Figure: with Executable.style_context(): figure, axis = plt.subplots() colors = plt.rcParams["axes.prop_cycle"].by_key()["color"] for c, (delay, power) in enumerate(zip(self.cluster_delays, self.cluster_powers)): stem = axis.stem(delay, power, markerfmt="x") plt.setp(stem[0], "color", colors[c % len(colors)]) plt.setp(stem[1], "color", colors[c % len(colors)]) figure.suptitle("Power Delay Profile" if title is None else title) axis.set_yscale("log") axis.xaxis.set_label_text("Delay") axis.yaxis.set_label_text("Power") return figure
[docs] def plot_rays(self, title: str | None = None) -> plt.Figure: with Executable.style_context(): figure, axis = plt.subplots(subplot_kw={"projection": "3d"}) colors = plt.rcParams["axes.prop_cycle"].by_key()["color"] magnitude = np.linalg.norm(self.alpha_device.position - self.beta_device.position) for i, (aoa, zoa, aod, zod) in enumerate( zip( self.azimuth_of_arrival, self.zenith_of_arrival, self.azimuth_of_departure, self.zenith_of_departure, ) ): tx_vectors = np.array( [np.sin(zod) * np.cos(aod), np.sin(zod) * np.sin(aod), np.cos(zod)] ) rx_vectors = -np.array( [np.sin(zoa) * np.cos(aoa), np.sin(zoa) * np.sin(aoa), np.cos(zoa)] ) for vectors, origin in zip( (tx_vectors, rx_vectors), (self.alpha_device.position, self.beta_device.position) ): stops = vectors * magnitude + origin[:, np.newaxis] for stop in stops.T: axis.plot( [origin[0], stop[0]], [origin[1], stop[1]], [origin[2], stop[2]], color=colors[i % len(colors)], ) """start_arrival = self.transmitter_position stop_arrival = self.transmitter_position + arrival start_departure = self.receiver_position stop_departure = self.receiver_position + departure""" axis.set_zlim(-magnitude, magnitude) axis.scatter(*self.alpha_device.position, marker="o", color="w") axis.text(*self.alpha_device.position, "Tx", zorder=1) axis.scatter(*self.beta_device.position, marker="o", color="w") axis.text(*self.beta_device.position, "Rx", zorder=1) figure.suptitle("Rays" if title is None else title) return figure
@staticmethod def _angular_spread(angles: np.ndarray, powers: np.ndarray) -> float: """Compute the angular spread of a set of data. Implements equation (A-1) of :footcite:t:`3GPP:TR38901`. Args: angles (np.ndarray): Numpy array of angles in radians for all paths of propagation. powers (np.ndarray): Numpy angles of power for all paths of propagation. Returns: The angluar spread in radians. Raises: ValueError: If the dimensions of `angles` and `powers` don't match. """ if angles.shape != powers.shape: raise ValueError("Dimensions of angles and powers don't match") summed_power = np.sum(powers, axis=None) weighted_angle_sum = np.sum(np.exp(1j * angles) * powers, axis=None) spread = np.sqrt(-2 * np.log(np.abs(weighted_angle_sum / summed_power))) return spread @cached_property def azimuth_arrival_spread(self) -> float: """Spread of azimuth of arrival angles. Returns: Angular spread in radians. """ return self._angular_spread( self.azimuth_of_arrival, np.repeat(self.cluster_powers[:, None], self.azimuth_of_arrival.shape[1], axis=1), ) @cached_property def azimuth_departure_spread(self) -> float: """Spread of azimuth of departure angles. Returns: Angular spread in radians. """ return self._angular_spread( self.azimuth_of_departure, np.repeat(self.cluster_powers[:, None], self.azimuth_of_departure.shape[1], axis=1), ) @cached_property def zenith_arrival_spread(self) -> float: """Spread of zenith of arrival angles. Returns: Angular spread in radians. """ return self._angular_spread( self.zenith_of_arrival, np.repeat(self.cluster_powers[:, None], self.zenith_of_arrival.shape[1], axis=1), ) @cached_property def zenith_departure_spread(self) -> float: """Spread of zenith of departure angles. Returns: Angular spread in radians. """ return self._angular_spread( self.zenith_of_departure, np.repeat(self.cluster_powers[:, None], self.zenith_of_departure.shape[1], axis=1), )
[docs] def to_HDF(self, group: Group) -> None: """Serialize the object state to HDF5. Dumps the object's state and additional information to a HDF5 group. Args: group (Group): The HDF5 group to which the object is serialized. """ # Serialize base class ChannelRealization.to_HDF(self, group) # Serialize attributes group.attrs["line_of_sight"] = self.line_of_sight group.attrs["rice_factor"] = self.rice_factor group.attrs["cluster_delay_spread"] = self.cluster_delay_spread # Serialize datasets HDFSerializable._write_dataset(group, "azimuth_of_arrival", self.azimuth_of_arrival) HDFSerializable._write_dataset(group, "zenith_of_arrival", self.zenith_of_arrival) HDFSerializable._write_dataset(group, "azimuth_of_departure", self.azimuth_of_departure) HDFSerializable._write_dataset(group, "zenith_of_departure", self.zenith_of_departure) HDFSerializable._write_dataset(group, "cluster_delays", self.cluster_delays) HDFSerializable._write_dataset(group, "cluster_powers", self.cluster_powers) HDFSerializable._write_dataset( group, "polarization_transformations", self.polarization_transformations )
[docs] @classmethod def From_HDF( cls: Type[ClusterDelayLineRealization], group: Group, alpha_device: Device, beta_device: Device, ) -> ClusterDelayLineRealization: # Deserialize base class parameters params = cls._parameters_from_HDF(group) # Deserialize attributes params["line_of_sight"] = group.attrs["line_of_sight"] params["rice_factor"] = group.attrs["rice_factor"] params["cluster_delay_spread"] = group.attrs["cluster_delay_spread"] # Deserialize datasets params["azimuth_of_arrival"] = np.array(group["azimuth_of_arrival"], dtype=np.float_) params["zenith_of_arrival"] = np.array(group["zenith_of_arrival"], dtype=np.float_) params["azimuth_of_departure"] = np.array(group["azimuth_of_departure"], dtype=np.float_) params["zenith_of_departure"] = np.array(group["zenith_of_departure"], dtype=np.float_) params["cluster_delays"] = np.array(group["cluster_delays"], dtype=np.float_) params["cluster_powers"] = np.array(group["cluster_powers"], dtype=np.float_) params["polarization_transformations"] = np.array( group["polarization_transformations"], dtype=np.complex_ ) # Initialize cdl realization instance return cls(alpha_device, beta_device, **params)
[docs] class ClusterDelayLineBase(Channel[ClusterDelayLineRealization]): delay_normalization: DelayNormalization """The delay normalization routine applied during channel sampling.""" # Cluster scaling factors for the angle of arrival __azimuth_scaling_factors = np.array( [ [4, 0.779], [5, 0.86], [8, 1.018], [10, 1.090], [11, 1.123], [12, 1.146], [14, 1.19], [15, 1.211], [16, 1.226], [19, 1.273], [20, 1.289], [25, 1.358], ], dtype=float, ) __zenith_scaling_factors = np.array( [ [8, 0.889], [10, 0.957], [11, 1.031], [12, 1.104], [15, 1.108], [19, 1.184], [20, 1.178], [25, 1.282], ], dtype=float, ) # Ray offset angles _ray_offset_angles = np.array( [ 0.0447, -0.0447, 0.1413, -0.1413, 0.2492, -0.2492, 0.3715, -0.3715, 0.5129, -0.5129, 0.6797, -0.6797, 0.8844, -0.8844, 1.1481, -1.1481, 1.5195, -1.5195, 2.1551, -2.1551, ] ) # Sub-cluster partitions for the three strongest clusters subcluster_indices: List[List[int]] = [ [0, 1, 2, 3, 4, 5, 6, 7, 18, 19], [8, 9, 10, 11, 16, 17], [12, 13, 14, 15], ] def __init__( self, alpha_device: SimulatedDevice | None = None, beta_device: SimulatedDevice | None = None, gain: float = 1.0, delay_normalization: DelayNormalization = DelayNormalization.ZERO, **kwargs, ) -> None: """ Args: alpha_device (SimulatedDevice, optional): First device linked by the :class:`.ClusterDelayLineBase` instance that generated this realization. beta_device (SimulatedDevice, optional): Second device linked by the :class:`.ClusterDelayLineBase` instance that generated this realization. gain (float, optional): Linear power gain factor a signal experiences when being propagated over this realization. :math:`1.0` by default. delay_normalization (DelayNormalization, optional): The delay normalization routine applied during channel sampling. """ # Initialize base class Channel.__init__(self, alpha_device, beta_device, gain, **kwargs) # Initialize class attributes self.delay_normalization = delay_normalization @property @abstractmethod def line_of_sight(self) -> bool: """Does this model assume direct line of sight between the two devices? Referred to as :math:`LOS` within the standard. Returns: bool: Line of sight indicator. """ ... # pragma: no cover @property @abstractmethod def delay_spread_mean(self) -> float: """Mean of the cluster delay spread. The spread realization and its mean are referred to as :math:`\\mathrm{DS}` and :math:`\\mu_{\\mathrm{lgDS}}` within the the standard, respectively. Returns: float: Mean delay spread in seconds. """ ... # pragma: no cover @property @abstractmethod def delay_spread_std(self) -> float: """Standard deviation of the cluster delay spread. The spread realization and its standard deviation are referred to as :math:`\\mathrm{DS}` and :math:`\\sigma_{\\mathrm{lgDS}}` within the the standard, respectively. Returns: float: Delay spread standard deviation in seconds. Raises: ValueError: If the standard deviation is smaller than zero. """ ... # pragma: no cover @property @abstractmethod def aod_spread_mean(self) -> float: """Mean of the Azimuth Angle-of-Departure spread. The spread realization and its mean are referred to as :math:`\\mathrm{ASD}` and :math:`\\mu_{\\mathrm{lgASD}}` within the the standard, respectively. Returns: float: Mean angle spread in seconds """ ... # pragma: no cover @property @abstractmethod def aod_spread_std(self) -> float: """Standard deviation of the Azimuth Angle-of-Departure spread. The spread realization and its standard deviation are referred to as :math:`\\mathrm{ASD}` and :math:`\\sigma_{\\mathrm{lgASD}}` within the the standard, respectively. Returns: float: Angle spread standard deviation in seconds. Raises: ValueError: If the standard deviation is smaller than zero. """ ... # pragma: no cover @property @abstractmethod def aoa_spread_mean(self) -> float: """Mean of the Azimuth Angle-of-Arriaval spread. The spread realization and its mean are referred to as :math:`\\mathrm{ASA}` and :math:`\\mu_{\\mathrm{lgASA}}` within the the standard, respectively. Returns: float: Mean angle spread in seconds """ ... # pragma: no cover @property @abstractmethod def aoa_spread_std(self) -> float: """Standard deviation of the Azimuth Angle-of-Arrival spread. The spread realization and its standard deviation are referred to as :math:`\\mathrm{ASA}` and :math:`\\sigma_{\\mathrm{lgASA}}` within the the standard, respectively. Returns: float: Angle spread standard deviation in seconds. Raises: ValueError: If the standard deviation is smaller than zero. """ ... # pragma: no cover @property @abstractmethod def zoa_spread_mean(self) -> float: """Mean of the Zenith Angle-of-Arriaval spread. The spread realization and its mean are referred to as :math:`\\mathrm{ZSA}` and :math:`\\mu_{\\mathrm{lgZSA}}` within the the standard, respectively. Returns: float: Mean angle spread in seconds """ ... # pragma: no cover @property @abstractmethod def zoa_spread_std(self) -> float: """Standard deviation of the Zenith Angle-of-Arrival spread. The spread realization and its standard deviation are referred to as :math:`\\mathrm{ZSA}` and :math:`\\sigma_{\\mathrm{lgZSA}}` within the the standard, respectively. Returns: float: Angle spread standard deviation in seconds. Raises: ValueError: If the standard deviation is smaller than zero. """ ... # pragma: no cover @property @abstractmethod def zod_spread_mean(self) -> float: """Mean of the Zenith Angle-of-Departure spread. The spread realization and its mean are referred to as :math:`\\mathrm{ZOD}` and :math:`\\mu_{\\mathrm{lgZSD}}` within the the standard, respectively. Returns: float: Mean angle spread in degrees """ ... # pragma: no cover @property @abstractmethod def zod_spread_std(self) -> float: """Standard deviation of the Zenith Angle-of-Departure spread. The spread realization and its standard deviation are referred to as :math:`\\mathrm{ZOD}` and :math:`\\sigma_{\\mathrm{lgZOD}}` within the the standard, respectively. Returns: float: Angle spread standard deviation in degrees. Raises: ValueError: If the standard deviation is smaller than zero. """ ... # pragma: no cover @property @abstractmethod def zod_offset(self) -> float: """Offset between Zenith Angle-of-Arrival and Angle-of-Departure. The offset is referred to as :math:`\\mu_{\\mathrm{offset,ZOD}}` within the standard. Returns: float: The offset in degrees. """ ... # pragma: no cover ############################### # ToDo: Shadow fading function ############################### @property @abstractmethod def rice_factor_mean(self) -> float: """Mean of the rice factor distribution. The rice factor realization and its mean are referred to as :math:`K` and :math:`\\mu_K` within the the standard, respectively. Returns: float: Rice factor mean in dB. """ ... # pragma: no cover @property @abstractmethod def rice_factor_std(self) -> float: """Standard deviation of the rice factor distribution. The rice factor realization and its standard deviation are referred to as :math:`K` and :math:`\\sigma_K` within the the standard, respectively. Returns: float: Rice factor standard deviation in dB. Raises: ValueError: If the standard deviation is smaller than zero. """ ... # pragma: no cover @property @abstractmethod def delay_scaling(self) -> float: """Delay scaling proportionality factor Referred to as :math:`r_{\\tau}` within the standard. Returns: float: Scaling factor. Raises: ValueError: If scaling factor is smaller than one. """ ... # pragma: no cover @property @abstractmethod def cross_polarization_power_mean(self) -> float: """Mean of the cross-polarization power. The cross-polarization power and its mean are referred to as :math:`\\mathrm{XPR}` and :math:`\\mu_{\\mathrm{XPR}}` within the the standard, respectively. Returns: float: Mean power in dB. """ ... # pragma: no cover @property @abstractmethod def cross_polarization_power_std(self) -> float: """Standard deviation of the cross-polarization power. The cross-polarization power and its standard deviation are referred to as :math:`\\mathrm{XPR}` and :math:`\\sigma_{\\mathrm{XPR}}` within the the standard, respectively. Returns: float: Power standard deviation in dB. Raises: ValueError: If the standard deviation is smaller than zero. """ ... # pragma: no cover @property @abstractmethod def num_clusters(self) -> int: """Number of clusters. Referred to as :math:`M` within the standard. Returns: int: Number of clusters. Raises: ValueError: If the number of clusters is smaller than one. """ ... # pragma: no cover @property @abstractmethod def num_rays(self) -> int: """Number of rays per cluster. Referred to as :math:`N` within the standard. Returns: int: Number of rays. """ ... # pragma: no cover @property @abstractmethod def cluster_delay_spread(self) -> float: """Delay spread within an individual cluster. Referred to as :math:`c_{DS}` within the standard. Returns: float: Delay spread in seconds. Raises: ValueError: If spread is smaller than zero. """ ... # pragma: no cover @property @abstractmethod def cluster_aod_spread(self) -> float: """Azimuth Angle-of-Departure spread within an individual cluster. Referred to as :math:`c_{ASD}` within the standard. Returns: float: Angle spread in degrees. Raises: ValueError: If spread is smaller than zero. """ ... # pragma: no cover @property @abstractmethod def cluster_aoa_spread(self) -> float: """Azimuth Angle-of-Arrival spread within an individual cluster. Referred to as :math:`c_{ASA}` within the standard. Returns: float: Angle spread in degrees. Raises: ValueError: If spread is smaller than zero. """ ... # pragma: no cover @property @abstractmethod def cluster_zoa_spread(self) -> float: """Zenith Angle-of-Arrival spread within an individual cluster. Referred to as :math:`c_{ZSA}` within the standard. Returns: float: Angle spread in degrees. Raises: ValueError: If spread is smaller than zero. """ ... # pragma: no cover @property @abstractmethod def cluster_shadowing_std(self) -> float: """Standard deviation of the cluster shadowing. Referred to as :math:`\\zeta` within the the standard. Returns: float: Cluster shadowing standard deviation. Raises: ValueError: If the deviation is smaller than zero. """ ... # pragma: no cover def _cluster_delays( self, delay_spread: float, rice_factor: float ) -> Tuple[np.ndarray, np.ndarray]: """Compute a single sample set of normalized cluster delays. A single cluster delay is referred to as :math:`\\tau_n` within the the standard. Args: delay_spread (float): Delay spread in seconds. rice_factor (float): Rice factor K in dB. Returns: Tuple of - DRaw delay samples - True delays """ # Generate delays according to the configured spread and scales raw_delays = ( -self.delay_scaling * delay_spread * np.log(self._rng.uniform(size=self.num_clusters)) ) # Sort the delays in ascending order raw_delays.sort() # Normalize delays if the respective flag is enabled if ( self.delay_normalization == DelayNormalization.ZERO or self.delay_normalization == DelayNormalization.TOF ): raw_delays -= raw_delays[0] # Scale delays, if required by the configuration scaled_delays = raw_delays.copy() # In case of line of sight, scale the delays by the appropriate K-factor if self.line_of_sight: rice_scale = ( 0.775 - 0.0433 * rice_factor + 2e-4 * rice_factor**2 + 17e-6 * rice_factor**3 ) scaled_delays /= rice_scale # Account for the time of flight over the line of sight, if required if self.delay_normalization == DelayNormalization.TOF: time_of_flight = ( np.linalg.norm(self.alpha_device.position - self.beta_device.position, 2) / speed_of_light ) scaled_delays += time_of_flight # Return the raw and scaled delays, since they are both required for further processing return raw_delays, scaled_delays def _cluster_powers( self, delay_spread: float, delays: np.ndarray, rice_factor: float ) -> np.ndarray: """Compute a single sample set of normalized cluster power factors from delays. A single cluster power factor is referred to as :math:`P_n` within the the standard. Args: delay_spread (float): Delay spread in seconds. delays (np.ndarray): Vector of cluster delays. rice_factor (float): Rice factor K in dB. Returns: np.ndarray: Vector of cluster power scales. """ shadowing = 10 ** ( -0.1 * self._rng.normal(scale=self.cluster_shadowing_std, size=delays.shape) ) powers = ( np.exp(-delays * (self.delay_scaling - 1) / (self.delay_scaling * delay_spread)) * shadowing ) # In case of line of sight, add a specular component to the cluster delays if self.line_of_sight: linear_rice_factor = db2lin(rice_factor) powers /= (1 + linear_rice_factor) * np.sum(powers.flat) powers[0] += linear_rice_factor / (1 + linear_rice_factor) else: powers /= np.sum(powers.flat) return powers def _ray_azimuth_angles( self, cluster_powers: np.ndarray, rice_factor: float, los_azimuth: float, direction: Literal["arrival", "departure"], ) -> np.ndarray: """Compute cluster ray azimuth angles of arrival or departure. Args: cluster_powers (np.ndarray): Vector of cluster powers. The length determines the number of clusters. rice_factor (float): Rice factor in dB. los_azimuth (float): Line of sight azimuth angle to the target in degrees. Returns: np.ndarray: Matrix of angles in degrees. The first dimension indicates the cluster index, the second dimension the ray index. """ # Determine the closest scaling factor scale_index = np.argmin(np.abs(self.__azimuth_scaling_factors[:, 0] - len(cluster_powers))) angle_scale = self.__azimuth_scaling_factors[scale_index, 1] size = cluster_powers.shape # Scale the scale (hehe) in the line of sight case if self.line_of_sight: angle_scale *= ( 1.1035 - 0.028 * rice_factor - 2e-3 * rice_factor**2 + 1e-4 * rice_factor**3 ) # Draw azimuth angle spread from the distribution spread_mean = self.aoa_spread_mean if direction == "arrival" else self.aod_spread_mean spread_std = self.aoa_spread_std if direction == "arrival" else self.aod_spread_std spread = 10 ** self._rng.normal(spread_mean, spread_std, size=size) angles: np.ndarray = ( 2 * (spread / 1.4) * np.sqrt(-np.log(cluster_powers / cluster_powers.max())) / angle_scale ) # Assign positive / negative integers and add some noise angle_variation = self._rng.normal(0.0, (spread / 7) ** 2, size=size) angle_spread_sign = self._rng.choice([-1.0, 1.0], size=size) spread_angles = angle_spread_sign * angles + angle_variation # Add the actual line of sight term if self.line_of_sight: # The first angle within the list is exactly the line of sight component spread_angles += los_azimuth - spread_angles[0] else: spread_angles += los_azimuth # Spread the angles cluster_spread = ( self.cluster_aoa_spread if direction == "arrival" else self.cluster_aod_spread ) ray_offsets = cluster_spread * self._ray_offset_angles ray_angles = np.tile(spread_angles[:, None], len(ray_offsets)) + ray_offsets return ray_angles def _ray_zoa( self, cluster_powers: np.ndarray, rice_factor: float, los_zenith: float ) -> np.ndarray: """Compute cluster ray zenith angles of arrival. Args: cluster_powers (np.ndarray): Vector of cluster powers. The length determines the number of clusters. rice_factor (float): Rice factor in dB. los_zenith (float): Line of sight zenith angle to the target in degrees. Returns: np.ndarray: Matrix of angles in degrees. The first dimension indicates the cluster index, the second dimension the ray index. """ size = cluster_powers.shape # Select the scaling factor scale_index = np.argmin(np.abs(self.__zenith_scaling_factors[:, 0] - len(cluster_powers))) zenith_scale = self.__zenith_scaling_factors[scale_index, 1] if self.line_of_sight: zenith_scale *= ( 1.3086 + 0.0339 * rice_factor - 0.0077 * rice_factor**2 + 2e-4 * rice_factor**3 ) # Draw zenith angle spread from the distribution zenith_spread = 10 ** self._rng.normal(self.zoa_spread_mean, self.zoa_spread_std) # Generate angle starting point zenith_centroids: np.ndarray = ( -zenith_spread * np.log(cluster_powers / cluster_powers.max()) / zenith_scale ) cluster_variation = self._rng.normal(0.0, (zenith_spread / 7) ** 2, size=size) cluster_sign = self._rng.choice([-1.0, 1.0], size=size) # ToDo: Treat the BST-UT case!!!! (los_zenith = 90°) cluster_zenith = cluster_sign * zenith_centroids + cluster_variation if self.line_of_sight: cluster_zenith += los_zenith - cluster_zenith[0] else: cluster_zenith += los_zenith # Spread the angles ray_offsets = self.cluster_zoa_spread * self._ray_offset_angles ray_zenith = np.tile(cluster_zenith[:, None], len(ray_offsets)) + ray_offsets return ray_zenith def _ray_zod( self, cluster_powers: np.ndarray, rice_factor: float, los_zenith: float ) -> np.ndarray: """Compute cluster ray zenith angles of departure. Args: cluster_powers (np.ndarray): Vector of cluster powers. The length determines the number of clusters. rice_factor (float): Rice factor in dB. los_zenith (float): Line of sight zenith angle to the target in degrees. Returns: np.ndarray: Matrix of angles in degrees. The first dimension indicates the cluster index, the second dimension the ray index. """ size = cluster_powers.shape # Select the scaling factor scale_index = np.argmin(np.abs(self.__zenith_scaling_factors[:, 0] - len(cluster_powers))) zenith_scale = self.__zenith_scaling_factors[scale_index, 1] if self.line_of_sight: zenith_scale *= ( 1.3086 + 0.0339 * rice_factor - 0.0077 * rice_factor**2 + 2e-4 * rice_factor**3 ) # Draw zenith angle spread from the distribution zenith_spread = 10 ** self._rng.normal(self.zod_spread_mean, self.zod_spread_std, size=size) # Generate angle starting point zenith_centroids: np.ndarray = ( -zenith_spread * np.log(cluster_powers / cluster_powers.max()) / zenith_scale ) cluster_variation = self._rng.normal(0.0, (zenith_spread / 7) ** 2, size=size) cluster_sign = self._rng.choice([-1.0, 1.0], size=size) # ToDo: Treat the BST-UT case!!!! (los_zenith = 90°) # Equation 7.5-19 cluster_zenith = cluster_sign * zenith_centroids + cluster_variation + self.zod_offset if self.line_of_sight: cluster_zenith += los_zenith - cluster_zenith[0] else: cluster_zenith += los_zenith # Spread the angles # Equation 7.5 -20 ray_offsets = 3 / 8 * 10**self.zoa_spread_mean * self._ray_offset_angles ray_zenith = np.tile(cluster_zenith[:, None], len(ray_offsets)) + ray_offsets return ray_zenith def _realize(self) -> ClusterDelayLineRealization: delay_spread = 10 ** self._rng.normal(self.delay_spread_mean, self.delay_spread_std) rice_factor = self._rng.normal(loc=self.rice_factor_mean, scale=self.rice_factor_std) # Compute the line of sight distance los_distance = np.linalg.norm( self.alpha_device.global_position - self.beta_device.global_position, 2 ) # Ensure transmiter and receiver are not located at identical positions if los_distance == 0.0: raise RuntimeError( "Identical device positions violate the far-field assumption of the 3GPP CDL channel model" ) # Compute line of sight directions in local coordinates for both devices tx_los_direction = self.alpha_device.backwards_transformation.transform_direction( self.beta_device.global_position, True ) rx_los_direction = self.beta_device.backwards_transformation.transform_direction( self.alpha_device.global_position, True ) # Extract directive angles in spherical coordinates tx_los_angles = tx_los_direction.to_spherical() rx_los_angles = rx_los_direction.to_spherical() # Compute cluster delays and powers num_clusters = self.num_clusters num_rays = self.num_rays raw_cluster_delays, cluster_delays = self._cluster_delays(delay_spread, rice_factor) cluster_powers = self._cluster_powers(delay_spread, raw_cluster_delays, rice_factor) # Compute cluster angles ray_aod = ( pi / 180 * self._ray_azimuth_angles( cluster_powers, rice_factor, 180 * tx_los_angles[0] / pi, "departure" ) ) ray_aoa = ( pi / 180 * self._ray_azimuth_angles( cluster_powers, rice_factor, 180 * rx_los_angles[0] / pi, "arrival" ) ) ray_zod = pi / 180 * self._ray_zod(cluster_powers, rice_factor, 180 * tx_los_angles[1] / pi) ray_zoa = pi / 180 * self._ray_zoa(cluster_powers, rice_factor, 180 * rx_los_angles[1] / pi) # Couple cluster angles randomly (step 8) # This is equivalent to shuffeling the angles within each cluster set for ray_angles in (ray_aod, ray_aoa, ray_zod, ray_zoa): for a in ray_angles: self._rng.shuffle(a) # Generate cross-polarization power ratios (step 9) cross_polarization = 10 ** ( 0.1 * self._rng.normal( self.cross_polarization_power_mean, self.cross_polarization_power_std, size=(num_clusters, num_rays), ) ) # Draw initial random phases (step 10) # A single 2x2 slice represents the jones matrix transforming the polarization of a single ray polarization_transformations = np.exp( 2j * pi * self._rng.uniform(size=(2, 2, num_clusters, num_rays)) ) polarization_transformations[0, 1, ::] *= cross_polarization**-0.5 polarization_transformations[1, 0, ::] *= cross_polarization**-0.5 return ClusterDelayLineRealization( self.alpha_device, self.beta_device, self.gain, self.line_of_sight, rice_factor, ray_aoa, ray_zoa, ray_aod, ray_zod, cluster_delays, self.cluster_delay_spread, cluster_powers, polarization_transformations, ) @property def _center_frequency(self) -> float: return 0.5 * (self.alpha_device.carrier_frequency + self.beta_device.carrier_frequency)
[docs] def recall_realization(self, group: Group) -> ClusterDelayLineRealization: return ClusterDelayLineRealization.From_HDF(group, self.alpha_device, self.beta_device)
[docs] class ClusterDelayLine(ClusterDelayLineBase, Serializable): """3GPP Cluster Delay Line Channel Model.""" yaml_tag = "ClusterDelayLine" __line_of_sight: bool __delay_spread_mean: float __delay_spread_std: float __aod_spread_mean: float __aod_spread_std: float __aoa_spread_mean: float __aoa_spread_std: float __zoa_spread_mean: float __zoa_spread_std: float __zod_spread_mean: float __zod_spread_std: float __zod_offset: float __rice_factor_mean: float __rice_factor_std: float __delay_scaling: float __cross_polarization_power_mean: float __cross_polarization_power_std: float __num_clusters: int __num_rays: int __cluster_delay_spread: float __cluster_aod_spread: float __cluster_aoa_spread: float __cluster_zoa_spread: float __cluster_shadowing_std: float def __init__( self, alpha_device: SimulatedDevice | None = None, beta_device: SimulatedDevice | None = None, gain: float = 1.0, line_of_sight: bool = True, delay_spread_mean: float = 7.14, delay_spread_std: float = 0.38, aod_spread_mean: float = 1.21, aod_spread_std: float = 0.41, aoa_spread_mean: float = 1.73, aoa_spread_std: float = 0.28, zoa_spread_mean: float = 0.73, zoa_spread_std: float = 0.34, zod_spread_mean: float = 0.1, zod_spread_std: float = 0.0, zod_offset: float = 0.0, rice_factor_mean: float = 9.0, rice_factor_std: float = 5.0, delay_scaling: float = 1.0, cross_polarization_power_mean: float = 9.0, cross_polarization_power_std: float = 3.0, num_clusters: int = 12, num_rays: int = 20, cluster_delay_spread: float = 5e-9, cluster_aod_spread: float = 5.0, cluster_aoa_spread: float = 17.0, cluster_zoa_spread: float = 7.0, cluster_shadowing_std: float = 3.0, **kwargs: Any, ) -> None: """ Args: alpha_device (SimulatedDevice, optional): First device linked by the :class:`.ClusterDelayLine` instance that generated this realization. beta_device (SiumulatedDevice, optional): Second device linked by the :class:`.ClusterDelayLine` instance that generated this realization. gain (float, optional): Linear power gain factor a signal experiences when being propagated over this realization. :math:`1.0` by default. num_clusters (int, optional): Number of generated clusters per channel sample. delay_spread (float, optional): Root-Mean-Square spread of the cluster delay in seconds. delay_scaling (float, optional): Delay distribution proportionality factor. rice_factor_mean (float, optional): Mean of the rice factor K. rice_factor_std (float, optional): Standard deviation of the rice factor K. cluster_shadowing_std (float, optional): Cluster shadowing standard deviation in dB. line_of_sight (bool, optional): Is this model a line-of-sight model? """ # Set initial parameters self.line_of_sight = line_of_sight self.delay_spread_mean = delay_spread_mean self.delay_spread_std = delay_spread_std self.aod_spread_mean = aod_spread_mean self.aod_spread_std = aod_spread_std self.aoa_spread_mean = aoa_spread_mean self.aoa_spread_std = aoa_spread_std self.zoa_spread_mean = zoa_spread_mean self.zoa_spread_std = zoa_spread_std self.zod_spread_mean = zod_spread_mean self.zod_spread_std = zod_spread_std self.zod_offset = zod_offset self.rice_factor_mean = rice_factor_mean self.rice_factor_std = rice_factor_std self.delay_scaling = delay_scaling self.cross_polarization_power_mean = cross_polarization_power_mean self.cross_polarization_power_std = cross_polarization_power_std self.num_clusters = num_clusters self.num_rays = num_rays self.cluster_delay_spread = cluster_delay_spread self.cluster_aod_spread = cluster_aod_spread self.cluster_aoa_spread = cluster_aoa_spread self.cluster_zoa_spread = cluster_zoa_spread self.cluster_shadowing_std = cluster_shadowing_std # Initialize base class ClusterDelayLineBase.__init__(self, alpha_device, beta_device, gain, **kwargs) @property def line_of_sight(self) -> bool: return self.__line_of_sight @line_of_sight.setter def line_of_sight(self, value: bool) -> None: self.__line_of_sight = value @property def delay_spread_mean(self) -> float: return self.__delay_spread_mean @delay_spread_mean.setter def delay_spread_mean(self, value: float) -> None: self.__delay_spread_mean = value @property def delay_spread_std(self) -> float: return self.__delay_spread_std @delay_spread_std.setter def delay_spread_std(self, value: float) -> None: if value < 0.0: raise ValueError("Delay spread standard deviation must be greater or equal to zero") self.__delay_spread_std = value @property def aod_spread_mean(self) -> float: return self.__aod_spread_mean @aod_spread_mean.setter def aod_spread_mean(self, value: float) -> None: self.__aod_spread_mean = value @property def aod_spread_std(self) -> float: return self.__aod_spread_std @aod_spread_std.setter def aod_spread_std(self, value: float) -> None: if value < 0.0: raise ValueError("Angle spread standard deviation must be greater or equal to zero") self.__aod_spread_std = value @property def aoa_spread_mean(self) -> float: return self.__aoa_spread_mean @aoa_spread_mean.setter def aoa_spread_mean(self, value: float) -> None: self.__aoa_spread_mean = value @property def aoa_spread_std(self) -> float: return self.__aoa_spread_std @aoa_spread_std.setter def aoa_spread_std(self, value: float) -> None: if value < 0.0: raise ValueError("Angle spread standard deviation must be greater or equal to zero") self.__aoa_spread_std = value @property def zoa_spread_mean(self) -> float: return self.__zoa_spread_mean @zoa_spread_mean.setter def zoa_spread_mean(self, value: float) -> None: self.__zoa_spread_mean = value @property def zoa_spread_std(self) -> float: return self.__zoa_spread_std @zoa_spread_std.setter def zoa_spread_std(self, value: float) -> None: if value < 0.0: raise ValueError("Angle spread standard deviation must be greater or equal to zero") self.__zoa_spread_std = value @property def zod_spread_mean(self) -> float: return self.__zod_spread_mean @zod_spread_mean.setter def zod_spread_mean(self, value: float) -> None: self.__zod_spread_mean = value @property def zod_spread_std(self) -> float: return self.__zod_spread_std @zod_spread_std.setter def zod_spread_std(self, value: float) -> None: if value < 0.0: raise ValueError("Zenith spread standard deviation must be greater or equal to zero") self.__zod_spread_std = value @property def zod_offset(self) -> float: return self.__zod_offset @zod_offset.setter def zod_offset(self, value: float) -> None: self.__zod_offset = value ############################### # ToDo: Shadow fading function ############################### @property def rice_factor_mean(self) -> float: return self.__rice_factor_mean @rice_factor_mean.setter def rice_factor_mean(self, value: float) -> None: if value < 0.0: raise ValueError("Rice factor must be greater or equal to zero") self.__rice_factor_mean = value @property def rice_factor_std(self) -> float: return self.__rice_factor_std @rice_factor_std.setter def rice_factor_std(self, value: float) -> None: if value < 0.0: raise ValueError("Rice factor standard deviation must be greater or equal to zero") self.__rice_factor_std = value @property def delay_scaling(self) -> float: return self.__delay_scaling @delay_scaling.setter def delay_scaling(self, value: float) -> None: if value < 1.0: raise ValueError("Delay scaling must be greater or equal to one") self.__delay_scaling = value @property def cross_polarization_power_mean(self) -> float: return self.__cross_polarization_power_mean @cross_polarization_power_mean.setter def cross_polarization_power_mean(self, value: float) -> None: self.__cross_polarization_power_mean = value @property def cross_polarization_power_std(self) -> float: return self.__cross_polarization_power_std @cross_polarization_power_std.setter def cross_polarization_power_std(self, value: float) -> None: if value < 0.0: raise ValueError( "Cross-polarization power standard deviation must be greater or equal to zero" ) self.__cross_polarization_power_std = value @property def num_clusters(self) -> int: return self.__num_clusters @num_clusters.setter def num_clusters(self, value: int) -> None: if value < 1: raise ValueError("Number of clusters must be greater or equal to one") self.__num_clusters = value @property def num_rays(self) -> int: return self.__num_rays @num_rays.setter def num_rays(self, value: int) -> None: if value < 1: raise ValueError("Number of rays per cluster must be greater or equal to one") self.__num_rays = value @property def cluster_delay_spread(self) -> float: return self.__cluster_delay_spread @cluster_delay_spread.setter def cluster_delay_spread(self, value: float) -> None: if value < 0.0: raise ValueError("Cluster delay spread must be greater or equal to zero") self.__cluster_delay_spread = value @property def cluster_aod_spread(self) -> float: return self.__cluster_aod_spread @cluster_aod_spread.setter def cluster_aod_spread(self, value: float) -> None: if value < 0.0: raise ValueError("Cluster angle spread must be greater or equal to zero") self.__cluster_aod_spread = value @property def cluster_aoa_spread(self) -> float: return self.__cluster_aoa_spread @cluster_aoa_spread.setter def cluster_aoa_spread(self, value: float) -> None: if value < 0.0: raise ValueError("Cluster angle spread must be greater or equal to zero") self.__cluster_aoa_spread = value @property def cluster_zoa_spread(self) -> float: return self.__cluster_zoa_spread @cluster_zoa_spread.setter def cluster_zoa_spread(self, value: float) -> None: if value < 0.0: raise ValueError("Cluster angle spread must be greater or equal to zero") self.__cluster_zoa_spread = value @property def cluster_shadowing_std(self) -> float: return self.__cluster_shadowing_std @cluster_shadowing_std.setter def cluster_shadowing_std(self, value: float) -> None: if value < 0.0: raise ValueError( "Cluster shadowing standard deviation must be greater or equal to zero" ) self.__cluster_shadowing_std = value