Quantum 3xplorer (Q3)
An AI-native quantum simulation engine
Loading...
Searching...
No Matches
harmonics.hpp
Go to the documentation of this file.
1#pragma once
2
3/**
4 * Spherical harmonics Y_lm and degree/order indexing utilities.
5 *
6 * Provides:
7 * - Real spherical harmonics Y_lm(theta, phi), Condon-Shortley phase retained
8 * - Degree/order indexing: m = 0 first, then (+|m|, -|m|) pairs in ascending
9 * |m|, which keeps the cos(m*phi) and sin(m*phi) partners of one |m|
10 * adjacent
11 *
12 * Both are basis choices. Everything Q3 computes is invariant under any other
13 * consistent choice, so what matters is that every consumer in the tree uses
14 * these two functions rather than reproducing the mapping.
15 *
16 * Terminology: l = degree, m = order (standard mathematical convention).
17 */
18
19#include <algorithm>
20#include <array>
21#include <cmath>
22#include <stdexcept>
23#include <string>
24#include <vector>
25
28
29namespace q3 {
30
31/**
32 * Return harmonic order sequence: 0, 1, -1, 2, -2, ...
33 * This alternating-sign convention places m=0 first, then pairs
34 * (|m|, -|m|) in ascending |m| order. Widely used for
35 * real spherical harmonic indexing and projector enumeration.
36 *
37 * @param degree Spherical harmonic degree l
38 * @return Vector of order values in alternating-sign sequence
39 */
40inline std::vector<int> harmonic_order_sequence(int degree) {
41 std::vector<int> sequence;
42 sequence.push_back(0);
43 for (int order_index = 1; order_index <= degree; order_index++) {
44 sequence.push_back(order_index);
45 sequence.push_back(-order_index);
46 }
47 return sequence;
48}
49
50/**
51 * Compute combined (l,m) index from degree and order.
52 * Maps the alternating-sign ordering to a flat 0-based index:
53 * m=0 → l²
54 * m>0 → l² + 2m - 1
55 * m<0 → l² + 2|m|
56 *
57 * @param degree Spherical harmonic degree l
58 * @param order Spherical harmonic order m
59 * @return Combined lm index (0-based)
60 */
61inline int harmonic_combined_degree_order_index(int degree, int order) {
62 if (order == 0) {
63 return degree * degree;
64 }
65 if (order > 0) {
66 return degree * degree + 2 * order - 1;
67 }
68 return degree * degree - 2 * order;
69}
70
71/**
72 * Compute real spherical harmonic Y_lm at a Cartesian direction vector.
73 * Hard-coded closed forms for l=0..4, carrying the Condon-Shortley (-1)^m
74 * phase through to the real combinations -- so Y_11 is proportional to -x/r,
75 * not +x/r as in the many presentations that drop the phase once the
76 * harmonics are real. Agrees with the recursion in
77 * compute_real_spherical_harmonics_batch below to ~1e-14 over the whole
78 * l <= 4 set, which is the property the one-center path depends on.
79 *
80 * @param degree Spherical harmonic degree l (0..4)
81 * @param order Spherical harmonic order m (-l..+l)
82 * @param direction Cartesian direction vector (need not be normalized)
83 * @return Y_lm evaluated at the direction of the vector
84 */
85inline Real compute_real_spherical_harmonic(int degree, int order, const Vector3D& direction) {
86 Real magnitude = direction.norm();
88 if (degree == 0)
89 return 1.0 / std::sqrt(constants::four_pi);
90 return 0.0;
91 }
92 Real normalized_x = direction.x / magnitude;
93 Real normalized_y = direction.y / magnitude;
94 Real normalized_z = direction.z / magnitude;
95
96 if (degree == 0) {
97 return 1.0 / std::sqrt(constants::four_pi);
98 } else if (degree == 1) {
99 Real normalization_factor = std::sqrt(3.0 / constants::four_pi);
100 if (order == -1)
101 return -normalization_factor * normalized_y;
102 if (order == 0)
103 return normalization_factor * normalized_z;
104 if (order == 1)
105 return -normalization_factor * normalized_x;
106 } else if (degree == 2) {
107 Real normalization_factor_l2 = std::sqrt(15.0 / constants::four_pi);
108 if (order == -2)
109 return normalization_factor_l2 * normalized_x * normalized_y;
110 if (order == -1)
111 return -normalization_factor_l2 * normalized_y * normalized_z;
112 if (order == 0)
113 return std::sqrt(5.0 / constants::four_pi) / 2.0 *
114 (3.0 * normalized_z * normalized_z - 1.0);
115 if (order == 1)
116 return -normalization_factor_l2 * normalized_x * normalized_z;
117 if (order == 2)
118 return normalization_factor_l2 / 2.0 *
119 (normalized_x * normalized_x - normalized_y * normalized_y);
120 } else if (degree == 3) {
121 if (order == -3)
122 return -std::sqrt(35.0 / (32.0 * constants::pi)) * normalized_y *
123 (3.0 * normalized_x * normalized_x - normalized_y * normalized_y);
124 if (order == -2)
125 return std::sqrt(105.0 / (4.0 * constants::pi)) * normalized_x * normalized_y *
126 normalized_z;
127 if (order == -1)
128 return -std::sqrt(21.0 / (32.0 * constants::pi)) * normalized_y *
129 (5.0 * normalized_z * normalized_z - 1.0);
130 if (order == 0)
131 return std::sqrt(7.0 / (16.0 * constants::pi)) * normalized_z *
132 (5.0 * normalized_z * normalized_z - 3.0);
133 if (order == 1)
134 return -std::sqrt(21.0 / (32.0 * constants::pi)) * normalized_x *
135 (5.0 * normalized_z * normalized_z - 1.0);
136 if (order == 2)
137 return std::sqrt(105.0 / (16.0 * constants::pi)) * normalized_z *
138 (normalized_x * normalized_x - normalized_y * normalized_y);
139 if (order == 3)
140 return -std::sqrt(35.0 / (32.0 * constants::pi)) * normalized_x *
141 (normalized_x * normalized_x - 3.0 * normalized_y * normalized_y);
142 } else if (degree == 4) {
143 Real normalized_x2 = normalized_x * normalized_x,
144 normalized_y2 = normalized_y * normalized_y,
145 normalized_z2 = normalized_z * normalized_z;
146 if (order == -4)
147 return (3.0 / 4.0) * std::sqrt(35.0 / (constants::pi)) * normalized_x * normalized_y *
148 (normalized_x2 - normalized_y2);
149 if (order == -3)
150 return -(3.0 / 4.0) * std::sqrt(35.0 / (2.0 * constants::pi)) * normalized_y *
151 normalized_z * (3.0 * normalized_x2 - normalized_y2);
152 if (order == -2)
153 return (3.0 / 4.0) * std::sqrt(5.0 / (constants::pi)) * normalized_x * normalized_y *
154 (7.0 * normalized_z2 - 1.0);
155 if (order == -1)
156 return -(3.0 / 4.0) * std::sqrt(5.0 / (2.0 * constants::pi)) * normalized_y *
157 normalized_z * (7.0 * normalized_z2 - 3.0);
158 if (order == 0)
159 return (3.0 / 16.0) * std::sqrt(1.0 / (constants::pi)) *
160 (35.0 * normalized_z2 * normalized_z2 - 30.0 * normalized_z2 + 3.0);
161 if (order == 1)
162 return -(3.0 / 4.0) * std::sqrt(5.0 / (2.0 * constants::pi)) * normalized_x *
163 normalized_z * (7.0 * normalized_z2 - 3.0);
164 if (order == 2)
165 return (3.0 / 8.0) * std::sqrt(5.0 / (constants::pi)) *
166 (normalized_x2 - normalized_y2) * (7.0 * normalized_z2 - 1.0);
167 if (order == 3)
168 return -(3.0 / 4.0) * std::sqrt(35.0 / (2.0 * constants::pi)) * normalized_x *
169 normalized_z * (normalized_x2 - 3.0 * normalized_y2);
170 if (order == 4)
171 return (3.0 / 16.0) * std::sqrt(35.0 / (constants::pi)) *
172 (normalized_x2 * normalized_x2 - 6.0 * normalized_x2 * normalized_y2 +
173 normalized_y2 * normalized_y2);
174 }
175 /**
176 * Above degree 4 there is no closed form here, and there never silently
177 * was one: this returned 0.0, which is a legal value of every harmonic
178 * and therefore indistinguishable from an answer. A caller that asked for
179 * a g-channel harmonic got a number back, used it, and produced a result
180 * with no error anywhere in the chain -- the Gaunt table built by
181 * collocation is the case that made this concrete, where the zeros left
182 * the sampling matrix singular in exactly the rows they should have
183 * filled, and the inversion skipped those pivots rather than failing.
184 *
185 * Refusing is not a limitation this introduces; it is the existing
186 * limitation, said out loud. Extending the closed forms to l = 5 and 6,
187 * or routing this through the recursion that
188 * compute_real_spherical_harmonics_batch already uses for arbitrary
189 * degree, is the way to lift it -- see TODOs.md.
190 */
191 throw std::invalid_argument(
192 "compute_real_spherical_harmonic: degree " + std::to_string(degree) +
193 " is above the highest closed form implemented here (4), so no value can be "
194 "returned. An f-channel dataset reaches this through a Q-function expansion of "
195 "degree " +
196 std::to_string(degree) +
197 "; it needs harmonics up to degree 6, which Q3 does not "
198 "yet have.");
199}
200
201/**
202 * Compute real spherical harmonics Y_lm for a batch of G-vectors.
203 *
204 * Two stages per G-vector: the normalized associated Legendre values Q(l,m)
205 * are built by upward recursion into their own triangular workspace, then the
206 * azimuthal expansion writes each Y_lm into the slot
207 * harmonic_combined_degree_order_index names for its (l, m). The output array
208 * is only ever written, never read back as scratch.
209 *
210 * @param number_of_lm_components Total number of (l,m) components = (lmax+1)²
211 * @param number_of_g_vectors Number of G-vectors in the batch
212 * @param g_vectors Cartesian G-vectors (3-component arrays)
213 * @param g_squared |G|² for each G-vector
214 * @param spherical_harmonics_values Output: values[g_index][lm] for all G-vectors and (l,m)
215 * components
216 */
217inline void
218compute_real_spherical_harmonics_batch(int number_of_lm_components,
219 int number_of_g_vectors,
220 const std::vector<std::array<Real, 3>>& g_vectors,
221 const std::vector<Real>& g_squared,
222 std::vector<std::vector<Real>>& spherical_harmonics_values) {
223 int maximum_angular_momentum = 0;
224 while ((maximum_angular_momentum + 1) * (maximum_angular_momentum + 1) <
225 number_of_lm_components)
226 maximum_angular_momentum++;
227
228 spherical_harmonics_values.assign(number_of_g_vectors,
229 std::vector<Real>(number_of_lm_components, 0.0));
230
231 if (number_of_lm_components == 0 || number_of_g_vectors == 0)
232 return;
233
234 /**
235 * |G| below which the direction is not defined at all. In a plane-wave
236 * basis this is G = 0 exactly and nothing else: the next-smallest |G| is a
237 * reciprocal lattice vector. Y_lm still has to return something there, and
238 * the convention below is the one the callers have always been given --
239 * stated here rather than left to fall out of the arithmetic.
240 */
241 constexpr Real degenerate_direction_threshold = 1e-9;
242
243 /**
244 * Q(l,m) = sqrt((l-m)!/(l+m)!) * P_l^m(cos_theta) for 0 <= m <= l, the
245 * combination the azimuthal expansion needs. Held in its own triangular
246 * workspace of exactly the (l,m) pairs that exist, rather than parked in
247 * the unused slots of the output array: the recursion reads three Q values
248 * per step, and aliasing them onto the results makes the write pattern of
249 * the second phase depend on the layout of the first.
250 *
251 * Normalizing before recursing rather than after is what keeps the
252 * intermediates O(1) -- the unnormalized P_l^m grow like (2l-1)!! and
253 * overflow well before the l this routine is asked for.
254 */
255 const int legendre_size = (maximum_angular_momentum + 1) * (maximum_angular_momentum + 2) / 2;
256 std::vector<Real> legendre(static_cast<size_t>(legendre_size), 0.0);
257 const auto legendre_index = [](int degree, int order) {
258 return degree * (degree + 1) / 2 + order;
259 };
260
261 for (int g_index = 0; g_index < number_of_g_vectors; g_index++) {
262 const Real g_modulus = std::sqrt(g_squared[g_index]);
263 const bool direction_is_degenerate = g_modulus < degenerate_direction_threshold;
264
265 const Real cos_theta = direction_is_degenerate ? 0.0 : g_vectors[g_index][2] / g_modulus;
266 const Real sin_theta = std::sqrt(std::max(0.0, 1.0 - cos_theta * cos_theta));
267
268 /**
269 * atan2 is total on every real direction, including the x = 0 meridian
270 * where a quotient is not. It is not total on the degenerate direction:
271 * atan2(0, 0) is 0, which would silently move G = 0 onto the x axis and
272 * change every m != 0 component there. So that one case keeps its own
273 * branch and its own documented convention (the +y meridian).
274 */
275 const Real azimuthal_angle = direction_is_degenerate
276 ? std::copysign(constants::pi / 2.0, g_vectors[g_index][1])
277 : std::atan2(g_vectors[g_index][1], g_vectors[g_index][0]);
278
279 std::fill(legendre.begin(), legendre.end(), 0.0);
280 legendre[legendre_index(0, 0)] = 1.0;
281 if (maximum_angular_momentum >= 1) {
282 legendre[legendre_index(1, 0)] = cos_theta;
283 legendre[legendre_index(1, 1)] = -sin_theta / std::sqrt(2.0);
284 }
285
286 for (int degree = 2; degree <= maximum_angular_momentum; degree++) {
287 for (int order = 0; order <= degree - 2; order++) {
288 const Real denominator =
289 std::sqrt(static_cast<Real>(degree * degree - order * order));
290 legendre[legendre_index(degree, order)] =
291 cos_theta * (2 * degree - 1) / denominator *
292 legendre[legendre_index(degree - 1, order)] -
293 std::sqrt(static_cast<Real>((degree - 1) * (degree - 1) - order * order)) /
294 denominator * legendre[legendre_index(degree - 2, order)];
295 }
296 const Real diagonal_factor = std::sqrt(static_cast<Real>(2 * degree - 1));
297 const Real previous_diagonal = legendre[legendre_index(degree - 1, degree - 1)];
298 legendre[legendre_index(degree, degree - 1)] =
299 cos_theta * diagonal_factor * previous_diagonal;
300 legendre[legendre_index(degree, degree)] = -diagonal_factor /
301 std::sqrt(static_cast<Real>(2 * degree)) *
302 sin_theta * previous_diagonal;
303 }
304
305 /**
306 * Each Y_lm is written to the slot harmonic_combined_degree_order_index
307 * names for its (l, m), so this loop states where every value lands
308 * instead of walking a counter that has to stay in step with it.
309 */
310 std::vector<Real>& values = spherical_harmonics_values[g_index];
312 legendre[legendre_index(0, 0)] / std::sqrt(constants::four_pi);
313
314 for (int degree = 1; degree <= maximum_angular_momentum; degree++) {
315 const Real normalization =
316 std::sqrt(static_cast<Real>(2 * degree + 1) / constants::four_pi);
317 values[harmonic_combined_degree_order_index(degree, 0)] =
318 normalization * legendre[legendre_index(degree, 0)];
319
320 const Real off_axis_normalization = normalization * std::sqrt(2.0);
321 for (int order = 1; order <= degree; order++) {
322 const Real legendre_value = legendre[legendre_index(degree, order)];
323 values[harmonic_combined_degree_order_index(degree, order)] =
324 off_axis_normalization * legendre_value * std::cos(order * azimuthal_angle);
325 values[harmonic_combined_degree_order_index(degree, -order)] =
326 off_axis_normalization * legendre_value * std::sin(order * azimuthal_angle);
327 }
328 }
329 }
330}
331
332/**
333 * Compute derivatives of real spherical harmonics dY_lm/dG_i for a batch of G-vectors.
334 * Central finite differences on the batch Ylm function rather than closed-form
335 * angular derivatives -- the same trade Q3 makes for the one-center tangential
336 * gradients: one differentiation path to keep correct instead of a per-(l,m)
337 * table, at a cost negligible next to the two batch evaluations it already
338 * needs. The step is relative (delta * |G|) because Y_lm depends only on the
339 * direction, so |G| is the only scale in the problem and a fixed absolute step
340 * would be wrong across a shell structure spanning orders of magnitude.
341 *
342 * This is much more efficient than computing derivatives per-projector-per-G because:
343 * - The batch Ylm function computes all (l,m) components at once per G-vector
344 * - Only 2 batch calls per polarization direction (6 total) regardless of number of projectors
345 *
346 * @param number_of_lm_components Total number of (l,m) components = (lmax+1)²
347 * @param number_of_g_vectors Number of G-vectors in the batch
348 * @param g_vectors Cartesian G-vectors (3-component arrays)
349 * @param g_squared |G|² for each G-vector
350 * @param polarization_index Cartesian direction index (0=x, 1=y, 2=z)
351 * @param spherical_harmonic_derivatives Output: derivatives[g_index][lm]
352 */
354 int number_of_lm_components,
355 int number_of_g_vectors,
356 const std::vector<std::array<Real, 3>>& g_vectors,
357 const std::vector<Real>& g_squared,
358 int polarization_index,
359 std::vector<std::vector<Real>>& spherical_harmonic_derivatives) {
360
361 constexpr Real delta = 1.0e-6;
362 constexpr Real epsilon_threshold = 1.0e-9;
363
364 // Create perturbed G-vectors: G + delta*|G|*e_i and G - delta*|G|*e_i
365 std::vector<std::array<Real, 3>> g_vectors_plus(number_of_g_vectors);
366 std::vector<std::array<Real, 3>> g_vectors_minus(number_of_g_vectors);
367 std::vector<Real> g_squared_plus(number_of_g_vectors);
368 std::vector<Real> g_squared_minus(number_of_g_vectors);
369 std::vector<Real> delta_g(number_of_g_vectors);
370
371 for (int g_index = 0; g_index < number_of_g_vectors; g_index++) {
372 Real g_modulus = std::sqrt(g_squared[g_index]);
373 delta_g[g_index] = delta * g_modulus;
374
375 g_vectors_plus[g_index] = g_vectors[g_index];
376 g_vectors_minus[g_index] = g_vectors[g_index];
377 g_vectors_plus[g_index][polarization_index] += delta_g[g_index];
378 g_vectors_minus[g_index][polarization_index] -= delta_g[g_index];
379
380 g_squared_plus[g_index] = g_vectors_plus[g_index][0] * g_vectors_plus[g_index][0] +
381 g_vectors_plus[g_index][1] * g_vectors_plus[g_index][1] +
382 g_vectors_plus[g_index][2] * g_vectors_plus[g_index][2];
383 g_squared_minus[g_index] = g_vectors_minus[g_index][0] * g_vectors_minus[g_index][0] +
384 g_vectors_minus[g_index][1] * g_vectors_minus[g_index][1] +
385 g_vectors_minus[g_index][2] * g_vectors_minus[g_index][2];
386 }
387
388 // Compute Y_lm at G + dG and G - dG
389 std::vector<std::vector<Real>> ylm_plus, ylm_minus;
391 number_of_lm_components, number_of_g_vectors, g_vectors_plus, g_squared_plus, ylm_plus);
393 number_of_lm_components, number_of_g_vectors, g_vectors_minus, g_squared_minus, ylm_minus);
394
395 // Compute central finite difference: dY/dG_i = (Y(G+dG) - Y(G-dG)) / (2*dG)
396 spherical_harmonic_derivatives.resize(number_of_g_vectors,
397 std::vector<Real>(number_of_lm_components, 0.0));
398 for (int g_index = 0; g_index < number_of_g_vectors; g_index++) {
399 if (g_squared[g_index] > epsilon_threshold) {
400 Real inverse_two_delta_g = 0.5 / delta_g[g_index];
401 for (int lm_index = 0; lm_index < number_of_lm_components; lm_index++) {
402 spherical_harmonic_derivatives[g_index][lm_index] =
403 (ylm_plus[g_index][lm_index] - ylm_minus[g_index][lm_index]) *
404 inverse_two_delta_g;
405 }
406 }
407 }
408}
409
410} // namespace q3
constexpr Real pi
Definition constants.hpp:24
constexpr Real four_pi
Definition constants.hpp:26
constexpr double zero_vector_magnitude_threshold
void compute_real_spherical_harmonics_batch(int number_of_lm_components, int number_of_g_vectors, const std::vector< std::array< Real, 3 > > &g_vectors, const std::vector< Real > &g_squared, std::vector< std::vector< Real > > &spherical_harmonics_values)
int harmonic_combined_degree_order_index(int degree, int order)
Definition harmonics.hpp:61
void compute_spherical_harmonic_derivatives_batch(int number_of_lm_components, int number_of_g_vectors, const std::vector< std::array< Real, 3 > > &g_vectors, const std::vector< Real > &g_squared, int polarization_index, std::vector< std::vector< Real > > &spherical_harmonic_derivatives)
double Real
Real compute_real_spherical_harmonic(int degree, int order, const Vector3D &direction)
Definition harmonics.hpp:85
std::vector< int > harmonic_order_sequence(int degree)
Definition harmonics.hpp:40
Real norm() const
Definition vector_3d.hpp:68