treams_core/sw/
expansion.rs

1//! Spherical expansion matrices between two bases and over a lattice, with analytic
2//! pullbacks. Each pair of a destination and a source position forms one block, and
3//! blocks with the same modes share one [`TranslationPlan`].
4//!
5//! Upstream: the `treams.Expand` and `treams.ExpandLattice` operators between spherical
6//! bases.
7#![allow(clippy::indexing_slicing)] // Basis and matrix dimensions validated on entry.
8
9use super::{Basis, plan::TranslationPlan};
10use crate::{
11    Complex, Error, Result,
12    basis::{ExpansionGradient, LatticeExpansionGradient, validate_wavenumbers},
13    numerics::{self, finite},
14    special::Radial,
15};
16use nalgebra::DMatrix;
17use rayon::prelude::*;
18use std::{collections::HashMap, sync::Arc};
19
20#[path = "expansion_lattice_state.rs"]
21mod lattice_state;
22
23/// The modes of one destination position against those of one source position.
24#[derive(Clone, Debug)]
25struct Block {
26    destination: usize,
27    source: usize,
28    /// Destination minus source position.
29    displacement: [f64; 3],
30    rows: Vec<usize>,
31    cols: Vec<usize>,
32    polarizations: Vec<u8>,
33    plan: Arc<TranslationPlan>,
34}
35
36impl Block {
37    fn len(&self) -> usize {
38        self.rows.len() * self.cols.len()
39    }
40
41    /// A shared polarization uses one buffer; distinct polarizations use two.
42    fn values(
43        shared: bool,
44        evaluate: impl Fn(usize) -> Result<Option<Vec<Complex>>>,
45    ) -> Result<[Option<Vec<Complex>>; 2]> {
46        Ok([evaluate(0)?, if shared { None } else { evaluate(1)? }])
47    }
48
49    /// The block of `matrix`, column-major, with the columns of other polarizations
50    /// than `pol` zeroed when `pol` is given.
51    fn gather(&self, matrix: &DMatrix<Complex>, pol: Option<usize>) -> Vec<Complex> {
52        let mut values = vec![Complex::default(); self.len()];
53        for (column, (&col, &p)) in values
54            .chunks_exact_mut(self.rows.len())
55            .zip(self.cols.iter().zip(&self.polarizations))
56        {
57            if pol.is_none_or(|pol| usize::from(p) == pol) {
58                for (value, &row) in column.iter_mut().zip(&self.rows) {
59                    *value = matrix[(row, col)];
60                }
61            }
62        }
63        values
64    }
65
66    /// Write column-major block values into `matrix`, only in the columns of
67    /// polarization `pol` when it is given.
68    fn scatter(&self, values: &[Complex], pol: Option<usize>, matrix: &mut DMatrix<Complex>) {
69        for (column, (&col, &p)) in values
70            .chunks_exact(self.rows.len())
71            .zip(self.cols.iter().zip(&self.polarizations))
72        {
73            if pol.is_none_or(|pol| usize::from(p) == pol) {
74                for (&value, &row) in column.iter().zip(&self.rows) {
75                    matrix[(row, col)] = value;
76                }
77            }
78        }
79    }
80}
81
82/// Validate both bases and the medium of an expansion.
83fn validate_expansion(
84    destination: &Basis,
85    source: &Basis,
86    ks: [Complex; 2],
87    helicity: bool,
88) -> Result<()> {
89    destination.validate()?;
90    source.validate()?;
91    // Spherical expansions accept k = 0, where the regular expansion is the identity.
92    validate_wavenumbers(ks, helicity, true)
93}
94
95/// What [`expansion`] saves for its pullback: the bases, the wavenumbers, the radial
96/// kind and every block with its translation plan. The pullback recomputes the
97/// derivative tables per block.
98///
99/// The pullback evaluates blocks in parallel and adds their gradients in block order, so
100/// its result does not depend on the thread count.
101#[derive(Clone, Debug)]
102pub struct ExpansionResidual {
103    destination: Basis,
104    source: Basis,
105    ks: [Complex; 2],
106    radial: Radial,
107    helicity: bool,
108    blocks: Vec<Block>,
109}
110
111/// Construct a regular or singular spherical expansion matrix, which maps source
112/// coefficients to destination coefficients.
113///
114/// Upstream: the `treams.Expand` operator (`treams.operators.expand`) between spherical
115/// bases.
116pub fn expansion(
117    destination: Basis,
118    source: Basis,
119    ks: [Complex; 2],
120    helicity: bool,
121    radial: Radial,
122) -> Result<(DMatrix<Complex>, ExpansionResidual)> {
123    let residual = ExpansionResidual::prepare(destination, source, ks, helicity, radial)?;
124    let value = assemble(residual.shape(), &residual.blocks, BATCH, |block, _| {
125        Block::values(ks[0] == ks[1], |pol| {
126            expansion_block(&block.plan, ks[pol], block.displacement, radial)
127        })
128    })?;
129    Ok((value, residual))
130}
131
132impl ExpansionResidual {
133    fn prepare(
134        destination: Basis,
135        source: Basis,
136        ks: [Complex; 2],
137        helicity: bool,
138        radial: Radial,
139    ) -> Result<Self> {
140        validate_expansion(&destination, &source, ks, helicity)?;
141        let blocks = blocks(&destination, &source, helicity, false)?;
142        Ok(Self {
143            destination,
144            source,
145            ks,
146            radial,
147            helicity,
148            blocks,
149        })
150    }
151
152    /// Fixed saved-state bytes for the two basis shapes.
153    pub fn state_size(
154        destination_modes: usize,
155        destination_positions: usize,
156        source_modes: usize,
157        source_positions: usize,
158    ) -> Result<usize> {
159        let destination = crate::saved::sw_basis_size(destination_modes, destination_positions)?;
160        let source = crate::saved::sw_basis_size(source_modes, source_positions)?;
161        destination
162            .checked_add(source)
163            .and_then(|n| n.checked_add(34))
164            .ok_or_else(crate::saved::invalid)
165    }
166
167    /// Append state for a containing residual without allocating an intermediate buffer.
168    pub(crate) fn write_state(&self, writer: &mut crate::saved::Writer) {
169        crate::saved::write_sw_basis(writer, &self.destination);
170        crate::saved::write_sw_basis(writer, &self.source);
171        for k in self.ks {
172            writer.complex(k);
173        }
174        crate::saved::write_radial(writer, self.radial);
175        writer.byte(u8::from(self.helicity));
176    }
177    /// Destination and source mode counts: the shape of the expansion matrix.
178    #[must_use]
179    pub fn shape(&self) -> (usize, usize) {
180        (self.destination.modes.len(), self.source.modes.len())
181    }
182
183    /// Directional derivative in the positions and the two medium wavenumbers.
184    pub fn pushforward(
185        &self,
186        destination: &[[f64; 3]],
187        source: &[[f64; 3]],
188        ks: [Complex; 2],
189    ) -> Result<DMatrix<Complex>> {
190        crate::basis::validate_expansion_tangent(
191            destination,
192            source,
193            ks,
194            (
195                self.destination.positions.len(),
196                self.source.positions.len(),
197            ),
198        )?;
199        assemble(self.shape(), &self.blocks, BATCH, |block, _| {
200            if self.radial == Radial::Singular && block.displacement.iter().all(|&x| x == 0.0) {
201                return Ok([None, None]);
202            }
203            let direction = std::array::from_fn(|axis| {
204                destination[block.destination][axis] - source[block.source][axis]
205            });
206            Block::values(self.ks[0] == self.ks[1] && ks[0] == ks[1], |pol| {
207                block
208                    .plan
209                    .pushforward(
210                        self.ks[pol],
211                        block.displacement,
212                        self.radial,
213                        direction,
214                        ks[pol],
215                    )
216                    .map(Some)
217            })
218        })
219    }
220
221    /// Position and wavenumber gradients from `cotangent`, the gradient of a real loss
222    /// with respect to the matrix.
223    pub fn pullback(&self, cotangent: &DMatrix<Complex>) -> Result<ExpansionGradient> {
224        if cotangent.shape() != self.shape() || cotangent.iter().any(|&z| !finite(z)) {
225            return Err(Error::InvalidInput("invalid expansion cotangent".into()));
226        }
227        // Blocks run in parallel; their gradients are summed in block order.
228        let gradients = crate::threads::install(|| {
229            self.blocks
230                .par_iter()
231                .map(|block| {
232                    let mut gradients = [([0.0; 3], Complex::default()); 2];
233                    if self.radial == Radial::Singular
234                        && block.displacement.iter().all(|&x| x == 0.0)
235                    {
236                        return Ok(gradients);
237                    }
238                    for (pol, gradient) in gradients.iter_mut().enumerate() {
239                        let g = block.gather(cotangent, Some(pol));
240                        if g.iter().any(|&z| z != Complex::default()) {
241                            *gradient = block.plan.pullback(
242                                self.ks[pol],
243                                block.displacement,
244                                self.radial,
245                                &g,
246                            )?;
247                        }
248                    }
249                    Ok(gradients)
250                })
251                .collect::<Result<Vec<_>>>()
252        })?;
253        let mut result = ExpansionGradient::zeros(
254            self.destination.positions.len(),
255            self.source.positions.len(),
256        );
257        for (block, gradients) in self.blocks.iter().zip(gradients) {
258            for (pol, (position, k)) in gradients.into_iter().enumerate() {
259                result.ks[pol] += k;
260                for (axis, grad) in position.into_iter().enumerate() {
261                    result.destination[block.destination][axis] += grad;
262                    result.source[block.source][axis] -= grad;
263                }
264            }
265        }
266        Ok(result)
267    }
268}
269
270impl crate::saved::SavedState for ExpansionResidual {
271    fn save_state(&self) -> Result<Vec<u8>> {
272        let mut writer = crate::saved::Writer::new(Self::state_size(
273            self.destination.modes.len(),
274            self.destination.positions.len(),
275            self.source.modes.len(),
276            self.source.positions.len(),
277        )?);
278        self.write_state(&mut writer);
279        Ok(writer.finish())
280    }
281
282    fn from_state(bytes: &[u8]) -> Result<Self> {
283        let mut shape = crate::saved::Reader::new(bytes);
284        let (dm, dp) = crate::saved::read_sw_basis_dimensions(&mut shape)?;
285        let (sm, sp) = crate::saved::read_sw_basis_dimensions(&mut shape)?;
286        if bytes.len() != Self::state_size(dm, dp, sm, sp)? {
287            return Err(crate::saved::invalid());
288        }
289        let mut reader = crate::saved::Reader::new(bytes);
290        let destination = crate::saved::read_sw_basis(&mut reader)?;
291        let source = crate::saved::read_sw_basis(&mut reader)?;
292        let ks = [reader.complex()?, reader.complex()?];
293        let radial = crate::saved::read_radial(&mut reader)?;
294        let helicity = match reader.byte()? {
295            0 => false,
296            1 => true,
297            _ => return Err(crate::saved::invalid()),
298        };
299        reader.finish()?;
300        // Angular plans depend only on static mode labels. No translation values are reevaluated.
301        Self::prepare(destination, source, ks, helicity, radial)
302    }
303}
304
305fn blocks(
306    destination: &Basis,
307    source: &Basis,
308    helicity: bool,
309    normalized_harmonics: bool,
310) -> Result<Vec<Block>> {
311    let mut plans = HashMap::new();
312    let (destination_groups, source_groups) = (destination.groups(), source.groups());
313    let mut blocks = Vec::new();
314    // A saturated count fails the reservation.
315    numerics::reserve(
316        &mut blocks,
317        destination_groups.len().saturating_mul(source_groups.len()),
318    )?;
319    for (p, (rows, to)) in destination_groups {
320        let position = destination.positions[p];
321        for (&q, (cols, from)) in &source_groups {
322            let key = (to.clone(), from.clone());
323            let plan = if let Some(plan) = plans.get(&key) {
324                Arc::clone(plan)
325            } else {
326                let mut plan = TranslationPlan::between(&to, from, helicity)?;
327                if normalized_harmonics {
328                    plan.weights_for_normalized_harmonics();
329                }
330                let plan = Arc::new(plan);
331                plans.insert(key, Arc::clone(&plan));
332                plan
333            };
334            blocks.push(Block {
335                destination: p,
336                source: q,
337                displacement: std::array::from_fn(|a| position[a] - source.positions[q][a]),
338                rows: rows.clone(),
339                cols: cols.clone(),
340                polarizations: from.iter().map(|m| m.pol).collect(),
341                plan,
342            });
343        }
344    }
345    Ok(blocks)
346}
347
348/// The regular or singular translation of one block, or `None` for the singular
349/// self block: outgoing waves do not couple a position to itself.
350fn expansion_block(
351    plan: &TranslationPlan,
352    k: Complex,
353    displacement: [f64; 3],
354    radial: Radial,
355) -> Result<Option<Vec<Complex>>> {
356    if radial == Radial::Singular && displacement.iter().all(|&x| x == 0.0) {
357        return Ok(None);
358    }
359    plan.evaluate(k, displacement, radial).map(Some)
360}
361
362/// Entries of the block values evaluated at once, bounding the transient memory
363/// of the parallel assembly (16 MiB, twice that for two wavenumbers).
364const BATCH: usize = 1 << 20;
365
366/// Blocks fitting the scratch-entry budget, or one block if it alone exceeds it.
367fn batch_count(blocks: &[Block], budget: usize) -> usize {
368    let mut size = 0;
369    blocks
370        .iter()
371        .take_while(|block| {
372            size += block.len();
373            size <= budget
374        })
375        .count()
376        .max(1)
377}
378
379/// Evaluate the blocks in parallel, in batches of at most `batch` entries or of one
380/// larger block, and scatter them in order. A missing second polarization shares
381/// the first; two missing polarizations skip the block. The evaluator also receives
382/// the worker budget of the current batch for nested lattice evaluations.
383fn assemble(
384    shape: (usize, usize),
385    blocks: &[Block],
386    batch: usize,
387    evaluate: impl Fn(&Block, usize) -> Result<[Option<Vec<Complex>>; 2]> + Sync,
388) -> Result<DMatrix<Complex>> {
389    let mut value = numerics::zeros(shape.0, shape.1)?;
390    let mut rest = blocks;
391    while !rest.is_empty() {
392        let count = batch_count(rest, batch);
393        let (current, next) = rest.split_at(count);
394        rest = next;
395        let workers = crate::threads::current_num_threads().div_ceil(current.len());
396        let values = crate::threads::install(|| {
397            current
398                .par_iter()
399                .map(|block| evaluate(block, workers))
400                .collect::<Result<Vec<_>>>()
401        })?;
402        for (block, [first, second]) in current.iter().zip(values) {
403            if let Some(first) = first {
404                block.scatter(&first, second.as_ref().map(|_| 0), &mut value);
405            }
406            if let Some(second) = second {
407                block.scatter(&second, Some(1), &mut value);
408            }
409        }
410    }
411    Ok(value)
412}
413
414/// Periodic outgoing-to-regular spherical coupling, including nonzero lattice images of self blocks.
415///
416/// Upstream: `treams.sw.translate_periodic` and the `treams.ExpandLattice` operator
417/// (`treams.operators.expandlattice`) between spherical bases.
418pub fn lattice_expansion(
419    destination: Basis,
420    source: Basis,
421    ks: [Complex; 2],
422    helicity: bool,
423    lattice: crate::lattice::BlochLattice,
424    eta: Complex,
425) -> Result<(DMatrix<Complex>, LatticeExpansionResidual)> {
426    validate_expansion(&destination, &source, ks, helicity)?;
427    let blocks = blocks(&destination, &source, helicity, false)?;
428    let workers = crate::threads::current_num_threads().div_ceil(blocks.len().max(1));
429    let value = assemble(
430        (destination.modes.len(), source.modes.len()),
431        &blocks,
432        BATCH,
433        |block, _| {
434            Block::values(ks[0] == ks[1], |pol| {
435                block
436                    .plan
437                    .evaluate_periodic(ks[pol], block.displacement, &lattice, eta, workers)
438                    .map(Some)
439            })
440        },
441    )?;
442    Ok((
443        value,
444        LatticeExpansionResidual {
445            destination,
446            source,
447            ks,
448            lattice,
449            eta,
450            helicity,
451            blocks,
452        },
453    ))
454}
455
456/// What [`lattice_expansion`] saves for its pullback: the bases, the wavenumbers, the
457/// lattice, the split and the blocks, whose angular plans every position pair shares.
458///
459/// The pullback evaluates bounded batches of blocks in parallel and adds their
460/// gradients in block and harmonic order, so the thread count does not change it.
461#[derive(Debug)]
462pub struct LatticeExpansionResidual {
463    destination: Basis,
464    source: Basis,
465    ks: [Complex; 2],
466    lattice: crate::lattice::BlochLattice,
467    eta: Complex,
468    helicity: bool,
469    blocks: Vec<Block>,
470}
471
472impl LatticeExpansionResidual {
473    /// Directional derivative in destination/source positions, medium wavenumbers,
474    /// Bloch wavevector and lattice vectors. The Ewald split remains fixed.
475    pub fn pushforward(
476        &self,
477        destination: &[[f64; 3]],
478        source: &[[f64; 3]],
479        ks: [Complex; 2],
480        kpar: &[f64],
481        vectors: &DMatrix<f64>,
482    ) -> Result<DMatrix<Complex>> {
483        crate::basis::validate_expansion_tangent(
484            destination,
485            source,
486            ks,
487            (
488                self.destination.positions.len(),
489                self.source.positions.len(),
490            ),
491        )?;
492        let geometry =
493            crate::lattice::SumTangent::with_lattice(kpar, vectors, self.lattice.dimension())?;
494        assemble(self.shape(), &self.blocks, BATCH, |block, workers| {
495            let shift = std::array::from_fn(|a| {
496                source[block.source][a] - destination[block.destination][a]
497            });
498            let tangents = ks.map(|k| crate::lattice::SumTangent {
499                k,
500                shift,
501                ..geometry
502            });
503            block
504                .plan
505                .pushforward_periodic(
506                    self.ks,
507                    block.displacement,
508                    &self.lattice,
509                    self.eta,
510                    &tangents,
511                    workers,
512                )
513                .map(|values| values.map(Some))
514        })
515    }
516
517    /// Destination and source mode counts: the shape of the coupling matrix.
518    #[must_use]
519    pub fn shape(&self) -> (usize, usize) {
520        (self.destination.modes.len(), self.source.modes.len())
521    }
522
523    /// Position, wavenumber, Bloch-vector and lattice-vector gradients from `cotangent`;
524    /// the pullback recomputes the analytic derivatives of the Ewald sums.
525    pub fn pullback(&self, cotangent: &DMatrix<Complex>) -> Result<LatticeExpansionGradient> {
526        if cotangent.shape() != self.shape() || cotangent.iter().any(|&g| !finite(g)) {
527            return Err(Error::InvalidInput(
528                "invalid periodic expansion cotangent".into(),
529            ));
530        }
531        let mut result = LatticeExpansionGradient::zeros(
532            self.destination.positions.len(),
533            self.source.positions.len(),
534            self.lattice.dimension(),
535        );
536        let mut rest = self.blocks.as_slice();
537        while !rest.is_empty() {
538            let (current, next) = rest.split_at(batch_count(rest, BATCH));
539            rest = next;
540            let workers = crate::threads::current_num_threads().div_ceil(current.len());
541            let gradients = crate::threads::install(|| {
542                current
543                    .par_iter()
544                    .map(|block| {
545                        let g = [0, 1].map(|pol| block.gather(cotangent, Some(pol)));
546                        block.plan.pullback_periodic(
547                            self.ks,
548                            block.displacement,
549                            &self.lattice,
550                            self.eta,
551                            &g,
552                            workers,
553                        )
554                    })
555                    .collect::<Result<Vec<_>>>()
556            })?;
557            for (block, gradients) in current.iter().zip(gradients) {
558                for (pol, gradient) in gradients.into_iter().enumerate() {
559                    result.add_lattice(&gradient);
560                    result.expansion.ks[pol] += gradient.k;
561                    for (axis, value) in gradient.shift.into_iter().enumerate() {
562                        result.expansion.destination[block.destination][axis] += value;
563                        result.expansion.source[block.source][axis] -= value;
564                    }
565                }
566            }
567        }
568        Ok(result)
569    }
570}
571
572/// What [`lattice_expansion_from_table`] saves for its pullback: the sparse angular
573/// weights, never the table values. The pullback runs in block order and gives the same
574/// result every run.
575#[derive(Debug)]
576pub struct LatticeExpansionFromTableResidual {
577    destination: Basis,
578    source: Basis,
579    helicity: bool,
580    blocks: Vec<Block>,
581    table_shape: [usize; 4],
582    shape: (usize, usize),
583}
584
585/// The lattice expansion matrix from a table of lattice sums, a linear map of the table.
586///
587/// The table axes are destination position, source position, wavenumber channel (one, or
588/// two for distinct helicity wavenumbers) and harmonic index `p * p + p + m` for
589/// `p <= lmax`, where `lmax` is the largest destination degree plus the largest source
590/// degree. Each entry holds a lattice sum of orthonormal spherical harmonics, with the
591/// normalization `sqrt((2p + 1) / 4 pi) sqrt((p - m)! / (p + m)!)` of
592/// [`crate::lattice::sum`] for [`crate::lattice::Family::Spherical`].
593///
594/// Upstream: `treams.sw.translate_periodic` after its `dlms` table of
595/// `treams.lattice.lsumsw` sums, which has the same axes and normalization.
596pub fn lattice_expansion_from_table(
597    destination: &Basis,
598    source: &Basis,
599    helicity: bool,
600    channels: usize,
601    table: &[Complex],
602) -> Result<(DMatrix<Complex>, LatticeExpansionFromTableResidual)> {
603    destination.validate()?;
604    source.validate()?;
605    if !(1..=2).contains(&channels) || (!helicity && channels != 1) {
606        return Err(Error::InvalidInput(
607            "lattice table needs one or two channels; parity needs one".into(),
608        ));
609    }
610    let lmax = destination
611        .modes
612        .iter()
613        .map(|(_, m)| m.l)
614        .max()
615        .unwrap_or(0)
616        + source.modes.iter().map(|(_, m)| m.l).max().unwrap_or(0);
617    let harmonics = usize::try_from((lmax + 1).pow(2))
618        .map_err(|_| Error::InvalidInput("invalid harmonic count".into()))?;
619    let table_shape = [
620        destination.positions.len(),
621        source.positions.len(),
622        channels,
623        harmonics,
624    ];
625    let size = table_shape
626        .iter()
627        .try_fold(1_usize, |size, &length| size.checked_mul(length));
628    if size != Some(table.len()) || table.iter().any(|&value| !finite(value)) {
629        return Err(Error::InvalidInput(
630            "lattice table requires matching finite harmonic values".into(),
631        ));
632    }
633    let blocks = blocks(destination, source, helicity, true)?;
634    let shape = (destination.modes.len(), source.modes.len());
635    let residual = LatticeExpansionFromTableResidual {
636        destination: destination.clone(),
637        source: source.clone(),
638        helicity,
639        blocks,
640        table_shape,
641        shape,
642    };
643    Ok((residual.apply_table(table)?, residual))
644}
645
646impl LatticeExpansionFromTableResidual {
647    /// The same sparse linear angular map applied to the table direction.
648    pub fn pushforward(&self, tangent: &[Complex]) -> Result<DMatrix<Complex>> {
649        if tangent.len() != self.table_shape.iter().product::<usize>()
650            || tangent.iter().any(|&value| !finite(value))
651        {
652            return Err(Error::InvalidInput(
653                "lattice table tangent must be finite and match the table".into(),
654            ));
655        }
656        self.apply_table(tangent)
657    }
658
659    /// Values and directions use one sparse linear contraction.
660    fn apply_table(&self, tangent: &[Complex]) -> Result<DMatrix<Complex>> {
661        let [_, sources, channels, harmonics] = self.table_shape;
662        let mut result = numerics::zeros(self.shape.0, self.shape.1)?;
663        for block in &self.blocks {
664            let offset = (block.destination * sources + block.source) * channels * harmonics;
665            for channel in 0..channels {
666                let table =
667                    &tangent[offset + channel * harmonics..offset + (channel + 1) * harmonics];
668                let evaluated = block.plan.evaluate_table(table);
669                block.scatter(&evaluated, (channels == 2).then_some(channel), &mut result);
670            }
671        }
672        if result.iter().any(|&value| !finite(value)) {
673            return Err(Error::NonFinite("lattice table sum overflows".into()));
674        }
675        Ok(result)
676    }
677
678    /// Destination and source mode counts: the shape of the coupling matrix.
679    #[must_use]
680    pub const fn shape(&self) -> (usize, usize) {
681        self.shape
682    }
683
684    /// The shape of the normalized harmonic table, which is also the shape of the
685    /// table gradient.
686    #[must_use]
687    pub const fn table_shape(&self) -> [usize; 4] {
688        self.table_shape
689    }
690
691    /// The table gradient: the conjugate transpose of the fixed angular weights applied to
692    /// `cotangent`.
693    pub fn pullback(&self, cotangent: &DMatrix<Complex>) -> Result<Vec<Complex>> {
694        if cotangent.shape() != self.shape || cotangent.iter().any(|&v| !finite(v)) {
695            return Err(Error::InvalidInput(
696                "invalid lattice table cotangent".into(),
697            ));
698        }
699        let [_, sources, channels, harmonics] = self.table_shape;
700        let mut gradient = vec![Complex::default(); self.table_shape.iter().product()];
701        for block in &self.blocks {
702            let offset = (block.destination * sources + block.source) * channels * harmonics;
703            for channel in 0..channels {
704                let local = block.gather(cotangent, (channels == 2).then_some(channel));
705                block.plan.pullback_table(
706                    &local,
707                    &mut gradient[offset + channel * harmonics..offset + (channel + 1) * harmonics],
708                );
709            }
710        }
711        Ok(gradient)
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    //! Batched assembly of the expansion matrix. Identities of expansions are in
718    //! `properties/waves.rs`.
719
720    use super::{Basis, Block, assemble, blocks, expansion, expansion_block};
721    use crate::{Complex, special::Radial, sw};
722
723    /// Bounded batches, down to one block at a time, assemble the matrix of a single
724    /// batch, for both radial kinds with equal and distinct helicity wavenumbers.
725    #[test]
726    fn batches_assemble_the_same_matrix() {
727        // Positions with modes up to degree 1, 2 and 1 make blocks of 36, 96 and 256
728        // entries. Batches of 150 entries hold two blocks or the largest one alone,
729        // batches of 300 up to four blocks.
730        let basis = Basis {
731            modes: [1, 2, 1]
732                .into_iter()
733                .enumerate()
734                .flat_map(|(p, lmax)| {
735                    let modes = sw::modes(lmax).unwrap();
736                    modes.into_iter().map(move |mode| (p, mode))
737                })
738                .collect(),
739            positions: vec![[0.0; 3], [0.9, 0.2, 0.3], [-0.4, 0.8, -0.5]],
740        };
741        let blocks = blocks(&basis, &basis, true, false).unwrap();
742        let k = Complex::new(1.3, 0.05);
743        for ks in [[k, k], [k, k * 1.4]] {
744            for radial in [Radial::Regular, Radial::Singular] {
745                let (expected, _) =
746                    expansion(basis.clone(), basis.clone(), ks, true, radial).unwrap();
747                for batch in [1, 150, 300] {
748                    let value = assemble(
749                        (basis.modes.len(), basis.modes.len()),
750                        &blocks,
751                        batch,
752                        |block, _| {
753                            Block::values(ks[0] == ks[1], |pol| {
754                                expansion_block(&block.plan, ks[pol], block.displacement, radial)
755                            })
756                        },
757                    )
758                    .unwrap();
759                    assert_eq!(value, expected, "batch {batch}, {radial:?}");
760                }
761            }
762        }
763    }
764}