Skip to main content

dreid_kernel/potentials/nonbonded/
vdw.rs

1use crate::math::Real;
2use crate::traits::PairKernel;
3use crate::types::EnergyDiff;
4
5/// Lennard-Jones 12-6 potential for Van der Waals interactions.
6///
7/// # Physics
8///
9/// Models the short-range repulsion and long-range attraction between non-bonded atoms.
10///
11/// - **Formula**: $$ E = D_0 \left[ \left( \frac{R_0}{r} \right)^{12} - 2 \left( \frac{R_0}{r} \right)^6 \right] $$
12/// - **Derivative Factor (`diff`)**: $$ D = -\frac{1}{r} \frac{dE}{dr} = \frac{12 D_0}{r^2} \left[ \left( \frac{R_0}{r} \right)^{12} - \left( \frac{R_0}{r} \right)^6 \right] $$
13///
14/// # Parameters
15///
16/// - `d0`: Energy well depth $D_0$.
17/// - `r0_sq`: Squared equilibrium distance $R_0^2$.
18///
19/// # Pre-computation
20///
21/// Use [`LennardJones::precompute`] to convert physical constants into optimized parameters:
22/// $(D_0, R_0) \to (D_0, R_0^2)$.
23///
24/// # Inputs
25///
26/// - `r_sq`: Squared distance $r^2$ between two atoms.
27///
28/// # Implementation Notes
29///
30/// - This implementation avoids `sqrt` entirely by operating on squared distances.
31/// - The power chain `s -> s3 -> s6` is used for efficient calculation of $r^{-6}$ and $r^{-12}$ terms.
32/// - All intermediate calculations are shared between energy and force computations.
33/// - Branchless and panic-free.
34#[derive(Clone, Copy, Debug, Default)]
35pub struct LennardJones;
36
37impl LennardJones {
38    /// Pre-computes optimized kernel parameters from physical constants.
39    ///
40    /// # Input
41    ///
42    /// - `d0`: Energy well depth $D_0$.
43    /// - `r0`: Equilibrium distance $R_0$.
44    ///
45    /// # Output
46    ///
47    /// Returns `(d0, r0_sq)`:
48    /// - `d0`: Well depth (passed through).
49    /// - `r0_sq`: Squared equilibrium distance $R_0^2$.
50    ///
51    /// # Computation
52    ///
53    /// $$ R_0^2 = R_0 \times R_0 $$
54    #[inline(always)]
55    pub fn precompute<T: Real>(d0: T, r0: T) -> (T, T) {
56        (d0, r0 * r0)
57    }
58}
59
60impl<T: Real> PairKernel<T> for LennardJones {
61    type Params = (T, T);
62
63    /// Computes only the potential energy.
64    ///
65    /// # Formula
66    ///
67    /// $$ E = D_0 (s^6 - 2 s^3), \quad \text{where } s = R_0^2 / r^2 $$
68    #[inline(always)]
69    fn energy(r_sq: T, (d0, r0_sq): Self::Params) -> T {
70        let s = r0_sq * r_sq.recip();
71        let s3 = s * s * s;
72        let s6 = s3 * s3;
73
74        d0 * (s6 - T::from(2.0) * s3)
75    }
76
77    /// Computes only the force pre-factor $D$.
78    ///
79    /// # Formula
80    ///
81    /// $$ D = \frac{12 D_0}{r^2} (s^6 - s^3), \quad \text{where } s = R_0^2 / r^2 $$
82    ///
83    /// This factor is defined such that the force vector can be computed
84    /// by a single vector multiplication: $\vec{F} = -D \cdot \vec{r}$.
85    #[inline(always)]
86    fn diff(r_sq: T, (d0, r0_sq): Self::Params) -> T {
87        let inv_r2 = r_sq.recip();
88        let s = r0_sq * inv_r2;
89        let s3 = s * s * s;
90        let s6 = s3 * s3;
91
92        T::from(12.0) * d0 * inv_r2 * (s6 - s3)
93    }
94
95    /// Computes both energy and force pre-factor efficiently.
96    ///
97    /// This method reuses intermediate calculations to minimize operations.
98    #[inline(always)]
99    fn compute(r_sq: T, (d0, r0_sq): Self::Params) -> EnergyDiff<T> {
100        let inv_r2 = r_sq.recip();
101        let s = r0_sq * inv_r2;
102        let s3 = s * s * s;
103        let s6 = s3 * s3;
104
105        let energy = d0 * (s6 - T::from(2.0) * s3);
106
107        let diff = T::from(12.0) * d0 * inv_r2 * (s6 - s3);
108
109        EnergyDiff { energy, diff }
110    }
111}
112
113/// Buckingham (Exponential-6) potential for Van der Waals interactions.
114///
115/// # Physics
116///
117/// Models short-range repulsion exponentially and long-range attraction with an $r^{-6}$ term,
118/// providing a more physically accurate repulsion wall than Lennard-Jones.
119///
120/// - **Formula**: $$ E = D_0 \left[ \frac{6}{\zeta-6} \exp\left(\zeta(1 - \frac{r}{R_0})\right) - \frac{\zeta}{\zeta-6} \left(\frac{R_0}{r}\right)^6 \right] $$
121/// - **Derivative Factor (`diff`)**: $$ D = -\frac{1}{r} \frac{dE}{dr} = \frac{6\zeta D_0}{r(\zeta-6)R_0} \left[ \exp\left(\zeta(1 - \frac{r}{R_0})\right) - \left(\frac{R_0}{r}\right)^7 \right] $$
122///
123/// # Parameters
124///
125/// For computational efficiency, the physical parameters ($D_0, R_0, \zeta$) are pre-computed
126/// into the standard Buckingham form ($A, B, C$):
127/// - `a`: Repulsion pre-factor $A = \frac{6 D_0}{\zeta-6} e^{\zeta}$.
128/// - `b`: Repulsion decay constant $B = \zeta / R_0$.
129/// - `c`: Attraction pre-factor $C = \frac{\zeta D_0 R_0^6}{\zeta-6}$.
130/// - `r_max_sq`: Squared distance of the local energy maximum $r_{\text{max}}^2$.
131/// - `two_e_max`: Twice the energy at the local maximum, $2 E(r_{\text{max}})$.
132///
133/// # Pre-computation
134///
135/// Use [`Buckingham::precompute`] to convert physical constants into optimized parameters:
136/// $(D_0, R_0, \zeta) \to (A, B, C, r_{max}^2, 2E_{max})$.
137/// This involves computing the $A, B, C$ form and finding the reflection point
138/// via bisection.
139///
140/// # Inputs
141///
142/// - `r_sq`: Squared distance $r^2$ between two atoms.
143///
144/// # Implementation Notes
145///
146/// - The kernel operates on the computationally efficient $A, B, C$ form.
147/// - For $r < r_{\text{max}}$, the energy is reflected about the local maximum:
148///   $E_{\text{ref}}(r) = 2 E_{\text{max}} - E(r)$. This produces a repulsive wall
149///   that diverges to $+\infty$ as $r \to 0$ via the $C/r^6$ attraction term,
150///   while maintaining $C^1$ continuity at $r_{\text{max}}$ (where $E'(r_{\text{max}}) = 0$).
151/// - A branchless sign-flip replaces the traditional constant penalty, providing
152///   physically correct short-range behavior at zero additional runtime cost.
153/// - Requires one `sqrt` and one `exp` call, making it computationally more demanding than LJ.
154/// - Power chain `inv_r2 -> inv_r6 -> inv_r8` is used for the attractive term.
155#[derive(Clone, Copy, Debug, Default)]
156pub struct Buckingham;
157
158impl Buckingham {
159    /// Pre-computes optimized kernel parameters from physical constants.
160    ///
161    /// # Input
162    ///
163    /// - `d0`: Energy well depth $D_0$.
164    /// - `r0`: Equilibrium distance $R_0$.
165    /// - `zeta`: Steepness parameter $\zeta$ (must be $> 7$).
166    ///
167    /// # Output
168    ///
169    /// Returns `(a, b, c, r_max_sq, two_e_max)`:
170    /// - `a`: Repulsion pre-factor $A = \frac{6 D_0}{\zeta-6} e^{\zeta}$.
171    /// - `b`: Repulsion decay constant $B = \zeta / R_0$.
172    /// - `c`: Attraction pre-factor $C = \frac{\zeta D_0 R_0^6}{\zeta-6}$.
173    /// - `r_max_sq`: Squared distance of the local energy maximum.
174    /// - `two_e_max`: Twice the energy at the local maximum.
175    ///
176    /// # Computation
177    ///
178    /// $$ A = \frac{6 D_0}{\zeta - 6} e^{\zeta}, \quad B = \frac{\zeta}{R_0}, \quad C = \frac{\zeta D_0 R_0^6}{\zeta - 6} $$
179    ///
180    /// The reflection point $r_{max}$ is found by bisecting $dE/dr = 0$ on $(0, 7/B)$.
181    #[inline(always)]
182    pub fn precompute<T: Real>(d0: T, r0: T, zeta: T) -> (T, T, T, T, T) {
183        let six = T::from(6.0);
184        let zeta_minus_6 = zeta - six;
185
186        let r0_2 = r0 * r0;
187        let r0_3 = r0_2 * r0;
188        let r0_6 = r0_3 * r0_3;
189
190        let a = six * d0 * T::exp(zeta) / zeta_minus_6;
191        let b = zeta / r0;
192        let c = zeta * d0 * r0_6 / zeta_minus_6;
193
194        let mut lo = T::from(0.0);
195        let mut hi = T::from(7.0) / b;
196        for _ in 0..52 {
197            let mid = (lo + hi) * T::from(0.5);
198            let r2 = mid * mid;
199            let r3 = r2 * mid;
200            let r6 = r3 * r3;
201            let r7 = r6 * mid;
202            let g = a * b * T::exp(-b * mid) * r7 - six * c;
203            if g < T::from(0.0) {
204                lo = mid;
205            } else {
206                hi = mid;
207            }
208        }
209        let r = (lo + hi) * T::from(0.5);
210
211        let r_max_sq = r * r;
212
213        let inv_r = r.recip();
214        let inv_r2 = inv_r * inv_r;
215        let inv_r3 = inv_r2 * inv_r;
216        let inv_r6 = inv_r3 * inv_r3;
217        let e_max = a * T::exp(-b * r) - c * inv_r6;
218        let two_e_max = e_max + e_max;
219
220        (a, b, c, r_max_sq, two_e_max)
221    }
222}
223
224impl<T: Real> PairKernel<T> for Buckingham {
225    type Params = (T, T, T, T, T);
226
227    /// Computes only the potential energy.
228    ///
229    /// # Formula
230    ///
231    /// $$ E(r) = \begin{cases} A e^{-Br} - C/r^6 & r \ge r_{\text{max}} \\ 2 E_{\text{max}} - (A e^{-Br} - C/r^6) & r < r_{\text{max}} \end{cases} $$
232    #[inline(always)]
233    fn energy(r_sq: T, (a, b, c, r_max_sq, two_e_max): Self::Params) -> T {
234        let mask = T::from((r_sq >= r_max_sq) as u8 as f32);
235        let sign = mask + mask - T::from(1.0);
236
237        let r = r_sq.sqrt();
238        let inv_r2 = r_sq.recip();
239        let inv_r6 = inv_r2 * inv_r2 * inv_r2;
240
241        let e_buck = a * T::exp(-b * r) - c * inv_r6;
242
243        sign * e_buck + (T::from(1.0) - mask) * two_e_max
244    }
245
246    /// Computes only the force pre-factor $D$.
247    ///
248    /// # Formula
249    ///
250    /// $$ D(r) = \text{sign}(r) \left( \frac{A B e^{-Br}}{r} - \frac{6C}{r^8} \right) $$
251    ///
252    /// where $\text{sign}(r) = +1$ for $r \ge r_{\text{max}}$ and $-1$ otherwise.
253    /// At the maximum, $D(r_{\text{max}}) = 0$ from both sides, ensuring $C^1$ continuity.
254    ///
255    /// This factor is defined such that the force vector can be computed
256    /// by a single vector multiplication: $\vec{F} = -D \cdot \vec{r}$.
257    #[inline(always)]
258    fn diff(r_sq: T, (a, b, c, r_max_sq, _two_e_max): Self::Params) -> T {
259        let mask = T::from((r_sq >= r_max_sq) as u8 as f32);
260        let sign = mask + mask - T::from(1.0);
261
262        let inv_r = r_sq.rsqrt();
263        let r = r_sq * inv_r;
264        let inv_r2 = inv_r * inv_r;
265        let inv_r4 = inv_r2 * inv_r2;
266        let inv_r8 = inv_r4 * inv_r4;
267
268        let exp_term = T::exp(-b * r);
269        let d_buck = a * b * exp_term * inv_r - T::from(6.0) * c * inv_r8;
270
271        sign * d_buck
272    }
273
274    /// Computes both energy and force pre-factor efficiently.
275    ///
276    /// This method reuses intermediate calculations to minimize operations.
277    #[inline(always)]
278    fn compute(r_sq: T, (a, b, c, r_max_sq, two_e_max): Self::Params) -> EnergyDiff<T> {
279        let mask = T::from((r_sq >= r_max_sq) as u8 as f32);
280        let sign = mask + mask - T::from(1.0);
281
282        let inv_r = r_sq.rsqrt();
283        let r = r_sq * inv_r;
284        let inv_r2 = inv_r * inv_r;
285        let inv_r4 = inv_r2 * inv_r2;
286        let inv_r6 = inv_r4 * inv_r2;
287        let inv_r8 = inv_r6 * inv_r2;
288
289        let exp_term = T::exp(-b * r);
290
291        let e_buck = a * exp_term - c * inv_r6;
292        let d_buck = a * b * exp_term * inv_r - T::from(6.0) * c * inv_r8;
293
294        EnergyDiff {
295            energy: sign * e_buck + (T::from(1.0) - mask) * two_e_max,
296            diff: sign * d_buck,
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use approx::assert_relative_eq;
305
306    // ------------------------------------------------------------------------
307    // Test Constants
308    // ------------------------------------------------------------------------
309
310    const H: f64 = 1e-6;
311    const TOL_DIFF: f64 = 1e-4;
312
313    // ========================================================================
314    // Lennard-Jones Tests
315    // ========================================================================
316
317    mod lennard_jones {
318        use super::*;
319
320        const D0: f64 = 0.1;
321        const R0: f64 = 3.5;
322        const R0_SQ: f64 = R0 * R0;
323
324        fn params() -> (f64, f64) {
325            LennardJones::precompute(D0, R0)
326        }
327
328        // --------------------------------------------------------------------
329        // 1. Sanity Checks
330        // --------------------------------------------------------------------
331
332        #[test]
333        fn sanity_compute_equals_separate() {
334            let r_sq = 9.0_f64;
335            let p = params();
336
337            let result = LennardJones::compute(r_sq, p);
338            let energy_only = LennardJones::energy(r_sq, p);
339            let diff_only = LennardJones::diff(r_sq, p);
340
341            assert_relative_eq!(result.energy, energy_only, epsilon = 1e-14);
342            assert_relative_eq!(result.diff, diff_only, epsilon = 1e-14);
343        }
344
345        #[test]
346        fn sanity_f32_f64_consistency() {
347            let r_sq = 12.25;
348            let p64 = params();
349            let p32 = LennardJones::precompute(D0 as f32, R0 as f32);
350
351            let e64 = LennardJones::energy(r_sq, p64);
352            let e32 = LennardJones::energy(r_sq as f32, p32);
353
354            assert_relative_eq!(e64, e32 as f64, epsilon = 1e-5);
355        }
356
357        #[test]
358        fn sanity_equilibrium_energy_minimum() {
359            let e = LennardJones::energy(R0_SQ, params());
360            assert_relative_eq!(e, -D0, epsilon = 1e-10);
361        }
362
363        #[test]
364        fn sanity_equilibrium_zero_force() {
365            let d = LennardJones::diff(R0_SQ, params());
366            assert_relative_eq!(d, 0.0, epsilon = 1e-10);
367        }
368
369        // --------------------------------------------------------------------
370        // 2. Numerical Stability
371        // --------------------------------------------------------------------
372
373        #[test]
374        fn stability_large_distance() {
375            let r_sq = 1e6_f64;
376            let result = LennardJones::compute(r_sq, params());
377
378            assert!(result.energy.is_finite());
379            assert!(result.diff.is_finite());
380            assert!(result.energy.abs() < 1e-10);
381        }
382
383        #[test]
384        fn stability_small_distance() {
385            let r_sq = 1.0_f64;
386            let result = LennardJones::compute(r_sq, params());
387
388            assert!(result.energy.is_finite());
389            assert!(result.diff.is_finite());
390            assert!(result.energy > 0.0);
391        }
392
393        // --------------------------------------------------------------------
394        // 3. Finite Difference Verification
395        // --------------------------------------------------------------------
396
397        fn finite_diff_check(r: f64) {
398            let p = params();
399            let r_sq = r * r;
400
401            let r_plus = r + H;
402            let r_minus = r - H;
403            let e_plus = LennardJones::energy(r_plus * r_plus, p);
404            let e_minus = LennardJones::energy(r_minus * r_minus, p);
405            let de_dr_numerical = (e_plus - e_minus) / (2.0 * H);
406
407            let d_analytic = LennardJones::diff(r_sq, p);
408            let de_dr_analytic = -d_analytic * r;
409
410            assert_relative_eq!(de_dr_numerical, de_dr_analytic, epsilon = TOL_DIFF);
411        }
412
413        #[test]
414        fn finite_diff_repulsion_region() {
415            finite_diff_check(2.5);
416        }
417
418        #[test]
419        fn finite_diff_equilibrium_region() {
420            finite_diff_check(R0);
421        }
422
423        #[test]
424        fn finite_diff_attraction_region() {
425            finite_diff_check(5.0);
426        }
427
428        #[test]
429        fn finite_diff_long_range() {
430            finite_diff_check(10.0);
431        }
432
433        // --------------------------------------------------------------------
434        // 4. LJ-Specific: Physical Behavior
435        // --------------------------------------------------------------------
436
437        #[test]
438        fn specific_repulsion_positive_energy() {
439            let e = LennardJones::energy(4.0, params());
440            assert!(e > 0.0);
441        }
442
443        #[test]
444        fn specific_attraction_negative_energy() {
445            let e = LennardJones::energy(25.0, params());
446            assert!(e < 0.0);
447        }
448
449        #[test]
450        fn specific_diff_sign_repulsion() {
451            let d = LennardJones::diff(4.0, params());
452            assert!(d > 0.0);
453        }
454
455        #[test]
456        fn specific_diff_sign_attraction() {
457            let d = LennardJones::diff(25.0, params());
458            assert!(d < 0.0);
459        }
460
461        // --------------------------------------------------------------------
462        // 5. Precompute
463        // --------------------------------------------------------------------
464
465        #[test]
466        fn precompute_values() {
467            let (d0, r0_sq) = LennardJones::precompute(D0, R0);
468            assert_relative_eq!(d0, D0, epsilon = 1e-14);
469            assert_relative_eq!(r0_sq, R0_SQ, epsilon = 1e-14);
470        }
471
472        #[test]
473        fn precompute_round_trip() {
474            let p = LennardJones::precompute(D0, R0);
475            let e = LennardJones::energy(R0_SQ, p);
476            assert_relative_eq!(e, -D0, epsilon = 1e-10);
477        }
478    }
479
480    // ========================================================================
481    // Buckingham Tests
482    // ========================================================================
483
484    mod buckingham {
485        use super::*;
486
487        const D0: f64 = 1.0;
488        const R0: f64 = 2.0;
489        const ZETA: f64 = 12.0;
490
491        fn params() -> (f64, f64, f64, f64, f64) {
492            Buckingham::precompute(D0, R0, ZETA)
493        }
494
495        // --------------------------------------------------------------------
496        // 1. Sanity Checks
497        // --------------------------------------------------------------------
498
499        #[test]
500        fn sanity_compute_equals_separate() {
501            let r_sq = 4.0_f64;
502            let p = params();
503
504            let result = Buckingham::compute(r_sq, p);
505            let energy_only = Buckingham::energy(r_sq, p);
506            let diff_only = Buckingham::diff(r_sq, p);
507
508            assert_relative_eq!(result.energy, energy_only, epsilon = 1e-12);
509            assert_relative_eq!(result.diff, diff_only, epsilon = 1e-12);
510        }
511
512        #[test]
513        fn sanity_compute_equals_separate_reflected() {
514            let p = params();
515            let r_sq = 0.25_f64;
516
517            let result = Buckingham::compute(r_sq, p);
518            let energy_only = Buckingham::energy(r_sq, p);
519            let diff_only = Buckingham::diff(r_sq, p);
520
521            assert_relative_eq!(result.energy, energy_only, epsilon = 1e-12);
522            assert_relative_eq!(result.diff, diff_only, epsilon = 1e-12);
523        }
524
525        #[test]
526        fn sanity_f32_f64_consistency() {
527            let r_sq = 4.0;
528            let p64 = params();
529            let p32 = Buckingham::precompute(D0 as f32, R0 as f32, ZETA as f32);
530
531            let e64 = Buckingham::energy(r_sq, p64);
532            let e32 = Buckingham::energy(r_sq as f32, p32);
533
534            assert_relative_eq!(e64, e32 as f64, epsilon = 1e-3);
535        }
536
537        #[test]
538        fn sanity_equilibrium_zero_force() {
539            let d = Buckingham::diff(R0 * R0, params());
540            assert_relative_eq!(d, 0.0, epsilon = 1e-10);
541        }
542
543        // --------------------------------------------------------------------
544        // 2. Numerical Stability
545        // --------------------------------------------------------------------
546
547        #[test]
548        fn stability_reflected_region() {
549            let r_sq = 0.1_f64;
550            let result = Buckingham::compute(r_sq, params());
551
552            assert!(result.energy.is_finite());
553            assert!(result.diff.is_finite());
554            assert!(result.energy > 0.0);
555            assert!(result.diff > 0.0);
556        }
557
558        #[test]
559        fn stability_large_distance() {
560            let r_sq = 1e4_f64;
561            let result = Buckingham::compute(r_sq, params());
562
563            assert!(result.energy.is_finite());
564            assert!(result.diff.is_finite());
565        }
566
567        #[test]
568        fn stability_near_zero() {
569            let r_sq = 1e-20_f64;
570            let p = params();
571            let e = Buckingham::energy(r_sq, p);
572
573            assert!(e.is_finite());
574            assert!(e > 0.0);
575        }
576
577        // --------------------------------------------------------------------
578        // 3. Finite Difference Verification
579        // --------------------------------------------------------------------
580
581        fn finite_diff_check(r: f64) {
582            let p = params();
583            let r_sq = r * r;
584
585            let r_plus = r + H;
586            let r_minus = r - H;
587            let e_plus = Buckingham::energy(r_plus * r_plus, p);
588            let e_minus = Buckingham::energy(r_minus * r_minus, p);
589            let de_dr_numerical = (e_plus - e_minus) / (2.0 * H);
590
591            let d_analytic = Buckingham::diff(r_sq, p);
592            let de_dr_analytic = -d_analytic * r;
593
594            assert_relative_eq!(de_dr_numerical, de_dr_analytic, epsilon = TOL_DIFF);
595        }
596
597        #[test]
598        fn finite_diff_reflected_region() {
599            finite_diff_check(0.4);
600        }
601
602        #[test]
603        fn finite_diff_normal_short_range() {
604            finite_diff_check(1.5);
605        }
606
607        #[test]
608        fn finite_diff_medium_range() {
609            finite_diff_check(3.0);
610        }
611
612        #[test]
613        fn finite_diff_long_range() {
614            finite_diff_check(8.0);
615        }
616
617        // --------------------------------------------------------------------
618        // 4. Buckingham-Specific: Reflection Properties
619        // --------------------------------------------------------------------
620
621        #[test]
622        fn specific_reflection_diverges() {
623            let p = params();
624            let e_close = Buckingham::energy(0.01, p);
625            let e_far = Buckingham::energy(0.25, p);
626            assert!(e_close > e_far);
627        }
628
629        #[test]
630        fn specific_diff_at_maximum_is_zero() {
631            let p = params();
632            let r_max_sq = p.3;
633            let d = Buckingham::diff(r_max_sq, p);
634            assert_relative_eq!(d, 0.0, epsilon = 1e-6);
635        }
636
637        #[test]
638        fn specific_c1_continuity_at_maximum() {
639            let p = params();
640            let r_max = p.3.sqrt();
641            let eps = 1e-8;
642
643            let r_inside = r_max - eps;
644            let r_outside = r_max + eps;
645
646            let d_inside = Buckingham::diff(r_inside * r_inside, p);
647            let d_outside = Buckingham::diff(r_outside * r_outside, p);
648
649            let de_dr_inside = -d_inside * r_inside;
650            let de_dr_outside = -d_outside * r_outside;
651
652            assert_relative_eq!(de_dr_inside, de_dr_outside, epsilon = 1e-3);
653        }
654
655        #[test]
656        fn specific_energy_continuity_at_maximum() {
657            let p = params();
658            let r_max = p.3.sqrt();
659            let eps = 1e-8;
660
661            let e_inside = Buckingham::energy((r_max - eps).powi(2), p);
662            let e_outside = Buckingham::energy((r_max + eps).powi(2), p);
663
664            assert_relative_eq!(e_inside, e_outside, epsilon = 1e-4);
665        }
666
667        #[test]
668        fn specific_finite_diff_across_boundary() {
669            let p = params();
670            let r_max = p.3.sqrt();
671
672            let r_out = r_max + 0.01;
673            let e_p = Buckingham::energy((r_out + H).powi(2), p);
674            let e_m = Buckingham::energy((r_out - H).powi(2), p);
675            let de_dr_num_out = (e_p - e_m) / (2.0 * H);
676            let de_dr_ana_out = -Buckingham::diff(r_out * r_out, p) * r_out;
677            assert_relative_eq!(de_dr_num_out, de_dr_ana_out, epsilon = TOL_DIFF);
678
679            let r_in = r_max - 0.01;
680            let e_p = Buckingham::energy((r_in + H).powi(2), p);
681            let e_m = Buckingham::energy((r_in - H).powi(2), p);
682            let de_dr_num_in = (e_p - e_m) / (2.0 * H);
683            let de_dr_ana_in = -Buckingham::diff(r_in * r_in, p) * r_in;
684            assert_relative_eq!(de_dr_num_in, de_dr_ana_in, epsilon = TOL_DIFF);
685        }
686
687        // --------------------------------------------------------------------
688        // 5. Precompute
689        // --------------------------------------------------------------------
690
691        #[test]
692        fn precompute_values() {
693            let (a, b, c, r_max_sq, two_e_max) = Buckingham::precompute(D0, R0, ZETA);
694            assert_relative_eq!(a, 12.0_f64.exp(), epsilon = 1e-12);
695            assert_relative_eq!(b, 6.0, epsilon = 1e-14);
696            assert_relative_eq!(c, 128.0, epsilon = 1e-10);
697            assert!(r_max_sq > 0.0);
698            assert!(two_e_max.is_finite());
699        }
700
701        #[test]
702        fn precompute_r_max_is_critical_point() {
703            let (a, b, c, r_max_sq, _) = params();
704            let r = r_max_sq.sqrt();
705            let g = a * b * (-b * r).exp() * r.powi(7) - 6.0 * c;
706            assert!(g.abs() < 1e-9);
707        }
708
709        #[test]
710        fn precompute_r_max_less_than_r0() {
711            let (_, _, _, r_max_sq, _) = params();
712            assert!(r_max_sq < R0 * R0);
713        }
714
715        #[test]
716        fn precompute_e_max_positive() {
717            let (_, _, _, _, two_e_max) = params();
718            assert!(two_e_max > 0.0);
719        }
720
721        #[test]
722        fn precompute_round_trip() {
723            let p = Buckingham::precompute(D0, R0, ZETA);
724            let e = Buckingham::energy(R0 * R0, p);
725            assert_relative_eq!(e, -D0, epsilon = 1e-6);
726        }
727    }
728}