treams_core/special/
harmonics.rs

1//! Cartesian solid harmonics `r^l P_l^m(z/r) e^(i m phi)` with their gradients and Hessians,
2//! singly or tabulated for every degree and order, their values and tangential gradients
3//! on the unit sphere, and the normalization of spherical harmonics.
4//!
5//! treams-rs extension. Solid harmonics are polynomials in `x, y, z`, so they and their
6//! derivatives stay finite on the polar axis and at the origin. Spherical translations,
7//! lattice sums and field evaluations use them.
8#![allow(clippy::indexing_slicing)] // Fixed Cartesian triples, never caller indices.
9
10use std::f64::consts::PI;
11
12use super::{factorial_ratio_sqrt, log_factorial};
13use crate::{Complex, numerics::parity};
14
15/// A solid harmonic and its Cartesian derivatives.
16#[derive(Clone, Copy, Debug, Default)]
17pub(crate) struct Solid {
18    /// The value `S`.
19    pub(crate) value: Complex,
20    /// The derivatives `dS/dx_i`.
21    pub(crate) gradient: [Complex; 3],
22    /// The second derivatives `d^2 S / dx_i dx_j`; zero unless requested.
23    pub(crate) hessian: [[Complex; 3]; 3],
24}
25
26/// `r^l P_l^m(z/r) exp(i m phi)`, zero for `|m| > l`. The second derivative is compiled
27/// out unless requested.
28pub(crate) fn solid<const SECOND: bool>(l: i32, m: i32, position: [f64; 3]) -> Solid {
29    if m.abs() > l {
30        return Solid::default();
31    }
32    let [x, y, z] = position;
33    let r2 = x * x + y * y + z * z;
34    let order = m.abs();
35    let xy = Complex::new(x, y);
36    let e = [Complex::new(1.0, 0.0), Complex::i(), Complex::default()];
37    let mut p = Complex::new(1.0, 0.0);
38    let mut grad = [Complex::default(); 3];
39    let mut hessian = [[Complex::default(); 3]; 3];
40    for k in 1..=order {
41        let factor = -f64::from(2 * k - 1);
42        if SECOND {
43            hessian = std::array::from_fn(|i| {
44                std::array::from_fn(|j| {
45                    factor * (hessian[i][j] * xy + grad[i] * e[j] + grad[j] * e[i])
46                })
47            });
48        }
49        grad = std::array::from_fn(|i| factor * (grad[i] * xy + p * e[i]));
50        p *= factor * xy;
51    }
52    let (mut prev, mut prev_grad, mut prev_hessian) = (
53        Complex::default(),
54        [Complex::default(); 3],
55        [[Complex::default(); 3]; 3],
56    );
57    for degree in order + 1..=l {
58        let a = f64::from(2 * degree - 1) / f64::from(degree - order);
59        let b = f64::from(degree + order - 1) / f64::from(degree - order);
60        let next = a * z * p - b * r2 * prev;
61        let next_grad = std::array::from_fn(|i| {
62            a * (z * grad[i] + if i == 2 { p } else { Complex::default() })
63                - b * (r2 * prev_grad[i] + 2.0 * position[i] * prev)
64        });
65        if SECOND {
66            let next_hessian = std::array::from_fn(|i| {
67                std::array::from_fn(|j| {
68                    a * (z * hessian[i][j]
69                        + if i == 2 { grad[j] } else { Complex::default() }
70                        + if j == 2 { grad[i] } else { Complex::default() })
71                        - b * (r2 * prev_hessian[i][j]
72                            + 2.0 * position[i] * prev_grad[j]
73                            + 2.0 * position[j] * prev_grad[i]
74                            + if i == j {
75                                2.0 * prev
76                            } else {
77                                Complex::default()
78                            })
79                })
80            });
81            prev_hessian = hessian;
82            hessian = next_hessian;
83        }
84        (prev, prev_grad) = (p, grad);
85        (p, grad) = (next, next_grad);
86    }
87    if m < 0 {
88        let factor = parity(order) * (log_factorial(l - order) - log_factorial(l + order)).exp();
89        p = factor * p.conj();
90        grad = grad.map(|g| factor * g.conj());
91        if SECOND {
92            hessian = hessian.map(|row| row.map(|g| factor * g.conj()));
93        }
94    }
95    Solid {
96        value: p,
97        gradient: grad,
98        hessian,
99    }
100}
101
102/// Length `r` and direction `x / r` of a displacement `x`. Scaling by the largest
103/// component first keeps both accurate where `|x|^2` under- or overflows.
104///
105/// `None` at the origin, and also where every component is subnormal: `1 / r` would
106/// overflow there, while regular translations and their derivatives differ from their
107/// origin limits by `O(|k| r)` relative to their size, below rounding for `|k| < 1e290`.
108pub(crate) fn direction(position: [f64; 3]) -> Option<(f64, [f64; 3])> {
109    let scale = position.iter().fold(0.0_f64, |scale, x| scale.max(x.abs()));
110    if scale < f64::MIN_POSITIVE {
111        return None;
112    }
113    let scaled = position.map(|x| x / scale);
114    let norm = scaled.iter().map(|x| x * x).sum::<f64>().sqrt();
115    Some((scale * norm, scaled.map(|x| x / norm)))
116}
117
118/// `Y = P_l^m(cos theta) exp(i m phi)` at a unit vector `u` and its tangential gradient
119/// `grad S(u) - l S(u) u`, where `S` is the degree-`l` solid harmonic with `S(u) = Y`.
120/// The tangential gradient is `r` times the Cartesian gradient of `Y` at `r u`.
121pub(crate) fn on_sphere(l: i32, m: i32, unit: [f64; 3]) -> (Complex, [Complex; 3]) {
122    let solid = solid::<false>(l, m, unit);
123    (solid.value, tangent(l, solid.value, solid.gradient, unit))
124}
125
126/// The tangential part `grad S(u) - l S(u) u` of a degree-`l` solid harmonic's gradient.
127pub(crate) fn tangent(
128    l: i32,
129    value: Complex,
130    gradient: [Complex; 3],
131    unit: [f64; 3],
132) -> [Complex; 3] {
133    let radial = f64::from(l) * value;
134    std::array::from_fn(|axis| gradient[axis] - radial * unit[axis])
135}
136
137/// `sqrt((2l + 1) / 4 pi) sqrt((l - m)! / (l + m)!)`, which turns `P_l^m(cos theta)
138/// exp(i m phi)` into the orthonormal spherical harmonic `Y_lm`.
139pub(crate) fn harmonic_normalization(l: i32, m: i32) -> f64 {
140    (f64::from(2 * l + 1) / (4.0 * PI)).sqrt() * factorial_ratio_sqrt(l, m)
141}
142
143/// Solid harmonics `r^p P_p^m e^(i m phi)` of every degree `p <= order` and order
144/// `|m| <= p`, from one degree recurrence per `|m|` rather than one per entry, with the
145/// order-reflection factors of negative orders precomputed.
146#[derive(Clone, Debug)]
147pub(crate) struct SolidTable {
148    order: i32,
149    reflection: Vec<f64>,
150}
151
152/// Position `p * p + p + m` of `(p, m)` in a harmonic table.
153fn table_index(p: i32, m: i32) -> usize {
154    usize::try_from(p * p + p + m).unwrap_or_default()
155}
156
157impl SolidTable {
158    /// The table of every degree up to `order`.
159    pub(crate) fn new(order: i32) -> Self {
160        let reflection = (0..=order)
161            .flat_map(|l| (-l..=l).map(move |m| (l, m)))
162            .map(|(l, m)| {
163                let order = m.abs();
164                parity(order) * (log_factorial(l - order) - log_factorial(l + order)).exp()
165            })
166            .collect();
167        Self { order, reflection }
168    }
169
170    /// Number of table entries, `(order + 1)^2`.
171    pub(crate) fn len(&self) -> usize {
172        self.reflection.len()
173    }
174
175    /// Visits `(p * p + p + m, p, S, grad S)` for every entry. Each value and gradient
176    /// equals `solid::<false>(p, m, position)` bit for bit; gradients stay zero unless
177    /// `GRADIENT`.
178    pub(crate) fn visit<const GRADIENT: bool>(
179        &self,
180        position: [f64; 3],
181        mut emit: impl FnMut(usize, i32, Complex, [Complex; 3]),
182    ) {
183        self.visit_impl::<GRADIENT, false>(position, |index, degree, solid| {
184            emit(index, degree, solid.value, solid.gradient);
185        });
186    }
187
188    /// Visits every solid harmonic with its gradient and, if `SECOND`, Hessian.
189    /// Entries equal the corresponding single [`solid`] evaluation bit for bit.
190    pub(crate) fn visit_solid<const SECOND: bool>(
191        &self,
192        position: [f64; 3],
193        emit: impl FnMut(usize, i32, Solid),
194    ) {
195        self.visit_impl::<true, SECOND>(position, emit);
196    }
197
198    fn visit_impl<const GRADIENT: bool, const SECOND: bool>(
199        &self,
200        position: [f64; 3],
201        mut emit: impl FnMut(usize, i32, Solid),
202    ) {
203        let [x, y, z] = position;
204        let r2 = x * x + y * y + z * z;
205        let xy = Complex::new(x, y);
206        let e = [Complex::new(1.0, 0.0), Complex::i(), Complex::default()];
207        let (mut diagonal, mut diagonal_gradient) =
208            (Complex::new(1.0, 0.0), [Complex::default(); 3]);
209        let mut diagonal_hessian = [[Complex::default(); 3]; 3];
210        for order in 0..=self.order {
211            if order > 0 {
212                // One step of `solid`'s ladder to P_order^order.
213                let factor = -f64::from(2 * order - 1);
214                if SECOND {
215                    diagonal_hessian = std::array::from_fn(|i| {
216                        std::array::from_fn(|j| {
217                            factor
218                                * (diagonal_hessian[i][j] * xy
219                                    + diagonal_gradient[i] * e[j]
220                                    + diagonal_gradient[j] * e[i])
221                        })
222                    });
223                }
224                if GRADIENT {
225                    diagonal_gradient = std::array::from_fn(|i| {
226                        factor * (diagonal_gradient[i] * xy + diagonal * e[i])
227                    });
228                }
229                diagonal *= factor * xy;
230            }
231            let (mut p, mut grad) = (diagonal, diagonal_gradient);
232            let mut hessian = diagonal_hessian;
233            let (mut prev, mut prev_grad) = (Complex::default(), [Complex::default(); 3]);
234            let mut prev_hessian = [[Complex::default(); 3]; 3];
235            for degree in order..=self.order {
236                if degree > order {
237                    let a = f64::from(2 * degree - 1) / f64::from(degree - order);
238                    let b = f64::from(degree + order - 1) / f64::from(degree - order);
239                    let next = a * z * p - b * r2 * prev;
240                    let next_grad = if GRADIENT {
241                        std::array::from_fn(|i| {
242                            a * (z * grad[i] + if i == 2 { p } else { Complex::default() })
243                                - b * (r2 * prev_grad[i] + 2.0 * position[i] * prev)
244                        })
245                    } else {
246                        grad
247                    };
248                    if SECOND {
249                        let next_hessian = std::array::from_fn(|i| {
250                            std::array::from_fn(|j| {
251                                a * (z * hessian[i][j]
252                                    + if i == 2 { grad[j] } else { Complex::default() }
253                                    + if j == 2 { grad[i] } else { Complex::default() })
254                                    - b * (r2 * prev_hessian[i][j]
255                                        + 2.0 * position[i] * prev_grad[j]
256                                        + 2.0 * position[j] * prev_grad[i]
257                                        + if i == j {
258                                            2.0 * prev
259                                        } else {
260                                            Complex::default()
261                                        })
262                            })
263                        });
264                        prev_hessian = hessian;
265                        hessian = next_hessian;
266                    }
267                    (prev, prev_grad) = (p, grad);
268                    (p, grad) = (next, next_grad);
269                }
270                emit(
271                    table_index(degree, order),
272                    degree,
273                    Solid {
274                        value: p,
275                        gradient: grad,
276                        hessian,
277                    },
278                );
279                if order > 0 {
280                    let index = table_index(degree, -order);
281                    let factor = self.reflection[index];
282                    let gradient = if GRADIENT {
283                        grad.map(|g| factor * g.conj())
284                    } else {
285                        grad
286                    };
287                    let hessian = if SECOND {
288                        hessian.map(|row| row.map(|g| factor * g.conj()))
289                    } else {
290                        hessian
291                    };
292                    emit(
293                        index,
294                        degree,
295                        Solid {
296                            value: factor * p.conj(),
297                            gradient,
298                            hessian,
299                        },
300                    );
301                }
302            }
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    //! Solid harmonics, their tables and angular values against the Legendre recurrence;
310    //! the translations and lattice sums built on them are checked in `properties/`.
311
312    use std::f64::consts::PI;
313
314    use proptest::{prelude::*, test_runner::TestCaseError};
315
316    use super::{SolidTable, direction, on_sphere, solid};
317    use crate::{
318        Complex,
319        special::{Angular, angular_value},
320        test_support::{ALGEBRA_CASES, degree_order, prop_assert_close},
321    };
322
323    /// Points in a box, on the polar axis and in the equatorial plane.
324    fn position() -> impl Strategy<Value = [f64; 3]> {
325        prop_oneof![
326            (-2.0_f64..2.0, -2.0_f64..2.0, -2.0_f64..2.0).prop_map(<[f64; 3]>::from),
327            (-2.0_f64..2.0).prop_map(|z| [0.0, 0.0, z]),
328            (-2.0_f64..2.0, -2.0_f64..2.0).prop_map(|(x, y)| [x, y, 0.0]),
329        ]
330    }
331
332    proptest! {
333        #![proptest_config(ProptestConfig::with_cases(ALGEBRA_CASES))]
334
335        #[test]
336        fn solid_harmonics_are_harmonic_and_homogeneous(
337            (l, m) in degree_order(0..31),
338            position in position(),
339        ) {
340            check_solid_harmonic(l, m, position)?;
341        }
342
343        #[test]
344        fn solid_tables_match_single_harmonics(order in 0_i32..=30, position in position()) {
345            check_solid_table(order, position)?;
346        }
347
348        #[test]
349        fn angular_functions_match_legendre_and_ignore_the_length_unit(
350            (l, m) in degree_order(0..31),
351            theta in prop_oneof![Just(0.0), Just(PI), 0.0..PI],
352            phi in -PI..PI,
353            radius in 0.1_f64..10.0,
354            exponent in -150_i32..=150,
355        ) {
356            check_angular(l, m, [theta, phi], radius, exponent)?;
357        }
358
359        /// Within `1e-12` to `0.1` of either pole, where the cosine the reference sees
360        /// fixes the polar angle worst.
361        #[test]
362        fn angular_functions_match_legendre_near_the_poles(
363            (l, m) in degree_order(0..31),
364            offset in -12.0_f64..-1.0,
365            south in any::<bool>(),
366            phi in -PI..PI,
367            radius in 0.1_f64..10.0,
368            exponent in -150_i32..=150,
369        ) {
370            let offset = 10_f64.powf(offset);
371            let theta = if south { PI - offset } else { offset };
372            check_angular(l, m, [theta, phi], radius, exponent)?;
373        }
374    }
375
376    /// A table visits every index once, and each entry equals [`solid`] bit for bit, with
377    /// and without gradients.
378    fn check_solid_table(order: i32, position: [f64; 3]) -> Result<(), TestCaseError> {
379        let table = SolidTable::new(order);
380        for with_gradient in [false, true] {
381            let mut entries = Vec::new();
382            let push = |index, p, value, gradient| entries.push((index, p, value, gradient));
383            if with_gradient {
384                table.visit::<true>(position, push);
385            } else {
386                table.visit::<false>(position, push);
387            }
388            let mut indices: Vec<usize> = entries.iter().map(|entry| entry.0).collect();
389            indices.sort_unstable();
390            prop_assert_eq!(indices, (0..table.len()).collect::<Vec<_>>());
391            for (index, p, value, gradient) in entries {
392                let m = i32::try_from(index).unwrap() - p * p - p;
393                let expected = solid::<false>(p, m, position);
394                prop_assert_eq!(value, expected.value, "{} {}", p, m);
395                if with_gradient {
396                    prop_assert_eq!(gradient, expected.gradient, "{} {}", p, m);
397                }
398            }
399        }
400        let mut second = vec![super::Solid::default(); table.len()];
401        table.visit_solid::<true>(position, |index, _, solid| second[index] = solid);
402        for p in 0..=order {
403            for m in -p..=p {
404                let expected = solid::<true>(p, m, position);
405                let actual = second[super::table_index(p, m)];
406                prop_assert_eq!(actual.value, expected.value, "{} {}", p, m);
407                prop_assert_eq!(actual.gradient, expected.gradient, "{} {}", p, m);
408                prop_assert_eq!(actual.hessian, expected.hessian, "{} {}", p, m);
409            }
410        }
411        Ok(())
412    }
413
414    /// Relative tolerance with a floor for exact zeros, since values scale down to about
415    /// `1e-40` at extreme orders.
416    fn tolerance(scale: f64) -> f64 {
417        1e-12 * scale + f64::MIN_POSITIVE
418    }
419
420    /// `r^l P_l^m e^(i m phi)` solves Laplace's equation (`tr H = 0`) and is homogeneous
421    /// of degree `l`, so Euler's identity holds for the value (`x ยท grad S = l S`) and
422    /// for each gradient component (`H x = (l - 1) grad S`). It vanishes identically
423    /// for `|m| > l`.
424    fn check_solid_harmonic(l: i32, m: i32, x: [f64; 3]) -> Result<(), TestCaseError> {
425        for m in [l + 1, -l - 1] {
426            let beyond = solid::<true>(l, m, x);
427            prop_assert_eq!(beyond.value, Complex::default(), "m = {}", m);
428            prop_assert_eq!(beyond.gradient, [Complex::default(); 3], "m = {}", m);
429            prop_assert_eq!(beyond.hessian, [[Complex::default(); 3]; 3], "m = {}", m);
430        }
431        let s = solid::<true>(l, m, x);
432        let laplacian: Complex = (0..3).map(|i| s.hessian[i][i]).sum();
433        let scale: f64 = (0..3).map(|i| s.hessian[i][i].norm()).sum();
434        prop_assert_close!(laplacian, Complex::default(), tolerance(scale));
435        let radial: Complex = (0..3).map(|i| x[i] * s.gradient[i]).sum();
436        let scale = f64::from(l) * s.value.norm()
437            + (0..3).map(|i| (x[i] * s.gradient[i]).norm()).sum::<f64>();
438        prop_assert_close!(radial, f64::from(l) * s.value, tolerance(scale));
439        for i in 0..3 {
440            let radial: Complex = (0..3).map(|j| x[j] * s.hessian[i][j]).sum();
441            let scale = f64::from(l) * s.gradient[i].norm()
442                + (0..3).map(|j| (x[j] * s.hessian[i][j]).norm()).sum::<f64>();
443            let expected = f64::from(l - 1) * s.gradient[i];
444            prop_assert_close!(radial, expected, tolerance(scale), "row {}", i);
445        }
446        Ok(())
447    }
448
449    /// `P_l^m(z/r) e^(i m phi)` at `x` and its Cartesian gradient, from the solid
450    /// harmonic on the unit sphere as translations evaluate it.
451    fn angular(l: i32, m: i32, x: [f64; 3]) -> (Complex, [Complex; 3]) {
452        let (r, unit) = direction(x).unwrap();
453        let (value, tangent) = on_sphere(l, m, unit);
454        (value, tangent.map(|t| t / r))
455    }
456
457    /// The Cartesian angular function at `r (sin theta cos phi, sin theta sin phi,
458    /// cos theta)` equals the Legendre recurrence `P_l^m(cos theta) e^(i m phi)` (in
459    /// the direction the rounded `cos theta` represents), and it is invariant under
460    /// `r -> 10^e r` with its gradient scaling as `10^-e`, far past the range where
461    /// `r^l` is representable.
462    fn check_angular(
463        l: i32,
464        m: i32,
465        [theta, phi]: [f64; 2],
466        radius: f64,
467        exponent: i32,
468    ) -> Result<(), TestCaseError> {
469        let (sin, cos) = theta.sin_cos();
470        let x = [
471            radius * sin * phi.cos(),
472            radius * sin * phi.sin(),
473            radius * cos,
474        ];
475        check_legendre(l, m, cos, phi, radius)?;
476        let (value, gradient) = angular(l, m, x);
477        // Rounding of the direction moves values by ulps times the tangential gradient.
478        let tangent: f64 = gradient.iter().map(|g| g.norm()).sum::<f64>() * radius;
479        let scale = value.norm() + tangent;
480        let s = 10_f64.powi(exponent);
481        let (scaled, scaled_gradient) = angular(l, m, x.map(|x| x * s));
482        prop_assert_close!(scaled, value, tolerance(scale));
483        // At large scales the gradient, of order 1/r, may be subnormal.
484        let subnormal = 8.0 * f64::MIN_POSITIVE * f64::EPSILON * s * radius;
485        prop_assert_close!(
486            scaled_gradient.map(|g| g * s * radius),
487            gradient.map(|g| g * radius),
488            tolerance((1.0 + f64::from(l)) * scale) + subnormal
489        );
490        Ok(())
491    }
492
493    /// The Cartesian angular function in the direction whose polar cosine is `z` equals
494    /// the Legendre recurrence `P_l^m(z) e^(i m phi)`.
495    ///
496    /// The reference sees only `z`, and `cos theta` rounds to it with an error that moves
497    /// the polar angle by up to `eps / sin theta` (`theta` itself is lost below `1e-8`,
498    /// where `z = 1`), which moves values by that times the tangential gradient, beyond
499    /// any fixed number of ulps near the poles. So the Cartesian side takes the direction
500    /// that `z` represents, whose sine `sqrt((1 - z)(1 + z))` is accurate to ulps.
501    fn check_legendre(l: i32, m: i32, z: f64, phi: f64, radius: f64) -> Result<(), TestCaseError> {
502        let sine = ((1.0 - z) * (1.0 + z)).sqrt();
503        let represented = [
504            radius * sine * phi.cos(),
505            radius * sine * phi.sin(),
506            radius * z,
507        ];
508        let (value, gradient) = angular(l, m, represented);
509        let legendre = angular_value(
510            f64::from(l),
511            f64::from(m),
512            Complex::new(z, 0.0),
513            Angular::Legendre,
514        )
515        .unwrap();
516        let expected = legendre * Complex::from_polar(1.0, f64::from(m) * phi);
517        // Rounding of the direction moves values by ulps times the tangential gradient.
518        let tangent: f64 = gradient.iter().map(|g| g.norm()).sum::<f64>() * radius;
519        prop_assert_close!(value, expected, tolerance(value.norm() + tangent));
520        Ok(())
521    }
522}