treams_core/sw/
polar.rs

1//! Spherical translation coefficients of mode pairs in polar coordinates `(kr, theta, phi)`,
2//! broadcast over arrays, with analytic pullbacks.
3//!
4//! Upstream: `treams.sw.translate` and `treams.special.tl_vsw_A`, `tl_vsw_B`, `tl_vsw_rA`
5//! and `tl_vsw_rB`.
6#![allow(clippy::indexing_slicing)] // Fixed argument arrays, degree rows and deduplicated plans.
7
8use crate::{
9    Complex, Error, Result,
10    basis::ModeLabel,
11    numerics::{Jet, broadcast, finite, parallel::Parallel},
12    special::{self, Bessel, Radial, SERIES_RADIUS, polar_trig, radial_jet},
13    sw::{self, Mode},
14};
15
16/// Fixed spherical mode-pair couplings shared across displacements and pullbacks.
17///
18/// Upstream: `treams.sw.translate` of one mode pair, which combines
19/// `treams.special.tl_vsw_A` and `tl_vsw_B` (`tl_vsw_rA` and `tl_vsw_rB` for regular
20/// waves).
21#[derive(Debug)]
22pub struct PolarTranslation {
23    /// Degree and weight of each term.
24    terms: Vec<(i32, Complex)>,
25    /// Order difference `source.m - destination.m`, shared by all terms.
26    order: i32,
27    /// Smallest and largest term degree.
28    degrees: [i32; 2],
29    radial: Radial,
30}
31
32impl PolarTranslation {
33    /// Prepare one mode pair in helicity or parity polarization.
34    pub fn new(destination: Mode, source: Mode, helicity: bool, radial: Radial) -> Result<Self> {
35        destination.validate()?;
36        source.validate()?;
37        let order = source.m - destination.m;
38        let terms: Vec<_> = sw::terms(destination, source, helicity)
39            .into_iter()
40            .map(|(degree, term_order, weight)| {
41                debug_assert_eq!(term_order, order);
42                (degree, weight)
43            })
44            .collect();
45        let degrees = terms
46            .iter()
47            .fold([i32::MAX, 0], |[low, high], &(degree, _)| {
48                [low.min(degree), high.max(degree)]
49            });
50        Ok(Self {
51            terms,
52            order,
53            degrees,
54            radial,
55        })
56    }
57
58    /// Spherical radial jets `z_p(kr)` of the term degrees, indexed by `p` minus the
59    /// smallest degree, or `None` when each term evaluates its own.
60    ///
61    /// Outgoing functions come from one AMOS sequence over the degree range. Regular
62    /// derivatives outside the series region evaluate each single value once and
63    /// share `j_p` between the value of degree `p` and the derivative of `p - 1`,
64    /// so they equal those of [`crate::special::spherical_radial`] bitwise.
65    fn radial_row<const N: usize>(&self, z: Jet<N>) -> Result<Option<Vec<Jet<N>>>> {
66        let [first, last] = self.degrees;
67        let last = last + i32::from(N > 0);
68        // Term degrees lie in `first..=last`.
69        let offset = |p: i32| usize::try_from(p - first).unwrap_or_default();
70        let values = match self.radial {
71            Radial::Singular => {
72                if z.value.norm_sqr() == 0.0 {
73                    return Err(Error::InvalidInput(
74                        "outgoing spherical waves are singular at zero".into(),
75                    ));
76                }
77                let degree = |p: i32| u32::try_from(p).unwrap_or_default();
78                special::spherical_hankels(degree(first), degree(last), z.value)?
79            }
80            Radial::Regular if N > 0 && z.value.norm() >= SERIES_RADIUS => {
81                let mut values = vec![Complex::default(); offset(last) + 1];
82                for &(degree, _) in &self.terms {
83                    for p in [degree, degree + 1] {
84                        let value = &mut values[offset(p)];
85                        if *value == Complex::default() {
86                            *value = special::bessel(f64::from(p), z.value, Bessel::J, true, 0)?;
87                        }
88                    }
89                }
90                values
91            }
92            Radial::Regular => return Ok(None),
93        };
94        if N == 0 {
95            return Ok(Some(values.into_iter().map(Jet::constant).collect()));
96        }
97        // The first derivative `z_p' = p z_p / z - z_{p+1}` of `special::spherical_radial`.
98        Ok(Some(
99            (first..)
100                .zip(values.windows(2))
101                .map(|(p, pair)| z.chain(pair[0], f64::from(p) * pair[0] / z.value - pair[1]))
102                .collect(),
103        ))
104    }
105
106    /// The coefficient `sum_p weight_p z_p(kr) P_p^order(cos theta) e^(i order phi)` at
107    /// `args = [kr, theta, phi]`, with the derivatives with respect to the `N` leading
108    /// arguments.
109    fn evaluate<const N: usize>(&self, args: [Complex; 3]) -> Result<Jet<N>> {
110        self.evaluate_jets(std::array::from_fn(|i| Jet::<N>::variable(args[i], i)))
111    }
112
113    /// One numerical kernel for value-only, coordinate and directional jets.
114    fn evaluate_jets<const N: usize>(&self, args: [Jet<N>; 3]) -> Result<Jet<N>> {
115        if args.iter().any(|&v| !v.finite()) {
116            return Err(Error::InvalidInput(
117                "translation arguments must be finite".into(),
118            ));
119        }
120        if self.terms.is_empty() {
121            return Ok(Jet::default());
122        }
123        let [kr, theta, phi] = args;
124        let [cosine, sine] = polar_trig(theta);
125        let order = self.order;
126        let phase = sine.powi(order.abs()) * (Complex::i() * f64::from(order) * phi).exp();
127        let [low, high] = self.degrees;
128        let mut legendre =
129            Vec::with_capacity(usize::try_from(high - order.abs() + 1).unwrap_or_default());
130        special::legendre_factors(high, order, cosine, |p| legendre.push(p));
131        let row = self.radial_row(kr)?;
132        let mut result = Jet::default();
133        for &(degree, weight) in &self.terms {
134            let radial = match &row {
135                Some(row) => row[usize::try_from(degree - low).unwrap_or_default()],
136                None => radial_jet(degree, kr, self.radial, true)?,
137            };
138            let factor = legendre[usize::try_from(degree - order.abs()).unwrap_or_default()];
139            result += weight * radial * (phase * factor);
140        }
141        if !result.finite() {
142            return Err(Error::NonFinite(
143                "non-finite translation or derivative".into(),
144            ));
145        }
146        Ok(result)
147    }
148
149    /// Whether the polarized coefficient of `radial` waves at the radial argument `kr` is
150    /// the self term, which is zero: singular waves with `|kr| < 1e-16`, which includes
151    /// coinciding expansion centres. The `tl_vsw_*` coefficients have no such rule.
152    ///
153    /// Upstream: `treams.sw.translate`, which returns zero there.
154    /// Differences: treams returns this zero for any mode labels, such as `l = 0` or
155    /// polarization 2; the `sw.translate` ufunc checks the labels first and rejects those.
156    #[inline]
157    #[must_use]
158    pub fn is_self_term(radial: Radial, kr: Complex) -> bool {
159        radial == Radial::Singular && kr.norm() < 1e-16
160    }
161
162    /// Evaluate at `(kr, theta, phi)`; all three arguments may be complex.
163    pub fn value(&self, args: [Complex; 3]) -> Result<Complex> {
164        Ok(self.evaluate::<0>(args)?.value)
165    }
166
167    /// The gradients of the three arguments from a complex scalar cotangent.
168    pub fn pullback(&self, args: [Complex; 3], cotangent: Complex) -> Result<[Complex; 3]> {
169        if !finite(cotangent) {
170            return Err(Error::InvalidInput(
171                "translation cotangent must be finite".into(),
172            ));
173        }
174        if cotangent == Complex::default() {
175            self.value(args)?;
176            return Ok([Complex::default(); 3]);
177        }
178        Ok(self
179            .evaluate::<3>(args)?
180            .derivative
181            .map(|d| d.conj() * cotangent))
182    }
183}
184
185/// Spherical translation arrays evaluate in parallel from this many elements.
186const PARALLEL: Parallel = Parallel::AtLeast(1024);
187/// Coupling plans, which cost several evaluations each, build in parallel from this
188/// many distinct mode pairs.
189const PLANS: Parallel = Parallel::AtLeast(64);
190
191/// What [`polar_translation_array`] saves for its pullback: the arguments and one
192/// coupling plan per distinct mode pair.
193///
194/// The pullback keeps every output in index order and adds the cotangents of scalar
195/// arguments in that order, so its result does not depend on the thread count.
196#[derive(Debug)]
197pub struct PolarTranslationResidual {
198    plans: Vec<PolarTranslation>,
199    /// The plan of each output, or of all outputs.
200    indices: Vec<usize>,
201    arguments: [Vec<Complex>; 3],
202    size: usize,
203}
204
205impl PolarTranslationResidual {
206    fn element(&self, i: usize) -> (&PolarTranslation, [Complex; 3]) {
207        (
208            &self.plans[broadcast::element(&self.indices, i)],
209            self.arguments.each_ref().map(|a| broadcast::element(a, i)),
210        )
211    }
212
213    /// Directional derivative of the three broadcast polar arguments.
214    pub fn pushforward(&self, tangents: [&[Complex]; 3]) -> Result<Vec<Complex>> {
215        broadcast::pushforward(
216            tangents,
217            self.size,
218            "translation tangents must be finite and match output",
219            PARALLEL,
220            |i, tangent| {
221                let (plan, args) = self.element(i);
222                let jets = std::array::from_fn(|a| Jet {
223                    value: args[a],
224                    derivative: [tangent[a]],
225                });
226                Ok(plan.evaluate_jets(jets)?.derivative[0])
227            },
228        )
229    }
230
231    /// The argument gradients from one cotangent per translation; an argument given as
232    /// one value for all outputs gets the sum of its gradients.
233    pub fn pullback(&self, cotangent: &[Complex]) -> Result<[Vec<Complex>; 3]> {
234        broadcast::pullback(
235            cotangent,
236            self.size,
237            "translation cotangent must be finite and match output",
238            self.arguments.each_ref().map(Vec::len),
239            PARALLEL,
240            |i, &g| {
241                let (plan, args) = self.element(i);
242                plan.pullback(args, g)
243            },
244        )
245    }
246}
247
248/// Broadcast polar translations, preserving scalar arguments and fixed mode pairs.
249///
250/// Upstream: `treams.sw.translate`, `treams.special.tl_vsw_A` and `tl_vsw_B`.
251pub fn polar_translation_array(
252    modes: Vec<[Mode; 2]>,
253    arguments: [Vec<Complex>; 3],
254    helicity: bool,
255    radial: Radial,
256) -> Result<(Vec<Complex>, PolarTranslationResidual)> {
257    let residual = PolarTranslationResidual::new(modes, arguments, helicity, radial)?;
258    let values = broadcast::map(residual.size, PARALLEL, |i| {
259        let (plan, args) = residual.element(i);
260        plan.value(args)
261    })?;
262    Ok((values, residual))
263}
264
265impl PolarTranslationResidual {
266    /// Retain broadcast inputs and prepare static labels without evaluating translations.
267    pub fn new(
268        modes: Vec<[Mode; 2]>,
269        arguments: [Vec<Complex>; 3],
270        helicity: bool,
271        radial: Radial,
272    ) -> Result<Self> {
273        let [a, b, c] = arguments.each_ref().map(Vec::len);
274        let size = broadcast::size(
275            &[modes.len(), a, b, c],
276            "translation arrays must have equal lengths or scalar inputs",
277        )?;
278        let mut lookup = std::collections::HashMap::new();
279        let mut distinct = Vec::new();
280        let indices = modes
281            .into_iter()
282            .map(|pair| {
283                *lookup.entry(pair).or_insert_with(|| {
284                    distinct.push(pair);
285                    distinct.len() - 1
286                })
287            })
288            .collect();
289        let plans = broadcast::map(distinct.len(), PLANS, |i| {
290            let [to, from] = distinct[i];
291            PolarTranslation::new(to, from, helicity, radial)
292        })?;
293        Ok(Self {
294            plans,
295            indices,
296            arguments,
297            size,
298        })
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    //! Polar coefficients and their pullbacks against Cartesian translations. Identities
305    //! of translations are in `properties/waves.rs`.
306
307    use super::{Complex, Mode, PolarTranslation, Radial, sw};
308    use crate::{
309        special::{self, on_sphere},
310        test_support::{DEFAULT_CASES, degree_order, log_uniform, prop_assert_close, radial},
311    };
312    use proptest::{prelude::*, test_runner::TestCaseError};
313    use std::f64::consts::PI;
314
315    /// Shrunk failures of the properties below, written out so that they do not depend
316    /// on the strategy shapes that the seeds in `proptest-regressions/sw/polar.txt` replay.
317    #[test]
318    fn recorded_polar_translation_cases() -> Result<(), TestCaseError> {
319        // `cartesian_translation_and_all_polar_adjoints`: singular waves at
320        // `kr = 1.3 + 0.2i`, in the helicity and in the parity basis.
321        let kr = Complex::new(1.3, 0.2);
322        // Release-readiness regressions: the former real-projected finite difference
323        // cancelled even though the full complex derivatives remained well conditioned.
324        check_spherical(
325            Mode { l: 4, m: 3, pol: 1 },
326            Mode { l: 4, m: 3, pol: 1 },
327            kr,
328            [2.788_667_920_988_279_6, 0.0],
329            true,
330            Radial::Singular,
331        )?;
332        check_spherical(
333            Mode { l: 4, m: 2, pol: 1 },
334            Mode { l: 4, m: 2, pol: 1 },
335            kr,
336            [0.1, 0.0],
337            false,
338            Radial::Singular,
339        )?;
340        check_spherical(
341            Mode { l: 5, m: 3, pol: 1 },
342            Mode {
343                l: 3,
344                m: -2,
345                pol: 1,
346            },
347            kr,
348            [2.255_087_340_294_624_5, -0.095_390_013_154_433_45],
349            true,
350            Radial::Singular,
351        )?;
352        check_spherical(
353            Mode {
354                l: 3,
355                m: -2,
356                pol: 1,
357            },
358            Mode { l: 5, m: 3, pol: 1 },
359            kr,
360            [0.813_134_622_536_094_8, 0.0],
361            false,
362            Radial::Singular,
363        )?;
364        // `spherical_translation_across_argument_decades_and_poles`: a regular wave on
365        // the south pole, where the order difference 2 makes the coefficient vanish.
366        check_spherical(
367            Mode { l: 2, m: 2, pol: 0 },
368            Mode { l: 1, m: 0, pol: 0 },
369            Complex::new(1.0, 0.0),
370            [PI, 0.0],
371            false,
372            Radial::Regular,
373        )
374    }
375
376    proptest! {
377        #![proptest_config(ProptestConfig::with_cases(DEFAULT_CASES))]
378        #[test]
379        fn cartesian_translation_and_all_polar_adjoints(
380            (l, m) in degree_order(1..6), (lambda, mu) in degree_order(1..6), pol in 0_u8..2,
381            theta in 0.1_f64..3.0, phi in -3.0_f64..3.0, helicity in any::<bool>(),
382            radial in radial(),
383        ) {
384            let to = Mode { l: lambda, m: mu, pol: 1 };
385            let from = Mode { l, m, pol };
386            check_spherical(to, from, Complex::new(1.3, 0.2), [theta, phi], helicity, radial)?;
387        }
388    }
389
390    proptest! {
391        // Twice the default: the draw spans 3.5 decades of |kr|, both poles and degrees
392        // up to 8.
393        #![proptest_config(ProptestConfig::with_cases(2 * DEFAULT_CASES))]
394        #[test]
395        fn spherical_translation_across_argument_decades_and_poles(
396            (l, m) in degree_order(1..9), (lambda, mu) in degree_order(1..9),
397            pol in 0_u8..2, to_pol in 0_u8..2, magnitude in log_uniform(-2.0..1.5),
398            angle in -0.3_f64..0.3, theta in prop_oneof![Just(0.0), Just(PI), 0.0..PI],
399            phi in -3.0_f64..3.0, helicity in any::<bool>(), radial in radial(),
400        ) {
401            let to = Mode { l: lambda, m: mu, pol: to_pol };
402            let from = Mode { l, m, pol };
403            let kr = Complex::from_polar(magnitude, angle);
404            check_spherical(to, from, kr, [theta, phi], helicity, radial)?;
405        }
406    }
407
408    /// Polar spherical coefficients are Cartesian translations of the unit displacement
409    /// `d(theta, phi)`, and their derivatives follow by the chain rule:
410    /// `d/d(kr) T = d/dk T`, `d/dtheta T = grad T . d_theta d` and
411    /// `d/dphi T = grad T . d_phi d`, which is also exactly `i (m_from - m_to) T`.
412    /// On the poles the polar derivative is the one-sided limit from inside `(0, pi)`.
413    ///
414    /// The terms of a coefficient can exceed their sum by orders of magnitude, for
415    /// example when `kr` has a large imaginary part, so rounding is bounded relative
416    /// to the summed term magnitudes of [`term_scale`]; the azimuthal identity also
417    /// scales with the order difference.
418    fn check_spherical(
419        to: Mode,
420        from: Mode,
421        kr: Complex,
422        [theta, phi]: [f64; 2],
423        helicity: bool,
424        radial: Radial,
425    ) -> Result<(), TestCaseError> {
426        let args = [kr, theta.into(), phi.into()];
427        let plan = PolarTranslation::new(to, from, helicity, radial).unwrap();
428        // Like `polar_trig`, read exact multiples of the PI constant as the poles.
429        let (sine, cosine) = if theta % PI == 0.0 {
430            (0.0, theta.cos())
431        } else {
432            theta.sin_cos()
433        };
434        let position = [sine * phi.cos(), sine * phi.sin(), cosine];
435        let reference =
436            sw::cartesian_translation(to, from, kr, position, helicity, radial).unwrap();
437        let tangents = [
438            [cosine * phi.cos(), cosine * phi.sin(), -sine],
439            [-sine * phi.sin(), sine * phi.cos(), 0.0],
440        ];
441        let [d_theta, d_phi] = tangents.map(|t| {
442            (0..3)
443                .map(|axis| reference.position[axis] * t[axis])
444                .sum::<Complex>()
445        });
446        let scale = term_scale(&plan, kr, position);
447        let tolerance = 1e-13 * scale + f64::MIN_POSITIVE;
448        let value = plan.value(args).unwrap();
449        prop_assert_close!(value, reference.value, tolerance);
450        let g = Complex::new(0.3, -0.2);
451        let derivative = plan.pullback(args, g).unwrap().map(|d| d.conj() / g.conj());
452        prop_assert_close!(derivative[0], reference.k, tolerance, "kr");
453        prop_assert_close!(derivative[1], d_theta, tolerance, "theta");
454        prop_assert_close!(derivative[2], d_phi, tolerance, "phi");
455        let order = f64::from(from.m - to.m);
456        prop_assert_close!(
457            derivative[2],
458            Complex::i() * order * value,
459            1e-14 * (1.0 + order.abs()) * scale + f64::MIN_POSITIVE,
460            "azimuthal"
461        );
462        Ok(())
463    }
464
465    /// The rounding scale `Σ_p |w_p| (|z_p| + (1 + |kr|) |z_p'|) (|Y_p| + Σ_a |∂_a Y_p|)`
466    /// of the terms `w_p z_p(kr) Y_p(d)` of `plan` at the unit displacement `d`, from the
467    /// independent `special::spherical_radial` and `on_sphere` evaluations. It bounds
468    /// each term's value, `kr` derivative and Cartesian gradient; the gradient also
469    /// bounds the rounding of `Y_p` near its roots, where `|Y_p|` alone underestimates it.
470    fn term_scale(plan: &PolarTranslation, kr: Complex, position: [f64; 3]) -> f64 {
471        plan.terms
472            .iter()
473            .map(|&(degree, weight)| {
474                let z = special::spherical_radial(degree.unsigned_abs(), kr, plan.radial).unwrap();
475                let (harmonic, gradient) = on_sphere(degree, plan.order, position);
476                let harmonic = harmonic.norm() + gradient.iter().map(|g| g.norm()).sum::<f64>();
477                weight.norm() * (z.value.norm() + (1.0 + kr.norm()) * z.first.norm()) * harmonic
478            })
479            .sum()
480    }
481}