Coverage for local_installation_linux/mumott/core/spherical_harmonic_mapper.py: 96%

124 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-08-11 23:08 +0000

1""" Container for class SphericalHarmonicMapper. """ 

2import numpy as np 

3from numpy.typing import NDArray 

4from scipy.spatial.transform import Rotation as rot 

5from scipy.special import sph_harm, factorial2 

6from typing import Tuple 

7 

8 

9class SphericalHarmonicMapper: 

10 """ 

11 Helper class for visualizing and analyzing spherical harmonics. 

12 Using this class, one can obtain the amplitudes for a given set 

13 of even-ordered harmonics and apply the Funk-Radon transform. 

14 These can then be plotted, analyzed, and so forth. In addition, the class 

15 allows one to represent functions in terms of spherical harmonics, 

16 if they are given as functions of the azimuthal and polar angles 

17 of the class instance. 

18 

19 Parameters 

20 ---------- 

21 ell_max : int, optional 

22 Maximum order of the spherical harmonics. Default is ``2``. 

23 polar_resolution : int, optional 

24 Number of samples in the polar direction. Default is ``16``. 

25 azimuthal_resolution : int, optional 

26 Number of samples in the azimuthal direction. Default is ``32``. 

27 polar_zero : float, optional 

28 The polar angle of the spherical harmonic coordinate system's 

29 pole, relative to the reference coordinate system. Default is ``0``. 

30 azimuthal_zero : float, optional 

31 The azimuthal angle of the spherical harmonic coordinate system's 

32 pole, relative to a reference coordinate system. Default is ``0``. 

33 enforce_friedel_symmetry : bool 

34 If set to ``True``, Friedel symmetry will be enforced, using the assumption that points 

35 on opposite sides of the sphere are equivalent. 

36 

37 Example 

38 ------- 

39 >>> import numpy as np 

40 >>> import matplotlib.pyplot as plt 

41 >>> S = SphericalHarmonicMapper(ell_max=4, polar_resolution=16, azimuthal_resolution=8) 

42 >>> S.ell_indices 

43 array([0, 2, 2, 2, 2, 2, 4, 4, ...]) 

44 >>> S.emm_indices 

45 array([ 0, -2, -1, 0, 1, 2, -4, -3, ...]) 

46 >>> a = S.get_amplitudes(np.random.rand(S.ell_indices.size) - 0.5) 

47 >>> plt.pcolormesh(S.phi * np.sqrt(1 / 2), # basic cylindrical equal-area projection 

48 np.cos(S.theta) / np.sqrt(1 / 2), 

49 a[:-1, :-1]) 

50 ... 

51 """ 

52 

53 def __init__(self, 

54 ell_max: int = 2, 

55 polar_resolution: int = 16, 

56 azimuthal_resolution: int = 32, 

57 polar_zero: float = 0, 

58 azimuthal_zero: float = 0, 

59 enforce_friedel_symmetry: bool = True): 

60 self._polar_zero = polar_zero 

61 self._azimuthal_zero = azimuthal_zero 

62 self._ell_max = ell_max 

63 polar_coordinates = np.linspace(0, np.pi, polar_resolution) 

64 azimuthal_coordinates = np.linspace(-np.pi, np.pi, azimuthal_resolution + 1)[:-1] 

65 self._theta, self._phi = np.meshgrid( 

66 polar_coordinates, 

67 azimuthal_coordinates, 

68 indexing='ij') 

69 self._enforce_friedel_symmetry = enforce_friedel_symmetry 

70 self._update_l_and_m() 

71 

72 self._map = np.zeros(self._theta.shape + self._ell_indices.shape) 

73 self._complex_map = np.zeros(self._theta.shape + self._ell_indices.shape).astype(complex) 

74 self._update_map() 

75 

76 self._rotated_system = True 

77 self._get_coordinates() 

78 self._update_funk_coefficients() 

79 

80 self._xyz = np.concatenate((self._X[..., None], self._Y[..., None], self._Z[..., None]), axis=2) 

81 self.update_zeros(self._polar_zero, self._azimuthal_zero) 

82 

83 def get_amplitudes(self, 

84 coefficients: NDArray[float], 

85 apply_funk_transform: bool = False) -> NDArray[float]: 

86 """ 

87 Returns the amplitudes of a set of spherical harmonics. For sorting 

88 of the coefficients, see the :attr:`orders` and :attr:`degrees` attributes. 

89 

90 Parameters 

91 ---------- 

92 coefficients 

93 The coefficients for which the amplitudes are to be calculated. 

94 Size must be equal to ``(ell_max + 1) * (ell_max / 2 + 1)``. 

95 apply_funk_fransform 

96 Whether to apply the Funk-Radon transform to the coefficients. 

97 This is useful for orientation analysis in some cases. 

98 Default is ``False``. 

99 """ 

100 if apply_funk_transform: 

101 coefficients = (self._funk_coefficients * coefficients.ravel()).reshape(1, 1, -1) 

102 else: 

103 coefficients = coefficients.reshape(1, 1, -1) 

104 return np.einsum('...i, ...i', coefficients, self._map) 

105 

106 def get_harmonic_coefficients(self, amplitude: NDArray[float]) -> NDArray[float]: 

107 """ Returns the spherical harmonic coefficients for the given amplitudes. 

108 Can be used with amplitudes from another instance of 

109 :class:`SphericalHarmonicParameters` with a different orientation 

110 in order to solve for the rotated coefficients. The accuracy of the 

111 representation depends on the maximum order and the polar and azimuthal 

112 resolution. 

113 

114 Parameters 

115 ---------- 

116 amplitude 

117 The amplitude of the spherical function to be 

118 represented, as a function of ``theta`` and ``phi``. 

119 """ 

120 assert np.allclose(amplitude.shape[-1:-3:-1], self.theta.shape) 

121 area_normer = np.sin(self.theta) 

122 area_normer /= np.sum(area_normer) 

123 scaled_amp = np.einsum('...ij, ij -> ...ij', 

124 amplitude, 

125 area_normer, optimize='greedy') 

126 coeffs = np.einsum('ijk, ...ij -> ...k', self.map, scaled_amp, optimize='greedy') 

127 return coeffs 

128 

129 def _get_coordinates(self) -> Tuple[NDArray[float], NDArray[float], NDArray[float]]: 

130 """ Gets the X, Y, and Z-coordinates. Updates them only if the system has 

131 been rotated since the last call. """ 

132 if self._rotated_system: 

133 self._X, self._Y, self._Z = \ 

134 (np.multiply(0.5, np.einsum('..., ...', np.sin(self._theta), np.cos(self._phi))), 

135 np.multiply(0.5, np.einsum('..., ...', np.sin(self._theta), np.sin(self._phi))), 

136 np.multiply(0.5, np.cos(self._theta))) 

137 self._rotated_system = False 

138 return self._X, self._Y, self._Z 

139 

140 def _update_funk_coefficients(self) -> None: 

141 """ Updates the Funk coefficients used for the Funk transform. """ 

142 funk_coefficients = [] 

143 for i in range(self._ell_max+1): 

144 if i % 2 == 0: 

145 funk_coefficients.append(((-1) ** (i // 2)) * factorial2(i - 1) / factorial2(i)) 

146 funk_coefficients = np.array(funk_coefficients) 

147 self._funk_coefficients = funk_coefficients[self._ell_indices // 2] 

148 

149 def _update_l_and_m(self) -> None: 

150 """ Updates the order and degree vectors of the system. """ 

151 if self._enforce_friedel_symmetry: 

152 ell_step = 2 

153 else: 

154 ell_step = 1 

155 self._ell_indices = [] 

156 self._emm_indices = [] 

157 for ll in range(0, self._ell_max+1, ell_step): 

158 for mm in range(-ll, ll+1): 

159 self._ell_indices.append(ll) 

160 self._emm_indices.append(mm) 

161 self._ell_indices = np.array(self._ell_indices) 

162 self._emm_indices = np.array(self._emm_indices) 

163 

164 def _update_map(self) -> None: 

165 """ Updates the map of the system. """ 

166 self._update_complex_map() 

167 self._update_real_map() 

168 

169 def _update_complex_map(self) -> None: 

170 """ Retrieves the complex map used for determining 

171 the real map. """ 

172 self._complex_map[...] = sph_harm( 

173 abs(self._emm_indices.reshape(1, 1, -1)), 

174 self._ell_indices.reshape(1, 1, -1), 

175 self._phi[:, :, None], 

176 self._theta[:, :, None]) 

177 

178 def _update_real_map(self) -> None: 

179 """ Calculates the real spherical harmonic map based on 

180 the complex map. """ 

181 self._map[:, :, self._emm_indices == 0] = \ 

182 np.sqrt(4 * np.pi) * self._complex_map[:, :, self._emm_indices == 0].real 

183 self._map[:, :, self._emm_indices > 0] = \ 

184 ((-1.) ** (self._emm_indices[self._emm_indices > 0])) * np.sqrt(2) * \ 

185 np.sqrt(4 * np.pi) * self._complex_map[:, :, self._emm_indices > 0].real 

186 self._map[:, :, self._emm_indices < 0] = \ 

187 ((-1.) ** (self._emm_indices[self._emm_indices < 0])) * np.sqrt(2) * \ 

188 np.sqrt(4 * np.pi) * self._complex_map[:, :, self._emm_indices < 0].imag 

189 

190 def update_zeros(self, polar_zero: float, azimuthal_zero: float) -> None: 

191 """Changes the orientation of the coordinate system. 

192 

193 Parameters 

194 ---------- 

195 polar_zero 

196 The new polar angle at which the pole should be, 

197 relative to a fixed reference system. 

198 azimuthal_zero 

199 The new azimuthal angle at which the pole should be, 

200 relative to a fixed reference system. 

201 """ 

202 if polar_zero == 0 and azimuthal_zero == 0: 

203 self._theta = np.arccos(self._xyz[..., 2] / np.sqrt((self._xyz ** 2).sum(-1))) 

204 self._phi = np.arctan2(self._xyz[..., 1], self._xyz[..., 0]) 

205 return 

206 xyz = np.copy(self._xyz) 

207 xyz = xyz.reshape(-1, 3) 

208 rotvec = rot.from_euler('Z', (-azimuthal_zero)) 

209 rotvec = rot.from_euler('Y', (-polar_zero)) * rotvec 

210 xyz = rotvec.apply(xyz).reshape(self._theta.shape + (3,)) 

211 self._theta = np.arccos(xyz[..., 2] / np.sqrt((xyz ** 2).sum(-1))) 

212 self._phi = np.arctan2(xyz[..., 1], xyz[..., 0]) 

213 self._polar_zero = polar_zero 

214 self._azimuthal_zero = azimuthal_zero 

215 self._rotated_system = True 

216 self._update_map() 

217 

218 @property 

219 def unit_vectors(self): 

220 """ Probed coordinates object used by BasisSets to calculate function maps. 

221 """ 

222 X, Y, Z = self.coordinates 

223 vectors = 2 * np.stack((X, Y, Z), axis=-1) # The mapper uses a sphere of radius 0.5 

224 return vectors 

225 

226 @property 

227 def ell_indices(self) -> NDArray[int]: 

228 """ The orders of the harmonics calculated 

229 by the class instance. """ 

230 return self._ell_indices 

231 

232 @property 

233 def emm_indices(self) -> NDArray[int]: 

234 """ The degrees of the harmonics calculated 

235 by the class instance. """ 

236 return self._emm_indices 

237 

238 @property 

239 def polar_zero(self) -> NDArray[float]: 

240 """ The polar angle of the spherical harmonic pole, 

241 relative to a fixed reference system. """ 

242 return self._polar_zero 

243 

244 @property 

245 def azimuthal_zero(self) -> NDArray[float]: 

246 """ The azimuthal angle of the spherical harmonic pole, 

247 relative to a fixed reference system. """ 

248 return self._azimuthal_zero 

249 

250 @property 

251 def theta(self) -> NDArray[float]: 

252 """ The polar angle to which the amplitude is mapped. """ 

253 return self._theta 

254 

255 @property 

256 def phi(self) -> NDArray[float]: 

257 """ The azimuthal angle to which the amplitude is mapped. """ 

258 return self._phi 

259 

260 @property 

261 def ell_max(self) -> int: 

262 """ Maximum order of the spherical harmonics. """ 

263 return self._ell_max 

264 

265 @property 

266 def map(self) -> NDArray[float]: 

267 """ Map between amplitude and harmonics. """ 

268 return self._map 

269 

270 @property 

271 def coordinates(self) -> Tuple[NDArray[float], NDArray[float], NDArray[float]]: 

272 """ The X, Y, Z coordinates that the amplitudes 

273 are mapped to. """ 

274 return self._get_coordinates() 

275 

276 @ell_max.setter 

277 def ell_max(self, new_ell_max: int): 

278 self._ell_max = new_ell_max 

279 self._update_l_and_m() 

280 self._map = np.zeros(self._theta.shape + self._ell_indices.shape) 

281 self._complex_map = np.zeros(self._theta.shape + self._ell_indices.shape).astype(complex) 

282 self._update_map() 

283 self._update_funk_coefficients()