Skip to main content

integral_core/
rys.rs

1//! Two-electron repulsion integrals (ERIs) by Rys quadrature.
2//!
3//! This is the general ERI engine (see `ARCHITECTURE.md`, L1). It evaluates a
4//! Cartesian shell-quartet block `(ab|cd)` of the Coulomb kernel `1/r₁₂` over
5//! four **primitive** Gaussians and accumulates the coefficient-weighted result
6//! into a caller-provided block, mirroring the one-electron [`crate::os`] engine.
7//!
8//! ## Method
9//!
10//! With the Gaussian-product centres `P` (bra, exponent `p = a+b`) and `Q` (ket,
11//! `q = c+d`), `ρ = pq/(p+q)`, and `T = ρ|P−Q|²`, the Coulomb integral is
12//!
13//! ```text
14//!   (ab|cd) = 2π^{5/2}/(p q √(p+q)) · K_ab · K_cd · Σ_α w_α Iˣ_α Iʸ_α Iᶻ_α,
15//! ```
16//!
17//! where `{x_α, w_α}` are the `nroots = ⌊L_total/2⌋+1` Rys roots/weights for `T`
18//! ([`integral_math::rys`], `L_total = la+lb+lc+ld`) and `I^w_α` is the 2D integral
19//! along axis `w` for the four angular-momentum powers on that axis, normalized
20//! so the all-`s` case gives `I = 1`.
21//!
22//! Per root and axis the 2D integrals are built by the standard Rys recurrences:
23//! a **vertical** recurrence (VRR) raises the combined bra power `n` and ket
24//! power `m` from `(0,0)`, then two **horizontal** recurrences (HRR) split
25//! `n → (i on A, j on B)` using `A−B` and `m → (k on C, l on D)` using `C−D`.
26//! The recurrence coefficients reduce, in the appropriate limits, to the
27//! Obara–Saika `(W−P)`/`(W−Q)`/`1/2p`/`1/2(p+q)` terms (verified analytically for
28//! `(ps|ss)`, `(ss|ps)`, `(ds|ss)`, `(ps|ps)`).
29//!
30//! ## Block layout
31//!
32//! The output block for the quartet `(la, lb, lc, ld)` is **row-major over the
33//! four Cartesian component indices** `(a, b, c, d)`:
34//!
35//! ```text
36//!   out[((ia · n_b + ib) · n_c + ic) · n_d + id] += scale · (ab|cd)[ia,ib,ic,id]
37//! ```
38//!
39//! with `n_x = n_cart(lx)` and the component order of [`integral_math::am`]
40//! (fastest-varying index is `d`, slowest is `a`). The driver crate documents the
41//! same convention for the contracted builder.
42
43use integral_math::am::{cart_components, n_cart};
44use integral_math::rys::{rys_roots_weights, MAX_RYS_ROOTS};
45
46use crate::os::{Prim, Vec3, MAX_L};
47
48/// Per-axis component-power range `0..=MAX_L` ⇒ dimension `MAX_L + 1`.
49const L1: usize = MAX_L + 1;
50/// Combined bra/ket power range `0..=2·MAX_L` ⇒ dimension `2·MAX_L + 1`.
51const LT1: usize = 2 * MAX_L + 1;
52
53/// Per-axis 4-index 2D-integral table `I[i][j][k][l]` (`i≤la, j≤lb, k≤lc, l≤ld`).
54type Axis4 = [[[[f64; L1]; L1]; L1]; L1];
55
56/// Accumulate `scale · (ab|cd)` for the Coulomb kernel over four primitives into
57/// the row-major block `out` (shape `[n_cart(la)·n_cart(lb)·n_cart(lc)·n_cart(ld)]`,
58/// see the module-level "Block layout").
59///
60/// `scale` is the product of the four contraction/normalization coefficients,
61/// supplied by the driver (the engine itself works on un-normalized monomials).
62///
63/// # Panics
64/// Panics (in debug builds) if any `l > MAX_L` or `out` is too short.
65pub fn coulomb_into(a: Prim, b: Prim, c: Prim, d: Prim, scale: f64, out: &mut [f64]) {
66    let (la, lb, lc, ld) = (a.l, b.l, c.l, d.l);
67    debug_assert!(
68        la <= MAX_L && lb <= MAX_L && lc <= MAX_L && ld <= MAX_L,
69        "angular momentum exceeds MAX_L"
70    );
71
72    let (na, nb, nc, nd) = (n_cart(la), n_cart(lb), n_cart(lc), n_cart(ld));
73    debug_assert!(out.len() >= na * nb * nc * nd, "ERI output block too short");
74
75    // Bra / ket Gaussian products.
76    let p = a.exp + b.exp;
77    let q = c.exp + d.exp;
78    let p_ctr = combine(a.exp, a.center, b.exp, b.center, p);
79    let q_ctr = combine(c.exp, c.center, d.exp, d.center, q);
80    let kab = (-(a.exp * b.exp / p) * dist2(a.center, b.center)).exp();
81    let kcd = (-(c.exp * d.exp / q) * dist2(c.center, d.center)).exp();
82
83    let pq = p + q;
84    let rho = p * q / pq;
85    let pq_vec = sub(p_ctr, q_ctr);
86    let t = rho * norm2(pq_vec);
87
88    // (ss|ss) prefactor 2π^{5/2}/(p q √(p+q)) · K_ab · K_cd. Combined with
89    // Σ_α w_α = F_0(T) this is exactly the (ss|ss) Coulomb integral.
90    use std::f64::consts::PI;
91    let pref = 2.0 * PI * PI * PI.sqrt() / (p * q * pq.sqrt()) * kab * kcd;
92
93    let l_total = la + lb + lc + ld;
94    let nroots = l_total / 2 + 1;
95    let mut roots = [0.0f64; MAX_RYS_ROOTS];
96    let mut wts = [0.0f64; MAX_RYS_ROOTS];
97    rys_roots_weights(nroots, t, &mut roots, &mut wts);
98
99    // Per-axis geometric scalars (root-independent parts).
100    let pa = sub(p_ctr, a.center); // P − A
101    let qc = sub(q_ctr, c.center); // Q − C
102    let ab = sub(a.center, b.center); // A − B  (bra HRR)
103    let cd = sub(c.center, d.center); // C − D  (ket HRR)
104
105    let comps_a = cart_components(la);
106    let comps_b = cart_components(lb);
107    let comps_c = cart_components(lc);
108    let comps_d = cart_components(ld);
109
110    let mut ix: Axis4 = zeroed();
111    let mut iy: Axis4 = zeroed();
112    let mut iz: Axis4 = zeroed();
113
114    for r in 0..nroots {
115        let x = roots[r];
116        // Root-dependent recurrence coefficients (shared across axes).
117        let b00 = x / (2.0 * pq);
118        let b10 = (1.0 - x * q / pq) / (2.0 * p);
119        let b01 = (1.0 - x * p / pq) / (2.0 * q);
120        let cq = x * q / pq; // weight of (P−Q) in the bra shift C00
121        let cp = x * p / pq; // weight of (P−Q) in the ket shift CP0
122
123        for (axis, out4) in [&mut ix, &mut iy, &mut iz].into_iter().enumerate() {
124            let c00 = pa[axis] - cq * pq_vec[axis];
125            let cp0 = qc[axis] + cp * pq_vec[axis];
126            build_axis(
127                la, lb, lc, ld, c00, cp0, b10, b01, b00, ab[axis], cd[axis], out4,
128            );
129        }
130
131        let pw = scale * pref * wts[r];
132        for (ia, ca) in comps_a.iter().enumerate() {
133            for (ib, cb) in comps_b.iter().enumerate() {
134                for (ic, cc) in comps_c.iter().enumerate() {
135                    for (id, cd_) in comps_d.iter().enumerate() {
136                        let v = ix[ca[0]][cb[0]][cc[0]][cd_[0]]
137                            * iy[ca[1]][cb[1]][cc[1]][cd_[1]]
138                            * iz[ca[2]][cb[2]][cc[2]][cd_[2]];
139                        out[((ia * nb + ib) * nc + ic) * nd + id] += pw * v;
140                    }
141                }
142            }
143        }
144    }
145}
146
147/// Build the per-axis 4-index 2D-integral table `out4[i][j][k][l]` for one
148/// Cartesian axis, given the axis-projected recurrence coefficients.
149//
150// Index-based loops are intrinsic to these recurrences (neighbour access like
151// `i+1`, `k-1` across several parallel tables), so the range-loop lint is off.
152#[allow(clippy::too_many_arguments, clippy::needless_range_loop)]
153fn build_axis(
154    la: usize,
155    lb: usize,
156    lc: usize,
157    ld: usize,
158    c00: f64,
159    cp0: f64,
160    b10: f64,
161    b01: f64,
162    b00: f64,
163    ab: f64,
164    cd: f64,
165    out4: &mut Axis4,
166) {
167    let nbra = la + lb; // combined bra power
168    let nket = lc + ld; // combined ket power
169
170    // --- VRR: g[n][m], n = 0..=nbra, m = 0..=nket. ---
171    let mut g = [[0.0f64; LT1]; LT1];
172    g[0][0] = 1.0;
173    if nbra >= 1 {
174        g[1][0] = c00;
175    }
176    for n in 1..nbra {
177        g[n + 1][0] = c00 * g[n][0] + (n as f64) * b10 * g[n - 1][0];
178    }
179    for m in 0..nket {
180        for n in 0..=nbra {
181            let mut term = cp0 * g[n][m];
182            if m >= 1 {
183                term += (m as f64) * b01 * g[n][m - 1];
184            }
185            if n >= 1 {
186                term += (n as f64) * b00 * g[n - 1][m];
187            }
188            g[n][m + 1] = term;
189        }
190    }
191
192    // --- HRR bra: h[i][j][m] = h[i+1][j-1][m] + (A−B)·h[i][j-1][m]. ---
193    // h[i][0][m] = g[i][m]; final i ≤ la, j ≤ lb (intermediate i up to nbra).
194    let mut h = [[[0.0f64; LT1]; L1]; LT1];
195    for i in 0..=nbra {
196        for m in 0..=nket {
197            h[i][0][m] = g[i][m];
198        }
199    }
200    for j in 1..=lb {
201        for i in 0..=(nbra - j) {
202            for m in 0..=nket {
203                h[i][j][m] = h[i + 1][j - 1][m] + ab * h[i][j - 1][m];
204            }
205        }
206    }
207
208    // --- HRR ket: per (i,j), t[k][l] = t[k+1][l-1] + (C−D)·t[k][l-1]. ---
209    for i in 0..=la {
210        for j in 0..=lb {
211            let mut t = [[0.0f64; L1]; LT1];
212            for k in 0..=nket {
213                t[k][0] = h[i][j][k];
214            }
215            for l in 1..=ld {
216                for k in 0..=(nket - l) {
217                    t[k][l] = t[k + 1][l - 1] + cd * t[k][l - 1];
218                }
219            }
220            for k in 0..=lc {
221                for l in 0..=ld {
222                    out4[i][j][k][l] = t[k][l];
223                }
224            }
225        }
226    }
227}
228
229#[inline]
230fn zeroed() -> Axis4 {
231    [[[[0.0; L1]; L1]; L1]; L1]
232}
233
234#[inline]
235fn combine(a: f64, ca: Vec3, b: f64, cb: Vec3, p: f64) -> Vec3 {
236    [
237        (a * ca[0] + b * cb[0]) / p,
238        (a * ca[1] + b * cb[1]) / p,
239        (a * ca[2] + b * cb[2]) / p,
240    ]
241}
242
243#[inline]
244fn sub(u: Vec3, v: Vec3) -> Vec3 {
245    [u[0] - v[0], u[1] - v[1], u[2] - v[2]]
246}
247
248#[inline]
249fn dist2(u: Vec3, v: Vec3) -> f64 {
250    norm2(sub(u, v))
251}
252
253#[inline]
254fn norm2(u: Vec3) -> f64 {
255    u[0] * u[0] + u[1] * u[1] + u[2] * u[2]
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    /// (ss|ss) over four unit s primitives must equal the closed form
263    /// `2π^{5/2}/(p q √(p+q)) · K_ab · K_cd · F_0(T)`.
264    #[test]
265    fn ssss_matches_closed_form() {
266        let a = Prim::new(0.8, [0.0, 0.0, 0.0], 0);
267        let b = Prim::new(1.3, [0.0, 0.0, 0.7], 0);
268        let c = Prim::new(0.5, [0.4, 0.0, 0.0], 0);
269        let d = Prim::new(1.1, [0.0, 0.6, 0.2], 0);
270        let mut out = [0.0; 1];
271        coulomb_into(a, b, c, d, 1.0, &mut out);
272
273        // Independent closed form.
274        let p = a.exp + b.exp;
275        let q = c.exp + d.exp;
276        let pc = combine(a.exp, a.center, b.exp, b.center, p);
277        let qc = combine(c.exp, c.center, d.exp, d.center, q);
278        let kab = (-(a.exp * b.exp / p) * dist2(a.center, b.center)).exp();
279        let kcd = (-(c.exp * d.exp / q) * dist2(c.center, d.center)).exp();
280        let rho = p * q / (p + q);
281        let t = rho * dist2(pc, qc);
282        let mut fm = [0.0; 1];
283        integral_math::boys::boys_array(0, t, &mut fm);
284        use std::f64::consts::PI;
285        let expect = 2.0 * PI * PI * PI.sqrt() / (p * q * (p + q).sqrt()) * kab * kcd * fm[0];
286        assert!(
287            (out[0] - expect).abs() < 1e-14 * expect.abs(),
288            "ssss {} vs {}",
289            out[0],
290            expect
291        );
292    }
293
294    /// The `scale` argument multiplies the whole block and the engine accumulates.
295    #[test]
296    fn scale_and_accumulate() {
297        let a = Prim::new(0.8, [0.0, 0.0, 0.0], 0);
298        let b = Prim::new(1.3, [0.0, 0.0, 0.7], 0);
299        let c = Prim::new(0.5, [0.4, 0.0, 0.0], 0);
300        let d = Prim::new(1.1, [0.0, 0.6, 0.2], 0);
301        let mut one = [0.0; 1];
302        coulomb_into(a, b, c, d, 1.0, &mut one);
303        let mut acc = [0.0; 1];
304        coulomb_into(a, b, c, d, 2.0, &mut acc);
305        coulomb_into(a, b, c, d, 3.0, &mut acc);
306        assert!((acc[0] - 5.0 * one[0]).abs() < 1e-12 * one[0].abs());
307    }
308
309    /// Bra–ket exchange symmetry of a primitive block: `(ab|cd) = (cd|ab)` under
310    /// the corresponding index transpose. Uses a `(ps|ss)`-type block so the
311    /// p-shell makes the test non-trivial.
312    #[test]
313    fn bra_ket_exchange_primitive() {
314        let a = Prim::new(0.9, [0.1, 0.0, 0.0], 1); // p
315        let b = Prim::new(0.7, [0.0, 0.2, 0.0], 0); // s
316        let c = Prim::new(1.2, [0.0, 0.0, 0.3], 0); // s
317        let d = Prim::new(0.6, [0.2, 0.1, 0.0], 0); // s
318        let mut abcd = [0.0; 3]; // (p s| s s): na=3, others 1
319        coulomb_into(a, b, c, d, 1.0, &mut abcd);
320        // (cd|ab) = (s s| p s): the p block is the last index -> nd=3.
321        let mut cdab = [0.0; 3];
322        coulomb_into(c, d, a, b, 1.0, &mut cdab);
323        for i in 0..3 {
324            assert!(
325                (abcd[i] - cdab[i]).abs() < 1e-13 * abcd[i].abs().max(1e-300),
326                "bra-ket exchange mismatch at {i}: {} vs {}",
327                abcd[i],
328                cdab[i]
329            );
330        }
331    }
332}