Source code for spharpy.classes.sh

"""
The spherical harmonic classes store the respective definitions and basis
functions, as well as their derivative and inverse. Please refer to the
:doc:`/theory/spherical_harmonic_definition` page for more specific information
on the mathematical definitions of spherical harmonics.
"""
import numpy as np
import pyfar as pf
import spharpy
from abc import ABC, abstractmethod


class _SphericalHarmonicBase(ABC):
    """Base class defining properties to parametrize spherical harmonics.

    This base class serves as a base for all classes requiring a definition of
    the spherical harmonics without explicitly setting a spherical harmonic
    order. This class is intended for cases where the spherical harmonic order
    is implemented as a read-only property in child classes, for example when
    the order is implicitly defined by other parameters or inferred from data.

    Attributes
    ----------
    basis_type : str
        Type of spherical harmonic basis, either ``'real'`` or ``'complex'``.
        The default is ``'real'``.
    normalization : str, optional
        Normalization convention, either ``'N3D'``, ``'NM'``, ``'maxN'``,
        ``'SN3D'``, or ``'SNM'``. The default is ``'N3D'``. Note that
        ``'maxN'`` is only supported up to 3rd order.
    channel_convention : str, optional
        Channel ordering convention, either ``'ACN'`` or ``'FuMa'``.
        The default is ``'ACN'``. Note that ``'FuMa'`` is only supported up to
        3rd order.
    condon_shortley : bool, str, optional
        Condon-Shortley phase term. If ``True``, Condon-Shortley is included,
        if ``False`` it is not included. The default is ``'auto'``, which
        corresponds to ``True`` for type ``complex`` and ``False`` for type
        ``real``.
    """

    def __init__(
            self,
            basis_type="real",
            normalization="N3D",
            channel_convention="ACN",
            condon_shortley="auto",
        ):
        self._basis_type = None
        self._channel_convention = None
        self._condon_shortley = None
        self._normalization = None

        # basis_type needs to be initialized first, since the default for the
        # Condon-Shortley phase depends on the basis type
        self.basis_type = basis_type
        self.condon_shortley = condon_shortley
        # n_max needs to be initialized before channel_convention and
        # normalization, since both have restrictions on n_max
        self.channel_convention = channel_convention
        self.normalization = normalization

    @property
    @abstractmethod
    def n_max(self):
        """Get or set the spherical harmonic order."""

    @property
    def condon_shortley(self):
        """Get or set the Condon-Shortley phase term."""
        return self._condon_shortley

    @condon_shortley.setter
    def condon_shortley(self, value):
        """Get or set the Condon-Shortley phase term."""
        if isinstance(value, str):
            if value != 'auto':
                raise ValueError(
                    "condon_shortley must be a bool or the string 'auto'")

            value = self.basis_type == "complex"
        elif not isinstance(value, bool):
            raise ValueError(
                "condon_shortley must be a bool or the string 'auto'")

        if self._condon_shortley != value:
            self._condon_shortley = value
            self._on_property_change()

    @property
    def basis_type(self):
        """Get or set the spherical harmonic basis type."""
        return self._basis_type

    @basis_type.setter
    def basis_type(self, value):
        """Get or set the spherical harmonic basis type."""
        if value not in ["complex", "real"]:
            raise ValueError(
                "Invalid basis type, only 'complex' and 'real' are supported")

        if self._basis_type != value:
            self._basis_type = value
            self._on_property_change()

    @property
    def channel_convention(self):
        """Get or set the channel ordering convention."""
        return self._channel_convention

    @channel_convention.setter
    def channel_convention(self, value):
        """Get or set the channel order convention."""
        if value not in ["ACN", "FuMa"]:
            raise ValueError("Invalid channel convention, "
                             "currently only 'ACN' "
                             "and 'FuMa' are supported")

        if value == "FuMa" and self.n_max > 3:
            raise ValueError(
                "n_max > 3 is not allowed with 'FuMa' channel convention")

        if self._channel_convention != value:
            self._channel_convention = value
            self._on_property_change()

    @property
    def normalization(self):
        """Get or set the normalization convention."""
        return self._normalization

    @normalization.setter
    def normalization(self, value):
        """Get or set the normalization convention."""
        if value not in ["N3D", "NM", "maxN", "SN3D", "SNM"]:
            raise ValueError(
                "Invalid normalization, "
                "currently only 'N3D', 'NM', 'maxN', 'SN3D', 'SNM' are "
                "supported",
            )

        if value == "maxN" and self.n_max > 3:
            raise ValueError(
                "n_max > 3 is not allowed with 'maxN' normalization")

        if self._normalization != value:
            self._normalization = value
            self._on_property_change()

    def _on_property_change(self):  # noqa: B027
        """Method called when a class property changes.
        This method can be overridden in child classes to re-compute dependent
        properties.
        """
        pass


[docs] class SphericalHarmonicDefinition(_SphericalHarmonicBase): """Class storing the (discrete) definition of spherical harmonics. This class can serve as a container to create related objects, e.g., spherical harmonic basis matrices for given sampling points, transforms, or other spherical harmonic related data and computations. Parameters ---------- n_max : int, optional Maximum spherical harmonic order. Must be an integer greater or equal to 0. The default is ``0``. basis_type : str Type of spherical harmonic basis, either ``'complex'`` or ``'real'``. The default is ``'real'``. normalization : str, optional Normalization convention, either ``'N3D'``, ``'NM'``, ``'SN3D'``, ``'SNM'``, or ``'maxN'``. ``'maxN'`` is only supported up to 3rd order. The default is ``'N3D'``. channel_convention : str, optional Channel ordering convention, either ``'ACN'`` or ``'FuMa'``. ``'FuMa'`` is only supported up to 3rd order. The default is ``'ACN'``. condon_shortley : bool, str, optional Whether to include the Condon-Shortley phase term. If ``True``, Condon-Shortley is included, if ``False`` it is not included. The default is ``'auto'``, which corresponds to ``True`` for complex `basis_type` and ``False`` for real `basis_type`. """ def __init__( self, n_max=0, basis_type="real", normalization="N3D", channel_convention="ACN", condon_shortley="auto", ): self._n_max = 0 super().__init__( basis_type=basis_type, normalization=normalization, channel_convention=channel_convention, condon_shortley=condon_shortley, ) self.n_max = n_max @property def n_max(self): """Get or set the spherical harmonic order.""" return self._n_max @n_max.setter def n_max(self, value : int): """Get or set the spherical harmonic order.""" if value < 0 or value % 1 != 0: raise ValueError("n_max must be a positive integer") if self.channel_convention == "FuMa" and value > 3: raise ValueError( "n_max > 3 is not allowed with 'FuMa' channel convention") if self.normalization == "maxN" and value > 3: raise ValueError( "n_max > 3 is not allowed with 'maxN' normalization") if self._n_max != value: self._n_max = value self._on_property_change()
[docs] class SphericalHarmonics(SphericalHarmonicDefinition): r""" Compute spherical harmonic basis matrices, their inverses, and gradients. The the basis functions that are used to build the matrices can be configured with the parameters described below. Parameters ---------- n_max : int Maximum spherical harmonic order. Must be an integer greater or equal to 0. coordinates : :py:class:`~pyfar.Coordinates`, :py:class:`SamplingSphere` The sampling points for which the matrices are computed. basis_type : str, optional Type of spherical harmonic basis, either ``'complex'`` or ``'real'``. The default is ``'real'``. normalization : str, optional Normalization convention, either ``'N3D'``, ``'NM'``, ``'SN3D'``, ``'SNM'``, or ``'maxN'``. ``'maxN'`` is only supported up to 3rd order. The default is ``'N3D'``. channel_convention : str, optional Channel ordering convention, either ``'ACN'`` or ``'FuMa'``. ``'FuMa'`` is only supported up to 3rd order. The default is ``'ACN'``. inverse_method : str, ``None``, optional Method for computing the inverse transform: - ``'quadrature'``: compute the inverse via numerical quadrature. Note that this requires `coordinates` to be a :py:class:`SamplingSphere` object with weights summing to :math:`4\pi`. - ``'pseudo_inverse'``: compute the inverse via the pseudo-inverse. Note that this requires `coordinates` to be a :py:class:`SamplingSphere` object. - ``None``: denotes that the inverse is not defined and cannot be computed. - ``'auto'``: If coordinates are a :py:class:`SamplingSphere`, use ``'quadrature'`` when applicable and ``'pseudo_inverse'`` otherwise. If coordinates are a :py:class:`~pyfar.Coordinates` object ``None`` is used. The default is ``'auto'``. condon_shortley : bool or str, optional Whether to include the Condon-Shortley phase term. If ``True``, Condon-Shortley is included, if ``False`` it is not included. The default is ``'auto'``, which corresponds to ``True`` for complex `basis_type` and ``False`` for real `basis_type`. """ def __init__( self, n_max, coordinates, basis_type="real", normalization="N3D", channel_convention="ACN", inverse_method="auto", condon_shortley="auto", ): super().__init__( n_max=n_max, basis_type=basis_type, normalization=normalization, channel_convention=channel_convention, condon_shortley=condon_shortley, ) # initialize private attributes self._coordinates = pf.Coordinates() self._inverse_method = None self._reset_compute_attributes() self.coordinates = coordinates self.inverse_method = inverse_method
[docs] @classmethod def from_definition(cls, definition, coordinates, inverse_method="auto"): r""" Create SphericalHarmonics instance from SphericalHarmonicDefinition. Parameters ---------- definition : SphericalHarmonicDefinition The spherical harmonic definition. coordinates : :py:class:`~pyfar.Coordinates`, :py:class:`SamplingSphere` The sampling points for which the matrices are computed. inverse_method : str, ``None``, optional Method for computing the inverse transform: - ``'quadrature'``: compute the inverse via numerical quadrature. Note that this requires `coordinates` to be a :py:class:`SamplingSphere` object with weights summing to :math:`4\pi`. - ``'pseudo_inverse'``: compute the inverse via the pseudo-inverse. Note that this requires `coordinates` to be a :py:class:`SamplingSphere` object. - ``None``: denotes that the inverse is not defined and cannot be computed. - ``'auto'``: If coordinates are a :py:class:`SamplingSphere`, use ``'quadrature'`` when applicable and ``'pseudo_inverse'`` otherwise. If coordinates are a :py:class:`~pyfar.Coordinates` object ``None`` is used. The default is ``'auto'``. Returns ------- SphericalHarmonics A new SphericalHarmonics instance initialized with the parameters from the definition object. """ # noqa: E501 if type(definition) is not SphericalHarmonicDefinition: raise TypeError('definition must be a SphericalHarmonicDefinition') return cls(definition.n_max, coordinates, definition.basis_type, definition.normalization, definition.channel_convention, inverse_method, definition.condon_shortley)
@property def coordinates(self): """Get or set the coordinates.""" return self._coordinates @coordinates.setter def coordinates(self, value): """Get or set the coordinates.""" if not isinstance(value, pf.Coordinates): raise TypeError("coordinates must be a pyfar.Coordinates " "object or spharpy.SamplingSphere object") if value.cdim != 1: raise ValueError("Coordinates must be a 1D array") if value.csize == 0: raise ValueError("Coordinates cannot be empty") if value != self._coordinates: self._reset_compute_attributes() self._coordinates = value @property def inverse_method(self): """Get or set the inverse transform method.""" return self._inverse_method @inverse_method.setter def inverse_method(self, value): """Get or set the inverse transform method.""" if value not in ["pseudo_inverse", "quadrature", "auto", None]: raise ValueError( "Invalid inverse_method. Allowed: 'pseudo_inverse', " "'quadrature', or 'auto'.") if value == self._inverse_method: return if value is None: self._inverse_method = value return if type(self.coordinates) is pf.Coordinates: if value == "auto": self.inverse_method = None return raise ValueError( "The inverse method can only be set if the coordinates " "are a provided as SamplingSphere.") elif type(self.coordinates) is spharpy.SamplingSphere: if value == "auto": value = ( "quadrature" if self.coordinates.quadrature else "pseudo_inverse" ) elif value == "quadrature" and not self.coordinates.quadrature: raise ValueError( "'quadrature' requires `coordinates` to be a '" "SamplingSphere and coordinates.quadrature to be " "True.") self._reset_compute_attributes() self._inverse_method = value @property def basis(self): """Get the spherical harmonic basis matrix.""" if self._basis is None: self._compute_basis() return self._basis @property def basis_gradient_theta(self): """ Get the gradient of the basis matrix with respect to the `colatitude`/`elevation` angle. See the :py:mod:`~pyfar.classes.coordinates` documentation for the definition of the angles. """ if self._basis_gradient_theta is None: self._compute_basis_gradient() return self._basis_gradient_theta @property def basis_gradient_phi(self): """ Get the gradient of the basis matrix with respect to the `azimuth`/`polar` angle. See the :py:mod:`~pyfar.classes.coordinates` documentation for the definition of the angles. """ if self._basis_gradient_phi is None: self._compute_basis_gradient() return self._basis_gradient_phi def _compute_basis(self): """ Compute the basis matrix for the SphericalHarmonics class. """ if self.basis_type == "complex": function = spharpy.spherical.spherical_harmonic_basis elif self.basis_type == "real": function = spharpy.spherical.spherical_harmonic_basis_real else: raise ValueError( "Invalid basis type, should be either 'complex' or 'real'") self._basis = function( n_max=self.n_max, coordinates=self.coordinates, normalization=self.normalization, channel_convention=self.channel_convention, condon_shortley=self.condon_shortley, ) def _compute_basis_gradient(self): """ Compute the gradient of the basis matrix for the SphericalHarmonics class. """ if any((self.normalization in ["maxN", "SN3D"], self.channel_convention == "fuma")): raise ValueError( f"Gradient computation not supported for normalization " f"'{self.normalization}' and " f"channel convention '{self.channel_convention}'.") if self.basis_type == "complex": function = spharpy.spherical.spherical_harmonic_basis_gradient elif self.basis_type == "real": function = spharpy.spherical.spherical_harmonic_basis_gradient_real else: raise ValueError( "Invalid basis type, should be either 'complex' or 'real'") self._basis_gradient_theta, self._basis_gradient_phi = function( self.n_max, self.coordinates) @property def basis_inv(self): """Get the inverse basis matrix.""" if self._inverse_method is None: raise ValueError("The inverse method is not defined.") if self._basis is None: self._compute_basis() if self._basis_inv is None: self._compute_inverse() return self._basis_inv def _compute_inverse(self): """ Compute the inverse basis matrix for the SphericalHarmonics class. """ if self._basis is None: self._compute_basis() _inv_flag = self.inverse_method if _inv_flag == "pseudo_inverse": self._basis_inv = np.linalg.pinv(self._basis) elif _inv_flag == "quadrature": self._basis_inv = np.einsum( 'ij,i->ji', np.conj(self._basis), self.coordinates.weights) def _reset_compute_attributes(self): """Reset the computed attributes for the SphericalHarmonics class in case of changes in the parameters. """ self._basis = None self._basis_gradient_theta = None self._basis_gradient_phi = None self._basis_inv = None def _on_property_change(self): """Reset computed attributes on property changes.""" self._reset_compute_attributes()