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}