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}