treams_core/pw/
expand.rs

1//! Regular spherical and cylindrical expansions of plane waves, with analytic pullbacks.
2//!
3//! Upstream: `treams.pw.to_sw`, `treams.pw.to_cw`, and the `Expand` operator from a
4//! plane-wave basis to a spherical or cylindrical basis.
5#![allow(clippy::indexing_slicing)] // Validated bases, label slots and matrix shapes.
6
7use nalgebra::DMatrix;
8
9use super::{field::phase, polarization::Direction};
10use crate::{
11    Complex, Error, Result,
12    basis::{ModeLabel, MultipoleBasis},
13    numerics::{
14        self, Jet, finite,
15        parallel::{try_fill_chunks, try_fold_ordered},
16    },
17    special::{check_pol, polarized_angular},
18    sw::Mode,
19};
20
21/// Normalization and phase `i^l` of the spherical expansion of a plane wave, which
22/// depend on the degree and order alone.
23fn spherical_prefactor(mode: Mode) -> Complex {
24    let (l, m) = (mode.l, mode.m);
25    let normalization = 2.0
26        * (std::f64::consts::PI * f64::from(2 * l + 1) / f64::from(l * (l + 1))).sqrt()
27        * crate::special::factorial_ratio_sqrt(l, m);
28    normalization * Complex::i().powi(l)
29}
30
31/// The coefficient of `mode` in the regular expansion of a unit plane wave of
32/// polarization `pol`, without the position phase: the prefactor times
33/// `exp(-i m phi)` times the polarized angular function of `theta`.
34fn spherical_coefficient<const N: usize>(
35    mode: Mode,
36    prefactor: Complex,
37    vector: [Complex; 3],
38    direction: &Direction<N>,
39    pol: u8,
40    helicity: bool,
41) -> Jet<N> {
42    if helicity && mode.pol != pol {
43        return Jet::default();
44    }
45    let m = mode.m;
46    let axis = direction.transverse.value == Complex::default();
47    let azimuth = if axis {
48        Jet::constant(1.0)
49    } else {
50        (direction.xy[0] - Complex::i() * direction.xy[1]).powi(m)
51    };
52    let cosine = if axis {
53        Jet::constant(if (vector[2] / direction.k.value).re >= 0.0 {
54            1.0
55        } else {
56            -1.0
57        })
58    } else {
59        Jet::variable(vector[2], 2) / direction.k
60    };
61    // Keep the same transverse branch as the Cartesian polarization. Taking a
62    // second principal square root of 1-cos(theta)^2 can flip complex directions.
63    let sine = direction.transverse / direction.k;
64    let angular = polarized_angular(mode.l, m, [cosine, sine], [mode.pol, pol], helicity);
65    prefactor * azimuth * angular
66}
67
68/// Spherical expansion coefficient for a unit-amplitude plane wave.
69///
70/// Upstream: `treams.pw.to_sw`.
71pub fn to_sw(mode: Mode, vector: [Complex; 3], pol: u8, helicity: bool) -> Result<Complex> {
72    mode.validate()?;
73    check_pol(pol)?;
74    let direction = Direction::<0>::new(vector)?;
75    let prefactor = spherical_prefactor(mode);
76    Ok(spherical_coefficient(mode, prefactor, vector, &direction, pol, helicity).value)
77}
78
79/// Regular spherical multipole amplitudes of one plane wave: the single-plane-wave
80/// reference of the plane-wave property tests.
81#[cfg(test)]
82pub(crate) fn reference_spherical_expansion(
83    basis: &crate::sw::Basis,
84    vector: [Complex; 3],
85    pol: u8,
86    helicity: bool,
87) -> Result<Vec<Complex>> {
88    basis.validate()?;
89    check_pol(pol)?;
90    let direction = Direction::<0>::new(vector)?;
91    let labels = AngularLabels::spherical(&basis.modes);
92    let angular = labels.evaluate_spherical(&basis.modes, vector, &direction, pol, helicity);
93    let phases: Vec<_> = basis.positions.iter().map(|&r| phase(vector, r)).collect();
94    Ok(basis
95        .modes
96        .iter()
97        .zip(&labels.slot)
98        .map(|(&(pidx, _), &slot)| phases[pidx] * angular[slot].value)
99        .collect())
100}
101
102/// Whether a plane wave of axial wavenumber `kz` and polarization `pol` couples to the
103/// cylindrical `mode`: equal `pol`, a real `kz`, and `kz` within 16 ulps of the mode's.
104///
105/// The tolerance lets a plane basis whose `kz` values come from arithmetic, such as a
106/// normalization, find its cylindrical modes. It is relative, so a change of length
107/// units cannot merge two labels.
108pub(crate) fn cylindrical_mode_matches(mode: crate::cw::Mode, kz: Complex, pol: u8) -> bool {
109    mode.pol == pol
110        && kz.im == 0.0
111        && (mode.kz - kz.re).abs() <= 16.0 * f64::EPSILON * mode.kz.abs().max(kz.re.abs())
112}
113
114/// The angular factor `((i kx + ky) / sqrt(kx^2 + ky^2))^m = (i exp(-i phi))^m` of the
115/// cylindrical expansion of a plane wave, `i^m` on the z axis.
116fn cylindrical_angular<const N: usize>(m: i32, direction: &Direction<N>) -> Jet<N> {
117    if direction.transverse.value == Complex::default() {
118        Jet::constant(Complex::i().powi(m))
119    } else {
120        (Complex::i() * direction.xy[0] + direction.xy[1]).powi(m)
121    }
122}
123
124/// The coefficient of `mode` in the regular expansion of a unit plane wave of
125/// polarization `pol`, without the position phase; zero unless the labels match.
126fn cylindrical_coefficient<const N: usize>(
127    mode: crate::cw::Mode,
128    vector: [Complex; 3],
129    direction: &Direction<N>,
130    pol: u8,
131) -> Jet<N> {
132    if cylindrical_mode_matches(mode, vector[2], pol) {
133        cylindrical_angular(mode.m, direction)
134    } else {
135        Jet::default()
136    }
137}
138
139/// One plane-to-cylindrical coefficient with exact axial-label matching.
140///
141/// A `kz` that differs from the mode's `kz` in any bit gives zero, as in treams.
142/// [`expansion`] matches the axial labels of a basis within 16 ulps instead (units in
143/// the last place, a relative difference of about 3.6e-15).
144///
145/// Upstream: `treams.pw.to_cw`.
146#[allow(clippy::float_cmp)] // A direct coefficient preserves exact discrete labels.
147pub fn to_cw(mode: crate::cw::Mode, vector: [Complex; 3], pol: u8) -> Result<Complex> {
148    mode.validate()?;
149    if pol > 1 || vector.iter().any(|&v| !finite(v)) || vector[2].im != 0.0 {
150        return Err(Error::InvalidInput(
151            "finite wavevector, real axial component and polarization 0/1 required".into(),
152        ));
153    }
154    if mode.pol != pol || mode.kz != vector[2].re {
155        return Ok(Complex::default());
156    }
157    Ok(cylindrical_angular(mode.m, &Direction::<0>::new(vector)?).value)
158}
159
160/// Regular cylindrical multipole amplitudes of one plane wave: the single-plane-wave
161/// reference of the plane-wave property tests.
162#[cfg(test)]
163pub(crate) fn reference_cylindrical_expansion(
164    basis: &crate::cw::Basis,
165    vector: [Complex; 3],
166    pol: u8,
167) -> Result<Vec<Complex>> {
168    basis.validate()?;
169    let direction = Direction::<0>::new(vector)?;
170    if pol > 1 || vector[2].im != 0.0 {
171        return Err(Error::InvalidInput(
172            "cylindrical expansion requires real axial wavenumber and polarization 0 or 1".into(),
173        ));
174    }
175    Ok(basis
176        .modes
177        .iter()
178        .map(|&(pidx, mode)| {
179            phase(vector, basis.positions[pidx])
180                * cylindrical_coefficient(mode, vector, &direction, pol).value
181        })
182        .collect())
183}
184
185/// Angular plane-wave coefficients depend on a mode's label, not on its position:
186/// modes that repeat at several positions share one evaluation.
187struct AngularLabels {
188    /// Label index of every basis mode.
189    slot: Vec<usize>,
190    /// The first basis mode of every label, with its spherical prefactor.
191    first: Vec<(usize, Complex)>,
192}
193
194impl AngularLabels {
195    fn new<K: std::hash::Hash + Eq>(
196        len: usize,
197        key: impl Fn(usize) -> K,
198        prefactor: impl Fn(usize) -> Complex,
199    ) -> Self {
200        let mut index = std::collections::HashMap::new();
201        let mut first = Vec::new();
202        let slot = (0..len)
203            .map(|i| {
204                *index.entry(key(i)).or_insert_with(|| {
205                    first.push((i, prefactor(i)));
206                    first.len() - 1
207                })
208            })
209            .collect();
210        Self { slot, first }
211    }
212
213    fn spherical(modes: &[(usize, Mode)]) -> Self {
214        Self::new(
215            modes.len(),
216            |i| modes[i].1,
217            |i| spherical_prefactor(modes[i].1),
218        )
219    }
220
221    fn cylindrical(modes: &[(usize, crate::cw::Mode)]) -> Self {
222        Self::new(
223            modes.len(),
224            |i| (modes[i].1.kz.to_bits(), modes[i].1.m, modes[i].1.pol),
225            |_| Complex::default(),
226        )
227    }
228
229    fn of(basis: &MultipoleBasis) -> Self {
230        match basis {
231            MultipoleBasis::Spherical(b) => Self::spherical(&b.modes),
232            MultipoleBasis::Cylindrical(b) => Self::cylindrical(&b.modes),
233        }
234    }
235
236    /// One spherical coefficient per label.
237    fn evaluate_spherical<const N: usize>(
238        &self,
239        modes: &[(usize, Mode)],
240        vector: [Complex; 3],
241        direction: &Direction<N>,
242        pol: u8,
243        helicity: bool,
244    ) -> Vec<Jet<N>> {
245        self.first
246            .iter()
247            .map(|&(i, prefactor)| {
248                spherical_coefficient(modes[i].1, prefactor, vector, direction, pol, helicity)
249            })
250            .collect()
251    }
252
253    /// One angular coefficient per label of `basis`.
254    fn evaluate<const N: usize>(
255        &self,
256        basis: &MultipoleBasis,
257        vector: [Complex; 3],
258        direction: &Direction<N>,
259        pol: u8,
260        helicity: bool,
261    ) -> Vec<Jet<N>> {
262        match basis {
263            MultipoleBasis::Spherical(b) => {
264                self.evaluate_spherical(&b.modes, vector, direction, pol, helicity)
265            }
266            MultipoleBasis::Cylindrical(b) => self
267                .first
268                .iter()
269                .map(|&(i, _)| cylindrical_coefficient(b.modes[i].1, vector, direction, pol))
270                .collect(),
271        }
272    }
273}
274
275/// What [`expansion`] saves for its pullback: its inputs. The pullback recomputes the
276/// coefficients instead of keeping a Jacobian.
277///
278/// The pullback adds the per-mode position gradients in chunks of consecutive plane
279/// modes fixed by the mode count, and the chunk sums in chunk order
280/// (`numerics::parallel::try_fold_ordered`), so the thread count does not change them.
281/// Each wavevector gradient belongs to one plane mode, which writes it in place.
282#[derive(Debug)]
283pub struct ExpansionResidual {
284    pub(super) basis: MultipoleBasis,
285    pub(super) vectors: Vec<[Complex; 3]>,
286    pub(super) polarizations: Vec<u8>,
287    pub(super) helicity: bool,
288    pub(super) fixed_vectors: bool,
289}
290/// Plane-expansion cotangents.
291#[derive(Debug)]
292pub struct ExpansionGradient {
293    /// Multipole position cotangents.
294    pub positions: Vec<[f64; 3]>,
295    /// Full complex plane-wavevector cotangents; zero when the forward holds the
296    /// wavevectors fixed.
297    pub vectors: Vec<[Complex; 3]>,
298}
299/// Expand multiple plane waves in a regular multipole basis: column `j` holds the
300/// amplitudes of plane wave `j` at the basis positions.
301///
302/// Spherical modes use [`to_sw`]; cylindrical modes use the coefficient of [`to_cw`]
303/// but match axial labels within 16 ulps (a relative difference of about 3.6e-15).
304/// With `fixed_vectors`, the pullback holds the wavevectors fixed and returns zero
305/// wavevector gradients.
306///
307/// Upstream: the `Expand` operator from a plane-wave basis.
308pub fn expansion(
309    basis: impl Into<MultipoleBasis>,
310    vectors: Vec<[Complex; 3]>,
311    polarizations: Vec<u8>,
312    helicity: bool,
313    fixed_vectors: bool,
314) -> Result<(DMatrix<Complex>, ExpansionResidual)> {
315    let basis = basis.into();
316    basis.validate()?;
317    if vectors.is_empty()
318        || vectors.len() != polarizations.len()
319        || polarizations.iter().any(|&p| p > 1)
320        || (matches!(basis, MultipoleBasis::Cylindrical(_))
321            && vectors.iter().any(|k| k[2].im != 0.0))
322    {
323        return Err(Error::InvalidInput(
324            "require nonempty plane vectors and matching polarizations 0/1; cylindrical axial labels must be real".into(),
325        ));
326    }
327    let labels = AngularLabels::of(&basis);
328    let mut value = numerics::zeros(basis.len(), vectors.len())?;
329    try_fill_chunks(
330        value.as_mut_slice(),
331        basis.len(),
332        vectors.len() > 1,
333        |j, column| -> Result<()> {
334            let direction = Direction::<0>::new(vectors[j])?;
335            let angular =
336                labels.evaluate(&basis, vectors[j], &direction, polarizations[j], helicity);
337            let phases: Vec<_> = basis
338                .positions()
339                .iter()
340                .map(|&p| phase(vectors[j], p))
341                .collect();
342            for (i, (out, &slot)) in column.iter_mut().zip(&labels.slot).enumerate() {
343                *out = angular[slot].value * phases[basis.position_pol(i).0];
344            }
345            Ok(())
346        },
347    )?;
348    if value.iter().any(|&v| !finite(v)) {
349        return Err(Error::NonFinite("plane expansion overflow".into()));
350    }
351    Ok((
352        value,
353        ExpansionResidual {
354            basis,
355            vectors,
356            polarizations,
357            helicity,
358            fixed_vectors,
359        },
360    ))
361}
362impl ExpansionResidual {
363    /// Multipole output and plane input mode counts.
364    #[must_use]
365    pub fn shape(&self) -> (usize, usize) {
366        (self.basis.len(), self.vectors.len())
367    }
368
369    /// Validate position and wavevector tangents for the recorded operation.
370    pub fn validate_tangents(
371        &self,
372        positions: &[[f64; 3]],
373        vectors: &[[Complex; 3]],
374    ) -> Result<()> {
375        if positions.len() != self.basis.positions().len()
376            || vectors.len() != self.vectors.len()
377            || positions.iter().flatten().any(|r| !r.is_finite())
378            || vectors.iter().flatten().any(|&k| !finite(k))
379        {
380            return Err(Error::InvalidInput(
381                "invalid plane-expansion tangents".into(),
382            ));
383        }
384        Ok(())
385    }
386
387    /// Push a direction through the position phases and angular coefficients.
388    /// Cylindrical axial components remain fixed mode labels, as in the pullback.
389    pub fn pushforward(
390        &self,
391        positions: &[[f64; 3]],
392        vectors: &[[Complex; 3]],
393    ) -> Result<DMatrix<Complex>> {
394        self.validate_tangents(positions, vectors)?;
395        let labels = AngularLabels::of(&self.basis);
396        let axes = if matches!(self.basis, MultipoleBasis::Spherical(_)) {
397            3
398        } else {
399            2
400        };
401        let mut tangent = numerics::zeros(self.basis.len(), self.vectors.len())?;
402        try_fill_chunks(
403            tangent.as_mut_slice(),
404            self.basis.len(),
405            self.vectors.len() > 1,
406            |j, column| -> Result<()> {
407                if self.fixed_vectors || vectors[j][..axes].iter().all(|&v| v == Complex::default())
408                {
409                    self.push_column(
410                        &labels,
411                        j,
412                        &Direction::<0>::new(self.vectors[j])?,
413                        positions,
414                        vectors,
415                        column,
416                    );
417                } else {
418                    self.push_column(
419                        &labels,
420                        j,
421                        &Direction::<3>::new(self.vectors[j])?,
422                        positions,
423                        vectors,
424                        column,
425                    );
426                }
427                Ok(())
428            },
429        )?;
430        if tangent.iter().any(|&v| !finite(v)) {
431            return Err(Error::NonFinite("plane expansion tangent overflow".into()));
432        }
433        Ok(tangent)
434    }
435
436    /// Evaluate and contract the same angular jets used by the reverse contraction.
437    fn push_column<const N: usize>(
438        &self,
439        labels: &AngularLabels,
440        j: usize,
441        direction: &Direction<N>,
442        positions: &[[f64; 3]],
443        vectors: &[[Complex; 3]],
444        column: &mut [Complex],
445    ) {
446        let vector = self.vectors[j];
447        let angular = labels.evaluate(
448            &self.basis,
449            vector,
450            direction,
451            self.polarizations[j],
452            self.helicity,
453        );
454        let axes = if matches!(self.basis, MultipoleBasis::Spherical(_)) {
455            3
456        } else {
457            2
458        };
459        let phases: Vec<_> = self
460            .basis
461            .positions()
462            .iter()
463            .enumerate()
464            .map(|(p, &position)| {
465                let angle: Complex = (0..3).map(|a| vector[a] * positions[p][a]).sum::<Complex>()
466                    + (0..N.min(axes))
467                        .map(|a| vectors[j][a] * position[a])
468                        .sum::<Complex>();
469                (phase(vector, position), Complex::i() * angle)
470            })
471            .collect();
472        for (i, (out, &slot)) in column.iter_mut().zip(&labels.slot).enumerate() {
473            let angular = angular[slot];
474            let (phase, angle) = phases[self.basis.position_pol(i).0];
475            let derivative: Complex = (0..N.min(axes))
476                .map(|a| angular.derivative[a] * vectors[j][a])
477                .sum();
478            *out = phase * (derivative + angular.value * angle);
479        }
480    }
481    /// Differentiate position phases and the full direction-dependent angular coefficient.
482    pub fn pullback(&self, cotangent: &DMatrix<Complex>) -> Result<ExpansionGradient> {
483        if cotangent.shape() != self.shape() || cotangent.iter().any(|&v| !finite(v)) {
484            return Err(Error::InvalidInput(
485                "invalid plane-expansion cotangent".into(),
486            ));
487        }
488        let fixed_vectors = self.fixed_vectors;
489        let labels = AngularLabels::of(&self.basis);
490        // Each plane mode writes its own wavevector gradient in place; the partial sums
491        // carry only the position gradients.
492        let mut vectors = vec![[Complex::default(); 3]; self.vectors.len()];
493        let modes: Vec<_> = self.vectors.iter().zip(&mut vectors).collect();
494        let positions = try_fold_ordered(
495            modes,
496            true,
497            || vec![[0.0; 3]; self.basis.positions().len()],
498            |mut positions, j, (&vector, vector_gradient)| -> Result<_> {
499                // Position gradients need only values, which are identical for every
500                // derivative count; fixed vectors skip the direction derivatives.
501                if fixed_vectors {
502                    let direction = Direction::<0>::new(vector)?;
503                    self.contract(
504                        &labels,
505                        j,
506                        &direction,
507                        cotangent,
508                        &mut positions,
509                        vector_gradient,
510                    );
511                } else {
512                    let direction = Direction::<3>::new(vector)?;
513                    self.contract(
514                        &labels,
515                        j,
516                        &direction,
517                        cotangent,
518                        &mut positions,
519                        vector_gradient,
520                    );
521                }
522                Ok(positions)
523            },
524            |mut total, partial| {
525                for (a, b) in total.iter_mut().flatten().zip(partial.iter().flatten()) {
526                    *a += b;
527                }
528                total
529            },
530        )?;
531        Ok(ExpansionGradient { positions, vectors })
532    }
533
534    /// Add the gradients of column `j` to the position gradients `positions` and, with
535    /// direction derivatives (`N > 0`), to its wavevector gradient `vector_gradient`.
536    fn contract<const N: usize>(
537        &self,
538        labels: &AngularLabels,
539        j: usize,
540        direction: &Direction<N>,
541        cotangent: &DMatrix<Complex>,
542        positions: &mut [[f64; 3]],
543        vector_gradient: &mut [Complex; 3],
544    ) {
545        let vector = self.vectors[j];
546        let angular = labels.evaluate(
547            &self.basis,
548            vector,
549            direction,
550            self.polarizations[j],
551            self.helicity,
552        );
553        let phases: Vec<_> = self
554            .basis
555            .positions()
556            .iter()
557            .map(|&p| phase(vector, p))
558            .collect();
559        // Cylindrical axial components are fixed labels with zero cotangents.
560        let axes = if matches!(self.basis, MultipoleBasis::Spherical(_)) {
561            3
562        } else {
563            2
564        };
565        for (i, &slot) in labels.slot.iter().enumerate() {
566            let p = self.basis.position_pol(i).0;
567            let position = self.basis.positions()[p];
568            let angular = &angular[slot];
569            let phase = phases[p];
570            let value = phase * angular.value;
571            for axis in 0..3 {
572                positions[p][axis] +=
573                    (cotangent[(i, j)].conj() * value * Complex::i() * vector[axis]).re;
574                if N > 0 && axis < axes {
575                    vector_gradient[axis] += cotangent[(i, j)]
576                        * (phase
577                            * (angular.derivative[axis]
578                                + Complex::i() * position[axis] * angular.value))
579                            .conj();
580                }
581            }
582        }
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::expansion;
589    use crate::{
590        Complex, Error, sw,
591        test_support::{assert_same_bits_on_pools, bits, patterned, spherical_basis},
592    };
593
594    /// The overflowing wave of the plane-field tests, expanded about the same points.
595    #[test]
596    fn overflowing_plane_expansions_report_nonfinite() {
597        let k = vec![[0.6.into(), 0.0.into(), Complex::new(0.8, 2.0)]];
598        let basis = |z| spherical_basis(1, [0.0, 0.0, z]);
599        let at = |z| expansion(basis(z), k.clone(), vec![0], true, false);
600        assert!(matches!(at(-400.0), Err(Error::NonFinite(_))));
601        let (_, residual) = at(-350.0).unwrap();
602        let tangent = residual.pushforward(&[[0.0, 0.0, 1e10]], &[[0.0.into(); 3]]);
603        assert!(matches!(tangent, Err(Error::NonFinite(_))));
604    }
605
606    /// The position gradients add in chunks fixed by the plane-mode count: with more
607    /// plane modes than chunks, every gradient repeats bit for bit on every pool size.
608    #[test]
609    fn pullback_does_not_depend_on_the_thread_count() {
610        let modes = sw::modes(3).unwrap();
611        let basis = sw::Basis {
612            modes: (0..2)
613                .flat_map(|p| modes.iter().map(move |&mode| (p, mode)))
614                .collect(),
615            positions: vec![[0.1, -0.2, 0.3], [-0.3, 0.2, -0.1]],
616        };
617        let count = 100_u32;
618        let vectors: Vec<[Complex; 3]> = (0..count)
619            .map(|j| {
620                let t = f64::from(j);
621                let (theta, phi) = (1.55 + 1.3 * (0.61 * t).sin(), 2.3 * t);
622                [
623                    theta.sin() * phi.cos(),
624                    theta.sin() * phi.sin(),
625                    theta.cos(),
626                ]
627                .map(|x| Complex::new(1.2 * x, 0.0))
628            })
629            .collect();
630        let polarizations: Vec<u8> = (0..count).map(|j| u8::from(j % 2 == 0)).collect();
631        let g = patterned(basis.modes.len(), vectors.len(), 0.5);
632        assert_same_bits_on_pools(|| {
633            let (_, residual) = expansion(
634                basis.clone(),
635                vectors.clone(),
636                polarizations.clone(),
637                true,
638                false,
639            )
640            .unwrap();
641            let g = residual.pullback(&g).unwrap();
642            bits(&[&g.positions, &g.vectors])
643        });
644    }
645}