Skip to content

Commit 6f9ff64

Browse files
authored
Merge pull request #285 from glotzerlab/spherical
Spherical
2 parents 45247da + c443293 commit 6f9ff64

4 files changed

Lines changed: 290 additions & 24 deletions

File tree

doc/src/release-notes.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,22 @@
44

55
*Added:*
66

7+
* `[hoomd-manifold]`: Add `Spherical<4>::from_versor` and the corresponding `::to_versor` (#285).
8+
* `[hoomd-mc]`: Implement translation moves for `Point<Spherical<4>>` (#287).
79
* `[hoomd-utility]`: Implement `Eq`, `PartialOrd`, and `Ord` for `PositiveReal` (#287).
810

911
*Changed:*
1012

13+
* `[hoomd-mc]`: Improve the numerical stability of translation moves for `Point<Spherical<3>>` (#287).
14+
1115
*Deprecated:*
1216

1317
*Removed:*
1418

1519
*Fixed:*
1620

21+
* `[hoomd-manifold]`: Fixed numerical stability issue in `Spherical<3>::distance` where the dot product could result in an out of bounds value.
22+
1723
## 1.1.0 (2026-04-17)
1824

1925
*Highlights:*

hoomd-manifold/src/sphere.rs

Lines changed: 194 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
66
use approxim::{approx_derive::RelativeEq, assert_relative_eq};
77
use rand::{
8-
Rng,
8+
Rng, RngExt,
99
distr::{Distribution, Uniform},
1010
};
1111
use serde::{Deserialize, Serialize};
1212
use std::f64::consts::PI;
1313

1414
use hoomd_utility::valid::PositiveReal;
15-
use hoomd_vector::{Cartesian, InnerProduct, Metric};
15+
use hoomd_vector::{Cartesian, InnerProduct, Metric, Quaternion, Rotate, Versor};
1616

1717
/// Point on the surface of a sphere.
1818
///
@@ -53,8 +53,8 @@ impl<const N: usize> Spherical<N> {
5353
assert_relative_eq!(rad, 1.0_f64, epsilon = 1e-6);
5454
Spherical { point }
5555
}
56-
57-
/// Implements a stereographic projection from the N-sphere to an N-dimensional plane.
56+
/// Implements a stereographic projection from the N-sphere to an n-dimensional
57+
/// plane by projecting through the $`(0,\cdots, 0,1)`$ axis.
5858
///
5959
/// # Example
6060
/// ```
@@ -100,7 +100,18 @@ impl Spherical<3> {
100100
}
101101

102102
impl Spherical<4> {
103-
/// Create a 3-sphere from spherical coordinates
103+
/// Create a point on a 3-sphere from spherical coordinates. Note that this uses
104+
/// the convention
105+
/// ```math
106+
/// \begin{pmatrix}
107+
/// \sin\theta \cos\phi_1
108+
/// \\ \sin\theta \sin\phi_1 \cos\phi_2
109+
/// \\ \sin\theta \sin\phi_1 \sin\phi_2
110+
/// \\ \cos\theta
111+
/// \end{pmatrix}
112+
/// ```
113+
/// where $`\theta`$ and $`phi_1`$ both run over the range $`0`$ to $`\pi`$ and $`\phi_2`$
114+
/// runs from $`0`$ to $`2\pi`$.
104115
#[inline]
105116
#[must_use]
106117
pub fn from_polar_coordinates(theta: f64, phi_1: f64, phi_2: f64) -> Spherical<4> {
@@ -115,6 +126,80 @@ impl Spherical<4> {
115126
]);
116127
Spherical::from_cartesian_coordinates(point)
117128
}
129+
/// Create a point on a unit-radius 3-sphere from a unit quaternion.
130+
#[inline]
131+
#[must_use]
132+
pub fn from_versor(versor: Versor) -> Spherical<4> {
133+
let quat = versor.get();
134+
let (a, b, c, d) = (quat.scalar, quat.vector[0], quat.vector[1], quat.vector[2]);
135+
Spherical::<4>::from_cartesian_coordinates(Cartesian::from([a, b, c, d]))
136+
}
137+
/// Create a versor which maps $`(1,0,0,0)`$ to the target `Spherical<4>` point.
138+
/// # Example
139+
/// ```
140+
/// use approxim::assert_relative_eq;
141+
/// use hoomd_manifold::Spherical;
142+
/// use hoomd_vector::{Cartesian, Quaternion, Versor};
143+
/// use std::f64::consts::PI;
144+
///
145+
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
146+
/// let radius = 1.0;
147+
/// let x = Spherical::<4>::from_polar_coordinates(
148+
/// PI / 4.0,
149+
/// PI / 8.0,
150+
/// 5.0 * PI / 4.0,
151+
/// );
152+
/// let x_versor = x.to_versor();
153+
/// let pole_versor = Quaternion::from([1.0, 0.0, 0.0, 0.0])
154+
/// .to_versor()
155+
/// .expect("not a null vector");
156+
/// let transformation =
157+
/// (*x_versor.get() * *pole_versor.get() * *x_versor.get())
158+
/// .to_versor()
159+
/// .expect("Hard-coded example is valid");
160+
/// let mapped_pole = Spherical::<4>::from_versor(transformation);
161+
/// assert_relative_eq!(
162+
/// mapped_pole.coordinates()[0],
163+
/// x.coordinates()[0],
164+
/// epsilon = 1e-12
165+
/// );
166+
/// assert_relative_eq!(
167+
/// mapped_pole.coordinates()[1],
168+
/// x.coordinates()[1],
169+
/// epsilon = 1e-12
170+
/// );
171+
/// assert_relative_eq!(
172+
/// mapped_pole.coordinates()[2],
173+
/// x.coordinates()[2],
174+
/// epsilon = 1e-12
175+
/// );
176+
/// assert_relative_eq!(
177+
/// mapped_pole.coordinates()[3],
178+
/// x.coordinates()[3],
179+
/// epsilon = 1e-12
180+
/// );
181+
/// # Ok(())
182+
/// # }
183+
/// ```
184+
#[inline]
185+
#[must_use]
186+
pub fn to_versor(&self) -> Versor {
187+
let phi = self.coordinates()[3].atan2(self.coordinates()[2]);
188+
let theta = ((self.coordinates()[3].powi(2) + self.coordinates()[2].powi(2)).sqrt())
189+
.atan2(self.coordinates()[1]);
190+
let psi = ((self.coordinates()[3].powi(2)
191+
+ self.coordinates()[2].powi(2)
192+
+ self.coordinates()[1].powi(2))
193+
.sqrt())
194+
.atan2(self.coordinates()[0]);
195+
let n_hat = Cartesian::from([
196+
theta.cos(),
197+
(theta.sin()) * (phi.cos()),
198+
(theta.sin()) * (phi.sin()),
199+
])
200+
.to_unit_unchecked();
201+
Versor::from_axis_angle(n_hat.0, psi)
202+
}
118203
}
119204

120205
impl Metric for Spherical<3> {
@@ -131,7 +216,8 @@ impl Metric for Spherical<3> {
131216
#[inline]
132217
fn distance(&self, other: &Self) -> f64 {
133218
let arg = Cartesian::dot(&self.point, &other.point);
134-
arg.acos()
219+
let arg_clipped = arg.clamp(-1.0, 1.0);
220+
arg_clipped.acos()
135221
}
136222
#[inline]
137223
fn distance_squared(&self, other: &Self) -> f64 {
@@ -158,7 +244,8 @@ impl Metric for Spherical<4> {
158244
#[inline]
159245
fn distance(&self, other: &Self) -> f64 {
160246
let arg = Cartesian::dot(&self.point, &other.point);
161-
arg.acos()
247+
let arg_clipped = arg.clamp(-1.0, 1.0);
248+
arg_clipped.acos()
162249
}
163250
#[inline]
164251
fn distance_squared(&self, other: &Self) -> f64 {
@@ -206,11 +293,11 @@ impl Metric for Spherical<4> {
206293
/// # }
207294
/// ```
208295
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
209-
pub struct SphericalDisk {
296+
pub struct SphericalDisk<const N: usize> {
210297
/// Max distance away from point.
211298
pub disk_radius: PositiveReal,
212299
/// The center of the disk.
213-
pub point: Spherical<3>,
300+
pub point: Spherical<N>,
214301
}
215302

216303
impl<const N: usize> Default for Spherical<N> {
@@ -222,7 +309,9 @@ impl<const N: usize> Default for Spherical<N> {
222309
}
223310
}
224311

225-
impl Distribution<Spherical<3>> for SphericalDisk {
312+
impl Distribution<Spherical<3>> for SphericalDisk<3> {
313+
/// Translates 3-dimensional cartesian vector named "point" along the
314+
/// surface of a sphere by maximum distance of r.
226315
#[inline]
227316
fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> Spherical<3> {
228317
let max_angle = self.disk_radius.get();
@@ -274,6 +363,34 @@ impl Distribution<Spherical<3>> for SphericalDisk {
274363
}
275364
}
276365

366+
impl Distribution<Spherical<4>> for SphericalDisk<4> {
367+
/// Translates 3-dimensional cartesian vector named "point" along the
368+
/// surface of a sphere by maximum distance of r.
369+
#[inline]
370+
fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> Spherical<4> {
371+
let max_trans = self.disk_radius.get();
372+
let point = self.point;
373+
// generate random unit cartesian vector
374+
let v: Versor = rng.random();
375+
let b_hat = v
376+
.rotate(&Cartesian::from([1.0, 0.0, 0.0]))
377+
.to_unit()
378+
.expect("hard coded non-null vector");
379+
let eta = Uniform::new(0.0, max_trans).expect("hard coded non-negative");
380+
let translation_versor = Versor::from_axis_angle(b_hat.0, eta.sample(rng));
381+
382+
let position_versor = Quaternion::from(*point.coordinates())
383+
.to_versor()
384+
.expect("spherical points cannot be null");
385+
let transformation =
386+
((*translation_versor.get()) * (*position_versor.get()) * (*translation_versor.get()))
387+
.to_versor()
388+
.expect("spherical points cannot be null");
389+
let sphere_point = Spherical::<4>::from_versor(transformation);
390+
Spherical::<4>::from_cartesian_coordinates(*sphere_point.point())
391+
}
392+
}
393+
277394
#[cfg(test)]
278395
mod tests {
279396
use super::*;
@@ -329,7 +446,7 @@ mod tests {
329446
}
330447

331448
#[test]
332-
fn random_sphere() {
449+
fn random_two_sphere() {
333450
// Generate ten random points on the Hyperbolic
334451
let mut rng = StdRng::seed_from_u64(42);
335452
let d = 0.1;
@@ -350,4 +467,70 @@ mod tests {
350467
assert!(d > distance);
351468
}
352469
}
470+
#[test]
471+
fn random_three_sphere() {
472+
// Generate ten random points on the Hyperbolic
473+
let mut rng = StdRng::seed_from_u64(42);
474+
let d = 0.1;
475+
let n_pole = Spherical::from_cartesian_coordinates(Cartesian::from([1.0, 0.0, 0.0, 0.0]));
476+
for _n in 0..10 {
477+
let disk = SphericalDisk {
478+
disk_radius: d.try_into().expect("hard-coded positive number"),
479+
point: n_pole,
480+
};
481+
let random_point: Spherical<4> = disk.sample(&mut rng);
482+
483+
// check that points remain on Sphere
484+
let rho = random_point.point.norm_squared();
485+
assert_relative_eq!(rho, 1.0, epsilon = 1e-12);
486+
487+
// check that points are within distance d of north pole
488+
let distance = random_point.point().distance(n_pole.point());
489+
assert!(d > distance);
490+
}
491+
}
492+
#[test]
493+
fn from_versor() {
494+
// generate a 3-sphere point from a versor
495+
let mut rng = StdRng::seed_from_u64(358);
496+
let v: Versor = rng.random();
497+
let sphere_pt = Spherical::<4>::from_versor(v);
498+
assert_eq!(sphere_pt.coordinates()[0], v.get().scalar);
499+
assert_eq!(sphere_pt.coordinates()[1], v.get().vector.coordinates[0]);
500+
assert_eq!(sphere_pt.coordinates()[2], v.get().vector.coordinates[1]);
501+
assert_eq!(sphere_pt.coordinates()[3], v.get().vector.coordinates[2]);
502+
}
503+
#[test]
504+
fn to_versor() {
505+
// generate a 3-sphere point from a versor
506+
let mut rng = StdRng::seed_from_u64(5121);
507+
let v: Versor = rng.random();
508+
let sphere_pt = Spherical::<4>::from_versor(v);
509+
// get versor which maps identity versor to 3-sphere point
510+
let versor_from_spherical = sphere_pt.to_versor();
511+
let pole_versor = Quaternion::from([1.0, 0.0, 0.0, 0.0])
512+
.to_versor()
513+
.expect("not a null vector");
514+
let transformation =
515+
(*versor_from_spherical.get() * *pole_versor.get() * *versor_from_spherical.get())
516+
.to_versor()
517+
.expect("Hard-coded example is valid");
518+
let q = transformation.get();
519+
assert_relative_eq!(q.scalar, v.get().scalar, epsilon = 1e-12);
520+
assert_relative_eq!(
521+
q.vector.coordinates[0],
522+
v.get().vector.coordinates[0],
523+
epsilon = 1e-12
524+
);
525+
assert_relative_eq!(
526+
q.vector.coordinates[1],
527+
v.get().vector.coordinates[1],
528+
epsilon = 1e-12
529+
);
530+
assert_relative_eq!(
531+
q.vector.coordinates[2],
532+
v.get().vector.coordinates[2],
533+
epsilon = 1e-12
534+
);
535+
}
353536
}

0 commit comments

Comments
 (0)