integral_core/deriv.rs
1//! Geometric (nuclear-coordinate) first derivatives of integrals (L2).
2//!
3//! Derivatives are assembled from **shifted-angular-momentum value integrals**
4//! via the Gaussian center-derivative relation — no new recurrences. For a
5//! primitive Cartesian Gaussian `g_a` on center `A`,
6//!
7//! ```text
8//! ∂/∂A_i g_a = 2α · g_{a+1_i} − a_i · g_{a−1_i}
9//! ```
10//!
11//! (raise / lower the angular momentum along axis `i`), with `α` the primitive
12//! exponent and `a_i` the Cartesian power along `i`. Because the operator of a
13//! one- or two-electron integral does not depend on the basis-function center,
14//! `∂/∂A_i ⟨g_a|O|…⟩ = ⟨∂/∂A_i g_a|O|…⟩`, so the derivative of any integral block
15//! is this same fixed linear combination of the value blocks evaluated at `l±1`
16//! on the differentiated index. The existing engines compute those shifted
17//! blocks; this module only combines them.
18//!
19//! ## The `2α` weight is folded by the caller (engine-agnostic)
20//!
21//! The `2α` weight is **per primitive**. Rather than bake an exponent into the
22//! combiner — which would only work for a per-primitive engine — the **raised**
23//! block passed to [`accumulate_center_derivative`] is already weighted by `2α`.
24//! Every value engine takes a `scale`, so:
25//!
26//! - the per-primitive **Rys** path evaluates the raised primitive block with
27//! `scale = 2α` directly;
28//! - the contracted **OS/HGP** path folds `2α` into each primitive's contraction
29//! coefficient (`coeff_p · 2α_p`) when it evaluates the raised contracted block.
30//!
31//! Both yield the same `2α`-weighted raised block, so one combiner serves both
32//! engines and the one- and two-electron paths alike.
33//!
34//! ## Convention
35//!
36//! The result is `∂/∂(center of the differentiated shell)` — the basis-function
37//! (center) derivative with respect to the position of the center the
38//! differentiated angular-momentum index sits on. The sign / which-center
39//! convention is documented above.
40
41use integral_math::am::{cart_components, cart_index, n_cart};
42
43/// Geometry of one center-derivative step: which angular-momentum index of a
44/// row-major block is being differentiated, along which Cartesian axis, and how
45/// that index is embedded in the block.
46///
47/// The block's indices are grouped as `[outer, l-index, inner]` (row-major): the
48/// `l`-index runs over `n_cart(l)` Cartesian components, `outer` is the product
49/// of all slower index dimensions and `inner` the product of all faster ones.
50#[derive(Debug, Clone, Copy)]
51pub struct AxisDeriv {
52 /// Angular momentum of the differentiated index.
53 pub l: usize,
54 /// Cartesian axis of the derivative: `0 = x`, `1 = y`, `2 = z`.
55 pub cart_axis: usize,
56 /// Product of the dimensions of all slower indices.
57 pub outer: usize,
58 /// Product of the dimensions of all faster indices.
59 pub inner: usize,
60}
61
62/// Accumulate the center-derivative of one row-major block along one Cartesian
63/// axis of one angular-momentum index (geometry in `d`).
64///
65/// The two inputs are the **value** blocks with the differentiated index raised
66/// and lowered. The raised block must **already be weighted by `2α`** (see the
67/// module docs):
68///
69/// - `raised`: `2α`-weighted, dims `outer × n_cart(l+1) × inner`,
70/// - `lowered`: dims `outer × n_cart(l-1) × inner` (pass `None` when `l == 0`).
71///
72/// For each component `a` (axis `d.cart_axis ∈ {0,1,2}`) the result is
73///
74/// ```text
75/// out[outer, a, inner] += scale · ( raised[outer, a+1_i, inner]
76/// − a_i · lowered[outer, a−1_i, inner] )
77/// ```
78///
79/// accumulated into `out` (dims `outer × n_cart(l) × inner`). `scale` is the
80/// contraction-coefficient product for this primitive (set/term).
81///
82/// This single primitive serves every index position: a one-electron bra
83/// (`outer = 1`, `inner = n_cart(lb)`), a ket (`outer = n_cart(la)`,
84/// `inner = 1`), and any of the four ERI indices (`outer`/`inner` the products of
85/// the dimensions before/after it).
86///
87/// # Panics
88/// Debug-asserts the input/output lengths are consistent and that `lowered` is
89/// present whenever `l > 0`.
90pub fn accumulate_center_derivative(
91 d: AxisDeriv,
92 scale: f64,
93 raised: &[f64],
94 lowered: Option<&[f64]>,
95 out: &mut [f64],
96) {
97 let AxisDeriv {
98 l,
99 cart_axis,
100 outer,
101 inner,
102 } = d;
103 debug_assert!(cart_axis < 3, "cart_axis must be 0, 1, or 2");
104 let nl = n_cart(l);
105 let np1 = n_cart(l + 1);
106 debug_assert_eq!(raised.len(), outer * np1 * inner, "raised block size");
107 debug_assert_eq!(out.len(), outer * nl * inner, "output block size");
108 if l > 0 {
109 debug_assert!(lowered.is_some(), "lowered block required for l > 0");
110 debug_assert_eq!(
111 lowered.unwrap().len(),
112 outer * n_cart(l - 1) * inner,
113 "lowered block size"
114 );
115 }
116 let nm1 = if l > 0 { n_cart(l - 1) } else { 0 };
117
118 for (a_idx, a) in cart_components(l).into_iter().enumerate() {
119 // Raise along the axis: a + 1_i lands at `r_idx` in shell l+1.
120 let mut ar = a;
121 ar[cart_axis] += 1;
122 let r_idx = cart_index(ar);
123
124 // Lower along the axis: a − 1_i in shell l-1, weighted by the power a_i.
125 let pw = a[cart_axis];
126 let l_idx = if pw >= 1 {
127 let mut al = a;
128 al[cart_axis] -= 1;
129 Some(cart_index(al))
130 } else {
131 None
132 };
133
134 for o in 0..outer {
135 let out_row = (o * nl + a_idx) * inner;
136 let r_row = (o * np1 + r_idx) * inner;
137 let l_row = l_idx.map(|li| (o * nm1 + li) * inner);
138 for s in 0..inner {
139 let mut v = raised[r_row + s];
140 if let Some(lr) = l_row {
141 v -= (pw as f64) * lowered.unwrap()[lr + s];
142 }
143 out[out_row + s] += scale * v;
144 }
145 }
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 fn desc(l: usize, cart_axis: usize, outer: usize, inner: usize) -> AxisDeriv {
154 AxisDeriv {
155 l,
156 cart_axis,
157 outer,
158 inner,
159 }
160 }
161
162 // For l = 0 (s function) the derivative is purely the raise term. With the
163 // raised p-block already weighted by 2α, the x-derivative picks raised_x.
164 #[test]
165 fn s_function_derivative_is_raise_only() {
166 let raised = vec![10.0, 20.0, 30.0]; // 2α-weighted p block (n_cart(1)=3)
167 let mut out = vec![0.0; 1];
168 accumulate_center_derivative(desc(0, 0, 1, 1), 1.0, &raised, None, &mut out);
169 assert_eq!(out[0], 10.0);
170 let mut outy = vec![0.0; 1];
171 accumulate_center_derivative(desc(0, 1, 1, 1), 1.0, &raised, None, &mut outy);
172 assert_eq!(outy[0], 20.0);
173 }
174
175 // For a p function, ∂_x p_x = (2α·d)_xx − 1·s ; ∂_x p_y = (2α·d)_xy − 0.
176 #[test]
177 fn p_function_derivative_has_raise_and_lower() {
178 // raised = 2α-weighted d block over n_cart(2)=6: xx,xy,xz,yy,yz,zz
179 let d = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
180 // lowered = s block (n_cart(0)=1)
181 let s = vec![7.0];
182 let mut out = vec![0.0; 3]; // p block: x,y,z
183 accumulate_center_derivative(desc(1, 0, 1, 1), 1.0, &d, Some(&s), &mut out);
184 // ∂_x p_x = d_xx − 1·s (p_x=[1,0,0], a_x=1, raise->xx idx0, lower->s)
185 assert_eq!(out[0], 1.0 - 7.0);
186 // ∂_x p_y = d_xy − 0 (p_y=[0,1,0], a_x=0, raise->xy idx1)
187 assert_eq!(out[1], 2.0);
188 // ∂_x p_z = d_xz − 0 (p_z=[0,0,1], a_x=0, raise->xz idx2)
189 assert_eq!(out[2], 3.0);
190 }
191
192 // `scale` multiplies the whole term, and repeated calls accumulate.
193 #[test]
194 fn scale_multiplies_and_accumulates() {
195 let raised = vec![10.0, 0.0, 0.0];
196 let mut out = vec![0.0; 1];
197 accumulate_center_derivative(desc(0, 0, 1, 1), 2.0, &raised, None, &mut out);
198 accumulate_center_derivative(desc(0, 0, 1, 1), 3.0, &raised, None, &mut out);
199 assert_eq!(out[0], (2.0 + 3.0) * 10.0);
200 }
201
202 // Outer indexing: a ket-style derivative with outer=2, inner=1 lands in the
203 // right rows.
204 #[test]
205 fn outer_indexing_places_correctly() {
206 // l=0 differentiated index with outer=2: raised dims 2*n_cart(1)*1 = 6.
207 let raised = vec![1.0, 0.0, 0.0, /*row1*/ 2.0, 0.0, 0.0];
208 let mut out = vec![0.0; 2];
209 accumulate_center_derivative(desc(0, 0, 2, 1), 1.0, &raised, None, &mut out);
210 assert_eq!(out, vec![1.0, 2.0]);
211 }
212
213 // Inner indexing (ket of a 1e pair): outer=1, the differentiated index is
214 // fast (inner=1) but here we exercise inner>1 to check striding.
215 #[test]
216 fn inner_indexing_places_correctly() {
217 // Differentiate an l=0 index sitting BEFORE a size-2 inner index.
218 // raised dims outer(1)*n_cart(1)(3)*inner(2) = 6, laid out [comp][inner].
219 let raised = vec![
220 11.0, 12.0, // px row over inner=2
221 21.0, 22.0, // py
222 31.0, 32.0, // pz
223 ];
224 let mut out = vec![0.0; 2];
225 accumulate_center_derivative(desc(0, 0, 1, 2), 1.0, &raised, None, &mut out);
226 assert_eq!(out, vec![11.0, 12.0]); // x-derivative picks the px row
227 }
228}