Source code for sionna.phy.signal.filter

#
# SPDX-FileCopyrightText: Copyright (c) 2021-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0#
"""Blocks implementing filters"""

import tensorflow as tf
import matplotlib.pyplot as plt
import numpy as np
from sionna.phy import Block
from . import convolve, Window, HannWindow, HammingWindow, BlackmanWindow, empirical_aclr

[docs] class Filter(Block): # pylint: disable=line-too-long r""" Abtract class defining a filter of ``length`` K which can be applied to an input ``x`` of length N The filter length K is equal to the filter span in symbols (``span_in_symbols``) multiplied by the oversampling factor (``samples_per_symbol``). If this product is even, a value of one will be added. The filter is applied through discrete convolution. An optional windowing function ``window`` can be applied to the filter. Three padding modes are available for applying the filter: * "full" (default): Returns the convolution at each point of overlap between ``x`` and the filter. The length of the output is N + K - 1. Zero-padding of the input ``x`` is performed to compute the convolution at the borders. * "same": Returns an output of the same length as the input ``x``. The convolution is computed such that the coefficients of the input ``x`` are centered on the coefficient of the filter with index (K-1)/2. Zero-padding of the input signal is performed to compute the convolution at the borders. * "valid": Returns the convolution only at points where ``x`` and the filter completely overlap. The length of the output is N - K + 1. Parameters ---------- span_in_symbols: `int` Filter span as measured by the number of symbols samples_per_symbol: `int` Number of samples per symbol, i.e., the oversampling factor window: `None` (default) | :class:`~sionna.phy.signal.Window` | "hann" | "hamming" | "blackman" Window that is applied to the filter coefficients normalize: `bool`, (default `True`) If `True`, the filter is normalized to have unit power. precision : `None` (default) | "single" | "double" Precision used for internal calculations and outputs. If set to `None`, :attr:`~sionna.phy.config.Config.precision` is used. Input ----- x : [...,N], `tf.complex` or `tf.float` Input to which the filter is applied along the last dimension padding : "full" (default) | "valid" | "same" Padding mode for convolving ``x`` and the filter conjugate : `bool`, (default `False`) If `True`, the complex conjugate of the filter is applied. Output ------ y : [...,M], `tf.complex` or `tf.float` Filtered input. The length M depends on the ``padding``. """ def __init__(self, span_in_symbols, samples_per_symbol, window=None, normalize=True, precision=None, **kwargs): super().__init__(precision=precision, **kwargs) assert span_in_symbols>0, "span_in_symbols must be positive" self._span_in_symbols = span_in_symbols assert samples_per_symbol>0, "samples_per_symbol must be positive" self._samples_per_symbol = samples_per_symbol self.window = window assert isinstance(normalize, bool), "normalize must be bool" self._normalize = normalize @property def span_in_symbols(self): """ `int` : Filter span in symbols """ return self._span_in_symbols @property def samples_per_symbol(self): """ `int` : Number of samples per symbol, i.e., the oversampling factor """ return self._samples_per_symbol @property def length(self): """ `int` : Filter length in samples """ l = self._span_in_symbols*self._samples_per_symbol l = 2*(l//2)+1 # Force length to be the next odd number return l @property def window(self): """ :class:`~sionna.phy.signal.Window` : Get/set window function that is applied to the filter coefficients """ return self._window @window.setter def window(self, value): if isinstance(value, str): if value=="hann": self._window = HannWindow() elif value=="hamming": self._window = HammingWindow() elif value=="blackman": self._window = BlackmanWindow() else: raise AssertionError("Invalid window type") elif isinstance(value, Window) or value is None: self._window = value else: raise AssertionError("Invalid window type") if value is not None: assert self._window.precision == self._precision, \ "Window and Filter must have the same precision." # Run window once to initialize coefficients self._window(tf.ones([self.length], self.cdtype)) @property def normalize(self): """ `bool` : If `True` the filter is normalized to have unit power. """ return self._normalize @property def coefficients(self): """ [K], `tf.float` of `tf.complex` : Set/get raw filter coefficients """ return self._coefficients @coefficients.setter def coefficients(self, v): self._coefficients = self._cast_or_check_precision(v) @property def sampling_times(self): """ [K], `numpy.float32` : Sampling times in multiples of the symbol duration """ n_min = -(self.length//2) n_max = n_min + self.length t = np.arange(n_min, n_max, dtype=np.float32) t /= self.samples_per_symbol return t
[docs] def show(self, response="impulse", scale="lin"): r"""Plot the impulse or magnitude response Plots the impulse response (time domain) or magnitude response (frequency domain) of the filter. For the computation of the magnitude response, a minimum DFT size of 1024 is assumed which is obtained through zero padding of the filter coefficients in the time domain. Input ----- response: "impulse" (default) | "magnitude" Desired response type scale: "lin" (default) | "db" y-scale of the magnitude response. Can be "lin" (i.e., linear) or "db" (, i.e., Decibel). """ assert response in ["impulse", "magnitude"], "Invalid response" h = self.coefficients # Apply window if self.window is not None: h = self._window(h) # Ensure unit L2-norm of the coefficients if self.normalize: energy = tf.reduce_sum(tf.square(tf.abs(h))) h = h / tf.cast(tf.sqrt(energy), h.dtype) if response=="impulse": plt.figure(figsize=(12,6)) plt.plot(self.sampling_times, np.real(h)) if self.coefficients.dtype.is_complex: plt.plot(self.sampling_times, np.imag(h)) plt.legend(["Real part", "Imaginary part"]) plt.title("Impulse response") plt.grid() plt.xlabel(r"Normalized time $(t/T)$") plt.ylabel(r"$h(t)$") plt.xlim(self.sampling_times[0], self.sampling_times[-1]) else: assert scale in ["lin", "db"], "Invalid scale" fft_size = max(1024, h.shape[-1]) h = np.fft.fft(h, fft_size) h = np.fft.fftshift(h) h = np.abs(h) plt.figure(figsize=(12,6)) if scale=="db": h = np.maximum(h, 1e-10) h = 10*np.log10(h) plt.ylabel(r"$|H(f)|$ (dB)") else: plt.ylabel(r"$|H(f)|$") f = np.linspace(-self._samples_per_symbol/2, self._samples_per_symbol/2, fft_size) plt.plot(f, h) plt.title("Magnitude response") plt.grid() plt.xlabel(r"Normalized frequency $(f/W)$") plt.xlim(f[0], f[-1])
@property def aclr(self): """ACLR of the filter This ACLR corresponds to what one would obtain from using this filter as pulse shaping filter on an i.i.d. sequence of symbols. The in-band is assumed to range from [-0.5, 0.5] in normalized frequency. `tf.float` : ACLR in linear scale """ h = self.coefficients # Apply window if self.window is not None: h = self._window(h) # Ensure unit L2-norm of the coefficients if self.normalize: energy = tf.reduce_sum(tf.square(tf.abs(h))) h = h / tf.cast(tf.sqrt(energy), h.dtype) fft_size = 1024 n = fft_size - tf.shape(h)[-1] z = tf.zeros([n], h.dtype) c = tf.cast(tf.concat([h, z], -1), tf.complex64) return empirical_aclr(c, oversampling=self._samples_per_symbol, precision=self.precision) def call(self, x, padding='full', conjugate=False): h = self.coefficients # Apply window if self.window is not None: h = self._window(h) # Ensure unit L2-norm of the coefficients if self.normalize: energy = tf.reduce_sum(tf.square(tf.abs(h))) h = h / tf.cast(tf.sqrt(energy), h.dtype) # (Optionally) compute the complex conjugate if conjugate and h.dtype.is_complex: h = tf.math.conj(h) y = convolve(x, h, padding=padding, precision=self.precision) return y
[docs] class RaisedCosineFilter(Filter): # pylint: disable=line-too-long r""" Block for applying a raised-cosine filter of ``length`` K to an input ``x`` of length N The raised-cosine filter is defined by .. math:: h(t) = \begin{cases} \frac{\pi}{4T} \text{sinc}\left(\frac{1}{2\beta}\right), & \text { if }t = \pm \frac{T}{2\beta}\\ \frac{1}{T}\text{sinc}\left(\frac{t}{T}\right)\frac{\cos\left(\frac{\pi\beta t}{T}\right)}{1-\left(\frac{2\beta t}{T}\right)^2}, & \text{otherwise} \end{cases} where :math:`\beta` is the roll-off factor and :math:`T` the symbol duration. The filter length K is equal to the filter span in symbols (``span_in_symbols``) multiplied by the oversampling factor (``samples_per_symbol``). If this product is even, a value of one will be added. The filter is applied through discrete convolution. An optional windowing function ``window`` can be applied to the filter. The `dtype` of the output is `tf.float` if both ``x`` and the filter coefficients have dtype `tf.float`. Otherwise, the dtype of the output is `tf.complex`. Three padding modes are available for applying the filter: * "full" (default): Returns the convolution at each point of overlap between ``x`` and the filter. The length of the output is N + K - 1. Zero-padding of the input ``x`` is performed to compute the convolution at the borders. * "same": Returns an output of the same length as the input ``x``. The convolution is computed such that the coefficients of the input ``x`` are centered on the coefficient of the filter with index (K-1)/2. Zero-padding of the input signal is performed to compute the convolution at the borders. * "valid": Returns the convolution only at points where ``x`` and the filter completely overlap. The length of the output is N - K + 1. Parameters ---------- span_in_symbols: `int` Filter span as measured by the number of symbols samples_per_symbol: `int` Number of samples per symbol, i.e., the oversampling factor beta : `float` Roll-off factor. Must be in the range :math:`[0,1]`. window: `None` (default) | :class:`~sionna.phy.signal.Window` | "hann" | "hamming" | "blackman" Window that is applied to the filter coefficients normalize: `bool`, (default `True`) If `True`, the filter is normalized to have unit power. precision : `None` (default) | "single" | "double" Precision used for internal calculations and outputs. If set to `None`, :attr:`~sionna.phy.config.Config.precision` is used. Input ----- x : [...,N], `tf.complex` or `tf.float` Input to which the filter is applied along the last dimension padding : "full" (default) | "valid" | "same" Padding mode for convolving ``x`` and the filter conjugate : `bool`, (default `False`) If `True`, the complex conjugate of the filter is applied. Output ------ y : [...,M], `tf.complex` or `tf.float` Filtered input. The length M depends on the ``padding``. """ def __init__(self, span_in_symbols, samples_per_symbol, beta, window=None, normalize=True, precision=None, **kwargs): super().__init__(span_in_symbols, samples_per_symbol, window=window, normalize=normalize, precsion=precision, **kwargs) assert 0 <= beta <= 1, "beta must be from the intervall [0,1]" self._beta = beta self.coefficients = self._raised_cosine(self.sampling_times, 1.0, self.beta) @property def beta(self): """ `float` : Roll-off factor """ return self._beta def _raised_cosine(self, t, symbol_duration, beta): """Raised-cosine filter from Wikipedia https://en.wikipedia.org/wiki/Raised-cosine_filter""" h = np.zeros([len(t)], np.float32) for i, tt in enumerate(t): tt = np.abs(tt) if beta>0 and (tt-np.abs(symbol_duration/2/beta)==0): h[i] = np.pi/4/symbol_duration*np.sinc(1/2/beta) else: h[i] = 1./symbol_duration*np.sinc(tt/symbol_duration)\ * np.cos(np.pi*beta*tt/symbol_duration)\ /(1-(2*beta*tt/symbol_duration)**2) return h
[docs] class RootRaisedCosineFilter(Filter): # pylint: disable=line-too-long r""" Block for applying a root-raised-cosine filter of ``length`` K to an input ``x`` of length N The root-raised-cosine filter is defined by .. math:: h(t) = \begin{cases} \frac{1}{T} \left(1 + \beta\left(\frac{4}{\pi}-1\right) \right), & \text { if }t = 0\\ \frac{\beta}{T\sqrt{2}} \left[ \left(1+\frac{2}{\pi}\right)\sin\left(\frac{\pi}{4\beta}\right) + \left(1-\frac{2}{\pi}\right)\cos\left(\frac{\pi}{4\beta}\right) \right], & \text { if }t = \pm\frac{T}{4\beta} \\ \frac{1}{T} \frac{\sin\left(\pi\frac{t}{T}(1-\beta)\right) + 4\beta\frac{t}{T}\cos\left(\pi\frac{t}{T}(1+\beta)\right)}{\pi\frac{t}{T}\left(1-\left(4\beta\frac{t}{T}\right)^2\right)}, & \text { otherwise} \end{cases} where :math:`\beta` is the roll-off factor and :math:`T` the symbol duration. The filter length K is equal to the filter span in symbols (``span_in_symbols``) multiplied by the oversampling factor (``samples_per_symbol``). If this product is even, a value of one will be added. The filter is applied through discrete convolution. An optional windowing function ``window`` can be applied to the filter. The `dtype` of the output is `tf.float` if both ``x`` and the filter coefficients have dtype `tf.float`. Otherwise, the dtype of the output is `tf.complex`. Three padding modes are available for applying the filter: * "full" (default): Returns the convolution at each point of overlap between ``x`` and the filter. The length of the output is N + K - 1. Zero-padding of the input ``x`` is performed to compute the convolution at the borders. * "same": Returns an output of the same length as the input ``x``. The convolution is computed such that the coefficients of the input ``x`` are centered on the coefficient of the filter with index (K-1)/2. Zero-padding of the input signal is performed to compute the convolution at the borders. * "valid": Returns the convolution only at points where ``x`` and the filter completely overlap. The length of the output is N - K + 1. Parameters ---------- span_in_symbols: `int` Filter span as measured by the number of symbols samples_per_symbol: `int` Number of samples per symbol, i.e., the oversampling factor beta : `float` Roll-off factor. Must be in the range :math:`[0,1]`. window: `None` (default) | :class:`~sionna.phy.signal.Window` | "hann" | "hamming" | "blackman" Window that is applied to the filter coefficients normalize: `bool`, (default `True`) If `True`, the filter is normalized to have unit power. precision : `None` (default) | "single" | "double" Precision used for internal calculations and outputs. If set to `None`, :attr:`~sionna.phy.config.Config.precision` is used. Input ----- x : [...,N], `tf.complex` or `tf.float` Input to which the filter is applied along the last dimension padding : "full" (default) | "valid" | "same" Padding mode for convolving ``x`` and the filter conjugate : `bool`, (default `False`) If `True`, the complex conjugate of the filter is applied. Output ------ y : [...,M], `tf.complex` or `tf.float` Filtered input. The length M depends on the ``padding``. """ def __init__(self, span_in_symbols, samples_per_symbol, beta, window=None, normalize=True, precision=None, **kwargs): super().__init__(span_in_symbols, samples_per_symbol, window=window, normalize=normalize, precision=precision, **kwargs) assert 0 <= beta <= 1, "beta must be from the intervall [0,1]" self._beta = beta self.coefficients = self._root_raised_cosine(self.sampling_times, 1.0, self.beta) @property def beta(self): """ `float`: Roll-off factor """ return self._beta def _root_raised_cosine(self, t, symbol_duration, beta): """Root-raised-cosine filter from Wikipedia https://en.wikipedia.org/wiki/Root-raised-cosine_filter""" h = np.zeros([len(t)], np.float32) for i, tt in enumerate(t): tt = np.abs(tt) if tt==0: h[i] = 1/symbol_duration*(1+beta*(4/np.pi-1)) elif beta>0 and (tt-np.abs(symbol_duration/4/beta)==0): h[i] = beta/symbol_duration/np.sqrt(2)\ * ((1+2/np.pi)*np.sin(np.pi/4/beta) + \ (1-2/np.pi)*np.cos(np.pi/4/beta)) else: h[i] = 1/symbol_duration\ / (np.pi*tt/symbol_duration*(1-(4*beta*tt/symbol_duration)**2))\ * (np.sin(np.pi*tt/symbol_duration*(1-beta)) + \ 4*beta*tt/symbol_duration\ *np.cos(np.pi*tt/symbol_duration*(1+beta))) return h
[docs] class SincFilter(Filter): # pylint: disable=line-too-long r""" Block for applying a sinc filter of ``length`` K to an input ``x`` of length N The sinc filter is defined by .. math:: h(t) = \frac{1}{T}\text{sinc}\left(\frac{t}{T}\right) where :math:`T` the symbol duration. The filter length K is equal to the filter span in symbols (``span_in_symbols``) multiplied by the oversampling factor (``samples_per_symbol``). If this product is even, a value of one will be added. The filter is applied through discrete convolution. An optional windowing function ``window`` can be applied to the filter. The `dtype` of the output is `tf.float` if both ``x`` and the filter coefficients have dtype `tf.float`. Otherwise, the dtype of the output is `tf.complex`. Three padding modes are available for applying the filter: * "full" (default): Returns the convolution at each point of overlap between ``x`` and the filter. The length of the output is N + K - 1. Zero-padding of the input ``x`` is performed to compute the convolution at the borders. * "same": Returns an output of the same length as the input ``x``. The convolution is computed such that the coefficients of the input ``x`` are centered on the coefficient of the filter with index (K-1)/2. Zero-padding of the input signal is performed to compute the convolution at the borders. * "valid": Returns the convolution only at points where ``x`` and the filter completely overlap. The length of the output is N - K + 1. Parameters ---------- span_in_symbols: `int` Filter span as measured by the number of symbols samples_per_symbol: `int` Number of samples per symbol, i.e., the oversampling factor window: `None` (default) | :class:`~sionna.phy.signal.Window` | "hann" | "hamming" | "blackman" Window that is applied to the filter coefficients normalize: `bool`, (default `True`) If `True`, the filter is normalized to have unit power precision : `None` (default) | "single" | "double" Precision used for internal calculations and outputs. If set to `None`, :attr:`~sionna.phy.config.Config.precision` is used. Input ----- x : [...,N], `tf.complex` or `tf.float` Input to which the filter is applied along the last dimension padding : "full" (default) | "valid" | "same" Padding mode for convolving ``x`` and the filter conjugate : `bool`, (default `False`) If `True`, the complex conjugate of the filter is applied. Output ------ y : [...,M], `tf.complex` or `tf.float` Filtered input. The length M depends on the ``padding``. """ def __init__(self, span_in_symbols, samples_per_symbol, window=None, normalize=True, precision=None, **kwargs): super().__init__(span_in_symbols, samples_per_symbol, window=window, normalize=normalize, precision=precision, **kwargs) self.coefficients = self._sinc(self.sampling_times, 1.0) def _sinc(self, t, symbol_duration): """Sinc filter""" return 1/symbol_duration*np.sinc(t/symbol_duration)
[docs] class CustomFilter(Filter): # pylint: disable=line-too-long r""" Block for applying a custom filter of ``length`` K to an input ``x`` of length N The filter length K is equal to the filter span in symbols (``span_in_symbols``) multiplied by the oversampling factor (``samples_per_symbol``). If this product is even, a value of one will be added. The filter is applied through discrete convolution. An optional windowing function ``window`` can be applied to the filter. The `dtype` of the output is `tf.float` if both ``x`` and the filter coefficients have dtype `tf.float`. Otherwise, the dtype of the output is `tf.complex`. Three padding modes are available for applying the filter: * "full" (default): Returns the convolution at each point of overlap between ``x`` and the filter. The length of the output is N + K - 1. Zero-padding of the input ``x`` is performed to compute the convolution at the borders. * "same": Returns an output of the same length as the input ``x``. The convolution is computed such that the coefficients of the input ``x`` are centered on the coefficient of the filter with index (K-1)/2. Zero-padding of the input signal is performed to compute the convolution at the borders. * "valid": Returns the convolution only at points where ``x`` and the filter completely overlap. The length of the output is N - K + 1. Parameters ---------- samples_per_symbol: `int` Number of samples per symbol, i.e., the oversampling factor coefficients: [K], `tf.float` or `tf.complex` Filter coefficients window: `None` (default) | :class:`~sionna.phy.signal.Window` | "hann" | "hamming" | "blackman" Window that is applied to the filter coefficients normalize: `bool`, (default `True`) If `True`, the filter is normalized to have unit power. precision : `None` (default) | "single" | "double" Precision used for internal calculations and outputs. If set to `None`, :attr:`~sionna.phy.config.Config.precision` is used. Input ----- x : [...,N], `tf.complex` or `tf.float` Input to which the filter is applied along the last dimension padding : "full" (default) | "valid" | "same" Padding mode for convolving ``x`` and the filter conjugate : `bool`, (default `False`) If `True`, the complex conjugate of the filter is applied. Output ------ y : [...,M], `tf.complex` or `tf.float` Filtered input. The length M depends on the ``padding``. """ def __init__(self, samples_per_symbol, coefficients, window=None, normalize=True, precision=None, **kwargs): assert samples_per_symbol>0, "samples_per_symbol must be positive" l = coefficients.shape[-1] assert l%2==1, "The number of coefficients must be odd" span_in_symbols = l//samples_per_symbol super().__init__(span_in_symbols, samples_per_symbol, window=window, normalize=normalize, precision=precision, **kwargs) self.coefficients = coefficients assert self.length == l, \ f"""`coefficients` must have length {self.length}"""