treams_core/lattice/
shells.rs

1//! Sums over cube shells and when they stop.
2//!
3//! Upstream: the shell loops of `realsum*` and `recsum*` in `lattice/_esum.pyx`. The
4//! relative tolerance, the shell limits that grow with the terms and the compensated
5//! sums are treams-rs extensions.
6
7use std::f64::consts::PI;
8
9use super::{
10    accuracy::{Rounding, TERM_ULPS, below_automatic},
11    cell::{BlochLattice, Reduction},
12    geometry::visit_cube,
13    wave::Family,
14};
15use crate::{Complex, Error, Result, numerics::Jet};
16
17/// `Jet::norm`, the largest modulus of the value and derivatives, from one square root
18/// of the largest squared modulus instead of a `hypot` per component; `Jet::norm` when
19/// the squares overflow.
20pub(super) fn largest_modulus<const N: usize>(jet: &Jet<N>) -> f64 {
21    let square = jet
22        .derivative
23        .iter()
24        .fold(jet.value.norm_sqr(), |square, d| square.max(d.norm_sqr()));
25    if square.is_finite() {
26        square.sqrt()
27    } else {
28        jet.norm()
29    }
30}
31
32/// Largest part of the scale of a shell sum that each of its last two shells may add
33/// before the sum stops.
34pub(crate) const SHELL_TOLERANCE: f64 = 2e-13;
35
36/// One Ewald part summed over cube shells of integer points ([`Shells::sum`]), which
37/// [`Shells::extend`] continues. Below every automatic split ([`below_automatic`]), where
38/// the parts cancel, a part that stops at [`SHELL_TOLERANCE`] of its own sum leaves that
39/// much in the complete sum, so both parts continue to [`SHELL_TOLERANCE`] of the
40/// complete sum, and each part keeps the rounding errors of its additions
41/// ([`add_exactly`]): next to a lattice point at a zero Bloch vector millions of
42/// real-space terms add in phase far below an ulp of it.
43///
44/// Calibrated: continuing the parts this way at every split costs the other splits 5%
45/// and gains them 1e-14 of `max(|S|, 1)`.
46#[derive(Clone, Copy, Debug)]
47pub(super) struct Shells<const N: usize> {
48    /// The sum, or with `error` its leading part.
49    pub(super) sum: Jet<N>,
50    /// The rounding errors of the additions to `sum` since the last shell, where they
51    /// are kept (`cancelling`); each shell adds them to `sum`.
52    error: Jet<N>,
53    /// Whether the part may cancel against the other, below every automatic split: it
54    /// then keeps the rounding errors of its additions and counts `bound`.
55    cancelling: bool,
56    /// The rounding bounds of the value and each derivative that its terms predict, in
57    /// units of epsilon: [`TERM_ULPS`] ulps of the moduli of each term, plus the bounds
58    /// the terms add beyond their relative accuracy.
59    pub(super) bound: Rounding<N>,
60    /// Shells summed so far.
61    count: i32,
62    /// Shells to sum before the convergence test may stop the sum ([`peak_shells`]).
63    floor: i32,
64    /// The largest moduli of the value and derivatives that the last two shells added.
65    last: [f64; 2],
66    /// The same of what each of the last two shells added in all, where their terms
67    /// cancel.
68    net: [f64; 2],
69    /// Whether the sum stopped at its shell limit on `net` instead of `last` (see
70    /// [`Shells::sum`]).
71    pub(super) settled: bool,
72}
73
74impl<const N: usize> Shells<N> {
75    /// Sum `term` over cube shells until two consecutive shells add less than
76    /// [`SHELL_TOLERANCE`] times `max(|sum|, 1)`, but not before `floor` shells
77    /// ([`peak_shells`]), failing after `maximum` shells; where `cancelling`, keep the
78    /// rounding errors of the additions and count their bounds ([`Self::bound`]). With
79    /// `settle` a sum at the limit whose last two shells add less than that in all
80    /// ([`Self::settled`]) is returned for the caller to verify: at `|k eta|` far below the
81    /// automatic split the real-space terms reach beyond the limit while their Bloch
82    /// phases cancel them within each shell.
83    pub(super) fn sum(
84        dim: usize,
85        [maximum, floor]: [i32; 2],
86        term: &mut impl FnMut([i64; 3], &mut Rounding<N>) -> Result<Jet<N>>,
87        settle: bool,
88        cancelling: bool,
89    ) -> Result<Self> {
90        let mut shells = Self {
91            sum: Jet::default(),
92            error: Jet::default(),
93            cancelling,
94            bound: Rounding::default(),
95            count: 0,
96            floor,
97            last: [f64::INFINITY; 2],
98            net: [f64::INFINITY; 2],
99            settled: false,
100        };
101        if shells.extend(dim, maximum, term, None)? {
102            return Ok(shells);
103        }
104        let limit = SHELL_TOLERANCE * shells.sum.norm().max(1.0);
105        if settle && shells.net.iter().all(|&net| net <= limit) {
106            shells.settled = true;
107            return Ok(shells);
108        }
109        Err(not_converged())
110    }
111
112    /// The moduli of the last two shells, or for a sum that settled what they added in
113    /// all: a bound on what the shells beyond would add.
114    pub(super) fn tail(&self) -> f64 {
115        if self.settled {
116            self.net.iter().sum()
117        } else {
118            self.last.iter().sum()
119        }
120    }
121
122    /// Adds `value` to a cancelling sum with the rounding error of that addition, and
123    /// counts [`TERM_ULPS`] of its moduli in [`Self::bound`]. Kept out of line, like
124    /// [`small_split_kambe`], for the sums at other splits.
125    ///
126    /// [`small_split_kambe`]: super::real::small_split_kambe
127    #[inline(never)]
128    fn add_compensated(&mut self, value: &Jet<N>) {
129        add_jet_exactly(&mut self.sum, &mut self.error, value);
130        self.bound += Rounding::relative(value, TERM_ULPS);
131    }
132
133    /// Add shells until the last two add less than [`SHELL_TOLERANCE`] times `scale`, or
134    /// without one each less than that of `max(|sum|, 1)` as it was then, from
135    /// [`Self::floor`] shells on, and whether they did within `maximum` shells.
136    pub(super) fn extend(
137        &mut self,
138        dim: usize,
139        maximum: i32,
140        term: &mut impl FnMut([i64; 3], &mut Rounding<N>) -> Result<Jet<N>>,
141        scale: Option<f64>,
142    ) -> Result<bool> {
143        let tolerance =
144            |sum: &Jet<N>| SHELL_TOLERANCE * scale.unwrap_or_else(|| sum.norm().max(1.0));
145        let limit = tolerance(&self.sum);
146        let mut quiet = self.last.iter().rev().take_while(|&&m| m <= limit).count();
147        while quiet < 2 || self.count < self.floor {
148            if self.count >= maximum {
149                return Ok(false);
150            }
151            let mut shell = 0.0;
152            let mut net = Jet::default();
153            visit_cube(dim, i64::from(self.count), true, |point| {
154                let value = term(point, &mut self.bound)?;
155                if !value.finite() {
156                    return Err(Error::NonFinite(
157                        "non-finite Ewald summand; change the split parameter or reduce the order"
158                            .into(),
159                    ));
160                }
161                if self.cancelling {
162                    self.add_compensated(&value);
163                } else {
164                    self.sum += value;
165                }
166                net += value;
167                shell += largest_modulus(&value);
168                Ok(())
169            })?;
170            if self.cancelling {
171                let error = std::mem::take(&mut self.error);
172                add_jet_exactly(&mut self.sum, &mut self.error, &error);
173            }
174            self.count += 1;
175            self.last = [self.last[1], shell];
176            self.net = [self.net[1], largest_modulus(&net)];
177            quiet = if shell <= tolerance(&self.sum) {
178                quiet + 1
179            } else {
180                0
181            };
182        }
183        Ok(true)
184    }
185}
186
187/// Adds `x` to `sum` and the rounding error of that addition, which Knuth's two-sum
188/// finds exactly, to `error`.
189fn add_exactly(sum: &mut f64, error: &mut f64, x: f64) {
190    let total = *sum + x;
191    let back = total - *sum;
192    *error += (*sum - (total - back)) + (x - back);
193    *sum = total;
194}
195
196/// [`add_exactly`] for the real and imaginary parts of a jet's value and derivatives.
197fn add_jet_exactly<const N: usize>(sum: &mut Jet<N>, error: &mut Jet<N>, x: &Jet<N>) {
198    let add = |sum: &mut Complex, error: &mut Complex, x: Complex| {
199        add_exactly(&mut sum.re, &mut error.re, x.re);
200        add_exactly(&mut sum.im, &mut error.im, x.im);
201    };
202    add(&mut sum.value, &mut error.value, x.value);
203    for ((sum, error), &x) in sum
204        .derivative
205        .iter_mut()
206        .zip(&mut error.derivative)
207        .zip(&x.derivative)
208    {
209        add(sum, error, x);
210    }
211}
212
213/// The message of a shell sum that does not converge within its limit.
214pub(super) const NOT_CONVERGED: &str = "Ewald sum did not converge within the shell limit";
215
216/// The failure of a shell sum that does not converge within its limit.
217pub(super) fn not_converged() -> Error {
218    Error::NotConverged(NOT_CONVERGED.into())
219}
220
221/// Whether `error` is the failure of [`not_converged`] rather than one of the
222/// cancellation failures that share its variant.
223pub(super) fn is_shell_limit(error: &Error) -> bool {
224    matches!(error, Error::NotConverged(text) if text == NOT_CONVERGED)
225}
226
227/// The shell limits of [`real_shells`] and [`reciprocal_shells`] reach every term whose
228/// Gaussian factor exceeds `e^-TERM_DECAY`.
229const TERM_DECAY: f64 = 50.0;
230
231/// Cube shells a sum may visit before it fails: 200, 32 and 16 in one, two and three
232/// dimensions.
233const fn shells(dim: usize) -> i32 {
234    match dim {
235        1 => 200,
236        2 => 32,
237        _ => 16,
238    }
239}
240
241/// Cube shells the reciprocal sum may visit, as `[fail, extend]`.
242///
243/// # Formula
244///
245/// The terms fall like `exp(-Re(1 / (k eta)^2) |q + G|^2 / 2)`, below `exp(-50)` beyond
246/// `|q + G| = sqrt(100 / Re(1 / (k eta)^2))`. That is `10 |k eta|` for real `k eta`,
247/// within `|k| sqrt(1 + 100 |eta|^2)`, and grows as `k eta` turns off the real axis.
248///
249/// # Limits
250///
251/// - A sum fails once it passes `fail` shells, which reach the larger of the two radii
252///   plus `|q|`.
253/// - At small splits a converged sum continues toward the tolerance of the complete sum
254///   and keeps what it leaves out as its tail. It stops at `extend` shells, which reach
255///   `|k| sqrt(1 + 100 |eta|^2) + |q|` alone, so the larger radius changes no sum that
256///   converges within the smaller one.
257/// - A 1D spherical sum fails at `extend` shells and takes its spectral series instead.
258///
259/// At large `|k| L` either radius needs more than the fixed limit.
260pub(super) fn reciprocal_shells(
261    lattice: &BlochLattice,
262    k: Complex,
263    eta: Complex,
264    kpar: [f64; 3],
265) -> [i32; 2] {
266    let within = |radius| {
267        shells_within(
268            lattice.dim,
269            radius,
270            &lattice.direct,
271            lattice.reciprocal_reduction.as_ref(),
272        )
273    };
274    let split = k * eta;
275    let decay = (split * split).inv().re;
276    let radius = k.norm() * eta.norm_sqr().mul_add(2.0 * TERM_DECAY, 1.0).sqrt();
277    let extend = within(radius + norm(kpar));
278    // Off the real axis the terms fall slower: 0.27 times as fast with `k eta` at 37 degrees.
279    let turned = if decay > 0.0 {
280        (2.0 * TERM_DECAY / decay).sqrt()
281    } else {
282        0.0
283    };
284    if turned > radius {
285        [within(turned + norm(kpar)), extend]
286    } else {
287        [extend, extend]
288    }
289}
290
291/// Cube shells the real-space sum may visit.
292///
293/// # Formula
294///
295/// The terms fall like `exp(Re(1 / (2 eta^2)) - Re((k eta)^2) |R|^2 / 2)` from the
296/// nearest ones, below `exp(-50)` beyond `|R| = sqrt(2 (50 + Re(1 / (2 eta^2))) /
297/// Re((k eta)^2))`. Splits with `|k eta|` well below the automatic one need more than the
298/// fixed limit to reach it.
299///
300/// # Why
301///
302/// Where `(k eta)^2` lies more than 45 degrees off the real axis
303/// (`Re((k eta)^2) < |Im((k eta)^2)|`), the phase of the terms turns faster than they
304/// fall, and their far shells cancel. There only 1D and 2D sums at small splits, which
305/// check the [`cancellation`] of their parts, take the grown limit.
306///
307/// Calibrated: with the grown limit, 3D sums take up to 20 s, and a 1D cylindrical sum
308/// above the automatic splits comes back 2e-5 of `max(|S|, 1)` off.
309///
310/// [`cancellation`]: super::accuracy::cancellation
311pub(super) fn real_shells(lattice: &BlochLattice, k: Complex, eta: Complex) -> i32 {
312    let split = k * eta;
313    let square = split * split;
314    let decay = square.re;
315    let turned = decay < square.im.abs();
316    if decay > 0.0 && (!turned || (lattice.dim < 3 && below_automatic(eta))) {
317        let growth = (0.5 / (eta * eta)).re.max(0.0);
318        let radius = (2.0 * (TERM_DECAY + growth) / decay).sqrt();
319        shells_within(
320            lattice.dim,
321            radius,
322            &lattice.reciprocal,
323            lattice.direct_reduction.as_ref(),
324        )
325    } else {
326        shells(lattice.dim)
327    }
328}
329
330/// The Euclidean norm of a lattice-frame vector.
331pub(super) fn norm(v: [f64; 3]) -> f64 {
332    v.iter().map(|x| x * x).sum::<f64>().sqrt()
333}
334
335/// Cube shells of a sum over a lattice, reduced by `reduction`, whose dual rows are
336/// `dual` (`a_i . b_j = 2 pi delta_ij`): [`shells`], or where reaching the lattice
337/// vectors within `radius` needs more, that many plus two, up to four times the base.
338/// Index `i` of a lattice vector `x` is `c_i . x / 2 pi` for the dual rows `c` of the
339/// reduced basis.
340#[allow(clippy::cast_possible_truncation)] // Bounded by four times the base limit.
341fn shells_within(
342    dim: usize,
343    radius: f64,
344    dual: &[[f64; 3]; 3],
345    reduction: Option<&Reduction>,
346) -> i32 {
347    let base = shells(dim);
348    let needed = largest_index(dim, radius, dual, reduction).ceil() + 2.0;
349    if needed > f64::from(base) {
350        needed.min(f64::from(4 * base)) as i32
351    } else {
352        base
353    }
354}
355
356/// The largest shell index of the lattice vectors within `radius` of a lattice reduced
357/// by `reduction`, whose dual rows are `dual` (see [`shells_within`]).
358fn largest_index(
359    dim: usize,
360    radius: f64,
361    dual: &[[f64; 3]; 3],
362    reduction: Option<&Reduction>,
363) -> f64 {
364    let spacing = (0..dim)
365        .map(|i| {
366            norm(reduction.map_or(dual[i], |reduction| {
367                std::array::from_fn(|j| (0..dim).map(|k| reduction.dual[i][k] * dual[k][j]).sum())
368            }))
369        })
370        .fold(0.0, f64::max);
371    radius * spacing / (2.0 * PI)
372}
373
374/// Cube shells that an Ewald part of `wave` on a lattice of dimension `dim` visits
375/// before its convergence test may stop it ([`Shells::sum`]).
376///
377/// These are the shells with a lattice vector within `offset` (the reduced shift or Bloch
378/// vector, the origin of the part) plus the radius where the moduli of its far terms
379/// peak, on a lattice reduced by `reduction` with the dual rows `dual`. `0` where the
380/// terms do not fall (`decay <= 0`): both parts diverge there.
381///
382/// # Formula
383///
384/// Far from the origin each term is the Gaussian `exp(-decay x^2 / 2)` of its distance
385/// `x` times a polynomial of degree `l` (`l - |m|` along a chain, `|m|` for cylindrical
386/// waves) in real and reciprocal space, less 2 for the endpoint of its Kambe integral or
387/// `1 / v`. A shell holds about `x^(dim - 1)` terms, and jets up to `x^2` more, so the
388/// moduli of a shell peak at `x^2 = power / decay` and fall monotonically beyond.
389///
390/// # Why
391///
392/// Before the peak the moduli can fall and rise again, as the real-space terms of lossy
393/// `k` at splits turned close to 90 degrees and the reciprocal terms of high degree do;
394/// without the floor such sums stop on their first shells.
395#[allow(clippy::cast_possible_truncation)] // Clamped to the range of i32.
396pub(super) fn peak_shells(
397    wave: Family,
398    dim: usize,
399    jet: bool,
400    decay: f64,
401    offset: f64,
402    (dual, reduction): (&[[f64; 3]; 3], Option<&Reduction>),
403) -> i32 {
404    // Terms that do not fall have no peak, nor does a split that makes `decay` NaN.
405    if decay.is_nan() || decay <= 0.0 {
406        return 0;
407    }
408    let degree = match wave {
409        Family::Spherical { l, m } if dim == 1 => l - m.abs(),
410        Family::Spherical { l, .. } => l,
411        Family::Cylindrical { m } => m.abs(),
412    };
413    let derivatives = if jet { 2 } else { 0 };
414    let power = degree - 2 + i32::try_from(dim).unwrap_or(3) - 1 + derivatives;
415    if power <= 0 {
416        return 0;
417    }
418    let radius = (f64::from(power) / decay).sqrt() + offset;
419    let shells = largest_index(dim, radius, dual, reduction).ceil() + 1.0;
420    shells.min(f64::from(i32::MAX)) as i32
421}