treams_core/special/
coordinates.rs

1//! Coordinate and vector-component transformations in orthonormal local frames.
2//!
3//! Upstream: `treams.special.car2cyl`, `car2sph`, `cyl2car`, `cyl2sph`, `sph2car`,
4//! `sph2cyl`, `car2pol`, `pol2car` ([`point`]) and their `v`-prefixed vector twins such
5//! as `vcar2sph` ([`vector`]), from `_coord.pyx`. Spherical coordinates are
6//! `(r, theta, phi)` with the polar angle `theta`, cylindrical ones `(rho, phi, z)`, as
7//! in treams.
8#![allow(clippy::indexing_slicing)] // Fixed Cartesian triples.
9
10use crate::{Complex, Error, Result, numerics::finite};
11
12/// Coordinate conversion; vector positions are expressed in the input system.
13///
14/// Upstream: each variant names a function of `treams.special` for [`point`]
15/// (`CarToCyl` is `car2cyl`); [`vector`] takes the `v`-prefixed one (`vcar2cyl`).
16#[derive(Clone, Copy, Debug)]
17pub enum Transform {
18    /// Cartesian (x,y,z) to cylindrical (rho,phi,z).
19    CarToCyl,
20    /// Cartesian (x,y,z) to spherical (r,theta,phi).
21    CarToSph,
22    /// Cylindrical to Cartesian.
23    CylToCar,
24    /// Cylindrical to spherical.
25    CylToSph,
26    /// Spherical to Cartesian.
27    SphToCar,
28    /// Spherical to cylindrical.
29    SphToCyl,
30    /// Cartesian (x,y) to polar (rho,phi).
31    CarToPol,
32    /// Polar to Cartesian (x,y).
33    PolToCar,
34}
35use Transform::{CarToCyl, CarToPol, CarToSph, CylToCar, CylToSph, PolToCar, SphToCar, SphToCyl};
36
37impl Transform {
38    /// Number of coordinate or vector components.
39    #[must_use]
40    pub fn dimension(self) -> usize {
41        match self {
42            Self::CarToPol | Self::PolToCar => 2,
43            _ => 3,
44        }
45    }
46}
47
48/// `sqrt(a^2 + b^2)`: a fused multiply-add where the squares stay normal, `hypot`
49/// elsewhere.
50#[inline]
51fn radius(a: f64, b: f64) -> f64 {
52    let scale = a.abs().max(b.abs());
53    if (1e-150..=1e150).contains(&scale) {
54        a.mul_add(a, b * b).sqrt()
55    } else {
56        a.hypot(b)
57    }
58}
59
60/// Checks that every component is finite.
61fn validate(position: [f64; 3]) -> Result<()> {
62    if position.iter().any(|v| !v.is_finite()) {
63        return Err(Error::InvalidInput("coordinates must be finite".into()));
64    }
65    Ok(())
66}
67
68/// Cylindrical `(rho, phi, z)` of Cartesian `(x, y, z)`.
69#[inline]
70fn cylindrical_from_cartesian([a, b, c]: [f64; 3]) -> [f64; 3] {
71    [radius(a, b), b.atan2(a), c]
72}
73
74/// Cartesian `(x, y, z)` of cylindrical `(rho, phi, z)`.
75#[inline]
76fn cartesian_from_cylindrical([a, b, c]: [f64; 3]) -> [f64; 3] {
77    let (sin, cos) = b.sin_cos();
78    [a * cos, a * sin, c]
79}
80
81/// Spherical `(r, theta, phi)` of cylindrical `(rho, phi, z)`.
82#[inline]
83fn spherical_from_cylindrical([a, b, c]: [f64; 3]) -> [f64; 3] {
84    [radius(a, c), a.atan2(c), b]
85}
86
87/// Cylindrical `(rho, phi, z)` of spherical `(r, theta, phi)`.
88#[inline]
89fn cylindrical_from_spherical([a, b, c]: [f64; 3]) -> [f64; 3] {
90    let (sin, cos) = b.sin_cos();
91    [a * sin, c, a * cos]
92}
93
94/// Transform a point. Polar conversions ignore and return zero in the third slot.
95///
96/// Cartesian and spherical coordinates convert through cylindrical coordinates.
97///
98/// Upstream: `treams.special.car2cyl` and the other point conversions of
99/// [`Transform`]. Differences: non-finite coordinates give an error.
100// Always inlined: the ufunc loops pass a constant transform, which then selects
101// one chart at compile time (a call per point costs a third more).
102#[allow(clippy::inline_always)]
103#[inline(always)]
104pub fn point(position: [f64; 3], transform: Transform) -> Result<[f64; 3]> {
105    validate(position)?;
106    Ok(match transform {
107        CarToCyl => cylindrical_from_cartesian(position),
108        CarToPol => {
109            let [rho, phi, _] = cylindrical_from_cartesian(position);
110            [rho, phi, 0.0]
111        }
112        CylToCar => cartesian_from_cylindrical(position),
113        PolToCar => {
114            let [x, y, _] = cartesian_from_cylindrical(position);
115            [x, y, 0.0]
116        }
117        CylToSph => spherical_from_cylindrical(position),
118        SphToCyl => cylindrical_from_spherical(position),
119        // A cylindrical radius that overflows gives an infinite spherical radius with
120        // exact angles, as in treams.
121        CarToSph => spherical_from_cylindrical(cylindrical_from_cartesian(position)),
122        SphToCar => cartesian_from_cylindrical(cylindrical_from_spherical(position)),
123    })
124}
125
126/// The gradient with respect to the input coordinates of [`point`], given the gradient
127/// `cotangent` with respect to its output.
128///
129/// Angular derivatives are undefined on the axes; they give an error only where their
130/// `cotangent` component is nonzero.
131pub fn point_pullback(
132    position: [f64; 3],
133    transform: Transform,
134    cotangent: [f64; 3],
135) -> Result<[f64; 3]> {
136    validate(position)?;
137    validate(cotangent)?;
138    let [a, b, c] = position;
139    let undefined =
140        || Error::InvalidInput("coordinate derivative is undefined at this axis or origin".into());
141    Ok(match transform {
142        CarToCyl | CarToPol => {
143            let rho = radius(a, b);
144            if rho == 0.0 && (cotangent[0] != 0.0 || cotangent[1] != 0.0) {
145                return Err(undefined());
146            }
147            let (sp, cp) = if rho == 0.0 {
148                (0.0, 1.0)
149            } else {
150                (b / rho, a / rho)
151            };
152            let angular = if cotangent[1] == 0.0 {
153                0.0
154            } else {
155                cotangent[1] / rho
156            };
157            [
158                cotangent[0] * cp - angular * sp,
159                cotangent[0] * sp + angular * cp,
160                if matches!(transform, CarToPol) {
161                    0.0
162                } else {
163                    cotangent[2]
164                },
165            ]
166        }
167        CylToCar | PolToCar => {
168            let (sin, cos) = b.sin_cos();
169            [
170                cotangent[0] * cos + cotangent[1] * sin,
171                a * (-cotangent[0] * sin + cotangent[1] * cos),
172                if matches!(transform, PolToCar) {
173                    0.0
174                } else {
175                    cotangent[2]
176                },
177            ]
178        }
179        CylToSph => {
180            let r = radius(a, c);
181            if r == 0.0 && (cotangent[0] != 0.0 || cotangent[1] != 0.0) {
182                return Err(undefined());
183            }
184            if r == 0.0 {
185                [0.0, cotangent[2], 0.0]
186            } else {
187                [
188                    cotangent[0] * (a / r) + cotangent[1] * (c / r) / r,
189                    cotangent[2],
190                    cotangent[0] * (c / r) - cotangent[1] * (a / r) / r,
191                ]
192            }
193        }
194        SphToCyl => {
195            let (sin, cos) = b.sin_cos();
196            [
197                cotangent[0] * sin + cotangent[2] * cos,
198                a * (cotangent[0] * cos - cotangent[2] * sin),
199                cotangent[1],
200            ]
201        }
202        CarToSph => {
203            // Polar and azimuthal derivatives are undefined on the whole z axis. Check
204            // here: the intermediate cylindrical radial cotangent may underflow to zero.
205            if radius(a, b) == 0.0 && (cotangent[1] != 0.0 || cotangent[2] != 0.0) {
206                return Err(undefined());
207            }
208            let cylindrical = point(position, CarToCyl)?;
209            point_pullback(
210                position,
211                CarToCyl,
212                point_pullback(cylindrical, CylToSph, cotangent)?,
213            )?
214        }
215        SphToCar => {
216            let cylindrical = point(position, SphToCyl)?;
217            point_pullback(
218                position,
219                SphToCyl,
220                point_pullback(cylindrical, CylToCar, cotangent)?,
221            )?
222        }
223    })
224}
225
226/// The directional derivative of [`point`] along the input `tangent`.
227///
228/// On a coordinate axis or origin, directions that change an undefined radial or
229/// angular coordinate give an error. Directions confined to the axis remain valid.
230pub fn point_pushforward(
231    position: [f64; 3],
232    transform: Transform,
233    tangent: [f64; 3],
234) -> Result<[f64; 3]> {
235    validate(position)?;
236    validate(tangent)?;
237    let [a, b, c] = position;
238    let [da, db, dc] = tangent;
239    let undefined =
240        || Error::InvalidInput("coordinate derivative is undefined at this axis or origin".into());
241    Ok(match transform {
242        CarToCyl | CarToPol => {
243            let rho = radius(a, b);
244            if rho == 0.0 && (da != 0.0 || db != 0.0) {
245                return Err(undefined());
246            }
247            let (drho, dphi) = if rho == 0.0 {
248                (0.0, 0.0)
249            } else {
250                let (sin, cos) = (b / rho, a / rho);
251                (cos * da + sin * db, (cos * db - sin * da) / rho)
252            };
253            [
254                drho,
255                dphi,
256                if matches!(transform, CarToPol) {
257                    0.0
258                } else {
259                    dc
260                },
261            ]
262        }
263        CylToCar | PolToCar => {
264            let (sin, cos) = b.sin_cos();
265            [
266                cos * da - a * sin * db,
267                sin * da + a * cos * db,
268                if matches!(transform, PolToCar) {
269                    0.0
270                } else {
271                    dc
272                },
273            ]
274        }
275        CylToSph => {
276            let r = radius(a, c);
277            if r == 0.0 && (da != 0.0 || dc != 0.0) {
278                return Err(undefined());
279            }
280            if r == 0.0 {
281                [0.0, 0.0, db]
282            } else {
283                [
284                    (a / r) * da + (c / r) * dc,
285                    ((c / r) * da - (a / r) * dc) / r,
286                    db,
287                ]
288            }
289        }
290        SphToCyl => {
291            let (sin, cos) = b.sin_cos();
292            [sin * da + a * cos * db, dc, cos * da - a * sin * db]
293        }
294        CarToSph => point_pushforward(
295            point(position, CarToCyl)?,
296            CylToSph,
297            point_pushforward(position, CarToCyl, tangent)?,
298        )?,
299        SphToCar => point_pushforward(
300            point(position, SphToCyl)?,
301            CylToCar,
302            point_pushforward(position, SphToCyl, tangent)?,
303        )?,
304    })
305}
306
307/// Rotation of vector components into the output frame of `transform`, and its
308/// derivatives with respect to the (at most two) angles it depends on. Inverse
309/// transforms use the transposed matrices. Polar vectors use the first two
310/// rows/columns; no radial metric factors occur.
311#[inline]
312fn frame(position: [f64; 3], transform: Transform) -> ([[f64; 3]; 3], [[[f64; 3]; 3]; 2]) {
313    let [a, b, c] = position;
314    let inverse = matches!(transform, CylToCar | PolToCar | SphToCyl | SphToCar);
315    let zero = [[0.0; 3]; 3];
316    let (rotation, derivatives) = match transform {
317        CarToCyl | CarToPol | CylToCar | PolToCar => {
318            let phi = if inverse { b } else { b.atan2(a) };
319            let (sin, cos) = phi.sin_cos();
320            (
321                [[cos, sin, 0.0], [-sin, cos, 0.0], [0.0, 0.0, 1.0]],
322                [[[-sin, cos, 0.0], [-cos, -sin, 0.0], [0.0; 3]], zero],
323            )
324        }
325        CylToSph | SphToCyl => {
326            let theta = if inverse { b } else { a.atan2(c) };
327            let (sin, cos) = theta.sin_cos();
328            (
329                [[sin, 0.0, cos], [cos, 0.0, -sin], [0.0, 1.0, 0.0]],
330                [[[cos, 0.0, -sin], [-sin, 0.0, -cos], [0.0; 3]], zero],
331            )
332        }
333        CarToSph | SphToCar => {
334            let theta = if inverse { b } else { radius(a, b).atan2(c) };
335            let phi = if inverse { c } else { b.atan2(a) };
336            let (st, ct) = theta.sin_cos();
337            let (sp, cp) = phi.sin_cos();
338            (
339                [
340                    [st * cp, st * sp, ct],
341                    [ct * cp, ct * sp, -st],
342                    [-sp, cp, 0.0],
343                ],
344                [
345                    [[ct * cp, ct * sp, -st], [-st * cp, -st * sp, -ct], [0.0; 3]],
346                    [
347                        [-st * sp, st * cp, 0.0],
348                        [-ct * sp, ct * cp, 0.0],
349                        [-cp, -sp, 0.0],
350                    ],
351                ],
352            )
353        }
354    };
355    if inverse {
356        (transpose(rotation), derivatives.map(transpose))
357    } else {
358        (rotation, derivatives)
359    }
360}
361
362/// The transpose of a 3 x 3 matrix.
363fn transpose(matrix: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
364    std::array::from_fn(|i| std::array::from_fn(|j| matrix[j][i]))
365}
366
367/// The product of a real 3 x 3 matrix and a complex vector.
368fn apply(matrix: [[f64; 3]; 3], vector: [Complex; 3]) -> [Complex; 3] {
369    matrix.map(|row| row.into_iter().zip(vector).map(|(a, b)| a * b).sum())
370}
371
372/// Transform vector components at a point in the source coordinate system.
373///
374/// Upstream: `treams.special.vcar2cyl` and the other vector conversions of
375/// [`Transform`]. Differences: non-finite components or coordinates give an error.
376// Always inlined, as `point`: a constant transform selects one frame.
377#[allow(clippy::inline_always)]
378#[inline(always)]
379pub fn vector(
380    value: [Complex; 3],
381    position: [f64; 3],
382    transform: Transform,
383) -> Result<[Complex; 3]> {
384    validate(position)?;
385    if value.into_iter().any(|v| !finite(v)) {
386        return Err(Error::InvalidInput(
387            "vector components must be finite".into(),
388        ));
389    }
390    // Apply `frame`'s rotation without its structural zeros and ones: the dense
391    // product costs a third more per point, since x * 0.0 cannot be folded.
392    let r = frame(position, transform).0;
393    let [x, y, z] = value;
394    let row = |i: usize| x * r[i][0] + y * r[i][1];
395    Ok(match transform {
396        CarToCyl | CarToPol | CylToCar | PolToCar => [row(0), row(1), z],
397        CylToSph => [x * r[0][0] + z * r[0][2], x * r[1][0] + z * r[1][2], y],
398        SphToCyl => [row(0), z, row(2)],
399        CarToSph | SphToCar => [row(0) + z * r[0][2], row(1) + z * r[1][2], row(2)],
400    })
401}
402
403/// The gradients with respect to the vector components and the position of [`vector`],
404/// given the gradient `cotangent` with respect to its output components.
405pub fn vector_pullback(
406    value: [Complex; 3],
407    position: [f64; 3],
408    transform: Transform,
409    cotangent: [Complex; 3],
410) -> Result<([Complex; 3], [f64; 3])> {
411    validate(position)?;
412    if value.into_iter().chain(cotangent).any(|v| !finite(v)) {
413        return Err(Error::InvalidInput(
414            "vector and cotangent must be finite".into(),
415        ));
416    }
417    let (rotation, derivatives) = frame(position, transform);
418    let angles = derivatives.map(|d| {
419        apply(d, value)
420            .into_iter()
421            .zip(cotangent)
422            .map(|(d, g)| (g.conj() * d).re)
423            .sum::<f64>()
424    });
425    let point = match transform {
426        CarToCyl | CarToPol | CylToSph => {
427            point_pullback(position, transform, [0.0, angles[0], 0.0])?
428        }
429        CarToSph => point_pullback(position, transform, [0.0, angles[0], angles[1]])?,
430        CylToCar | PolToCar | SphToCyl => [0.0, angles[0], 0.0],
431        SphToCar => [0.0, angles[0], angles[1]],
432    };
433    Ok((apply(transpose(rotation), cotangent), point))
434}
435
436/// The directional derivative of [`vector`] along its components and position.
437///
438/// This applies the frame rotation to `value_tangent` and its angular derivative
439/// to `value`; no dense coordinate Jacobian is formed.
440pub fn vector_pushforward(
441    value: [Complex; 3],
442    position: [f64; 3],
443    transform: Transform,
444    value_tangent: [Complex; 3],
445    position_tangent: [f64; 3],
446) -> Result<[Complex; 3]> {
447    validate(position)?;
448    validate(position_tangent)?;
449    if value.into_iter().chain(value_tangent).any(|v| !finite(v)) {
450        return Err(Error::InvalidInput(
451            "vector and tangent must be finite".into(),
452        ));
453    }
454    let angles = match transform {
455        CarToCyl | CarToPol | CylToSph => [
456            point_pushforward(position, transform, position_tangent)?[1],
457            0.0,
458        ],
459        CarToSph => {
460            let tangent = point_pushforward(position, transform, position_tangent)?;
461            [tangent[1], tangent[2]]
462        }
463        CylToCar | PolToCar | SphToCyl => [position_tangent[1], 0.0],
464        SphToCar => [position_tangent[1], position_tangent[2]],
465    };
466    let (rotation, derivatives) = frame(position, transform);
467    let mut result = apply(rotation, value_tangent);
468    for (derivative, angle) in derivatives.into_iter().zip(angles) {
469        if angle != 0.0 {
470            for (result, angular) in result.iter_mut().zip(apply(derivative, value)) {
471                *result += angular * angle;
472            }
473        }
474    }
475    Ok(result)
476}
477
478#[cfg(test)]
479mod tests {
480    //! Charts against their inverses and compositions, and coordinate pullbacks against
481    //! finite differences.
482
483    use super::{
484        Complex, Transform, point, point_pullback, point_pushforward, radius, vector,
485        vector_pullback, vector_pushforward,
486    };
487    use crate::test_support::{
488        ALGEBRA_CASES, DEFAULT_CASES, complex, log_uniform, prop_assert_close, re_dot,
489    };
490    use Transform::{
491        CarToCyl, CarToPol, CarToSph, CylToCar, CylToSph, PolToCar, SphToCar, SphToCyl,
492    };
493    use proptest::{prelude::*, test_runner::TestCaseError};
494
495    /// Each chart and its inverse.
496    const PAIRS: [(Transform, Transform); 4] = [
497        (CarToCyl, CylToCar),
498        (CarToSph, SphToCar),
499        (CylToSph, SphToCyl),
500        (CarToPol, PolToCar),
501    ];
502
503    #[test]
504    fn coordinate_norm_preserves_extreme_scales() {
505        for scale in [
506            f64::from_bits(1),
507            1e-300,
508            1e-151,
509            1e-149,
510            1.0,
511            1e149,
512            1e151,
513            1e300,
514            f64::MAX / 2.0,
515        ] {
516            for (a, b) in [(0.0, 1.0), (1.0, 0.0), (0.3, -0.4), (1.0, -1.0)] {
517                let a = a * scale;
518                let b = b * scale;
519                let expected = a.hypot(b);
520                assert!((radius(a, b) - expected).abs() <= 3.0 * f64::EPSILON * expected);
521            }
522        }
523    }
524
525    /// Cartesian points from 1e-300 to 1e300, a quarter of them on the polar axis on
526    /// either side of the origin.
527    fn cartesian_point() -> impl Strategy<Value = [f64; 3]> {
528        let direction = prop_oneof![
529            3 => prop::array::uniform3(-2.0_f64..2.0),
530            1 => (-2.0_f64..2.0).prop_map(|z| [0.0, 0.0, z]),
531        ];
532        let scale = prop_oneof![Just(1e-300), Just(1.0), Just(1e300), log_uniform(-3.0..3.0),];
533        (direction, scale).prop_map(|(direction, scale)| direction.map(|v| v * scale))
534    }
535
536    proptest! {
537        #![proptest_config(ProptestConfig::with_cases(ALGEBRA_CASES))]
538
539        #[test]
540        fn charts_invert_compose_and_rotate_vectors_orthogonally(
541            position in cartesian_point(),
542            value in prop::array::uniform3(complex(1.0)),
543        ) {
544            check_charts(position, value)?;
545        }
546    }
547
548    proptest! {
549        #![proptest_config(ProptestConfig::with_cases(DEFAULT_CASES))]
550
551        #[test]
552        fn coordinate_and_vector_adjoints(
553            a in 0.2_f64..2.0,
554            b in -1.0_f64..1.0,
555            c in -1.0_f64..1.0,
556            value in prop::array::uniform3(complex(1.0)),
557            cotangent in prop::array::uniform3(complex(1.0)),
558        ) {
559            check_adjoints([a, b, c], value, cotangent)?;
560        }
561
562        #[test]
563        fn coordinate_and_vector_pushforwards(
564            a in 0.2_f64..2.0,
565            b in -1.0_f64..1.0,
566            c in -1.0_f64..1.0,
567            value in prop::array::uniform3(complex(1.0)),
568            cotangent in prop::array::uniform3(complex(1.0)),
569            dp in prop::array::uniform3(-0.2_f64..0.2),
570            dv in prop::array::uniform3(complex(0.2)),
571        ) {
572            check_pushforwards([a, b, c], value, cotangent, dp, dv)?;
573        }
574    }
575
576    /// Directional finite differences, the forward/reverse pairing, and inverse
577    /// coordinate charts independently check every point and vector transform.
578    fn check_pushforwards(
579        cartesian: [f64; 3],
580        value: [Complex; 3],
581        cotangent: [Complex; 3],
582        dp: [f64; 3],
583        dv: [Complex; 3],
584    ) -> Result<(), TestCaseError> {
585        let h = 1e-6;
586        for (forward, inverse) in PAIRS {
587            for (transform, other) in [(forward, inverse), (inverse, forward)] {
588                let (position, value) = input(cartesian, value, transform);
589                let planar = transform.dimension() == 2;
590                let dp = if planar { [dp[0], dp[1], 0.0] } else { dp };
591                let dv = if planar {
592                    [dv[0], dv[1], Complex::default()]
593                } else {
594                    dv
595                };
596                let g = cotangent.map(|g| g.re);
597                let tangent = point_pushforward(position, transform, dp).unwrap();
598                let rotated = vector_pushforward(value, position, transform, dv, dp).unwrap();
599                let plus = std::array::from_fn(|i| position[i] + h * dp[i]);
600                let minus = std::array::from_fn(|i| position[i] - h * dp[i]);
601                let p_plus = point(plus, transform).unwrap();
602                let p_minus = point(minus, transform).unwrap();
603                let numeric: [f64; 3] =
604                    std::array::from_fn(|i| (p_plus[i] - p_minus[i]) / (2.0 * h));
605                prop_assert_close!(tangent, numeric, 2e-8, "{transform:?}");
606                let v_plus = vector(
607                    std::array::from_fn(|i| value[i] + h * dv[i]),
608                    plus,
609                    transform,
610                )
611                .unwrap();
612                let v_minus = vector(
613                    std::array::from_fn(|i| value[i] - h * dv[i]),
614                    minus,
615                    transform,
616                )
617                .unwrap();
618                let numeric: [Complex; 3] =
619                    std::array::from_fn(|i| (v_plus[i] - v_minus[i]) / (2.0 * h));
620                prop_assert_close!(rotated, numeric, 2e-8, "{transform:?}");
621                let gp = point_pullback(position, transform, g).unwrap();
622                let dot = |a: [f64; 3], b: [f64; 3]| {
623                    a.into_iter().zip(b).map(|(a, b)| a * b).sum::<f64>()
624                };
625                prop_assert_close!(dot(tangent, g), dot(dp, gp), 1e-13, "{transform:?}");
626                let (gv, gp) = vector_pullback(value, position, transform, cotangent).unwrap();
627                prop_assert_close!(
628                    re_dot(rotated, cotangent),
629                    re_dot(dv, gv) + dot(dp, gp),
630                    1e-13,
631                    "{transform:?}"
632                );
633                let image = point(position, transform).unwrap();
634                let chained = point_pushforward(image, other, tangent).unwrap();
635                prop_assert_close!(chained, dp, 1e-13, "{transform:?}");
636                let image_value = vector(value, position, transform).unwrap();
637                let chained =
638                    vector_pushforward(image_value, image, other, rotated, tangent).unwrap();
639                prop_assert_close!(chained, dv, 1e-13, "{transform:?}");
640            }
641        }
642        Ok(())
643    }
644
645    /// The point and vector components of `transform`'s input system, restricted to
646    /// the plane for polar charts.
647    fn input(
648        position: [f64; 3],
649        value: [Complex; 3],
650        transform: Transform,
651    ) -> ([f64; 3], [Complex; 3]) {
652        let source = match transform {
653            CylToCar | CylToSph => Some(CarToCyl),
654            SphToCar | SphToCyl => Some(CarToSph),
655            PolToCar => Some(CarToPol),
656            CarToCyl | CarToSph | CarToPol => None,
657        };
658        let planar = transform.dimension() == 2;
659        let (position, value) = if planar {
660            (
661                [position[0], position[1], 0.0],
662                [value[0], value[1], Complex::default()],
663            )
664        } else {
665            (position, value)
666        };
667        match source {
668            Some(source) => (
669                point(position, source).unwrap(),
670                vector(value, position, source).unwrap(),
671            ),
672            None => (position, value),
673        }
674    }
675
676    /// Per-slot tolerances of coordinates in the output system of `transform` for
677    /// points of norm `scale`: relative for lengths and absolute for angles.
678    fn slot_tolerances(transform: Transform, scale: f64) -> [f64; 3] {
679        let length = 1e-14 * scale + f64::MIN_POSITIVE;
680        match transform {
681            CarToCyl | SphToCyl | CarToPol => [length, 1e-14, length],
682            CarToSph | CylToSph => [length, 1e-14, 1e-14],
683            CylToCar | SphToCar | PolToCar => [length; 3],
684        }
685    }
686
687    /// Every chart inverts, `CylToSph ∘ CarToCyl = CarToSph` and
688    /// `CylToCar ∘ SphToCyl = SphToCar` exactly, and vector frames are orthogonal:
689    /// they invert, preserve the norm and compose.
690    #[allow(clippy::float_cmp)] // Chart compositions are exact.
691    fn check_charts(cartesian: [f64; 3], value: [Complex; 3]) -> Result<(), TestCaseError> {
692        let scale = cartesian.iter().map(|v| v.abs()).fold(0.0, f64::max);
693        for (forward, inverse) in PAIRS {
694            for (from, to) in [(forward, inverse), (inverse, forward)] {
695                let (position, value) = input(cartesian, value, from);
696                let image = point(position, from).unwrap();
697                let back = point(image, to).unwrap();
698                let tolerances = slot_tolerances(to, scale);
699                for ((back, position), tolerance) in back.into_iter().zip(position).zip(tolerances)
700                {
701                    prop_assert_close!(back, position, tolerance, "{from:?} then {to:?}");
702                }
703                let rotated = vector(value, position, from).unwrap();
704                let norm = re_dot(value, value);
705                prop_assert_close!(re_dot(rotated, rotated), norm, 1e-14 * norm, "{from:?}");
706                let restored = vector(rotated, image, to).unwrap();
707                prop_assert_close!(restored, value, 1e-14 * norm.sqrt(), "{from:?} then {to:?}");
708            }
709        }
710        let cylindrical = point(cartesian, CarToCyl).unwrap();
711        let spherical = point(cartesian, CarToSph).unwrap();
712        prop_assert_eq!(point(cylindrical, CylToSph).unwrap(), spherical);
713        prop_assert_eq!(
714            point(point(spherical, SphToCyl).unwrap(), CylToCar).unwrap(),
715            point(spherical, SphToCar).unwrap()
716        );
717        let tolerance = 1e-14 * re_dot(value, value).sqrt();
718        let via_cylinder = vector(
719            vector(value, cartesian, CarToCyl).unwrap(),
720            cylindrical,
721            CylToSph,
722        );
723        prop_assert_close!(
724            via_cylinder.unwrap(),
725            vector(value, cartesian, CarToSph).unwrap(),
726            tolerance
727        );
728        let components = vector(value, spherical, SphToCyl).unwrap();
729        let cylinder = point(spherical, SphToCyl).unwrap();
730        let via_cylinder = vector(components, cylinder, CylToCar).unwrap();
731        prop_assert_close!(
732            via_cylinder,
733            vector(value, spherical, SphToCar).unwrap(),
734            tolerance
735        );
736        Ok(())
737    }
738
739    /// Point pullbacks match central differences and invert with their chart, and
740    /// vector pullbacks are the transposed rotation (exactly, since `vector` is linear
741    /// in its components) plus a position term matching central differences.
742    fn check_adjoints(
743        cartesian: [f64; 3],
744        value: [Complex; 3],
745        cotangent: [Complex; 3],
746    ) -> Result<(), TestCaseError> {
747        let h = 1e-6;
748        for (forward, inverse) in PAIRS {
749            for (transform, other) in [(forward, inverse), (inverse, forward)] {
750                let (position, value) = input(cartesian, value, transform);
751                let planar = transform.dimension() == 2;
752                let cotangent = if planar {
753                    [cotangent[0], cotangent[1], Complex::default()]
754                } else {
755                    cotangent
756                };
757                let g = cotangent.map(|g| g.re);
758                let point_g = point_pullback(position, transform, g).unwrap();
759                let (vector_g, position_g) =
760                    vector_pullback(value, position, transform, cotangent).unwrap();
761                let rotated = vector(value, position, transform).unwrap();
762                prop_assert_close!(
763                    re_dot(cotangent, rotated),
764                    re_dot(vector_g, value),
765                    1e-14 * (1.0 + re_dot(cotangent, cotangent) + re_dot(value, value)),
766                    "{transform:?}"
767                );
768                let image = point(position, transform).unwrap();
769                let chained = point_pullback(
770                    position,
771                    transform,
772                    point_pullback(image, other, g).unwrap(),
773                );
774                prop_assert_close!(
775                    chained.unwrap(),
776                    g,
777                    1e-13 * (1.0 + g.iter().map(|v| v.abs()).sum::<f64>()),
778                    "{transform:?}"
779                );
780                for axis in 0..transform.dimension() {
781                    let step = |t: f64| {
782                        let mut shifted = position;
783                        shifted[axis] += t * h;
784                        shifted
785                    };
786                    let numeric = (point(step(1.0), transform).unwrap().into_iter())
787                        .zip(point(step(-1.0), transform).unwrap())
788                        .zip(g)
789                        .map(|((p, m), g)| g * (p - m) / (2.0 * h))
790                        .sum::<f64>();
791                    prop_assert_close!(
792                        point_g[axis],
793                        numeric,
794                        2e-8 * (1.0 + numeric.abs()),
795                        "{transform:?}"
796                    );
797                    let numeric = re_dot(
798                        cotangent,
799                        (0..3).map(|c| {
800                            (vector(value, step(1.0), transform).unwrap()[c]
801                                - vector(value, step(-1.0), transform).unwrap()[c])
802                                / (2.0 * h)
803                        }),
804                    );
805                    prop_assert_close!(
806                        position_g[axis],
807                        numeric,
808                        2e-8 * (1.0 + numeric.abs()),
809                        "{transform:?}"
810                    );
811                }
812            }
813        }
814        Ok(())
815    }
816
817    #[test]
818    #[allow(clippy::float_cmp)] // Axis normals and zero cotangents have exact results.
819    fn coordinate_axis_derivative_contract() {
820        assert!(point_pullback([0.0, 0.0, 1.0], CarToSph, [0.0, 0.0, 1.0]).is_err());
821        // The polar cotangent divided by r underflows in the cylindrical composition,
822        // so only the explicit axis check rejects it.
823        assert!(point_pullback([0.0, 0.0, 1e300], CarToSph, [0.0, 1e-30, 0.0]).is_err());
824        assert_eq!(
825            point_pullback([0.0, 0.0, -1.0], CarToSph, [1.0, 0.0, 0.0]).unwrap(),
826            [0.0, 0.0, -1.0]
827        );
828        assert_eq!(
829            point_pullback([0.0; 3], CarToPol, [0.0; 3]).unwrap(),
830            [0.0; 3]
831        );
832        assert!(point_pushforward([0.0, 0.0, 1.0], CarToSph, [1.0, 0.0, 0.0]).is_err());
833        assert!(point_pushforward([0.0; 3], CylToSph, [0.0, 0.0, 1.0]).is_err());
834        assert_eq!(
835            point_pushforward([0.0, 0.0, -1.0], CarToSph, [0.0, 0.0, 0.7]).unwrap(),
836            [-0.7, 0.0, 0.0]
837        );
838        assert_eq!(
839            point_pushforward([0.0; 3], CarToPol, [0.0; 3]).unwrap(),
840            [0.0; 3]
841        );
842    }
843}