treams_core/
ebcm.rs

1//! Q-matrices of the extended boundary condition method (EBCM) for particles that are
2//! symmetric about the z axis, with analytic gradients for the surface and the media.
3//!
4//! Upstream: `treams.ebcm`. [`qmat`] computes `treams.ebcm.qmat` with two differences:
5//!
6//! - `radial_area_factor = true` includes the factor `r` of the surface element
7//!   `(r rhat - r' theta-hat) r sin(theta) dtheta dphi`, which treams omits; `false`
8//!   reproduces treams. See
9//!   <https://yaugenst.github.io/treams-rs/latest/coming-from-treams/differences/>.
10//! - It sums over the fixed polar nodes and weights of a [`Surface`]; the Python
11//!   `ebcm.qmat` passes Gauss-Legendre nodes. treams integrates each matrix entry with
12//!   the adaptive `scipy.integrate.quad`.
13#![allow(clippy::indexing_slicing)] // Validated mode/sample dimensions and Cartesian axes.
14
15use std::collections::HashMap;
16
17use nalgebra::DMatrix;
18use rayon::prelude::*;
19
20use crate::{
21    Complex, Error, Result,
22    basis::{ModeLabel, MultipoleBasis},
23    fields::{SPATIAL_AND_K, VALUES, VectorWave, WaveSet},
24    numerics::{self, finite},
25    saved::{Reader, SavedState, Writer, invalid},
26    special::Radial,
27    sw::Mode,
28};
29
30/// A surface of revolution `r(theta)` about the z axis, sampled at fixed polar
31/// quadrature nodes in [0, pi].
32#[derive(Debug)]
33pub struct Surface {
34    /// Polar angles; the pullback holds these and the integration weights fixed.
35    pub theta: Vec<f64>,
36    /// Integration weights for d theta, without the sin(theta) Jacobian.
37    pub weights: Vec<f64>,
38    /// Positive surface radius at each node.
39    pub radii: Vec<f64>,
40    /// The slope `dr/dtheta` at each node. The pullback treats it as an input
41    /// independent of the radii.
42    pub slopes: Vec<f64>,
43}
44
45/// What [`qmat`] saves for its pullback: the modes, the surface and the media, without
46/// any wave values; the pullback evaluates the waves again.
47#[derive(Debug)]
48pub struct QmatResidual {
49    destination: Vec<Mode>,
50    source: Vec<Mode>,
51    surface: Surface,
52    ks: [[Complex; 2]; 2],
53    zs: [Complex; 2],
54    radial: Radial,
55    radial_area_factor: bool,
56}
57
58/// Input gradients of [`QmatResidual::pullback`].
59#[derive(Debug)]
60pub struct QmatGradient {
61    /// Gradients of the surface radii, one per node.
62    pub radii: Vec<f64>,
63    /// Gradients of the surface slopes, one per node.
64    pub slopes: Vec<f64>,
65    /// Gradients of the inner and outer wavenumbers, each ordered negative, positive
66    /// helicity.
67    pub ks: [[Complex; 2]; 2],
68    /// Gradients of the inner and outer impedances.
69    pub zs: [Complex; 2],
70}
71
72/// The point of node `i` in the x-z plane, its unnormalized surface normal
73/// `r rhat - r' theta-hat` (`r'` is the slope `dr/dtheta`) and the unit vectors `rhat`
74/// and `-theta-hat`, along which the normal changes with the radius and the slope.
75fn node_geometry(surface: &Surface, i: usize) -> [[f64; 3]; 4] {
76    let (s, c) = surface.theta[i].sin_cos();
77    let rhat = [s, 0.0, c];
78    let slope = [-c, 0.0, s];
79    [
80        rhat.map(|x| x * surface.radii[i]),
81        std::array::from_fn(|j| surface.radii[i] * rhat[j] + surface.slopes[i] * slope[j]),
82        rhat,
83        slope,
84    ]
85}
86
87/// `a x n` of a complex and a real vector.
88fn cross(a: [Complex; 3], n: [f64; 3]) -> [Complex; 3] {
89    [
90        a[1] * n[2] - a[2] * n[1],
91        a[2] * n[0] - a[0] * n[2],
92        a[0] * n[1] - a[1] * n[0],
93    ]
94}
95
96/// The bilinear (unconjugated) product `a . b`.
97fn dot(a: [Complex; 3], b: [Complex; 3]) -> Complex {
98    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
99}
100
101/// The triple product `n . (a x b)`.
102fn triple(n: [f64; 3], a: [Complex; 3], b: [Complex; 3]) -> Complex {
103    n[0] * (a[1] * b[2] - a[2] * b[1])
104        + n[1] * (a[2] * b[0] - a[0] * b[2])
105        + n[2] * (a[0] * b[1] - a[1] * b[0])
106}
107
108/// Spherical helicity waves of `modes` about the origin with wavenumbers `ks`. Only
109/// the wavenumbers of helicities with modes are evaluated. The other may be zero,
110/// which [`WaveSet::new`] rejects, so it takes the evaluated one's value.
111fn origin_waves(
112    modes: impl Iterator<Item = Mode>,
113    ks: [Complex; 2],
114    radial: Radial,
115) -> Result<WaveSet> {
116    let modes: Vec<_> = modes.map(|mode| (0, mode)).collect();
117    let used = |pol: usize| modes.iter().any(|(_, mode)| usize::from(mode.pol) == pol);
118    let ks = [0, 1].map(|pol| if used(pol) { ks[pol] } else { ks[1 - pol] });
119    let basis = crate::sw::Basis {
120        modes,
121        positions: vec![[0.0; 3]],
122    };
123    WaveSet::new(MultipoleBasis::Spherical(basis), ks, true, radial)
124}
125
126/// The first `count` waves of `waves` at `point`, which share their radial functions
127/// per degree and wavenumber and their solid harmonics per degree and order. `N` is
128/// the derivative level, [`VALUES`] or [`SPATIAL_AND_K`].
129fn evaluate_waves<const N: usize>(
130    waves: &WaveSet,
131    count: usize,
132    point: [f64; 3],
133) -> Result<Vec<VectorWave>> {
134    let mut cache = waves.cache::<N>();
135    // The loop writes each 240-byte wave once into a presized vector; collecting the
136    // `Result`s copies it through an iterator adapter and grows the vector stepwise.
137    let mut values = Vec::with_capacity(count);
138    for i in 0..count {
139        values.push(waves.wave::<N>(i, point, &mut cache)?.0);
140    }
141    Ok(values)
142}
143
144/// Integrate Q between regular inner test waves and regular or singular outer waves.
145///
146/// The surface element of node `i` is `(r rhat - r' theta-hat) r sin(theta) w_i`, where
147/// `w_i` is the quadrature weight. With `radial_area_factor` false the factor `r` is
148/// omitted. Each entry sums its nodes in order, so the result does not depend on the
149/// thread count.
150///
151/// Upstream: `treams.ebcm.qmat`. Differences: treams omits the radial area factor `r`,
152/// and `radial_area_factor = false` reproduces treams; treams integrates each entry
153/// with adaptive quadrature, while this sums over the fixed nodes of `surface`.
154pub fn qmat(
155    destination: Vec<Mode>,
156    source: Vec<Mode>,
157    surface: Surface,
158    ks: [[Complex; 2]; 2],
159    zs: [Complex; 2],
160    radial: Radial,
161    radial_area_factor: bool,
162) -> Result<(DMatrix<Complex>, QmatResidual)> {
163    let n = surface.theta.len();
164    let residual = QmatResidual {
165        destination,
166        source,
167        surface,
168        ks,
169        zs,
170        radial,
171        radial_area_factor,
172    };
173    residual.validate()?;
174    // The wave evaluations dominate; each node is independent.
175    let [destination, source] = residual.wave_sets()?;
176    let samples = crate::threads::install(|| {
177        (0..n)
178            .into_par_iter()
179            .map(|node| -> Result<Sample> {
180                let [point, normal, _, _] = node_geometry(&residual.surface, node);
181                let values = |waves: Vec<VectorWave>| waves.into_iter().map(|w| w.value).collect();
182                Ok(Sample {
183                    weight: residual.weight(node),
184                    normal,
185                    destination: values(evaluate_waves::<VALUES>(
186                        &destination,
187                        residual.destination.len(),
188                        point,
189                    )?),
190                    source: values(evaluate_waves::<VALUES>(
191                        &source,
192                        residual.source.len(),
193                        point,
194                    )?),
195                })
196            })
197            .collect::<Result<Vec<_>>>()
198    })?;
199    let rows = residual.rows_by_order();
200    let factors = residual.factors();
201    let mut value = numerics::zeros(residual.destination.len(), residual.source.len())?;
202    // Each entry sums its nodes in order, so the result is independent of threading.
203    crate::threads::install(|| {
204        value
205            .as_mut_slice()
206            .par_chunks_mut(residual.destination.len())
207            .zip(&residual.source)
208            .enumerate()
209            .for_each(|(j, (column, from))| {
210                let rows = rows.get(&from.m).map_or(&[][..], Vec::as_slice);
211                for sample in &samples {
212                    for &i in rows {
213                        let factor = factors[usize::from(residual.destination[i].pol)]
214                            [usize::from(from.pol)];
215                        column[i] += sample.weight
216                            * factor
217                            * triple(sample.normal, sample.destination[i], sample.source[j]);
218                    }
219                }
220            });
221    });
222    if value.iter().any(|&z| !finite(z)) {
223        return Err(Error::NonFinite("non-finite EBCM integral".into()));
224    }
225    Ok((value, residual))
226}
227
228/// The sign `2 pol - 1` of polarizations 0 and 1 (negative and positive helicity), as
229/// [`helicity_sign`](crate::special::helicity_sign) gives it. A pair of helicities
230/// couples through the impedance factor `HELICITY[a] z_outer + HELICITY[b] z_inner`,
231/// so the impedance gradients use the same signs.
232const HELICITY: [f64; 2] = [-1.0, 1.0];
233
234/// Surface weight and normal of one node with the waves evaluated there.
235struct Sample {
236    weight: f64,
237    normal: [f64; 3],
238    destination: Vec<[Complex; 3]>,
239    source: Vec<[Complex; 3]>,
240}
241
242/// The value and directional derivative of a node's integrand factors. Wave
243/// derivatives are contracted here, before assembling any matrix entries.
244struct SampleTangent {
245    value: Sample,
246    weight: f64,
247    normal: [f64; 3],
248    destination: Vec<[Complex; 3]>,
249    source: Vec<[Complex; 3]>,
250}
251
252impl QmatResidual {
253    fn validate(&self) -> Result<()> {
254        let surface = &self.surface;
255        let n = surface.theta.len();
256        if n == 0
257            || self.destination.is_empty()
258            || self.source.is_empty()
259            || surface.weights.len() != n
260            || surface.radii.len() != n
261            || surface.slopes.len() != n
262            || surface
263                .theta
264                .iter()
265                .any(|t| !t.is_finite() || !(0.0..=std::f64::consts::PI).contains(t))
266            || surface.radii.iter().any(|r| !r.is_finite() || *r <= 0.0)
267            || surface
268                .weights
269                .iter()
270                .chain(&surface.slopes)
271                .any(|x| !x.is_finite())
272            || self
273                .ks
274                .iter()
275                .flatten()
276                .chain(&self.zs)
277                .any(|&z| !finite(z))
278        {
279            return Err(Error::InvalidInput("require nonempty modes, finite matching surface samples, positive radii and polar angles in [0, pi]".into()));
280        }
281        for mode in self.destination.iter().chain(&self.source) {
282            mode.validate()?;
283        }
284        Ok(())
285    }
286
287    /// Bytes in the private callback state for the quadrature and mode counts.
288    pub fn state_size(samples: usize, destinations: usize, sources: usize) -> Result<usize> {
289        if samples == 0 || destinations == 0 || sources == 0 {
290            return Err(invalid());
291        }
292        // Three counts, two flags, six complex media values, then the mode labels
293        // and four real surface arrays. No wave values or Q matrix are cached.
294        destinations
295            .checked_add(sources)
296            .and_then(|modes| modes.checked_mul(9))
297            .and_then(|modes| samples.checked_mul(32).and_then(|n| modes.checked_add(n)))
298            .and_then(|bytes| bytes.checked_add(122))
299            .ok_or_else(invalid)
300    }
301
302    /// The inner test waves, whose orders are reversed, and the outer waves.
303    fn wave_sets(&self) -> Result<[WaveSet; 2]> {
304        let reversed = self
305            .destination
306            .iter()
307            .map(|&mode| Mode { m: -mode.m, ..mode });
308        Ok([
309            origin_waves(reversed, self.ks[0], Radial::Regular)?,
310            origin_waves(self.source.iter().copied(), self.ks[1], self.radial)?,
311        ])
312    }
313
314    /// Integration weight of a node: the quadrature weight, `sin(theta)` and, with
315    /// `radial_area_factor`, the radius.
316    fn weight(&self, node: usize) -> f64 {
317        self.measure(node)
318            * if self.radial_area_factor {
319                self.surface.radii[node]
320            } else {
321                1.0
322            }
323    }
324
325    /// The quadrature weight times `sin(theta)`, which does not depend on the radius.
326    fn measure(&self, node: usize) -> f64 {
327        self.surface.weights[node] * self.surface.theta[node].sin()
328    }
329
330    /// The rows of each azimuthal order; only equal orders couple.
331    fn rows_by_order(&self) -> HashMap<i32, Vec<usize>> {
332        let mut rows = HashMap::<i32, Vec<usize>>::new();
333        for (i, mode) in self.destination.iter().enumerate() {
334            rows.entry(mode.m).or_default().push(i);
335        }
336        rows
337    }
338
339    /// The impedance factor `HELICITY[a] z_outer + HELICITY[b] z_inner` of each (row
340    /// helicity `a`, column helicity `b`) pair.
341    fn factors(&self) -> [[Complex; 2]; 2] {
342        HELICITY.map(|a| HELICITY.map(|b| a * self.zs[1] + b * self.zs[0]))
343    }
344
345    /// Matrix shape, for binding-level cotangent validation.
346    #[must_use]
347    pub fn shape(&self) -> (usize, usize) {
348        (self.destination.len(), self.source.len())
349    }
350
351    /// Number of fixed quadrature nodes, for binding-level tangent validation.
352    #[must_use]
353    pub fn samples(&self) -> usize {
354        self.surface.radii.len()
355    }
356
357    /// Directional derivative with respect to the surface radii and slopes,
358    /// wavenumbers and impedances. Quadrature nodes and weights stay fixed.
359    /// Each wave's local derivatives are contracted once per node, before the
360    /// matrix entries are assembled, sharing the same cached waves as the pullback.
361    pub fn pushforward(
362        &self,
363        radii: &[f64],
364        slopes: &[f64],
365        ks: [[Complex; 2]; 2],
366        zs: [Complex; 2],
367    ) -> Result<DMatrix<Complex>> {
368        if radii.len() != self.samples()
369            || slopes.len() != self.samples()
370            || radii.iter().chain(slopes).any(|x| !x.is_finite())
371            || ks.iter().flatten().chain(&zs).any(|&z| !finite(z))
372        {
373            return Err(Error::InvalidInput("invalid EBCM tangents".into()));
374        }
375        let [destination, source] = self.wave_sets()?;
376        let samples = crate::threads::install(|| {
377            (0..self.samples())
378                .into_par_iter()
379                .map(|node| -> Result<SampleTangent> {
380                    let [point, normal, rhat, slope] = node_geometry(&self.surface, node);
381                    let point_tangent = rhat.map(|x| x * radii[node]);
382                    let contract = |waves: Vec<VectorWave>, modes: &[Mode], dk: [Complex; 2]| {
383                        waves
384                            .into_iter()
385                            .zip(modes)
386                            .map(|(wave, mode)| {
387                                let tangent = std::array::from_fn(|component| {
388                                    wave.k[component] * dk[usize::from(mode.pol)]
389                                        + (0..3)
390                                            .map(|axis| {
391                                                wave.position[component][axis] * point_tangent[axis]
392                                            })
393                                            .sum::<Complex>()
394                                });
395                                (wave.value, tangent)
396                            })
397                            .unzip()
398                    };
399                    let (a, da) = contract(
400                        evaluate_waves::<SPATIAL_AND_K>(
401                            &destination,
402                            self.destination.len(),
403                            point,
404                        )?,
405                        &self.destination,
406                        ks[0],
407                    );
408                    let (b, db) = contract(
409                        evaluate_waves::<SPATIAL_AND_K>(&source, self.source.len(), point)?,
410                        &self.source,
411                        ks[1],
412                    );
413                    Ok(SampleTangent {
414                        value: Sample {
415                            weight: self.weight(node),
416                            normal,
417                            destination: a,
418                            source: b,
419                        },
420                        weight: if self.radial_area_factor {
421                            self.measure(node) * radii[node]
422                        } else {
423                            0.0
424                        },
425                        normal: std::array::from_fn(|axis| {
426                            radii[node] * rhat[axis] + slopes[node] * slope[axis]
427                        }),
428                        destination: da,
429                        source: db,
430                    })
431                })
432                .collect::<Result<Vec<_>>>()
433        })?;
434        let rows = self.rows_by_order();
435        let factors = self.factors();
436        let factor_tangents = HELICITY.map(|a| HELICITY.map(|b| a * zs[1] + b * zs[0]));
437        let mut tangent = numerics::zeros(self.destination.len(), self.source.len())?;
438        crate::threads::install(|| {
439            tangent
440                .as_mut_slice()
441                .par_chunks_mut(self.destination.len())
442                .zip(&self.source)
443                .enumerate()
444                .for_each(|(j, (column, from))| {
445                    let rows = rows.get(&from.m).map_or(&[][..], Vec::as_slice);
446                    let pb = usize::from(from.pol);
447                    for sample in &samples {
448                        let value = &sample.value;
449                        for &i in rows {
450                            let pa = usize::from(self.destination[i].pol);
451                            let factor = factors[pa][pb];
452                            let a = value.destination[i];
453                            let b = value.source[j];
454                            let integral = triple(value.normal, a, b);
455                            let derivative = triple(sample.normal, a, b)
456                                + triple(value.normal, sample.destination[i], b)
457                                + triple(value.normal, a, sample.source[j]);
458                            column[i] += (sample.weight * factor
459                                + value.weight * factor_tangents[pa][pb])
460                                * integral
461                                + value.weight * factor * derivative;
462                        }
463                    }
464                });
465        });
466        if tangent.iter().any(|&z| !finite(z)) {
467            return Err(Error::NonFinite("non-finite EBCM tangent".into()));
468        }
469        Ok(tangent)
470    }
471
472    /// Gradients of the surface radii and slopes, the wavenumbers and the impedances
473    /// from `cotangent`, the gradient of a real loss with respect to the matrix. The
474    /// quadrature nodes and weights stay fixed. The sums run over the nodes in order,
475    /// so the gradients do not depend on the thread count.
476    pub fn pullback(&self, cotangent: &DMatrix<Complex>) -> Result<QmatGradient> {
477        if cotangent.shape() != self.shape() || cotangent.iter().any(|&z| !finite(z)) {
478            return Err(Error::InvalidInput("invalid EBCM cotangent".into()));
479        }
480        let rows = self.rows_by_order();
481        let factors = self.factors();
482        let [destination, source] = self.wave_sets()?;
483        let nodes = crate::threads::install(|| {
484            (0..self.surface.theta.len())
485                .into_par_iter()
486                .map(|node| {
487                    let [point, normal, rhat, slope] = node_geometry(&self.surface, node);
488                    let a = evaluate_waves::<SPATIAL_AND_K>(
489                        &destination,
490                        self.destination.len(),
491                        point,
492                    )?;
493                    let b = evaluate_waves::<SPATIAL_AND_K>(&source, self.source.len(), point)?;
494                    let (measure, weight) = (self.measure(node), self.weight(node));
495                    let along_radius = |wave: &VectorWave| -> [Complex; 3] {
496                        std::array::from_fn(|c| (0..3).map(|d| wave.position[c][d] * rhat[d]).sum())
497                    };
498                    let ar: Vec<_> = a.iter().map(along_radius).collect();
499                    let mut result = NodeGradient::default();
500                    for (j, (vb, from)) in b.iter().zip(&self.source).enumerate() {
501                        let pb = usize::from(from.pol);
502                        // With triple(n, a, b) = a . (b x n), each outer wave's cross
503                        // products serve all of its inner partners.
504                        let normal_cross = cross(vb.value, normal);
505                        let rhat_cross = cross(vb.value, rhat);
506                        let slope_cross = cross(vb.value, slope);
507                        let radius_cross = cross(along_radius(vb), normal);
508                        let k_cross = cross(vb.k, normal);
509                        for &i in rows.get(&from.m).into_iter().flatten() {
510                            let cot = cotangent[(i, j)];
511                            if cot == Complex::default() {
512                                continue;
513                            }
514                            let va = &a[i];
515                            let pa = usize::from(self.destination[i].pol);
516                            let factor = factors[pa][pb];
517                            let t = dot(va.value, normal_cross);
518                            let dr = dot(va.value, rhat_cross)
519                                + dot(ar[i], normal_cross)
520                                + dot(va.value, radius_cross);
521                            let scaled = cot.conj() * weight;
522                            let weighted = scaled * factor;
523                            result.radius += (weighted * dr).re;
524                            if self.radial_area_factor {
525                                result.radius += (cot.conj() * measure * factor * t).re;
526                            }
527                            result.slope += (weighted * dot(va.value, slope_cross)).re;
528                            result.ks[0][pa] += (weighted * dot(va.k, normal_cross)).conj();
529                            result.ks[1][pb] += (weighted * dot(va.value, k_cross)).conj();
530                            result.zs[0] += (scaled * HELICITY[pb] * t).conj();
531                            result.zs[1] += (scaled * HELICITY[pa] * t).conj();
532                        }
533                    }
534                    Ok(result)
535                })
536                .collect::<Result<Vec<_>>>()
537        })?;
538        let mut result = QmatGradient {
539            radii: Vec::with_capacity(nodes.len()),
540            slopes: Vec::with_capacity(nodes.len()),
541            ks: [[Complex::default(); 2]; 2],
542            zs: [Complex::default(); 2],
543        };
544        for node in nodes {
545            result.radii.push(node.radius);
546            result.slopes.push(node.slope);
547            for side in 0..2 {
548                result.zs[side] += node.zs[side];
549                for pol in 0..2 {
550                    result.ks[side][pol] += node.ks[side][pol];
551                }
552            }
553        }
554        Ok(result)
555    }
556}
557
558impl SavedState for QmatResidual {
559    fn save_state(&self) -> Result<Vec<u8>> {
560        let mut writer = Writer::new(Self::state_size(
561            self.samples(),
562            self.destination.len(),
563            self.source.len(),
564        )?);
565        writer.usize(self.samples());
566        writer.usize(self.destination.len());
567        writer.usize(self.source.len());
568        writer.byte(u8::from(self.radial == Radial::Singular));
569        writer.byte(u8::from(self.radial_area_factor));
570        for mode in self.destination.iter().chain(&self.source) {
571            writer.i32(mode.l);
572            writer.i32(mode.m);
573            writer.byte(mode.pol);
574        }
575        for values in [
576            &self.surface.theta,
577            &self.surface.weights,
578            &self.surface.radii,
579            &self.surface.slopes,
580        ] {
581            for &value in values {
582                writer.f64(value);
583            }
584        }
585        for &value in self.ks.iter().flatten().chain(&self.zs) {
586            writer.complex(value);
587        }
588        Ok(writer.finish())
589    }
590
591    fn from_state(bytes: &[u8]) -> Result<Self> {
592        let mut reader = Reader::new(bytes);
593        let samples = reader.usize()?;
594        let destinations = reader.usize()?;
595        let sources = reader.usize()?;
596        if bytes.len() != Self::state_size(samples, destinations, sources)? {
597            return Err(invalid());
598        }
599        let radial = match reader.byte()? {
600            0 => Radial::Regular,
601            1 => Radial::Singular,
602            _ => return Err(invalid()),
603        };
604        let radial_area_factor = match reader.byte()? {
605            0 => false,
606            1 => true,
607            _ => return Err(invalid()),
608        };
609        let mut modes = |count| {
610            (0..count)
611                .map(|_| {
612                    Ok(Mode {
613                        l: reader.i32()?,
614                        m: reader.i32()?,
615                        pol: reader.byte()?,
616                    })
617                })
618                .collect::<Result<Vec<_>>>()
619        };
620        let destination = modes(destinations)?;
621        let source = modes(sources)?;
622        let mut surface_values = || {
623            (0..samples)
624                .map(|_| reader.f64())
625                .collect::<Result<Vec<_>>>()
626        };
627        let surface = Surface {
628            theta: surface_values()?,
629            weights: surface_values()?,
630            radii: surface_values()?,
631            slopes: surface_values()?,
632        };
633        let ks = [
634            [reader.complex()?, reader.complex()?],
635            [reader.complex()?, reader.complex()?],
636        ];
637        let zs = [reader.complex()?, reader.complex()?];
638        reader.finish()?;
639        let residual = Self {
640            destination,
641            source,
642            surface,
643            ks,
644            zs,
645            radial,
646            radial_area_factor,
647        };
648        residual.validate()?;
649        Ok(residual)
650    }
651}
652
653/// The gradient contributions of one node: its radius and slope, and its share of
654/// the wavenumber and impedance gradients.
655#[derive(Default)]
656struct NodeGradient {
657    radius: f64,
658    slope: f64,
659    ks: [[Complex; 2]; 2],
660    zs: [Complex; 2],
661}
662
663#[cfg(test)]
664mod tests {
665    #![allow(clippy::float_cmp)] // Saved state must preserve every derivative exactly.
666
667    use super::{QmatResidual, Surface, qmat};
668    use crate::{Complex, Result, saved::SavedState, special::Radial, sw::Mode};
669    use nalgebra::DMatrix;
670
671    /// Only the wavenumbers of helicities with modes are evaluated, so the others may
672    /// be zero, in the forward integral and in its pullback.
673    #[test]
674    fn unused_helicities_accept_zero_wavenumbers() {
675        let surface = || Surface {
676            theta: vec![0.5, 1.5, 2.5],
677            weights: vec![1.0; 3],
678            radii: vec![0.3, 0.35, 0.3],
679            slopes: vec![0.1, 0.0, -0.1],
680        };
681        let modes = vec![Mode { l: 1, m: 0, pol: 1 }, Mode { l: 2, m: 1, pol: 1 }];
682        let (k, zero) = (Complex::new(1.3, 0.1), Complex::default());
683        let zs = [Complex::new(1.0, 0.0), Complex::new(0.8, 0.1)];
684        let q = |ks| {
685            qmat(
686                modes.clone(),
687                modes.clone(),
688                surface(),
689                ks,
690                zs,
691                Radial::Singular,
692                true,
693            )
694        };
695        let (value, residual) = q([[zero, k], [zero, k * 2.0]]).unwrap();
696        let (expected, _) = q([[k, k], [k, k * 2.0]]).unwrap();
697        assert_eq!(value, expected);
698        let gradient = residual.pullback(&DMatrix::from_element(2, 2, k)).unwrap();
699        assert_eq!(gradient.ks[0][0], zero);
700        assert!(q([[k, zero], [k, k]]).is_err());
701    }
702
703    fn state_fixture(radial: Radial, radial_area_factor: bool) -> Result<QmatResidual> {
704        let destination = vec![
705            Mode { l: 1, m: 0, pol: 0 },
706            Mode { l: 2, m: 1, pol: 1 },
707            Mode {
708                l: 1,
709                m: -1,
710                pol: 0,
711            },
712        ];
713        let source = vec![Mode { l: 2, m: 1, pol: 0 }, Mode { l: 1, m: 0, pol: 1 }];
714        let surface = Surface {
715            theta: vec![0.5, 1.5, 2.5],
716            weights: vec![0.7, 0.9, 0.8],
717            radii: vec![0.3, 0.35, 0.3],
718            slopes: vec![0.1, 0.0, -0.1],
719        };
720        let ks = [
721            [Complex::new(1.3, 0.1), Complex::new(1.4, 0.12)],
722            [Complex::new(1.1, 0.0), Complex::new(1.2, 0.02)],
723        ];
724        let zs = [Complex::new(0.9, 0.03), Complex::new(0.8, 0.1)];
725        qmat(
726            destination,
727            source,
728            surface,
729            ks,
730            zs,
731            radial,
732            radial_area_factor,
733        )
734        .map(|(_, residual)| residual)
735    }
736
737    #[test]
738    fn saved_state_preserves_repeated_derivatives() -> Result<()> {
739        let radii = [0.03, -0.02, 0.01];
740        let slopes = [0.02, 0.01, -0.04];
741        let ks = [
742            [Complex::new(0.02, 0.01), Complex::new(-0.03, 0.02)],
743            [Complex::new(0.01, -0.02), Complex::new(-0.02, -0.01)],
744        ];
745        let zs = [Complex::new(0.02, -0.01), Complex::new(-0.01, 0.03)];
746        let cotangent = DMatrix::from_row_slice(
747            3,
748            2,
749            &[
750                Complex::new(0.1, 0.2),
751                Complex::new(-0.2, 0.3),
752                Complex::new(0.3, -0.1),
753                Complex::new(-0.4, 0.1),
754                Complex::new(0.2, 0.4),
755                Complex::new(0.1, -0.3),
756            ],
757        );
758        for radial in [Radial::Regular, Radial::Singular] {
759            for radial_area_factor in [false, true] {
760                let residual = state_fixture(radial, radial_area_factor)?;
761                let state = residual.save_state()?;
762                assert_eq!(state.len(), QmatResidual::state_size(3, 3, 2)?);
763                let restored = QmatResidual::from_state(&state)?;
764                assert_eq!(restored.shape(), (3, 2));
765                assert_eq!(restored.samples(), 3);
766                let expected = residual.pushforward(&radii, &slopes, ks, zs)?;
767                let expected_gradient = residual.pullback(&cotangent)?;
768                for _ in 0..2 {
769                    assert_eq!(restored.pushforward(&radii, &slopes, ks, zs)?, expected);
770                    let gradient = restored.pullback(&cotangent)?;
771                    assert_eq!(gradient.radii, expected_gradient.radii);
772                    assert_eq!(gradient.slopes, expected_gradient.slopes);
773                    assert_eq!(gradient.ks, expected_gradient.ks);
774                    assert_eq!(gradient.zs, expected_gradient.zs);
775                }
776                assert_eq!(restored.save_state()?, state);
777            }
778        }
779        Ok(())
780    }
781
782    #[test]
783    fn saved_state_rejects_malformed_layout_and_invalid_domain() -> Result<()> {
784        let state = state_fixture(Radial::Singular, true)?.save_state()?;
785        for length in [0, 1, 23, 24, 25, state.len() - 1] {
786            assert!(QmatResidual::from_state(&state[..length]).is_err());
787        }
788        let mut trailing = state.clone();
789        trailing.push(0);
790        assert!(QmatResidual::from_state(&trailing).is_err());
791        for offset in [0, 8, 16] {
792            for count in [0_u64, u64::MAX] {
793                let mut malformed = state.clone();
794                malformed[offset..offset + 8].copy_from_slice(&count.to_le_bytes());
795                assert!(QmatResidual::from_state(&malformed).is_err());
796            }
797        }
798        // Radial kind, area-factor flag and the first mode's polarization.
799        for offset in [24, 25, 34] {
800            let mut malformed = state.clone();
801            malformed[offset] = 2;
802            assert!(QmatResidual::from_state(&malformed).is_err());
803        }
804        let mut malformed = state;
805        let first_radius = 26 + 9 * 5 + 16 * 3;
806        malformed[first_radius..first_radius + 8].copy_from_slice(&f64::NAN.to_le_bytes());
807        assert!(QmatResidual::from_state(&malformed).is_err());
808        assert!(QmatResidual::state_size(usize::MAX, 1, 1).is_err());
809        assert!(QmatResidual::state_size(1, usize::MAX, 1).is_err());
810        assert!(QmatResidual::state_size(1, 1, usize::MAX).is_err());
811        Ok(())
812    }
813}