Skip to main content

fits_io/wcs/
wcs.rs

1use crate::header::Header;
2use crate::wcs::Projection;
3use crate::wcs::distortion::{Distortion, number};
4use crate::wcs::projection::ProjectionParams;
5use crate::wcs::spherical::Rotation;
6use std::error::Error;
7
8/// The world coordinate system of an image.
9///
10/// Built from a header's CRPIXn, CRVALn, CDELTn, CDi_j, CROTAn and CTYPEn cards,
11/// this converts between pixel positions and the sky coordinates they fall on.
12/// The first two axes carry the projection, and [`Wcs::pixel_to_world`] works on
13/// those; a cube's remaining axes are read one at a time through
14/// [`Wcs::pixel_to_world_axis`].
15///
16/// Pixel coordinates follow the FITS convention: the centre of the first pixel
17/// is `(1.0, 1.0)`, not `(0.0, 0.0)`. Use [`Wcs::pixel_to_world_indexed`] and
18/// [`Wcs::world_to_pixel_indexed`] to work in zero-based array indices instead.
19///
20/// A pixel the projection cannot place on the sky — a corner of an all-sky
21/// image falls outside the sky itself — comes back as `NaN` rather than as a
22/// plausible coordinate somewhere else.
23#[derive(Debug, Clone, PartialEq)]
24pub struct Wcs {
25    reference_pixel: (f64, f64),
26    reference_value: (f64, f64),
27    /// Row-major, taking pixel offsets to intermediate world coordinates.
28    transform: [[f64; 2]; 2],
29    /// Its inverse, worked out once so that `world_to_pixel` need not.
30    inverse: [[f64; 2]; 2],
31    projection: Projection,
32    /// How the projection's own sphere sits against the celestial one. A linear
33    /// system has no sphere and no rotation.
34    rotation: Option<Rotation>,
35    params: ProjectionParams,
36    distortion: Distortion,
37    /// Every axis the header describes, including the two the projection uses.
38    axes: Vec<Axis>,
39}
40
41/// One axis of the coordinate system, as its own CRPIXn, CRVALn and CDELTn
42/// describe it.
43#[derive(Debug, Clone, PartialEq)]
44struct Axis {
45    reference_pixel: f64,
46    reference_value: f64,
47    delta: f64,
48    ctype: Option<String>,
49    cunit: Option<String>,
50}
51
52impl Wcs {
53    /// Reads the world coordinate system out of `header`.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error when the header carries no usable WCS — CRPIXn and
58    /// CRVALn are both required — when it names a projection this crate does not
59    /// implement, or when its celestial axes are given in units other than
60    /// degrees.
61    pub fn from_header(header: &Header) -> Result<Self, Box<dyn Error + Send + Sync>> {
62        let axis = |name: &str, value: Option<f64>, index: usize| {
63            value.ok_or_else(|| {
64                format!(
65                    "Header has no {}{} card, so it carries no world coordinate system",
66                    name,
67                    index + 1
68                )
69            })
70        };
71
72        let reference_pixel = (
73            axis("CRPIX", header.coordinate_reference_pixel(0), 0)?,
74            axis("CRPIX", header.coordinate_reference_pixel(1), 1)?,
75        );
76        let reference_value = (
77            axis("CRVAL", header.coordinate_value_at_pixel(0), 0)?,
78            axis("CRVAL", header.coordinate_value_at_pixel(1), 1)?,
79        );
80
81        let transform = transform_from(header);
82
83        // A matrix that cannot be inverted maps every pixel onto the same point,
84        // so there is no coordinate system here to speak of.
85        let inverse = invert(transform).ok_or_else(|| {
86            format!(
87                "The header's coordinate transformation matrix {:?} cannot be inverted, so it \
88                 describes no usable world coordinate system",
89                transform
90            )
91        })?;
92
93        // Both axes must agree on the projection; the first one names it.
94        let ctype = header.coordinate_axis_name(0).map(str::to_string);
95        let projection = match ctype.as_deref() {
96            Some(ctype) => Projection::from_ctype(projection_code(ctype))?,
97            None => Projection::Linear,
98        };
99
100        let rotation = if projection == Projection::Linear {
101            None
102        } else {
103            for index in 0..2 {
104                degrees(header, index)?;
105            }
106
107            Some(Rotation::new(
108                reference_value,
109                projection.fiducial(),
110                number(header, "LONPOLE").or_else(|| number(header, "PV1_3")),
111                number(header, "LATPOLE").or_else(|| number(header, "PV1_4")),
112            )?)
113        };
114
115        let params = ProjectionParams {
116            cea_lambda: number(header, "PV2_1"),
117        };
118
119        Ok(Self {
120            reference_pixel,
121            reference_value,
122            transform,
123            inverse,
124            projection,
125            rotation,
126            params,
127            distortion: Distortion::from_header(header, ctype.as_deref()),
128            axes: axes_of(header),
129        })
130    }
131
132    /// The matrix taking pixel offsets to intermediate world coordinates,
133    /// row-major.
134    ///
135    /// Whichever of the three conventions the header used — a CDi_j matrix, a
136    /// PCi_j matrix with CDELTn, or CDELTn with CROTAn — this is what it came
137    /// to.
138    pub fn transform(&self) -> [[f64; 2]; 2] {
139        self.transform
140    }
141
142    /// The projection this system uses.
143    pub fn projection(&self) -> Projection {
144        self.projection
145    }
146
147    /// Whether the first two axes describe a position on the sky, as opposed to
148    /// a plain linear pair.
149    pub fn is_celestial(&self) -> bool {
150        self.rotation.is_some()
151    }
152
153    /// The celestial coordinates of the projection's own pole, in degrees, for a
154    /// system that has one.
155    ///
156    /// For the zenithal projections — TAN among them — this is the reference
157    /// point itself. For the whole-sky projections it is a quarter turn away
158    /// from it, which is what an all-sky map is drawn about.
159    pub fn celestial_pole(&self) -> Option<(f64, f64)> {
160        self.rotation.as_ref().map(Rotation::pole)
161    }
162
163    /// How many axes the header describes.
164    pub fn axis_count(&self) -> usize {
165        self.axes.len()
166    }
167
168    /// The CTYPEn of an axis, counting from zero.
169    pub fn axis_type(&self, axis: usize) -> Option<&str> {
170        self.axes.get(axis)?.ctype.as_deref()
171    }
172
173    /// The CUNITn of an axis, counting from zero — the unit its world
174    /// coordinates are in.
175    pub fn axis_unit(&self, axis: usize) -> Option<&str> {
176        self.axes.get(axis)?.cunit.as_deref()
177    }
178
179    /// The world coordinate at a pixel along one axis, counting from zero.
180    ///
181    /// This is how a cube's third axis is read: the wavelength, frequency or
182    /// time a plane was taken at. `pixel` is one-based, as FITS counts pixels.
183    ///
184    /// A `-LOG` axis is read as the standard defines it, with the coordinate
185    /// growing geometrically rather than by a fixed step. The two celestial
186    /// axes have no coordinate of their own — they only mean anything together —
187    /// so they come back `None`; [`Wcs::pixel_to_world`] is what reads those.
188    pub fn pixel_to_world_axis(&self, axis: usize, pixel: f64) -> Option<f64> {
189        if self.is_celestial() && axis < 2 {
190            return None;
191        }
192
193        let described = self.axes.get(axis)?;
194        let offset = described.delta * (pixel - described.reference_pixel);
195
196        Some(if self.is_logarithmic(axis) {
197            described.reference_value * (offset / described.reference_value).exp()
198        } else {
199            described.reference_value + offset
200        })
201    }
202
203    /// The pixel a world coordinate falls on along one axis, the inverse of
204    /// [`Wcs::pixel_to_world_axis`].
205    pub fn world_to_pixel_axis(&self, axis: usize, world: f64) -> Option<f64> {
206        if self.is_celestial() && axis < 2 {
207            return None;
208        }
209
210        let described = self.axes.get(axis)?;
211
212        if described.delta == 0.0 {
213            return None;
214        }
215
216        let offset = if self.is_logarithmic(axis) {
217            described.reference_value * (world / described.reference_value).ln()
218        } else {
219            world - described.reference_value
220        };
221
222        Some(described.reference_pixel + offset / described.delta)
223    }
224
225    /// Whether an axis grows geometrically, as its CTYPEn `-LOG` code says.
226    fn is_logarithmic(&self, axis: usize) -> bool {
227        self.axes
228            .get(axis)
229            .and_then(|axis| axis.ctype.as_deref())
230            .is_some_and(|ctype| ctype.trim().ends_with("-LOG"))
231    }
232
233    /// The sky coordinate at a pixel, in degrees.
234    ///
235    /// `pixel` is one-based, as FITS counts pixels.
236    pub fn pixel_to_world(&self, pixel: (f64, f64)) -> (f64, f64) {
237        let intermediate = self.pixel_to_intermediate(pixel);
238
239        let Some(rotation) = &self.rotation else {
240            return (
241                self.reference_value.0 + intermediate.0,
242                self.reference_value.1 + intermediate.1,
243            );
244        };
245
246        let (phi, theta) = self
247            .projection
248            .to_native(intermediate.0, intermediate.1, &self.params);
249
250        rotation.to_celestial(phi, theta)
251    }
252
253    /// The pixel a sky coordinate falls on, in degrees in and one-based pixels
254    /// out.
255    pub fn world_to_pixel(&self, world: (f64, f64)) -> (f64, f64) {
256        let intermediate = match &self.rotation {
257            None => (
258                world.0 - self.reference_value.0,
259                world.1 - self.reference_value.1,
260            ),
261            Some(rotation) => {
262                let (phi, theta) = rotation.to_native(world.0, world.1);
263                self.projection.from_native(phi, theta, &self.params)
264            }
265        };
266
267        self.intermediate_to_pixel(intermediate)
268    }
269
270    /// As [`Wcs::pixel_to_world`], but taking a zero-based array index.
271    pub fn pixel_to_world_indexed(&self, index: (u32, u32)) -> (f64, f64) {
272        self.pixel_to_world((index.0 as f64 + 1.0, index.1 as f64 + 1.0))
273    }
274
275    /// As [`Wcs::world_to_pixel`], but returning a zero-based array index.
276    ///
277    /// The index is rounded to the nearest pixel, and `None` when the
278    /// coordinate falls outside the `width` by `height` image.
279    pub fn world_to_pixel_indexed(
280        &self,
281        world: (f64, f64),
282        width: u32,
283        height: u32,
284    ) -> Option<(u32, u32)> {
285        let (x, y) = self.world_to_pixel(world);
286        let x = (x - 1.0).round();
287        let y = (y - 1.0).round();
288
289        if !x.is_finite() || !y.is_finite() || x < 0.0 || y < 0.0 {
290            return None;
291        }
292
293        let (x, y) = (x as u32, y as u32);
294        (x < width && y < height).then_some((x, y))
295    }
296
297    /// Pixel offsets from the reference pixel, put through the transformation
298    /// matrix into the intermediate world coordinates the projection works in.
299    fn pixel_to_intermediate(&self, pixel: (f64, f64)) -> (f64, f64) {
300        let offset = (
301            pixel.0 - self.reference_pixel.0,
302            pixel.1 - self.reference_pixel.1,
303        );
304
305        // SIP corrects the pixel offsets, TPV the coordinates the matrix
306        // produces, so each sits on its own side of it.
307        let corrected = self.distortion.correct_pixel(offset);
308
309        self.distortion
310            .correct_intermediate(apply(self.transform, corrected))
311    }
312
313    /// The inverse of [`Wcs::pixel_to_intermediate`].
314    fn intermediate_to_pixel(&self, intermediate: (f64, f64)) -> (f64, f64) {
315        let undistorted = self.distortion.uncorrect_intermediate(intermediate);
316        let offset = self
317            .distortion
318            .uncorrect_pixel(apply(self.inverse, undistorted));
319
320        (
321            self.reference_pixel.0 + offset.0,
322            self.reference_pixel.1 + offset.1,
323        )
324    }
325}
326
327/// The part of a CTYPEn that names the projection, with any distortion code
328/// taken off the end.
329///
330/// `RA---TAN-SIP` is the gnomonic projection with a polynomial correction, not a
331/// projection called SIP.
332fn projection_code(ctype: &str) -> &str {
333    ctype.trim().strip_suffix("-SIP").unwrap_or(ctype.trim())
334}
335
336/// Checks that a celestial axis is given in degrees.
337///
338/// CDELTn and CRVALn mean nothing without their unit, and every formula here is
339/// in degrees. A header measuring its axis in arcseconds and being read as
340/// degrees is wrong by a factor of 3600.
341fn degrees(header: &Header, index: usize) -> Result<(), Box<dyn Error + Send + Sync>> {
342    let Some(unit) = string(header, &format!("CUNIT{}", index + 1)) else {
343        // No CUNITn at all means degrees, which is what the standard says.
344        return Ok(());
345    };
346
347    match unit.trim() {
348        "deg" | "degree" | "degrees" | "" => Ok(()),
349        other => Err(format!(
350            "The celestial axis CUNIT{} is {:?}, and this crate reads celestial coordinates in \
351             degrees",
352            index + 1,
353            other
354        )
355        .into()),
356    }
357}
358
359/// The text a card holds, for the keywords with no typed accessor of their own.
360fn string(header: &Header, key: &str) -> Option<String> {
361    match header.card(key)? {
362        crate::header::Value::String { value, .. } => Some(value),
363        _ => None,
364    }
365}
366
367/// Reads every axis the header describes, in order.
368fn axes_of(header: &Header) -> Vec<Axis> {
369    let count = header.naxis().unwrap_or(0).max(0) as usize;
370
371    (0..count)
372        .map(|index| Axis {
373            reference_pixel: header.coordinate_reference_pixel(index).unwrap_or(0.0),
374            reference_value: header.coordinate_value_at_pixel(index).unwrap_or(0.0),
375            // A missing CDELTn is one unit per pixel, as the standard says.
376            delta: header.coordinate_delta(index).unwrap_or(1.0),
377            ctype: header.coordinate_axis_name(index).map(str::to_string),
378            cunit: string(header, &format!("CUNIT{}", index + 1)),
379        })
380        .collect()
381}
382
383/// Reads the transformation matrix out of whichever convention the header uses.
384///
385/// FITS has three ways of saying the same thing, and they are tried in the order
386/// the standard gives them:
387///
388/// 1. `CDi_j`, which carries the scale and the rotation together. This is what
389///    most modern pipelines write, and when it is present CDELTn and CROTAn are
390///    ignored.
391/// 2. `PCi_j` scaled by `CDELTn`, which separates the rotation from the scale.
392/// 3. `CDELTn` with `CROTAn`, the older convention, where the rotation is a
393///    single angle.
394///
395/// Reading only the third and defaulting the scale to 1 leaves a `CDi_j` header
396/// pointing a whole degree per pixel away from where it means.
397fn transform_from(header: &Header) -> [[f64; 2]; 2] {
398    let element = |row, column| header.coordinate_transform(row, column);
399
400    // Any CDi_j at all means the header uses the CD convention; the elements it
401    // leaves out are zero, as the standard says.
402    if (0..2).any(|row| (0..2).any(|column| element(row, column).is_some())) {
403        return [
404            [element(0, 0).unwrap_or(0.0), element(0, 1).unwrap_or(0.0)],
405            [element(1, 0).unwrap_or(0.0), element(1, 1).unwrap_or(0.0)],
406        ];
407    }
408
409    // CDELTn defaults to 1, which is what the standard says a header with no
410    // scale at all means.
411    let scale = (
412        header.coordinate_delta(0).unwrap_or(1.0),
413        header.coordinate_delta(1).unwrap_or(1.0),
414    );
415
416    let rotation = |row, column| header.coordinate_rotation_matrix(row, column);
417
418    if (0..2).any(|row| (0..2).any(|column| rotation(row, column).is_some())) {
419        // A PCi_j the header leaves out is the identity matrix's value there.
420        let identity = |row: usize, column: usize| if row == column { 1.0 } else { 0.0 };
421        let element = |row, column| rotation(row, column).unwrap_or_else(|| identity(row, column));
422
423        return [
424            [scale.0 * element(0, 0), scale.0 * element(0, 1)],
425            [scale.1 * element(1, 0), scale.1 * element(1, 1)],
426        ];
427    }
428
429    // CROTAn is carried on the second axis by convention, but accept it on the
430    // first for the headers that put it there.
431    let angle = header
432        .coordinate_rotation(1)
433        .or_else(|| header.coordinate_rotation(0))
434        .unwrap_or(0.0);
435    let (sin, cos) = angle.to_radians().sin_cos();
436
437    // The standard's relation between CROTAn and the matrix. Note which CDELT
438    // goes with which element: the off-diagonal terms take the scale of the axis
439    // they draw from, not the one they feed.
440    [
441        [scale.0 * cos, -scale.1 * sin],
442        [scale.0 * sin, scale.1 * cos],
443    ]
444}
445
446/// Multiplies a two-element offset by a matrix.
447fn apply(matrix: [[f64; 2]; 2], offset: (f64, f64)) -> (f64, f64) {
448    (
449        matrix[0][0] * offset.0 + matrix[0][1] * offset.1,
450        matrix[1][0] * offset.0 + matrix[1][1] * offset.1,
451    )
452}
453
454/// Inverts a two-by-two matrix, or `None` if it is singular.
455fn invert(matrix: [[f64; 2]; 2]) -> Option<[[f64; 2]; 2]> {
456    let determinant = matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0];
457
458    if determinant == 0.0 || !determinant.is_finite() {
459        return None;
460    }
461
462    Some([
463        [matrix[1][1] / determinant, -matrix[0][1] / determinant],
464        [-matrix[1][0] / determinant, matrix[0][0] / determinant],
465    ])
466}
467
468#[cfg(test)]
469mod tests {
470    use super::{Wcs, invert};
471    use crate::header::Header;
472    use crate::wcs::spherical::normalise_longitude;
473
474    /// A header describing a two-axis image under `projection`, rotated by
475    /// `rotation` degrees, the way a CDELTn and CROTAn header would say it.
476    fn header(projection: &str, rotation: f64) -> Header {
477        let mut header = Header::default();
478
479        header.set_card("NAXIS", 2_i64).unwrap();
480        header.set_card("CRPIX1", 100.5).unwrap();
481        header.set_card("CRPIX2", 200.5).unwrap();
482        header.set_card("CRVAL1", 150.0).unwrap();
483        header.set_card("CRVAL2", 40.0).unwrap();
484        header.set_card("CDELT1", -0.001).unwrap();
485        header.set_card("CDELT2", 0.001).unwrap();
486        header.set_card("CROTA2", rotation).unwrap();
487        header
488            .set_card("CTYPE1", format!("RA---{projection}"))
489            .unwrap();
490        header
491            .set_card("CTYPE2", format!("DEC--{projection}"))
492            .unwrap();
493
494        header
495    }
496
497    fn wcs(projection: &str, rotation: f64) -> Wcs {
498        Wcs::from_header(&header(projection, rotation)).expect("a complete WCS header")
499    }
500
501    fn assert_close(actual: (f64, f64), expected: (f64, f64)) {
502        assert!(
503            (actual.0 - expected.0).abs() < 1e-9 && (actual.1 - expected.1).abs() < 1e-9,
504            "expected {expected:?}, got {actual:?}"
505        );
506    }
507
508    #[test]
509    fn the_reference_pixel_sits_at_the_reference_value() {
510        for projection in ["TAN", "SIN", "ARC", "STG", "ZEA", "CAR", "AIT", "MOL"] {
511            let wcs = wcs(projection, 0.0);
512            assert_close(wcs.pixel_to_world((100.5, 200.5)), (150.0, 40.0));
513        }
514    }
515
516    #[test]
517    fn pixel_and_world_round_trip() {
518        for projection in [
519            "TAN", "SIN", "ARC", "STG", "ZEA", "CAR", "MER", "AIT", "MOL",
520        ] {
521            for rotation in [0.0, 30.0, -12.5] {
522                let wcs = wcs(projection, rotation);
523
524                for pixel in [(1.0, 1.0), (100.5, 200.5), (512.0, 480.0)] {
525                    let world = wcs.pixel_to_world(pixel);
526                    assert!(
527                        world.0.is_finite(),
528                        "{projection} put pixel {pixel:?} nowhere"
529                    );
530
531                    // A millionth of a pixel: the projections that are solved
532                    // by iteration rather than in closed form give up a few
533                    // digits, and this is far below what any of it means.
534                    let back = wcs.world_to_pixel(world);
535                    assert!(
536                        (back.0 - pixel.0).abs() < 1e-6 && (back.1 - pixel.1).abs() < 1e-6,
537                        "{projection} took {pixel:?} to {world:?} and back to {back:?}"
538                    );
539                }
540            }
541        }
542    }
543
544    #[test]
545    fn a_linear_axis_is_a_plain_offset_from_the_reference() {
546        let mut header = header("TAN", 0.0);
547        header.set_card("CTYPE1", "LINEAR").unwrap();
548        header.set_card("CTYPE2", "LINEAR").unwrap();
549
550        let wcs = Wcs::from_header(&header).unwrap();
551
552        // Ten pixels along the first axis, at -0.001 degrees per pixel.
553        assert_close(wcs.pixel_to_world((110.5, 200.5)), (149.99, 40.0));
554        assert!(!wcs.is_celestial());
555    }
556
557    #[test]
558    fn a_gnomonic_axis_is_not_a_plain_offset() {
559        // The whole point of a projection is that it is not linear: away from
560        // the reference point, right ascension converges with latitude.
561        let mut linear = header("TAN", 0.0);
562        linear.set_card("CTYPE1", "LINEAR").unwrap();
563        linear.set_card("CTYPE2", "LINEAR").unwrap();
564
565        let linear = Wcs::from_header(&linear)
566            .unwrap()
567            .pixel_to_world((1100.5, 200.5));
568        let gnomonic = wcs("TAN", 0.0).pixel_to_world((1100.5, 200.5));
569
570        assert!(
571            (linear.0 - gnomonic.0).abs() > 1e-6,
572            "TAN and linear should disagree a degree from the reference, got {linear:?} and {gnomonic:?}"
573        );
574    }
575
576    #[test]
577    fn north_is_up_and_east_is_left_in_an_unrotated_image() {
578        let wcs = wcs("TAN", 0.0);
579
580        // A pixel above the reference is north of it, and one to the right is
581        // west — which is what the negative CDELT1 of a sky image means.
582        let north = wcs.pixel_to_world((100.5, 300.5));
583        let right = wcs.pixel_to_world((200.5, 200.5));
584
585        assert!(
586            north.1 > 40.0,
587            "north of the reference should be, got {north:?}"
588        );
589        assert!(
590            (north.0 - 150.0).abs() < 1e-9,
591            "straight north keeps its right ascension, got {north:?}"
592        );
593        assert!(
594            right.0 < 150.0,
595            "the right of the frame is west, got {right:?}"
596        );
597    }
598
599    #[test]
600    fn the_projections_agree_close_to_the_reference_point() {
601        // Every projection is locally the same to first order, so a pixel a few
602        // arcseconds out lands in the same place whichever one is used. A
603        // projection with its formulae the wrong way round shows up here.
604        let reference = wcs("TAN", 0.0).pixel_to_world((110.5, 210.5));
605
606        for projection in ["SIN", "ARC", "STG", "ZEA"] {
607            let other = wcs(projection, 0.0).pixel_to_world((110.5, 210.5));
608
609            assert!(
610                (other.0 - reference.0).abs() < 1e-6 && (other.1 - reference.1).abs() < 1e-6,
611                "{projection} put the pixel at {other:?}, TAN at {reference:?}"
612            );
613        }
614    }
615
616    /// The closed-form gnomonic deprojection, written out independently of
617    /// everything the crate does, so that the general machinery has something
618    /// to be checked against.
619    fn gnomonic_by_hand(reference: (f64, f64), xi: f64, eta: f64) -> (f64, f64) {
620        let (xi, eta) = (xi.to_radians(), eta.to_radians());
621        let (longitude, latitude) = (reference.0.to_radians(), reference.1.to_radians());
622        let (sin, cos) = latitude.sin_cos();
623
624        let denominator = cos - eta * sin;
625
626        (
627            normalise_longitude((longitude + xi.atan2(denominator)).to_degrees()),
628            ((sin + eta * cos) / (xi * xi + denominator * denominator).sqrt())
629                .atan()
630                .to_degrees(),
631        )
632    }
633
634    #[test]
635    fn the_gnomonic_projection_agrees_with_its_closed_form() {
636        let wcs = wcs("TAN", 0.0);
637
638        for pixel in [(1.0, 1.0), (250.0, 60.0), (900.5, 1000.5)] {
639            // The intermediate coordinates the matrix produces, by hand.
640            let (u, v) = (pixel.0 - 100.5, pixel.1 - 200.5);
641            let expected = gnomonic_by_hand((150.0, 40.0), -0.001 * u, 0.001 * v);
642
643            let actual = wcs.pixel_to_world(pixel);
644
645            assert!(
646                (actual.0 - expected.0).abs() < 1e-10 && (actual.1 - expected.1).abs() < 1e-10,
647                "at {pixel:?} the closed form gives {expected:?} and the projection {actual:?}"
648            );
649        }
650    }
651
652    #[test]
653    fn indexed_helpers_shift_between_pixel_conventions() {
654        let wcs = wcs("TAN", 0.0);
655
656        // Array index (0, 0) is FITS pixel (1.0, 1.0).
657        assert_close(
658            wcs.pixel_to_world_indexed((0, 0)),
659            wcs.pixel_to_world((1.0, 1.0)),
660        );
661
662        let world = wcs.pixel_to_world_indexed((10, 20));
663        assert_eq!(wcs.world_to_pixel_indexed(world, 512, 512), Some((10, 20)));
664    }
665
666    #[test]
667    fn a_coordinate_outside_the_image_has_no_pixel() {
668        let wcs = wcs("TAN", 0.0);
669
670        let world = wcs.pixel_to_world_indexed((400, 400));
671        assert_eq!(wcs.world_to_pixel_indexed(world, 100, 100), None);
672    }
673
674    #[test]
675    fn a_cubes_third_axis_reads_on_its_own() {
676        let mut header = header("TAN", 0.0);
677        header.set_card("NAXIS", 3_i64).unwrap();
678        header.set_card("CTYPE3", "WAVE").unwrap();
679        header.set_card("CUNIT3", "Angstrom").unwrap();
680        header.set_card("CRPIX3", 1.0).unwrap();
681        header.set_card("CRVAL3", 4000.0).unwrap();
682        header.set_card("CDELT3", 1.25).unwrap();
683
684        let wcs = Wcs::from_header(&header).unwrap();
685
686        assert_eq!(wcs.axis_count(), 3);
687        assert_eq!(wcs.axis_type(2), Some("WAVE"));
688        assert_eq!(wcs.axis_unit(2), Some("Angstrom"));
689        assert_eq!(wcs.pixel_to_world_axis(2, 1.0), Some(4000.0));
690        assert_eq!(wcs.pixel_to_world_axis(2, 5.0), Some(4005.0));
691        assert_eq!(wcs.world_to_pixel_axis(2, 4005.0), Some(5.0));
692
693        // The celestial axes only mean anything together.
694        assert_eq!(wcs.pixel_to_world_axis(0, 1.0), None);
695        assert_eq!(wcs.pixel_to_world_axis(9, 1.0), None);
696    }
697
698    #[test]
699    fn a_logarithmic_axis_grows_geometrically() {
700        let mut header = header("TAN", 0.0);
701        header.set_card("NAXIS", 3_i64).unwrap();
702        header.set_card("CTYPE3", "FREQ-LOG").unwrap();
703        header.set_card("CRPIX3", 1.0).unwrap();
704        header.set_card("CRVAL3", 1000.0).unwrap();
705        header.set_card("CDELT3", 10.0).unwrap();
706
707        let wcs = Wcs::from_header(&header).unwrap();
708
709        let at_reference = wcs.pixel_to_world_axis(2, 1.0).unwrap();
710        let further = wcs.pixel_to_world_axis(2, 11.0).unwrap();
711
712        assert!((at_reference - 1000.0).abs() < 1e-9);
713        assert!((further - 1000.0 * (100.0_f64 / 1000.0).exp()).abs() < 1e-6);
714        assert!((wcs.world_to_pixel_axis(2, further).unwrap() - 11.0).abs() < 1e-9);
715    }
716
717    #[test]
718    fn a_celestial_axis_in_the_wrong_unit_is_refused() {
719        let mut header = header("TAN", 0.0);
720        header.set_card("CUNIT1", "arcsec").unwrap();
721
722        let error = Wcs::from_header(&header).expect_err("arcseconds are not degrees");
723        assert!(error.to_string().contains("arcsec"), "got: {error}");
724    }
725
726    #[test]
727    fn a_sip_header_bends_the_field_and_bends_it_back() {
728        let mut header = header("TAN", 0.0);
729        header.set_card("CTYPE1", "RA---TAN-SIP").unwrap();
730        header.set_card("CTYPE2", "DEC--TAN-SIP").unwrap();
731        header.set_card("A_ORDER", 2_i64).unwrap();
732        header.set_card("A_2_0", 1e-5).unwrap();
733        header.set_card("B_ORDER", 2_i64).unwrap();
734        header.set_card("B_0_2", -2e-5).unwrap();
735
736        let distorted = Wcs::from_header(&header).unwrap();
737        let ideal = wcs("TAN", 0.0);
738
739        let pixel = (600.5, 700.5);
740
741        let with = distorted.pixel_to_world(pixel);
742        let without = ideal.pixel_to_world(pixel);
743
744        assert!(
745            (with.0 - without.0).abs() > 1e-6 || (with.1 - without.1).abs() > 1e-6,
746            "the correction should move the corner of the frame, got {with:?} and {without:?}"
747        );
748
749        let back = distorted.world_to_pixel(with);
750        assert!(
751            (back.0 - pixel.0).abs() < 1e-6 && (back.1 - pixel.1).abs() < 1e-6,
752            "{pixel:?} came back as {back:?}"
753        );
754    }
755
756    #[test]
757    fn a_tpv_header_bends_the_field_and_bends_it_back() {
758        let mut header = header("TAN", 0.0);
759        header.set_card("CTYPE1", "RA---TPV").unwrap();
760        header.set_card("CTYPE2", "DEC--TPV").unwrap();
761        header.set_card("PV1_1", 1.0).unwrap();
762        header.set_card("PV1_4", 0.002).unwrap();
763        header.set_card("PV2_1", 1.0).unwrap();
764
765        let wcs = Wcs::from_header(&header).unwrap();
766        let pixel = (600.5, 700.5);
767
768        let world = wcs.pixel_to_world(pixel);
769        let back = wcs.world_to_pixel(world);
770
771        assert!(
772            (back.0 - pixel.0).abs() < 1e-6 && (back.1 - pixel.1).abs() < 1e-6,
773            "{pixel:?} came back as {back:?}"
774        );
775    }
776
777    #[test]
778    fn an_all_sky_projection_puts_its_pole_a_quarter_turn_from_its_centre() {
779        let mut header = header("AIT", 0.0);
780        header.set_card("CRVAL1", 0.0).unwrap();
781        header.set_card("CRVAL2", 0.0).unwrap();
782
783        let wcs = Wcs::from_header(&header).unwrap();
784
785        let pole = wcs.celestial_pole().expect("a celestial system has a pole");
786        assert!((pole.1 - 90.0).abs() < 1e-9, "got {pole:?}");
787    }
788
789    #[test]
790    fn a_singular_matrix_has_no_inverse() {
791        // Two axes that map onto the same line describe no coordinate system:
792        // every pixel would land on the same place, and nothing maps back.
793        assert!(invert([[1.0, 2.0], [2.0, 4.0]]).is_none());
794        assert!(invert([[0.0, 0.0], [0.0, 0.0]]).is_none());
795
796        assert!(invert([[1.0, 0.0], [0.0, 1.0]]).is_some());
797    }
798
799    #[test]
800    fn longitudes_wrap_into_a_single_turn() {
801        assert_eq!(normalise_longitude(370.0), 10.0);
802        assert_eq!(normalise_longitude(-10.0), 350.0);
803        assert_eq!(normalise_longitude(180.0), 180.0);
804    }
805}