hoomd_manifold/hyperbolic_angle.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//! Implements a three-dimensional representation of SO(3,1) on Minkowski
5//! space.
6
7use num::complex::Complex;
8use rand::{
9 Rng,
10 distr::{Distribution, StandardUniform, Uniform},
11};
12use serde::{Deserialize, Serialize};
13use std::{f64::consts::PI, fmt};
14
15use crate::HyperbolicRotationMatrix;
16
17/// Combine rotations and boosts in hyperbolic space.
18///
19/// "Hyperbolic rotations" describe elements of the group SO(N,1), which
20/// preserve Hyperbolics embedded in [`Minkowski<N+1>`]. Transformations include
21/// pure spatial rotations as well as "boosts".
22///
23/// [`Minkowski<N+1>`]: crate::Minkowski
24///
25/// For two-dimensional hyperbolic surfaces, use [`HyperbolicAngle`] to
26/// implement elements of SO(2,1) which rotate points about the z-axis or boost
27/// points along the x- and y-axes. SO(2,1) transformations represent
28/// symmetries of two-dimensional hyperbolic space. The algebra so(2,1) is
29/// generated by the matrices
30/// ```math
31/// K_x = \begin{bmatrix} 0&0&1\\ 0&0&0\\1&0&0 \end{bmatrix} \qquad\qquad
32/// K_y = \begin{bmatrix} 0&0&0\\ 0&0&1\\0&1&0 \end{bmatrix}\qquad\qquad
33/// J_z = \begin{bmatrix} 0&-1&0\\ 1&0&0\\0&0&0 \end{bmatrix}
34/// ```
35/// which satisfy the commutation relations
36/// ```math
37/// [J_z,K_x] = K_y \qquad [K_x,K_y] = -J_z \qquad [J_z, K_y] = -K_x
38/// ```
39/// The group elements SO(2,1) can be obtained by exponentiating the generators
40/// near identity,
41/// i.e.,
42/// ```math
43/// G = \exp(aJ_z + b K_x + c K_y)
44/// ```
45/// In particular,
46/// ```math
47/// \exp(aJ_z) = \begin{bmatrix}\cos(a)&-\sin(a)&0\\ \sin(a)&\cos(a)&0 \\ 0&0&1\end{bmatrix}
48/// \\ \exp(bK_x) = \begin{bmatrix}\cosh(b)&0&\sinh(b)\\ 0&1&0 \\ \sinh(b)&0&\cosh(b)\end{bmatrix}
49/// \\ \exp(cK_y) = \begin{bmatrix}1&0&0\\ 0&\cosh(c)&\sinh(c) \\ 0&\sinh(c)&\cosh(c)\end{bmatrix}
50/// ```
51/// [`HyperbolicAngle`] is a struct of three real numbers $`(a,b,c)`$ which
52/// parameterize the generators in this exponential map. The first value $`a`$
53/// may be interpreted as a rotation angle, while the latter two numbers $`b, c`$
54/// may be interpreted as rapidities for boosts along the x- and y- directions,
55/// respectively. Use [`HyperbolicRotationMatrix`] to generate the matrix from
56/// the values defined by [`HyperbolicAngle`].
57///
58/// Rotation about z axis:
59/// ```
60/// use approxim::assert_relative_eq;
61/// use hoomd_manifold::{
62/// HyperbolicAngle, HyperbolicRotate, HyperbolicRotationMatrix, Minkowski,
63/// };
64/// use std::f64::consts::PI;
65///
66/// let v = Minkowski::from([1.0, 0.0, 1.0]);
67/// let rotation_about_z = HyperbolicAngle::from((PI / 2.0, 0.0_f64, 0.0_f64));
68/// let matrix = HyperbolicRotationMatrix::from(rotation_about_z);
69/// let rotated = matrix.hyperbolic_rotate(&v);
70/// assert_relative_eq!(rotated, [0.0, 1.0, 1.0].into(), epsilon = 1e-12);
71/// ```
72///
73/// Boost in the y direction:
74/// ```
75/// use approxim::assert_relative_eq;
76/// use hoomd_manifold::{
77/// HyperbolicAngle, HyperbolicRotate, HyperbolicRotationMatrix, Minkowski,
78/// };
79///
80/// let v = Minkowski::from([1.0, 0.0, 1.0]);
81/// let boost_in_y = HyperbolicAngle::from((0.0_f64, 0.0_f64, 0.5_f64));
82/// let matrix = HyperbolicRotationMatrix::from(boost_in_y);
83/// let boosted = matrix.hyperbolic_rotate(&v);
84/// assert_relative_eq!(
85/// boosted,
86/// [1.0, (0.5_f64).sinh(), (0.5_f64).cosh()].into(),
87/// epsilon = 1e-12
88/// );
89/// ```
90///
91/// The default value `HyperbolicAngle::Default` returns the tuple
92/// `(0.0,0.0,0.0)`, which corresponds to the identify transformation.
93#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
94pub struct HyperbolicAngle {
95 /// Get the rotation angle in radians.
96 pub angles: (f64, f64, f64),
97}
98
99impl HyperbolicAngle {
100 /// Modulo the second and third components of a `HyperbolicAngle` by $`2 \pi`$.
101 #[inline]
102 #[must_use]
103 pub fn to_reduced(self) -> Self {
104 Self {
105 angles: (
106 self.angles.0.rem_euclid(2.0 * PI),
107 self.angles.1,
108 self.angles.2,
109 ),
110 }
111 }
112}
113
114impl From<HyperbolicAngle> for HyperbolicRotationMatrix<3> {
115 /// Create a matrix belonging to SO(2,1) from the generators of the algebra so(2,1).
116 #[inline]
117 fn from(angle_list: HyperbolicAngle) -> HyperbolicRotationMatrix<3> {
118 let (a, b, c) = angle_list.angles;
119 if (a, b, c) == (0.0_f64, 0.0_f64, 0.0_f64) {
120 HyperbolicRotationMatrix {
121 rows: [
122 [1.0, 0.0, 0.0].into(),
123 [0.0, 1.0, 0.0].into(),
124 [0.0, 0.0, 1.0].into(),
125 ],
126 }
127 } else {
128 let arg_sq = -a.powi(2) + b.powi(2) + c.powi(2);
129 let arg = Complex::new(arg_sq, 0.0);
130 let arg_sqrt = arg.sqrt();
131 let ch = arg_sqrt.cosh();
132 let sh = arg_sqrt.sinh();
133 let sh_c = Complex::new(
134 arg_sqrt.re * sh.re - arg_sqrt.im * sh.im,
135 arg_sqrt.re * sh.im + arg_sqrt.im * sh.re,
136 );
137
138 HyperbolicRotationMatrix {
139 rows: [
140 [
141 ((Complex::new(c.powi(2), 0.0) + ch.scale(b.powi(2) - a.powi(2)))
142 .scale(1.0 / arg_sq))
143 .re,
144 ((Complex::new(b * c, 0.0) - ch.scale(b * c) + sh_c.scale(a))
145 .scale(-1.0 / arg_sq))
146 .re,
147 ((Complex::new(-a * c, 0.0) + ch.scale(a * c) - sh_c.scale(b))
148 .scale(-1.0 / arg_sq))
149 .re,
150 ]
151 .into(),
152 [
153 ((Complex::new(b * c, 0.0) - ch.scale(b * c) - sh_c.scale(a))
154 .scale(-1.0 / arg_sq))
155 .re,
156 ((Complex::new(b.powi(2), 0.0) + ch.scale(c.powi(2) - a.powi(2)))
157 .scale(1.0 / arg_sq))
158 .re,
159 ((Complex::new(a * b, 0.0) - ch.scale(a * b) - sh_c.scale(c))
160 .scale(-1.0 / arg_sq))
161 .re,
162 ]
163 .into(),
164 [
165 ((Complex::new(a * c, 0.0) - ch.scale(a * c) - sh_c.scale(b))
166 .scale(-1.0 / arg_sq))
167 .re,
168 ((Complex::new(-a * b, 0.0) + ch.scale(a * b) - sh_c.scale(c))
169 .scale(-1.0 / arg_sq))
170 .re,
171 ((Complex::new(a.powi(2), 0.0) - ch.scale(b.powi(2) + c.powi(2)))
172 .scale(-1.0 / arg_sq))
173 .re,
174 ]
175 .into(),
176 ],
177 }
178 }
179 }
180}
181
182impl From<(f64, f64, f64)> for HyperbolicAngle {
183 /// A tuple of three real numbers (a,b,c) describing the coefficients for the
184 /// generators (`J_z`, `K_x`, `K_y`) of the algebra so(2,1).
185 #[inline]
186 fn from(value: (f64, f64, f64)) -> Self {
187 Self { angles: value }
188 }
189}
190
191impl fmt::Display for HyperbolicAngle {
192 /// Format a [`HyperbolicAngle`] as `[{v[0]}, {v[1]}, {v[2]}]`.
193 #[inline]
194 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
195 write!(
196 f,
197 "[{}, {}, {}]",
198 self.angles.0, self.angles.1, self.angles.2
199 )
200 }
201}
202
203impl Distribution<HyperbolicAngle> for StandardUniform {
204 /// Sample a random hyperbolic angle.
205 ///
206 /// Note that the distribution is not
207 /// truly uniform---the boost magnitude is sampled from a square-root
208 /// distribution (between zero and one) while the angle is sampled
209 /// from a uniform distribution between 0 and $`2\pi`$.
210 ///
211 /// Every point on the Hyperbolic can be generated by a boost in the
212 /// x-direction followed by a rotation. The [Baker-Campbell-Hausdorff Lemma]
213 /// gives an expression for the relationship between subsequent SO(2,1)
214 /// transformations and the so(2,1) algebra generators. Explicitly,
215 ///
216 /// [Baker-Campbell-Hausdorff Lemma]: https://en.wikipedia.org/wiki/Baker%E2%80%93Campbell%E2%80%93Hausdorff_formula
217 ///
218 /// ```math
219 /// \exp[\theta J_z]\exp[v K_x] = \exp[(\theta + \frac{1}{12}\theta v^2)J_z + (v-\frac{1}{12}\theta^2v)K_x + \frac{1}{2}\theta v K_y + \text{higher order}]
220 /// ```
221 /// To avoid clustering around the cusp of the Hyperbolic, the random
222 /// boost parameter $`v`$ is sampled from a uniform distribution between
223 /// 0 and 1 and then passed through a square root function.
224 ///
225 /// # Example
226 ///
227 /// ```
228 /// use approxim::assert_relative_eq;
229 /// use hoomd_manifold::{
230 /// HyperbolicAngle, HyperbolicRotate, HyperbolicRotationMatrix, Minkowski,
231 /// };
232 /// use hoomd_vector::{Metric, Vector};
233 /// use rand::{RngExt, SeedableRng, rngs::StdRng};
234 ///
235 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
236 /// let mut rng = StdRng::seed_from_u64(1);
237 /// let v: HyperbolicAngle = rng.random();
238 ///
239 /// let matrix = HyperbolicRotationMatrix::from(v);
240 /// let origin = Minkowski::from([0.0, 0.0, 1.0]);
241 /// let point = matrix.hyperbolic_rotate(&origin);
242 /// assert_relative_eq!(
243 /// -1.0,
244 /// point.distance_squared(&Minkowski::<3>::default()),
245 /// epsilon = 1e-12
246 /// );
247 /// # Ok(())
248 /// # }
249 /// ```
250 #[inline]
251 fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> HyperbolicAngle {
252 let uniform_angle =
253 Uniform::new(0.0, 2.0 * PI).expect("hard-coded distribution should be valid");
254 let uniform_boost =
255 Uniform::new(0.0, 1.0).expect("hard-coded distribution should be valid");
256 let theta = uniform_angle.sample(rng);
257 let v: f64 = uniform_boost.sample(rng);
258 let v_sqrt = v.sqrt();
259 HyperbolicAngle::from((
260 theta + v * theta / 12.0,
261 v_sqrt - theta.powi(2) * v_sqrt / 12.0,
262 theta * v_sqrt / 2.0,
263 ))
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::{HyperbolicRotate, HyperbolicRotationMatrix, Minkowski};
271 use approxim::assert_relative_eq;
272 use rand::{RngExt, SeedableRng, rngs::StdRng};
273 use rstest::rstest;
274 use std::f64::consts::PI;
275
276 // Test named cases of three input values (hyperbolic angle tuple, Minkowski input, and answer)
277 #[rstest]
278 #[case::z_pi((PI, 0.0_f64, 0.0_f64), (1.0,0.0,0.0), (-1.0,0.0,0.0))]
279 #[case::z_pi_halves((PI/2.0, 0.0_f64, 0.0_f64), (1.0,0.0,0.0), (0.0,1.0,0.0))]
280 #[case::x_one((0.0_f64, 1.0_f64, 0.0_f64), (1.0,0.0,0.0), ((1.0_f64).cosh(),0.0,(1.0_f64).sinh()))]
281 #[case::x_tenth((0.0_f64, 0.1_f64, 0.0_f64), (1.0,0.0,1.0), ((0.1_f64).cosh()+(0.1_f64).sinh(),0.0,(0.1_f64).cosh()+(0.1_f64).sinh()))]
282 #[case::x_negative_one((0.0_f64, -1.0_f64, 0.0_f64), (1.0,0.0,0.0), ((1.0_f64).cosh(),0.0,-(1.0_f64).sinh()))]
283 #[case::y_one((0.0_f64, 0.0_f64, 1.0_f64), (0.0,1.0,0.0), (0.0,(1.0_f64).cosh(),(1.0_f64).sinh()))]
284 #[case::y_tenth((0.0_f64, 0.0_f64, 0.1_f64), (1.0,0.0,1.0), (1.0,(0.1_f64).sinh(),(0.1_f64).cosh()))]
285 #[case::y_negative_one((0.0_f64, 0.0_f64, -1.0_f64), (0.0,1.0,0.0), (0.0,(1.0_f64).cosh(),-(1.0_f64).sinh()))]
286
287 fn rotate_hyperbolic(
288 #[case] hyperbolic_angle: (f64, f64, f64),
289 #[case] vec: (f64, f64, f64),
290 #[case] ans: (f64, f64, f64),
291 ) {
292 let hyperbolic_angle = HyperbolicAngle::from(hyperbolic_angle);
293 let vec = Minkowski::from(vec);
294 let ans = Minkowski::from(ans);
295
296 let matrix = HyperbolicRotationMatrix::from(hyperbolic_angle);
297 let transformed = matrix.hyperbolic_rotate(&vec);
298 assert_relative_eq!(transformed, ans, epsilon = 1e-12);
299 }
300
301 #[test]
302 fn display() {
303 let a = HyperbolicAngle::from((1.3, 1.1, 1.2));
304 let s = format!("[{}, {}, {}]", a.angles.0, a.angles.1, a.angles.2);
305 assert_eq!(s, "[1.3, 1.1, 1.2]");
306 }
307
308 #[test]
309 fn reduced() {
310 let a = HyperbolicAngle::from((2.0 * PI + 0.1, 1.2, 2.3)).to_reduced();
311 assert_relative_eq!(a.angles.0, 0.1, epsilon = 1e-12);
312 assert_relative_eq!(a.angles.1, 1.2, epsilon = 1e-12);
313 assert_relative_eq!(a.angles.2, 2.3, epsilon = 1e-12);
314
315 let b = HyperbolicAngle::from((2.0 * PI, 1.2, 2.3)).to_reduced();
316 assert_relative_eq!(b.angles.0, 0.0, epsilon = 1e-12);
317 assert_relative_eq!(b.angles.1, 1.2, epsilon = 1e-12);
318 assert_relative_eq!(b.angles.2, 2.3, epsilon = 1e-12);
319
320 let c = HyperbolicAngle::from((0.5, 1.2, 2.3)).to_reduced();
321 assert_relative_eq!(c.angles.0, 0.5, epsilon = 1e-12);
322 assert_relative_eq!(c.angles.1, 1.2, epsilon = 1e-12);
323 assert_relative_eq!(c.angles.2, 2.3, epsilon = 1e-12);
324 }
325
326 #[test]
327 fn random() {
328 let mut rng = StdRng::seed_from_u64(13);
329
330 for _ in 0..10000 {
331 let a_sample: HyperbolicAngle = rng.random();
332 let a = a_sample.to_reduced();
333 assert!(a.angles.0 >= 0.0 && a.angles.0 < 2.0 * PI);
334 assert!(a.angles.1 < 1.0);
335 assert!(a.angles.2 < PI);
336 }
337 }
338}