treams_core/
fields.rs

1//! Cartesian spherical and cylindrical vector waves, field sums and field operators.
2//!
3//! [`field`] sums a mode expansion at sample points, and [`operator`] builds the
4//! matrix that maps mode coefficients to those fields; both have analytic gradients.
5//!
6//! The waves use Cartesian components built from solid harmonics, so they stay finite
7//! on the z axis and at the expansion centre, where polar coordinates are singular.
8//!
9//! Upstream: `treams.efield` and the vector waves `treams.special.vsw_*` and `vcw_*`.
10#![allow(clippy::indexing_slicing)] // Fixed Cartesian vectors and Hessians.
11
12use std::{
13    array::from_fn,
14    f64::consts::{FRAC_1_SQRT_2, PI},
15};
16
17use crate::{
18    Complex, Error, Result,
19    basis::{ModeLabel, MultipoleBasis, validate_wavenumbers},
20    cw,
21    numerics::{
22        self, Jet, finite,
23        parallel::{try_fill_chunks, try_fold_ordered, try_map},
24    },
25    saved::{self, Reader, SavedState, Writer},
26    special::{
27        Radial, SERIES_RADIUS, Solid, SolidTable, bessel, helicity_sign, polarized_wave, solid,
28        spherical_hankels,
29    },
30    sw::Mode,
31};
32
33/// A spherical or cylindrical electric vector wave with its analytic derivatives with
34/// respect to the evaluation point and the medium wavenumber `k`.
35///
36/// Evaluations without derivatives leave `position` and `k` zero.
37#[derive(Clone, Copy, Debug, Default)]
38pub struct VectorWave {
39    /// Cartesian electric field.
40    pub value: [Complex; 3],
41    /// `position[component][axis]` is the field's Cartesian Jacobian.
42    pub position: [[Complex; 3]; 3],
43    /// Derivative of each component with respect to the complex wavenumber `k`.
44    pub k: [Complex; 3],
45}
46
47/// Cartesian cylindrical vector wave and its point and wavenumber derivatives.
48/// Adjacent scalar harmonics remove all polar-coordinate divisions at the axis.
49///
50/// Upstream: `treams.special.vcw_rA`, `vcw_A`, `vcw_rM`, `vcw_M`, `vcw_rN` and `vcw_N`,
51/// converted to Cartesian components as by `treams.special.vcyl2car`.
52pub fn cylindrical_wave(
53    mode: cw::Mode,
54    k: Complex,
55    position: [f64; 3],
56    helicity: bool,
57    radial: Radial,
58) -> Result<VectorWave> {
59    let [m, n] = cylindrical_components::<SPATIAL_AND_K>(mode, k, position, radial)?;
60    Ok(pack_cylindrical(polarized_wave(m, n, mode.pol, helicity)))
61}
62
63/// The value, point derivatives (jet variables 0 to 2) and wavenumber derivative
64/// (variable 3) of a cylindrical wave; jets without derivatives give zeros.
65fn pack_cylindrical<const N: usize>(fields: [Jet<N>; 3]) -> VectorWave {
66    VectorWave {
67        value: fields.map(|v| v.value),
68        position: fields.map(|v| from_fn(|i| v.derivative.get(i).copied().unwrap_or_default())),
69        k: fields.map(|v| v.derivative.get(3).copied().unwrap_or_default()),
70    }
71}
72
73/// The M and N components of a cylindrical wave, which do not depend on its
74/// polarization. Jet variables are the position (0..3), `k` (3) and `kz` (4).
75fn cylindrical_components<const N: usize>(
76    mode: cw::Mode,
77    k: Complex,
78    position: [f64; 3],
79    radial: Radial,
80) -> Result<[[Jet<N>; 3]; 2]> {
81    mode.validate()?;
82    if !finite(k) || k == Complex::default() || position.iter().any(|x| !x.is_finite()) {
83        return Err(Error::InvalidInput(
84            "require a finite nonzero wavenumber and finite field position".into(),
85        ));
86    }
87    let r: [Jet<N>; 3] = from_fn(|i| Jet::variable(position[i], i));
88    let k = Jet::<N>::variable(k, 3);
89    let kz = Jet::<N>::variable(mode.kz, 4);
90    let transverse = cw::transverse(k, kz);
91    if transverse.value == Complex::default() {
92        return Err(Error::InvalidInput(
93            "cylindrical cutoff requires a limiting formulation".into(),
94        ));
95    }
96    let rho = position[0].hypot(position[1]);
97    let [lower, center, upper] =
98        if radial == Radial::Regular && (transverse.value * rho).norm() < SERIES_RADIUS {
99            [mode.m - 1, mode.m, mode.m + 1].map(|m| cw::regular_harmonic(m, transverse, r, kz))
100        } else {
101            if rho == 0.0 {
102                return Err(Error::NonFinite(
103                    "outgoing cylindrical wave is singular on the axis".into(),
104                ));
105            }
106            let radius = (r[0] * r[0] + r[1] * r[1]).sqrt();
107            let argument = transverse * radius;
108            let radial = crate::special::cylindrical_radial(mode.m, argument.value, radial)?;
109            let value = argument.chain(radial.value, radial.first);
110            let derivative = argument.chain(radial.first, radial.second);
111            let azimuth = (r[0] + Complex::i() * r[1]) / radius;
112            let phase = (Complex::i() * kz * r[2]).exp() * azimuth.powi(mode.m);
113            let adjacent = f64::from(mode.m) * value / argument;
114            [
115                (adjacent + derivative) * phase / azimuth,
116                value * phase,
117                (adjacent - derivative) * phase * azimuth,
118            ]
119        };
120    let m = [
121        0.5 * Complex::i() * (lower + upper),
122        0.5 * (upper - lower),
123        Jet::default(),
124    ];
125    let n = [
126        0.5 * Complex::i() * kz / k * (lower - upper),
127        -0.5 * kz / k * (lower + upper),
128        transverse / k * center,
129    ];
130    Ok([m, n])
131}
132
133fn cross(a: [Complex; 3], b: [Complex; 3]) -> [Complex; 3] {
134    [
135        a[1] * b[2] - a[2] * b[1],
136        a[2] * b[0] - a[0] * b[2],
137        a[0] * b[1] - a[1] * b[0],
138    ]
139}
140
141// z_l(kr)/r^l has a finite regular limit. Evaluating its series directly
142// avoids divisions by zero and cancellation in the near-origin field gradients.
143// Elsewhere only the value of z_l is needed: one complex Bessel evaluation.
144fn scaled_radial(l: u32, k: Complex, r: f64, radial: Radial) -> Result<Complex> {
145    let x = k * r;
146    if radial == Radial::Regular && x.norm() < SERIES_RADIUS {
147        let mut denominator = 1.0;
148        for i in 0..=l {
149            denominator *= f64::from(2 * i + 1);
150        }
151        let mut coefficient = k.powu(l) / denominator;
152        let mut sum = Complex::default();
153        // At |x| < 1/2, term q+1 is at most 1/[8(q+1)(2q+3)]
154        // times term q. The tail after these 12 terms is below 4e-33
155        // of the leading term even for l=0, well below f64 rounding.
156        for q in 0..12 {
157            sum += coefficient * x.powu(2 * q);
158            coefficient /= -2.0 * f64::from(q + 1) * f64::from(2 * l + 2 * q + 3);
159        }
160        return Ok(sum);
161    }
162    if x.norm_sqr() == 0.0 {
163        return Err(Error::InvalidInput(
164            "outgoing spherical waves are singular at zero".into(),
165        ));
166    }
167    Ok(bessel(f64::from(l), x, radial.into(), true, 0)? / r.powf(f64::from(l)))
168}
169
170/// A common length unit bounds solid-harmonic coordinates by one and bounds the
171/// wavenumber in the regular origin series. Hold this scale fixed when taking
172/// derivatives: the wave is unchanged by any common change of length units.
173fn spherical_arguments(k: Complex, position: [f64; 3], radial: Radial) -> (Complex, [f64; 3], f64) {
174    let mut scale = position.iter().map(|x| x.abs()).fold(0.0, f64::max);
175    // Only regular waves need an origin series. Singular waves keep unit-sized
176    // coordinates even at small kr, so dividing a large Hankel value by r^l
177    // cannot overflow before multiplication by its solid harmonic.
178    if radial == Radial::Regular || scale == 0.0 {
179        scale = scale.max(1.0 / k.re.abs().max(k.im.abs()));
180    }
181    (k * scale, position.map(|x| x / scale), scale)
182}
183
184/// Evaluate a vector spherical wave in treams normalization.
185/// Parity polarization 0 is M, 1 is N; helicity is `(N + (2pol-1) M)/sqrt(2)`.
186///
187/// Upstream: `treams.special.vsw_rA`, `vsw_A`, `vsw_rM`, `vsw_M`, `vsw_rN` and `vsw_N`,
188/// converted to Cartesian components as by `treams.special.vsph2car`.
189pub fn spherical_wave(
190    mode: Mode,
191    k: Complex,
192    position: [f64; 3],
193    helicity: bool,
194    radial: Radial,
195) -> Result<VectorWave> {
196    spherical_wave_impl::<true>(mode, k, position, helicity, radial)
197}
198
199/// [`spherical_wave`] with the point and wavenumber derivatives only if `DERIVATIVES`.
200fn spherical_wave_impl<const DERIVATIVES: bool>(
201    mode: Mode,
202    k: Complex,
203    position: [f64; 3],
204    helicity: bool,
205    radial: Radial,
206) -> Result<VectorWave> {
207    mode.validate()?;
208    if !finite(k) || k.norm_sqr() == 0.0 || position.iter().any(|v| !v.is_finite()) {
209        return Err(Error::InvalidInput(
210            "require a finite nonzero wavenumber and finite position".into(),
211        ));
212    }
213    let (k, position, scale) = spherical_arguments(k, position, radial);
214    let r2 = position.iter().map(|v| v * v).sum::<f64>();
215    let l = mode.l.unsigned_abs();
216    let radials = [
217        scaled_radial(l, k, r2.sqrt(), radial)?,
218        scaled_radial(l + 1, k, r2.sqrt(), radial)?,
219        if DERIVATIVES {
220            scaled_radial(l + 2, k, r2.sqrt(), radial)?
221        } else {
222            Complex::default()
223        },
224    ];
225    let solid = solid::<DERIVATIVES>(mode.l, mode.m, position);
226    let parts = spherical_parts::<DERIVATIVES>(mode.l, k, position, r2, &solid, radials, scale);
227    Ok(combine::<DERIVATIVES>(
228        &parts,
229        normalization(mode.l, mode.m),
230        weights(mode.pol, helicity),
231    ))
232}
233
234/// The unnormalized parts `[n, m]` of a spherical wave of degree `l`, which every
235/// polarization combines with its own [`weights`].
236///
237/// With the scaled radial function `c = z_l(kr) / r^l` and the solid harmonic
238/// `S = r^l P_l^m(cos theta) e^(i m phi)`, the scalar wave is `psi = c S`. Then `m = r x grad psi`,
239/// the negative of the unnormalized `M = curl(r psi) = grad psi x r`, and `n` is the
240/// unnormalized `N`. The inputs are the scaled radial functions of orders `l`, `l + 1` and
241/// `l + 2`; the last one enters only the derivatives. `scale` returns the derivatives
242/// from the internal length unit to the caller's unit.
243fn spherical_parts<const DERIVATIVES: bool>(
244    l: i32,
245    k: Complex,
246    position: [f64; 3],
247    r2: f64,
248    solid: &Solid,
249    [c, e, f]: [Complex; 3],
250    scale: f64,
251) -> [VectorWave; 2] {
252    let degree = f64::from(l);
253    let d = (degree + 1.0) / k * c - r2 * e;
254    let vector = position.map(|v| Complex::new(v, 0.0));
255    let rotation = cross(vector, solid.gradient);
256    let mut n = VectorWave {
257        value: from_fn(|i| d * solid.gradient[i] + degree * e * solid.value * position[i]),
258        ..VectorWave::default()
259    };
260    let mut m = VectorWave {
261        value: from_fn(|i| c * rotation[i]),
262        ..VectorWave::default()
263    };
264    if DERIVATIVES {
265        let ck = degree / k * c - r2 * e;
266        let ek = (degree + 1.0) / k * e - r2 * f;
267        let dk = (degree + 1.0) / k * ck - (degree + 1.0) / k.powu(2) * c - r2 * ek;
268        n.k =
269            from_fn(|i| scale * (dk * solid.gradient[i] + degree * ek * solid.value * position[i]));
270        m.k = from_fn(|i| scale * ck * rotation[i]);
271        for axis in 0..3 {
272            let ca = -k * e * position[axis];
273            let ea = -k * f * position[axis];
274            let da = (degree + 1.0) / k * ca - 2.0 * position[axis] * e - r2 * ea;
275            let mut unit = [Complex::default(); 3];
276            unit[axis] = Complex::new(1.0, 0.0);
277            let first = cross(unit, solid.gradient);
278            let second = cross(vector, from_fn(|i| solid.hessian[i][axis]));
279            for i in 0..3 {
280                n.position[i][axis] = (da * solid.gradient[i]
281                    + d * solid.hessian[i][axis]
282                    + degree
283                        * (ea * solid.value * position[i]
284                            + e * solid.gradient[axis] * position[i]
285                            + if i == axis {
286                                e * solid.value
287                            } else {
288                                Complex::default()
289                            }))
290                    / scale;
291                m.position[i][axis] = (ca * rotation[i] + c * (first[i] + second[i])) / scale;
292            }
293        }
294    }
295    [n, m]
296}
297
298/// Normalization `i sqrt((2l + 1) / (4 pi l (l + 1))) sqrt((l - m)! / (l + m)!)` of the
299/// spherical wave `(l, m)`; it depends on neither point nor `k`.
300fn normalization(l: i32, m: i32) -> Complex {
301    let degree = f64::from(l);
302    Complex::i()
303        * ((2.0 * degree + 1.0) / (4.0 * PI * degree * (degree + 1.0))).sqrt()
304        * crate::special::factorial_ratio_sqrt(l, m)
305}
306
307/// Weights `[weight_n, weight_m]` of the N and M parts of polarization `pol`: parity
308/// polarization 1 is N and 0 is M; helicity polarization `pol` is
309/// `(N + (2 pol - 1) M) / sqrt(2)`, as in `treams.special.vsw_A`.
310fn weights(pol: u8, helicity: bool) -> [f64; 2] {
311    if helicity {
312        [FRAC_1_SQRT_2, helicity_sign(pol) * FRAC_1_SQRT_2]
313    } else if pol == 1 {
314        [1.0, 0.0]
315    } else {
316        [0.0, 1.0]
317    }
318}
319
320/// The wave `normalization (weight_n N + weight_m M)` of the value and requested
321/// derivatives. The part `m` from [`spherical_parts`] is `-M`, so it enters with a
322/// minus sign.
323fn combine<const DERIVATIVES: bool>(
324    [n, m]: &[VectorWave; 2],
325    normalization: Complex,
326    [weight_n, weight_m]: [f64; 2],
327) -> VectorWave {
328    let combine = |n: Complex, m: Complex| normalization * (weight_n * n - weight_m * m);
329    let mut wave = VectorWave {
330        value: from_fn(|i| combine(n.value[i], m.value[i])),
331        ..VectorWave::default()
332    };
333    if DERIVATIVES {
334        wave.k = from_fn(|i| combine(n.k[i], m.k[i]));
335        wave.position = from_fn(|i| from_fn(|a| combine(n.position[i][a], m.position[i][a])));
336    }
337    wave
338}
339
340/// What [`field`] saves for its pullback: the wave set, the points and the
341/// coefficients. Its size grows with the samples plus the modes, not their product.
342#[derive(Debug)]
343pub struct FieldResidual {
344    waves: WaveSet,
345    points: Vec<[f64; 3]>,
346    coefficients: Vec<Complex>,
347}
348
349/// Derivative level of a wave evaluation: values only. Each level is the jet size of
350/// the cylindrical path; the spherical path evaluates derivatives above this level.
351pub(crate) const VALUES: usize = 0;
352/// Derivative level: values, point derivatives (jet variables 0 to 2) and the
353/// wavenumber derivative (variable 3).
354pub(crate) const SPATIAL_AND_K: usize = 4;
355/// Derivative level: [`SPATIAL_AND_K`] plus the axial-wavenumber derivative of
356/// cylindrical waves (variable 4).
357pub(crate) const WITH_AXIAL: usize = 5;
358
359/// Evaluates the waves of a multipole basis at any point, for one pair of medium
360/// wavenumbers, polarization convention and radial kind.
361#[derive(Debug)]
362pub(crate) struct WaveSet {
363    basis: MultipoleBasis,
364    ks: [Complex; 2],
365    helicity: bool,
366    radial: Radial,
367    /// Point-independent normalization of each spherical mode; empty for cylinders.
368    normalizations: Vec<Complex>,
369    /// Scaled radial functions cached per position and wavenumber: the largest
370    /// spherical degree plus three, or zero for cylinders.
371    radial_table_len: usize,
372    /// Dense harmonic recurrences per (position, wavenumber). Sparse mode sets and
373    /// interleaved centres use single harmonics instead of repeatedly filling tables.
374    harmonic_tables: Vec<Option<SolidTable>>,
375    /// Degree ranges whose outgoing radial values share one Hankel sequence, selected
376    /// independently of mode order so column permutations retain the same values.
377    radial_ranges: Vec<Option<(u32, u32)>>,
378}
379
380/// Work shared by the waves of one sample point: scaled radial functions per
381/// (position, wavenumber, order), and the last spherical parts or cylindrical
382/// components, which the adjacent polarization of the same mode reuses.
383/// Every entry is keyed by all of its inputs, so any mode order is
384/// evaluated exactly as without the cache. `N` is the derivative level
385/// ([`VALUES`], [`SPATIAL_AND_K`] or [`WITH_AXIAL`]).
386pub(crate) struct SampleCache<const N: usize> {
387    radials: Vec<Option<Complex>>,
388    /// Only the current centre for each wavenumber, bounding table storage by lmax
389    /// rather than the number of centres. Each entry packs the value and gradient,
390    /// followed by the row-major Hessian only above `VALUES`.
391    harmonics: [Option<(usize, Vec<Complex>)>; 2],
392    spherical: Option<(PartsKey, [VectorWave; 2])>,
393    cylindrical: Option<(ComponentsKey, [[Jet<N>; 3]; 2])>,
394}
395
396/// Position index, wavenumber index, degree and order of spherical parts.
397type PartsKey = (usize, usize, i32, i32);
398/// Position index, wavenumber index, order and axial-wavenumber bits of cylindrical components.
399type ComponentsKey = (usize, usize, i32, u64);
400
401/// The waves of `basis` and the sample points, checked in this order: basis, points,
402/// wavenumbers.
403fn sampled_waves(
404    basis: MultipoleBasis,
405    points: Vec<[f64; 3]>,
406    ks: [Complex; 2],
407    helicity: bool,
408    radial: Radial,
409) -> Result<(WaveSet, Vec<[f64; 3]>)> {
410    basis.validate()?;
411    validate_points(&points)?;
412    Ok((
413        WaveSet::of_valid_basis(basis, ks, helicity, radial)?,
414        points,
415    ))
416}
417
418fn validate_points(points: &[[f64; 3]]) -> Result<()> {
419    if points.iter().flatten().any(|v| !v.is_finite()) {
420        return Err(Error::InvalidInput("field points must be finite".into()));
421    }
422    Ok(())
423}
424
425fn validate_coefficients(basis: &MultipoleBasis, coefficients: &[Complex]) -> Result<()> {
426    if coefficients.len() != basis.len() || coefficients.iter().any(|&v| !finite(v)) {
427        return Err(Error::InvalidInput(
428            "require one finite field coefficient per mode".into(),
429        ));
430    }
431    Ok(())
432}
433
434impl WaveSet {
435    fn write_state(&self, writer: &mut Writer) {
436        saved::write_basis(writer, &self.basis);
437        for k in self.ks {
438            writer.complex(k);
439        }
440        writer.byte(u8::from(self.helicity));
441        saved::write_radial(writer, self.radial);
442    }
443
444    /// Rebuild only basis-dependent normalization and recurrence plans. Sampling
445    /// values and derivatives remains the responsibility of the later operation.
446    fn read_state(reader: &mut Reader<'_>) -> Result<Self> {
447        let basis = saved::read_basis(reader)?;
448        let ks = [reader.complex()?, reader.complex()?];
449        let helicity = match reader.byte()? {
450            0 => false,
451            1 => true,
452            _ => return Err(saved::invalid()),
453        };
454        let radial = saved::read_radial(reader)?;
455        Self::new(basis, ks, helicity, radial)
456    }
457
458    pub(crate) fn new(
459        basis: MultipoleBasis,
460        ks: [Complex; 2],
461        helicity: bool,
462        radial: Radial,
463    ) -> Result<Self> {
464        basis.validate()?;
465        Self::of_valid_basis(basis, ks, helicity, radial)
466    }
467
468    /// [`Self::new`] for a basis that has passed `validate`.
469    fn of_valid_basis(
470        basis: MultipoleBasis,
471        ks: [Complex; 2],
472        helicity: bool,
473        radial: Radial,
474    ) -> Result<Self> {
475        validate_wavenumbers(ks, helicity, false)?;
476        let (harmonic_tables, radial_ranges) = match &basis {
477            MultipoleBasis::Spherical(basis) => {
478                let mut labels = vec![Vec::new(); 2 * basis.positions.len()];
479                let mut runs = vec![0; basis.positions.len()];
480                let mut previous = None;
481                for &(pidx, mode) in &basis.modes {
482                    if previous != Some(pidx) {
483                        runs[pidx] += 1;
484                        previous = Some(pidx);
485                    }
486                    let wavenumber = if ks[0] == ks[1] {
487                        0
488                    } else {
489                        usize::from(mode.pol)
490                    };
491                    labels[2 * pidx + wavenumber].push((mode.l, mode.m));
492                }
493                labels
494                    .into_iter()
495                    .enumerate()
496                    .map(|(key, mut labels)| {
497                        labels.sort_unstable();
498                        labels.dedup();
499                        let order = labels.last().map_or(0, |&(l, _)| l);
500                        // A scalar harmonic repeats l recurrence steps. Fill the
501                        // complete table only when those steps exceed twice its
502                        // size, so sparse custom bases retain their scalar cost.
503                        let work: i32 = labels.iter().map(|&(l, _)| l).sum();
504                        let dense = work >= 2 * (order + 1).pow(2);
505                        let first = labels.first().map_or(0, |&(l, _)| l);
506                        // Every degree needs its next radial value too. Gaps of at
507                        // most two therefore leave no unused entries in a sequence.
508                        let radial_sequence = !labels.is_empty()
509                            && labels.windows(2).all(|pair| pair[1].0 <= pair[0].0 + 2);
510                        (
511                            (runs[key / 2] == 1 && dense).then(|| SolidTable::new(order)),
512                            radial_sequence.then_some((first.unsigned_abs(), order.unsigned_abs())),
513                        )
514                    })
515                    .unzip()
516            }
517            MultipoleBasis::Cylindrical(_) => (Vec::new(), Vec::new()),
518        };
519        let (normalizations, radial_table_len) = match &basis {
520            MultipoleBasis::Spherical(b) => (
521                b.modes
522                    .iter()
523                    .map(|&(_, mode)| normalization(mode.l, mode.m))
524                    .collect(),
525                b.modes
526                    .iter()
527                    .map(|&(_, mode)| mode.l.unsigned_abs() as usize + 3)
528                    .max()
529                    .unwrap_or_default(),
530            ),
531            MultipoleBasis::Cylindrical(_) => (Vec::new(), 0),
532        };
533        Ok(Self {
534            basis,
535            ks,
536            helicity,
537            radial,
538            normalizations,
539            radial_table_len,
540            harmonic_tables,
541            radial_ranges,
542        })
543    }
544
545    pub(crate) fn cache<const N: usize>(&self) -> SampleCache<N> {
546        SampleCache {
547            radials: vec![None; 2 * self.basis.positions().len() * self.radial_table_len],
548            harmonics: [None, None],
549            spherical: None,
550            cylindrical: None,
551        }
552    }
553
554    /// Index of the wavenumber of polarization `pol`; equal wavenumbers share one.
555    fn wavenumber(&self, pol: u8) -> usize {
556        if self.ks[0] == self.ks[1] {
557            0
558        } else {
559            usize::from(pol)
560        }
561    }
562
563    /// Wave `i` at `point` and, for cylindrical waves at level [`WITH_AXIAL`], its
564    /// axial-wavenumber derivative. Derivatives are evaluated only above [`VALUES`].
565    pub(crate) fn wave<const N: usize>(
566        &self,
567        i: usize,
568        point: [f64; 3],
569        cache: &mut SampleCache<N>,
570    ) -> Result<(VectorWave, [Complex; 3])> {
571        match &self.basis {
572            MultipoleBasis::Spherical(basis) => {
573                let (pidx, mode) = basis.modes[i];
574                let wavenumber = self.wavenumber(mode.pol);
575                let key = (pidx, wavenumber, mode.l, mode.m);
576                let parts = match cache.spherical {
577                    Some((cached, parts)) if cached == key => parts,
578                    _ => {
579                        let parts = self.cached_parts(pidx, wavenumber, mode, point, cache)?;
580                        cache.spherical = Some((key, parts));
581                        parts
582                    }
583                };
584                let normalization = self.normalizations[i];
585                let weights = weights(mode.pol, self.helicity);
586                let wave = if N > VALUES {
587                    combine::<true>(&parts, normalization, weights)
588                } else {
589                    combine::<false>(&parts, normalization, weights)
590                };
591                Ok((wave, [Complex::default(); 3]))
592            }
593            MultipoleBasis::Cylindrical(basis) => {
594                let (pidx, mode) = basis.modes[i];
595                let wavenumber = self.wavenumber(mode.pol);
596                let key = (pidx, wavenumber, mode.m, mode.kz.to_bits());
597                let components = match cache.cylindrical {
598                    Some((cached, components)) if cached == key => components,
599                    _ => {
600                        let position = from_fn(|a| point[a] - basis.positions[pidx][a]);
601                        let k = self.ks[wavenumber];
602                        let components = cylindrical_components(mode, k, position, self.radial)?;
603                        cache.cylindrical = Some((key, components));
604                        components
605                    }
606                };
607                let [m, n] = components;
608                let fields = polarized_wave(m, n, mode.pol, self.helicity);
609                let axial = fields.map(|v| v.derivative.get(4).copied().unwrap_or_default());
610                Ok((pack_cylindrical(fields), axial))
611            }
612        }
613    }
614
615    /// The spherical parts of `mode` at `point` from the cached radial functions and
616    /// a scaled solid harmonic. It rejects what [`spherical_wave`] rejects: a wavenumber whose
617    /// square underflows and a relative position that overflows.
618    fn cached_parts<const N: usize>(
619        &self,
620        pidx: usize,
621        wavenumber: usize,
622        mode: Mode,
623        point: [f64; 3],
624        cache: &mut SampleCache<N>,
625    ) -> Result<[VectorWave; 2]> {
626        let position: [f64; 3] = from_fn(|a| point[a] - self.basis.positions()[pidx][a]);
627        let k = self.ks[wavenumber];
628        if k.norm_sqr() == 0.0 || position.iter().any(|v| !v.is_finite()) {
629            return Err(Error::InvalidInput(
630                "require a finite nonzero wavenumber and finite position".into(),
631            ));
632        }
633        let (k, position, scale) = spherical_arguments(k, position, self.radial);
634        let r2 = position.iter().map(|v| v * v).sum::<f64>();
635        let key = 2 * pidx + wavenumber;
636        let table = key * self.radial_table_len;
637        if self.radial == Radial::Singular
638            && let Some((first, last)) = self.radial_ranges[key]
639            && cache.radials[table + first as usize].is_none()
640        {
641            let radius = r2.sqrt();
642            let argument = k * radius;
643            // Outside the sequence domain, the scalar path below retains its
644            // existing checks for non-finite arguments and underflowed kr.
645            if finite(argument) && argument.norm_sqr() != 0.0 {
646                let last = last + if N > VALUES { 2 } else { 1 };
647                for (degree, value) in (first..=last).zip(spherical_hankels(first, last, argument)?)
648                {
649                    if !finite(value) {
650                        return Err(Error::SpecialFunction("non-finite Bessel result".into()));
651                    }
652                    cache.radials[table + degree as usize] =
653                        Some(value / radius.powf(f64::from(degree)));
654                }
655            }
656        }
657        let mut radial = |order: u32| -> Result<Complex> {
658            let entry = &mut cache.radials[table + order as usize];
659            if let Some(value) = *entry {
660                return Ok(value);
661            }
662            let value = scaled_radial(order, k, r2.sqrt(), self.radial)?;
663            *entry = Some(value);
664            Ok(value)
665        };
666        let l = mode.l.unsigned_abs();
667        let radials = [
668            radial(l)?,
669            radial(l + 1)?,
670            if N > VALUES {
671                radial(l + 2)?
672            } else {
673                Complex::default()
674            },
675        ];
676        let solid = if let Some(table) = &self.harmonic_tables[key] {
677            let stride = if N > VALUES { 13 } else { 4 };
678            let slot = &mut cache.harmonics[wavenumber];
679            if slot.as_ref().is_some_and(|&(cached, _)| cached != pidx) {
680                *slot = None;
681            }
682            let (_, entries) = slot.get_or_insert_with(|| {
683                let mut entries = vec![Complex::default(); stride * table.len()];
684                let mut emit = |index, _, solid: Solid| {
685                    let start = stride * index;
686                    entries[start] = solid.value;
687                    entries[start + 1..start + 4].copy_from_slice(&solid.gradient);
688                    if N > VALUES {
689                        entries[start + 4..start + 13]
690                            .copy_from_slice(solid.hessian.as_flattened());
691                    }
692                };
693                if N > VALUES {
694                    table.visit_solid::<true>(position, &mut emit);
695                } else {
696                    table.visit_solid::<false>(position, &mut emit);
697                }
698                (pidx, entries)
699            });
700            let start =
701                stride * usize::try_from(mode.l * mode.l + mode.l + mode.m).unwrap_or_default();
702            Solid {
703                value: entries[start],
704                gradient: from_fn(|axis| entries[start + 1 + axis]),
705                hessian: if N > VALUES {
706                    from_fn(|i| from_fn(|j| entries[start + 4 + 3 * i + j]))
707                } else {
708                    [[Complex::default(); 3]; 3]
709                },
710            }
711        } else if N > VALUES {
712            solid::<true>(mode.l, mode.m, position)
713        } else {
714            solid::<false>(mode.l, mode.m, position)
715        };
716        Ok(if N > VALUES {
717            spherical_parts::<true>(mode.l, k, position, r2, &solid, radials, scale)
718        } else {
719            spherical_parts::<false>(mode.l, k, position, r2, &solid, radials, scale)
720        })
721    }
722
723    /// Sample an operator with shared radial and harmonic work per point. Both
724    /// the primal and its pushforward write directly into column-major storage.
725    fn sampled_operator<const N: usize>(
726        &self,
727        points: &[[f64; 3]],
728        value: impl Fn(usize, usize, VectorWave, [Complex; 3]) -> [Complex; 3] + Sync,
729    ) -> Result<nalgebra::DMatrix<Complex>> {
730        let samples = points.len();
731        let mut output = numerics::zeros(3 * samples, self.basis.len())?;
732        if samples == 0 {
733            return Ok(output);
734        }
735        // Each task evaluates all modes at consecutive samples, preserving the
736        // radial cache while writing the corresponding rows of every column.
737        let block = samples
738            .div_ceil(crate::threads::current_num_threads().saturating_mul(8))
739            .clamp(1, 64);
740        let mut blocks: Vec<Vec<&mut [Complex]>> = (0..samples.div_ceil(block))
741            .map(|_| {
742                let mut columns = Vec::new();
743                numerics::reserve(&mut columns, self.basis.len()).map(|()| columns)
744            })
745            .collect::<Result<_>>()?;
746        for column in output.as_mut_slice().chunks_exact_mut(3 * samples) {
747            for (rows, columns) in column.chunks_mut(3 * block).zip(&mut blocks) {
748                columns.push(rows);
749            }
750        }
751        try_fill_chunks(
752            &mut blocks,
753            1,
754            samples > block,
755            |index, chunk| -> Result<()> {
756                let columns = &mut chunk[0];
757                for (sample, &point) in points.iter().skip(index * block).take(block).enumerate() {
758                    let mut cache = self.cache::<N>();
759                    for (mode, column) in columns.iter_mut().enumerate() {
760                        let (wave, axial) = self.wave(mode, point, &mut cache)?;
761                        let result = value(index * block + sample, mode, wave, axial);
762                        column[3 * sample..3 * sample + 3].copy_from_slice(&result);
763                    }
764                }
765                Ok(())
766            },
767        )?;
768        Ok(output)
769    }
770
771    /// The pullback over all `points`; level [`WITH_AXIAL`] adds per-mode axial
772    /// gradients.
773    fn pullback<const N: usize>(
774        &self,
775        points: &[[f64; 3]],
776        coefficients: Option<&[Complex]>,
777        cotangent: impl Fn(usize, usize) -> [Complex; 3] + Sync,
778    ) -> Result<(FieldGradient, Vec<f64>)> {
779        let with_axial = N == WITH_AXIAL;
780        if with_axial && matches!(self.basis, MultipoleBasis::Spherical(_)) {
781            return Err(Error::InvalidInput(
782                "axial field derivatives require a cylindrical basis".into(),
783            ));
784        }
785        let zero = || Accumulator {
786            gradient: FieldGradient {
787                coefficients: vec![Complex::default(); coefficients.map_or(0, <[Complex]>::len)],
788                points: Vec::new(),
789                positions: vec![[0.0; 3]; self.basis.positions().len()],
790                ks: [Complex::default(); 2],
791            },
792            axial: vec![0.0; if with_axial { self.basis.len() } else { 0 }],
793        };
794        let mut point_gradients = vec![[0.0; 3]; points.len()];
795        let samples: Vec<_> = point_gradients.iter_mut().zip(points).collect();
796        let Accumulator {
797            mut gradient,
798            axial,
799        } = try_fold_ordered(
800            samples,
801            true,
802            zero,
803            |mut sum, sample, (point_gradient, point)| {
804                let mut cache = self.cache::<N>();
805                for i in 0..self.basis.len() {
806                    let (pidx, pol) = self.basis.position_pol(i);
807                    let (wave, axial_derivative) = self.wave(i, *point, &mut cache)?;
808                    let amplitude = coefficients.map_or(Complex::new(1.0, 0.0), |c| c[i]);
809                    let g = cotangent(sample, i);
810                    for (component, &cot) in g.iter().enumerate() {
811                        if coefficients.is_some() {
812                            sum.gradient.coefficients[i] += wave.value[component].conj() * cot;
813                        }
814                        sum.gradient.ks[pol] += (amplitude * wave.k[component]).conj() * cot;
815                        if with_axial {
816                            sum.axial[i] +=
817                                (cot.conj() * amplitude * axial_derivative[component]).re;
818                        }
819                        for (axis, point_derivative) in point_gradient.iter_mut().enumerate() {
820                            let derivative =
821                                (cot.conj() * amplitude * wave.position[component][axis]).re;
822                            *point_derivative += derivative;
823                            sum.gradient.positions[pidx][axis] -= derivative;
824                        }
825                    }
826                }
827                Ok(sum)
828            },
829            Accumulator::merge,
830        )?;
831        gradient.points = point_gradients;
832        Ok((gradient, axial))
833    }
834}
835
836/// A direction in field geometry, shared by field sums and operators. Local wave
837/// derivatives are contracted immediately, without constructing a global Jacobian.
838#[derive(Clone, Copy)]
839struct GeometryTangent<'a> {
840    points: &'a [[f64; 3]],
841    positions: &'a [[f64; 3]],
842    ks: [Complex; 2],
843    axial: Option<&'a [f64]>,
844}
845
846impl GeometryTangent<'_> {
847    fn validate(&self, waves: &WaveSet, samples: usize) -> Result<()> {
848        if self.points.len() != samples
849            || self.positions.len() != waves.basis.positions().len()
850            || self
851                .points
852                .iter()
853                .chain(self.positions)
854                .flatten()
855                .any(|v| !v.is_finite())
856            || self.ks.iter().any(|&v| !finite(v))
857        {
858            return Err(Error::InvalidInput("invalid field geometry tangent".into()));
859        }
860        if let Some(axial) = self.axial {
861            if matches!(waves.basis, MultipoleBasis::Spherical(_)) {
862                return Err(Error::InvalidInput(
863                    "axial field derivatives require a cylindrical basis".into(),
864                ));
865            }
866            if axial.len() != waves.basis.len() || axial.iter().any(|v| !v.is_finite()) {
867                return Err(Error::InvalidInput("invalid field axial tangent".into()));
868            }
869        }
870        Ok(())
871    }
872
873    fn contract(
874        &self,
875        waves: &WaveSet,
876        sample: usize,
877        mode: usize,
878        wave: VectorWave,
879        axial: [Complex; 3],
880    ) -> [Complex; 3] {
881        let (position, polarization) = waves.basis.position_pol(mode);
882        from_fn(|component| {
883            wave.k[component] * self.ks[polarization]
884                + (0..3)
885                    .map(|axis| {
886                        wave.position[component][axis]
887                            * (self.points[sample][axis] - self.positions[position][axis])
888                    })
889                    .sum::<Complex>()
890                + self
891                    .axial
892                    .map_or_else(Complex::default, |kz| axial[component] * kz[mode])
893        })
894    }
895}
896
897/// Partial sums of a field pullback over a chunk of consecutive samples. Point
898/// gradients are written in place, one per sample.
899struct Accumulator {
900    gradient: FieldGradient,
901    axial: Vec<f64>,
902}
903
904impl Accumulator {
905    fn merge(mut self, other: Self) -> Self {
906        let (gradient, sum) = (other.gradient, &mut self.gradient);
907        for (a, b) in sum.coefficients.iter_mut().zip(gradient.coefficients) {
908            *a += b;
909        }
910        for (a, b) in sum
911            .positions
912            .iter_mut()
913            .flatten()
914            .zip(gradient.positions.iter().flatten())
915        {
916            *a += b;
917        }
918        for (a, b) in sum.ks.iter_mut().zip(gradient.ks) {
919            *a += b;
920        }
921        for (a, b) in self.axial.iter_mut().zip(other.axial) {
922            *a += b;
923        }
924        self
925    }
926}
927
928/// Input gradients of a field or operator pullback, under the convention
929/// `dL = Re sum(conj(g) dx)`.
930#[derive(Debug)]
931pub struct FieldGradient {
932    /// Gradients of the complex mode coefficients; empty for [`OperatorResidual`].
933    pub coefficients: Vec<Complex>,
934    /// Gradients of the sample points.
935    pub points: Vec<[f64; 3]>,
936    /// Gradients of the expansion centres of the basis.
937    pub positions: Vec<[f64; 3]>,
938    /// Gradients of the wavenumbers of polarizations 0 and 1 (negative and positive
939    /// helicity).
940    pub ks: [Complex; 2],
941}
942
943/// Evaluate weighted electric fields in parallel over samples: one Cartesian field
944/// vector per point.
945///
946/// Upstream: `treams.efield(r, basis=...) @ coefficients`.
947pub fn field(
948    basis: impl Into<MultipoleBasis>,
949    coefficients: Vec<Complex>,
950    points: Vec<[f64; 3]>,
951    ks: [Complex; 2],
952    helicity: bool,
953    radial: Radial,
954) -> Result<(Vec<[Complex; 3]>, FieldResidual)> {
955    let basis = basis.into();
956    validate_coefficients(&basis, &coefficients)?;
957    let (waves, points) = sampled_waves(basis, points, ks, helicity, radial)?;
958    let value = try_map(points.len(), points.len() > 1, |index| -> Result<_> {
959        let mut cache = waves.cache::<VALUES>();
960        let mut value = [Complex::default(); 3];
961        for (i, &amplitude) in coefficients.iter().enumerate() {
962            let (wave, _) = waves.wave(i, points[index], &mut cache)?;
963            for (v, f) in value.iter_mut().zip(wave.value) {
964                *v += amplitude * f;
965            }
966        }
967        Ok(value)
968    })?;
969    Ok((
970        value,
971        FieldResidual {
972            waves,
973            points,
974            coefficients,
975        },
976    ))
977}
978
979impl FieldResidual {
980    /// Saved-state byte count determined solely by the input dimensions and wave
981    /// family, independent of material values or derivative directions.
982    pub fn state_size(
983        modes: usize,
984        positions: usize,
985        points: usize,
986        cylindrical: bool,
987    ) -> Result<usize> {
988        OperatorResidual::state_size(modes, positions, points, cylindrical)?
989            .checked_add(modes.checked_mul(16).ok_or_else(saved::invalid)?)
990            .ok_or_else(saved::invalid)
991    }
992
993    /// Samples and Cartesian components: the shape of the field array.
994    #[must_use]
995    pub fn shape(&self) -> (usize, usize) {
996        (self.points.len(), 3)
997    }
998
999    /// Number of mode coefficients and expansion centres in the input.
1000    #[must_use]
1001    pub fn input_sizes(&self) -> (usize, usize) {
1002        (self.coefficients.len(), self.waves.basis.positions().len())
1003    }
1004
1005    /// Field tangent in one direction of coefficients, sample points, expansion
1006    /// centres and medium wavenumbers. Each sample reuses the primal wave caches.
1007    pub fn pushforward(
1008        &self,
1009        coefficients: &[Complex],
1010        points: &[[f64; 3]],
1011        positions: &[[f64; 3]],
1012        ks: [Complex; 2],
1013    ) -> Result<Vec<[Complex; 3]>> {
1014        self.pushforward_impl::<SPATIAL_AND_K>(
1015            coefficients,
1016            GeometryTangent {
1017                points,
1018                positions,
1019                ks,
1020                axial: None,
1021            },
1022        )
1023    }
1024
1025    /// [`Self::pushforward`] with an axial-wavenumber tangent per cylindrical mode.
1026    pub fn pushforward_axial(
1027        &self,
1028        coefficients: &[Complex],
1029        points: &[[f64; 3]],
1030        positions: &[[f64; 3]],
1031        ks: [Complex; 2],
1032        kz: &[f64],
1033    ) -> Result<Vec<[Complex; 3]>> {
1034        self.pushforward_impl::<WITH_AXIAL>(
1035            coefficients,
1036            GeometryTangent {
1037                points,
1038                positions,
1039                ks,
1040                axial: Some(kz),
1041            },
1042        )
1043    }
1044
1045    fn pushforward_impl<const N: usize>(
1046        &self,
1047        coefficients: &[Complex],
1048        tangent: GeometryTangent<'_>,
1049    ) -> Result<Vec<[Complex; 3]>> {
1050        if coefficients.len() != self.coefficients.len() || coefficients.iter().any(|&v| !finite(v))
1051        {
1052            return Err(Error::InvalidInput(
1053                "invalid field coefficient tangent".into(),
1054            ));
1055        }
1056        tangent.validate(&self.waves, self.points.len())?;
1057        try_map(self.points.len(), self.points.len() > 1, |sample| {
1058            let mut cache = self.waves.cache::<N>();
1059            let mut value = [Complex::default(); 3];
1060            for (mode, &coefficient) in coefficients.iter().enumerate() {
1061                let (wave, axial) = self.waves.wave(mode, self.points[sample], &mut cache)?;
1062                let derivative = tangent.contract(&self.waves, sample, mode, wave, axial);
1063                for component in 0..3 {
1064                    value[component] += coefficient * wave.value[component]
1065                        + self.coefficients[mode] * derivative[component];
1066                }
1067            }
1068            Ok(value)
1069        })
1070    }
1071
1072    /// Input gradients from `cotangent`, the gradient of a real loss with respect to the
1073    /// field, one Cartesian vector per sample. The waves are evaluated again per sample;
1074    /// no dense Jacobian is stored.
1075    ///
1076    /// Each point gradient comes from its own sample only. The coefficient, position
1077    /// and wavenumber gradients add in chunks of consecutive samples fixed by the
1078    /// sample count, and the chunk sums in chunk order
1079    /// (`numerics::parallel::try_fold_ordered`), so the thread count does not change
1080    /// them.
1081    pub fn pullback(&self, cotangent: &[[Complex; 3]]) -> Result<FieldGradient> {
1082        self.pullback_impl::<SPATIAL_AND_K>(cotangent)
1083            .map(|(gradient, _)| gradient)
1084    }
1085
1086    /// [`Self::pullback`] plus a real axial-wavenumber gradient for each mode of a
1087    /// cylindrical basis; the axial gradients add their chunk sums the same way.
1088    pub fn pullback_axial(&self, cotangent: &[[Complex; 3]]) -> Result<(FieldGradient, Vec<f64>)> {
1089        self.pullback_impl::<WITH_AXIAL>(cotangent)
1090    }
1091
1092    fn pullback_impl<const N: usize>(
1093        &self,
1094        cotangent: &[[Complex; 3]],
1095    ) -> Result<(FieldGradient, Vec<f64>)> {
1096        if cotangent.len() != self.points.len() || cotangent.iter().flatten().any(|&g| !finite(g)) {
1097            return Err(Error::InvalidInput("invalid field cotangent".into()));
1098        }
1099        self.waves
1100            .pullback::<N>(&self.points, Some(&self.coefficients), |sample, _| {
1101                cotangent[sample]
1102            })
1103    }
1104}
1105
1106impl SavedState for FieldResidual {
1107    fn save_state(&self) -> Result<Vec<u8>> {
1108        let (modes, positions) = self.input_sizes();
1109        let cylindrical = matches!(self.waves.basis, MultipoleBasis::Cylindrical(_));
1110        let mut writer = Writer::new(Self::state_size(
1111            modes,
1112            positions,
1113            self.points.len(),
1114            cylindrical,
1115        )?);
1116        write_sampled_state(&mut writer, &self.waves, &self.points);
1117        for &coefficient in &self.coefficients {
1118            writer.complex(coefficient);
1119        }
1120        Ok(writer.finish())
1121    }
1122
1123    fn from_state(bytes: &[u8]) -> Result<Self> {
1124        let (waves, points, mut reader) = read_sampled_state(bytes, true)?;
1125        let coefficients = (0..waves.basis.len())
1126            .map(|_| reader.complex())
1127            .collect::<Result<Vec<_>>>()?;
1128        reader.finish()?;
1129        validate_coefficients(&waves.basis, &coefficients)?;
1130        Ok(Self {
1131            waves,
1132            points,
1133            coefficients,
1134        })
1135    }
1136}
1137
1138fn write_sampled_state(writer: &mut Writer, waves: &WaveSet, points: &[[f64; 3]]) {
1139    writer.usize(points.len());
1140    waves.write_state(writer);
1141    for &coordinate in points.as_flattened() {
1142        writer.f64(coordinate);
1143    }
1144}
1145
1146/// Check the complete byte size from the allocation-free header before decoding
1147/// any arrays. Both residual layouts share the same sampled-wave prefix.
1148fn read_sampled_state(
1149    bytes: &[u8],
1150    coefficients: bool,
1151) -> Result<(WaveSet, Vec<[f64; 3]>, Reader<'_>)> {
1152    let mut header = Reader::new(bytes);
1153    let samples = header.usize()?;
1154    let (modes, positions, cylindrical) = saved::read_basis_dimensions(&mut header)?;
1155    let size = if coefficients {
1156        FieldResidual::state_size(modes, positions, samples, cylindrical)?
1157    } else {
1158        OperatorResidual::state_size(modes, positions, samples, cylindrical)?
1159    };
1160    if bytes.len() != size {
1161        return Err(saved::invalid());
1162    }
1163    let mut reader = Reader::new(bytes);
1164    reader.usize()?;
1165    let waves = WaveSet::read_state(&mut reader)?;
1166    let points = (0..samples)
1167        .map(|_| Ok([reader.f64()?, reader.f64()?, reader.f64()?]))
1168        .collect::<Result<Vec<_>>>()?;
1169    validate_points(&points)?;
1170    Ok((waves, points, reader))
1171}
1172
1173/// What [`operator`] saves for its pullback: the wave set and the points, without the
1174/// matrix.
1175#[derive(Debug)]
1176pub struct OperatorResidual {
1177    waves: WaveSet,
1178    points: Vec<[f64; 3]>,
1179}
1180
1181/// Matrix mapping multipole amplitudes to Cartesian samples. Rows pack (sample, component).
1182/// The residual keeps the basis, wavenumbers and points, without the output or any
1183/// derivative matrix.
1184///
1185/// Upstream: `treams.efield`, which returns the matrix with shape `(..., 3, modes)`.
1186pub fn operator(
1187    basis: impl Into<MultipoleBasis>,
1188    points: Vec<[f64; 3]>,
1189    ks: [Complex; 2],
1190    helicity: bool,
1191    radial: Radial,
1192) -> Result<(nalgebra::DMatrix<Complex>, OperatorResidual)> {
1193    let (waves, points) = sampled_waves(basis.into(), points, ks, helicity, radial)?;
1194    let value = waves.sampled_operator::<VALUES>(&points, |_, _, wave, _| wave.value)?;
1195    Ok((value, OperatorResidual { waves, points }))
1196}
1197
1198impl OperatorResidual {
1199    /// Saved-state byte count determined solely by the input dimensions and wave
1200    /// family, without evaluating a field operator.
1201    pub fn state_size(
1202        modes: usize,
1203        positions: usize,
1204        points: usize,
1205        cylindrical: bool,
1206    ) -> Result<usize> {
1207        let basis = if cylindrical {
1208            saved::cw_basis_size(modes, positions)?
1209        } else {
1210            saved::sw_basis_size(modes, positions)?
1211        };
1212        // Samples (8), family (1), wavenumbers (32), helicity and radial (2).
1213        basis
1214            .checked_add(43)
1215            .and_then(|size| {
1216                points
1217                    .checked_mul(24)
1218                    .and_then(|points| size.checked_add(points))
1219            })
1220            .ok_or_else(saved::invalid)
1221    }
1222
1223    /// Flattened operator shape (three times samples, modes).
1224    #[must_use]
1225    pub fn shape(&self) -> (usize, usize) {
1226        (3 * self.points.len(), self.waves.basis.len())
1227    }
1228
1229    /// Number of expansion centres in the input basis.
1230    #[must_use]
1231    pub fn position_count(&self) -> usize {
1232        self.waves.basis.positions().len()
1233    }
1234
1235    /// Operator tangent in one direction of sample points, expansion centres and
1236    /// wavenumbers, evaluated directly into the output's column-major storage.
1237    pub fn pushforward(
1238        &self,
1239        points: &[[f64; 3]],
1240        positions: &[[f64; 3]],
1241        ks: [Complex; 2],
1242    ) -> Result<nalgebra::DMatrix<Complex>> {
1243        self.pushforward_impl::<SPATIAL_AND_K>(GeometryTangent {
1244            points,
1245            positions,
1246            ks,
1247            axial: None,
1248        })
1249    }
1250
1251    /// [`Self::pushforward`] with an axial-wavenumber tangent per cylindrical mode.
1252    pub fn pushforward_axial(
1253        &self,
1254        points: &[[f64; 3]],
1255        positions: &[[f64; 3]],
1256        ks: [Complex; 2],
1257        kz: &[f64],
1258    ) -> Result<nalgebra::DMatrix<Complex>> {
1259        self.pushforward_impl::<WITH_AXIAL>(GeometryTangent {
1260            points,
1261            positions,
1262            ks,
1263            axial: Some(kz),
1264        })
1265    }
1266
1267    fn pushforward_impl<const N: usize>(
1268        &self,
1269        tangent: GeometryTangent<'_>,
1270    ) -> Result<nalgebra::DMatrix<Complex>> {
1271        tangent.validate(&self.waves, self.points.len())?;
1272        self.waves
1273            .sampled_operator::<N>(&self.points, |sample, mode, wave, axial| {
1274                tangent.contract(&self.waves, sample, mode, wave, axial)
1275            })
1276    }
1277
1278    /// Point, position and wavenumber gradients from `cotangent`, the gradient of a real
1279    /// loss with respect to the matrix; the coefficient gradient is empty.
1280    ///
1281    /// Each point gradient comes from its own sample only. The position and wavenumber
1282    /// gradients add as in [`FieldResidual::pullback`], so the thread count does not
1283    /// change them.
1284    pub fn pullback(&self, cotangent: &nalgebra::DMatrix<Complex>) -> Result<FieldGradient> {
1285        self.pullback_impl::<SPATIAL_AND_K>(cotangent)
1286            .map(|(gradient, _)| gradient)
1287    }
1288
1289    /// [`Self::pullback`] plus a real axial-wavenumber gradient for each mode of a
1290    /// cylindrical basis; the axial gradients add their chunk sums the same way.
1291    pub fn pullback_axial(
1292        &self,
1293        cotangent: &nalgebra::DMatrix<Complex>,
1294    ) -> Result<(FieldGradient, Vec<f64>)> {
1295        self.pullback_impl::<WITH_AXIAL>(cotangent)
1296    }
1297
1298    fn pullback_impl<const N: usize>(
1299        &self,
1300        cotangent: &nalgebra::DMatrix<Complex>,
1301    ) -> Result<(FieldGradient, Vec<f64>)> {
1302        if cotangent.shape() != self.shape() || cotangent.iter().any(|&g| !finite(g)) {
1303            return Err(Error::InvalidInput(
1304                "invalid field operator cotangent".into(),
1305            ));
1306        }
1307        self.waves
1308            .pullback::<N>(&self.points, None, |sample, mode| {
1309                from_fn(|i| cotangent[(3 * sample + i, mode)])
1310            })
1311    }
1312}
1313
1314impl SavedState for OperatorResidual {
1315    fn save_state(&self) -> Result<Vec<u8>> {
1316        let cylindrical = matches!(self.waves.basis, MultipoleBasis::Cylindrical(_));
1317        let mut writer = Writer::new(Self::state_size(
1318            self.waves.basis.len(),
1319            self.position_count(),
1320            self.points.len(),
1321            cylindrical,
1322        )?);
1323        write_sampled_state(&mut writer, &self.waves, &self.points);
1324        Ok(writer.finish())
1325    }
1326
1327    fn from_state(bytes: &[u8]) -> Result<Self> {
1328        let (waves, points, reader) = read_sampled_state(bytes, false)?;
1329        reader.finish()?;
1330        Ok(Self { waves, points })
1331    }
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336    use std::array::from_fn;
1337
1338    use proptest::{prelude::*, test_runner::TestCaseError};
1339
1340    use super::{field, operator, spherical_wave, spherical_wave_impl};
1341    use crate::{
1342        Complex, Error,
1343        special::Radial,
1344        sw::Mode,
1345        test_support::{
1346            assert_same_bits_on_pools, bits, degree_order, patterned, radial, spherical_basis,
1347        },
1348    };
1349
1350    proptest! {
1351        #![proptest_config(ProptestConfig::with_cases(64))]
1352
1353        #[test]
1354        fn forward_waves_carry_no_derivatives(
1355            (l, m) in degree_order(1..6),
1356            pol in 0_u8..2,
1357            position in prop::array::uniform3(-1.5_f64..1.5),
1358            helicity in any::<bool>(),
1359            radial in radial(),
1360        ) {
1361            check_forward_wave(Mode { l, m, pol }, position, helicity, radial)?;
1362        }
1363    }
1364
1365    /// The shortened regular origin series agrees with independent AMOS values
1366    /// through high degree, for real, lossy and amplifying complex arguments.
1367    #[test]
1368    fn regular_origin_series_matches_bessel_values() {
1369        for l in 0..=64 {
1370            for k in [
1371                Complex::new(1.2, 0.0),
1372                Complex::new(1.1, 0.3),
1373                Complex::new(0.8, -0.5),
1374            ] {
1375                for radius in [0.01_f64, 0.2, 0.38] {
1376                    let actual = super::scaled_radial(l, k, radius, Radial::Regular).unwrap();
1377                    let argument = k * radius;
1378                    let expected = (super::PI / (2.0 * argument)).sqrt()
1379                        * complex_bessel::besselj(f64::from(l) + 0.5, argument).unwrap()
1380                        / radius.powf(f64::from(l));
1381                    assert!(
1382                        (actual - expected).norm() <= 1e-13 * expected.norm(),
1383                        "l={l}, k={k}, r={radius}, actual={actual}, expected={expected}"
1384                    );
1385                }
1386            }
1387        }
1388    }
1389
1390    /// Radial batching follows the needed degree intervals `[l, l+1]`, independently
1391    /// of harmonic density. It includes single modes and excludes gaps with unused
1392    /// radial orders; reversing modes leaves the selected range unchanged.
1393    #[test]
1394    fn radial_sequences_include_only_contiguous_needed_degrees() {
1395        for (degrees, range) in [
1396            (vec![1], Some((1, 1))),
1397            (vec![2], Some((2, 2))),
1398            (vec![1, 3], Some((1, 3))),
1399            (vec![1, 4], None),
1400            (vec![3, 128], None),
1401        ] {
1402            let mut basis = crate::sw::Basis {
1403                modes: degrees
1404                    .into_iter()
1405                    .map(|l| (0, Mode { l, m: 0, pol: 1 }))
1406                    .collect(),
1407                positions: vec![[0.0; 3], [1.0, 0.0, 0.0]],
1408            };
1409            for _ in 0..2 {
1410                let waves = super::WaveSet::new(
1411                    basis.clone().into(),
1412                    [Complex::new(1.0, 0.0); 2],
1413                    true,
1414                    Radial::Singular,
1415                )
1416                .unwrap();
1417                assert_eq!(waves.radial_ranges, vec![range, None, None, None]);
1418                assert!(waves.harmonic_tables.iter().all(Option::is_none));
1419                basis.modes.reverse();
1420            }
1421        }
1422    }
1423
1424    /// A high-degree centre does not make a nearby low-degree centre evaluate
1425    /// unused Hankel orders that overflow, in either values or analytic gradients.
1426    #[test]
1427    fn outgoing_sequences_stop_at_each_centres_degree() {
1428        let mut basis = spherical_basis(3, [0.0; 3]);
1429        basis.positions.push([1.0, 0.0, 0.0]);
1430        basis.modes.extend(
1431            crate::sw::modes(8)
1432                .unwrap()
1433                .into_iter()
1434                .map(|mode| (1, mode)),
1435        );
1436        let waves = super::WaveSet::new(
1437            basis.clone().into(),
1438            [Complex::new(1.0, 0.0); 2],
1439            true,
1440            Radial::Singular,
1441        )
1442        .unwrap();
1443        let point = [1e-40, 0.0, 0.0];
1444        let mut values = waves.cache::<{ super::VALUES }>();
1445        let mut derivatives = waves.cache::<{ super::SPATIAL_AND_K }>();
1446        for i in 0..basis.modes.len() {
1447            let (value, _) = waves.wave(i, point, &mut values).unwrap();
1448            let (wave, _) = waves.wave(i, point, &mut derivatives).unwrap();
1449            assert!(value.value.into_iter().all(crate::numerics::finite));
1450            assert!(
1451                wave.position
1452                    .into_iter()
1453                    .flatten()
1454                    .all(crate::numerics::finite)
1455            );
1456            assert!(wave.k.into_iter().all(crate::numerics::finite));
1457        }
1458    }
1459
1460    /// Dense, reordered and sparse harmonic tables retain the scalar waves and
1461    /// their analytic point and wavenumber derivatives, including regular origins,
1462    /// polar axes and changes of length unit. Both equal and chiral wavenumbers
1463    /// exercise the table's complete cache key. Scalar radials isolate the harmonic
1464    /// recurrence here; `field_evaluation_contract` covers the full dispatched path.
1465    #[test]
1466    fn spherical_harmonic_tables_match_individual_waves() {
1467        for radial in [Radial::Regular, Radial::Singular] {
1468            for chirality in [0.0, 0.2] {
1469                for exponent in [-100, 0, 100] {
1470                    let unit = 10_f64.powi(exponent);
1471                    let ks = [Complex::new(1.2, 0.1), Complex::new(1.2 + chirality, 0.1)]
1472                        .map(|k| k / unit);
1473                    let mut basis = spherical_basis(8, [0.0; 3]);
1474                    basis.positions.push([0.2 * unit, -0.1 * unit, 0.0]);
1475                    let modes = basis.modes.clone();
1476                    basis
1477                        .modes
1478                        .extend(modes.into_iter().map(|(_, mode)| (1, mode)));
1479                    basis.modes.reverse();
1480                    for ordering in 0..3 {
1481                        let mut basis = basis.clone();
1482                        if ordering == 1 {
1483                            basis.modes.retain(|&(_, mode)| mode.l == 8 && mode.m == 3);
1484                        } else if ordering == 2 {
1485                            basis.modes.sort_unstable_by_key(|&(pidx, mode)| {
1486                                (mode.l, mode.m, mode.pol, pidx)
1487                            });
1488                        }
1489                        let mut waves =
1490                            super::WaveSet::new(basis.clone().into(), ks, true, radial).unwrap();
1491                        waves.radial_ranges.fill(None);
1492                        assert_eq!(
1493                            waves.harmonic_tables.iter().any(Option::is_some),
1494                            ordering == 0
1495                        );
1496                        for point in [[0.0; 3], [0.0, 0.0, 0.7], [0.3, -0.8, 1.1]] {
1497                            if radial == Radial::Singular && point.iter().all(|&v| v == 0.0) {
1498                                continue;
1499                            }
1500                            let point = point.map(|x| x * unit);
1501                            let mut cache = waves.cache::<{ super::SPATIAL_AND_K }>();
1502                            let mut value_cache = waves.cache::<{ super::VALUES }>();
1503                            for (i, &(pidx, mode)) in basis.modes.iter().enumerate() {
1504                                let offset = from_fn(|a| point[a] - basis.positions[pidx][a]);
1505                                let expected = spherical_wave(
1506                                    mode,
1507                                    ks[usize::from(mode.pol)],
1508                                    offset,
1509                                    true,
1510                                    radial,
1511                                )
1512                                .unwrap();
1513                                let (actual, _) = waves.wave(i, point, &mut cache).unwrap();
1514                                let (value, _) = waves.wave(i, point, &mut value_cache).unwrap();
1515                                assert_eq!(actual.value, expected.value);
1516                                assert_eq!(value.value, expected.value);
1517                                assert_eq!(actual.position, expected.position);
1518                                assert_eq!(actual.k, expected.k);
1519                            }
1520                        }
1521                    }
1522                }
1523            }
1524        }
1525    }
1526
1527    /// Spherical fields and operators reject what a single spherical wave rejects,
1528    /// a wavenumber whose square underflows and a sample whose offset from its basis position
1529    /// overflows, with the same message instead of NaN values. The pullbacks evaluate
1530    /// the same waves and are reached only through a successful forward pass.
1531    #[test]
1532    fn spherical_fields_reject_what_a_single_wave_rejects() {
1533        let message = "require a finite nonzero wavenumber and finite position";
1534        let rejected = |result: crate::Result<()>| matches!(result, Err(Error::InvalidInput(ref text)) if text == message);
1535        let tiny = [Complex::new(1e-170, 1e-170); 2];
1536        let unit = [Complex::new(1.0, 0.0); 2];
1537        let cases = [
1538            (tiny, [0.0; 3], [0.3, 0.2, 0.1]),
1539            (unit, [-1e308, 0.0, 0.0], [1e308, 0.0, 0.0]),
1540        ];
1541        for (ks, basis_position, point) in cases {
1542            for radial in [Radial::Regular, Radial::Singular] {
1543                for helicity in [true, false] {
1544                    let basis = spherical_basis(1, basis_position);
1545                    let position = from_fn(|a| point[a] - basis_position[a]);
1546                    let mode = basis.modes[0].1;
1547                    let wave = spherical_wave(mode, ks[0], position, helicity, radial);
1548                    assert!(rejected(wave.map(drop)));
1549                    let coefficients = vec![Complex::new(1.0, 0.0); basis.modes.len()];
1550                    let values = field(
1551                        basis.clone(),
1552                        coefficients,
1553                        vec![point],
1554                        ks,
1555                        helicity,
1556                        radial,
1557                    );
1558                    assert!(rejected(values.map(drop)), "{radial:?}");
1559                    let matrix = operator(basis, vec![point], ks, helicity, radial);
1560                    assert!(rejected(matrix.map(drop)), "{radial:?}");
1561                }
1562            }
1563        }
1564    }
1565
1566    /// Field and operator pullbacks add their shared gradients in chunks fixed by the
1567    /// sample count: with more samples than chunks, every gradient of a weighted
1568    /// spherical field and of the axial pullback of a cylindrical operator repeats bit
1569    /// for bit on every pool size.
1570    #[test]
1571    fn pullbacks_do_not_depend_on_the_thread_count() {
1572        let points: Vec<[f64; 3]> = (0..120_u32)
1573            .map(|i| {
1574                let t = f64::from(i);
1575                [
1576                    1.5 * (0.37 * t).sin(),
1577                    1.2 * (0.53 * t).cos(),
1578                    0.9 * (0.71 * t).sin() + 0.1,
1579                ]
1580            })
1581            .collect();
1582        let ks = [Complex::new(1.1, 0.02), Complex::new(1.3, 0.01)];
1583        let modes = crate::sw::modes(3).unwrap();
1584        let spheres = crate::sw::Basis {
1585            modes: (0..2)
1586                .flat_map(|p| modes.iter().map(move |&mode| (p, mode)))
1587                .collect(),
1588            positions: vec![[0.1, 0.2, -0.3], [-0.2, 0.0, 0.4]],
1589        };
1590        let coefficients = patterned(spheres.modes.len(), 1, 0.2).as_slice().to_vec();
1591        let g = patterned(3, points.len(), 0.9);
1592        let g = g.as_slice().as_chunks::<3>().0;
1593        assert_same_bits_on_pools(|| {
1594            let (_, residual) = field(
1595                spheres.clone(),
1596                coefficients.clone(),
1597                points.clone(),
1598                ks,
1599                true,
1600                Radial::Regular,
1601            )
1602            .unwrap();
1603            let g = residual.pullback(g).unwrap();
1604            bits(&[&g.coefficients, &g.points, &g.positions, &g.ks])
1605        });
1606        let cylinders = crate::cw::Basis {
1607            modes: (0..2)
1608                .flat_map(|p| {
1609                    [0.2, -0.35].into_iter().flat_map(move |kz| {
1610                        (-3..=3).flat_map(move |m| {
1611                            [1, 0].map(|pol| (p, crate::cw::Mode { kz, m, pol }))
1612                        })
1613                    })
1614                })
1615                .collect(),
1616            positions: vec![[0.1, 0.2, 0.0], [-0.2, 0.0, 0.0]],
1617        };
1618        let g = patterned(3 * points.len(), cylinders.modes.len(), 0.4);
1619        assert_same_bits_on_pools(|| {
1620            let (_, residual) =
1621                operator(cylinders.clone(), points.clone(), ks, true, Radial::Regular).unwrap();
1622            let (g, axial) = residual.pullback_axial(&g).unwrap();
1623            bits(&[&g.points, &g.positions, &g.ks, &axial])
1624        });
1625    }
1626
1627    /// A forward-only spherical wave has exactly the value of the differentiated one
1628    /// and zero position and wavenumber derivatives.
1629    fn check_forward_wave(
1630        mode: Mode,
1631        position: [f64; 3],
1632        helicity: bool,
1633        radial: Radial,
1634    ) -> Result<(), TestCaseError> {
1635        let distance = position[0].hypot(position[1]).hypot(position[2]);
1636        prop_assume!(radial == Radial::Regular || distance > 0.1);
1637        let k = Complex::new(1.3, 0.1);
1638        let full = spherical_wave_impl::<true>(mode, k, position, helicity, radial).unwrap();
1639        let forward = spherical_wave_impl::<false>(mode, k, position, helicity, radial).unwrap();
1640        prop_assert_eq!(forward.value, full.value);
1641        prop_assert_eq!(forward.position, [[Complex::default(); 3]; 3]);
1642        prop_assert_eq!(forward.k, [Complex::default(); 3]);
1643        Ok(())
1644    }
1645}