treams_core/cw/
expansion.rs

1//! Cylindrical expansion matrices between bases and over periodic arrays, with analytic
2//! pullbacks. Entries that share a `CouplingKey` share one coefficient, evaluated once.
3//!
4//! Upstream: the `treams.Expand` and `treams.ExpandLattice` operators between cylindrical
5//! bases.
6#![allow(clippy::indexing_slicing)] // Validated modes, basis indices and matrix shapes.
7
8use super::{Basis, CartesianTranslation, cartesian_translation, transverse_wavenumber};
9use crate::{
10    Complex, Error, Result,
11    basis::validate_wavenumbers,
12    numerics::{self, broadcast, finite, parallel::Parallel},
13    special::Radial,
14};
15use nalgebra::DMatrix;
16use rayon::prelude::*;
17use std::collections::HashMap;
18
19#[path = "expansion_lattice_state.rs"]
20mod lattice_state;
21
22/// The sorted distinct axial wavenumbers of both bases: the entries of the axial
23/// cotangent of `pullback_axial`.
24fn axial_groups(destination: &Basis, source: &Basis) -> Vec<f64> {
25    let mut groups: Vec<_> = destination
26        .modes
27        .iter()
28        .chain(&source.modes)
29        .map(|(_, m)| m.kz)
30        .collect();
31    groups.sort_by(f64::total_cmp);
32    groups.dedup();
33    groups
34}
35
36/// The key of one distinct coupling coefficient. A coefficient depends on its mode pair
37/// only through these fields.
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
39struct CouplingKey {
40    /// Destination position index.
41    destination: usize,
42    /// Source position index.
43    source: usize,
44    /// Bits of the shared axial wavenumber.
45    kz: u64,
46    /// Index into `ks`: the source polarization, or 0 when both wavenumbers are equal.
47    slot: u8,
48    /// Order difference `m - mu` of the source and destination modes.
49    order: i32,
50}
51
52/// The distinct coefficients of a cylindrical coupling matrix and the entries they
53/// fill. A coefficient depends on the mode pair only through its key, and entries
54/// of different axial wavenumbers or polarizations vanish.
55#[derive(Debug)]
56struct Couplings {
57    /// Each key with its first entry `(row, column)`.
58    requests: Vec<(CouplingKey, (usize, usize))>,
59    /// The request of each coupled entry in the order of [`for_each_coupled`].
60    /// Residuals keep this until their pullback, so it holds one compact index per
61    /// entry instead of the entry's position.
62    entries: Vec<u32>,
63}
64
65/// Call `f(row, column)` for each coupled entry of a destination and a source basis,
66/// column by column: equal axial wavenumbers and polarizations.
67#[allow(clippy::float_cmp)] // Axial wavenumbers are exact discrete basis labels.
68fn for_each_coupled(destination: &Basis, source: &Basis, mut f: impl FnMut(usize, usize)) {
69    for (j, &(_, from)) in source.modes.iter().enumerate() {
70        for (i, &(_, to)) in destination.modes.iter().enumerate() {
71            if to.kz == from.kz && to.pol == from.pol {
72                f(i, j);
73            }
74        }
75    }
76}
77
78impl Couplings {
79    /// Equal wavenumbers share one slot.
80    fn new(destination: &Basis, source: &Basis, ks: [Complex; 2]) -> Result<Self> {
81        let shared = ks[0] == ks[1];
82        let mut indices = HashMap::new();
83        let mut requests = Vec::new();
84        let mut entries = Vec::new();
85        let mut overflow = false;
86        for_each_coupled(destination, source, |i, j| {
87            let (p, to) = destination.modes[i];
88            let (q, from) = source.modes[j];
89            let key = CouplingKey {
90                destination: p,
91                source: q,
92                kz: from.kz.to_bits(),
93                slot: if shared { 0 } else { from.pol },
94                order: from.m - to.m,
95            };
96            let index = *indices.entry(key).or_insert_with(|| {
97                requests.push((key, (i, j)));
98                requests.len() - 1
99            });
100            match u32::try_from(index) {
101                Ok(index) => entries.push(index),
102                Err(_) => overflow = true,
103            }
104        });
105        if overflow {
106            return Err(Error::InvalidInput(
107                "too many distinct cylindrical couplings".into(),
108            ));
109        }
110        Ok(Self { requests, entries })
111    }
112
113    /// Call `f(row, column, request)` for each coupled entry.
114    fn for_each(
115        &self,
116        destination: &Basis,
117        source: &Basis,
118        mut f: impl FnMut(usize, usize, usize),
119    ) {
120        let mut entries = self.entries.iter();
121        for_each_coupled(destination, source, |i, j| {
122            if let Some(&request) = entries.next() {
123                f(i, j, request as usize);
124            }
125        });
126    }
127
128    /// The matrix whose coupled entries take their request's value.
129    fn matrix(
130        &self,
131        destination: &Basis,
132        source: &Basis,
133        values: &[Complex],
134    ) -> Result<DMatrix<Complex>> {
135        let mut matrix = numerics::zeros(destination.modes.len(), source.modes.len())?;
136        self.for_each(destination, source, |i, j, request| {
137            matrix[(i, j)] = values[request];
138        });
139        Ok(matrix)
140    }
141
142    /// The cotangents of each request, summed over its entries by source polarization.
143    fn gather(
144        &self,
145        destination: &Basis,
146        source: &Basis,
147        cotangent: &DMatrix<Complex>,
148    ) -> Vec<[Complex; 2]> {
149        let mut g = vec![[Complex::default(); 2]; self.requests.len()];
150        self.for_each(destination, source, |i, j, request| {
151            g[request][usize::from(source.modes[j].1.pol)] += cotangent[(i, j)];
152        });
153        g
154    }
155}
156
157/// What [`expansion`] saves for its pullback: the bases, the wavenumbers, the radial
158/// kind and the distinct couplings.
159///
160/// Equal axial wavenumbers select the coupled entries, and the pullback holds that
161/// selection fixed.
162///
163/// The pullback evaluates the coefficients in parallel and adds their gradients in
164/// request order, so its result does not depend on the thread count.
165#[derive(Debug)]
166pub struct ExpansionResidual {
167    destination: Basis,
168    source: Basis,
169    ks: [Complex; 2],
170    radial: Radial,
171    couplings: Couplings,
172}
173
174/// Expansion coefficients and their jets evaluate in parallel from this many
175/// distinct couplings.
176const PARALLEL: Parallel = Parallel::AtLeast(64);
177
178/// Construct a cylindrical expansion matrix in the supplied mode order.
179///
180/// Upstream: the `treams.Expand` operator (`treams.operators.expand`) between cylindrical
181/// bases.
182pub fn expansion(
183    destination: Basis,
184    source: Basis,
185    ks: [Complex; 2],
186    radial: Radial,
187) -> Result<(DMatrix<Complex>, ExpansionResidual)> {
188    let residual = ExpansionResidual::prepare(destination, source, ks, radial)?;
189    let requests = &residual.couplings.requests;
190    let values = broadcast::map(requests.len(), PARALLEL, |r| {
191        Ok(residual.translation(requests[r].1)?.value)
192    })?;
193    let value = residual
194        .couplings
195        .matrix(&residual.destination, &residual.source, &values)?;
196    Ok((value, residual))
197}
198
199impl ExpansionResidual {
200    fn prepare(
201        destination: Basis,
202        source: Basis,
203        ks: [Complex; 2],
204        radial: Radial,
205    ) -> Result<Self> {
206        destination.validate()?;
207        source.validate()?;
208        // The expansion takes no polarization convention and never couples the two
209        // polarizations, so it skips the parity check.
210        validate_wavenumbers(ks, true, false)?;
211        // Like cartesian_translation, reject a wavenumber whose square underflows, also
212        // when no entry couples.
213        if ks.iter().any(|k| k.norm_sqr() == 0.0) {
214            return Err(Error::InvalidInput(
215                "medium wavenumber squared underflows to zero".into(),
216            ));
217        }
218        let couplings = Couplings::new(&destination, &source, ks)?;
219        Ok(Self {
220            destination,
221            source,
222            ks,
223            radial,
224            couplings,
225        })
226    }
227
228    /// Fixed saved-state bytes for the two basis shapes.
229    pub fn state_size(
230        destination_modes: usize,
231        destination_positions: usize,
232        source_modes: usize,
233        source_positions: usize,
234    ) -> Result<usize> {
235        let destination = crate::saved::cw_basis_size(destination_modes, destination_positions)?;
236        let source = crate::saved::cw_basis_size(source_modes, source_positions)?;
237        destination
238            .checked_add(source)
239            .and_then(|n| n.checked_add(33))
240            .ok_or_else(crate::saved::invalid)
241    }
242
243    /// Append state for a containing residual without an intermediate buffer.
244    pub(crate) fn write_state(&self, writer: &mut crate::saved::Writer) {
245        crate::saved::write_cw_basis(writer, &self.destination);
246        crate::saved::write_cw_basis(writer, &self.source);
247        for k in self.ks {
248            writer.complex(k);
249        }
250        crate::saved::write_radial(writer, self.radial);
251    }
252    /// Destination and source mode counts: the shape of the expansion matrix.
253    #[must_use]
254    pub fn shape(&self) -> (usize, usize) {
255        (self.destination.modes.len(), self.source.modes.len())
256    }
257
258    /// Directional derivative with fixed axial mode labels.
259    pub fn pushforward(
260        &self,
261        destination: &[[f64; 3]],
262        source: &[[f64; 3]],
263        ks: [Complex; 2],
264    ) -> Result<DMatrix<Complex>> {
265        self.pushforward_impl(destination, source, ks, None)
266    }
267
268    /// Also perturb the sorted distinct shared axial wavenumbers, holding their
269    /// equality partitions fixed.
270    pub fn pushforward_axial(
271        &self,
272        destination: &[[f64; 3]],
273        source: &[[f64; 3]],
274        ks: [Complex; 2],
275        kz: &[f64],
276    ) -> Result<DMatrix<Complex>> {
277        self.pushforward_impl(destination, source, ks, Some(kz))
278    }
279
280    fn pushforward_impl(
281        &self,
282        destination: &[[f64; 3]],
283        source: &[[f64; 3]],
284        ks: [Complex; 2],
285        kz: Option<&[f64]>,
286    ) -> Result<DMatrix<Complex>> {
287        crate::basis::validate_expansion_tangent(
288            destination,
289            source,
290            ks,
291            (
292                self.destination.positions.len(),
293                self.source.positions.len(),
294            ),
295        )?;
296        let groups = kz.map(|_| axial_groups(&self.destination, &self.source));
297        if let (Some(kz), Some(groups)) = (kz, &groups)
298            && (kz.len() != groups.len() || kz.iter().any(|k| !k.is_finite()))
299        {
300            return Err(Error::InvalidInput(
301                "axial tangents must be finite and match shared labels".into(),
302            ));
303        }
304        let requests = &self.couplings.requests;
305        // Equal primal wavenumbers may have distinct polarization directions, so
306        // share each coefficient's jet and contract it separately per polarization.
307        let values = broadcast::map(requests.len(), PARALLEL, |r| {
308            let (key, pair) = requests[r];
309            let jet = self.translation(pair)?;
310            let axial = if let (Some(kz), Some(groups)) = (kz, &groups) {
311                jet.kz * kz[groups.partition_point(|&k| k < f64::from_bits(key.kz))]
312            } else {
313                Complex::default()
314            };
315            Ok([0, 1].map(|pol| {
316                crate::basis::pair_tangent(
317                    destination,
318                    source,
319                    ks,
320                    [key.destination, key.source, pol],
321                    jet.position,
322                    jet.k,
323                ) + axial
324            }))
325        })?;
326        let mut result = numerics::zeros(self.shape().0, self.shape().1)?;
327        self.couplings
328            .for_each(&self.destination, &self.source, |i, j, request| {
329                result[(i, j)] = values[request][usize::from(self.source.modes[j].1.pol)];
330            });
331        Ok(result)
332    }
333
334    /// Position and complex wavenumber gradients. Axial mode labels stay fixed.
335    pub fn pullback(
336        &self,
337        cotangent: &DMatrix<Complex>,
338    ) -> Result<crate::basis::ExpansionGradient> {
339        self.pullback_impl::<false>(cotangent).map(|(g, _)| g)
340    }
341
342    /// Also differentiate each shared axial wavenumber. The final array follows
343    /// the sorted distinct kz values from both bases; equality partitions stay fixed.
344    pub fn pullback_axial(
345        &self,
346        cotangent: &DMatrix<Complex>,
347    ) -> Result<(crate::basis::ExpansionGradient, Vec<f64>)> {
348        self.pullback_impl::<true>(cotangent)
349    }
350
351    /// The translation coefficient and derivatives of request `(i, j)`.
352    fn translation(&self, (i, j): (usize, usize)) -> Result<CartesianTranslation> {
353        let (p, to) = self.destination.modes[i];
354        let (q, from) = self.source.modes[j];
355        let position =
356            std::array::from_fn(|a| self.destination.positions[p][a] - self.source.positions[q][a]);
357        cartesian_translation(
358            to,
359            from,
360            self.ks[usize::from(from.pol)],
361            position,
362            self.radial,
363        )
364    }
365
366    fn pullback_impl<const AXIAL: bool>(
367        &self,
368        cotangent: &DMatrix<Complex>,
369    ) -> Result<(crate::basis::ExpansionGradient, Vec<f64>)> {
370        if cotangent.shape() != self.shape() || cotangent.iter().any(|&g| !finite(g)) {
371            return Err(Error::InvalidInput(
372                "invalid cylindrical expansion cotangent".into(),
373            ));
374        }
375        let requests = &self.couplings.requests;
376        let g = self
377            .couplings
378            .gather(&self.destination, &self.source, cotangent);
379        let jets = broadcast::map(requests.len(), PARALLEL, |r| {
380            if g[r] == [Complex::default(); 2] {
381                Ok(None)
382            } else {
383                self.translation(requests[r].1).map(Some)
384            }
385        })?;
386        let groups = if AXIAL {
387            axial_groups(&self.destination, &self.source)
388        } else {
389            Vec::new()
390        };
391        let mut axial = vec![0.0; groups.len()];
392        let mut result = crate::basis::ExpansionGradient::zeros(
393            self.destination.positions.len(),
394            self.source.positions.len(),
395        );
396        for ((&(key, _), g), jet) in self.couplings.requests.iter().zip(g).zip(jets) {
397            let Some(jet) = jet else { continue };
398            for (out, g) in result.ks.iter_mut().zip(g) {
399                *out += jet.k.conj() * g;
400            }
401            let total = g[0] + g[1];
402            if AXIAL {
403                let kz = f64::from_bits(key.kz);
404                axial[groups.partition_point(|&k| k < kz)] += (total.conj() * jet.kz).re;
405            }
406            for axis in 0..3 {
407                let derivative = (total.conj() * jet.position[axis]).re;
408                result.destination[key.destination][axis] += derivative;
409                result.source[key.source][axis] -= derivative;
410            }
411        }
412        Ok((result, axial))
413    }
414}
415
416impl crate::saved::SavedState for ExpansionResidual {
417    fn save_state(&self) -> Result<Vec<u8>> {
418        let mut writer = crate::saved::Writer::new(Self::state_size(
419            self.destination.modes.len(),
420            self.destination.positions.len(),
421            self.source.modes.len(),
422            self.source.positions.len(),
423        )?);
424        self.write_state(&mut writer);
425        Ok(writer.finish())
426    }
427
428    fn from_state(bytes: &[u8]) -> Result<Self> {
429        let mut shape = crate::saved::Reader::new(bytes);
430        let (dm, dp) = crate::saved::read_cw_basis_dimensions(&mut shape)?;
431        let (sm, sp) = crate::saved::read_cw_basis_dimensions(&mut shape)?;
432        if bytes.len() != Self::state_size(dm, dp, sm, sp)? {
433            return Err(crate::saved::invalid());
434        }
435        let mut reader = crate::saved::Reader::new(bytes);
436        let destination = crate::saved::read_cw_basis(&mut reader)?;
437        let source = crate::saved::read_cw_basis(&mut reader)?;
438        let ks = [reader.complex()?, reader.complex()?];
439        let radial = crate::saved::read_radial(&mut reader)?;
440        reader.finish()?;
441        Self::prepare(destination, source, ks, radial)
442    }
443}
444
445/// Periodic cylindrical coupling with exact axial/polarization selection and cached orders.
446///
447/// Upstream: `treams.cw.translate_periodic` and the `treams.ExpandLattice` operator
448/// (`treams.operators.expandlattice`) between cylindrical bases.
449pub fn lattice_expansion(
450    destination: Basis,
451    source: Basis,
452    ks: [Complex; 2],
453    lattice: crate::lattice::BlochLattice,
454    eta: Complex,
455) -> Result<(DMatrix<Complex>, LatticeExpansionResidual)> {
456    destination.validate()?;
457    source.validate()?;
458    // The expansion takes no polarization convention and never couples the two
459    // polarizations, so it skips the parity check.
460    validate_wavenumbers(ks, true, false)?;
461    let couplings = Couplings::new(&destination, &source, ks)?;
462    let residual = LatticeExpansionResidual {
463        destination,
464        source,
465        ks,
466        lattice,
467        eta,
468        couplings,
469    };
470    let values = crate::threads::install(|| {
471        residual
472            .couplings
473            .requests
474            .par_iter()
475            .map(|&(key, _)| {
476                let (_, krho, r, phase) = residual.geometry(key);
477                Ok(crate::lattice::sum(
478                    crate::lattice::Family::Cylindrical { m: key.order },
479                    krho,
480                    &residual.lattice,
481                    [r[0], r[1], 0.0],
482                    residual.eta,
483                )? * phase)
484            })
485            .collect::<Result<Vec<_>>>()
486    })?;
487    let value = residual
488        .couplings
489        .matrix(&residual.destination, &residual.source, &values)?;
490    Ok((value, residual))
491}
492
493/// What [`lattice_expansion`] saves for its pullback: the bases, the wavenumbers, the
494/// lattice, the split and the distinct couplings.
495///
496/// Equal axial wavenumbers select the coupled entries, and the pullback holds that
497/// selection fixed.
498///
499/// The pullback evaluates the lattice-sum gradients in parallel and adds them in request
500/// order, so its result does not depend on the thread count.
501#[derive(Debug)]
502pub struct LatticeExpansionResidual {
503    destination: Basis,
504    source: Basis,
505    ks: [Complex; 2],
506    lattice: crate::lattice::BlochLattice,
507    eta: Complex,
508    couplings: Couplings,
509}
510
511impl LatticeExpansionResidual {
512    /// Directional derivative in positions, medium wavenumbers and lattice geometry.
513    pub fn pushforward(
514        &self,
515        destination: &[[f64; 3]],
516        source: &[[f64; 3]],
517        ks: [Complex; 2],
518        kpar: &[f64],
519        vectors: &DMatrix<f64>,
520    ) -> Result<DMatrix<Complex>> {
521        self.pushforward_impl(destination, source, ks, kpar, vectors, None)
522    }
523
524    /// Also vary the sorted distinct shared axial wavenumbers, holding matching
525    /// mode groups fixed.
526    pub fn pushforward_axial(
527        &self,
528        destination: &[[f64; 3]],
529        source: &[[f64; 3]],
530        ks: [Complex; 2],
531        kpar: &[f64],
532        vectors: &DMatrix<f64>,
533        axial: &[f64],
534    ) -> Result<DMatrix<Complex>> {
535        self.pushforward_impl(destination, source, ks, kpar, vectors, Some(axial))
536    }
537
538    fn pushforward_impl(
539        &self,
540        destination: &[[f64; 3]],
541        source: &[[f64; 3]],
542        ks: [Complex; 2],
543        kpar: &[f64],
544        vectors: &DMatrix<f64>,
545        axial: Option<&[f64]>,
546    ) -> Result<DMatrix<Complex>> {
547        crate::basis::validate_expansion_tangent(
548            destination,
549            source,
550            ks,
551            (
552                self.destination.positions.len(),
553                self.source.positions.len(),
554            ),
555        )?;
556        let geometry =
557            crate::lattice::SumTangent::with_lattice(kpar, vectors, self.lattice.dimension())?;
558        let groups = axial
559            .map(|_| axial_groups(&self.destination, &self.source))
560            .unwrap_or_default();
561        if axial.is_some_and(|a| a.len() != groups.len() || a.iter().any(|x| !x.is_finite())) {
562            return Err(Error::InvalidInput(
563                "axial tangent must be finite and match the shared wavenumbers".into(),
564            ));
565        }
566        let tangents = crate::threads::install(|| {
567            self.couplings
568                .requests
569                .par_iter()
570                .map(|&(key, _)| {
571                    let kz = f64::from_bits(key.kz);
572                    let dkz = axial.map_or(0.0, |a| a[groups.partition_point(|&k| k < kz)]);
573                    let (k, krho, r, phase) = self.geometry(key);
574                    let shift: [f64; 3] = std::array::from_fn(|a| {
575                        source[key.source][a] - destination[key.destination][a]
576                    });
577                    let derivative = crate::lattice::derivatives(
578                        crate::lattice::Family::Cylindrical { m: key.order },
579                        krho,
580                        &self.lattice,
581                        [r[0], r[1], 0.0],
582                        self.eta,
583                    )?;
584                    let phase_tangent = -Complex::i() * (kz * shift[2] + dkz * r[2]) * phase;
585                    Ok(ks.map(|dk| {
586                        phase
587                            * derivative.pushforward(&crate::lattice::SumTangent {
588                                k: (k * dk - kz * dkz) / krho,
589                                shift: [shift[0], shift[1], 0.0],
590                                ..geometry
591                            })
592                            + derivative.value * phase_tangent
593                    }))
594                })
595                .collect::<Result<Vec<_>>>()
596        })?;
597        let mut result = numerics::zeros(self.shape().0, self.shape().1)?;
598        self.couplings
599            .for_each(&self.destination, &self.source, |i, j, request| {
600                result[(i, j)] = tangents[request][usize::from(self.source.modes[j].1.pol)];
601            });
602        Ok(result)
603    }
604
605    /// Destination and source mode counts: the shape of the coupling matrix.
606    #[must_use]
607    pub fn shape(&self) -> (usize, usize) {
608        (self.destination.modes.len(), self.source.modes.len())
609    }
610
611    /// The wavenumber, the transverse wavenumber, the source-to-destination
612    /// displacement `r` and the axial phase `exp(-i kz r_z)` of a request.
613    fn geometry(&self, key: CouplingKey) -> (Complex, Complex, [f64; 3], Complex) {
614        let kz = f64::from_bits(key.kz);
615        let k = self.ks[usize::from(key.slot)];
616        let r: [f64; 3] = std::array::from_fn(|a| {
617            self.source.positions[key.source][a] - self.destination.positions[key.destination][a]
618        });
619        let phase = (-Complex::i() * kz * r[2]).exp();
620        (k, transverse_wavenumber(k, kz), r, phase)
621    }
622
623    /// Differentiate positions, medium wavenumbers and lattice geometry.
624    pub fn pullback(
625        &self,
626        cotangent: &DMatrix<Complex>,
627    ) -> Result<crate::basis::LatticeExpansionGradient> {
628        self.pullback_impl::<false>(cotangent).map(|(g, _)| g)
629    }
630
631    /// Also differentiate sorted distinct axial wavenumbers with matching groups fixed.
632    pub fn pullback_axial(
633        &self,
634        cotangent: &DMatrix<Complex>,
635    ) -> Result<(crate::basis::LatticeExpansionGradient, Vec<f64>)> {
636        self.pullback_impl::<true>(cotangent)
637    }
638
639    fn pullback_impl<const AXIAL: bool>(
640        &self,
641        cotangent: &DMatrix<Complex>,
642    ) -> Result<(crate::basis::LatticeExpansionGradient, Vec<f64>)> {
643        if cotangent.shape() != self.shape() || cotangent.iter().any(|&g| !finite(g)) {
644            return Err(Error::InvalidInput(
645                "invalid periodic expansion cotangent".into(),
646            ));
647        }
648        let g = self
649            .couplings
650            .gather(&self.destination, &self.source, cotangent);
651        let gradients = crate::threads::install(|| {
652            self.couplings
653                .requests
654                .par_iter()
655                .zip(g)
656                .map(|(&(key, _), g)| {
657                    let kz = f64::from_bits(key.kz);
658                    let (k, krho, r, phase) = self.geometry(key);
659                    let jet = crate::lattice::derivatives(
660                        crate::lattice::Family::Cylindrical { m: key.order },
661                        krho,
662                        &self.lattice,
663                        [r[0], r[1], 0.0],
664                        self.eta,
665                    )?;
666                    // Equal wavenumbers share the Ewald jet, but keep independent k cotangents.
667                    let total: Complex = g.iter().sum();
668                    let scalar_g = total * phase.conj();
669                    let spectral = g.map(|g| (jet.k * k / krho).conj() * g * phase.conj());
670                    let mut gradient = crate::lattice::SumGradient {
671                        k: Complex::default(),
672                        eta: Complex::default(),
673                        shift: jet.shift.map(|d| (scalar_g.conj() * d).re),
674                        kpar: jet.kpar.map(|d| (scalar_g.conj() * d).re),
675                        vectors: jet.vectors.map(|row| row.map(|d| (scalar_g.conj() * d).re)),
676                    };
677                    gradient.shift[2] =
678                        (total.conj() * (-Complex::i() * kz) * phase * jet.value).re;
679                    let axial = if AXIAL {
680                        (total.conj()
681                            * phase
682                            * (-jet.k * kz / krho - Complex::i() * r[2] * jet.value))
683                            .re
684                    } else {
685                        0.0
686                    };
687                    Ok((spectral, gradient, axial))
688                })
689                .collect::<Result<Vec<_>>>()
690        })?;
691        let mut result = crate::basis::LatticeExpansionGradient::zeros(
692            self.destination.positions.len(),
693            self.source.positions.len(),
694            self.lattice.dimension(),
695        );
696        let groups = if AXIAL {
697            axial_groups(&self.destination, &self.source)
698        } else {
699            Vec::new()
700        };
701        let mut axial = vec![0.0; groups.len()];
702        for ((spectral, g, gkz), &(key, _)) in gradients.into_iter().zip(&self.couplings.requests) {
703            if AXIAL {
704                axial[groups.partition_point(|&k| k < f64::from_bits(key.kz))] += gkz;
705            }
706            result.add_lattice(&g);
707            for (g, k) in result.expansion.ks.iter_mut().zip(spectral) {
708                *g += k;
709            }
710            for (axis, value) in g.shift.into_iter().enumerate() {
711                result.expansion.destination[key.destination][axis] -= value;
712                result.expansion.source[key.source][axis] += value;
713            }
714        }
715        Ok((result, axial))
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    //! Expansion values and pullbacks against pairwise translations. Identities of
722    //! expansions are in `properties/waves.rs`.
723
724    use super::{Basis, Complex, Radial, cartesian_translation, expansion};
725    use crate::{
726        cw::Mode,
727        test_support::{DEFAULT_CASES, patterned, prop_assert_close, radial, re_dot},
728    };
729    use nalgebra::DMatrix;
730    use proptest::{prelude::*, test_runner::TestCaseError};
731
732    proptest! {
733        #![proptest_config(ProptestConfig::with_cases(DEFAULT_CASES))]
734
735        #[test]
736        fn expansion_pullback_matches_pairwise_translations(
737            mmax in 0_i32..3,
738            kzs in (-0.7_f64..0.35, 0.35_f64..0.7),
739            positions in prop::array::uniform3(prop::array::uniform3(-1.0_f64..1.0)),
740            shared in any::<bool>(),
741            radial in radial(),
742            seed in -3.0_f64..3.0,
743        ) {
744            check_expansion_pullback(mmax, kzs.into(), positions, shared, radial, seed)?;
745        }
746    }
747
748    #[test]
749    fn expansion_pullback_near_coaxial_pair() -> Result<(), TestCaseError> {
750        check_expansion_pullback(
751            2,
752            [0.0, 0.35],
753            [
754                [0.885_096_859_301_242_2, 0.389_709_429_113_160_26, 0.0],
755                [
756                    0.886_584_657_828_228_6,
757                    0.390_127_017_402_346_07,
758                    -0.441_485_675_024_714_25,
759                ],
760                [0.0, 0.0, 0.0],
761            ],
762            false,
763            Radial::Singular,
764            0.0,
765        )
766    }
767
768    /// Expansion values are the pairwise translation coefficients, and both pullbacks
769    /// are the pairwise sums `Σ_ij Re(conj(g_ij) ∂T_ij)` into positions, wavenumbers and
770    /// the sorted axial groups `kzs`, including zero-displacement outgoing self blocks.
771    #[allow(clippy::float_cmp)] // Axial wavenumbers are exact mode labels.
772    fn check_expansion_pullback(
773        mmax: i32,
774        kzs: [f64; 2],
775        [first, second, third]: [[f64; 3]; 3],
776        shared: bool,
777        radial: Radial,
778        seed: f64,
779    ) -> Result<(), TestCaseError> {
780        let modes = |count: usize| -> Vec<(usize, Mode)> {
781            (0..count)
782                .flat_map(|p| {
783                    kzs.into_iter().flat_map(move |kz| {
784                        (-mmax..=mmax).flat_map(move |m| [1, 0].map(|pol| (p, Mode { kz, m, pol })))
785                    })
786                })
787                .collect()
788        };
789        // The first positions coincide: outgoing translations between them vanish.
790        let destination = Basis {
791            modes: modes(2),
792            positions: vec![first, second],
793        };
794        let source = Basis {
795            modes: modes(2),
796            positions: vec![first, third],
797        };
798        let ks = if shared {
799            [Complex::new(1.3, 0.05); 2]
800        } else {
801            [Complex::new(1.3, 0.05), Complex::new(1.5, 0.07)]
802        };
803        let g = patterned(destination.modes.len(), source.modes.len(), seed);
804        let mut expected = DMatrix::zeros(g.nrows(), g.ncols());
805        let mut gradient = (
806            vec![[0.0; 3]; 2],
807            vec![[0.0; 3]; 2],
808            [Complex::default(); 2],
809        );
810        let mut axial = [0.0; 2];
811        // Bounds on the absolute products, before cancellation in either the
812        // complex pairing or the sum. Position derivatives of singular terms
813        // grow like T/rho, so the norm of T cannot bound their rounding error.
814        let mut magnitude = (vec![[0.0; 3]; 2], vec![[0.0; 3]; 2], [0.0; 2]);
815        let mut axial_magnitude = [0.0; 2];
816        for (j, &(q, from)) in source.modes.iter().enumerate() {
817            for (i, &(p, to)) in destination.modes.iter().enumerate() {
818                let position =
819                    std::array::from_fn(|a| destination.positions[p][a] - source.positions[q][a]);
820                let pol = usize::from(from.pol);
821                let jet = cartesian_translation(to, from, ks[pol], position, radial).unwrap();
822                expected[(i, j)] = jet.value;
823                gradient.2[pol] += jet.k.conj() * g[(i, j)];
824                let weight = g[(i, j)].norm();
825                magnitude.2[pol] += weight * jet.k.norm();
826                axial[usize::from(from.kz == kzs[1])] += re_dot([g[(i, j)]], [jet.kz]);
827                axial_magnitude[usize::from(from.kz == kzs[1])] += weight * jet.kz.norm();
828                for axis in 0..3 {
829                    let derivative = re_dot([g[(i, j)]], [jet.position[axis]]);
830                    gradient.0[p][axis] += derivative;
831                    gradient.1[q][axis] -= derivative;
832                    let product = weight * jet.position[axis].norm();
833                    magnitude.0[p][axis] += product;
834                    magnitude.1[q][axis] += product;
835                }
836            }
837        }
838        // Compare two ways of grouping the same products. Each reduction path
839        // has at most N additions; eight extra roundings cover complex products
840        // and evaluation of the magnitude bound. Both paths contribute gamma_n
841        // = n*eps/(1-n*eps). This remains sensitive to cancellation: the scale is
842        // the sum of absolute products, not the possibly vanishing final sum.
843        let n = f64::from(u32::try_from(g.len()).unwrap()) + 8.0;
844        let roundoff = 2.0 * n * f64::EPSILON / (1.0 - n * f64::EPSILON);
845        let (value, residual) = expansion(destination.clone(), source.clone(), ks, radial).unwrap();
846        prop_assert_close!(&value, &expected, 1e-14 * expected.norm());
847        let actual = residual.pullback(&g).unwrap();
848        let (with_axial, actual_axial) = expansion(destination, source, ks, radial)
849            .unwrap()
850            .1
851            .pullback_axial(&g)
852            .unwrap();
853        for actual in [actual, with_axial] {
854            for (actual, expected, magnitude) in [
855                (&actual.destination, &gradient.0, &magnitude.0),
856                (&actual.source, &gradient.1, &magnitude.1),
857            ] {
858                for ((a, e), m) in actual
859                    .iter()
860                    .flatten()
861                    .zip(expected.iter().flatten())
862                    .zip(magnitude.iter().flatten())
863                {
864                    prop_assert_close!(*a, *e, roundoff * m + f64::MIN_POSITIVE);
865                }
866            }
867            for ((a, e), m) in actual.ks.into_iter().zip(gradient.2).zip(magnitude.2) {
868                prop_assert_close!(a, e, roundoff * m + f64::MIN_POSITIVE);
869            }
870        }
871        for ((a, e), m) in actual_axial.into_iter().zip(axial).zip(axial_magnitude) {
872            prop_assert_close!(a, e, roundoff * m + f64::MIN_POSITIVE);
873        }
874        Ok(())
875    }
876}