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