Skip to main content

dreid_kernel/potentials/bonded/
inversion.rs

1use crate::math::Real;
2use crate::traits::AngleKernel;
3use crate::types::EnergyDiff;
4
5/// Planar inversion potential for sp² centers.
6///
7/// # Physics
8///
9/// Models planarity for sp² hybridized atoms (e.g., aromatic carbons, carbonyl groups).
10/// The central atom $I$ is bonded to three neighbors $J, K, L$, and the potential
11/// penalizes any out-of-plane displacement.
12///
13/// - **Formula**: $$ E = \frac{1}{2} C \cos^2\psi $$
14/// - **Derivative Factor (`diff`)**: $$ \Gamma = \frac{dE}{d(\cos\psi)} = C \cos\psi $$
15///
16/// # Parameters
17///
18/// - `c_half`: Half force constant $C_{half} = C/2$.
19///
20/// # Pre-computation
21///
22/// Use [`PlanarInversion::precompute`] to convert physical constants into optimized parameters:
23/// $C \to C/2$.
24///
25/// # Inputs
26///
27/// - `cos_psi`: Cosine of the out-of-plane angle $\psi$.
28///
29/// # Implementation Notes
30///
31/// - Optimized for $\cos\psi_0 = 0$: avoids one subtraction per evaluation.
32/// - Pure polynomial evaluation; no trigonometric functions required.
33/// - All intermediate calculations are shared between energy and force computations.
34/// - Branchless and panic-free.
35/// - **DREIDING convention:** force constant $C = K_{inv}/3$ (split among three permutations).
36#[derive(Clone, Copy, Debug, Default)]
37pub struct PlanarInversion;
38
39impl PlanarInversion {
40    /// Pre-computes optimized kernel parameters from physical constants.
41    ///
42    /// # Input
43    ///
44    /// - `c`: Force constant $C$.
45    ///
46    /// # Output
47    ///
48    /// Returns `c_half`:
49    /// - `c_half`: Half force constant $C/2$.
50    ///
51    /// # Computation
52    ///
53    /// $$ C_{half} = C / 2 $$
54    #[inline(always)]
55    pub fn precompute<T: Real>(c: T) -> T {
56        c * T::from(0.5)
57    }
58}
59
60impl<T: Real> AngleKernel<T> for PlanarInversion {
61    type Params = T;
62
63    /// Computes only the potential energy.
64    ///
65    /// # Formula
66    ///
67    /// $$ E = C_{half} \cos^2\psi $$
68    #[inline(always)]
69    fn energy(cos_psi: T, c_half: Self::Params) -> T {
70        c_half * cos_psi * cos_psi
71    }
72
73    /// Computes only the derivative factor $\Gamma$.
74    ///
75    /// # Formula
76    ///
77    /// $$ \Gamma = 2 C_{half} \cos\psi $$
78    ///
79    /// This factor allows computing forces via the chain rule:
80    /// $$ \vec{F} = -\Gamma \cdot \nabla (\cos\psi) $$
81    #[inline(always)]
82    fn diff(cos_psi: T, c_half: Self::Params) -> T {
83        let c = c_half + c_half;
84        c * cos_psi
85    }
86
87    /// Computes both energy and derivative factor efficiently.
88    ///
89    /// This method reuses intermediate calculations to minimize operations.
90    #[inline(always)]
91    fn compute(cos_psi: T, c_half: Self::Params) -> EnergyDiff<T> {
92        let energy = c_half * cos_psi * cos_psi;
93
94        let c = c_half + c_half;
95        let diff = c * cos_psi;
96
97        EnergyDiff { energy, diff }
98    }
99}
100
101/// Umbrella inversion potential for sp³ centers.
102///
103/// # Physics
104///
105/// Models a specific pyramidal geometry for sp³ hybridized atoms (e.g., amines, phosphines).
106/// The central atom $I$ is bonded to three neighbors $J, K, L$, and the potential
107/// penalizes deviations from the target out-of-plane angle $\psi_0$.
108///
109/// - **Formula**: $$ E = \frac{1}{2} C (\cos\psi - \cos\psi_0)^2 $$
110/// - **Derivative Factor (`diff`)**: $$ \Gamma = \frac{dE}{d(\cos\psi)} = C (\cos\psi - \cos\psi_0) $$
111///
112/// # Parameters
113///
114/// - `c_half`: Half force constant $C_{half} = C/2$.
115/// - `cos_psi0`: Cosine of equilibrium angle $\cos_{\psi,0} \neq 0$.
116///
117/// # Pre-computation
118///
119/// Use [`UmbrellaInversion::precompute`] to convert physical constants into optimized parameters:
120/// $(C, \psi_0°) \to (C/2, \cos_{\psi,0})$.
121///
122/// # Inputs
123///
124/// - `cos_psi`: Cosine of the out-of-plane angle $\psi$.
125///
126/// # Implementation Notes
127///
128/// - Pure polynomial evaluation; no trigonometric functions required.
129/// - All intermediate calculations are shared between energy and force computations.
130/// - Branchless and panic-free.
131/// - **DREIDING convention:** force constant $C = K_{inv}/3$ (split among three permutations).
132#[derive(Clone, Copy, Debug, Default)]
133pub struct UmbrellaInversion;
134
135impl UmbrellaInversion {
136    /// Pre-computes optimized kernel parameters from physical constants.
137    ///
138    /// # Input
139    ///
140    /// - `c`: Force constant $C$.
141    /// - `psi0_deg`: Equilibrium out-of-plane angle $\psi_0$ in degrees.
142    ///
143    /// # Output
144    ///
145    /// Returns `(c_half, cos_psi0)`:
146    /// - `c_half`: Half force constant $C/2$.
147    /// - `cos_psi0`: Cosine of equilibrium angle $\cos_{\psi,0}$.
148    ///
149    /// # Computation
150    ///
151    /// $$ C_{half} = C / 2, \quad \cos_{\psi,0} = \cos(\psi_0 \cdot \pi / 180) $$
152    #[inline(always)]
153    pub fn precompute<T: Real>(c: T, psi0_deg: T) -> (T, T) {
154        let deg_to_rad = T::pi() / T::from(180.0);
155        let c_half = c * T::from(0.5);
156        let cos_psi0 = (psi0_deg * deg_to_rad).cos();
157        (c_half, cos_psi0)
158    }
159}
160
161impl<T: Real> AngleKernel<T> for UmbrellaInversion {
162    type Params = (T, T);
163
164    /// Computes only the potential energy.
165    ///
166    /// # Formula
167    ///
168    /// $$ E = C_{half} (\cos\psi - \cos_{\psi,0})^2 $$
169    #[inline(always)]
170    fn energy(cos_psi: T, (c_half, cos_psi0): Self::Params) -> T {
171        let delta = cos_psi - cos_psi0;
172        c_half * delta * delta
173    }
174
175    /// Computes only the derivative factor $\Gamma$.
176    ///
177    /// # Formula
178    ///
179    /// $$ \Gamma = 2 C_{half} (\cos\psi - \cos_{\psi,0}) $$
180    ///
181    /// This factor allows computing forces via the chain rule:
182    /// $$ \vec{F} = -\Gamma \cdot \nabla (\cos\psi) $$
183    #[inline(always)]
184    fn diff(cos_psi: T, (c_half, cos_psi0): Self::Params) -> T {
185        let c = c_half + c_half;
186        c * (cos_psi - cos_psi0)
187    }
188
189    /// Computes both energy and derivative factor efficiently.
190    ///
191    /// This method reuses intermediate calculations to minimize operations.
192    #[inline(always)]
193    fn compute(cos_psi: T, (c_half, cos_psi0): Self::Params) -> EnergyDiff<T> {
194        let delta = cos_psi - cos_psi0;
195
196        let energy = c_half * delta * delta;
197
198        let c = c_half + c_half;
199        let diff = c * delta;
200
201        EnergyDiff { energy, diff }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use approx::assert_relative_eq;
209
210    // ------------------------------------------------------------------------
211    // Test Constants
212    // ------------------------------------------------------------------------
213
214    const H: f64 = 1e-6;
215    const TOL_DIFF: f64 = 1e-4;
216
217    // ========================================================================
218    // PlanarInversion Tests
219    // ========================================================================
220
221    mod planar_inversion {
222        use super::*;
223
224        const C: f64 = 40.0;
225        const C_HALF: f64 = C / 2.0;
226
227        fn params() -> f64 {
228            PlanarInversion::precompute(C)
229        }
230
231        // --------------------------------------------------------------------
232        // 1. Sanity Checks
233        // --------------------------------------------------------------------
234
235        #[test]
236        fn sanity_compute_equals_separate() {
237            let cos_psi = 0.3_f64;
238            let p = params();
239
240            let result = PlanarInversion::compute(cos_psi, p);
241            let energy_only = PlanarInversion::energy(cos_psi, p);
242            let diff_only = PlanarInversion::diff(cos_psi, p);
243
244            assert_relative_eq!(result.energy, energy_only, epsilon = 1e-12);
245            assert_relative_eq!(result.diff, diff_only, epsilon = 1e-12);
246        }
247
248        #[test]
249        fn sanity_f32_f64_consistency() {
250            let cos_psi = 0.4;
251            let p64 = params();
252            let p32 = PlanarInversion::precompute(C as f32);
253
254            let e64 = PlanarInversion::energy(cos_psi, p64);
255            let e32 = PlanarInversion::energy(cos_psi as f32, p32);
256
257            assert_relative_eq!(e64, e32 as f64, epsilon = 1e-5);
258        }
259
260        #[test]
261        fn sanity_equilibrium_planar() {
262            let result = PlanarInversion::compute(0.0, params());
263
264            assert_relative_eq!(result.energy, 0.0, epsilon = 1e-14);
265            assert_relative_eq!(result.diff, 0.0, epsilon = 1e-14);
266        }
267
268        // --------------------------------------------------------------------
269        // 2. Numerical Stability
270        // --------------------------------------------------------------------
271
272        #[test]
273        fn stability_cos_one() {
274            let result = PlanarInversion::compute(1.0, params());
275
276            assert!(result.energy.is_finite());
277            assert!(result.diff.is_finite());
278            assert_relative_eq!(result.energy, C_HALF, epsilon = 1e-10);
279        }
280
281        #[test]
282        fn stability_cos_minus_one() {
283            let result = PlanarInversion::compute(-1.0, params());
284
285            assert!(result.energy.is_finite());
286            assert!(result.diff.is_finite());
287            assert_relative_eq!(result.energy, C_HALF, epsilon = 1e-10);
288        }
289
290        // --------------------------------------------------------------------
291        // 3. Finite Difference Verification
292        // --------------------------------------------------------------------
293
294        fn finite_diff_check(cos_psi: f64) {
295            let p = params();
296
297            let c_plus = cos_psi + H;
298            let c_minus = cos_psi - H;
299            let e_plus = PlanarInversion::energy(c_plus, p);
300            let e_minus = PlanarInversion::energy(c_minus, p);
301            let de_dcos_numerical = (e_plus - e_minus) / (2.0 * H);
302
303            let gamma = PlanarInversion::diff(cos_psi, p);
304
305            assert_relative_eq!(de_dcos_numerical, gamma, epsilon = TOL_DIFF);
306        }
307
308        #[test]
309        fn finite_diff_small_deviation() {
310            finite_diff_check(0.1);
311        }
312
313        #[test]
314        fn finite_diff_medium_deviation() {
315            finite_diff_check(0.5);
316        }
317
318        #[test]
319        fn finite_diff_large_deviation() {
320            finite_diff_check(0.9);
321        }
322
323        #[test]
324        fn finite_diff_negative() {
325            finite_diff_check(-0.3);
326        }
327
328        // --------------------------------------------------------------------
329        // 4. PlanarInversion Specific
330        // --------------------------------------------------------------------
331
332        #[test]
333        fn specific_quadratic_in_cos() {
334            let cos_psi = 0.6;
335            let expected = C_HALF * cos_psi * cos_psi;
336
337            assert_relative_eq!(
338                PlanarInversion::energy(cos_psi, params()),
339                expected,
340                epsilon = 1e-14
341            );
342        }
343
344        #[test]
345        fn specific_symmetric_deviation() {
346            let e_pos = PlanarInversion::energy(0.5, params());
347            let e_neg = PlanarInversion::energy(-0.5, params());
348
349            assert_relative_eq!(e_pos, e_neg, epsilon = 1e-14);
350        }
351
352        #[test]
353        fn specific_restoring_force_direction() {
354            let d_pos = PlanarInversion::diff(0.5, params());
355            let d_neg = PlanarInversion::diff(-0.5, params());
356
357            assert!(d_pos > 0.0);
358            assert!(d_neg < 0.0);
359        }
360
361        // --------------------------------------------------------------------
362        // 5. Precompute
363        // --------------------------------------------------------------------
364
365        #[test]
366        fn precompute_values() {
367            let c_half = PlanarInversion::precompute(C);
368            assert_relative_eq!(c_half, C_HALF, epsilon = 1e-14);
369        }
370
371        #[test]
372        fn precompute_round_trip() {
373            let p = PlanarInversion::precompute(C);
374            let e = PlanarInversion::energy(0.0, p);
375            assert_relative_eq!(e, 0.0, epsilon = 1e-14);
376        }
377    }
378
379    // ========================================================================
380    // UmbrellaInversion Tests
381    // ========================================================================
382
383    mod umbrella_inversion {
384        use super::*;
385
386        const C: f64 = 30.0;
387        const PSI0_DEG: f64 = 60.0;
388        const C_HALF: f64 = C / 2.0;
389        const COS_PSI0: f64 = 0.5;
390
391        fn params() -> (f64, f64) {
392            UmbrellaInversion::precompute(C, PSI0_DEG)
393        }
394
395        // --------------------------------------------------------------------
396        // 1. Sanity Checks
397        // --------------------------------------------------------------------
398
399        #[test]
400        fn sanity_compute_equals_separate() {
401            let cos_psi = 0.5_f64;
402            let p = params();
403
404            let result = UmbrellaInversion::compute(cos_psi, p);
405            let energy_only = UmbrellaInversion::energy(cos_psi, p);
406            let diff_only = UmbrellaInversion::diff(cos_psi, p);
407
408            assert_relative_eq!(result.energy, energy_only, epsilon = 1e-12);
409            assert_relative_eq!(result.diff, diff_only, epsilon = 1e-12);
410        }
411
412        #[test]
413        fn sanity_f32_f64_consistency() {
414            let cos_psi = 0.4;
415            let p64 = params();
416            let p32 = UmbrellaInversion::precompute(C as f32, PSI0_DEG as f32);
417
418            let e64 = UmbrellaInversion::energy(cos_psi, p64);
419            let e32 = UmbrellaInversion::energy(cos_psi as f32, p32);
420
421            assert_relative_eq!(e64, e32 as f64, epsilon = 1e-5);
422        }
423
424        #[test]
425        fn sanity_equilibrium() {
426            let result = UmbrellaInversion::compute(COS_PSI0, params());
427
428            assert_relative_eq!(result.energy, 0.0, epsilon = 1e-14);
429            assert_relative_eq!(result.diff, 0.0, epsilon = 1e-14);
430        }
431
432        // --------------------------------------------------------------------
433        // 2. Numerical Stability
434        // --------------------------------------------------------------------
435
436        #[test]
437        fn stability_cos_one() {
438            let result = UmbrellaInversion::compute(1.0, params());
439
440            assert!(result.energy.is_finite());
441            assert!(result.diff.is_finite());
442        }
443
444        #[test]
445        fn stability_cos_minus_one() {
446            let result = UmbrellaInversion::compute(-1.0, params());
447
448            assert!(result.energy.is_finite());
449            assert!(result.diff.is_finite());
450        }
451
452        // --------------------------------------------------------------------
453        // 3. Finite Difference Verification
454        // --------------------------------------------------------------------
455
456        fn finite_diff_check(cos_psi: f64) {
457            let p = params();
458
459            let c_plus = cos_psi + H;
460            let c_minus = cos_psi - H;
461            let e_plus = UmbrellaInversion::energy(c_plus, p);
462            let e_minus = UmbrellaInversion::energy(c_minus, p);
463            let de_dcos_numerical = (e_plus - e_minus) / (2.0 * H);
464
465            let gamma = UmbrellaInversion::diff(cos_psi, p);
466
467            assert_relative_eq!(de_dcos_numerical, gamma, epsilon = TOL_DIFF);
468        }
469
470        #[test]
471        fn finite_diff_above_equilibrium() {
472            finite_diff_check(COS_PSI0 + 0.2);
473        }
474
475        #[test]
476        fn finite_diff_below_equilibrium() {
477            finite_diff_check(COS_PSI0 - 0.2);
478        }
479
480        #[test]
481        fn finite_diff_at_zero() {
482            finite_diff_check(0.0);
483        }
484
485        #[test]
486        fn finite_diff_negative() {
487            finite_diff_check(-0.5);
488        }
489
490        // --------------------------------------------------------------------
491        // 4. UmbrellaInversion Specific
492        // --------------------------------------------------------------------
493
494        #[test]
495        fn specific_quadratic_in_delta() {
496            let cos_psi = 0.6;
497            let delta = cos_psi - COS_PSI0;
498            let expected = C_HALF * delta * delta;
499
500            assert_relative_eq!(
501                UmbrellaInversion::energy(cos_psi, params()),
502                expected,
503                epsilon = 1e-14
504            );
505        }
506
507        #[test]
508        fn specific_reduces_to_planar_when_cos0_zero() {
509            let p_umbrella = (C_HALF, 0.0);
510            let p_planar = C_HALF;
511            let cos_psi = 0.5;
512
513            let e_umbrella = UmbrellaInversion::energy(cos_psi, p_umbrella);
514            let e_planar = PlanarInversion::energy(cos_psi, p_planar);
515
516            assert_relative_eq!(e_umbrella, e_planar, epsilon = 1e-14);
517        }
518
519        #[test]
520        fn specific_restoring_force_direction() {
521            let d_above = UmbrellaInversion::diff(COS_PSI0 + 0.3, params());
522            let d_below = UmbrellaInversion::diff(COS_PSI0 - 0.3, params());
523
524            assert!(d_above > 0.0);
525            assert!(d_below < 0.0);
526        }
527
528        // --------------------------------------------------------------------
529        // 5. Precompute
530        // --------------------------------------------------------------------
531
532        #[test]
533        fn precompute_values() {
534            let (c_half, cos_psi0) = UmbrellaInversion::precompute(C, PSI0_DEG);
535            assert_relative_eq!(c_half, C_HALF, epsilon = 1e-14);
536            assert_relative_eq!(cos_psi0, COS_PSI0, epsilon = 1e-10);
537        }
538
539        #[test]
540        fn precompute_round_trip() {
541            let p = UmbrellaInversion::precompute(C, PSI0_DEG);
542            let e = UmbrellaInversion::energy(COS_PSI0, p);
543            assert_relative_eq!(e, 0.0, epsilon = 1e-10);
544        }
545    }
546}