treams_core/
rotation.rs

1//! Multipole rotations in the z-y-z convention, with analytic Euler-angle pullbacks.
2//!
3//! [`sw_rotate`] and [`cw_rotate`] give the coefficient of one mode pair; [`sw_rotation`]
4//! and [`cw_rotation`] give the matrix between two bases. Both matrices return one
5//! [`RotationResidual`], whose pullback gives the cotangents of the Euler angles
6//! `[phi, theta, psi]`, so the two families share this module instead of living in
7//! [`crate::sw`] and [`crate::cw`].
8//!
9//! Upstream: `treams.sw.rotate`, `treams.cw.rotate` and the `treams.Rotate` operator.
10#![allow(clippy::indexing_slicing)] // Validated mode labels and complete angular blocks.
11#![allow(clippy::float_cmp)] // Exact zero rotations and exact axial labels.
12
13use crate::{
14    Complex, Error, MAX_DEGREE, Result,
15    basis::ModeLabel,
16    cw,
17    numerics::{self, finite, label_bits},
18    saved::{Reader, SavedState, Writer, invalid},
19    special::{index, ladder, pol_index, wigner_d, wigner_small_d_matrix},
20    sw,
21};
22use nalgebra::DMatrix;
23use std::collections::{BTreeMap, BTreeSet, HashMap};
24
25/// Entries `(row, column)` whose coupling keys agree, in column-major order; all
26/// other entries of a rotation vanish.
27fn coupled<K: Eq + std::hash::Hash>(rows: &[K], columns: &[K]) -> Vec<(usize, usize)> {
28    let mut groups: HashMap<&K, Vec<usize>> = HashMap::new();
29    for (i, key) in rows.iter().enumerate() {
30        groups.entry(key).or_default().push(i);
31    }
32    columns
33        .iter()
34        .enumerate()
35        .flat_map(|(j, key)| groups.get(key).into_iter().flatten().map(move |&i| (i, j)))
36        .collect()
37}
38
39fn sw_keys(modes: &[(usize, sw::Mode)]) -> Vec<(usize, i32, u8)> {
40    modes
41        .iter()
42        .map(|&(pidx, mode)| (pidx, mode.l, mode.pol))
43        .collect()
44}
45
46fn cw_keys(modes: &[(usize, cw::Mode)]) -> Vec<(usize, u64, i32, u8)> {
47    modes
48        .iter()
49        .map(|&(pidx, mode)| (pidx, label_bits(mode.kz), mode.m, mode.pol))
50        .collect()
51}
52
53/// The Wigner small-d blocks and Euler phases of a spherical rotation.
54#[derive(Debug)]
55struct Spherical {
56    /// Degree of each row.
57    degrees: Vec<i32>,
58    tables: BTreeMap<i32, DMatrix<f64>>,
59    /// Row phases `exp(-i mu phi)` and column phases `exp(-i m psi)`.
60    phases: [Vec<Complex>; 2],
61}
62
63/// What [`sw_rotation`] and [`cw_rotation`] save for their pullback: the rotation
64/// matrix, its coupled entries and, for spherical bases, the angular blocks.
65///
66/// The pullback adds the entries in order on the calling thread, so its result does not
67/// depend on the thread count.
68#[derive(Debug)]
69pub struct RotationResidual {
70    rows: Vec<i32>,
71    columns: Vec<i32>,
72    /// The coupled entries; all others vanish.
73    entries: Vec<(usize, usize)>,
74    spherical: Option<Spherical>,
75    value: DMatrix<Complex>,
76}
77
78/// Spherical Euler rotation; distinct positions, degrees and polarizations decouple.
79///
80/// Upstream: the `treams.Rotate` operator (`treams.operators.rotate`) between spherical
81/// bases.
82pub fn sw_rotation(
83    destination: &sw::Basis,
84    source: &sw::Basis,
85    angles: [f64; 3],
86) -> Result<RotationResidual> {
87    destination.validate()?;
88    source.validate()?;
89    if angles.iter().any(|a| !a.is_finite()) {
90        return Err(Error::InvalidInput("rotation angles must be finite".into()));
91    }
92    let mut tables = BTreeMap::new();
93    for &(_, mode) in &destination.modes {
94        if let std::collections::btree_map::Entry::Vacant(e) = tables.entry(mode.l) {
95            e.insert(wigner_small_d_matrix(mode.l, angles[1])?);
96        }
97    }
98    let entries = coupled(&sw_keys(&destination.modes), &sw_keys(&source.modes));
99    let orders = |basis: &sw::Basis| -> Vec<_> { basis.modes.iter().map(|(_, m)| m.m).collect() };
100    let (rows, columns) = (orders(destination), orders(source));
101    let phase = |m: i32, angle: f64| (-Complex::i() * f64::from(m) * angle).exp();
102    let spherical = Spherical {
103        degrees: destination.modes.iter().map(|(_, m)| m.l).collect(),
104        tables,
105        phases: [
106            rows.iter().map(|&mu| phase(mu, angles[0])).collect(),
107            columns.iter().map(|&m| phase(m, angles[2])).collect(),
108        ],
109    };
110    let mut value = numerics::zeros(rows.len(), columns.len())?;
111    for &(i, j) in &entries {
112        let l = spherical.degrees[i];
113        let d = spherical.tables[&l][(index(l, rows[i]), index(l, columns[j]))];
114        value[(i, j)] = spherical.phases[0][i] * spherical.phases[1][j] * d;
115    }
116    Ok(RotationResidual {
117        rows,
118        columns,
119        entries,
120        spherical: Some(spherical),
121        value,
122    })
123}
124
125/// Cylindrical rotation about z; axial labels and the transverse plane stay fixed.
126///
127/// Upstream: the `treams.Rotate` operator (`treams.operators.rotate`) between cylindrical
128/// bases.
129pub fn cw_rotation(
130    destination: &cw::Basis,
131    source: &cw::Basis,
132    angles: [f64; 3],
133) -> Result<RotationResidual> {
134    destination.validate()?;
135    source.validate()?;
136    if angles.iter().any(|a| !a.is_finite()) || angles[1] != 0.0 {
137        return Err(Error::InvalidInput(
138            "cylindrical rotation requires finite angles and theta=0".into(),
139        ));
140    }
141    let entries = coupled(&cw_keys(&destination.modes), &cw_keys(&source.modes));
142    let orders = |basis: &cw::Basis| -> Vec<_> { basis.modes.iter().map(|(_, m)| m.m).collect() };
143    let (rows, columns) = (orders(destination), orders(source));
144    let mut value = numerics::zeros(rows.len(), columns.len())?;
145    for &(i, j) in &entries {
146        value[(i, j)] = (-Complex::i() * f64::from(rows[i]) * (angles[0] + angles[2])).exp();
147    }
148    Ok(RotationResidual {
149        rows,
150        columns,
151        entries,
152        spherical: None,
153        value,
154    })
155}
156
157impl RotationResidual {
158    /// Saved-state size from spherical mode labels, without evaluating a rotation.
159    pub fn sw_state_size(
160        destination: &[(usize, sw::Mode)],
161        source: &[(usize, sw::Mode)],
162    ) -> Result<usize> {
163        for &(_, mode) in destination.iter().chain(source) {
164            mode.validate()?;
165        }
166        let degrees = destination.iter().map(|(_, mode)| mode.l).collect();
167        state_size(
168            destination.len(),
169            source.len(),
170            coupled_count(&sw_keys(destination), &sw_keys(source))?,
171            Some(&degrees),
172        )
173    }
174
175    /// Saved-state size from cylindrical mode labels, without evaluating a rotation.
176    pub fn cw_state_size(
177        destination: &[(usize, cw::Mode)],
178        source: &[(usize, cw::Mode)],
179    ) -> Result<usize> {
180        for &(_, mode) in destination.iter().chain(source) {
181            mode.validate()?;
182        }
183        state_size(
184            destination.len(),
185            source.len(),
186            coupled_count(&cw_keys(destination), &cw_keys(source))?,
187            None,
188        )
189    }
190
191    /// The rotation from the source to the destination multipole coefficients, which
192    /// the pullback reads.
193    #[must_use]
194    pub const fn value(&self) -> &DMatrix<Complex> {
195        &self.value
196    }
197
198    /// The shape of the rotation: destination and source mode counts.
199    #[must_use]
200    pub fn shape(&self) -> (usize, usize) {
201        self.value.shape()
202    }
203
204    /// Contract the Euler-angle derivatives with one real direction. Cylindrical
205    /// theta remains a fixed zero constraint and contributes no derivative.
206    pub fn pushforward(&self, angles: [f64; 3]) -> Result<DMatrix<Complex>> {
207        if angles.iter().any(|a| !a.is_finite()) {
208            return Err(Error::InvalidInput(
209                "rotation tangents must be finite".into(),
210            ));
211        }
212        let mut result = numerics::zeros(self.shape().0, self.shape().1)?;
213        for &(i, j) in &self.entries {
214            let (mu, m) = (self.rows[i], self.columns[j]);
215            result[(i, j)] = -Complex::i()
216                * self.value[(i, j)]
217                * (f64::from(mu) * angles[0] + f64::from(m) * angles[2])
218                + self.theta_derivative(i, j) * angles[1];
219        }
220        Ok(result)
221    }
222
223    /// The shared ladder rule used by the JVP and VJP.
224    fn theta_derivative(&self, i: usize, j: usize) -> Complex {
225        let Some(s) = &self.spherical else {
226            return Complex::default();
227        };
228        let (mu, m) = (self.rows[i], self.columns[j]);
229        let l = s.degrees[i];
230        let d = &s.tables[&l];
231        let column = index(l, m);
232        let raised = if mu < l {
233            ladder(l, mu) * d[(index(l, mu + 1), column)]
234        } else {
235            0.0
236        };
237        let lowered = if mu > -l {
238            ladder(l, mu - 1) * d[(index(l, mu - 1), column)]
239        } else {
240            0.0
241        };
242        s.phases[0][i] * s.phases[1][j] * (raised - lowered)
243    }
244
245    /// Euler-angle cotangents. Cylindrical theta is a fixed zero constraint.
246    pub fn pullback(&self, cotangent: &DMatrix<Complex>) -> Result<[f64; 3]> {
247        if cotangent.shape() != self.shape() || cotangent.iter().any(|&z| !finite(z)) {
248            return Err(Error::InvalidInput("invalid rotation cotangent".into()));
249        }
250        let mut result = [0.0; 3];
251        for &(i, j) in &self.entries {
252            let (mu, m) = (self.rows[i], self.columns[j]);
253            let g = cotangent[(i, j)].conj();
254            // `d/dphi D = -i mu D` and `d/dpsi D = -i m D`.
255            let paired = (g * (-Complex::i()) * self.value[(i, j)]).re;
256            result[0] += f64::from(mu) * paired;
257            result[2] += f64::from(m) * paired;
258            result[1] += (g * self.theta_derivative(i, j)).re;
259        }
260        Ok(result)
261    }
262}
263
264fn coupled_count<K: Eq + std::hash::Hash>(rows: &[K], columns: &[K]) -> Result<usize> {
265    let mut counts = HashMap::new();
266    for key in rows {
267        *counts.entry(key).or_insert(0_usize) += 1;
268    }
269    columns.iter().try_fold(0_usize, |total, key| {
270        total
271            .checked_add(*counts.get(key).unwrap_or(&0))
272            .ok_or_else(invalid)
273    })
274}
275
276fn bytes(count: usize, stride: usize) -> Result<usize> {
277    count.checked_mul(stride).ok_or_else(invalid)
278}
279
280fn table_dimension(degree: i32) -> Result<usize> {
281    if !(1..=MAX_DEGREE).contains(&degree) {
282        return Err(invalid());
283    }
284    usize::try_from(2 * degree + 1).map_err(|_| invalid())
285}
286
287fn state_size(
288    rows: usize,
289    columns: usize,
290    entries: usize,
291    degrees: Option<&BTreeSet<i32>>,
292) -> Result<usize> {
293    let labels = rows.checked_add(columns).ok_or_else(invalid)?;
294    let matrix = bytes(bytes(rows, columns)?, 16)?;
295    let mut parts = vec![25, bytes(labels, 4)?, bytes(entries, 16)?, matrix];
296    if let Some(degrees) = degrees {
297        parts.extend([bytes(rows, 4)?, bytes(labels, 16)?, 8]);
298        for &degree in degrees {
299            let dimension = table_dimension(degree)?;
300            parts.extend([4, bytes(bytes(dimension, dimension)?, 8)?]);
301        }
302    }
303    parts.into_iter().try_fold(0_usize, |total, part| {
304        total.checked_add(part).ok_or_else(invalid)
305    })
306}
307
308/// Check the entire byte layout before any residual allocation. Wigner block sizes
309/// are determined by their degree labels, so sparse blocks retain their native shape.
310fn check_state_layout(state: &[u8]) -> Result<()> {
311    let mut reader = Reader::new(state);
312    let spherical = match reader.byte()? {
313        0 => false,
314        1 => true,
315        _ => return Err(invalid()),
316    };
317    let (rows, columns, entries) = (reader.usize()?, reader.usize()?, reader.usize()?);
318    let labels = rows.checked_add(columns).ok_or_else(invalid)?;
319    reader.raw(bytes(labels, 4)?)?;
320    reader.raw(bytes(entries, 16)?)?;
321    reader.raw(bytes(bytes(rows, columns)?, 16)?)?;
322    if spherical {
323        reader.raw(bytes(rows, 4)?)?;
324        reader.raw(bytes(labels, 16)?)?;
325        for _ in 0..reader.count(4)? {
326            let dimension = table_dimension(reader.i32()?)?;
327            reader.raw(bytes(bytes(dimension, dimension)?, 8)?)?;
328        }
329    }
330    reader.finish()
331}
332
333impl SavedState for RotationResidual {
334    fn save_state(&self) -> Result<Vec<u8>> {
335        let degrees = self
336            .spherical
337            .as_ref()
338            .map(|s| s.tables.keys().copied().collect());
339        let mut writer = Writer::new(state_size(
340            self.rows.len(),
341            self.columns.len(),
342            self.entries.len(),
343            degrees.as_ref(),
344        )?);
345        writer.byte(u8::from(self.spherical.is_some()));
346        writer.usize(self.rows.len());
347        writer.usize(self.columns.len());
348        writer.usize(self.entries.len());
349        for &order in self.rows.iter().chain(&self.columns) {
350            writer.i32(order);
351        }
352        for &(row, column) in &self.entries {
353            writer.usize(row);
354            writer.usize(column);
355        }
356        for &value in &self.value {
357            writer.complex(value);
358        }
359        if let Some(s) = &self.spherical {
360            for &degree in &s.degrees {
361                writer.i32(degree);
362            }
363            for &phase in s.phases.iter().flatten() {
364                writer.complex(phase);
365            }
366            writer.usize(s.tables.len());
367            for (&degree, table) in &s.tables {
368                writer.i32(degree);
369                for &value in table {
370                    writer.f64(value);
371                }
372            }
373        }
374        Ok(writer.finish())
375    }
376
377    fn from_state(state: &[u8]) -> Result<Self> {
378        check_state_layout(state)?;
379        let mut reader = Reader::new(state);
380        let spherical = reader.byte()? == 1;
381        let (nrows, ncolumns, nentries) = (reader.usize()?, reader.usize()?, reader.usize()?);
382        let rows = read_orders(&mut reader, nrows)?;
383        let columns = read_orders(&mut reader, ncolumns)?;
384        let entries = (0..nentries)
385            .map(|_| {
386                let (i, j) = (reader.usize()?, reader.usize()?);
387                if i >= nrows || j >= ncolumns {
388                    return Err(invalid());
389                }
390                Ok((i, j))
391            })
392            .collect::<Result<Vec<_>>>()?;
393        let mut value = numerics::zeros(nrows, ncolumns)?;
394        for z in &mut value {
395            *z = reader.complex()?;
396        }
397        let spherical = if spherical {
398            let degrees = (0..nrows)
399                .map(|_| reader.i32())
400                .collect::<Result<Vec<_>>>()?;
401            let mut phases = [Vec::with_capacity(nrows), Vec::with_capacity(ncolumns)];
402            for (phase, length) in phases.iter_mut().zip([nrows, ncolumns]) {
403                for _ in 0..length {
404                    phase.push(reader.complex()?);
405                }
406            }
407            let mut tables = BTreeMap::new();
408            for _ in 0..reader.usize()? {
409                let degree = reader.i32()?;
410                let dimension = table_dimension(degree)?;
411                let values = (0..dimension * dimension)
412                    .map(|_| reader.f64())
413                    .collect::<Result<Vec<_>>>()?;
414                let table = DMatrix::from_vec(dimension, dimension, values);
415                if tables.insert(degree, table).is_some() {
416                    return Err(invalid());
417                }
418            }
419            if degrees
420                .iter()
421                .zip(&rows)
422                .any(|(l, m)| !tables.contains_key(l) || m.abs() > *l)
423                || entries.iter().any(|&(i, j)| columns[j].abs() > degrees[i])
424            {
425                return Err(invalid());
426            }
427            Some(Spherical {
428                degrees,
429                tables,
430                phases,
431            })
432        } else {
433            None
434        };
435        reader.finish()?;
436        Ok(Self {
437            rows,
438            columns,
439            entries,
440            spherical,
441            value,
442        })
443    }
444}
445
446fn read_orders(reader: &mut Reader<'_>, count: usize) -> Result<Vec<i32>> {
447    (0..count)
448        .map(|_| {
449            let order = reader.i32()?;
450            if order.unsigned_abs() > MAX_DEGREE.unsigned_abs() {
451                return Err(invalid());
452            }
453            Ok(order)
454        })
455        .collect()
456}
457
458/// Rotation coefficient of one spherical mode pair.
459///
460/// For the Euler angles `[phi, theta, psi]`, the coefficient from `source` to
461/// `destination` is the Wigner D-matrix element `D^l_(m' m)(phi, theta, psi)`
462/// ([`wigner_d`]) for equal degrees and polarizations, and zero otherwise; `m'` is the
463/// order of `destination` and `m` that of `source`.
464///
465/// Upstream: `treams.sw.rotate`.
466/// Differences: modes with `l = 0`, `l > 128` or `|m| > l` give an error, and so do
467/// non-finite angles at equal degrees and polarizations; treams returns a value or NaN for
468/// them, for example 1 at `l = 0` and zero for `|m| > l`.
469#[inline]
470pub fn sw_rotate(
471    destination: sw::Mode,
472    source: sw::Mode,
473    [phi, theta, psi]: [f64; 3],
474) -> Result<Complex> {
475    destination.validate()?;
476    source.validate()?;
477    if destination.l != source.l || destination.pol != source.pol {
478        Ok(Complex::default())
479    } else {
480        wigner_d(
481            destination.l,
482            destination.m,
483            source.m,
484            [phi.into(), theta.into(), psi.into()],
485        )
486    }
487}
488
489/// Rotation coefficient of one cylindrical mode pair about the z axis.
490///
491/// For the angle `phi`, the coefficient from `(qz, m, q)` to `(kz, mu, p)` is
492/// `exp(-i m phi)` for equal modes and zero otherwise. The polarizations `p` and `q`
493/// must be 0 or 1, the axial wavenumbers and `phi` finite, and the orders satisfy
494/// `|mu|, |m| <= 128` ([`MAX_DEGREE`]). The labels are `i64` so that every integer label
495/// gets these checks before its conversion.
496///
497/// Upstream: `treams.cw.rotate`.
498/// Differences: treams accepts orders above 128 and non-finite arguments, where this
499/// function gives an error.
500#[inline]
501pub fn cw_rotate(kz: f64, mu: i64, p: i64, qz: f64, m: i64, q: i64, phi: f64) -> Result<Complex> {
502    let p = pol_index(p)?;
503    let q = pol_index(q)?;
504    let bound = u64::from(MAX_DEGREE.unsigned_abs());
505    if !kz.is_finite()
506        || !qz.is_finite()
507        || !phi.is_finite()
508        || mu.unsigned_abs() > bound
509        || m.unsigned_abs() > bound
510    {
511        return Err(Error::InvalidInput(
512            // 128 is MAX_DEGREE.
513            "finite axial/angle arguments and |orders| <= 128 required".into(),
514        ));
515    }
516    if kz != qz || mu != m || p != q {
517        Ok(Complex::default())
518    } else {
519        let order =
520            f64::from(i32::try_from(m).map_err(|_| Error::InvalidInput("invalid order".into()))?);
521        let (sin, cos) = (-order * phi).sin_cos();
522        Ok(Complex::new(cos, sin))
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    fn check_saved_rotation(residual: &RotationResidual, size: usize) {
531        let state = residual.save_state().unwrap();
532        assert_eq!(state.len(), size);
533        let restored = RotationResidual::from_state(&state).unwrap();
534        assert_eq!(restored.save_state().unwrap(), state);
535        assert_eq!(restored.value(), residual.value());
536        let direction = [0.3, -0.4, 0.2];
537        assert_eq!(
538            restored.pushforward(direction).unwrap(),
539            residual.pushforward(direction).unwrap()
540        );
541        let cotangent = crate::test_support::patterned(residual.shape().0, residual.shape().1, 0.3);
542        assert_eq!(
543            restored.pullback(&cotangent).unwrap(),
544            residual.pullback(&cotangent).unwrap()
545        );
546        for length in 0..state.len() {
547            assert!(RotationResidual::from_state(&state[..length]).is_err());
548        }
549        let mut malformed = state.clone();
550        malformed[1..9].copy_from_slice(&u64::MAX.to_le_bytes());
551        assert!(RotationResidual::from_state(&malformed).is_err());
552        malformed = state;
553        malformed.push(0);
554        assert!(RotationResidual::from_state(&malformed).is_err());
555    }
556
557    #[test]
558    fn saved_spherical_rotation_preserves_sparse_blocks_and_derivatives() {
559        let basis = |labels: &[(usize, i32, i32, u8)]| sw::Basis {
560            modes: labels
561                .iter()
562                .map(|&(position, l, m, pol)| (position, sw::Mode { l, m, pol }))
563                .collect(),
564            positions: vec![[0.0; 3], [1.0, 0.0, 0.0]],
565        };
566        let destination = basis(&[(0, 1, 0, 0), (1, 2, 2, 1), (0, 3, -2, 0)]);
567        let source = basis(&[(0, 1, 1, 0), (1, 2, -1, 1), (0, 3, 1, 1), (0, 1, -1, 0)]);
568        let residual = sw_rotation(&destination, &source, [0.3, 0.8, -0.4]).unwrap();
569        let size = RotationResidual::sw_state_size(&destination.modes, &source.modes).unwrap();
570        check_saved_rotation(&residual, size);
571        let state = residual.save_state().unwrap();
572        let restored = RotationResidual::from_state(&state).unwrap();
573        assert_eq!(restored.entries, residual.entries);
574        let spherical = restored.spherical.unwrap();
575        assert_eq!(
576            spherical.tables.keys().copied().collect::<Vec<_>>(),
577            [1, 2, 3]
578        );
579        assert_eq!(spherical.tables[&3].shape(), (7, 7));
580    }
581
582    #[test]
583    fn saved_cylindrical_rotation_preserves_sparse_couplings_and_derivatives() {
584        let basis = |labels: &[(usize, f64, i32, u8)]| cw::Basis {
585            modes: labels
586                .iter()
587                .map(|&(position, kz, m, pol)| (position, cw::Mode { kz, m, pol }))
588                .collect(),
589            positions: vec![[0.0; 3]],
590        };
591        let destination = basis(&[(0, 0.0, -1, 0), (0, 0.2, 2, 1)]);
592        let source = basis(&[(0, -0.0, -1, 0), (0, 0.2, 1, 1), (0, 0.2, 2, 1)]);
593        let residual = cw_rotation(&destination, &source, [0.3, 0.0, -0.4]).unwrap();
594        let size = RotationResidual::cw_state_size(&destination.modes, &source.modes).unwrap();
595        check_saved_rotation(&residual, size);
596        let restored = RotationResidual::from_state(&residual.save_state().unwrap()).unwrap();
597        assert_eq!(restored.entries, [(0, 0), (1, 2)]);
598        assert!(restored.spherical.is_none());
599    }
600}