Skip to main content

dreid_kernel/potentials/bonded/
angle.rs

1use crate::math::Real;
2use crate::traits::AngleKernel;
3use crate::types::EnergyDiff;
4
5/// Cosine Harmonic potential for bond angles.
6///
7/// # Physics
8///
9/// Models the angle bending energy using a simple harmonic approximation on the cosine of the angle.
10///
11/// - **Formula**: $$ E = \frac{1}{2} C (\cos\theta - \cos\theta_0)^2 $$
12/// - **Derivative Factor (`diff`)**: $$ \Gamma = \frac{dE}{d(\cos\theta)} = C (\cos\theta - \cos\theta_0) $$
13///
14/// # Parameters
15///
16/// - `c_half`: Half force constant $C_{half} = C/2$.
17/// - `cos0`: Cosine of equilibrium angle $\cos_0$.
18///
19/// # Pre-computation
20///
21/// Use [`CosineHarmonic::precompute`] to convert physical constants into optimized parameters:
22/// $(C, \theta_0°) \to (C/2, \cos_0)$.
23///
24/// # Inputs
25///
26/// - `cos_theta`: Cosine of the bond angle $\theta_{ijk}$.
27///
28/// # Implementation Notes
29///
30/// - Pure polynomial evaluation; no `acos`, `sin`, or `sqrt` required.
31/// - All intermediate calculations are shared between energy and force computations.
32/// - Branchless and panic-free.
33#[derive(Clone, Copy, Debug, Default)]
34pub struct CosineHarmonic;
35
36impl CosineHarmonic {
37    /// Pre-computes optimized kernel parameters from physical constants.
38    ///
39    /// # Input
40    ///
41    /// - `c`: Force constant $C$.
42    /// - `theta0_deg`: Equilibrium angle $\theta_0$ in degrees.
43    ///
44    /// # Output
45    ///
46    /// Returns `(c_half, cos0)`:
47    /// - `c_half`: Half force constant $C/2$.
48    /// - `cos0`: Cosine of equilibrium angle $\cos_0$.
49    ///
50    /// # Computation
51    ///
52    /// $$ C_{half} = C / 2, \quad \cos_0 = \cos(\theta_0 \cdot \pi / 180) $$
53    #[inline(always)]
54    pub fn precompute<T: Real>(c: T, theta0_deg: T) -> (T, T) {
55        let deg_to_rad = T::pi() / T::from(180.0);
56        let theta0 = theta0_deg * deg_to_rad;
57        let c_half = c * T::from(0.5);
58        let cos0 = theta0.cos();
59        (c_half, cos0)
60    }
61}
62
63impl<T: Real> AngleKernel<T> for CosineHarmonic {
64    type Params = (T, T);
65
66    /// Computes only the potential energy.
67    ///
68    /// # Formula
69    ///
70    /// $$ E = C_{half} (\Delta)^2, \quad \text{where } \Delta = \cos\theta - \cos_0 $$
71    #[inline(always)]
72    fn energy(cos_theta: T, (c_half, cos0): Self::Params) -> T {
73        let delta = cos_theta - cos0;
74        c_half * delta * delta
75    }
76
77    /// Computes only the derivative factor $\Gamma$.
78    ///
79    /// # Formula
80    ///
81    /// $$ \Gamma = 2 C_{half} (\cos\theta - \cos_0) $$
82    ///
83    /// This factor allows computing forces via the chain rule:
84    /// $$ \vec{F} = -\Gamma \cdot \nabla (\cos\theta) $$
85    #[inline(always)]
86    fn diff(cos_theta: T, (c_half, cos0): Self::Params) -> T {
87        let c = c_half + c_half;
88        c * (cos_theta - cos0)
89    }
90
91    /// Computes both energy and derivative factor efficiently.
92    ///
93    /// This method reuses intermediate calculations to minimize operations.
94    #[inline(always)]
95    fn compute(cos_theta: T, (c_half, cos0): Self::Params) -> EnergyDiff<T> {
96        let delta = cos_theta - cos0;
97
98        let energy = c_half * delta * delta;
99
100        let c = c_half + c_half;
101        let diff = c * delta;
102
103        EnergyDiff { energy, diff }
104    }
105}
106
107/// Cosine Linear potential for bond angles with linear equilibrium geometry.
108///
109/// # Physics
110///
111/// Models the angle bending energy for atoms with linear equilibrium geometry ($\theta_0 = 180°$),
112/// using a simple linear function of the cosine of the angle.
113///
114/// - **Formula**: $$ E = C (1 + \cos\theta) $$
115/// - **Derivative Factor (`diff`)**: $$ \Gamma = \frac{dE}{d(\cos\theta)} = C $$
116///
117/// # Parameters
118///
119/// - `c`: Force constant $C$.
120///
121/// # Inputs
122///
123/// - `cos_theta`: Cosine of the bond angle $\theta_{ijk}$.
124///
125/// # Implementation Notes
126///
127/// - Single multiply-add for energy; zero arithmetic for derivative.
128/// - Pure polynomial evaluation; no trigonometric functions required.
129/// - Branchless and panic-free.
130#[derive(Clone, Copy, Debug, Default)]
131pub struct CosineLinear;
132
133impl<T: Real> AngleKernel<T> for CosineLinear {
134    type Params = T;
135
136    /// Computes only the potential energy.
137    ///
138    /// # Formula
139    ///
140    /// $$ E = C (1 + \cos\theta) $$
141    #[inline(always)]
142    fn energy(cos_theta: T, c: Self::Params) -> T {
143        let one = T::from(1.0f32);
144        c * (one + cos_theta)
145    }
146
147    /// Computes only the derivative factor $\Gamma$.
148    ///
149    /// # Formula
150    ///
151    /// $$ \Gamma = C $$
152    ///
153    /// This factor allows computing forces via the chain rule:
154    /// $$ \vec{F} = -\Gamma \cdot \nabla (\cos\theta) $$
155    #[inline(always)]
156    fn diff(_cos_theta: T, c: Self::Params) -> T {
157        c
158    }
159
160    /// Computes both energy and derivative factor efficiently.
161    ///
162    /// This method reuses intermediate calculations to minimize operations.
163    #[inline(always)]
164    fn compute(cos_theta: T, c: Self::Params) -> EnergyDiff<T> {
165        let one = T::from(1.0f32);
166        let energy = c * (one + cos_theta);
167        let diff = c;
168
169        EnergyDiff { energy, diff }
170    }
171}
172
173/// Theta Harmonic potential for bond angles.
174///
175/// # Physics
176///
177/// Models the angle bending energy using a harmonic approximation directly on the angle $\theta$ (in radians).
178///
179/// - **Formula**: $$ E = \frac{1}{2} K (\theta - \theta_0)^2 $$
180/// - **Derivative Factor (`diff`)**: $$ \Gamma = \frac{dE}{d(\cos\theta)} = -K \frac{\theta - \theta_0}{\sin\theta} $$
181///
182/// # Parameters
183///
184/// - `k_half`: Half force constant $K_{half} = K/2$.
185/// - `theta0`: Equilibrium angle $\theta_0$ in radians.
186///
187/// # Pre-computation
188///
189/// Use [`ThetaHarmonic::precompute`] to convert physical constants into optimized parameters:
190/// $(K, \theta_0°) \to (K/2, \theta_{0,rad})$.
191///
192/// # Inputs
193///
194/// - `cos_theta`: Cosine of the bond angle $\theta_{ijk}$.
195///
196/// # Implementation Notes
197///
198/// - Uses `k_half` to save one multiplication in the energy step.
199/// - Handles $\theta=0$ and $\theta=\pi$ analytically using L'Hopital's rule.
200/// - Needs a single `acos` call for angle calculation.
201#[derive(Clone, Copy, Debug, Default)]
202pub struct ThetaHarmonic;
203
204impl ThetaHarmonic {
205    /// Pre-computes optimized kernel parameters from physical constants.
206    ///
207    /// # Input
208    ///
209    /// - `k`: Force constant $K$.
210    /// - `theta0_deg`: Equilibrium angle $\theta_0$ in degrees.
211    ///
212    /// # Output
213    ///
214    /// Returns `(k_half, theta0)`:
215    /// - `k_half`: Half force constant $K/2$.
216    /// - `theta0`: Equilibrium angle $\theta_0$ in radians.
217    ///
218    /// # Computation
219    ///
220    /// $$ K_{half} = K / 2, \quad \theta_0 = \theta_0° \cdot \pi / 180 $$
221    #[inline(always)]
222    pub fn precompute<T: Real>(k: T, theta0_deg: T) -> (T, T) {
223        let deg_to_rad = T::pi() / T::from(180.0);
224        let k_half = k * T::from(0.5);
225        let theta0 = theta0_deg * deg_to_rad;
226        (k_half, theta0)
227    }
228}
229
230impl<T: Real> AngleKernel<T> for ThetaHarmonic {
231    type Params = (T, T);
232
233    /// Computes only the potential energy.
234    ///
235    /// # Formula
236    ///
237    /// $$ E = K_{half} (\theta - \theta_0)^2 $$
238    #[inline(always)]
239    fn energy(cos_theta: T, (k_half, theta0): Self::Params) -> T {
240        let one = T::from(1.0f32);
241        let minus_one = T::from(-1.0f32);
242
243        let c = cos_theta.max(minus_one).min(one);
244        let theta = c.acos();
245
246        let delta = theta - theta0;
247
248        k_half * delta * delta
249    }
250
251    /// Computes only the derivative factor $\Gamma$.
252    ///
253    /// # Formula
254    ///
255    /// $$ \Gamma = -2 K_{half} \frac{\theta - \theta_0}{\sin\theta} $$
256    ///
257    /// This factor allows computing forces via the chain rule:
258    /// $$ \vec{F} = -\Gamma \cdot \nabla (\cos\theta) $$
259    #[inline(always)]
260    fn diff(cos_theta: T, (k_half, theta0): Self::Params) -> T {
261        let one = T::from(1.0f32);
262        let minus_one = T::from(-1.0f32);
263        let zero = T::from(0.0f32);
264        let singularity_thresh = T::from(1.0e-4f32);
265        let epsilon = T::from(1.0e-20f32);
266
267        let c = cos_theta.max(minus_one).min(one);
268
269        let theta = c.acos();
270        let sin_theta = (one - c * c).max(zero).sqrt();
271
272        let factor = if sin_theta > singularity_thresh {
273            (theta - theta0) / sin_theta
274        } else {
275            let s_safe = sin_theta.max(epsilon);
276
277            if c > zero {
278                one - theta0 / s_safe
279            } else {
280                let pi = T::pi();
281                minus_one + (pi - theta0) / s_safe
282            }
283        };
284
285        let k = k_half + k_half;
286
287        -k * factor
288    }
289
290    /// Computes both energy and derivative factor efficiently.
291    ///
292    /// This method reuses intermediate calculations to minimize operations.
293    #[inline(always)]
294    fn compute(cos_theta: T, (k_half, theta0): Self::Params) -> EnergyDiff<T> {
295        let one = T::from(1.0f32);
296        let minus_one = T::from(-1.0f32);
297        let zero = T::from(0.0f32);
298        let singularity_thresh = T::from(1.0e-4f32);
299        let epsilon = T::from(1.0e-20f32);
300
301        let c = cos_theta.max(minus_one).min(one);
302        let theta = c.acos();
303        let sin_theta = (one - c * c).max(zero).sqrt();
304
305        let delta = theta - theta0;
306        let energy = k_half * delta * delta;
307
308        let factor = if sin_theta > singularity_thresh {
309            delta / sin_theta
310        } else {
311            let s_safe = sin_theta.max(epsilon);
312            if c > zero {
313                one - theta0 / s_safe
314            } else {
315                let pi = T::pi();
316                minus_one + (pi - theta0) / s_safe
317            }
318        };
319
320        let k = k_half + k_half;
321        let diff = -k * factor;
322
323        EnergyDiff { energy, diff }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use approx::assert_relative_eq;
331    use std::f64::consts::PI;
332
333    // ------------------------------------------------------------------------
334    // Test Constants
335    // ------------------------------------------------------------------------
336
337    const H: f64 = 1e-6;
338    const TOL_DIFF: f64 = 1e-4;
339
340    // ========================================================================
341    // CosineHarmonic Tests
342    // ========================================================================
343
344    mod cosine_harmonic {
345        use super::*;
346
347        const C: f64 = 100.0;
348        const THETA0_DEG: f64 = 60.0;
349        const C_HALF: f64 = C / 2.0;
350        const COS0: f64 = 0.5;
351
352        fn params() -> (f64, f64) {
353            CosineHarmonic::precompute(C, THETA0_DEG)
354        }
355
356        // --------------------------------------------------------------------
357        // 1. Sanity Checks
358        // --------------------------------------------------------------------
359
360        #[test]
361        fn sanity_compute_equals_separate() {
362            let cos_theta = 0.7_f64;
363            let p = params();
364
365            let result = CosineHarmonic::compute(cos_theta, p);
366            let energy_only = CosineHarmonic::energy(cos_theta, p);
367            let diff_only = CosineHarmonic::diff(cos_theta, p);
368
369            assert_relative_eq!(result.energy, energy_only, epsilon = 1e-12);
370            assert_relative_eq!(result.diff, diff_only, epsilon = 1e-12);
371        }
372
373        #[test]
374        fn sanity_f32_f64_consistency() {
375            let cos_theta = 0.6;
376            let p64 = params();
377            let p32 = CosineHarmonic::precompute(C as f32, THETA0_DEG as f32);
378
379            let e64 = CosineHarmonic::energy(cos_theta, p64);
380            let e32 = CosineHarmonic::energy(cos_theta as f32, p32);
381
382            assert_relative_eq!(e64, e32 as f64, epsilon = 1e-5);
383        }
384
385        #[test]
386        fn sanity_equilibrium() {
387            let result = CosineHarmonic::compute(COS0, params());
388
389            assert_relative_eq!(result.energy, 0.0, epsilon = 1e-12);
390            assert_relative_eq!(result.diff, 0.0, epsilon = 1e-12);
391        }
392
393        // --------------------------------------------------------------------
394        // 2. Numerical Stability
395        // --------------------------------------------------------------------
396
397        #[test]
398        fn stability_cos_one() {
399            let result = CosineHarmonic::compute(1.0, params());
400
401            assert!(result.energy.is_finite());
402            assert!(result.diff.is_finite());
403        }
404
405        #[test]
406        fn stability_cos_minus_one() {
407            let result = CosineHarmonic::compute(-1.0, params());
408
409            assert!(result.energy.is_finite());
410            assert!(result.diff.is_finite());
411        }
412
413        // --------------------------------------------------------------------
414        // 3. Finite Difference Verification
415        // --------------------------------------------------------------------
416
417        fn finite_diff_check(cos_theta: f64) {
418            let p = params();
419
420            let c_plus = cos_theta + H;
421            let c_minus = cos_theta - H;
422            let e_plus = CosineHarmonic::energy(c_plus, p);
423            let e_minus = CosineHarmonic::energy(c_minus, p);
424            let de_dcos_numerical = (e_plus - e_minus) / (2.0 * H);
425
426            let gamma = CosineHarmonic::diff(cos_theta, p);
427
428            assert_relative_eq!(de_dcos_numerical, gamma, epsilon = TOL_DIFF);
429        }
430
431        #[test]
432        fn finite_diff_bent() {
433            finite_diff_check(0.3);
434        }
435
436        #[test]
437        fn finite_diff_wide() {
438            finite_diff_check(0.8);
439        }
440
441        #[test]
442        fn finite_diff_near_equilibrium() {
443            finite_diff_check(COS0 + 0.01);
444        }
445
446        // --------------------------------------------------------------------
447        // 4. CosineHarmonic Specific
448        // --------------------------------------------------------------------
449
450        #[test]
451        fn specific_quadratic_scaling() {
452            let delta1 = 0.1;
453            let delta2 = 0.2;
454            let e1 = CosineHarmonic::energy(COS0 + delta1, params());
455            let e2 = CosineHarmonic::energy(COS0 + delta2, params());
456
457            assert_relative_eq!(e2 / e1, 4.0, epsilon = 1e-10);
458        }
459
460        #[test]
461        fn specific_no_trig_needed() {
462            let cos_theta = 0.7;
463            let delta = cos_theta - COS0;
464            let expected = C_HALF * delta * delta;
465
466            assert_relative_eq!(
467                CosineHarmonic::energy(cos_theta, params()),
468                expected,
469                epsilon = 1e-14
470            );
471        }
472
473        // --------------------------------------------------------------------
474        // 5. Precompute
475        // --------------------------------------------------------------------
476
477        #[test]
478        fn precompute_values() {
479            let (c_half, cos0) = CosineHarmonic::precompute(C, THETA0_DEG);
480            assert_relative_eq!(c_half, C_HALF, epsilon = 1e-14);
481            assert_relative_eq!(cos0, COS0, epsilon = 1e-10);
482        }
483
484        #[test]
485        fn precompute_round_trip() {
486            let p = CosineHarmonic::precompute(C, THETA0_DEG);
487            let e = CosineHarmonic::energy(COS0, p);
488            assert_relative_eq!(e, 0.0, epsilon = 1e-10);
489        }
490    }
491
492    // ========================================================================
493    // CosineLinear Tests
494    // ========================================================================
495
496    mod cosine_linear {
497        use super::*;
498
499        const C: f64 = 100.0;
500
501        fn params() -> f64 {
502            C
503        }
504
505        // --------------------------------------------------------------------
506        // 1. Sanity Checks
507        // --------------------------------------------------------------------
508
509        #[test]
510        fn sanity_compute_equals_separate() {
511            let cos_theta = 0.3_f64;
512            let p = params();
513
514            let result = CosineLinear::compute(cos_theta, p);
515            let energy_only = CosineLinear::energy(cos_theta, p);
516            let diff_only = CosineLinear::diff(cos_theta, p);
517
518            assert_relative_eq!(result.energy, energy_only, epsilon = 1e-12);
519            assert_relative_eq!(result.diff, diff_only, epsilon = 1e-12);
520        }
521
522        #[test]
523        fn sanity_f32_f64_consistency() {
524            let cos_theta = -0.3;
525            let p64 = params();
526            let p32 = C as f32;
527
528            let e64 = CosineLinear::energy(cos_theta, p64);
529            let e32 = CosineLinear::energy(cos_theta as f32, p32);
530
531            assert_relative_eq!(e64, e32 as f64, epsilon = 1e-5);
532        }
533
534        #[test]
535        fn sanity_equilibrium() {
536            let result = CosineLinear::compute(-1.0, params());
537
538            assert_relative_eq!(result.energy, 0.0, epsilon = 1e-14);
539            assert_relative_eq!(result.diff, C, epsilon = 1e-14);
540        }
541
542        // --------------------------------------------------------------------
543        // 2. Numerical Stability
544        // --------------------------------------------------------------------
545
546        #[test]
547        fn stability_cos_one() {
548            let result = CosineLinear::compute(1.0, params());
549
550            assert!(result.energy.is_finite());
551            assert!(result.diff.is_finite());
552        }
553
554        #[test]
555        fn stability_cos_minus_one() {
556            let result = CosineLinear::compute(-1.0, params());
557
558            assert!(result.energy.is_finite());
559            assert!(result.diff.is_finite());
560        }
561
562        // --------------------------------------------------------------------
563        // 3. Finite Difference Verification
564        // --------------------------------------------------------------------
565
566        fn finite_diff_check(cos_theta: f64) {
567            let p = params();
568
569            let c_plus = cos_theta + H;
570            let c_minus = cos_theta - H;
571            let e_plus = CosineLinear::energy(c_plus, p);
572            let e_minus = CosineLinear::energy(c_minus, p);
573            let de_dcos_numerical = (e_plus - e_minus) / (2.0 * H);
574
575            let gamma = CosineLinear::diff(cos_theta, p);
576
577            assert_relative_eq!(de_dcos_numerical, gamma, epsilon = TOL_DIFF);
578        }
579
580        #[test]
581        fn finite_diff_acute() {
582            finite_diff_check(0.3);
583        }
584
585        #[test]
586        fn finite_diff_obtuse() {
587            finite_diff_check(-0.5);
588        }
589
590        #[test]
591        fn finite_diff_near_equilibrium() {
592            finite_diff_check(-0.99);
593        }
594
595        // --------------------------------------------------------------------
596        // 4. CosineLinear Specific
597        // --------------------------------------------------------------------
598
599        #[test]
600        fn specific_linear_scaling() {
601            let e1 = CosineLinear::energy(0.0, params());
602            let e2 = CosineLinear::energy(-0.5, params());
603
604            assert_relative_eq!(e1 / e2, 2.0, epsilon = 1e-10);
605        }
606
607        #[test]
608        fn specific_constant_derivative() {
609            let p = params();
610
611            assert_relative_eq!(CosineLinear::diff(1.0, p), C, epsilon = 1e-14);
612            assert_relative_eq!(CosineLinear::diff(0.0, p), C, epsilon = 1e-14);
613            assert_relative_eq!(CosineLinear::diff(-0.5, p), C, epsilon = 1e-14);
614            assert_relative_eq!(CosineLinear::diff(-1.0, p), C, epsilon = 1e-14);
615        }
616    }
617
618    // ========================================================================
619    // ThetaHarmonic Tests
620    // ========================================================================
621
622    mod theta_harmonic {
623        use super::*;
624
625        const K: f64 = 100.0;
626        const THETA0_DEG: f64 = 60.0;
627        const K_HALF: f64 = K / 2.0;
628        const THETA0: f64 = PI / 3.0;
629        const COS0: f64 = 0.5;
630
631        fn params() -> (f64, f64) {
632            ThetaHarmonic::precompute(K, THETA0_DEG)
633        }
634
635        // --------------------------------------------------------------------
636        // 1. Sanity Checks
637        // --------------------------------------------------------------------
638
639        #[test]
640        fn sanity_compute_equals_separate() {
641            let cos_theta = 0.7_f64;
642            let p = params();
643
644            let result = ThetaHarmonic::compute(cos_theta, p);
645            let energy_only = ThetaHarmonic::energy(cos_theta, p);
646            let diff_only = ThetaHarmonic::diff(cos_theta, p);
647
648            assert_relative_eq!(result.energy, energy_only, epsilon = 1e-12);
649            assert_relative_eq!(result.diff, diff_only, epsilon = 1e-12);
650        }
651
652        #[test]
653        fn sanity_f32_f64_consistency() {
654            let cos_theta = 0.6;
655            let p64 = params();
656            let p32 = ThetaHarmonic::precompute(K as f32, THETA0_DEG as f32);
657
658            let e64 = ThetaHarmonic::energy(cos_theta, p64);
659            let e32 = ThetaHarmonic::energy(cos_theta as f32, p32);
660
661            assert_relative_eq!(e64, e32 as f64, epsilon = 1e-4);
662        }
663
664        #[test]
665        fn sanity_equilibrium() {
666            let result = ThetaHarmonic::compute(COS0, params());
667
668            assert_relative_eq!(result.energy, 0.0, epsilon = 1e-10);
669            assert!(result.diff.abs() < 1e-8);
670        }
671
672        // --------------------------------------------------------------------
673        // 2. Numerical Stability
674        // --------------------------------------------------------------------
675
676        #[test]
677        fn stability_theta_zero() {
678            let result = ThetaHarmonic::compute(1.0, params());
679
680            assert!(result.energy.is_finite());
681            assert!(result.diff.is_finite());
682        }
683
684        #[test]
685        fn stability_theta_pi() {
686            let result = ThetaHarmonic::compute(-1.0, params());
687
688            assert!(result.energy.is_finite());
689            assert!(result.diff.is_finite());
690        }
691
692        #[test]
693        fn stability_cos_slightly_outside() {
694            let result = ThetaHarmonic::compute(1.0001, params());
695
696            assert!(result.energy.is_finite());
697            assert!(result.diff.is_finite());
698        }
699
700        // --------------------------------------------------------------------
701        // 3. Finite Difference Verification
702        // --------------------------------------------------------------------
703
704        fn finite_diff_check(cos_theta: f64) {
705            if cos_theta.abs() > 0.999 {
706                return;
707            }
708
709            let p = params();
710
711            let c_plus = cos_theta + H;
712            let c_minus = cos_theta - H;
713            let e_plus = ThetaHarmonic::energy(c_plus, p);
714            let e_minus = ThetaHarmonic::energy(c_minus, p);
715            let de_dcos_numerical = (e_plus - e_minus) / (2.0 * H);
716
717            let gamma = ThetaHarmonic::diff(cos_theta, p);
718
719            assert_relative_eq!(de_dcos_numerical, gamma, epsilon = TOL_DIFF);
720        }
721
722        #[test]
723        fn finite_diff_acute() {
724            finite_diff_check(0.7);
725        }
726
727        #[test]
728        fn finite_diff_right() {
729            finite_diff_check(0.0);
730        }
731
732        #[test]
733        fn finite_diff_obtuse() {
734            finite_diff_check(-0.5);
735        }
736
737        #[test]
738        fn finite_diff_near_equilibrium() {
739            finite_diff_check(COS0 + 0.02);
740        }
741
742        // --------------------------------------------------------------------
743        // 4. ThetaHarmonic Specific
744        // --------------------------------------------------------------------
745
746        #[test]
747        fn specific_energy_formula() {
748            let cos_theta = 0.0;
749            let theta = PI / 2.0;
750            let delta = theta - THETA0;
751            let expected = K_HALF * delta * delta;
752
753            assert_relative_eq!(
754                ThetaHarmonic::energy(cos_theta, params()),
755                expected,
756                epsilon = 1e-10
757            );
758        }
759
760        #[test]
761        fn specific_diff_chain_rule() {
762            let cos_theta = 0.0;
763            let theta = PI / 2.0;
764            let sin_theta = 1.0;
765
766            let k = 2.0 * K_HALF;
767            let expected_gamma = -k * (theta - THETA0) / sin_theta;
768
769            assert_relative_eq!(
770                ThetaHarmonic::diff(cos_theta, params()),
771                expected_gamma,
772                epsilon = 1e-10
773            );
774        }
775
776        // --------------------------------------------------------------------
777        // 5. Precompute
778        // --------------------------------------------------------------------
779
780        #[test]
781        fn precompute_values() {
782            let (k_half, theta0) = ThetaHarmonic::precompute(K, THETA0_DEG);
783            assert_relative_eq!(k_half, K_HALF, epsilon = 1e-14);
784            assert_relative_eq!(theta0, THETA0, epsilon = 1e-10);
785        }
786
787        #[test]
788        fn precompute_round_trip() {
789            let p = ThetaHarmonic::precompute(K, THETA0_DEG);
790            let e = ThetaHarmonic::energy(COS0, p);
791            assert_relative_eq!(e, 0.0, epsilon = 1e-10);
792        }
793    }
794}