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}