Source code for mumott.core.spherical_harmonic_mapper

""" Container for class SphericalHarmonicMapper. """
import numpy as np
from numpy.typing import NDArray
from scipy.spatial.transform import Rotation as rot
from scipy.special import sph_harm, factorial2
from typing import Tuple


[docs]class SphericalHarmonicMapper: """ Helper class for visualizing and analyzing spherical harmonics. Using this class, one can obtain the amplitudes for a given set of even-ordered harmonics and apply the Funk-Radon transform. These can then be plotted, analyzed, and so forth. In addition, the class allows one to represent functions in terms of spherical harmonics, if they are given as functions of the azimuthal and polar angles of the class instance. Parameters ---------- ell_max : int, optional Maximum order of the spherical harmonics. Default is ``2``. polar_resolution : int, optional Number of samples in the polar direction. Default is ``16``. azimuthal_resolution : int, optional Number of samples in the azimuthal direction. Default is ``32``. polar_zero : float, optional The polar angle of the spherical harmonic coordinate system's pole, relative to the reference coordinate system. Default is ``0``. azimuthal_zero : float, optional The azimuthal angle of the spherical harmonic coordinate system's pole, relative to a reference coordinate system. Default is ``0``. enforce_friedel_symmetry : bool If set to ``True``, Friedel symmetry will be enforced, using the assumption that points on opposite sides of the sphere are equivalent. Example ------- >>> import numpy as np >>> import matplotlib.pyplot as plt >>> S = SphericalHarmonicMapper(ell_max=4, polar_resolution=16, azimuthal_resolution=8) >>> S.ell_indices array([0, 2, 2, 2, 2, 2, 4, 4, ...]) >>> S.emm_indices array([ 0, -2, -1, 0, 1, 2, -4, -3, ...]) >>> a = S.get_amplitudes(np.random.rand(S.ell_indices.size) - 0.5) >>> plt.pcolormesh(S.phi * np.sqrt(1 / 2), # basic cylindrical equal-area projection np.cos(S.theta) / np.sqrt(1 / 2), a[:-1, :-1]) ... """ def __init__(self, ell_max: int = 2, polar_resolution: int = 16, azimuthal_resolution: int = 32, polar_zero: float = 0, azimuthal_zero: float = 0, enforce_friedel_symmetry: bool = True): self._polar_zero = polar_zero self._azimuthal_zero = azimuthal_zero self._ell_max = ell_max polar_coordinates = np.linspace(0, np.pi, polar_resolution) azimuthal_coordinates = np.linspace(-np.pi, np.pi, azimuthal_resolution + 1)[:-1] self._theta, self._phi = np.meshgrid( polar_coordinates, azimuthal_coordinates, indexing='ij') self._enforce_friedel_symmetry = enforce_friedel_symmetry self._update_l_and_m() self._map = np.zeros(self._theta.shape + self._ell_indices.shape) self._complex_map = np.zeros(self._theta.shape + self._ell_indices.shape).astype(complex) self._update_map() self._rotated_system = True self._get_coordinates() self._update_funk_coefficients() self._xyz = np.concatenate((self._X[..., None], self._Y[..., None], self._Z[..., None]), axis=2) self.update_zeros(self._polar_zero, self._azimuthal_zero)
[docs] def get_amplitudes(self, coefficients: NDArray[float], apply_funk_transform: bool = False) -> NDArray[float]: """ Returns the amplitudes of a set of spherical harmonics. For sorting of the coefficients, see the :attr:`orders` and :attr:`degrees` attributes. Parameters ---------- coefficients The coefficients for which the amplitudes are to be calculated. Size must be equal to ``(ell_max + 1) * (ell_max / 2 + 1)``. apply_funk_fransform Whether to apply the Funk-Radon transform to the coefficients. This is useful for orientation analysis in some cases. Default is ``False``. """ if apply_funk_transform: coefficients = (self._funk_coefficients * coefficients.ravel()).reshape(1, 1, -1) else: coefficients = coefficients.reshape(1, 1, -1) return np.einsum('...i, ...i', coefficients, self._map)
[docs] def get_harmonic_coefficients(self, amplitude: NDArray[float]) -> NDArray[float]: """ Returns the spherical harmonic coefficients for the given amplitudes. Can be used with amplitudes from another instance of :class:`SphericalHarmonicParameters` with a different orientation in order to solve for the rotated coefficients. The accuracy of the representation depends on the maximum order and the polar and azimuthal resolution. Parameters ---------- amplitude The amplitude of the spherical function to be represented, as a function of ``theta`` and ``phi``. """ assert np.allclose(amplitude.shape[-1:-3:-1], self.theta.shape) area_normer = np.sin(self.theta) area_normer /= np.sum(area_normer) scaled_amp = np.einsum('...ij, ij -> ...ij', amplitude, area_normer, optimize='greedy') coeffs = np.einsum('ijk, ...ij -> ...k', self.map, scaled_amp, optimize='greedy') return coeffs
def _get_coordinates(self) -> Tuple[NDArray[float], NDArray[float], NDArray[float]]: """ Gets the X, Y, and Z-coordinates. Updates them only if the system has been rotated since the last call. """ if self._rotated_system: self._X, self._Y, self._Z = \ (np.multiply(0.5, np.einsum('..., ...', np.sin(self._theta), np.cos(self._phi))), np.multiply(0.5, np.einsum('..., ...', np.sin(self._theta), np.sin(self._phi))), np.multiply(0.5, np.cos(self._theta))) self._rotated_system = False return self._X, self._Y, self._Z def _update_funk_coefficients(self) -> None: """ Updates the Funk coefficients used for the Funk transform. """ funk_coefficients = [] for i in range(self._ell_max+1): if i % 2 == 0: funk_coefficients.append(((-1) ** (i // 2)) * factorial2(i - 1) / factorial2(i)) funk_coefficients = np.array(funk_coefficients) self._funk_coefficients = funk_coefficients[self._ell_indices // 2] def _update_l_and_m(self) -> None: """ Updates the order and degree vectors of the system. """ if self._enforce_friedel_symmetry: ell_step = 2 else: ell_step = 1 self._ell_indices = [] self._emm_indices = [] for ll in range(0, self._ell_max+1, ell_step): for mm in range(-ll, ll+1): self._ell_indices.append(ll) self._emm_indices.append(mm) self._ell_indices = np.array(self._ell_indices) self._emm_indices = np.array(self._emm_indices) def _update_map(self) -> None: """ Updates the map of the system. """ self._update_complex_map() self._update_real_map() def _update_complex_map(self) -> None: """ Retrieves the complex map used for determining the real map. """ self._complex_map[...] = sph_harm( abs(self._emm_indices.reshape(1, 1, -1)), self._ell_indices.reshape(1, 1, -1), self._phi[:, :, None], self._theta[:, :, None]) def _update_real_map(self) -> None: """ Calculates the real spherical harmonic map based on the complex map. """ self._map[:, :, self._emm_indices == 0] = \ np.sqrt(4 * np.pi) * self._complex_map[:, :, self._emm_indices == 0].real self._map[:, :, self._emm_indices > 0] = \ ((-1.) ** (self._emm_indices[self._emm_indices > 0])) * np.sqrt(2) * \ np.sqrt(4 * np.pi) * self._complex_map[:, :, self._emm_indices > 0].real self._map[:, :, self._emm_indices < 0] = \ ((-1.) ** (self._emm_indices[self._emm_indices < 0])) * np.sqrt(2) * \ np.sqrt(4 * np.pi) * self._complex_map[:, :, self._emm_indices < 0].imag
[docs] def update_zeros(self, polar_zero: float, azimuthal_zero: float) -> None: """Changes the orientation of the coordinate system. Parameters ---------- polar_zero The new polar angle at which the pole should be, relative to a fixed reference system. azimuthal_zero The new azimuthal angle at which the pole should be, relative to a fixed reference system. """ if polar_zero == 0 and azimuthal_zero == 0: self._theta = np.arccos(self._xyz[..., 2] / np.sqrt((self._xyz ** 2).sum(-1))) self._phi = np.arctan2(self._xyz[..., 1], self._xyz[..., 0]) return xyz = np.copy(self._xyz) xyz = xyz.reshape(-1, 3) rotvec = rot.from_euler('Z', (-azimuthal_zero)) rotvec = rot.from_euler('Y', (-polar_zero)) * rotvec xyz = rotvec.apply(xyz).reshape(self._theta.shape + (3,)) self._theta = np.arccos(xyz[..., 2] / np.sqrt((xyz ** 2).sum(-1))) self._phi = np.arctan2(xyz[..., 1], xyz[..., 0]) self._polar_zero = polar_zero self._azimuthal_zero = azimuthal_zero self._rotated_system = True self._update_map()
@property def unit_vectors(self): """ Probed coordinates object used by BasisSets to calculate function maps. """ X, Y, Z = self.coordinates vectors = 2 * np.stack((X, Y, Z), axis=-1) # The mapper uses a sphere of radius 0.5 return vectors @property def ell_indices(self) -> NDArray[int]: """ The orders of the harmonics calculated by the class instance. """ return self._ell_indices @property def emm_indices(self) -> NDArray[int]: """ The degrees of the harmonics calculated by the class instance. """ return self._emm_indices @property def polar_zero(self) -> NDArray[float]: """ The polar angle of the spherical harmonic pole, relative to a fixed reference system. """ return self._polar_zero @property def azimuthal_zero(self) -> NDArray[float]: """ The azimuthal angle of the spherical harmonic pole, relative to a fixed reference system. """ return self._azimuthal_zero @property def theta(self) -> NDArray[float]: """ The polar angle to which the amplitude is mapped. """ return self._theta @property def phi(self) -> NDArray[float]: """ The azimuthal angle to which the amplitude is mapped. """ return self._phi @property def ell_max(self) -> int: """ Maximum order of the spherical harmonics. """ return self._ell_max @property def map(self) -> NDArray[float]: """ Map between amplitude and harmonics. """ return self._map @property def coordinates(self) -> Tuple[NDArray[float], NDArray[float], NDArray[float]]: """ The X, Y, Z coordinates that the amplitudes are mapped to. """ return self._get_coordinates() @ell_max.setter def ell_max(self, new_ell_max: int): self._ell_max = new_ell_max self._update_l_and_m() self._map = np.zeros(self._theta.shape + self._ell_indices.shape) self._complex_map = np.zeros(self._theta.shape + self._ell_indices.shape).astype(complex) self._update_map() self._update_funk_coefficients()