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