treams_core/lattice/
ewald.rs

1//! The Ewald sum: real-space part, reciprocal-space part and self term.
2//!
3//! Upstream: `lsum*`, `realsum*` and `recsum*` of `lattice/_esum.pyx`. The reduction
4//! into a cell, the exact zeros by symmetry, the checks at small splits and the
5//! spectral series of 1D spherical sums are treams-rs extensions.
6
7use std::{
8    cell::{Cell, OnceCell, RefCell},
9    f64::consts::PI,
10};
11
12use super::{
13    SumPart,
14    accuracy::{
15        Checked, EARLY_FAILURE_LOSS, Rounding, TERM_ULPS, below_automatic, cancellation,
16        derivative_scale, too_small,
17    },
18    cell::{BlochLattice, Reduction},
19    evaluate,
20    inputs::{Evaluation, Inputs},
21    real::{RealKambe, real_term, spherical_from_radial, spherical_radial},
22    reciprocal::{Diffraction, reciprocal_term, self_term},
23    sheets::Split,
24    shells::{
25        NOT_CONVERGED, SHELL_TOLERANCE, Shells, is_shell_limit, norm, not_converged, peak_shells,
26        real_shells, reciprocal_shells,
27    },
28    spectral::{
29        REAL_ULPS, SPECTRAL_FIRST_W, SPECTRAL_SWITCH_LOSS, prefer_spectral_sw1d, spectral_sw1d,
30    },
31    wave::Family,
32};
33use crate::{Complex, Error, Result, numerics::Jet, special::SmallSplitKambe};
34
35/// The Ewald parts selected by `evaluation` with split `eta`. Both sums run over shells of
36/// reduced bases, and the reciprocal sum over the Bloch vector reduced into a cell
37/// of the reduced reciprocal basis, so their results do not depend on the given basis
38/// or on the Bloch vector's cell.
39pub(super) fn ewald<const N: usize>(
40    wave: Family,
41    lattice: &BlochLattice,
42    mut inputs: Inputs<N>,
43    eta: Complex,
44    evaluation: Evaluation,
45    shared: Option<&SphericalCache<N>>,
46) -> Result<Jet<N>> {
47    let CellReduction {
48        phase,
49        direct_rows: direct,
50        reciprocal_rows: rows,
51        kpar,
52        kpar_reduced: reduced,
53        given_shift,
54    } = reduce_to_cell(lattice, &mut inputs);
55    let inputs = &inputs;
56    let dim = inputs.dim;
57    let symmetry = Symmetry::of(wave, inputs, &direct, &kpar);
58    if symmetry.vanishes && (N == 0 || matches!(evaluation, Evaluation::EtaDerivative)) {
59        return Ok(Jet::default());
60    }
61    // Only splits below every automatic one check the cancellation of their parts.
62    let small = below_automatic(eta);
63    let checked = symmetry.checked(inputs, evaluation);
64    let real_limit = real_shells(lattice, inputs.k.value, eta);
65    // The shells before which neither part may stop (see `peak_shells`): the real-space
66    // terms fall like `exp(-Re((k eta)^2) |R|^2 / 2)`, the reciprocal ones like
67    // `exp(-Re(1 / (k eta)^2) |q + G|^2 / 2)`.
68    let square = (inputs.k.value * eta).powi(2);
69    let real_floor = peak_shells(
70        wave,
71        dim,
72        N > 0,
73        square.re,
74        norm(inputs.r.map(|r| r.value.re)),
75        (&lattice.reciprocal, lattice.direct_reduction.as_ref()),
76    );
77    let reciprocal_floor = peak_shells(
78        wave,
79        dim,
80        N > 0,
81        square.inv().re,
82        norm(kpar.map(|q| q.value.re)),
83        (&lattice.direct, lattice.reciprocal_reduction.as_ref()),
84    );
85    // Complete sums may settle their real-space shells (see `Shells::sum`) and fail
86    // early (see `EARLY_FAILURE_LOSS`).
87    let settle = small && matches!(evaluation, Evaluation::Part(SumPart::Full));
88    let early = settle && probes::early_failure_enabled();
89    let automatic_split = AutomaticSplit {
90        wave,
91        k: inputs.k.value,
92        lattice,
93        shift: given_shift,
94        evaluation,
95    };
96    let monitor = SmallSplitMonitor::new(
97        (small && !matches!(evaluation, Evaluation::EtaDerivative))
98            .then(|| SmallSplitKambe::new(eta)),
99        early,
100        automatic_split,
101        &checked,
102    );
103    let real_summands = RealSummands {
104        wave,
105        inputs,
106        direct: &direct,
107        eta,
108        evaluation,
109        monitor: &monitor,
110        shared: if small { None } else { shared },
111        index: Cell::new(0),
112    };
113    let real_summand = |n, bounds: &mut Rounding<N>| real_summands.at(n, bounds);
114    let real_part = || {
115        Shells::sum(
116            dim,
117            [real_limit, real_floor],
118            &mut { real_summand },
119            settle,
120            small,
121        )
122    };
123    if !evaluation.reciprocal() {
124        let real = real_part()?;
125        let sum = phase * real.sum;
126        if small {
127            cancellation(&[&real], &Jet::default(), 0.0, &phase, &sum, &checked)?;
128        }
129        return Ok(symmetry.zeroed(sum));
130    }
131    // 1D spherical sums may take the spectral series of the complete sum (less the
132    // real part for the reciprocal part alone), evaluated once.
133    let sw1d = match wave {
134        Family::Spherical { l, m } if dim == 1 && probes::spectral_series_enabled() => Some((l, m)),
135        _ => None,
136    };
137    let series_cell = OnceCell::new();
138    let series = || {
139        *series_cell.get_or_init(|| {
140            let (mut series, mut bound) = spectral_sw1d(
141                sw1d?,
142                inputs.k,
143                inputs.r,
144                kpar[0],
145                rows[0][0],
146                inputs.measure,
147            )?;
148            if !evaluation.real() {
149                let real = real_part().ok()?.sum;
150                series -= real;
151                bound += Rounding::relative(&real, REAL_ULPS);
152            }
153            Some((phase * series, bound.times(&phase)))
154        })
155    };
156    // Far off the axis the Ewald sum cancels (see `sum`), so a series that keeps its
157    // accuracy is taken without it.
158    let rho = (inputs.r[0].value.re).hypot(inputs.r[1].value.re);
159    if sw1d.is_some()
160        && inputs.k.value.norm() * rho * eta.norm() >= SPECTRAL_FIRST_W
161        && let Some((series, bound)) = series()
162        && f64::EPSILON * bound.value <= SPECTRAL_SWITCH_LOSS * series.value.norm().max(1.0)
163    {
164        return Ok(series);
165    }
166    let real = if evaluation.real() {
167        real_part().map(Some)
168    } else {
169        Ok(None)
170    };
171    let [maximum, extension] =
172        reciprocal_shells(lattice, inputs.k.value, eta, kpar.map(|q| q.value.re));
173    // A 1D spherical sum whose reciprocal part passes `extension` shells takes its
174    // spectral series where that is accurate. Calibrated: for a degree-3 chain the series
175    // lies 2e-15 from the sum at the automatic split, and the Ewald sum continued to the
176    // larger radius 3e-13.
177    let maximum = if sw1d.is_some() { extension } else { maximum };
178    // Absolute rounding error bounds of the reciprocal part in units of epsilon.
179    let mut rounding = Rounding::default();
180    let split = Split::new(inputs.k.value, eta);
181    let reciprocal_summands = ReciprocalSummands {
182        wave,
183        inputs,
184        rows: &rows,
185        kpar: &kpar,
186        kpar_reduced: reduced,
187        eta,
188        split: &split,
189        shared: if !small && dim == 2 && inputs.r[2].value == Complex::default() {
190            shared
191        } else {
192            None
193        },
194        index: Cell::new(0),
195    };
196    let reciprocal = Shells::sum(
197        dim,
198        [maximum, reciprocal_floor],
199        &mut |n, _| reciprocal_summands.at(n, &mut rounding),
200        false,
201        small,
202    );
203    let self_term = if inputs.r.iter().all(|r| r.value == Complex::default()) {
204        self_term(wave, inputs.k, inputs.r, eta)
205    } else {
206        Jet::default()
207    };
208    let total = |real: &Option<Shells<N>>, reciprocal: &Shells<N>| {
209        phase * (real.map_or_else(Jet::default, |real| real.sum) + reciprocal.sum + self_term)
210    };
211    // Below every automatic split both parts continue to `SHELL_TOLERANCE` of the
212    // complete sum, which fails where they cancel beyond use (and goes to
213    // `prefer_spectral_sw1d` then).
214    let complete_small_split =
215        |mut real: Option<Shells<N>>, mut reciprocal: Shells<N>, rounding: &mut Rounding<N>| {
216            let scale = Some(total(&real, &reciprocal).norm().max(1.0));
217            let mut tail = 0.0;
218            if !reciprocal.extend(
219                dim,
220                extension,
221                &mut |n, _| reciprocal_summands.at(n, rounding),
222                scale,
223            )? {
224                tail += reciprocal.tail();
225            }
226            if let Some(real) = &mut real
227                && !real.extend(dim, real_limit, &mut { real_summand }, scale)?
228            {
229                tail += real.tail();
230            }
231            let sum = total(&real, &reciprocal);
232            let parts: Vec<&Shells<N>> = real.iter().chain([&reciprocal]).collect();
233            let rounding = cancellation(&parts, &self_term, tail, &phase, &sum, &checked)?;
234            Ok((sum, rounding, tail))
235        };
236    let settled = matches!(&real, Ok(Some(real)) if real.settled);
237    // Every policy branch returns its sum, predicted cancellation and truncation.
238    // Errors still reach the spectral fallback, with no completed-part bounds.
239    let policy = match (real, reciprocal) {
240        (Err(error), _) | (Ok(_), Err(error)) => Err(error),
241        (Ok(real), Ok(reciprocal)) if small => {
242            complete_small_split(real, reciprocal, &mut rounding)
243        }
244        (Ok(real), Ok(reciprocal)) => Ok((total(&real, &reciprocal), Rounding::default(), 0.0)),
245    };
246    let (result, predicted, truncation) = match policy {
247        Ok((sum, predicted, truncation)) => (Ok(sum), predicted, truncation),
248        Err(error) => (Err(error), Rounding::default(), 0.0),
249    };
250    let result = if sw1d.is_none() {
251        result.map(|sum| symmetry.zeroed(sum))
252    } else {
253        let mut rounding = rounding.times(&phase);
254        rounding += predicted;
255        rounding.value += truncation / f64::EPSILON;
256        prefer_spectral_sw1d(result, rounding, series, inputs.k.value)
257            .map(|sum| symmetry.zeroed(sum))
258    };
259    if !settled {
260        return result;
261    }
262    verify_settled(result?, predicted.value, automatic_split)
263}
264
265/// Ewald samples of one spherical degree, shared by its orders. Each order still
266/// follows its own shell convergence test; the shell iterator visits the same prefix
267/// of lattice points, so later orders reuse the samples already visited and append
268/// only the farther points they need. Each slot also records its lattice point:
269/// a different visit order evaluates mismatched slots afresh, never reusing another
270/// point's sample. The cache lives for one degree evaluation with fixed inputs.
271#[derive(Default)]
272pub(super) struct SphericalCache<const N: usize> {
273    samples: RefCell<Vec<SphericalRealSample<N>>>,
274    plane_samples: RefCell<Vec<SphericalReciprocalSample<N>>>,
275}
276
277/// A bounded prefix covers ordinary Ewald sums without retaining a large 3D cube
278/// at the shell limit: at most 1.4 MiB per degree for the largest (16-slot) jets.
279/// Farther samples stream through the scalar arithmetic instead.
280const SHARED_REAL_POINTS: usize = 1024;
281
282#[cfg(test)]
283mod cache_tests {
284    use super::{SHARED_REAL_POINTS, SphericalCache};
285    use crate::{
286        Complex,
287        lattice::{BlochLattice, Family, SumPart, evaluate_shared, inputs::Evaluation},
288    };
289
290    /// Reordering a cached prefix must never substitute another point's geometry
291    /// or integrals, for either forward values or jets in any lattice dimension.
292    #[test]
293    fn spherical_sample_caches_verify_the_visited_lattice_points() {
294        fn check<const N: usize>() {
295            for dim in 1..=3 {
296                let rows: Vec<Vec<f64>> = (0..dim)
297                    .map(|i| (0..dim).map(|j| if i == j { 2.0 } else { 0.2 }).collect())
298                    .collect();
299                let lattice = BlochLattice::new(&rows, &vec![0.13; dim]).unwrap();
300                let cache = SphericalCache::<N>::default();
301                for m in [0, 2, -1, 3, -3] {
302                    let run = |shared| {
303                        let jet = evaluate_shared::<N>(
304                            Family::Spherical { l: 3, m },
305                            Complex::new(1.2, 0.08),
306                            &lattice,
307                            [0.3, -0.2, 0.0],
308                            Complex::new(1.0, 0.0),
309                            Evaluation::Part(SumPart::Full),
310                            shared,
311                        )
312                        .unwrap();
313                        crate::test_support::bits(&[&jet.value, &jet.derivative])
314                    };
315                    let reference = run(None);
316                    assert_eq!(run(Some(&cache)), reference);
317                    let counts = (
318                        cache.samples.borrow().len(),
319                        cache.plane_samples.borrow().len(),
320                    );
321                    assert!(counts.0 > 1);
322                    if dim == 2 {
323                        assert!(counts.1 > 1);
324                    }
325                    cache.samples.borrow_mut().reverse();
326                    cache.plane_samples.borrow_mut().reverse();
327                    assert_eq!(run(Some(&cache)), reference);
328                    assert_eq!(
329                        (
330                            cache.samples.borrow().len(),
331                            cache.plane_samples.borrow().len()
332                        ),
333                        counts,
334                    );
335                }
336            }
337        }
338        check::<0>();
339        check::<16>();
340    }
341
342    #[test]
343    fn spherical_sample_cache_keeps_only_a_bounded_prefix() {
344        let lattice = BlochLattice::new(
345            &[
346                vec![2.0, 0.2, 0.2],
347                vec![0.2, 2.0, 0.2],
348                vec![0.2, 0.2, 2.0],
349            ],
350            &[0.13; 3],
351        )
352        .unwrap();
353        let cache = SphericalCache::default();
354        for m in [-2, 0, 2] {
355            evaluate_shared::<16>(
356                Family::Spherical { l: 2, m },
357                Complex::new(1.2, 0.08),
358                &lattice,
359                [0.3, -0.2, 0.4],
360                Complex::new(0.4, 0.0),
361                Evaluation::Part(SumPart::Full),
362                Some(&cache),
363            )
364            .unwrap();
365            assert_eq!(cache.samples.borrow().len(), SHARED_REAL_POINTS);
366        }
367    }
368}
369
370#[derive(Clone, Copy)]
371struct SphericalRealSample<const N: usize> {
372    point: [i64; 3],
373    shift: [Jet<N>; 3],
374    radial: Jet<N>,
375    phase: Jet<N>,
376}
377
378struct SphericalReciprocalSample<const N: usize> {
379    point: [i64; 3],
380    diffraction: Diffraction<N>,
381    phase: Jet<N>,
382    integrals: Vec<Option<Complex>>,
383}
384
385/// What the real-space terms of one sum read.
386struct RealSummands<'a, const N: usize> {
387    wave: Family,
388    inputs: &'a Inputs<N>,
389    /// The reduced direct rows.
390    direct: &'a [[Jet<N>; 3]; 3],
391    eta: Complex,
392    evaluation: Evaluation,
393    monitor: &'a SmallSplitMonitor<'a, N>,
394    shared: Option<&'a SphericalCache<N>>,
395    index: Cell<usize>,
396}
397
398impl<const N: usize> RealSummands<'_, N> {
399    /// The real-space term at the lattice point with the coordinates `n`, adding its
400    /// rounding bounds below every automatic split to `bounds`. Always inlined: it is
401    /// the body of the term loop of [`Shells::sum`].
402    #[allow(clippy::inline_always)]
403    #[inline(always)]
404    fn at(&self, n: [i64; 3], bounds: &mut Rounding<N>) -> Result<Jet<N>> {
405        let (wave, inputs, direct, eta, evaluation, monitor) = (
406            self.wave,
407            self.inputs,
408            self.direct,
409            self.eta,
410            self.evaluation,
411            self.monitor,
412        );
413        if let (Some(shared), Family::Spherical { l, m }) = (self.shared, wave) {
414            return Ok(self.spherical_at(shared, l, m, n));
415        }
416        let point = inputs.point(direct, n);
417        let shift = inputs.image(&point);
418        if shift.iter().all(|r| r.value == Complex::default()) {
419            return Ok(Jet::default());
420        }
421        probes::count_real_term();
422        // Below every automatic split only: the rounding of the largest term so far and
423        // the bound of this one.
424        let mut small = monitor
425            .kambe
426            .as_ref()
427            .map(|kambe| (kambe, monitor.largest.get(), Rounding::default()));
428        let kambe = match (&mut small, evaluation) {
429            (Some((kambe, current, bound)), _) => RealKambe::SmallSplit {
430                kambe,
431                largest: current,
432                bound,
433            },
434            (None, Evaluation::EtaDerivative) => RealKambe::EtaDerivative,
435            (None, _) => RealKambe::Plain,
436        };
437        let term = real_term(wave, inputs.k, shift, eta, kambe);
438        let phase = (Complex::i() * inputs.phase(&point)).exp();
439        let Some((_, current, bound)) = small else {
440            return Ok(term * phase);
441        };
442        monitor
443            .largest
444            .set(current.max(Rounding::relative(&term, 1.0)));
445        let (term, bound) = (term * phase, bound.times(&phase));
446        *bounds += bound;
447        monitor.add_to_running(&term, bound)?;
448        Ok(term)
449    }
450
451    /// The ordinary spherical term, preserving its operation order while sharing its
452    /// geometry, Bloch phase and Kambe integral with the other orders of this degree.
453    fn spherical_at(&self, shared: &SphericalCache<N>, l: i32, m: i32, n: [i64; 3]) -> Jet<N> {
454        let index = self.index.replace(self.index.get() + 1);
455        let mut samples = shared.samples.borrow_mut();
456        let sample = if let Some(&sample) = samples.get(index).filter(|sample| sample.point == n) {
457            sample
458        } else {
459            let point = self.inputs.point(self.direct, n);
460            let shift = self.inputs.image(&point);
461            let radial = if shift.iter().all(|r| r.value == Complex::default()) {
462                Jet::default()
463            } else {
464                spherical_radial(l, self.inputs.k, shift, self.eta)
465            };
466            let sample = SphericalRealSample {
467                point: n,
468                shift,
469                radial,
470                phase: (Complex::i() * self.inputs.phase(&point)).exp(),
471            };
472            if index == samples.len() && index < SHARED_REAL_POINTS {
473                samples.push(sample);
474            }
475            sample
476        };
477        if sample.shift.iter().all(|r| r.value == Complex::default()) {
478            return Jet::default();
479        }
480        probes::count_real_term();
481        spherical_from_radial(l, m, self.inputs.k, sample.shift, sample.radial) * sample.phase
482    }
483}
484
485/// What the reciprocal-space terms of one sum read.
486struct ReciprocalSummands<'a, const N: usize> {
487    wave: Family,
488    inputs: &'a Inputs<N>,
489    /// The reduced reciprocal rows.
490    rows: &'a [[Jet<N>; 3]; 3],
491    /// The Bloch vector reduced into a cell of `rows`.
492    kpar: &'a [Jet<N>; 3],
493    /// Whether the reduction moved the Bloch vector.
494    kpar_reduced: bool,
495    eta: Complex,
496    split: &'a Split,
497    shared: Option<&'a SphericalCache<N>>,
498    index: Cell<usize>,
499}
500
501impl<const N: usize> ReciprocalSummands<'_, N> {
502    /// The reciprocal-space term at the reciprocal lattice point with the coordinates
503    /// `n`, adding its absolute rounding bounds in units of epsilon to `rounding`. Always
504    /// inlined: it is the body of the term loop of [`Shells::sum`].
505    #[allow(clippy::inline_always)]
506    #[inline(always)]
507    fn at(&self, n: [i64; 3], rounding: &mut Rounding<N>) -> Result<Jet<N>> {
508        if let (Some(shared), Family::Spherical { l, .. }) = (self.shared, self.wave) {
509            let index = self.index.replace(self.index.get() + 1);
510            // Bound the gamma table by 1024 scalar slots per degree, regardless of
511            // multipole order, and the geometry by at most 512 samples. Farther
512            // reciprocal points use ordinary evaluation.
513            let maximum = 1024 / (usize::try_from(l).unwrap_or_default() + 2);
514            if index < maximum {
515                let mut samples = shared.plane_samples.borrow_mut();
516                if index == samples.len() {
517                    samples.push(self.prepare(n)?);
518                }
519                if let Some(sample) = samples.get_mut(index).filter(|sample| sample.point == n) {
520                    return self.term(
521                        sample.diffraction,
522                        sample.phase,
523                        rounding,
524                        Some(&mut sample.integrals),
525                    );
526                }
527            }
528        }
529        let sample = self.prepare(n)?;
530        self.term(sample.diffraction, sample.phase, rounding, None)
531    }
532
533    /// Geometry, threshold argument and Bloch phase shared by all spherical orders.
534    #[allow(clippy::inline_always)]
535    #[inline(always)]
536    fn prepare(&self, n: [i64; 3]) -> Result<SphericalReciprocalSample<N>> {
537        let (inputs, rows, kpar) = (self.inputs, self.rows, self.kpar);
538        let dim = inputs.dim;
539        let vector = inputs.point(rows, n);
540        let mut q = [Jet::default(); 3];
541        for j in 0..dim {
542            q[inputs.axes[j]] = vector[j] + kpar[j];
543        }
544        let phase = (-Complex::i()
545            * q.into_iter()
546                .zip(inputs.r)
547                .map(|(q, r)| q * r)
548                .sum::<Jet<N>>())
549        .exp();
550        Ok(SphericalReciprocalSample {
551            point: n,
552            diffraction: Diffraction::new(
553                q,
554                !self.kpar_reduced && n == [0; 3],
555                inputs.k,
556                self.eta,
557            )?,
558            phase,
559            integrals: Vec::new(),
560        })
561    }
562
563    #[allow(clippy::inline_always)]
564    #[inline(always)]
565    fn term(
566        &self,
567        diffraction: Diffraction<N>,
568        phase: Jet<N>,
569        rounding: &mut Rounding<N>,
570        plane_integrals: Option<&mut Vec<Option<Complex>>>,
571    ) -> Result<Jet<N>> {
572        let mut bound = Rounding::default();
573        let term = reciprocal_term(
574            self.wave,
575            self.inputs.dim,
576            self.inputs.k,
577            diffraction,
578            self.inputs.r,
579            self.eta,
580            self.split,
581            self.inputs.measure,
582            &mut bound,
583            plane_integrals,
584        )?;
585        *rounding += bound.times(&phase);
586        Ok(term * phase)
587    }
588}
589
590/// The shift and the Bloch vector of one sum, each reduced into a cell of its reduced
591/// basis.
592struct CellReduction<const N: usize> {
593    /// The Bloch phase `exp(-i q ยท R)` that compensates subtracting the lattice point `R`
594    /// from the shift.
595    phase: Jet<N>,
596    /// The reduced direct rows.
597    direct_rows: [[Jet<N>; 3]; 3],
598    /// The reduced reciprocal rows.
599    reciprocal_rows: [[Jet<N>; 3]; 3],
600    /// The Bloch vector in lattice coordinates, reduced into a cell of `reciprocal_rows`.
601    kpar: [Jet<N>; 3],
602    /// Whether the reduction moved the Bloch vector.
603    kpar_reduced: bool,
604    /// The shift as given, for the sum at the automatic split ([`AutomaticSplit`]).
605    given_shift: [f64; 3],
606}
607
608/// Reduces the shift of `inputs` into a cell of the reduced direct basis, in place, and
609/// the Bloch vector into a cell of the reduced reciprocal basis.
610fn reduce_to_cell<const N: usize>(
611    lattice: &BlochLattice,
612    inputs: &mut Inputs<N>,
613) -> CellReduction<N> {
614    let dim = inputs.dim;
615    // Coordinates of a lattice-frame vector in the basis `rows`, whose dual is `dual`.
616    let coordinates = |dual: &[[f64; 3]; 3], vector: [f64; 3]| -> [f64; 3] {
617        std::array::from_fn(|i| {
618            if i < dim {
619                (0..dim).map(|j| dual[i][j] * vector[j]).sum::<f64>() / (2.0 * PI)
620            } else {
621                0.0
622            }
623        })
624    };
625    // A Bloch phase compensates the shift's reduction. The cell point is combined from
626    // the given rows, so a shift equal to one of them reduces to exactly zero.
627    let direct_reduction = lattice.direct_reduction.as_ref();
628    let shift = std::array::from_fn(|j| inputs.r[inputs.axes[j]].value.re);
629    let cells = Reduction::given(
630        direct_reduction,
631        Reduction::round(
632            direct_reduction,
633            coordinates(&lattice.reciprocal, shift),
634            dim,
635        ),
636        dim,
637    );
638    let given_shift = inputs.r.map(|r| r.value.re);
639    let cell = inputs.combination(&inputs.direct, cells);
640    for (&axis, &cell) in inputs.axes.iter().zip(&cell).take(dim) {
641        inputs.r[axis] -= cell;
642    }
643    let phase = (-Complex::i() * inputs.phase(&cell)).exp();
644    let direct_rows = inputs.reduce(&inputs.direct, direct_reduction);
645    let reciprocal_reduction = lattice.reciprocal_reduction.as_ref();
646    let reciprocal_rows = inputs.reduce(&inputs.reciprocal, reciprocal_reduction);
647    let cells = Reduction::round(
648        reciprocal_reduction,
649        coordinates(&lattice.direct, lattice.kpar),
650        dim,
651    );
652    let mut kpar = inputs.kpar;
653    let kpar_reduced = cells.iter().any(|&c| c != 0.0);
654    if kpar_reduced {
655        let point = inputs.combination(&reciprocal_rows, cells);
656        for (kpar, point) in kpar.iter_mut().zip(point).take(dim) {
657            *kpar -= point;
658        }
659    }
660    CellReduction {
661        phase,
662        direct_rows,
663        reciprocal_rows,
664        kpar,
665        kpar_reduced,
666        given_shift,
667    }
668}
669
670/// A sum that vanishes by symmetry ([`vanishes`]) is exactly zero, as are its derivatives
671/// that move `k` alone; jets keep the other derivatives and their check of the
672/// cancellation.
673struct Symmetry<const N: usize> {
674    /// Whether the sum vanishes by symmetry.
675    vanishes: bool,
676    /// Whether each jet slot moves `k` alone, with the shift, the Bloch vector and the
677    /// lattice vectors fixed.
678    k_alone: [bool; N],
679}
680
681impl<const N: usize> Symmetry<N> {
682    /// The symmetry of the sum with the reduced direct rows `direct` and the reduced
683    /// Bloch vector `kpar`.
684    fn of(wave: Family, inputs: &Inputs<N>, direct: &[[Jet<N>; 3]; 3], kpar: &[Jet<N>; 3]) -> Self {
685        Self {
686            vanishes: vanishes(wave, inputs, direct, kpar),
687            k_alone: std::array::from_fn(|slot| {
688                inputs
689                    .r
690                    .iter()
691                    .chain(&inputs.kpar)
692                    .chain(inputs.direct.iter().flatten())
693                    .all(|input| input.derivative[slot] == Complex::default())
694            }),
695        }
696    }
697
698    /// `sum` with the components that vanish by symmetry set to exactly zero.
699    fn zeroed(&self, mut sum: Jet<N>) -> Jet<N> {
700        if self.vanishes {
701            sum.value = Complex::default();
702            for (derivative, &k_alone) in sum.derivative.iter_mut().zip(&self.k_alone) {
703                if k_alone {
704                    *derivative = Complex::default();
705                }
706            }
707        }
708        sum
709    }
710
711    /// The components whose cancellation a sum below every automatic split checks: those
712    /// that [`Self::zeroed`] keeps, without the split derivative of the parts.
713    fn checked(&self, inputs: &Inputs<N>, evaluation: Evaluation) -> Checked<N> {
714        Checked {
715            value: !self.vanishes,
716            units: std::array::from_fn(|slot| {
717                if matches!(evaluation, Evaluation::EtaDerivative)
718                    || (self.vanishes && self.k_alone[slot])
719                {
720                    None
721                } else {
722                    Some(derivative_scale(slot, inputs.dim, inputs.k.value))
723                }
724            }),
725        }
726    }
727}
728
729/// The same sum at the automatic split, against which sums below every automatic split
730/// fail early ([`SmallSplitMonitor::hopeless`]) or verify their settled shells
731/// ([`verify_settled`]).
732#[derive(Clone, Copy)]
733struct AutomaticSplit<'a> {
734    wave: Family,
735    k: Complex,
736    lattice: &'a BlochLattice,
737    /// The shift as given, before its reduction into a cell.
738    shift: [f64; 3],
739    evaluation: Evaluation,
740}
741
742impl AutomaticSplit<'_> {
743    fn sum<const N: usize>(&self) -> Result<Jet<N>> {
744        evaluate::<N>(
745            self.wave,
746            self.k,
747            self.lattice,
748            self.shift,
749            Complex::default(),
750            self.evaluation,
751        )
752    }
753}
754
755/// The real-space terms below every automatic split: the Kambe integrals that keep their
756/// rounding below that of the largest term so far, and the early failure of complete
757/// sums ([`EARLY_FAILURE_LOSS`]).
758struct SmallSplitMonitor<'a, const N: usize> {
759    /// The Kambe integrals of the real-space terms below every automatic split, except
760    /// for the split derivative.
761    kambe: Option<SmallSplitKambe>,
762    /// The largest moduli of the value and derivatives of a real-space term so far,
763    /// whose rounding the closed forms of the later terms may reach.
764    largest: Cell<Rounding<N>>,
765    /// Whether the sum fails early once the rounding its real-space terms predict is
766    /// [`hopeless`](Self::hopeless).
767    early: bool,
768    /// The rounding the real-space terms predict so far.
769    running: Cell<Rounding<N>>,
770    /// The sum at the automatic split, on whose scales the sum fails early, evaluated
771    /// once; `None` where it fails.
772    automatic_sum: OnceCell<Option<Jet<N>>>,
773    automatic_split: AutomaticSplit<'a>,
774    checked: &'a Checked<N>,
775}
776
777impl<'a, const N: usize> SmallSplitMonitor<'a, N> {
778    fn new(
779        kambe: Option<SmallSplitKambe>,
780        early: bool,
781        automatic_split: AutomaticSplit<'a>,
782        checked: &'a Checked<N>,
783    ) -> Self {
784        Self {
785            kambe,
786            largest: Cell::new(Rounding::default()),
787            early,
788            running: Cell::new(Rounding::default()),
789            automatic_sum: OnceCell::new(),
790            automatic_split,
791            checked,
792        }
793    }
794
795    /// With [`Self::early`], adds the rounding of the real-space term `term` and its bound
796    /// `bound` to the running rounding and checks whether it is hopeless.
797    fn add_to_running(&self, term: &Jet<N>, bound: Rounding<N>) -> Result<()> {
798        if self.early {
799            let mut total = self.running.get();
800            total += Rounding::relative(term, TERM_ULPS);
801            total += bound;
802            self.running.set(total);
803            self.hopeless(&total)?;
804        }
805        Ok(())
806    }
807
808    /// Fails where the rounding `bound` exceeds [`EARLY_FAILURE_LOSS`] of the scale of a
809    /// checked component of the sum at the automatic split.
810    fn hopeless(&self, bound: &Rounding<N>) -> Result<()> {
811        let checked = self.checked;
812        let beyond = |bound: f64, scale: f64| f64::EPSILON * bound > EARLY_FAILURE_LOSS * scale;
813        // The scales are at least 1 for the value and `s` for a derivative, so smaller
814        // losses need no automatic sum.
815        let slots = || bound.derivative.iter().zip(checked.units);
816        let candidate = (checked.value && beyond(bound.value, 1.0))
817            || slots().any(|(&bound, unit)| unit.is_some_and(|unit| beyond(bound, unit)));
818        if !candidate {
819            return Ok(());
820        }
821        let Some(automatic) = self
822            .automatic_sum
823            .get_or_init(|| self.automatic_split.sum::<N>().ok())
824        else {
825            return Ok(());
826        };
827        let scale = automatic.value.norm().max(1.0);
828        if checked.value && beyond(bound.value, scale) {
829            return Err(too_small(f64::EPSILON * bound.value / scale, false));
830        }
831        for ((&bound, unit), derivative) in slots().zip(&automatic.derivative) {
832            if let Some(unit) = unit {
833                let scale = scale.mul_add(unit, derivative.norm());
834                if beyond(bound, scale) {
835                    return Err(too_small(f64::EPSILON * bound / scale, true));
836                }
837            }
838        }
839        Ok(())
840    }
841}
842
843/// `sum`, whose real-space shells settled at their limit ([`Shells::settled`]), if it
844/// agrees with the sum at the automatic split within the rounding `predicted` that its
845/// parts predict for the value (without the truncation that the comparison verifies)
846/// plus twice the convergence tolerance of each shell sum.
847fn verify_settled<const N: usize>(
848    sum: Jet<N>,
849    predicted: f64,
850    automatic_split: AutomaticSplit<'_>,
851) -> Result<Jet<N>> {
852    // A larger split cannot help where the automatic one does not converge either.
853    let automatic = automatic_split.sum::<N>().map_err(|error| {
854        if is_shell_limit(&error) {
855            Error::NotConverged(format!(
856                "{NOT_CONVERGED}, neither at this split nor at the automatic one"
857            ))
858        } else {
859            error
860        }
861    })?;
862    let scale = automatic.value.norm().max(1.0);
863    let relative = f64::EPSILON.mul_add(predicted / scale, 4.0 * SHELL_TOLERANCE);
864    let agrees = |a: Complex, b: Complex| (a - b).norm() <= relative * (b.norm() + scale);
865    if agrees(sum.value, automatic.value)
866        && sum
867            .derivative
868            .iter()
869            .zip(&automatic.derivative)
870            .all(|(&a, &b)| agrees(a, b))
871    {
872        Ok(sum)
873    } else {
874        Err(not_converged())
875    }
876}
877
878/// Whether the sum vanishes by symmetry.
879///
880/// That holds for odd degrees of spherical and odd orders of cylindrical waves where the
881/// reduced Bloch vector `kpar` is exactly zero and twice the reduced shift is exactly one
882/// of the combinations of the reduced rows `direct` with coefficients in `{-1, 0, 1}`
883/// (`2 r` lies in their cell).
884///
885/// # Why
886///
887/// Inversion maps the images `-r - R` onto themselves and changes the sign of the waves,
888/// so every Ewald part and the self term vanish at any split and `k`, while the computed
889/// parts would leave rounding of the order of the terms. The derivatives that move `k`
890/// alone vanish too; the others keep the check of the predicted loss ([`cancellation`]).
891pub(super) fn vanishes<const N: usize>(
892    wave: Family,
893    inputs: &Inputs<N>,
894    direct: &[[Jet<N>; 3]; 3],
895    kpar: &[Jet<N>; 3],
896) -> bool {
897    let odd = match wave {
898        Family::Spherical { l, .. } => l % 2 != 0,
899        Family::Cylindrical { m } => m % 2 != 0,
900    };
901    let dim = inputs.dim;
902    let lattice_axis = |axis: &usize| inputs.axes[..dim].contains(axis);
903    if !odd
904        || kpar[..dim].iter().any(|q| q.value != Complex::default())
905        || (0..3).any(|axis| !lattice_axis(&axis) && inputs.r[axis].value != Complex::default())
906    {
907        return false;
908    }
909    let twice: [f64; 3] = std::array::from_fn(|j| {
910        if j < dim {
911            2.0 * inputs.r[inputs.axes[j]].value.re
912        } else {
913            0.0
914        }
915    });
916    // Integer coefficients in {-1, 0, 1} for every reduced row.
917    let count = 3_usize.pow(u32::try_from(dim).unwrap_or(3));
918    (0..count).any(|index| {
919        let mut rest = index;
920        let coefficients: [f64; 3] = std::array::from_fn(|i| {
921            if i >= dim {
922                return 0.0;
923            }
924            let digit = rest % 3;
925            rest /= 3;
926            match digit {
927                0 => 0.0,
928                1 => 1.0,
929                _ => -1.0,
930            }
931        });
932        let point = inputs.combination(direct, coefficients);
933        (0..dim).all(|j| point[j].value.re == twice[j])
934    })
935}
936
937#[cfg(test)]
938pub(crate) mod probes {
939    //! Test probes of the Ewald sum: switches that turn off one decision of [`ewald`], a
940    //! count of its real-space terms, and the reduced integrals and the spectral series
941    //! on their own. Builds without tests take the no-op twins of the reads.
942    //!
943    //! [`ewald`]: super::ewald
944
945    use std::{cell::Cell, thread::LocalKey};
946
947    use super::super::{
948        Derivatives,
949        cell::BlochLattice,
950        inputs::{Inputs, unpack},
951        reduced::{Reduced, reduced_kambe},
952        sheets::{Split, lower_side, split_sheet},
953        spectral::spectral_sw1d,
954        wave::Family,
955    };
956    use crate::{Complex, numerics::Jet};
957
958    thread_local! {
959        static EWALD_ONLY: Cell<bool> = const { Cell::new(false) };
960        static LATE_FAILURE: Cell<bool> = const { Cell::new(false) };
961        static REAL_TERMS: Cell<u64> = const { Cell::new(0) };
962    }
963
964    /// Restores a switch when dropped, so a panicking test leaves it as it was.
965    struct Restore {
966        switch: &'static LocalKey<Cell<bool>>,
967        previous: bool,
968    }
969
970    impl Drop for Restore {
971        fn drop(&mut self) {
972            self.switch.set(self.previous);
973        }
974    }
975
976    /// Runs `run` with `switch` on in this thread.
977    fn with_switch<T>(switch: &'static LocalKey<Cell<bool>>, run: impl FnOnce() -> T) -> T {
978        let _restore = Restore {
979            switch,
980            previous: switch.replace(true),
981        };
982        run()
983    }
984
985    /// Runs `run` with this thread's 1D spherical sums kept on the Ewald sum: they skip
986    /// the spectral series, so tests can compare the two.
987    pub(crate) fn ewald_only<T>(run: impl FnOnce() -> T) -> T {
988        with_switch(&EWALD_ONLY, run)
989    }
990
991    /// Runs `run` with this thread's complete sums below every automatic split skipping
992    /// the early failure on [`EARLY_FAILURE_LOSS`], so tests can compare them with the
993    /// check of the complete sum alone.
994    ///
995    /// [`EARLY_FAILURE_LOSS`]: super::super::accuracy::EARLY_FAILURE_LOSS
996    pub(crate) fn late_failure<T>(run: impl FnOnce() -> T) -> T {
997        with_switch(&LATE_FAILURE, run)
998    }
999
1000    /// A count of the real-space terms this thread's Ewald sums evaluate, which tests read
1001    /// to see how far a sum went before it stopped.
1002    pub(crate) struct RealTerms {
1003        start: u64,
1004    }
1005
1006    impl RealTerms {
1007        /// The real-space terms evaluated since [`real_terms`] created this count.
1008        pub(crate) fn count(&self) -> u64 {
1009            REAL_TERMS.get() - self.start
1010        }
1011    }
1012
1013    /// Starts counting the real-space terms of this thread's Ewald sums.
1014    pub(crate) fn real_terms() -> RealTerms {
1015        RealTerms {
1016            start: REAL_TERMS.get(),
1017        }
1018    }
1019
1020    /// Whether 1D spherical sums may take the spectral series (see [`ewald_only`]).
1021    #[allow(clippy::inline_always)]
1022    #[inline(always)]
1023    pub(super) fn spectral_series_enabled() -> bool {
1024        !EWALD_ONLY.get()
1025    }
1026
1027    /// Whether complete sums may fail early (see [`late_failure`]).
1028    #[allow(clippy::inline_always)]
1029    #[inline(always)]
1030    pub(super) fn early_failure_enabled() -> bool {
1031        !LATE_FAILURE.get()
1032    }
1033
1034    /// Counts one real-space term (see [`real_terms`]).
1035    #[allow(clippy::inline_always)]
1036    #[inline(always)]
1037    pub(super) fn count_real_term() {
1038        REAL_TERMS.set(REAL_TERMS.get() + 1);
1039    }
1040
1041    /// `F_n(v, t)` of one reciprocal point, of squared transverse modulus `q2` at the
1042    /// distance `s` from the lattice plane or axis (`t = (k s eta)^2`), from its series in
1043    /// `t` and from Kambe integrals, each with its rounding bound, on the sheet of the
1044    /// automatic split.
1045    pub(crate) fn reduced_paths(
1046        twice_n: i32,
1047        k: Complex,
1048        q2: f64,
1049        eta: Complex,
1050        distance: f64,
1051    ) -> [(Complex, f64); 2] {
1052        let v = lower_side((q2 / (k * k) - 1.0) / (2.0 * eta * eta));
1053        let t = (k * distance * eta).powi(2);
1054        let sheet = Some(split_sheet(&Split::new(k, eta), q2.into()));
1055        let mut reduced = Reduced::<0>::new(Jet::constant(v), Jet::constant(t), sheet);
1056        let kambe = if twice_n % 2 == 0 {
1057            reduced_kambe(twice_n, v, t.sqrt(), sheet)
1058        } else {
1059            (reduced.by_even_recurrence(twice_n), 0.0)
1060        };
1061        [reduced.series(twice_n), kambe]
1062    }
1063
1064    /// The spectral series of a 1D spherical sum with its derivatives in every input, as
1065    /// [`derivatives`] returns them, and the rounding bounds of each component in units
1066    /// of epsilon (as real parts, in the same layout), or `None` where [`spectral_sw1d`]
1067    /// is not available.
1068    ///
1069    /// [`derivatives`]: super::super::derivatives
1070    pub(crate) fn spectral_sw1d_derivatives(
1071        (l, m): (i32, i32),
1072        k: Complex,
1073        lattice: &BlochLattice,
1074        r: [f64; 3],
1075    ) -> Option<(Derivatives, Derivatives)> {
1076        let inputs = Inputs::<6>::new(Family::Spherical { l, m }, k, lattice, r, true, true);
1077        let (series, bound) = spectral_sw1d(
1078            (l, m),
1079            inputs.k,
1080            inputs.r,
1081            inputs.kpar[0],
1082            inputs.reciprocal[0][0],
1083            inputs.measure,
1084        )?;
1085        let bound = Jet {
1086            value: bound.value.into(),
1087            derivative: bound.derivative.map(Complex::from),
1088        };
1089        Some((unpack(series, 1), unpack(bound, 1)))
1090    }
1091}
1092
1093/// The reads of the test probes in builds without tests: constants that fold away.
1094#[cfg(not(test))]
1095mod probes {
1096    /// Always true: 1D spherical sums may take the spectral series.
1097    #[allow(clippy::inline_always)]
1098    #[inline(always)]
1099    pub(super) const fn spectral_series_enabled() -> bool {
1100        true
1101    }
1102
1103    /// Always true: complete sums may fail early.
1104    #[allow(clippy::inline_always)]
1105    #[inline(always)]
1106    pub(super) const fn early_failure_enabled() -> bool {
1107        true
1108    }
1109
1110    /// Counts nothing.
1111    #[allow(clippy::inline_always)]
1112    #[inline(always)]
1113    pub(super) const fn count_real_term() {}
1114}