treams_core/sw/
plan.rs

1//! Couplings of complete mode blocks, computed once and shared by every pair of
2//! positions, with the radial functions and harmonics evaluated once per displacement.
3//!
4//! Upstream: `treams.sw.translate`, which evaluates one entry at a time; a plan gives
5//! the same coefficients for a whole block.
6#![allow(clippy::indexing_slicing)] // Internally constructed dense indices.
7
8use std::collections::{HashMap, HashSet};
9
10use faer::{MatMut, MatRef};
11
12use super::{
13    CartesianTranslation, Mode,
14    cartesian::{combine, harmonic},
15    coupling::{Coupling, Kinds, term_bound},
16};
17use crate::{
18    Complex, Error, Result,
19    basis::ModeLabel,
20    numerics::{self, finite, parallel::try_fold_ordered},
21    special::{
22        Radial, RadialJet, SolidTable, Wigner3jRow, direction, spherical_hankels, spherical_radial,
23        spherical_radial_sequence, tangent,
24    },
25};
26use rayon::prelude::*;
27
28/// The distinct degree-order pairs `(l, m)` of `modes`, in order of appearance.
29fn degree_orders(modes: &[Mode]) -> Vec<(i32, i32)> {
30    let mut seen = HashSet::new();
31    modes
32        .iter()
33        .map(|mode| (mode.l, mode.m))
34        .filter(|&pair| seen.insert(pair))
35        .collect()
36}
37
38/// Position of degree `p` in a radial sequence.
39fn degree(p: i32) -> usize {
40    usize::try_from(p).unwrap_or_default()
41}
42
43/// Harmonic `(p, m)` pairs of degrees `p <= lmax` in table order; `(p, m)` sits at
44/// `p * p + p + m`.
45fn harmonics(lmax: i32) -> impl Iterator<Item = (i32, i32)> {
46    (0..=lmax).flat_map(|p| (-p..=p).map(move |m| (p, m)))
47}
48
49/// One term of a block entry: `weight` times the harmonic table entry at `index`.
50#[derive(Clone, Copy, Debug, Default)]
51struct Term {
52    /// Table index `p * p + p + m` of the harmonic.
53    index: usize,
54    weight: Complex,
55}
56
57/// Geometry-independent spherical mode couplings, shared across position pairs.
58#[derive(Clone, Debug)]
59pub(crate) struct TranslationPlan {
60    /// Terms of all entries, entry `e` in `terms[starts[e]..starts[e + 1]]`.
61    terms: Vec<Term>,
62    starts: Vec<usize>,
63    /// Largest harmonic degree: the largest destination plus the largest source degree.
64    lmax: i32,
65    solids: SolidTable,
66}
67
68impl TranslationPlan {
69    /// The couplings from the `source` modes to the `destination` modes, in helicity or
70    /// parity polarization.
71    pub(crate) fn between(destination: &[Mode], source: &[Mode], helicity: bool) -> Result<Self> {
72        for mode in destination.iter().chain(source) {
73            mode.validate()?;
74        }
75        let lmax = destination.iter().map(|m| m.l).max().unwrap_or(0)
76            + source.iter().map(|m| m.l).max().unwrap_or(0);
77        // An entry has a term per admitted degree, so the terms outgrow the block. Give
78        // each entry a slot per degree, reserving every large array before evaluating
79        // any coupling, and close the slots of zero-weight terms at the end.
80        let width = destination.len();
81        let mut starts = Vec::new();
82        numerics::reserve(&mut starts, width * source.len() + 1)?;
83        starts.push(0);
84        for &from in source {
85            for &to in destination {
86                starts.push(starts[starts.len() - 1] + term_bound(to, from, helicity));
87            }
88        }
89        let mut terms = numerics::filled(starts[starts.len() - 1], Term::default())?;
90        let mut counts = numerics::filled(width * source.len(), 0_usize)?;
91        // Couplings depend on degrees and orders only, and the `(l, lambda, 0, 0)` 3j rows
92        // on degrees only: evaluate each once and share them across polarizations.
93        let (sources, destinations) = (degree_orders(source), degree_orders(destination));
94        let index = |pairs: &[(i32, i32)]| -> HashMap<_, _> {
95            pairs
96                .iter()
97                .enumerate()
98                .map(|(i, &pair)| (pair, i))
99                .collect()
100        };
101        let (source_index, destination_index) = (index(&sources), index(&destinations));
102        let zeros: HashMap<_, _> = crate::threads::install(|| {
103            sources
104                .iter()
105                .flat_map(|&(l, _)| destinations.iter().map(move |&(lambda, _)| (l, lambda)))
106                .collect::<HashSet<_>>()
107                .into_par_iter()
108                .map(|(l, lambda)| ((l, lambda), Wigner3jRow::new(l, lambda, 0, 0)))
109                .collect()
110        });
111        // Group the slots of the source modes by degree and order: each worker then
112        // evaluates one row of couplings at a time instead of the whole table.
113        let mut rows: Vec<Vec<_>> = sources.iter().map(|_| Vec::new()).collect();
114        let mut slots = terms.as_mut_slice();
115        for ((i, &from), counts) in source
116            .iter()
117            .enumerate()
118            .zip(counts.chunks_mut(width.max(1)))
119        {
120            let bounds = &starts[i * width..=(i + 1) * width];
121            let (row, rest) = std::mem::take(&mut slots).split_at_mut(bounds[width] - bounds[0]);
122            slots = rest;
123            rows[source_index[&(from.l, from.m)]].push((from, bounds, row, counts));
124        }
125        crate::threads::install(|| {
126            rows.into_par_iter()
127                .zip(&sources)
128                .try_for_each(|(rows, &(l, m))| {
129                    let couplings: Vec<_> = destinations
130                        .iter()
131                        .map(|&(lambda, mu)| {
132                            Coupling::new((l, m), (lambda, mu), &zeros[&(l, lambda)], Kinds::BOTH)
133                        })
134                        .collect();
135                    for (from, bounds, mut row, counts) in rows {
136                        for ((to, range), count) in
137                            destination.iter().zip(bounds.windows(2)).zip(counts)
138                        {
139                            let (entry, rest) =
140                                std::mem::take(&mut row).split_at_mut(range[1] - range[0]);
141                            row = rest;
142                            let coupling = &couplings[destination_index[&(to.l, to.m)]];
143                            for (p, weight) in coupling.terms(to.pol, from.pol, helicity) {
144                                let index =
145                                    usize::try_from(p * p + p + from.m - to.m).map_err(|_| {
146                                        Error::InvalidInput("invalid harmonic index".into())
147                                    })?;
148                                entry[*count] = Term { index, weight };
149                                *count += 1;
150                            }
151                        }
152                    }
153                    Ok(())
154                })
155        })?;
156        let mut end = 0;
157        for (e, &count) in counts.iter().enumerate() {
158            terms.copy_within(starts[e]..starts[e] + count, end);
159            starts[e] = end;
160            end += count;
161        }
162        starts[counts.len()] = end;
163        terms.truncate(end);
164        Ok(Self {
165            terms,
166            starts,
167            lmax,
168            solids: SolidTable::new(lmax),
169        })
170    }
171
172    /// Divide each weight by the normalization `N_pm` of its harmonic
173    /// ([`crate::special::harmonic_normalization`]). The plan then sums its weights
174    /// against tables of orthonormal-harmonic values `N_pm z_p P_p^m e^(i m phi)`, the
175    /// normalization of the lattice sums of [`crate::lattice::sum`], instead of
176    /// `z_p P_p^m e^(i m phi)`.
177    pub(crate) fn weights_for_normalized_harmonics(&mut self) {
178        let normalization: Vec<_> = harmonics(self.lmax)
179            .map(|(l, m)| crate::special::harmonic_normalization(l, m))
180            .collect();
181        for term in &mut self.terms {
182            term.weight /= normalization[term.index];
183        }
184    }
185
186    /// The terms of each block entry, column-major (source outer, destination inner).
187    fn entries(&self) -> impl Iterator<Item = &[Term]> {
188        self.starts
189            .windows(2)
190            .map(|range| &self.terms[range[0]..range[1]])
191    }
192
193    /// The block, column-major, from harmonic values `table` at index `p * p + p + m`.
194    pub(crate) fn evaluate_table(&self, table: &[Complex]) -> Vec<Complex> {
195        self.entries()
196            .map(|terms| {
197                terms
198                    .iter()
199                    .map(|term| term.weight * table[term.index])
200                    .sum()
201            })
202            .collect()
203    }
204
205    /// Add the block cotangent, weighted by the conjugate term weights, to the harmonic
206    /// cotangents `table`.
207    pub(crate) fn pullback_table(&self, cotangent: &[Complex], table: &mut [Complex]) {
208        for (terms, &g) in self.entries().zip(cotangent) {
209            for term in terms {
210                table[term.index] += term.weight.conj() * g;
211            }
212        }
213    }
214
215    /// Radial jets `z_p(z)` of degrees `p = 0..=lmax`.
216    fn radials(&self, z: Complex, radial: Radial) -> Result<Vec<RadialJet>> {
217        let lmax =
218            u32::try_from(self.lmax).map_err(|_| Error::InvalidInput("invalid lmax".into()))?;
219        spherical_radial_sequence(lmax, z, radial)
220    }
221
222    /// Harmonic table `z_p(k r) P_p^m(cos theta) e^(i m phi)` at index `p * p + p + m`.
223    /// Outgoing waves vanish at the origin, where only the regular `p = 0` term survives.
224    fn values(&self, k: Complex, position: [f64; 3], radial: Radial) -> Result<Vec<Complex>> {
225        let mut table = vec![Complex::default(); self.solids.len()];
226        let Some((r, unit)) = direction(position) else {
227            if radial == Radial::Regular {
228                table[0] = spherical_radial(0, k * 0.0, radial)?.value;
229            }
230            return Ok(table);
231        };
232        if radial == Radial::Singular {
233            // Values need neither the extra radial degree nor the first and
234            // second derivatives retained by the pullback's table.
235            let lmax =
236                u32::try_from(self.lmax).map_err(|_| Error::InvalidInput("invalid lmax".into()))?;
237            let radials = spherical_hankels(0, lmax, k * r)?;
238            if radials.iter().any(|&value| !finite(value)) {
239                return Err(Error::SpecialFunction("radial function overflow".into()));
240            }
241            self.solids.visit::<false>(unit, |index, p, value, _| {
242                table[index] = radials[degree(p)] * value;
243            });
244        } else {
245            let radials = self.radials(k * r, radial)?;
246            self.solids.visit::<false>(unit, |index, p, value, _| {
247                table[index] = radials[degree(p)].value * value;
248            });
249        }
250        Ok(table)
251    }
252
253    /// Harmonic table with the displacement and wavenumber derivatives of each entry.
254    fn table(
255        &self,
256        k: Complex,
257        position: [f64; 3],
258        radial: Radial,
259    ) -> Result<Vec<CartesianTranslation>> {
260        let geometry = direction(position);
261        let mut table = vec![CartesianTranslation::default(); self.solids.len()];
262        if geometry.is_none() && radial == Radial::Singular {
263            return Ok(table);
264        }
265        let radials = self.radials(k * geometry.map_or(0.0, |(r, _)| r), radial)?;
266        let Some((r, unit)) = geometry else {
267            for (entry, (p, m)) in table.iter_mut().zip(harmonics(self.lmax)) {
268                *entry = harmonic(p, m, k, None, radials[degree(p)]);
269            }
270            return Ok(table);
271        };
272        self.solids
273            .visit::<true>(unit, |index, p, value, gradient| {
274                let tangent = tangent(p, value, gradient, unit);
275                table[index] = combine(k, (r, unit), radials[degree(p)], value, tangent);
276            });
277        Ok(table)
278    }
279
280    /// The block of regular or singular translation coefficients at the displacement
281    /// `position`, column-major (source outer, destination inner).
282    pub(crate) fn evaluate(
283        &self,
284        k: Complex,
285        position: [f64; 3],
286        radial: Radial,
287    ) -> Result<Vec<Complex>> {
288        Ok(self.evaluate_table(&self.values(k, position, radial)?))
289    }
290
291    /// Add the translation applied to `input` to `output`, sharing each coefficient
292    /// across the input columns without storing a dense block. With `adjoint`, apply
293    /// its conjugate transpose instead. The row counts follow the plan's source and
294    /// destination modes, exchanged for the adjoint.
295    pub(crate) fn apply(
296        &self,
297        k: Complex,
298        position: [f64; 3],
299        radial: Radial,
300        input: MatRef<'_, Complex>,
301        output: MatMut<'_, Complex>,
302        adjoint: bool,
303    ) -> Result<()> {
304        let table = self.values(k, position, radial)?;
305        self.apply_table(&table, input, output, adjoint);
306        Ok(())
307    }
308
309    /// Apply the directional derivative of a translation directly to its incident
310    /// columns, without allocating a dense particle-pair block.
311    pub(crate) fn apply_pushforward(
312        &self,
313        k: Complex,
314        position: [f64; 3],
315        radial: Radial,
316        position_tangent: [f64; 3],
317        k_tangent: Complex,
318        input: MatRef<'_, Complex>,
319        output: MatMut<'_, Complex>,
320    ) -> Result<()> {
321        let table = self.tangent_table(k, position, radial, position_tangent, k_tangent)?;
322        self.apply_table(&table, input, output, false);
323        Ok(())
324    }
325
326    /// A value or tangent harmonic table, shared over all incident columns.
327    fn apply_table(
328        &self,
329        table: &[Complex],
330        input: MatRef<'_, Complex>,
331        mut output: MatMut<'_, Complex>,
332        adjoint: bool,
333    ) {
334        let (destinations, sources) = if adjoint {
335            (input.nrows(), output.nrows())
336        } else {
337            (output.nrows(), input.nrows())
338        };
339        for source in 0..sources {
340            let starts = &self.starts[source * destinations..][..=destinations];
341            for (destination, range) in starts.windows(2).enumerate() {
342                let terms = &self.terms[range[0]..range[1]];
343                if terms.is_empty() {
344                    continue;
345                }
346                let value: Complex = terms
347                    .iter()
348                    .map(|term| term.weight * table[term.index])
349                    .sum();
350                let (to, from, value) = if adjoint {
351                    (source, destination, value.conj())
352                } else {
353                    (destination, source, value)
354                };
355                for column in 0..input.ncols() {
356                    output[(to, column)] += value * input[(from, column)];
357                }
358            }
359        }
360    }
361
362    /// Cache-sharing groups in harmonic order. Whole degrees retain the most reuse;
363    /// when a block has more workers than degrees, consecutive orders expose enough
364    /// independent work to use that budget. Grouping changes no harmonic's arithmetic.
365    fn harmonic_groups(&self, workers: usize) -> Vec<(i32, std::ops::RangeInclusive<i32>)> {
366        let size = if workers > degree(self.lmax + 1) {
367            self.solids.len().div_ceil(workers)
368        } else {
369            self.solids.len()
370        };
371        let last = i32::try_from(size - 1).unwrap_or_default();
372        (0..=self.lmax)
373            .flat_map(|l| {
374                (-l..=l)
375                    .step_by(size)
376                    .map(move |first| (l, first..=(first + last).min(l)))
377            })
378            .collect()
379    }
380
381    /// The block of lattice-summed couplings at the displacement `position`
382    /// (destination minus source). The lattice sums take `source - destination`.
383    pub(crate) fn evaluate_periodic(
384        &self,
385        k: Complex,
386        position: [f64; 3],
387        lattice: &crate::lattice::BlochLattice,
388        eta: Complex,
389        workers: usize,
390    ) -> Result<Vec<Complex>> {
391        let degrees = crate::threads::install(|| {
392            self.harmonic_groups(workers)
393                .into_par_iter()
394                .map(|(l, orders)| {
395                    crate::lattice::spherical_degree(
396                        l,
397                        orders,
398                        k,
399                        lattice,
400                        position.map(|x| -x),
401                        eta,
402                    )
403                })
404                .collect::<Result<Vec<_>>>()
405        })?;
406        let table: Vec<_> = degrees
407            .into_iter()
408            .flatten()
409            .zip(harmonics(self.lmax))
410            .map(|(value, (l, m))| value / crate::special::harmonic_normalization(l, m))
411            .collect();
412        Ok(self.evaluate_table(&table))
413    }
414
415    /// Apply the angular plan to directional lattice-sum derivatives. Equal medium
416    /// wavenumbers share their Ewald jets even when their input directions differ.
417    pub(crate) fn pushforward_periodic(
418        &self,
419        ks: [Complex; 2],
420        position: [f64; 3],
421        lattice: &crate::lattice::BlochLattice,
422        eta: Complex,
423        tangents: &[crate::lattice::SumTangent; 2],
424        workers: usize,
425    ) -> Result<[Vec<Complex>; 2]> {
426        let groups = crate::threads::install(|| {
427            self.harmonic_groups(workers)
428                .into_par_iter()
429                .map(|(l, orders)| {
430                    let orders: Vec<_> = orders.collect();
431                    let mut values = [Vec::new(), Vec::new()];
432                    for pol in 0..2 {
433                        if pol == 1 && ks[0] == ks[1] {
434                            continue;
435                        }
436                        let derivatives = crate::lattice::spherical_degree_derivatives(
437                            l,
438                            &orders,
439                            ks[pol],
440                            lattice,
441                            position.map(|x| -x),
442                            eta,
443                        )?;
444                        for (&m, derivative) in orders.iter().zip(derivatives) {
445                            let normalization = crate::special::harmonic_normalization(l, m);
446                            for target in pol..if ks[0] == ks[1] { 2 } else { pol + 1 } {
447                                values[target].push(
448                                    derivative.pushforward(&tangents[target]) / normalization,
449                                );
450                            }
451                        }
452                    }
453                    Ok(values)
454                })
455                .collect::<Result<Vec<_>>>()
456        })?;
457        let mut tables = [Vec::new(), Vec::new()];
458        for group in groups {
459            for (table, values) in tables.iter_mut().zip(group) {
460                table.extend(values);
461            }
462        }
463        Ok(tables.map(|table| self.evaluate_table(&table)))
464    }
465
466    /// The lattice-sum gradients of the block cotangents of both polarizations.
467    ///
468    /// Cache-sharing groups adapt to the worker budget, but their gradients are
469    /// collected and added in harmonic order, so the thread count does not change them.
470    pub(crate) fn pullback_periodic(
471        &self,
472        ks: [Complex; 2],
473        position: [f64; 3],
474        lattice: &crate::lattice::BlochLattice,
475        eta: Complex,
476        cotangent: &[Vec<Complex>; 2],
477        workers: usize,
478    ) -> Result<[crate::lattice::SumGradient; 2]> {
479        let [first, second] = cotangent.each_ref().map(|cotangent| {
480            let mut table = vec![Complex::default(); self.solids.len()];
481            self.pullback_table(cotangent, &mut table);
482            table
483        });
484        let g: Vec<_> = first.into_iter().zip(second).map(<[_; 2]>::from).collect();
485        let groups = self
486            .harmonic_groups(workers)
487            .into_iter()
488            .map(|(l, orders)| {
489                let (first, last) = (*orders.start(), *orders.end());
490                let start = degree(l * l + l + first);
491                (l, first, &g[start..degree(l * l + l + last + 1)])
492            })
493            .collect();
494        let gradients = try_fold_ordered(
495            groups,
496            true,
497            Vec::new,
498            |mut gradients, _, (l, first, g): (i32, i32, &[[Complex; 2]])| {
499                let mut result = vec![[crate::lattice::SumGradient::default(); 2]; g.len()];
500                for pol in 0..2 {
501                    if pol == 1 && ks[0] == ks[1] {
502                        continue;
503                    }
504                    let orders: Vec<_> = (first..)
505                        .zip(g)
506                        .filter_map(|(m, g)| {
507                            (g[pol] != Complex::default()
508                                || (ks[0] == ks[1] && g[1] != Complex::default()))
509                            .then_some(m)
510                        })
511                        .collect();
512                    let derivatives = crate::lattice::spherical_degree_derivatives(
513                        l,
514                        &orders,
515                        ks[pol],
516                        lattice,
517                        position.map(|r| -r),
518                        eta,
519                    )?;
520                    for (m, d) in orders.into_iter().zip(derivatives) {
521                        let index = degree(m - first);
522                        let normalization = crate::special::harmonic_normalization(l, m);
523                        for target in pol..if ks[0] == ks[1] { 2 } else { pol + 1 } {
524                            if g[index][target] == Complex::default() {
525                                continue;
526                            }
527                            result[index][target] = d.pullback(g[index][target] / normalization);
528                            result[index][target].shift = result[index][target].shift.map(|g| -g);
529                        }
530                    }
531                }
532                gradients.extend(result);
533                Ok(gradients)
534            },
535            |mut gradients, partial| {
536                gradients.extend(partial);
537                gradients
538            },
539        )?;
540        Ok(gradients.into_iter().fold(
541            [crate::lattice::SumGradient::default(); 2],
542            |mut total, gradient| {
543                for (total, gradient) in total.iter_mut().zip(gradient) {
544                    total.add(gradient);
545                }
546                total
547            },
548        ))
549    }
550
551    /// Contract the shared harmonic derivatives before assembling the block.
552    pub(crate) fn pushforward(
553        &self,
554        k: Complex,
555        position: [f64; 3],
556        radial: Radial,
557        position_tangent: [f64; 3],
558        k_tangent: Complex,
559    ) -> Result<Vec<Complex>> {
560        Ok(self.evaluate_table(&self.tangent_table(
561            k,
562            position,
563            radial,
564            position_tangent,
565            k_tangent,
566        )?))
567    }
568
569    /// Contract derivatives before expanding harmonic coefficients into mode pairs.
570    fn tangent_table(
571        &self,
572        k: Complex,
573        position: [f64; 3],
574        radial: Radial,
575        position_tangent: [f64; 3],
576        k_tangent: Complex,
577    ) -> Result<Vec<Complex>> {
578        let table: Vec<_> = self
579            .table(k, position, radial)?
580            .into_iter()
581            .map(|entry| {
582                entry.k * k_tangent
583                    + entry
584                        .position
585                        .into_iter()
586                        .zip(position_tangent)
587                        .map(|(derivative, tangent)| derivative * tangent)
588                        .sum::<Complex>()
589            })
590            .collect();
591        Ok(table)
592    }
593
594    /// Pull a block cotangent of [`evaluate`](Self::evaluate) back to the real displacement
595    /// gradient and the complex wavenumber cotangent.
596    pub(crate) fn pullback(
597        &self,
598        k: Complex,
599        position: [f64; 3],
600        radial: Radial,
601        cotangent: &[Complex],
602    ) -> Result<([f64; 3], Complex)> {
603        let table = self.table(k, position, radial)?;
604        let mut contracted = vec![Complex::default(); table.len()];
605        self.pullback_table(cotangent, &mut contracted);
606        let mut gradient = [0.0; 3];
607        let mut gk = Complex::default();
608        for (g, item) in contracted.iter().zip(table) {
609            gk += g * item.k.conj();
610            for (value, derivative) in gradient.iter_mut().zip(item.position) {
611                *value += (g.conj() * derivative).re;
612            }
613        }
614        Ok((gradient, gk))
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    //! The plan against the per-pair `sw::cartesian_translation`, its exact symmetries,
621    //! its origin limit and the Lean harmonic table. Physical identities of translations
622    //! are in `properties/waves.rs`.
623
624    use std::f64::consts::PI;
625
626    use proptest::{prelude::*, test_runner::TestCaseError};
627
628    use super::TranslationPlan;
629    use crate::{
630        Complex,
631        linalg::{view, view_mut},
632        special::Radial,
633        sw::{self, Mode},
634        test_support::{DEFAULT_CASES, bits, patterned, prop_assert_close, radial, table},
635    };
636
637    /// Displacements on both polar half-axes and in general directions.
638    fn displacement() -> impl Strategy<Value = [f64; 3]> {
639        prop_oneof![
640            (0.3_f64..3.0).prop_map(|r| [0.0, 0.0, r]),
641            (0.3_f64..3.0).prop_map(|r| [0.0, 0.0, -r]),
642            (0.3_f64..3.0, 0.0..PI, -PI..PI).prop_map(|(r, theta, phi)| {
643                [
644                    r * theta.sin() * phi.cos(),
645                    r * theta.sin() * phi.sin(),
646                    r * theta.cos(),
647                ]
648            }),
649        ]
650    }
651
652    proptest! {
653        #![proptest_config(ProptestConfig::with_cases(DEFAULT_CASES))]
654
655        #[test]
656        fn plan_matches_direct_translation_and_its_symmetries(
657            lmax in 1_u32..=3,
658            helicity in any::<bool>(),
659            radial in radial(),
660            k in (0.5_f64..2.0, 0.0_f64..0.2).prop_map(|(re, im)| Complex::new(re, im)),
661            displacement in displacement(),
662            angle in -PI..PI,
663            seed in 0.0_f64..10.0,
664        ) {
665            let plan = Case::new(lmax, helicity, radial, k);
666            plan.check(displacement, angle, seed)?;
667        }
668
669        #[test]
670        fn regular_plan_at_the_origin_is_the_identity(
671            lmax in 1_u32..=3,
672            helicity in any::<bool>(),
673            k in (0.5_f64..2.0, 0.0_f64..0.2).prop_map(|(re, im)| Complex::new(re, im)),
674            angle in -PI..PI,
675            seed in 0.0_f64..10.0,
676        ) {
677            let plan = Case::new(lmax, helicity, Radial::Regular, k);
678            let block = plan.evaluate([0.0; 3]);
679            for (index, value) in block.iter().enumerate() {
680                let identity = if index % (plan.modes.len() + 1) == 0 { 1.0 } else { 0.0 };
681                prop_assert_close!(*value, Complex::new(identity, 0.0), 1e-13);
682            }
683            plan.check([0.0; 3], angle, seed)?;
684        }
685    }
686
687    /// A plan over every mode up to `lmax` in both directions, with its evaluation
688    /// parameters.
689    struct Case {
690        plan: TranslationPlan,
691        modes: Vec<Mode>,
692        helicity: bool,
693        radial: Radial,
694        k: Complex,
695    }
696
697    impl Case {
698        fn new(lmax: u32, helicity: bool, radial: Radial, k: Complex) -> Self {
699            let modes = sw::modes(lmax).unwrap();
700            let plan = TranslationPlan::between(&modes, &modes, helicity).unwrap();
701            Self {
702                plan,
703                modes,
704                helicity,
705                radial,
706                k,
707            }
708        }
709
710        fn evaluate(&self, displacement: [f64; 3]) -> Vec<Complex> {
711            self.plan
712                .evaluate(self.k, displacement, self.radial)
713                .unwrap()
714        }
715
716        /// Block entry `(destination, source)` of the column-major block.
717        fn entry(&self, block: &[Complex], to: Mode, from: Mode) -> Complex {
718            let index = |mode| self.modes.iter().position(|&m| m == mode).unwrap();
719            block[index(from) * self.modes.len() + index(to)]
720        }
721
722        /// Checks a block and its pullback against the per-entry `sw::cartesian_translation`,
723        /// which evaluates each coefficient and its derivatives on its own, and the exact
724        /// symmetries of translations:
725        /// - parity: `A(-d) = (-1)^(l + lambda) P A(d) P`, where `P` swaps helicities, or
726        ///   in the parity basis flips the sign of the cross-polarization couplings;
727        /// - reciprocity: `A_((lambda, mu, t), (l, m, s))(d) =
728        ///   (-1)^(m + mu) A_((l, -m, s), (lambda, -mu, t))(-d)`;
729        /// - azimuthal covariance: `A(R_z(angle) d) = e^(i (m - mu) angle) A(d)`;
730        /// - the Euler identity of the pullback, `d ยท g_d = Re(k conj(g_k))`.
731        ///
732        /// Parity flips exact signs only, so it holds bit for bit.
733        fn check(&self, d: [f64; 3], angle: f64, seed: f64) -> Result<(), TestCaseError> {
734            let (k, n) = (self.k, self.modes.len());
735            let block = self.evaluate(d);
736            // Direct actions preserve the dense block's accumulation order for
737            // each output, across both polarizations and several incident columns.
738            let input = patterned(n, 3, seed + 0.2);
739            for adjoint in [false, true] {
740                let mut actual = patterned(n, 3, seed + 0.7);
741                let mut expected = actual.clone();
742                for column in 0..input.ncols() {
743                    for source in 0..n {
744                        for destination in 0..n {
745                            let coefficient = block[source * n + destination];
746                            if adjoint {
747                                expected[(source, column)] +=
748                                    coefficient.conj() * input[(destination, column)];
749                            } else {
750                                expected[(destination, column)] +=
751                                    coefficient * input[(source, column)];
752                            }
753                        }
754                    }
755                }
756                self.plan
757                    .apply(
758                        k,
759                        d,
760                        self.radial,
761                        view(&input),
762                        view_mut(&mut actual),
763                        adjoint,
764                    )
765                    .unwrap();
766                prop_assert_eq!(actual, expected);
767            }
768            let inverted = self.evaluate(d.map(|x| -x));
769            let (sin, cos) = angle.sin_cos();
770            let rotated = self.evaluate([d[0] * cos - d[1] * sin, d[0] * sin + d[1] * cos, d[2]]);
771            let scale = block.iter().map(|v| v.norm()).fold(1e-300, f64::max);
772            let cotangent = patterned(n, n, seed);
773            let (mut position, mut wavenumber) = ([0.0; 3], Complex::default());
774            let mut derivative_scale = 0.0;
775            for (&from, g) in self.modes.iter().zip(cotangent.column_iter()) {
776                for (&to, &g) in self.modes.iter().zip(g.iter()) {
777                    let value = self.entry(&block, to, from);
778                    let direct =
779                        sw::cartesian_translation(to, from, k, d, self.helicity, self.radial)
780                            .unwrap();
781                    prop_assert_close!(
782                        value,
783                        direct.value,
784                        1e-13 * scale,
785                        "{:?} <- {:?}",
786                        to,
787                        from
788                    );
789                    for (sum, derivative) in position.iter_mut().zip(direct.position) {
790                        *sum += (g.conj() * derivative).re;
791                    }
792                    wavenumber += g * direct.k.conj();
793                    derivative_scale += g.norm()
794                        * (direct.position.iter().map(|v| v.norm()).sum::<f64>() + direct.k.norm());
795                    let sign = if (to.l + from.l) % 2 == 0 { 1.0 } else { -1.0 };
796                    let flip = |mode: Mode| Mode {
797                        pol: 1 - mode.pol,
798                        ..mode
799                    };
800                    let parity = if self.helicity {
801                        sign * self.entry(&block, flip(to), flip(from))
802                    } else if to.pol == from.pol {
803                        sign * value
804                    } else {
805                        -sign * value
806                    };
807                    prop_assert_eq!(self.entry(&inverted, to, from), parity);
808                    let reflect = |mode: Mode| Mode { m: -mode.m, ..mode };
809                    let sign = if (to.m + from.m) % 2 == 0 { 1.0 } else { -1.0 };
810                    let reciprocal = sign * self.entry(&inverted, reflect(from), reflect(to));
811                    prop_assert_close!(value, reciprocal, 1e-12 * scale);
812                    let phase = Complex::from_polar(1.0, f64::from(from.m - to.m) * angle);
813                    prop_assert_close!(
814                        self.entry(&rotated, to, from),
815                        phase * value,
816                        1e-12 * scale
817                    );
818                }
819            }
820            let (gradient, gk) = self
821                .plan
822                .pullback(k, d, self.radial, cotangent.as_slice())
823                .unwrap();
824            let tolerance = 1e-13 * derivative_scale;
825            prop_assert_close!(gradient, position, tolerance);
826            prop_assert_close!(gk, wavenumber, tolerance);
827            let spatial: f64 = gradient.iter().zip(d).map(|(g, x)| g * x).sum();
828            let euler_scale =
829                derivative_scale * (1.0 + k.norm() + d.iter().map(|x| x.abs()).sum::<f64>());
830            prop_assert_close!(spatial, (k * gk.conj()).re, 1e-13 * euler_scale);
831            Ok(())
832        }
833    }
834
835    #[test]
836    fn regular_translation_is_continuous_at_the_origin() {
837        // Rounding residues between coincident positions, such as `0.1 + 0.2 - 0.3`, and
838        // subnormal displacements give the origin block and its derivatives: they differ
839        // from them by `O(|k| r)`, far below rounding. `r^p` underflows from lmax 10 at
840        // `r = 5.6e-17` and `1 / r` overflows at subnormal `r`, so the plan must not divide
841        // by either (see `special::direction`).
842        let modes = sw::modes(10).unwrap();
843        let plan = TranslationPlan::between(&modes, &modes, true).unwrap();
844        let k = Complex::new(1.3, 0.1);
845        let origin = plan.evaluate(k, [0.0; 3], Radial::Regular).unwrap();
846        for (index, value) in origin.iter().enumerate() {
847            let identity = if index % (modes.len() + 1) == 0 {
848                1.0
849            } else {
850                0.0
851            };
852            assert!((value - identity).norm() < 1e-13, "entry {index} = {value}");
853        }
854        let cotangent = patterned(modes.len(), modes.len(), 0.3);
855        let pullback = |displacement| {
856            plan.pullback(k, displacement, Radial::Regular, cotangent.as_slice())
857                .unwrap()
858        };
859        let (position_limit, k_limit) = pullback([0.0; 3]);
860        let pullback_scale = cotangent.iter().map(|g| g.norm()).sum::<f64>() * k.norm();
861        let few = sw::modes(2).unwrap();
862        let direct = |displacement| {
863            few.iter()
864                .flat_map(|&from| few.iter().map(move |&to| (to, from)))
865                .map(|(to, from)| {
866                    sw::cartesian_translation(to, from, k, displacement, true, Radial::Regular)
867                        .unwrap()
868                })
869                .collect::<Vec<_>>()
870        };
871        let direct_limit = direct([0.0; 3]);
872        let displacements = [
873            [0.1 + 0.2 - 0.3, 0.0, 0.0],
874            [1e-100, -2e-100, 1e-100],
875            [0.0, 0.0, 1e-300],
876            [1e-310, 0.0, 0.0],
877            [0.0, -5e-324, 5e-324],
878        ];
879        for displacement in displacements {
880            let block = plan.evaluate(k, displacement, Radial::Regular).unwrap();
881            for (index, (value, limit)) in block.iter().zip(&origin).enumerate() {
882                assert!(
883                    (value - limit).norm() < 1e-15,
884                    "{displacement:?}: entry {index} = {value}"
885                );
886            }
887            let (position, wavenumber) = pullback(displacement);
888            for (derivative, limit) in position.into_iter().zip(position_limit) {
889                assert!(
890                    (derivative - limit).abs() < 1e-14 * pullback_scale,
891                    "{displacement:?}: position {derivative} != {limit}"
892                );
893            }
894            assert!(
895                (wavenumber - k_limit).norm() < 1e-14 * pullback_scale,
896                "{displacement:?}: wavenumber {wavenumber} != {k_limit}"
897            );
898            for (index, (term, limit)) in direct(displacement).iter().zip(&direct_limit).enumerate()
899            {
900                let distance = (term.value - limit.value).norm()
901                    + (term.k - limit.k).norm()
902                    + (0..3)
903                        .map(|axis| (term.position[axis] - limit.position[axis]).norm())
904                        .sum::<f64>();
905                assert!(
906                    distance < 1e-15,
907                    "{displacement:?}: pair {index}: {term:?} != {limit:?}"
908                );
909            }
910        }
911    }
912
913    /// Value-only translations remain finite when unused second radial derivatives
914    /// overflow; compare to the independently assembled value-only polar entries.
915    #[test]
916    fn singular_values_do_not_evaluate_unused_radial_derivatives() {
917        let case = Case::new(1, true, Radial::Singular, Complex::from(1.0));
918        let r = 1e-64;
919        let block = case.evaluate([0.0, 0.0, r]);
920        assert!(block.iter().all(|&value| crate::numerics::finite(value)));
921        let scale = block.iter().map(|value| value.norm()).fold(0.0, f64::max);
922        for &from in &case.modes {
923            for &to in &case.modes {
924                let polar = sw::PolarTranslation::new(to, from, true, Radial::Singular).unwrap();
925                let expected = polar
926                    .value([Complex::from(r), Complex::default(), Complex::default()])
927                    .unwrap();
928                let value = case.entry(&block, to, from);
929                assert!((value - expected).norm() < 1e-13 * scale);
930            }
931        }
932        assert!(
933            case.plan
934                .evaluate(case.k, [0.0, 0.0, 1e-120], Radial::Singular)
935                .is_err()
936        );
937    }
938
939    #[test]
940    fn harmonics_match_lean_model() {
941        // `just formal` keeps this file equal to `Treams.Harmonics.table`.
942        let cases = table::<i32, i32>(include_str!(concat!(
943            env!("CARGO_MANIFEST_DIR"),
944            "/../../formal/golden/harmonics.txt"
945        )));
946        let bounds: Vec<_> = cases.iter().map(|(key, _)| key.clone()).collect();
947        assert_eq!(bounds, (0..=8).map(|lmax| vec![lmax]).collect::<Vec<_>>());
948        for (key, pairs) in cases {
949            let lmax = key[0];
950            assert_eq!(pairs.len() % 2, 0, "lmax {lmax}: unpaired field");
951            let expected: Vec<(i32, i32)> = pairs
952                .as_chunks::<2>()
953                .0
954                .iter()
955                .map(|&pair| pair.into())
956                .collect();
957            let actual: Vec<_> = super::harmonics(lmax).collect();
958            assert_eq!(actual, expected, "lmax {lmax}");
959            for (index, (p, m)) in actual.into_iter().enumerate() {
960                assert_eq!(usize::try_from(p * p + p + m).unwrap(), index);
961            }
962        }
963    }
964
965    /// A singleton splits its harmonic groups on large pools; several centres expose
966    /// independent blocks instead. Both forward and gradient keep identical bits.
967    #[test]
968    fn periodic_pullback_does_not_depend_on_the_thread_count() {
969        for centres in [1, 3] {
970            let basis = |offset| {
971                let positions: Vec<_> = (0..centres)
972                    .map(|i| [0.2 * f64::from(i) + offset, -0.1 * f64::from(i), 0.0])
973                    .collect();
974                sw::Basis {
975                    modes: (0..positions.len())
976                        .flat_map(|i| sw::modes(4).unwrap().into_iter().map(move |mode| (i, mode)))
977                        .collect(),
978                    positions,
979                }
980            };
981            let (destination, source) = (basis(0.0), basis(0.13));
982            let lattice =
983                crate::lattice::BlochLattice::new(&[vec![1.0, 0.0], vec![0.0, 1.0]], &[0.3, 0.1])
984                    .unwrap();
985            let ks = [Complex::new(1.1, 0.01), Complex::new(1.3, 0.02)];
986            let g = patterned(destination.modes.len(), source.modes.len(), 0.6);
987            let mut reference = None;
988            for workers in [1, 2, 4, 16, 32] {
989                let pool = rayon::ThreadPoolBuilder::new()
990                    .num_threads(workers)
991                    .build()
992                    .unwrap();
993                let actual = pool.install(|| {
994                    let (value, residual) = sw::lattice_expansion(
995                        destination.clone(),
996                        source.clone(),
997                        ks,
998                        true,
999                        lattice.clone(),
1000                        Complex::default(),
1001                    )
1002                    .unwrap();
1003                    let g = residual.pullback(&g).unwrap();
1004                    let expansion = &g.expansion;
1005                    bits(&[
1006                        &value,
1007                        &expansion.destination,
1008                        &expansion.source,
1009                        &expansion.ks,
1010                        &g.kpar,
1011                        &g.vectors,
1012                    ])
1013                });
1014                if let Some(reference) = &reference {
1015                    assert_eq!(&actual, reference, "{centres} centres, {workers} threads");
1016                } else {
1017                    reference = Some(actual);
1018                }
1019            }
1020        }
1021    }
1022}