hoomd_mc/rotate/
versor.rs

1// Copyright (c) 2024-2026 The Regents of the University of Michigan.
2// Part of hoomd-rs, released under the BSD 3-Clause License.
3
4//! Implement Rotate for Versor
5
6use rand::{Rng, distr::Distribution};
7use rand_distr::Normal;
8use std::f64::consts::PI;
9
10use hoomd_microstate::property::Orientation;
11use hoomd_utility::valid::PositiveReal;
12use hoomd_vector::{Cartesian, InnerProduct, Quaternion, Rotation, Versor};
13
14use crate::{Adjust, LocalTrial, Rotate};
15
16/// A normal distribution of random Versors, centered on a mean with
17/// some standard deviation.
18#[derive(Debug, Clone, Copy)]
19pub(crate) struct VersorDisplacement {
20    /// The standard deviation of the normal distribution of quaternions around the mean.
21    std_dev: f64,
22}
23
24impl From<f64> for VersorDisplacement {
25    #[inline]
26    fn from(value: f64) -> Self {
27        Self { std_dev: value }
28    }
29}
30impl Distribution<Versor> for VersorDisplacement {
31    /// Sample a random [`Versor`] displacement from a provided mean.
32    ///
33    /// Mathematically, we sample from a 3-dimensional Normal distribution
34    /// in the tangent space of SO(3), lift to the manifold, then rotate to center on
35    /// the mean of the [`VersorDisplacement`]. The result is a small displacement from
36    /// a quaternion input, with fast decay in the tails that make large displacements
37    /// unlikely. This is desirable for Monte Carlo, as large moves are very likely to
38    /// be rejected.
39    #[inline]
40    fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> Versor {
41        // Based on Karney 2007: doi.org/10.1016/j.jmgm.2006.04.002
42        const _SAFE_THETA: f64 = 1e-12;
43        loop {
44            // As in section 7, we select `s` from a 3D Gaussian distribution.
45            let normal =
46                Normal::new(0.0, self.std_dev).expect("Failed to create normal distribution.");
47
48            let s = Cartesian::from([(); 3].map(|()| normal.sample(rng)));
49            let theta = s.norm();
50
51            // Reject moves with |s| > pi to ensure detailed balance. Otherwise, there
52            // is a shorter path between the proposed move and the start (theta - pi),
53            // which has a different rejection probability.
54            if theta > PI {
55                continue;
56            }
57
58            // Lift the normally distributed values to SO(3) with the exponential map
59            let half_theta = 0.5 * theta;
60            let w = half_theta.cos();
61
62            let mut v_factor = half_theta.sin() / theta;
63            if !v_factor.is_finite() {
64                v_factor = 0.5;
65            }
66
67            let v = s * v_factor;
68
69            // We are normalized by construction, so call the tuple initializer
70            return (Quaternion::from([w, v[0], v[1], v[2]])).to_versor_unchecked();
71        }
72    }
73}
74
75impl<B> LocalTrial<B> for Rotate<Versor>
76where
77    B: Orientation<Rotation = Versor>,
78{
79    /// Perturb a body's orientation by a random amount.
80    ///
81    /// In three dimensions, we design this perturbation as a versor whose
82    /// distribution is centered on the existing orientation and whose distribution is
83    /// narrow. To do so, we sample from a 3-dimensional Normal distribution
84    /// in the tangent space of SO(3), lift to the manifold, then rotate to center on
85    /// the current orientation. The result is a small displacement from
86    /// a quaternion input, with fast decay in the tails that make large displacements
87    /// unlikely. This is desirable for Monte Carlo, as large moves are very likely to
88    /// be rejected. This sampling obeys detailed balance.
89    ///
90    /// # Example
91    ///
92    /// ```
93    /// use hoomd_mc::{LocalTrial, Rotate};
94    /// use hoomd_microstate::property::OrientedPoint;
95    /// use hoomd_vector::{Cartesian, Versor};
96    /// use rand::{Rng, SeedableRng, rngs::StdRng};
97    ///
98    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
99    /// let mut rng = StdRng::seed_from_u64(1);
100    /// let initial_orientation = Versor::default();
101    /// let body_properties = OrientedPoint {
102    ///     position: Cartesian::from([0.0, 0.0, 0.0]),
103    ///     orientation: initial_orientation,
104    /// };
105    /// let rotate = Rotate::with_maximum_rotation(0.1.try_into()?);
106    ///
107    /// let new_body_properties = rotate.propose(&mut rng, body_properties);
108    /// assert!(new_body_properties.orientation != initial_orientation);
109    /// # Ok(())
110    /// # }
111    /// ```
112    #[inline]
113    fn propose<R: Rng>(&self, rng: &mut R, body_properties: B) -> B {
114        let mut trial = body_properties;
115        let displacement = VersorDisplacement {
116            std_dev: self.maximum_rotation.get(),
117        };
118
119        let delta_quat = displacement.sample(rng);
120        *trial.orientation_mut() = delta_quat.combine(trial.orientation());
121
122        trial
123    }
124}
125
126impl Adjust for Rotate<Versor> {
127    /// Change the maximum trial move size by the given scale factor.
128    #[inline]
129    fn adjust(&mut self, factor: PositiveReal) {
130        self.maximum_rotation *= factor;
131
132        if self.maximum_rotation.get() > PI / 2.0 {
133            self.maximum_rotation = (PI / 2.0)
134                .try_into()
135                .expect("PI/2.0 should be a positive real");
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use assert2::check;
144    use hoomd_microstate::property::OrientedPoint;
145    use hoomd_vector::{Cartesian, Versor};
146    use rand::{SeedableRng, rngs::StdRng};
147    use rstest::*;
148
149    /// Number of trial moves to test.
150    const N: usize = 65536;
151
152    #[rstest]
153    fn rotate(#[values(0.1, 1.0, 1.53)] a: f64) {
154        // Ensure that `Rotate` proposes moves that rotate the body with a valid
155        // range of maximum distances.
156
157        let mut delta_thetas = Vec::with_capacity(N);
158
159        let mut rng = StdRng::seed_from_u64(1);
160        let body = OrientedPoint {
161            position: Cartesian::from([0.0, 0.0, 0.0]),
162            orientation: Versor::default(),
163        };
164        let rotate = Rotate::with_maximum_rotation(
165            a.try_into()
166                .expect("hard-coded constant should be a positive real"),
167        );
168
169        for _ in 0..N {
170            let trial = rotate.propose(&mut rng, body);
171            let delta_theta = trial.orientation.arc_distance(&body.orientation);
172            delta_thetas.push(delta_theta);
173        }
174
175        // Validate our distribution's mean is relatively close to 0.75 * a.
176        // Why this is the correct choice? I'm not sure. But most values of `a` result
177        // in a distribution whose mean is near 0.75 * a
178        let mean = (delta_thetas.iter().sum::<f64>() / N as f64).abs();
179        assert!(
180            (mean - (0.75 * a)).abs() < (0.1 * a),
181            "{} !< {}",
182            (mean - (0.75 * a)).abs(),
183            (0.1 * a)
184        );
185    }
186
187    #[test]
188    fn test_adjust() -> anyhow::Result<()> {
189        let mut rotate = Rotate::<Versor>::with_maximum_rotation(0.5.try_into()?);
190
191        rotate.adjust(2.0.try_into()?);
192        check!(rotate.maximum_rotation().get() == 1.0);
193
194        rotate.adjust(0.5.try_into()?);
195        check!(rotate.maximum_rotation().get() == 0.5);
196
197        rotate.adjust(10.0.try_into()?);
198        check!(rotate.maximum_rotation().get() == PI / 2.0);
199
200        Ok(())
201    }
202}