Skip to main content

fits_io/wcs/
projection.rs

1use std::error::Error;
2
3/// How a flat image plane is carried onto the celestial sphere.
4///
5/// The projection is named by the last three characters of CTYPEn, after the
6/// coordinate type and a hyphen — `RA---TAN` is right ascension under the
7/// gnomonic projection.
8///
9/// Each projection converts between the intermediate world coordinates the
10/// header's matrix produces and the *native* spherical coordinates of the
11/// projection's own frame; [`Wcs`] then rotates those onto the sky. A point the
12/// projection cannot represent — the far hemisphere under TAN, say — comes back
13/// as `NaN` rather than as a plausible coordinate in the wrong place.
14///
15/// [`Wcs`]: crate::wcs::Wcs
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Projection {
18    /// No projection code: the intermediate coordinates *are* the world
19    /// coordinates, offset from the reference value. This is what a plain
20    /// `LINEAR`, `PIXEL` or wavelength axis uses.
21    Linear,
22    /// `TAN`, the gnomonic projection. Straight lines on the sky stay straight
23    /// on the plane, which is what an ordinary telescope with a flat detector
24    /// produces, and by far the most common projection in astronomy images.
25    Gnomonic,
26    /// `SIN`, the orthographic projection: the sphere as seen from infinitely
27    /// far away. Radio interferometers image in it.
28    Orthographic,
29    /// `STG`, the stereographic projection, which preserves angles.
30    Stereographic,
31    /// `ARC`, the zenithal equidistant projection, where radius from the
32    /// reference point is the angle from it. Schmidt plates and many all-sky
33    /// cameras use it.
34    ZenithalEquidistant,
35    /// `ZEA`, the zenithal equal-area projection, which preserves area.
36    ZenithalEqualArea,
37    /// `CAR`, the plate carrée: longitude and latitude used directly as
38    /// rectangular coordinates.
39    PlateCarree,
40    /// `MER`, the Mercator projection.
41    Mercator,
42    /// `CEA`, the cylindrical equal-area projection, whose standard parallel is
43    /// set by `PV2_1`.
44    CylindricalEqualArea,
45    /// `AIT`, the Hammer-Aitoff projection, an equal-area whole-sky projection.
46    /// All-sky maps are usually drawn in it.
47    HammerAitoff,
48    /// `MOL`, the Mollweide projection, another equal-area whole-sky one.
49    Mollweide,
50}
51
52/// The parameters some projections take from the header's `PVi_j` cards.
53///
54/// Only a few projections need one, and the rest ignore what is here.
55#[derive(Debug, Clone, Copy, PartialEq, Default)]
56pub(crate) struct ProjectionParams {
57    /// `PV2_1`: the standard parallel of `CEA`, in the units the projection
58    /// defines for it.
59    pub cea_lambda: Option<f64>,
60}
61
62/// How far the tangent plane is from the sphere's centre, in degrees per radian.
63const DEGREES_PER_RADIAN: f64 = 180.0 / std::f64::consts::PI;
64
65impl Projection {
66    /// Reads the projection out of a CTYPEn card.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error for a projection this crate does not implement, rather
71    /// than silently falling back to a linear mapping — a wrong projection
72    /// yields plausible coordinates that are quietly in the wrong place.
73    pub fn from_ctype(ctype: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
74        let ctype = ctype.trim();
75
76        // The projection code is the part after the last hyphen, and a CTYPEn
77        // with no hyphen names no projection at all.
78        let Some((_, code)) = ctype.rsplit_once('-') else {
79            return Ok(Projection::Linear);
80        };
81
82        match code {
83            "" | "LINEAR" | "PIXEL" => Ok(Projection::Linear),
84            "TAN" | "TPV" => Ok(Projection::Gnomonic),
85            "SIN" => Ok(Projection::Orthographic),
86            "STG" => Ok(Projection::Stereographic),
87            "ARC" => Ok(Projection::ZenithalEquidistant),
88            "ZEA" => Ok(Projection::ZenithalEqualArea),
89            "CAR" => Ok(Projection::PlateCarree),
90            "MER" => Ok(Projection::Mercator),
91            "CEA" => Ok(Projection::CylindricalEqualArea),
92            "AIT" => Ok(Projection::HammerAitoff),
93            "MOL" => Ok(Projection::Mollweide),
94            other => Err(From::from(format!(
95                "Unsupported WCS projection in CTYPE {:?}: {} is not implemented",
96                ctype, other
97            ))),
98        }
99    }
100
101    /// The projection's three-letter code, or `LINEAR` for no projection at all.
102    pub fn code(self) -> &'static str {
103        match self {
104            Projection::Linear => "LINEAR",
105            Projection::Gnomonic => "TAN",
106            Projection::Orthographic => "SIN",
107            Projection::Stereographic => "STG",
108            Projection::ZenithalEquidistant => "ARC",
109            Projection::ZenithalEqualArea => "ZEA",
110            Projection::PlateCarree => "CAR",
111            Projection::Mercator => "MER",
112            Projection::CylindricalEqualArea => "CEA",
113            Projection::HammerAitoff => "AIT",
114            Projection::Mollweide => "MOL",
115        }
116    }
117
118    /// The native coordinates of the point the reference pixel sits at, in
119    /// degrees.
120    ///
121    /// The zenithal projections are drawn about their pole, so their reference
122    /// point is the native pole; every other projection here is drawn about its
123    /// origin.
124    pub(crate) fn fiducial(self) -> (f64, f64) {
125        if self.is_zenithal() {
126            (0.0, 90.0)
127        } else {
128            (0.0, 0.0)
129        }
130    }
131
132    /// Whether this projection is drawn about a pole rather than an origin.
133    fn is_zenithal(self) -> bool {
134        matches!(
135            self,
136            Projection::Gnomonic
137                | Projection::Orthographic
138                | Projection::Stereographic
139                | Projection::ZenithalEquidistant
140                | Projection::ZenithalEqualArea
141        )
142    }
143
144    /// The native spherical coordinates `(phi, theta)` of a point at the
145    /// intermediate world coordinates `(x, y)`, all in degrees.
146    ///
147    /// A point outside what the projection can represent comes back as `NaN`.
148    pub(crate) fn to_native(self, x: f64, y: f64, params: &ProjectionParams) -> (f64, f64) {
149        if self.is_zenithal() {
150            let radius = x.hypot(y);
151            // Native longitude is measured from the negative y axis, which is
152            // what puts north up in an image with no rotation.
153            let phi = if radius == 0.0 {
154                0.0
155            } else {
156                x.atan2(-y).to_degrees()
157            };
158
159            return (phi, self.zenithal_theta(radius));
160        }
161
162        match self {
163            Projection::PlateCarree => (x, y),
164            Projection::Mercator => (
165                x,
166                2.0 * (y / DEGREES_PER_RADIAN).exp().atan().to_degrees() - 90.0,
167            ),
168            Projection::CylindricalEqualArea => {
169                let lambda = params.cea_lambda.unwrap_or(1.0);
170                let sine = lambda * y / DEGREES_PER_RADIAN;
171                (x, arcsine(sine))
172            }
173            Projection::HammerAitoff => {
174                let (x, y) = (x / DEGREES_PER_RADIAN, y / DEGREES_PER_RADIAN);
175                let inside = 1.0 - (x / 4.0).powi(2) - (y / 2.0).powi(2);
176
177                if inside < 0.0 {
178                    return (f64::NAN, f64::NAN);
179                }
180
181                let z = inside.sqrt();
182                let phi = 2.0 * (z * x / 2.0).atan2(2.0 * z * z - 1.0);
183
184                (phi.to_degrees(), arcsine(y * z))
185            }
186            Projection::Mollweide => {
187                let (x, y) = (x / DEGREES_PER_RADIAN, y / DEGREES_PER_RADIAN);
188                let root_two = std::f64::consts::SQRT_2;
189
190                let sine = y / root_two;
191                if !(-1.0..=1.0).contains(&sine) {
192                    return (f64::NAN, f64::NAN);
193                }
194
195                let gamma = sine.asin();
196                let cosine = gamma.cos();
197                if cosine == 0.0 {
198                    // The poles, where every longitude meets.
199                    return (0.0, 90.0_f64.copysign(y));
200                }
201
202                let phi = std::f64::consts::PI * x / (2.0 * root_two * cosine);
203                let theta = arcsine((2.0 * gamma + (2.0 * gamma).sin()) / std::f64::consts::PI);
204
205                (phi.to_degrees(), theta)
206            }
207            // The zenithal projections are handled above, and a linear axis
208            // never reaches here.
209            Projection::Linear
210            | Projection::Gnomonic
211            | Projection::Orthographic
212            | Projection::Stereographic
213            | Projection::ZenithalEquidistant
214            | Projection::ZenithalEqualArea => (x, y),
215        }
216    }
217
218    /// The intermediate world coordinates of a native spherical position, the
219    /// inverse of [`Projection::to_native`].
220    #[allow(clippy::wrong_self_convention)]
221    pub(crate) fn from_native(self, phi: f64, theta: f64, params: &ProjectionParams) -> (f64, f64) {
222        if self.is_zenithal() {
223            let radius = self.zenithal_radius(theta);
224            let (sin, cos) = phi.to_radians().sin_cos();
225
226            return (radius * sin, -radius * cos);
227        }
228
229        match self {
230            Projection::PlateCarree => (phi, theta),
231            Projection::Mercator => {
232                let half = (45.0 + theta / 2.0).to_radians();
233                (phi, DEGREES_PER_RADIAN * half.tan().ln())
234            }
235            Projection::CylindricalEqualArea => {
236                let lambda = params.cea_lambda.unwrap_or(1.0);
237                (phi, DEGREES_PER_RADIAN * theta.to_radians().sin() / lambda)
238            }
239            Projection::HammerAitoff => {
240                let (phi, theta) = (phi.to_radians(), theta.to_radians());
241                let denominator = 1.0 + theta.cos() * (phi / 2.0).cos();
242
243                if denominator <= 0.0 {
244                    return (f64::NAN, f64::NAN);
245                }
246
247                let gamma = (2.0 / denominator).sqrt();
248
249                (
250                    DEGREES_PER_RADIAN * 2.0 * gamma * theta.cos() * (phi / 2.0).sin(),
251                    DEGREES_PER_RADIAN * gamma * theta.sin(),
252                )
253            }
254            Projection::Mollweide => {
255                let (phi, theta) = (phi.to_radians(), theta.to_radians());
256                let gamma = mollweide_parametric(theta);
257                let root_two = std::f64::consts::SQRT_2;
258
259                (
260                    DEGREES_PER_RADIAN * 2.0 * root_two * phi * gamma.cos() / std::f64::consts::PI,
261                    DEGREES_PER_RADIAN * root_two * gamma.sin(),
262                )
263            }
264            Projection::Linear
265            | Projection::Gnomonic
266            | Projection::Orthographic
267            | Projection::Stereographic
268            | Projection::ZenithalEquidistant
269            | Projection::ZenithalEqualArea => (phi, theta),
270        }
271    }
272
273    /// The native latitude a zenithal projection puts `radius` degrees from its
274    /// pole.
275    fn zenithal_theta(self, radius: f64) -> f64 {
276        match self {
277            // The tangent plane touches the sphere at the pole, so the radius
278            // is the cotangent of the latitude.
279            Projection::Gnomonic => DEGREES_PER_RADIAN.atan2(radius).to_degrees(),
280            Projection::Orthographic => arccosine(radius / DEGREES_PER_RADIAN),
281            Projection::Stereographic => {
282                90.0 - 2.0 * (radius / (2.0 * DEGREES_PER_RADIAN)).atan().to_degrees()
283            }
284            Projection::ZenithalEquidistant => 90.0 - radius,
285            Projection::ZenithalEqualArea => {
286                90.0 - 2.0 * arcsine(radius / (2.0 * DEGREES_PER_RADIAN))
287            }
288            _ => f64::NAN,
289        }
290    }
291
292    /// How far from its pole a zenithal projection puts native latitude
293    /// `theta`, in degrees.
294    fn zenithal_radius(self, theta: f64) -> f64 {
295        let theta = theta.to_radians();
296
297        match self {
298            Projection::Gnomonic => {
299                let tan = theta.tan();
300                // The equator and the far hemisphere have no gnomonic image at
301                // all: the ray through them never meets the tangent plane.
302                if tan <= 0.0 {
303                    f64::NAN
304                } else {
305                    DEGREES_PER_RADIAN / tan
306                }
307            }
308            Projection::Orthographic => {
309                if theta < 0.0 {
310                    f64::NAN
311                } else {
312                    DEGREES_PER_RADIAN * theta.cos()
313                }
314            }
315            Projection::Stereographic => {
316                let half = (std::f64::consts::FRAC_PI_4 - theta / 2.0).tan();
317                2.0 * DEGREES_PER_RADIAN * half
318            }
319            Projection::ZenithalEquidistant => 90.0 - theta.to_degrees(),
320            Projection::ZenithalEqualArea => {
321                2.0 * DEGREES_PER_RADIAN * (std::f64::consts::FRAC_PI_4 - theta / 2.0).sin()
322            }
323            _ => f64::NAN,
324        }
325    }
326}
327
328/// `asin` in degrees, giving `NaN` outside the domain rather than clamping — a
329/// clamped value is a coordinate in the wrong place.
330fn arcsine(value: f64) -> f64 {
331    if !(-1.0..=1.0).contains(&value) {
332        f64::NAN
333    } else {
334        value.asin().to_degrees()
335    }
336}
337
338/// `acos` in degrees, with the same treatment of the domain.
339fn arccosine(value: f64) -> f64 {
340    if !(-1.0..=1.0).contains(&value) {
341        f64::NAN
342    } else {
343        value.acos().to_degrees()
344    }
345}
346
347/// Solves `2y + sin 2y = pi sin(theta)` for the parametric latitude Mollweide
348/// is drawn in.
349///
350/// The equation has no closed form, so it is solved by Newton's method, which
351/// converges in a handful of steps everywhere but the poles.
352fn mollweide_parametric(theta: f64) -> f64 {
353    let target = std::f64::consts::PI * theta.sin();
354
355    // At the poles the equation is satisfied exactly by the pole itself, where
356    // the derivative vanishes and Newton's method would not move.
357    if (theta.abs() - std::f64::consts::FRAC_PI_2).abs() < 1e-12 {
358        return std::f64::consts::FRAC_PI_2.copysign(theta);
359    }
360
361    let mut gamma = theta;
362    for _ in 0..32 {
363        let residual = 2.0 * gamma + (2.0 * gamma).sin() - target;
364        let derivative = 2.0 + 2.0 * (2.0 * gamma).cos();
365
366        if derivative.abs() < 1e-12 {
367            break;
368        }
369
370        let step = residual / derivative;
371        gamma -= step;
372
373        if step.abs() < 1e-14 {
374            break;
375        }
376    }
377
378    gamma
379}
380
381#[cfg(test)]
382mod tests {
383    use super::{Projection, ProjectionParams};
384
385    /// Every projection that carries a sphere, with the native latitude range
386    /// it can represent.
387    const SPHERICAL: [Projection; 10] = [
388        Projection::Gnomonic,
389        Projection::Orthographic,
390        Projection::Stereographic,
391        Projection::ZenithalEquidistant,
392        Projection::ZenithalEqualArea,
393        Projection::PlateCarree,
394        Projection::Mercator,
395        Projection::CylindricalEqualArea,
396        Projection::HammerAitoff,
397        Projection::Mollweide,
398    ];
399
400    #[test]
401    fn a_ctype_names_its_projection_in_its_last_field() {
402        assert_eq!(
403            Projection::from_ctype("RA---TAN").unwrap(),
404            Projection::Gnomonic
405        );
406        assert_eq!(
407            Projection::from_ctype("DEC--TAN").unwrap(),
408            Projection::Gnomonic
409        );
410        assert_eq!(
411            Projection::from_ctype("LINEAR").unwrap(),
412            Projection::Linear
413        );
414        assert_eq!(
415            Projection::from_ctype("RA---SIN").unwrap(),
416            Projection::Orthographic
417        );
418        assert_eq!(
419            Projection::from_ctype("GLON-AIT").unwrap(),
420            Projection::HammerAitoff
421        );
422    }
423
424    #[test]
425    fn a_distorted_gnomonic_axis_is_still_gnomonic() {
426        // TPV is TAN with a polynomial correction, which the distortion applies
427        // before the projection sees the coordinates.
428        assert_eq!(
429            Projection::from_ctype("RA---TPV").unwrap(),
430            Projection::Gnomonic
431        );
432    }
433
434    #[test]
435    fn an_unimplemented_projection_is_an_error_rather_than_a_wrong_answer() {
436        // Falling back to a linear mapping here would hand back coordinates that
437        // look entirely reasonable and are in the wrong place.
438        let error =
439            Projection::from_ctype("RA---COE").expect_err("the COE projection is not implemented");
440
441        assert!(error.to_string().contains("COE"), "got: {error}");
442    }
443
444    #[test]
445    fn every_projection_round_trips_between_the_plane_and_its_native_sphere() {
446        let params = ProjectionParams::default();
447
448        for projection in SPHERICAL {
449            for phi in [-150.0, -30.0, 0.0, 45.0, 170.0] {
450                for theta in [-60.0, -10.0, 0.0, 25.0, 80.0] {
451                    let (x, y) = projection.from_native(phi, theta, &params);
452
453                    // A projection that cannot draw this point says so, and
454                    // there is nothing to round trip.
455                    if x.is_nan() || y.is_nan() {
456                        continue;
457                    }
458
459                    let (back_phi, back_theta) = projection.to_native(x, y, &params);
460
461                    assert!(
462                        (back_phi - phi).abs() < 1e-8 && (back_theta - theta).abs() < 1e-8,
463                        "{:?} took ({phi}, {theta}) to ({x}, {y}) and back to ({back_phi}, \
464                         {back_theta})",
465                        projection
466                    );
467                }
468            }
469        }
470    }
471
472    #[test]
473    fn the_fiducial_point_is_at_the_origin_of_the_plane() {
474        let params = ProjectionParams::default();
475
476        for projection in SPHERICAL {
477            let (phi, theta) = projection.fiducial();
478            let (x, y) = projection.from_native(phi, theta, &params);
479
480            assert!(
481                x.abs() < 1e-9 && y.abs() < 1e-9,
482                "{:?} puts its reference point at ({x}, {y})",
483                projection
484            );
485        }
486    }
487
488    #[test]
489    fn a_point_the_projection_cannot_draw_is_not_quietly_moved() {
490        let params = ProjectionParams::default();
491
492        // The far hemisphere has no gnomonic image: every ray through it runs
493        // away from the tangent plane.
494        let (x, y) = Projection::Gnomonic.from_native(0.0, -30.0, &params);
495        assert!(x.is_nan() && y.is_nan(), "got ({x}, {y})");
496
497        // Orthographic sees only the near hemisphere.
498        let (x, y) = Projection::Orthographic.from_native(0.0, -1.0, &params);
499        assert!(x.is_nan() && y.is_nan(), "got ({x}, {y})");
500
501        // Outside the Hammer-Aitoff ellipse there is no sky at all.
502        let (phi, theta) = Projection::HammerAitoff.to_native(180.0, 120.0, &params);
503        assert!(phi.is_nan() && theta.is_nan(), "got ({phi}, {theta})");
504    }
505
506    #[test]
507    fn the_plate_carree_is_longitude_and_latitude_unchanged() {
508        let params = ProjectionParams::default();
509
510        assert_eq!(
511            Projection::PlateCarree.to_native(30.0, -20.0, &params),
512            (30.0, -20.0)
513        );
514    }
515}