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}