# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import Any, Generator, Set, Tuple, List
from typing_extensions import override
import matplotlib.pyplot as plt
import numpy as np
from numpy import cos, exp
from scipy.constants import pi
from sparse import GCXS  # type: ignore
from hermespy.core import (
    AntennaArrayState,
    AntennaMode,
    ChannelStateInformation,
    ChannelStateFormat,
    DeserializationProcess,
    Serializable,
    SerializationProcess,
    SignalBlock,
    VAT,
)
from ..channel import (
    Channel,
    ChannelSample,
    LinkState,
    ChannelSampleHook,
    ChannelRealization,
    InterpolationMode,
)
from ..consistent import ConsistentUniform, ConsistentGenerator, ConsistentRealization
__author__ = "Andre Noll Barreto"
__copyright__ = "Copyright 2024, Barkhausen Institut gGmbH"
__credits__ = ["Andre Noll Barreto", "Tobias Kronauer", "Jan Adler"]
__license__ = "AGPLv3"
__version__ = "1.5.0"
__maintainer__ = "Jan Adler"
__email__ = "jan.adler@barkhauseninstitut.org"
__status__ = "Prototype"
[docs]
class AntennaCorrelation(Serializable):
    """Base class for statistical modeling of antenna array correlations."""
    __channel: Channel | None
    def __init__(self, channel: Channel | None = None) -> None:
        """
        Args:
            channel:
                Channel this correlation model configures.
                `None` if the model is currently considered floating.
        """
        self.channel = channel
[docs]
    def sample_covariance(self, antennas: AntennaArrayState, mode: AntennaMode) -> np.ndarray:
        """Sample the covariance matrix of a given antenna array.
        Args:
            antennas:
                State of the antenna array.
            mode:
                Mode of the antenna array, i.e. transmit or receive.
        Returns: Two-dimensional numpy array representing the covariance matrix.
        """
        ...  # pragma: no cover 
    @property
    def channel(self) -> Channel | None:
        """The channel this correlation model configures.
        Returns:
            Handle to the channel.
            `None` if the model is currently considered floating
        """
        return self.__channel
    @channel.setter
    def channel(self, value: Channel | None) -> None:
        self.__channel = value 
[docs]
class CustomAntennaCorrelation(AntennaCorrelation):
    """Customizable antenna correlations."""
    __covariance_matrix: np.ndarray
    def __init__(self, covariance: np.ndarray) -> None:
        """
        Args:
            covariance:
                Postive definte square antenna covariance matrix.
        """
        self.covariance = covariance
[docs]
    def sample_covariance(self, antennas: AntennaArrayState, mode: AntennaMode) -> np.ndarray:
        num_antennas = (
            antennas.num_transmit_antennas
            if mode == AntennaMode.TX
            else antennas.num_receive_antennas
        )
        if self.__covariance_matrix.shape[0] < num_antennas:
            raise ValueError("Antenna correlation matrix does not match the number of antennas")
        return self.__covariance_matrix[:num_antennas, :num_antennas] 
    @property
    def covariance(self) -> np.ndarray:
        """Postive definte square antenna covariance matrix."""
        return self.__covariance_matrix
    @covariance.setter
    def covariance(self, value: np.ndarray) -> None:
        if value.ndim != 2 or not np.allclose(value, value.T.conj()):
            raise ValueError("Antenna correlation must be a hermitian matrix")
        if np.any(np.linalg.eigvals(value) <= 0.0):
            raise ValueError("Antenna correlation matrix must be positive definite")
        self.__covariance_matrix = value
[docs]
    @override
    def serialize(self, process: SerializationProcess) -> None:
        process.serialize_array(self.__covariance_matrix, "covariance_matrix") 
[docs]
    @classmethod
    @override
    def Deserialize(cls, process: DeserializationProcess) -> CustomAntennaCorrelation:
        return CustomAntennaCorrelation(
            process.deserialize_array("covariance_matrix", np.complex128)
        ) 
 
[docs]
class MultipathFadingSample(ChannelSample):
    """Immutable sample of a statistical multipath fading channel.
    Generated by the sample routine of a :class:`MultipathFadingRealization<MultipathFadingRealization>`.
    """
    __spatial_response: np.ndarray
    __max_delay: float
    def __init__(
        self,
        power_profile: np.ndarray,
        delay_profile: np.ndarray,
        los_angles: np.ndarray,
        nlos_angles: np.ndarray,
        los_phases: np.ndarray,
        nlos_phases: np.ndarray,
        los_gains: np.ndarray,
        nlos_gains: np.ndarray,
        los_doppler: float,
        nlos_doppler: float,
        spatial_response: np.ndarray,
        gain: float,
        state: LinkState,
    ) -> None:
        """
        Args:
            transmitter_state:
                State of the transmitting device at the time of sampling.
            receiver_state:
                State of the receiving device at the time of sampling.
            carrier_frequency:
                Carrier frequency of the channel in Hz.
            bandwidth:
                Bandwidth of the propagated signal in Hz.
            path_realizations:
                Realizations of the individual propagation paths.
            spatial_response:
                Spatial response matrix of the channel realization considering `alpha_device` is the transmitter and `beta_device` is the receiver.
            interpolation_mode:
                Interpolation behaviour of the channel realization's delay components with respect to the proagated signal's sampling rate.
        """
        # Initialize base class
        ChannelSample.__init__(self, state)
        # Initialize class attributes
        self.__power_profile = power_profile
        self.__delay_profile = delay_profile
        self.__los_angles = los_angles
        self.__nlos_angles = nlos_angles
        self.__los_phases = los_phases
        self.__nlos_phases = nlos_phases
        self.__los_gains = los_gains
        self.__nlos_gains = nlos_gains
        self.__los_doppler = los_doppler
        self.__nlos_doppler = nlos_doppler
        self.__spatial_response = spatial_response
        self.__gain = gain
        # Infer additional parameters
        self.__max_delay = self.__delay_profile.max()
    @property
    def power_profile(self) -> np.ndarray:
        """Power loss factor of each individual multipath tap."""
        return self.__power_profile
    @property
    def delay_profile(self) -> np.ndarray:
        """Delay in seconds of each individual multipath tap."""
        return self.__delay_profile
    @property
    def los_angles(self) -> np.ndarray:
        """Angle of arrival of the line-of sight components."""
        return self.__los_angles
    @property
    def nlos_angles(self) -> np.ndarray:
        """Angle of arrival of the non-line-of sight components."""
        return self.__nlos_angles
    @property
    def los_phases(self) -> np.ndarray:
        """Phase of the line-of sight components."""
        return self.__los_phases
    @property
    def nlos_phases(self) -> np.ndarray:
        """Phase of the non-line-of sight components."""
        return self.__nlos_phases
    @property
    def los_gains(self) -> np.ndarray:
        """Gain factors of the line-of sight components."""
        return self.__los_gains
    @property
    def nlos_gains(self) -> np.ndarray:
        """Gain factors of the non-line-of sight components."""
        return self.__nlos_gains
    @property
    def los_doppler(self) -> float:
        """Doppler frequency shift of the line-of sight components."""
        return self.__los_doppler
    @property
    def nlos_doppler(self) -> float:
        """Doppler frequency shift of the non-line-of sight components."""
        return self.__nlos_doppler
    @property
    def spatial_response(self) -> np.ndarray:
        """Spatial response matrix of the channel realization considering `alpha_device` is the transmitter and `beta_device` is the receiver."""
        return self.__spatial_response
    @property
    def gain(self) -> float:
        """Linear energy gain factor a signal experiences when being propagated over this realization."""
        return self.__gain
    @property
    def expected_energy_scale(self) -> float:
        return self.gain * np.sum(self.power_profile)
    def __path_impulse_generator(
        self, num_samples: int
    ) -> Generator[Tuple[np.ndarray, int], None, None]:
        path_delay_samples = np.rint(self.delay_profile * self.bandwidth).astype(int)
        num_sinusoids = self.__nlos_angles.shape[1]
        n = 1 + np.arange(num_sinusoids)
        timestamps = np.arange(num_samples) / self.bandwidth
        nlos_time = self.nlos_doppler * timestamps
        los_time = self.los_doppler * timestamps
        for (
            delay,
            power,
            los_gain,
            nlos_gain,
            los_angle,
            nlos_angles,
            los_phase,
            nlos_phases,
        ) in zip(
            path_delay_samples,
            self.power_profile,
            self.los_gains,
            self.nlos_gains,
            self.los_angles,
            self.nlos_angles,
            self.los_phases,
            self.nlos_phases,
        ):
            impulse = nlos_gain * np.sum(
                np.exp(
                    1j
                    * (
                        np.outer(nlos_time, np.cos((2 * pi * n + nlos_angles) / num_sinusoids))
                        + nlos_phases[None, :]
                    )
                ),
                axis=1,
                keepdims=False,
            )
            # Add the specular component
            impulse += los_gain * exp(1j * (los_time * cos(los_angle) + los_phase))
            # Scale by the overall path power
            impulse *= (self.gain * power) ** 0.5
            yield impulse, delay
[docs]
    def state(
        self,
        num_samples: int,
        max_num_taps: int,
        interpolation_mode: InterpolationMode = InterpolationMode.NEAREST,
    ) -> ChannelStateInformation:
        num_taps = min(1 + round(self.__max_delay * self.bandwidth), max_num_taps)
        siso_csi = np.zeros((num_samples, num_taps), dtype=np.complex128)
        for path_impulse, path_delay_samples in self.__path_impulse_generator(num_samples):
            # Skip paths with delays larger than the maximum delay required by the CSI request
            if path_delay_samples > num_taps:
                continue
            siso_csi[:, path_delay_samples] += path_impulse
        # For the multipath fading model, the MIMO CSI is the outer product of the SISO CSI with the spatial response
        # The resulting multidimensional array is sparse in its fourth dimension and converted to a GCXS array for memory efficiency
        mimo_csi = GCXS.from_numpy(
            np.einsum("ij,kl->ijkl", self.spatial_response, siso_csi), compressed_axes=(0, 1, 2)
        )
        state = ChannelStateInformation(
            ChannelStateFormat.IMPULSE_RESPONSE, mimo_csi, num_delay_taps=num_taps
        )
        return state 
    def _propagate(self, signal: SignalBlock, interpolation: InterpolationMode) -> SignalBlock:
        max_delay_in_samples = round(self.__max_delay * self.bandwidth)
        num_propagated_samples = signal.num_samples + max_delay_in_samples
        # Propagate the transmitted samples
        propagated_samples = np.zeros(
            (self.spatial_response.shape[0], num_propagated_samples), dtype=np.complex128
        )
        for path_impulse, path_num_delay_samples in self.__path_impulse_generator(
            signal.num_samples
        ):
            propagated_samples[
                :, path_num_delay_samples : path_num_delay_samples + signal.num_samples
            ] += (signal * path_impulse[np.newaxis, :])
        # Apply the channel's spatial response
        propagated_samples = self.spatial_response @ propagated_samples
        # Return the result
        propagated_block = SignalBlock(propagated_samples, signal.offset)
        return propagated_block
[docs]
    def plot_power_delay(self, axes: VAT | None = None) -> Tuple[plt.Figure, VAT]:
        if axes:
            _axes = axes
            figure = axes[0, 0].get_figure()
        else:
            figure, _axes = plt.subplots(1, 1, squeeze=False)
            figure.suptitle("Power Delay Profile")
        ax: plt.Axes = _axes.flat[0]
        ax.stem(self.__delay_profile, self.__power_profile)
        ax.set_xlabel("Delay [s]")
        ax.set_ylabel("Power [Watts]")
        ax.set_yscale("log")
        return figure, _axes 
 
[docs]
class MultipathFadingRealization(ChannelRealization[MultipathFadingSample]):
    """Realization of a statistical multipath fading channel.
    Generated by the :meth:`realize()<MultipathFadingChannel._realize>` routine of a :class:`MultipathFadingChannel<MultipathFadingChannel>`.
    """
    def __init__(
        self,
        random_realization: ConsistentRealization,
        antenna_correlation_variable: ConsistentUniform,
        los_angles_variable: float | ConsistentUniform,
        nlos_angles_variable: ConsistentUniform,
        los_phases_variable: ConsistentUniform,
        nlos_phases_variable: ConsistentUniform,
        power_profile: np.ndarray,
        delay_profile: np.ndarray,
        los_gains: np.ndarray,
        nlos_gains: np.ndarray,
        los_doppler: float,
        nlos_doppler: float,
        antenna_correlation: AntennaCorrelation | None,
        sample_hooks: Set[ChannelSampleHook[MultipathFadingSample]],
        gain: float,
    ) -> None:
        # Initialize base class
        ChannelRealization.__init__(self, sample_hooks, gain)
        # Initialize class attributes
        self.__random_realization = random_realization
        self.__antenna_correlation_variable = antenna_correlation_variable
        self.__los_angles_variable = los_angles_variable
        self.__path_angles_variable = nlos_angles_variable
        self.__los_phases_variable = los_phases_variable
        self.__path_phases_variable = nlos_phases_variable
        self.__power_profile = power_profile
        self.__delay_profile = delay_profile
        self.__los_gains = los_gains
        self.__nlos_gains = nlos_gains
        self.__los_doppler = los_doppler
        self.__nlos_doppler = nlos_doppler
        self.__antenna_correlation = antenna_correlation
    def _sample(self, state: LinkState) -> MultipathFadingSample:
        # Sample the spatiall consistent random realization
        consistent_sample = self.__random_realization.sample(
            state.transmitter.pose.translation, state.receiver.pose.translation
        )
        # Generate MIMO channel response
        spatial_response = np.exp(
            2j * np.pi * self.__antenna_correlation_variable.sample(consistent_sample)
        )[
            : state.receiver.antennas.num_receive_antennas,
            : state.transmitter.antennas.num_transmit_antennas,
        ]
        if self.__antenna_correlation is not None:
            spatial_response = (
                self.__antenna_correlation.sample_covariance(
                    state.receiver.antennas, AntennaMode.RX
                )
                @ spatial_response
                @ self.__antenna_correlation.sample_covariance(
                    state.transmitter.antennas, AntennaMode.TX
                )
            )
        # Sample multipath components
        los_angles = (
            self.__los_angles_variable * np.ones_like(self.__power_profile)
            if isinstance(self.__los_angles_variable, float)
            else 2 * np.pi * self.__los_angles_variable.sample(consistent_sample)
        )
        nlos_angles = -np.pi + 2 * np.pi * self.__path_angles_variable.sample(consistent_sample)
        los_phases = -np.pi + 2 * np.pi * self.__los_phases_variable.sample(consistent_sample)
        nlos_phases = -np.pi + 2 * np.pi * self.__path_phases_variable.sample(consistent_sample)
        return MultipathFadingSample(
            self.__power_profile,
            self.__delay_profile,
            los_angles,
            nlos_angles,
            los_phases,
            nlos_phases,
            self.__los_gains,
            self.__nlos_gains,
            self.__los_doppler,
            self.__nlos_doppler,
            spatial_response,
            self.gain,
            state,
        )
    def _reciprocal_sample(
        self, sample: MultipathFadingSample, state: LinkState
    ) -> MultipathFadingSample:
        return MultipathFadingSample(
            sample.power_profile,
            sample.delay_profile,
            sample.los_angles,
            sample.nlos_angles,
            sample.los_phases,
            sample.nlos_phases,
            sample.los_gains,
            sample.nlos_gains,
            sample.los_doppler,
            sample.nlos_doppler,
            sample.spatial_response.T,
            sample.gain,
            state,
        )
[docs]
    @override
    def serialize(self, process: SerializationProcess) -> None:
        process.serialize_object(self.__random_realization, "random_realization")
        process.serialize_object(
            self.__antenna_correlation_variable, "antenna_correlation_variable"
        )
        if isinstance(self.__los_angles_variable, float):
            process.serialize_floating(self.__los_angles_variable, "los_angles_variable")
        else:
            process.serialize_object(self.__los_angles_variable, "los_angles_variable")
        process.serialize_object(self.__path_angles_variable, "path_angles_variable")
        process.serialize_object(self.__los_phases_variable, "los_phases_variable")
        process.serialize_object(self.__path_phases_variable, "path_phases_variable")
        process.serialize_array(self.__power_profile, "power_profile")
        process.serialize_array(self.__delay_profile, "delay_profile")
        process.serialize_array(self.__los_gains, "los_gains")
        process.serialize_array(self.__nlos_gains, "nlos_gains")
        process.serialize_floating(self.__los_doppler, "los_doppler")
        process.serialize_floating(self.__nlos_doppler, "nlos_doppler")
        if self.__antenna_correlation is not None:
            process.serialize_object(self.__antenna_correlation, "antenna_correlation")
        ChannelRealization.serialize(self, process) 
[docs]
    @classmethod
    @override
    def Deserialize(cls, process: DeserializationProcess) -> MultipathFadingRealization:
        return MultipathFadingRealization(
            process.deserialize_object("random_realization", ConsistentRealization),
            process.deserialize_object("antenna_correlation_variable", ConsistentUniform),
            process.deserialize_object("los_angles_variable", ConsistentUniform),
            process.deserialize_object("path_angles_variable", ConsistentUniform),
            process.deserialize_object("los_phases_variable", ConsistentUniform),
            process.deserialize_object("path_phases_variable", ConsistentUniform),
            process.deserialize_array("power_profile", np.float64),
            process.deserialize_array("delay_profile", np.float64),
            process.deserialize_array("los_gains", np.float64),
            process.deserialize_array("nlos_gains", np.float64),
            process.deserialize_floating("los_doppler"),
            process.deserialize_floating("nlos_doppler"),
            process.deserialize_object("antenna_correlation", AntennaCorrelation, None),
            set(),
            **Channel._DeserializeParameters(process),  # type: ignore[arg-type]
        ) 
 
[docs]
class MultipathFadingChannel(
    Channel[MultipathFadingRealization, MultipathFadingSample], Serializable
):
    """Base class for the implementation of stochastic multipath fading channels."""
    _DEFAULT_DECORRELATION_DISTANCE = float("inf")
    _DEFAULT_NUM_SINUSOIDS = 20
    _DEFAULT_DOPPLER_FREQUENCY = 0.0
    __delays: np.ndarray
    __power_profile: np.ndarray
    __rice_factors: np.ndarray
    __max_delay: float
    __num_resolvable_paths: int
    __num_sinusoids: int
    __los_angle: float | None
    __los_gains: np.ndarray
    __doppler_frequency: float
    __los_doppler_frequency: float | None
    __antenna_correlation: AntennaCorrelation | None
    def __init__(
        self,
        delays: np.ndarray | List[float],
        power_profile: np.ndarray | List[float],
        rice_factors: np.ndarray | List[float],
        correlation_distance: float = _DEFAULT_DECORRELATION_DISTANCE,
        num_sinusoids: int = _DEFAULT_NUM_SINUSOIDS,
        los_angle: float | None = None,
        doppler_frequency: float = _DEFAULT_DOPPLER_FREQUENCY,
        los_doppler_frequency: float | None = None,
        antenna_correlation: AntennaCorrelation | None = None,
        gain: float = Channel._DEFAULT_GAIN,
        seed: int | None = None,
    ) -> None:
        """
        Args:
            delays:
                Delay in seconds of each individual multipath tap.
                Denoted by :math:`\\tau_{\\ell}` within the respective equations.
            power_profile:
                Power loss factor of each individual multipath tap.
                Denoted by :math:`g_{\\ell}` within the respective equations.
            rice_factors:
                Rice factor balancing line of sight and multipath in each individual channel tap.
                Denoted by :math:`K_{\\ell}` within the respective equations.
            correlation_distance:
                Distance at which channel samples are considered to be uncorrelated.
                :math:`\\infty` by default, i.e. the channel is considered to be fully correlated in space.
            num_sinusoids:
                Number of sinusoids used to sample the statistical distribution.
                Denoted by :math:`N` within the respective equations.
            los_angle:
                Angle phase of the line of sight component within the statistical distribution.
            doppler_frequency:
                Doppler frequency shift of the statistical distribution.
                Denoted by :math:`\\omega_{\\ell}` within the respective equations.
            antenna_correlation:
                Antenna correlation model.
                By default, the channel assumes ideal correlation, i.e. no cross correlations.
            gain:
                Linear power gain factor a signal experiences when being propagated over this realization.
                :math:`1.0` by default.
            seed:
                Seed used to initialize the pseudo-random number generator.
        Raises:
            ValueError: If the length of `delays`, `power_profile` and `rice_factors` is not identical.
            ValueError: If delays are smaller than zero.
            ValueError: If power factors are smaller than zero.
            ValueError: If rice factors are smaller than zero.
        """
        # Convert delays, power profile and rice factors to numpy arrays if they were provided as lists
        self.__delays = np.array(delays) if isinstance(delays, list) else delays
        self.__power_profile = (
            np.array(power_profile) if isinstance(power_profile, list) else power_profile
        )
        self.__rice_factors = (
            np.array(rice_factors) if isinstance(rice_factors, list) else rice_factors
        )
        if (
            self.__delays.ndim != 1
            or self.__power_profile.ndim != 1
            or self.__rice_factors.ndim != 1
        ):
            raise ValueError("Delays, power profile and rice factors must be vectors")
        if len(delays) < 1:
            raise ValueError("Configuration must contain at least one delay tap")
        if len(delays) != len(power_profile) or len(power_profile) != len(rice_factors):
            raise ValueError(
                "Delays, power profile and rice factor vectors must be of equal length"
            )
        if np.any(self.__delays < 0.0):
            raise ValueError("Delays must be greater or equal to zero")
        if np.any(self.__power_profile < 0.0):
            raise ValueError("Power profile factors must be greater or equal to zero")
        if np.any(self.__rice_factors < 0.0):
            raise ValueError("Rice factors must be greater or equal to zero")
        # Initialize base class
        self.__antenna_correlation = None
        Channel.__init__(self, gain, seed)
        # Sort delays
        sorting = np.argsort(delays)
        self.__delays = self.__delays[sorting]
        self.__power_profile = self.__power_profile[sorting]
        self.__rice_factors = self.__rice_factors[sorting]
        self.__num_sinusoids = num_sinusoids
        self.los_angle = self._rng.uniform(-pi, pi) if los_angle is None else los_angle
        self.doppler_frequency = doppler_frequency
        self.__los_doppler_frequency = los_doppler_frequency
        # Infer additional parameters
        self.__max_delay = max(self.__delays)
        self.__num_resolvable_paths = len(self.__delays)
        rice_inf_pos = np.isposinf(self.__rice_factors)
        rice_num_pos = np.invert(rice_inf_pos)
        self.__los_gains = np.empty(self.num_resolvable_paths, dtype=float)
        self.__non_los_gains = np.empty(self.num_resolvable_paths, dtype=float)
        self.__los_gains[rice_inf_pos] = 1.0
        self.__los_gains[rice_num_pos] = np.sqrt(
            self.__rice_factors[rice_num_pos] / (1 + self.__rice_factors[rice_num_pos])
        )
        self.__non_los_gains[rice_num_pos] = np.sqrt(
            1 / ((1 + self.__rice_factors[rice_num_pos]) * self.__num_sinusoids)
        )
        self.__non_los_gains[rice_inf_pos] = 0.0
        # Update correlations (required here to break dependency cycle during init)
        self.antenna_correlation = antenna_correlation
        self.correlation_distance = correlation_distance
        self.__rng = ConsistentGenerator(self)
        # self.__antenna_correlation_variable = self.__rng.uniform((self.beta_device.num_antennas, self.alpha_device.num_antennas))
        self.__antenna_correlation_variable = self.__rng.uniform((10, 10))
        self.__los_angles_variable = (
            self.__rng.uniform((self.__num_resolvable_paths,))
            if self.__los_angle is not None
            else self.__los_angle
        )
        self.__nlos_angles_variable = self.__rng.uniform(
            (self.num_resolvable_paths, self.__num_sinusoids)
        )
        self.__los_phases_variable = self.__rng.uniform((self.num_resolvable_paths,))
        self.__nlos_phases_variable = self.__rng.uniform(
            (self.num_resolvable_paths, self.__num_sinusoids)
        )
    @property
    def correlation_distance(self) -> float:
        """Correlation distance in meters.
        Represents the distance over which the antenna correlation is assumed to be constant.
        """
        return self.__correlation_distance
    @correlation_distance.setter
    def correlation_distance(self, distance: float) -> None:
        if distance < 0:
            raise ValueError("Correlation distance must be greater or equal to zero")
        self.__correlation_distance = distance
    @property
    def delays(self) -> np.ndarray:
        """Delays for each propagation path in seconds.
        Represented by the sequence
        .. math::
           \\left[\\tau_{1},\\, \\dotsc,\\, \\tau_{L} \\right]^{\\mathsf{T}} \\in \\mathbb{R}_{+}^{L}
        of :math:`L` propagtion delays within the respective equations.
        """
        return self.__delays
    @property
    def power_profile(self) -> np.ndarray:
        """Gain factors of each propagation path.
        Represented by the sequence
        .. math::
           \\left[g_{1},\\, \\dotsc,\\, g_{L} \\right]^{\\mathsf{T}} \\in \\mathbb{R}_{+}^{L}
        of :math:`L` propagtion factors within the respective equations.
        """
        return self.__power_profile
    @property
    def rice_factors(self) -> np.ndarray:
        """Rice factors balancing line of sight and non-line of sight power components for each propagation path.
        Represented by the sequence
        .. math::
           \\left[K_{1},\\, \\dotsc,\\, K_{L} \\right]^{\\mathsf{T}} \\in \\mathbb{R}_{+}^{L}
        of :math:`L` factors within the respective equations.
        """
        return self.__rice_factors
    @property
    def doppler_frequency(self) -> float:
        """Doppler frequency in :math:`Hz`.
        Represented by :math:`\\omega` within the respective equations.
        """
        return self.__doppler_frequency
    @doppler_frequency.setter
    def doppler_frequency(self, frequency: float) -> None:
        self.__doppler_frequency = frequency
    @property
    def los_doppler_frequency(self) -> float:
        """Line of sight Doppler frequency in :math:`Hz`.
        Represented by :math:`\\omega` within the respective equations.
        """
        if self.__los_doppler_frequency is None:
            return self.doppler_frequency
        return self.__los_doppler_frequency
    @los_doppler_frequency.setter
    def los_doppler_frequency(self, frequency: float | None) -> None:
        self.__los_doppler_frequency = frequency
    @property
    def max_delay(self) -> float:
        """Maximum propagation delay in seconds."""
        return self.__max_delay
    @property
    def num_resolvable_paths(self) -> int:
        """Number of dedicated propagation paths.
        Represented by :math:`L` within the respective equations.
        """
        return self.__num_resolvable_paths
    @property
    def num_sinusoids(self) -> int:
        """Number of sinusoids assumed to model the fading in time-domain.
        Represented by :math:`N` within the respective equations.
        Raises:
            ValueError: For values smaller than zero.
        """
        return self.__num_sinusoids
    @num_sinusoids.setter
    def num_sinusoids(self, num: int) -> None:
        if num < 0:
            raise ValueError("Number of sinusoids must be greater or equal to zero")
        self.__num_sinusoids = num
    @property
    def los_angle(self) -> float | None:
        """Line of sight doppler angle in radians.
        Represented by :math:`\\theta_{0}` within the respective equations.
        """
        return self.__los_angle
    @los_angle.setter
    def los_angle(self, angle: float | None) -> None:
        self.__los_angle = angle
[docs]
    def _realize(self) -> MultipathFadingRealization:
        return MultipathFadingRealization(
            self.__rng.realize(self.correlation_distance),
            self.__antenna_correlation_variable,
            self.__los_angles_variable,
            self.__nlos_angles_variable,
            self.__los_phases_variable,
            self.__nlos_phases_variable,
            self.__power_profile,
            self.__delays,
            self.__los_gains,
            self.__non_los_gains,
            self.los_doppler_frequency,
            self.doppler_frequency,
            self.antenna_correlation,
            self.sample_hooks,
            self.gain,
        ) 
    @property
    def antenna_correlation(self) -> AntennaCorrelation | None:
        """Antenna correlations.
        Returns:
            Handle to the correlation model.
            :py:obj:`None`, if no model was configured and ideal correlation is assumed.
        """
        return self.__antenna_correlation
    @antenna_correlation.setter
    def antenna_correlation(self, value: AntennaCorrelation | None) -> None:
        if value is not None:
            value.channel = self
        self.__alpha_correlation = value
[docs]
    @override
    def serialize(self, process: SerializationProcess) -> None:
        process.serialize_array(self.__delays, "delays")
        process.serialize_array(self.__power_profile, "power_profile")
        process.serialize_array(self.__rice_factors, "rice_factors")
        process.serialize_integer(self.num_sinusoids, "num_sinusoids")
        process.serialize_floating(self.__correlation_distance, "correlation_distance")
        process.serialize_floating(self.__doppler_frequency, "doppler_frequency")
        process.serialize_floating(self.__los_doppler_frequency, "los_doppler_frequency")
        if self.antenna_correlation is not None:
            process.serialize_object(self.antenna_correlation, "antenna_correlation")
        Channel.serialize(self, process) 
    @classmethod
    @override
    def _DeserializeParameters(cls, process: DeserializationProcess) -> dict[str, Any]:
        parameters = Channel._DeserializeParameters(process)
        parameters.update(
            {
                "delays": process.deserialize_array("delays", np.float64),
                "power_profile": process.deserialize_array("power_profile", np.float64),
                "rice_factors": process.deserialize_array("rice_factors", np.float64),
                "num_sinusoids": process.deserialize_integer(
                    "num_sinusoids", cls._DEFAULT_NUM_SINUSOIDS
                ),
                "correlation_distance": process.deserialize_floating(
                    "correlation_distance", cls._DEFAULT_DECORRELATION_DISTANCE
                ),
                "doppler_frequency": process.deserialize_floating(
                    "doppler_frequency", cls._DEFAULT_DOPPLER_FREQUENCY
                ),
                "los_doppler_frequency": process.deserialize_floating("los_doppler_frequency"),
                "antenna_correlation": process.deserialize_object(
                    "antenna_correlation", AntennaCorrelation, None
                ),
            }
        )
        return parameters
[docs]
    @classmethod
    @override
    def Deserialize(cls, process: DeserializationProcess) -> MultipathFadingChannel:
        return MultipathFadingChannel(**cls._DeserializeParameters(process))