treams_core/lattice/
accuracy.rs

1//! Rounding-error bounds, and the failure of sums whose Ewald parts cancel.
2//!
3//! treams-rs extension: treams returns the sum of its parts without a bound.
4
5use std::f64::consts::PI;
6
7use super::{
8    inputs::{K, KPAR},
9    shells::Shells,
10};
11use crate::{Complex, Error, Result, numerics::Jet};
12
13/// First-order bounds on the absolute rounding errors of a jet's value and of each of
14/// its derivatives, in units of epsilon.
15#[derive(Clone, Copy, Debug)]
16pub(super) struct Rounding<const N: usize> {
17    pub(super) value: f64,
18    pub(super) derivative: [f64; N],
19}
20
21impl<const N: usize> Default for Rounding<N> {
22    fn default() -> Self {
23        Self::of(0.0)
24    }
25}
26
27impl<const N: usize> Rounding<N> {
28    /// A bound on a value only, whose derivatives are exact.
29    pub(super) const fn of(value: f64) -> Self {
30        Self {
31            value,
32            derivative: [0.0; N],
33        }
34    }
35
36    /// The bounds of `exact * inexact`, where `self` bounds `inexact`. Bounds take the
37    /// cheaper `|Re| + |Im|` for moduli, at most `sqrt 2` larger.
38    pub(super) fn times(self, exact: &Jet<N>) -> Self {
39        if self.value == 0.0 && self.derivative.iter().all(|&d| d == 0.0) {
40            return self;
41        }
42        let scale = exact.value.l1_norm();
43        Self {
44            value: scale * self.value,
45            derivative: std::array::from_fn(|i| {
46                scale * self.derivative[i] + exact.derivative[i].l1_norm() * self.value
47            }),
48        }
49    }
50
51    /// The moduli of a jet's value and derivatives times `factor`: a bound for a jet
52    /// computed with a relative error of `factor` ulps in each component.
53    pub(super) fn relative(jet: &Jet<N>, factor: f64) -> Self {
54        Self {
55            value: factor * jet.value.l1_norm(),
56            derivative: jet.derivative.map(|d| factor * d.l1_norm()),
57        }
58    }
59
60    /// The bounds of `phase * inexact` for a `phase` of modulus 1, where `self` bounds
61    /// `inexact`: its value keeps its bound, which the derivatives of the phase carry
62    /// into theirs.
63    fn turned(self, phase: &Jet<N>) -> Self {
64        Self {
65            value: self.value,
66            derivative: std::array::from_fn(|i| {
67                phase.derivative[i]
68                    .norm()
69                    .mul_add(self.value, self.derivative[i])
70            }),
71        }
72    }
73
74    /// The larger bound of each component.
75    pub(super) fn max(self, other: Self) -> Self {
76        Self {
77            value: self.value.max(other.value),
78            derivative: std::array::from_fn(|i| self.derivative[i].max(other.derivative[i])),
79        }
80    }
81}
82
83impl<const N: usize> std::ops::AddAssign for Rounding<N> {
84    fn add_assign(&mut self, other: Self) {
85        self.value += other.value;
86        for (bound, other) in self.derivative.iter_mut().zip(other.derivative) {
87            *bound += other;
88        }
89    }
90}
91
92/// Largest rounding error bound of a 1D spherical sum relative to `max(|S|, 1)`, and
93/// largest loss of any sum to the [`cancellation`] of its Ewald parts relative to the
94/// scale of a component, before it fails.
95///
96/// Calibrated: the bounds exceed the errors by a median factor of 17 to 87, and fall
97/// short of them by at most a factor of 4.5.
98pub(super) const MAX_LOSS: f64 = 1e-3;
99
100/// Relative rounding error of every Ewald term and of the self term, in ulps, that
101/// [`cancellation`] counts.
102pub(super) const TERM_ULPS: f64 = 8.0;
103
104/// The largest `Re(1 / (2 eta^2))` of the automatic split ([`resolve_split`]): `16 / pi`
105/// from `|k L| = 8` on, with a relative margin of 1e-12 for its rounding.
106///
107/// It sets the growth `e^Re(1 / (2 eta^2))` of the Ewald parts. Only splits beyond it,
108/// the small splits:
109///
110/// - check the [`cancellation`] of their parts;
111/// - take the real-space integrals of [`SmallSplitKambe`];
112/// - continue both parts to the complete sum ([`Shells`]);
113/// - consider single Kambe integrals ([`Reduced::prefers_single`]).
114///
115/// The other splits keep their values and cost.
116///
117/// [`resolve_split`]: super::cell::resolve_split
118/// [`SmallSplitKambe`]: crate::special::SmallSplitKambe
119/// [`Reduced::prefers_single`]: super::reduced::Reduced::prefers_single
120const AUTOMATIC_GROWTH: f64 = 16.0 / PI * (1.0 + 1e-12);
121
122/// Whether the split `eta` lies below every automatic one ([`AUTOMATIC_GROWTH`]).
123pub(super) fn below_automatic(eta: Complex) -> bool {
124    (0.5 / (eta * eta)).re > AUTOMATIC_GROWTH
125}
126
127/// The components of a sum that [`cancellation`] checks: the value unless the sum
128/// vanishes by symmetry ([`vanishes`]), which returns it as an exact zero, and each
129/// derivative that such a sum does not return as zero either, with the scale `s` of
130/// its slot ([`derivative_scale`]).
131///
132/// [`vanishes`]: super::ewald::vanishes
133pub(super) struct Checked<const N: usize> {
134    pub(super) value: bool,
135    pub(super) units: [Option<f64>; N],
136}
137
138/// The scale `s` of the derivative in jet slot `slot` of a sum with `dim` lattice
139/// dimensions and the wavenumber `k`, on which it counts `|dS| + max(|S|, 1) s`: `1 / |k|`
140/// for the wavenumber and the Bloch vector, `|k|` for the shift and the lattice vectors.
141pub(super) fn derivative_scale(slot: usize, dim: usize, k: Complex) -> f64 {
142    if slot == K || (KPAR..KPAR + dim).contains(&slot) {
143        1.0 / k.norm()
144    } else {
145        k.norm()
146    }
147}
148
149/// The predicted absolute rounding errors, in units of epsilon, of the value and
150/// derivatives of `sum` (`phase` times the Ewald `parts` and the self term `other`), or a
151/// failure where they cancel beyond use: where the error of a `checked` component plus
152/// the truncation `tail` of parts that did not converge to the complete sum
153/// ([`Shells::tail`]) exceeds [`MAX_LOSS`] of its scale, `max(|S|, 1)` for the value
154/// and `|dS| + max(|S|, 1) s` for a derivative (`S = 0` for a sum that vanishes by
155/// symmetry). Each component predicts the bounds of the real-space Kambe integrals
156/// ([`SmallSplitKambe`]) plus [`TERM_ULPS`] times the moduli of the terms and of the self
157/// term, and an ulp of each compensated part ([`Shells`]).
158///
159/// Calibrated against the automatic split: the prediction exceeds the error of every sum
160/// more than 1e-11 off by a median factor of 45 at real and 87 at rotated splits (at
161/// least 4), and no sum that passes is more than 1.2e-4 off (not counting 1D spherical
162/// sums off the axis, see [`prefer_spectral_sw1d`]).
163///
164/// [`SmallSplitKambe`]: crate::special::SmallSplitKambe
165/// [`prefer_spectral_sw1d`]: super::spectral::prefer_spectral_sw1d
166pub(super) fn cancellation<const N: usize>(
167    parts: &[&Shells<N>],
168    other: &Jet<N>,
169    tail: f64,
170    phase: &Jet<N>,
171    sum: &Jet<N>,
172    checked: &Checked<N>,
173) -> Result<Rounding<N>> {
174    let mut rounding = Rounding::relative(other, TERM_ULPS);
175    for part in parts {
176        rounding += part.bound;
177        rounding += Rounding::relative(&part.sum, 1.0);
178    }
179    // The phase of the shift into the cell has modulus 1 (a real Bloch vector).
180    let rounding = rounding.turned(phase);
181    // A sum whose value is not checked is returned as an exact zero, whatever rounding
182    // its parts leave in it: its scale is 1.
183    let scale = if checked.value {
184        sum.value.norm().max(1.0)
185    } else {
186        1.0
187    };
188    // A bound that overflowed to NaN counts as lost.
189    let loss = |bound: f64, scale: f64| f64::EPSILON.mul_add(bound, tail) / scale;
190    let lost = |loss: f64| loss.is_nan() || loss > MAX_LOSS;
191    let value = loss(rounding.value, scale);
192    if checked.value && lost(value) {
193        return Err(too_small(value, false));
194    }
195    for ((&bound, derivative), unit) in rounding
196        .derivative
197        .iter()
198        .zip(&sum.derivative)
199        .zip(checked.units)
200    {
201        if let Some(unit) = unit {
202            let derivative = loss(bound, scale.mul_add(unit, derivative.norm()));
203            if lost(derivative) {
204                return Err(too_small(derivative, true));
205            }
206        }
207    }
208    Ok(rounding)
209}
210
211/// The failure of a sum whose cancelling Ewald parts lose `loss` of `max(|S|, 1)`, or
212/// of a `derivative` on its scale `|dS| + max(|S|, 1) s`.
213pub(super) fn too_small(loss: f64, derivative: bool) -> Error {
214    let scale = if derivative {
215        "of |dS| + max(|S|, 1) s in a derivative"
216    } else {
217        "of max(|S|, 1)"
218    };
219    Error::NotConverged(format!(
220        "Ewald split too small: its cancelling parts lose about {loss:.0e} {scale}; \
221         use a larger split (eta = 0 selects one)"
222    ))
223}
224
225/// The loss beyond which a complete sum below every automatic split fails early, while
226/// its real-space terms still add up.
227///
228/// The loss is relative to the scale of a component of the sum at the automatic split,
229/// as [`cancellation`] counts it. The failure comes before the far shells, where the
230/// Kambe series of the terms grow with `1 / eta^2`. The predicted loss only grows with
231/// the terms, so the complete sum would fail too unless it came back more than a scale
232/// off the automatic one. The automatic sum (the jet, for a jet) is evaluated once,
233/// where a loss exceeds this of 1 for the value or of `s` for a derivative; a sum whose
234/// automatic split fails continues.
235///
236/// Calibrated: without the early failure, a 3D sum at the split 0.03 takes 29 s to fail
237/// at the shell limit.
238pub(super) const EARLY_FAILURE_LOSS: f64 = 2.0 * MAX_LOSS;