Source code for pyFDN.generate.paraunitary

"""Paraunitary (lossless FIR) feedback matrices.

Translations of fdnToolbox's degreeOneLossless, shiftMatrix,
shiftMatrixDistribute, randomMatrixShift,
constructCascadedParaunitaryMatrix and
constructParaunitaryFromElementals.

Reference:
    Vaidyanathan, "Multirate Systems and Filter Banks," Prentice Hall, 1993,
    p. 732.
"""

from __future__ import annotations

import math

import numpy as np
from numpy.typing import ArrayLike
from scipy.linalg import hadamard

from pyFDN.auxiliary.math import matrix_convolution
from pyFDN.auxiliary.utils import ensure_3d, last_nonzero_indices

from .orthogonal import random_orthogonal


[docs] def degree_one_lossless(v: np.ndarray) -> np.ndarray: """Build the degree-one lossless polynomial matrix ``V(z) = (I - vv^T) + z^{-1} vv^T``. Args: v: Vector of shape ``(N,)`` or ``(N, 1)``. Returns: Polynomial matrix of shape ``(N, N, 2)`` where index ``[..., 0]`` is the ``z^0`` coefficient and ``[..., 1]`` is the ``z^{-1}`` coefficient. """ v = np.asarray(v, dtype=float).ravel() v = v / np.linalg.norm(v) vv = np.outer(v, v) N = len(v) V = np.zeros((N, N, 2)) V[:, :, 0] = np.eye(N) - vv V[:, :, 1] = vv return V
[docs] def shift_matrix(mat: ArrayLike, shift: ArrayLike, direction: str) -> np.ndarray: """Shift a polynomial matrix in time-domain by ``shift`` samples.""" mat_arr = ensure_3d(mat).copy() shift_vec = np.asarray(shift, dtype=int) if direction == "left": if shift_vec.ndim != 1 or shift_vec.size != mat_arr.shape[0]: raise ValueError("Shift vector must match number of rows") required_space = last_nonzero_indices(mat_arr) + shift_vec[:, np.newaxis] additional_space = int(np.max(required_space) - mat_arr.shape[2]) if additional_space > 0: pad = np.zeros( (mat_arr.shape[0], mat_arr.shape[1], additional_space), dtype=mat_arr.dtype, ) mat_arr = np.concatenate([mat_arr, pad], axis=2) for idx in range(mat_arr.shape[0]): mat_arr[idx, :, :] = np.roll(mat_arr[idx, :, :], shift_vec[idx], axis=1) return mat_arr if direction == "right": if shift_vec.ndim != 1 or shift_vec.size != mat_arr.shape[1]: raise ValueError("Shift vector must match number of columns") required_space = last_nonzero_indices(mat_arr) + shift_vec[np.newaxis, :] additional_space = int(np.max(required_space) - mat_arr.shape[2]) if additional_space > 0: pad = np.zeros( (mat_arr.shape[0], mat_arr.shape[1], additional_space), dtype=mat_arr.dtype, ) mat_arr = np.concatenate([mat_arr, pad], axis=2) for idx in range(mat_arr.shape[1]): mat_arr[:, idx, :] = np.roll(mat_arr[:, idx, :], shift_vec[idx], axis=1) return mat_arr raise ValueError("direction must be 'left' or 'right'")
[docs] def shift_matrix_distribute( mat: ArrayLike, sparsity: float, *, pulse_size: int | None = None ) -> np.ndarray: """Randomly distribute time shifts for a polynomial matrix.""" mat_arr = ensure_3d(mat) if pulse_size is None: indices = last_nonzero_indices(mat_arr) pulse_size = int(np.max(indices)) if indices.size else 1 pulse_size = max(pulse_size, 1) n = mat_arr.shape[0] base = np.arange(n) rand_left_shift = np.floor(sparsity * (base + np.random.rand(n) * 0.99)).astype(int) return rand_left_shift * pulse_size
[docs] def random_matrix_shift( max_shift: int, matrix: ArrayLike, matrix_rev: ArrayLike | None = None ) -> tuple[np.ndarray, np.ndarray | None, np.ndarray, np.ndarray]: """Randomly shift polynomial matrices in time.""" mat = ensure_3d(matrix) rev = ensure_3d(matrix_rev) if matrix_rev is not None else None n = mat.shape[0] rand_left: np.ndarray rand_right: np.ndarray if max_shift >= n: rand_left = np.random.permutation(max_shift)[:n] rand_right = np.random.permutation(max_shift)[:n] elif max_shift <= 0: rand_left = np.zeros(n, dtype=int) rand_right = np.zeros(n, dtype=int) else: rand_left = np.asarray(np.random.randint(0, max_shift, size=n)) rand_right = np.asarray(np.random.randint(0, max_shift, size=n)) rand_left -= rand_left.min() rand_right -= rand_right.min() shifted = shift_matrix(mat, rand_left, "left") shifted = shift_matrix(shifted, rand_right, "right") if rev is None: shifted_rev = None else: shifted_rev = shift_matrix(rev, rand_right, "left") shifted_rev = shift_matrix(shifted_rev, rand_left, "right") return shifted, shifted_rev, rand_left, rand_right
[docs] def construct_cascaded_paraunitary_matrix( n: int, k: int, *, sparsity: float = 1.0, matrix_type: str = "Hadamard", gain_per_sample: float = 1.0, ) -> tuple[np.ndarray, np.ndarray]: """Construct a paraunitary matrix and its reverse response.""" matrix_type_lower = matrix_type.lower() if matrix_type_lower == "hadamard": if n & (n - 1) != 0: raise ValueError("Hadamard construction requires n to be a power of two") def generate_matrix(size: int) -> np.ndarray: return hadamard(size) / math.sqrt(size) elif matrix_type_lower == "random": generate_matrix = random_orthogonal # type: ignore[assignment] else: raise ValueError("matrix_type must be 'Hadamard' or 'random'") sparsity_vector = np.concatenate(([sparsity], np.ones(max(k - 1, 0)))) matrix = ensure_3d(generate_matrix(n)) rev_matrix = ensure_3d(np.linalg.inv(matrix[:, :, 0])) pulse_size = 1 for stage in range(k): shift_left = shift_matrix_distribute( matrix, sparsity_vector[stage], pulse_size=pulse_size ) gain_diag = np.diag(np.power(gain_per_sample, shift_left)) R1 = ensure_3d(generate_matrix(n) @ gain_diag) matrix = shift_matrix(matrix, shift_left, "left") matrix = matrix_convolution(R1, matrix) rev_matrix = shift_matrix(rev_matrix, shift_left, "right") R1_inv = ensure_3d(np.linalg.inv(R1[:, :, 0])) rev_matrix = matrix_convolution(rev_matrix, R1_inv) pulse_size = max(int(pulse_size * n * sparsity_vector[stage]), 1) return matrix, rev_matrix
[docs] def construct_velvet_feedback_matrix( n: int, stages: int, sparsity: float ) -> tuple[np.ndarray, np.ndarray]: """Wrapper for ``construct_cascaded_paraunitary_matrix`` using Hadamard stages.""" return construct_cascaded_paraunitary_matrix( n, stages, sparsity=sparsity, matrix_type="Hadamard" )
[docs] def construct_paraunitary_from_elementals( n: int, degree: int ) -> tuple[np.ndarray, np.ndarray]: """Construct a random paraunitary matrix as a cascade of elemental factors. The matrix is a random orthogonal matrix multiplied by ``degree - 1`` random degree-one lossless factors ``V(z) = (I - vv^T) + z^{-1} vv^T``. Parameters ---------- n : int Size of the paraunitary matrix. degree : int Polynomial degree of the matrix (number of taps). Returns ------- matrix : (n, n, degree) ndarray Random paraunitary FIR matrix in z^{-1} convention. v : (n, degree - 1) ndarray The unit-norm direction vectors of the elemental factors. """ if degree < 1: raise ValueError("degree must be at least 1") matrix = ensure_3d(random_orthogonal(n)) v = np.random.randn(n, degree - 1) v = v / np.sqrt(np.sum(v**2, axis=0, keepdims=True)) for it in range(degree - 1): matrix = matrix_convolution(matrix, degree_one_lossless(v[:, it])) return matrix, v