qsym2/angmom/spinor_rotation_3d.rs
1//! Three-dimensional rotations of spinors.
2
3use std::cmp;
4use std::fmt;
5
6use anyhow::{self, ensure};
7use approx;
8use factorial::Factorial;
9use nalgebra::Vector3;
10use ndarray::{array, Array2, Axis};
11use num::{BigUint, Complex, Zero};
12use num_traits::ToPrimitive;
13use serde::{Deserialize, Serialize};
14
15use crate::auxiliary::geometry::normalise_rotation_angle;
16
17#[cfg(test)]
18#[path = "spinor_rotation_3d_tests.rs"]
19mod spinor_rotation_3d_tests;
20
21// ================
22// Trait definition
23// ================
24
25/// Trait for defining the spin-spatial structure of the coefficient matrices.
26pub trait StructureConstraint {
27 // ----------------
28 // Required methods
29 // ----------------
30
31 /// The total number of coefficient matrices expected in this constraint.
32 fn n_coefficient_matrices(&self) -> usize;
33
34 /// The number of isostructural components explicitly specified by each coefficient matrix. The
35 /// basis functions described by any accompanying basis angular order information will pertain to any
36 /// one of the isostructural components.
37 fn n_explicit_comps_per_coefficient_matrix(&self) -> usize;
38
39 /// The number of isostructural components implicitly specified by each coefficient matrix.
40 fn n_implicit_comps_per_coefficient_matrix(&self) -> usize;
41
42 // ----------------
43 // Provided methods
44 // ----------------
45 /// The total number of isostructural components given by the product of
46 /// [`Self::n_coefficient_matrices`] and [`Self::n_implicit_comps_per_coefficient_matrix`].
47 fn n_comps(&self) -> usize {
48 self.n_coefficient_matrices() * self.n_implicit_comps_per_coefficient_matrix()
49 }
50
51 /// The implicit factor given by the ratio between
52 /// [`Self::n_implicit_comps_per_coefficient_matrix`] and
53 /// [`Self::n_explicit_comps_per_coefficient_matrix`]. It will be checked that this ratio
54 /// yields an integer.
55 fn implicit_factor(&self) -> Result<usize, anyhow::Error> {
56 ensure!(
57 self.n_implicit_comps_per_coefficient_matrix()
58 .rem_euclid(self.n_explicit_comps_per_coefficient_matrix())
59 == 0
60 );
61 let implicit_factor = self
62 .n_implicit_comps_per_coefficient_matrix()
63 .div_euclid(self.n_explicit_comps_per_coefficient_matrix());
64 Ok(implicit_factor)
65 }
66}
67
68// ================
69// Enum definitions
70// ================
71
72// --------------
73// SpinConstraint
74// --------------
75
76/// Enumerated type to manage spin constraints and spin space information for the conventional
77/// treatment of spin and spatial degrees of freedom in a decoupled manner.
78#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)]
79pub enum SpinConstraint {
80 /// Variant for restricted spin constraint: the spatial parts of all spin spaces are identical.
81 /// The associated value is the number of spin spaces.
82 Restricted(u16),
83
84 /// Variant for unrestricted spin constraint: the spatial parts of different spin spaces are
85 /// different, but spin collinearity is maintained. The associated values are the number of spin
86 /// spaces (*i.e.* the number of different spatial parts that are handled separately) and a
87 /// boolean indicating if the spin spaces are arranged in increasing $`m`$ order.
88 Unrestricted(u16, bool),
89
90 /// Variant for generalised spin constraint: the spatial parts of different spin spaces are
91 /// different, and no spin collinearity is imposed. The associated values are the number of spin
92 /// spaces and a boolean indicating if the spin spaces are arranged in increasing $`m`$ order.
93 Generalised(u16, bool),
94}
95
96impl StructureConstraint for SpinConstraint {
97 fn n_coefficient_matrices(&self) -> usize {
98 match self {
99 SpinConstraint::Restricted(_) => 1,
100 SpinConstraint::Unrestricted(nspins, _) => *nspins as usize,
101 SpinConstraint::Generalised(_, _) => 1,
102 }
103 }
104
105 fn n_explicit_comps_per_coefficient_matrix(&self) -> usize {
106 match self {
107 SpinConstraint::Restricted(_) => 1,
108 SpinConstraint::Unrestricted(_, _) => 1,
109 SpinConstraint::Generalised(nspins, _) => *nspins as usize,
110 }
111 }
112
113 fn n_implicit_comps_per_coefficient_matrix(&self) -> usize {
114 match self {
115 SpinConstraint::Restricted(nspins) => *nspins as usize,
116 SpinConstraint::Unrestricted(_, _) => 1,
117 SpinConstraint::Generalised(nspins, _) => *nspins as usize,
118 }
119 }
120}
121
122impl fmt::Display for SpinConstraint {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 match self {
125 Self::Restricted(nspins) => write!(
126 f,
127 "Restricted ({} spin {})",
128 nspins,
129 if *nspins == 1 { "space" } else { "spaces" }
130 ),
131 Self::Unrestricted(nspins, increasingm) => write!(
132 f,
133 "Unrestricted ({} spin {}, {} m)",
134 nspins,
135 if *nspins == 1 { "space" } else { "spaces" },
136 if *increasingm {
137 "increasing"
138 } else {
139 "decreasing"
140 }
141 ),
142 Self::Generalised(nspins, increasingm) => write!(
143 f,
144 "Generalised ({} spin {}, {} m)",
145 nspins,
146 if *nspins == 1 { "space" } else { "spaces" },
147 if *increasingm {
148 "increasing"
149 } else {
150 "decreasing"
151 }
152 ),
153 }
154 }
155}
156
157// ----------------
158// SpinOrbitCoupled
159// ----------------
160
161/// Enumerated type to manage spin--orbit-coupled layout in the coupled treatment of spin and
162/// spatial degrees of freedom.
163#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)]
164pub enum SpinOrbitCoupled {
165 /// Variant for $`j`$-adapted basis functions where each shell consists of $`\ket{j, m_j}`$
166 /// functions. The associated value specifies the total number of duplications of the
167 /// $`j`$-adapted basis (*e.g.* `2` in Dirac--Hartree--Fock). The order of $`m_j`$ in each
168 /// shell should be specified in a [`BasisAngularOrder`] structure elsewhere.
169 JAdapted(u16),
170}
171
172impl StructureConstraint for SpinOrbitCoupled {
173 fn n_coefficient_matrices(&self) -> usize {
174 match self {
175 SpinOrbitCoupled::JAdapted(_) => 1,
176 }
177 }
178
179 fn n_explicit_comps_per_coefficient_matrix(&self) -> usize {
180 match self {
181 SpinOrbitCoupled::JAdapted(ncomps) => *ncomps as usize,
182 }
183 }
184
185 fn n_implicit_comps_per_coefficient_matrix(&self) -> usize {
186 match self {
187 SpinOrbitCoupled::JAdapted(ncomps) => *ncomps as usize,
188 }
189 }
190}
191
192impl fmt::Display for SpinOrbitCoupled {
193 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194 match self {
195 Self::JAdapted(ncomps) => write!(
196 f,
197 "Spin--orbit-coupled j-adapted ({} {})",
198 ncomps,
199 if *ncomps == 1 {
200 "explicit component"
201 } else {
202 "explicit components"
203 },
204 ),
205 }
206 }
207}
208
209// =========
210// Functions
211// =========
212
213/// Returns an element in the Wigner rotation matrix for $`j = 1/2`$ defined by
214///
215/// ```math
216/// \hat{R}(\alpha, \beta, \gamma) \ket{\tfrac{1}{2}m}
217/// = \sum_{m'} \ket{\tfrac{1}{2}m'} D^{(1/2)}_{m'm}(\alpha, \beta, \gamma).
218/// ```
219///
220/// # Arguments
221///
222/// * `mdashi` - Index for $`m'`$ given by $`m'+\tfrac{1}{2}`$.
223/// * `mi` - Index for $`m`$ given by $`m+\tfrac{1}{2}`$.
224/// * `euler_angles` - A triplet of Euler angles $`(\alpha, \beta, \gamma)`$ in radians, following
225/// the Whitaker convention, *i.e.* $`z_2-y-z_1`$ (extrinsic rotations).
226///
227/// # Returns
228///
229/// The element $`D^{(1/2)}_{m'm}(\alpha, \beta, \gamma)`$.
230fn dmat_euler_element(mdashi: usize, mi: usize, euler_angles: (f64, f64, f64)) -> Complex<f64> {
231 assert!(mdashi == 0 || mdashi == 1, "mdashi can only be 0 or 1.");
232 assert!(mi == 0 || mi == 1, "mi can only be 0 or 1.");
233 let (alpha, beta, gamma) = euler_angles;
234 let d = if (mi, mdashi) == (1, 1) {
235 // m = 1/2, mdash = 1/2
236 (beta / 2.0).cos()
237 } else if (mi, mdashi) == (1, 0) {
238 // m = 1/2, mdash = -1/2
239 (beta / 2.0).sin()
240 } else if (mi, mdashi) == (0, 1) {
241 // m = -1/2, mdash = 1/2
242 -(beta / 2.0).sin()
243 } else if (mi, mdashi) == (0, 0) {
244 // m = -1/2, mdash = -1/2
245 (beta / 2.0).cos()
246 } else {
247 panic!("Invalid mi and/or mdashi.");
248 };
249
250 let alpha_basic = alpha.rem_euclid(2.0 * std::f64::consts::PI);
251 let gamma_basic = gamma.rem_euclid(2.0 * std::f64::consts::PI);
252 let i = Complex::<f64>::i();
253 let mut prefactor = (-i
254 * (alpha_basic
255 * (mdashi
256 .to_f64()
257 .unwrap_or_else(|| panic!("Unable to convert `{mdashi}` to `f64`."))
258 - 0.5)
259 + gamma_basic
260 * (mi
261 .to_f64()
262 .unwrap_or_else(|| panic!("Unable to convert `{mi}` to `f64`."))
263 - 0.5)))
264 .exp();
265
266 // Half-integer j = 1/2; double-group behaviours possible.
267 let alpha_double = approx::relative_eq!(
268 alpha.div_euclid(2.0 * std::f64::consts::PI).rem_euclid(2.0),
269 1.0,
270 epsilon = 1e-14,
271 max_relative = 1e-14
272 );
273 let gamma_double = approx::relative_eq!(
274 gamma.div_euclid(2.0 * std::f64::consts::PI).rem_euclid(2.0),
275 1.0,
276 epsilon = 1e-14,
277 max_relative = 1e-14
278 );
279 if alpha_double != gamma_double {
280 prefactor *= -1.0;
281 }
282 prefactor * d
283}
284
285/// Returns the Wigner rotation matrix for $`j = 1/2`$ whose elements are defined by
286///
287/// ```math
288/// \hat{R}(\alpha, \beta, \gamma) \ket{\tfrac{1}{2}m}
289/// = \sum_{m'} \ket{\tfrac{1}{2}m'} D^{(1/2)}_{m'm}(\alpha, \beta, \gamma).
290/// ```
291///
292/// # Arguments
293///
294/// * `euler_angles` - A triplet of Euler angles $`(\alpha, \beta, \gamma)`$ in radians, following
295/// the Whitaker convention, *i.e.* $`z_2-y-z_1`$ (extrinsic rotations).
296/// * `increasingm` - If `true`, the rows and columns of $`\mathbf{D}^{(1/2)}`$ are
297/// arranged in increasing order of $`m_l = -l, \ldots, l`$. If `false`, the order is reversed:
298/// $`m_l = l, \ldots, -l`$. The recommended default is `false`, in accordance with convention.
299///
300/// # Returns
301///
302/// The matrix $`\mathbf{D}^{(1/2)}(\alpha, \beta, \gamma)`$.
303#[must_use]
304pub fn dmat_euler(euler_angles: (f64, f64, f64), increasingm: bool) -> Array2<Complex<f64>> {
305 let mut dmat = Array2::<Complex<f64>>::zeros((2, 2));
306 for mdashi in 0..2 {
307 for mi in 0..2 {
308 dmat[(mdashi, mi)] = dmat_euler_element(mdashi, mi, euler_angles);
309 }
310 }
311 if !increasingm {
312 dmat.invert_axis(Axis(0));
313 dmat.invert_axis(Axis(1));
314 }
315 dmat
316}
317
318/// Returns the Wigner rotation matrix for $`j = 1/2`$ whose elements are defined by
319///
320/// ```math
321/// \hat{R}(\phi\hat{\mathbf{n}}) \ket{\tfrac{1}{2}m}
322/// = \sum_{m'} \ket{\tfrac{1}{2}m'} D^{(1/2)}_{m'm}(\phi\hat{\mathbf{n}}).
323/// ```
324///
325/// The parametrisation of $`\mathbf{D}^{(1/2)}`$ by $`\phi`$ and $`\hat{\mathbf{n}}`$ is given
326/// in (**4**-9.12) of Altmann, S. L. Rotations, Quaternions, and Double Groups. (Dover
327/// Publications, Inc., 2005).
328///
329/// # Arguments
330///
331/// * `angle` - The angle $`\phi`$ of the rotation in radians. A positive rotation is an
332/// anticlockwise rotation when looking down `axis`.
333/// * `axis` - A space-fixed vector defining the axis of rotation. The supplied vector will be
334/// normalised.
335/// * `increasingm` - If `true`, the rows and columns of $`\mathbf{D}^{(1/2)}`$ are
336/// arranged in increasing order of $`m_l = -l, \ldots, l`$. If `false`, the order is reversed:
337/// $`m_l = l, \ldots, -l`$. The recommended default is `false`, in accordance with convention.
338///
339/// # Returns
340///
341/// The matrix $`\mathbf{D}^{(1/2)}(\phi\hat{\mathbf{n}})`$.
342#[must_use]
343pub fn dmat_angleaxis(angle: f64, axis: Vector3<f64>, increasingm: bool) -> Array2<Complex<f64>> {
344 let normalised_axis = axis.normalize();
345 let nx = normalised_axis.x;
346 let ny = normalised_axis.y;
347 let nz = normalised_axis.z;
348
349 let i = Complex::<f64>::i();
350 let double_angle = angle.rem_euclid(4.0 * std::f64::consts::PI);
351 let mut dmat = array![
352 [
353 (double_angle / 2.0).cos() + i * nz * (double_angle / 2.0).sin(),
354 (ny - i * nx) * (double_angle / 2.0).sin()
355 ],
356 [
357 -(ny + i * nx) * (double_angle / 2.0).sin(),
358 (double_angle / 2.0).cos() - i * nz * (double_angle / 2.0).sin(),
359 ]
360 ];
361 if !increasingm {
362 dmat.invert_axis(Axis(0));
363 dmat.invert_axis(Axis(1));
364 }
365 dmat
366}
367
368/// Returns an element in the Wigner rotation matrix for an integral or half-integral
369/// $`j`$, defined by
370/// ```math
371/// \hat{R}(\alpha, \beta, \gamma) \ket{jm}
372/// = \sum_{m'} \ket{jm'} D^{(j)}_{m'm}(\alpha, \beta, \gamma)
373/// ```
374/// where $`-\pi \le \alpha \le \pi`$, $`0 \le \beta \le \pi`$, $`-\pi \le \gamma \le \pi`$.
375///
376/// The explicit expression for the elements of $`\mathbf{D}^{(j)}(\alpha, \beta, \gamma)`$
377/// is given in Professor Anthony Stone's graduate lecture notes on Angular Momentum at the
378/// University of Cambridge in 2006.
379///
380/// # Arguments
381///
382/// * `twoj` - Two times the angular momentum $`2j`$. If this is even, $`j`$ is integral; otherwise,
383/// $`j`$ is half-integral.
384/// * `mdashi` - Index for $`m'`$ given by $`m'+\tfrac{1}{2}`$.
385/// * `mi` - Index for $`m`$ given by $`m+\tfrac{1}{2}`$.
386/// * `euler_angles` - A triplet of Euler angles $`(\alpha, \beta, \gamma)`$ in radians, following
387/// the Whitaker convention, *i.e.* $`z_2-y-z_1`$ (extrinsic rotations).
388///
389/// # Returns
390///
391/// The element $`D^{(j)}_{m'm}(\alpha, \beta, \gamma)`$.
392#[allow(clippy::too_many_lines)]
393pub fn dmat_euler_gen_element(
394 twoj: u32,
395 mdashi: usize,
396 mi: usize,
397 euler_angles: (f64, f64, f64),
398) -> Complex<f64> {
399 assert!(
400 mdashi <= twoj as usize,
401 "`mdashi` must be between 0 and {twoj} (inclusive).",
402 );
403 assert!(
404 mi <= twoj as usize,
405 "`mi` must be between 0 and {twoj} (inclusive).",
406 );
407 let (alpha, beta, gamma) = euler_angles;
408 let j = f64::from(twoj) / 2.0;
409 let mdash = mdashi
410 .to_f64()
411 .unwrap_or_else(|| panic!("Unable to convert `{mdashi}` to `f64`."))
412 - j;
413 let m = mi
414 .to_f64()
415 .unwrap_or_else(|| panic!("Unable to convert `{mi}` to `f64`."))
416 - j;
417
418 let i = Complex::<f64>::i();
419 let alpha_basic = alpha.rem_euclid(2.0 * std::f64::consts::PI);
420 let gamma_basic = gamma.rem_euclid(2.0 * std::f64::consts::PI);
421 let mut prefactor = (-i * (alpha_basic * mdash + gamma_basic * m)).exp();
422
423 if twoj % 2 != 0 {
424 // Half-integer j; double-group behaviours possible.
425 let alpha_double = approx::relative_eq!(
426 alpha.div_euclid(2.0 * std::f64::consts::PI).rem_euclid(2.0),
427 1.0,
428 epsilon = 1e-14,
429 max_relative = 1e-14
430 );
431 let gamma_double = approx::relative_eq!(
432 gamma.div_euclid(2.0 * std::f64::consts::PI).rem_euclid(2.0),
433 1.0,
434 epsilon = 1e-14,
435 max_relative = 1e-14
436 );
437 if alpha_double != gamma_double {
438 prefactor *= -1.0;
439 }
440 }
441
442 // tmax = min(int(j + mdash), int(j - m))
443 // j + mdash = mdashi
444 // j - m = twoj - mi
445 let tmax = cmp::min(mdashi, twoj as usize - mi);
446
447 // tmin = max(0, int(mdash - m))
448 // mdash - m = mdashi - mi
449 let tmin = if mdashi > mi { mdashi - mi } else { 0 };
450
451 let d = (tmin..=tmax).fold(Complex::<f64>::zero(), |acc, t| {
452 // j - m = twoj - mi
453 // j - mdash = twoj - mdashi
454 let num = (BigUint::from(mdashi)
455 .checked_factorial()
456 .unwrap_or_else(|| panic!("Unable to compute the factorial of {mdashi}."))
457 * BigUint::from(twoj as usize - mdashi)
458 .checked_factorial()
459 .unwrap_or_else(|| {
460 panic!(
461 "Unable to compute the factorial of {}.",
462 twoj as usize - mdashi
463 )
464 })
465 * BigUint::from(mi)
466 .checked_factorial()
467 .unwrap_or_else(|| panic!("Unable to compute the factorial of {mi}."))
468 * BigUint::from(twoj as usize - mi)
469 .checked_factorial()
470 .unwrap_or_else(|| {
471 panic!("Unable to compute the factorial of {}.", twoj as usize - mi)
472 }))
473 .to_f64()
474 .expect("Unable to convert a `BigUint` value to `f64`.")
475 .sqrt();
476
477 // t <= j + mdash ==> j + mdash - t = mdashi - t >= 0
478 // t <= j - m ==> j - m - t = twoj - mi - t >= 0
479 // t >= 0
480 // t >= mdash - m ==> t - (mdash - m) = t + mi - mdashi >= 0
481 let den = (BigUint::from(mdashi - t)
482 .checked_factorial()
483 .unwrap_or_else(|| panic!("Unable to compute the factorial of {}.", mdashi - t))
484 * BigUint::from(twoj as usize - mi - t)
485 .checked_factorial()
486 .unwrap_or_else(|| {
487 panic!(
488 "Unable to compute the factorial of {}.",
489 twoj as usize - mi - t
490 )
491 })
492 * BigUint::from(t)
493 .checked_factorial()
494 .unwrap_or_else(|| panic!("Unable to compute the factorial of {t}."))
495 * BigUint::from(t + mi - mdashi)
496 .checked_factorial()
497 .unwrap_or_else(|| {
498 panic!("Unable to compute the factorial of {}.", t + mi - mdashi)
499 }))
500 .to_f64()
501 .expect("Unable to convert a `BigUint` value to `f64`.");
502
503 let trigfactor = (beta / 2.0).cos().powi(
504 i32::try_from(twoj as usize + mdashi - mi - 2 * t).unwrap_or_else(|_| {
505 panic!(
506 "Unable to convert `{}` to `i32`.",
507 twoj as usize + mdashi - mi - 2 * t
508 )
509 }),
510 ) * (beta / 2.0).sin().powi(
511 i32::try_from(2 * t + mi - mdashi).unwrap_or_else(|_| {
512 panic!("Unable to convert `{}` to `i32`.", 2 * t + mi - mdashi)
513 }),
514 );
515
516 if t % 2 == 0 {
517 acc + (num / den) * trigfactor
518 } else {
519 acc - (num / den) * trigfactor
520 }
521 });
522
523 prefactor * d
524}
525
526/// Returns the Wigner rotation matrix in the Euler-angle parametrisation for any integral or
527/// half-integral $`j`$ whose elements are defined by
528/// ```math
529/// \hat{R}(\alpha, \beta, \gamma) \ket{jm}
530/// = \sum_{m'} \ket{jm'} D^{(j)}_{m'm}(\alpha, \beta, \gamma)
531/// ```
532/// and given in [`dmat_euler_gen_element`], where $`-\pi \le \alpha \le \pi`$,
533/// $`0 \le \beta \le \pi`$, $`-\pi \le \gamma \le \pi`$.
534///
535/// # Arguments
536///
537/// * `twoj` - Two times the angular momentum $`2j`$. If this is even, $`j`$ is integral; otherwise,
538/// $`j`$ is half-integral.
539/// * `euler_angles` - A triplet of Euler angles $`(\alpha, \beta, \gamma)`$ in radians, following
540/// the Whitaker convention, *i.e.* $`z_2-y-z_1`$ (extrinsic rotations).
541/// * `increasingm` - If `true`, the rows and columns of $`\mathbf{D}^{(j)}`$ are
542/// arranged in increasing order of $`m_l = -l, \ldots, l`$. If `false`, the order is reversed:
543/// $`m_l = l, \ldots, -l`$. The recommended default is `false`, in accordance with convention.
544///
545/// # Returns
546///
547/// The matrix $`\mathbf{D}^{(j)}(\alpha, \beta, \gamma)`$.
548#[must_use]
549pub fn dmat_euler_gen(
550 twoj: u32,
551 euler_angles: (f64, f64, f64),
552 increasingm: bool,
553) -> Array2<Complex<f64>> {
554 let dim = twoj as usize + 1;
555 let mut dmat = Array2::<Complex<f64>>::zeros((dim, dim));
556 for mdashi in 0..dim {
557 for mi in 0..dim {
558 dmat[(mdashi, mi)] = dmat_euler_gen_element(twoj, mdashi, mi, euler_angles);
559 }
560 }
561 if !increasingm {
562 dmat.invert_axis(Axis(0));
563 dmat.invert_axis(Axis(1));
564 }
565 dmat
566}
567
568/// Returns the Wigner rotation matrix in the angle-axis parametrisation for any integral or
569/// half-integral $`j`$ whose elements are defined by
570///
571/// ```math
572/// \hat{R}(\phi\hat{\mathbf{n}}) \ket{jm}
573/// = \sum_{m'} \ket{jm'} D^{(j)}_{m'm}(\phi\hat{\mathbf{n}}).
574/// ```
575///
576/// # Arguments
577///
578/// * `twoj` - Two times the angular momentum $`2j`$. If this is even, $`j`$ is integral; otherwise,
579/// $`j`$ is half-integral.
580/// * `angle` - The angle $`\phi`$ of the rotation in radians. A positive rotation is an
581/// anticlockwise rotation when looking down `axis`.
582/// * `axis` - A space-fixed vector defining the axis of rotation. The supplied vector will be
583/// normalised.
584/// * `increasingm` - If `true`, the rows and columns of $`\mathbf{D}^{(1/2)}`$ are
585/// arranged in increasing order of $`m_l = -l, \ldots, l`$. If `false`, the order is reversed:
586/// $`m_l = l, \ldots, -l`$. The recommended default is `false`, in accordance with convention.
587///
588/// # Returns
589///
590/// The matrix $`\mathbf{D}^{(j)}(\phi\hat{\mathbf{n}})`$.
591#[must_use]
592pub fn dmat_angleaxis_gen_double(
593 twoj: u32,
594 angle: f64,
595 axis: Vector3<f64>,
596 increasingm: bool,
597) -> Array2<Complex<f64>> {
598 let euler_angles = angleaxis_to_euler_double(angle, axis);
599 dmat_euler_gen(twoj, euler_angles, increasingm)
600}
601
602/// Converts an angle and axis of rotation to Euler angles using the equations in Section
603/// (**3**-5.4) in Altmann, S. L. Rotations, Quaternions, and Double Groups. (Dover
604/// Publications, Inc., 2005), but with an extended range,
605///
606/// ```math
607/// 0 \le \alpha \le 2\pi, \quad
608/// 0 \le \beta \le \pi, \quad
609/// 0 \le \gamma \le 4\pi,
610/// ```
611///
612/// such that all angle-axis parametrisations of $`\phi\hat{\mathbf{n}}`$ for
613/// $`0 \le \phi \le 4 \pi`$ are mapped to unique triplets of $`(\alpha, \beta, \gamma)`$,
614/// as explained in Fan, P.-D., Chen, J.-Q., Mcaven, L. & Butler, P. Unique Euler angles and
615/// self-consistent multiplication tables for double point groups. *International Journal of
616/// Quantum Chemistry* **75**, 1–9 (1999),
617/// [DOI](https://doi.org/10.1002/(SICI)1097-461X(1999)75:1<1::AID-QUA1>3.0.CO;2-V).
618///
619/// When $`\beta = 0`$, only the sum $`\alpha+\gamma`$ is determined. Likewise, when
620/// $`\beta = \pi`$, only the difference $`\alpha-\gamma`$ is determined. We thus set
621/// $`\alpha = 0`$ in these cases and solve for $`\gamma`$ without changing the nature of the
622/// results.
623///
624/// # Arguments
625///
626/// * `angle` - The angle $`\phi`$ of the rotation in radians. A positive rotation is an
627/// anticlockwise rotation when looking down `axis`.
628/// * `axis` - A space-fixed vector defining the axis of rotation $`\hat{\mathbf{n}}`$. The supplied
629/// vector will be normalised.
630///
631/// # Returns
632///
633/// The tuple containing the Euler angles $`(\alpha, \beta, \gamma)`$ in radians, following the
634/// Whitaker convention.
635fn angleaxis_to_euler_double(angle: f64, axis: Vector3<f64>) -> (f64, f64, f64) {
636 let normalised_axis = axis.normalize();
637 let nx = normalised_axis.x;
638 let ny = normalised_axis.y;
639 let nz = normalised_axis.z;
640
641 let double_angle = angle.rem_euclid(4.0 * std::f64::consts::PI);
642 let basic_angle = angle.rem_euclid(2.0 * std::f64::consts::PI);
643 let double = approx::relative_eq!(
644 angle.div_euclid(2.0 * std::f64::consts::PI).rem_euclid(2.0),
645 1.0,
646 epsilon = 1e-14,
647 max_relative = 1e-14
648 );
649
650 let cosbeta = 1.0 - 2.0 * (nx.powi(2) + ny.powi(2)) * (basic_angle / 2.0).sin().powi(2);
651 let cosbeta = if cosbeta.abs() > 1.0 {
652 // Numerical errors can cause cosbeta to be outside [-1, 1].
653 approx::assert_relative_eq!(cosbeta.abs(), 1.0, epsilon = 1e-14, max_relative = 1e-14);
654 cosbeta.round()
655 } else {
656 cosbeta
657 };
658
659 // acos gives 0 <= beta <= pi.
660 let beta = cosbeta.acos();
661
662 let (alpha, gamma) =
663 if approx::relative_ne!(cosbeta.abs(), 1.0, epsilon = 1e-14, max_relative = 1e-14) {
664 // cosbeta != 1 or -1, beta != 0 or pi
665 // alpha and gamma are given by Equations (**3**-5.4) to (**3**-5.10)
666 // in Altmann, S. L. Rotations, Quaternions, and Double Groups. (Dover Publications,
667 // Inc., 2005).
668 // These equations yield the same alpha and gamma for phi and phi+2pi.
669 // We therefore account for double-group behaviours separately.
670 let num_alpha =
671 -nx * basic_angle.sin() + 2.0 * ny * nz * (basic_angle / 2.0).sin().powi(2);
672 let den_alpha =
673 ny * basic_angle.sin() + 2.0 * nx * nz * (basic_angle / 2.0).sin().powi(2);
674 let alpha = num_alpha
675 .atan2(den_alpha)
676 .rem_euclid(2.0 * std::f64::consts::PI);
677
678 let num_gamma =
679 nx * basic_angle.sin() + 2.0 * ny * nz * (basic_angle / 2.0).sin().powi(2);
680 let den_gamma =
681 ny * basic_angle.sin() - 2.0 * nx * nz * (basic_angle / 2.0).sin().powi(2);
682 let gamma_raw = num_gamma.atan2(den_gamma);
683 let gamma = if double {
684 (gamma_raw + 2.0 * std::f64::consts::PI).rem_euclid(4.0 * std::f64::consts::PI)
685 } else {
686 gamma_raw.rem_euclid(4.0 * std::f64::consts::PI)
687 };
688
689 (alpha, gamma)
690 } else if approx::relative_eq!(cosbeta, 1.0, epsilon = 1e-14, max_relative = 1e-14) {
691 // cosbeta == 1, beta == 0
692 // cos(0.5(alpha+gamma)) = cos(0.5phi)
693 // We set alpha == 0 by convention.
694 // We then set gamma = phi mod (4*pi).
695 (0.0, double_angle)
696 } else {
697 // cosbeta == -1, beta == pi
698 // sin(0.5phi) must be non-zero, otherwise cosbeta == 1, a
699 // contradiction.
700 // sin(0.5(alpha-gamma)) = -nx*sin(0.5phi)
701 // cos(0.5(alpha-gamma)) = +ny*sin(0.5phi)
702 // We set alpha == 0 by convention.
703 // gamma then lies in [-2pi, 2pi].
704 // We obtain the same gamma for phi and phi+2pi.
705 // We therefore account for double-group behaviours separately.
706 let gamma_raw = 2.0 * nx.atan2(ny);
707 let gamma = if double {
708 (gamma_raw + 2.0 * std::f64::consts::PI).rem_euclid(4.0 * std::f64::consts::PI)
709 } else {
710 gamma_raw.rem_euclid(4.0 * std::f64::consts::PI)
711 };
712
713 (0.0, gamma)
714 };
715
716 (alpha, beta, gamma)
717}
718
719/// Returns the Wigner rotation matrix in the angle-axis parametrisation for any integral or
720/// half-integral $`j`$ whose elements are defined by
721///
722/// ```math
723/// \hat{R}(\phi\hat{\mathbf{n}}) \ket{jm}
724/// = \sum_{m'} \ket{jm'} D^{(j)}_{m'm}(\phi\hat{\mathbf{n}}),
725/// ```
726///
727/// where the angle of rotation is ensured to be in the range $`[-\pi, \pi]`$. In other words, for
728/// half-odd-integer $`j`$, this function only returns Wigner rotation matrices corresponding to
729/// three-dimensional rotations connected to the identity via a homotopy path of class 0.
730///
731/// # Arguments
732///
733/// * `twoj` - Two times the angular momentum $`2j`$. If this is even, $`j`$ is integral; otherwise,
734/// $`j`$ is half-integral.
735/// * `angle` - The angle $`\phi`$ of the rotation in radians. A positive rotation is an
736/// anticlockwise rotation when looking down `axis`.
737/// * `axis` - A space-fixed vector defining the axis of rotation. The supplied vector will be
738/// normalised.
739/// * `increasingm` - If `true`, the rows and columns of $`\mathbf{D}^{(1/2)}`$ are
740/// arranged in increasing order of $`m_l = -l, \ldots, l`$. If `false`, the order is reversed:
741/// $`m_l = l, \ldots, -l`$. The recommended default is `false`, in accordance with convention.
742///
743/// # Returns
744///
745/// The matrix $`\mathbf{D}^{(j)}(\phi\hat{\mathbf{n}})`$.
746#[must_use]
747pub fn dmat_angleaxis_gen_single(
748 twoj: u32,
749 angle: f64,
750 axis: Vector3<f64>,
751 increasingm: bool,
752) -> Array2<Complex<f64>> {
753 let euler_angles = angleaxis_to_euler_single(angle, axis, 1e-14);
754 let mat = dmat_euler_gen(twoj, euler_angles, increasingm);
755 mat
756}
757
758/// Converts an angle and axis of rotation to Euler angles using the equations in Section
759/// (**3**-5.4) in Altmann, S. L. Rotations, Quaternions, and Double Groups. (Dover
760/// Publications, Inc., 2005) such that
761///
762/// ```math
763/// -\pi \le \alpha \le \pi, \quad
764/// 0 \le \beta \le \pi, \quad
765/// -\pi \le \gamma \le \pi.
766/// ```
767///
768/// When $`\beta = 0`$, only the sum $`\alpha+\gamma`$ is determined. Likewise, when
769/// $`\beta = \pi`$, only the difference $`\alpha-\gamma`$ is determined. We thus set
770/// $`\alpha = 0`$ in these cases and solve for $`\gamma`$ without changing the nature of the
771/// results.
772///
773/// However, it is sometimes necessary to modify the above ranges such that the Euler angles
774/// correspond to an $`\mathsf{SU}(2)`$ rotation of homotopy class 0. This is achieved by ensuring
775/// the scalar part of the corresponding quaternion,
776///
777/// ```math
778/// \lambda = \cos\frac{\beta}{2} \sin\frac{\alpha + \gamma}{2},
779/// ```math
780///
781/// is non-negative by adding or subtracting $`2\pi`$ from $`\alpha`$ as necessary, so that the
782/// modified ranges are
783///
784/// ```math
785/// -2\pi \le \alpha \le 2\pi, \quad
786/// 0 \le \beta \le \pi, \quad
787/// -\pi \le \gamma \le \pi.
788/// ```
789///
790/// # Arguments
791///
792/// * `angle` - The angle $`\phi`$ of the rotation in radians. A positive rotation is an
793/// anticlockwise rotation when looking down `axis`.
794/// * `axis` - A space-fixed vector defining the axis of rotation $`\hat{\mathbf{n}}`$. The supplied
795/// vector will be normalised.
796///
797/// # Returns
798///
799/// The tuple containing the Euler angles $`(\alpha, \beta, \gamma)`$ in radians, following the
800/// Whitaker convention.
801fn angleaxis_to_euler_single(angle: f64, axis: Vector3<f64>, thresh: f64) -> (f64, f64, f64) {
802 let normalised_axis = axis.normalize();
803 let nx = normalised_axis.x;
804 let ny = normalised_axis.y;
805 let nz = normalised_axis.z;
806
807 let (normalised_angle, _) = normalise_rotation_angle(angle, thresh);
808
809 let cosbeta = 1.0 - 2.0 * (nx.powi(2) + ny.powi(2)) * (normalised_angle / 2.0).sin().powi(2);
810 let cosbeta = if cosbeta.abs() > 1.0 {
811 // Numerical errors can cause cosbeta to be outside [-1, 1].
812 approx::assert_relative_eq!(cosbeta.abs(), 1.0, epsilon = thresh, max_relative = thresh);
813 cosbeta.round()
814 } else {
815 cosbeta
816 };
817
818 // 0 <= beta <= pi.
819 let beta = cosbeta.acos();
820
821 let (alpha, gamma) =
822 if approx::relative_ne!(cosbeta.abs(), 1.0, epsilon = thresh, max_relative = thresh) {
823 // cosbeta != 1 or -1, beta != 0 or pi, sin(beta) != 0
824 // alpha and gamma are given by Equations (**3**-5.4) to (**3**-5.10)
825 // in Altmann, S. L. Rotations, Quaternions, and Double Groups. (Dover Publications,
826 // Inc., 2005).
827 let sgn = beta.sin().signum();
828 let num_alpha = -nx * normalised_angle.sin()
829 + 2.0 * ny * nz * (normalised_angle / 2.0).sin().powi(2) * sgn;
830 let den_alpha = ny * normalised_angle.sin()
831 + 2.0 * nx * nz * (normalised_angle / 2.0).sin().powi(2) * sgn;
832 // -pi <= alpha <= pi
833 let alpha = num_alpha.atan2(den_alpha);
834
835 let num_gamma = nx * normalised_angle.sin()
836 + 2.0 * ny * nz * (normalised_angle / 2.0).sin().powi(2) * sgn;
837 let den_gamma = ny * normalised_angle.sin()
838 - 2.0 * nx * nz * (normalised_angle / 2.0).sin().powi(2) * sgn;
839 // -pi <= gamma <= pi
840 let gamma = num_gamma.atan2(den_gamma);
841
842 // We shall next determine the scalar part of the quaternion corresponding to the deduced
843 // Euler angles. We need this to ensure that the Euler angles correspond to rotations of
844 // homotopy class 0.
845 // See Eqn. 12-11.4 in Altmann, S. L. Rotations, Quaternions, and Double Groups. (Dover
846 // Publications, Inc., 2005).
847 let lambda = (beta * 0.5).cos() * ((alpha + gamma) * 0.5).cos();
848 if lambda < 0.0 {
849 if approx::relative_eq!(
850 gamma.abs(),
851 std::f64::consts::PI,
852 epsilon = thresh,
853 max_relative = thresh
854 ) {
855 (alpha, gamma - gamma.signum() * 2.0 * std::f64::consts::PI)
856 } else if approx::relative_eq!(
857 alpha.abs(),
858 std::f64::consts::PI,
859 epsilon = thresh,
860 max_relative = thresh
861 ) {
862 (alpha - alpha.signum() * 2.0 * std::f64::consts::PI, gamma)
863 } else {
864 (alpha - alpha.signum() * 2.0 * std::f64::consts::PI, gamma)
865 // panic!("Unable to adjust α = {alpha:+.7} or γ = {gamma:+.7} within [-π, π] so that the resultant Euler angles correspond to a quaternion with a non-negative scalar part (λ = {lambda:+.7}).")
866 }
867 } else {
868 (alpha, gamma)
869 }
870 } else if approx::relative_eq!(cosbeta, 1.0, epsilon = thresh, max_relative = thresh) {
871 // cosbeta == 1, beta == 0
872 // => cos(0.5(alpha+gamma)) = cos(0.5phi)
873 // => alpha + gamma = ±phi
874 // There are two possibilities:
875 // 1. phi == 0, in which case we simply set both alpha and gamma to zero
876 // 2. nx^2 + ny^2 = 0, which means nx = ny = 0, which means we are at either the
877 // North pole or the South pole. In either case, we set alpha == 0 by convention.
878 // We then set gamma = phi at the North pole or -phi at the South pole.
879 // In both cases, lambda = cos(±phi/2), and since -π ≤ phi ≤ π, lambda ≥ 0.
880 if approx::relative_eq!(
881 normalised_angle,
882 0.0,
883 epsilon = thresh,
884 max_relative = thresh
885 ) {
886 (0.0, normalised_angle)
887 } else if nz > 0.0 {
888 (0.0, normalised_angle)
889 } else {
890 (0.0, -normalised_angle)
891 }
892 } else {
893 // cosbeta == -1, beta == pi
894 // sin(0.5phi) must be non-zero, otherwise cosbeta == 1, a contradiction.
895 // sin(0.5(alpha-gamma)) = -nx*sin(0.5phi)
896 // cos(0.5(alpha-gamma)) = +ny*sin(0.5phi)
897 // We set alpha == 0 by convention.
898 // gamma then lies in [-pi, pi].
899 // lambda = 0 since beta = pi.
900 let gamma = 2.0 * nx.atan2(ny);
901
902 (0.0, gamma)
903 };
904
905 (alpha, beta, gamma)
906}