Skip to main content

fits_well/wcs/
mod.rs

1//! Typed World Coordinate System (§8).
2//!
3//! Parses the per-axis WCS keywords from a [`Header`] and evaluates the standard
4//! pixel↔world pipeline (Greisen & Calabretta, FITS WCS papers I & II):
5//!
6//! ```text
7//! pixel ─ CRPIX ─►  ·(PC|CD, ×CDELT)  ─►  intermediate world (deg)
8//!        ─► deproject (CTYPE algorithm) ─► native sphere
9//!        ─► rotate (CRVAL, LONPOLE) ─► celestial (α, δ)
10//! ```
11//!
12//! The linear layer is `PC`+`CDELT`, `CD`, or legacy `CDELT`+`CROTA`, with general
13//! matrix inversion for the reverse direction, and full `PVi_m` parameters
14//! (φ₀/θ₀/LONPOLE/LATPOLE overrides plus per-projection params). Projections, via
15//! the general fiducial-point pole computation: zenithal `TAN`/`SIN`/`ARC`/`STG`/
16//! `ZEA`/`ZPN`/`AIR`, zenithal-perspective `AZP`/`SZP`, cylindrical `CAR`/`CEA`/
17//! `MER`/`SFL`/`CYP`, all-sky `AIT`/`MOL`/`PAR`, conic `COP`/`COE`/`COD`/`COO`,
18//! pseudoconic `BON`, and polyconic `PCO`. All validated against `astropy.wcs`
19//! (wcslib). The unimplemented non-linear transforms — quad-cube `TSC`/`CSC`/`QSC`,
20//! HEALPix `HPX`/`XPH`, and the non-linear spectral algorithms (§8.4) — are not
21//! evaluated: such an axis passes through the linear stage only (its intermediate
22//! world coordinate) and is listed in [`Wcs::unsupported_axes`], so a file using
23//! one still reads, just with that axis not fully decoded.
24//!
25//! Binary-table WCS (Table 22) is supported for both the pixel-list
26//! ([`Header::wcs_pixel_list`](crate::Header::wcs_pixel_list)) and vector-cell
27//! ([`Header::wcs_array_column`](crate::Header::wcs_array_column)) forms.
28//!
29//! Pixel↔world yields celestial coordinates in the frame the file declares
30//! (`RADESYS`/`EQUINOX`); converting *between* reference frames is astrometry
31//! beyond the FITS standard and is intentionally out of scope.
32
33use std::f64::consts::FRAC_PI_2;
34use std::f64::consts::FRAC_PI_4;
35use std::f64::consts::PI;
36use std::f64::consts::SQRT_2;
37
38use crate::error::FitsError;
39use crate::error::Result;
40use crate::header::Header;
41use crate::keyword::key;
42
43const R2D: f64 = 180.0 / PI;
44const D2R: f64 = PI / 180.0;
45
46/// The §8.4 spectral coordinate types (the 4-character `CTYPE` prefix). A bare
47/// type is sampled linearly (handled by the generic linear axis); a `TTTT-AAA`
48/// algorithm suffix means non-linear sampling, which is not yet evaluated.
49const SPECTRAL_TYPES: &[&str] = &[
50    "FREQ", "ENER", "WAVN", "VRAD", "WAVE", "VOPT", "ZOPT", "AWAV", "VELO", "BETA",
51];
52
53/// A celestial projection algorithm — the 3-letter `CTYPE` code.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Projection {
56    /// `TAN` — gnomonic (zenithal).
57    Tan,
58    /// `SIN` — orthographic/slant (zenithal).
59    Sin,
60    /// `ARC` — zenithal equidistant.
61    Arc,
62    /// `STG` — stereographic (zenithal).
63    Stg,
64    /// `ZEA` — zenithal equal-area.
65    Zea,
66    /// `CAR` — plate carrée (cylindrical).
67    Car,
68    /// `CEA` — cylindrical equal-area.
69    Cea,
70    /// `MER` — Mercator (cylindrical).
71    Mer,
72    /// `SFL` — Sanson–Flamsteed (pseudo-cylindrical).
73    Sfl,
74    /// `AIT` — Hammer–Aitoff (all-sky, pseudo-cylindrical).
75    Ait,
76    /// `MOL` — Mollweide (all-sky, pseudo-cylindrical).
77    Mol,
78    /// `ZPN` — zenithal polynomial (`PVi_m` coefficients).
79    Zpn,
80    /// `CYP` — cylindrical perspective (`μ = PVi_1`, `λ = PVi_2`).
81    Cyp,
82    /// `PAR` — parabolic (pseudo-cylindrical).
83    Par,
84    /// `COP` — conic perspective (`θ_a = PVi_1`, `η = PVi_2`).
85    Cop,
86    /// `COE` — conic equal-area.
87    Coe,
88    /// `COD` — conic equidistant.
89    Cod,
90    /// `COO` — conic orthomorphic.
91    Coo,
92    /// `BON` — Bonne's equal-area (pseudo-conic, `θ₁ = PVi_1`).
93    Bon,
94    /// `AIR` — Airy (zenithal, minimum-error; `θ_b = PVi_1`).
95    Air,
96    /// `AZP` — zenithal perspective (`μ = PVi_1`, tilt `γ = PVi_2`).
97    Azp,
98    /// `PCO` — polyconic.
99    Pco,
100    /// `SZP` — slant zenithal perspective (`μ = PVi_1`, `φc = PVi_2`, `θc = PVi_3`).
101    Szp,
102}
103
104/// The projection family — it fixes the fiducial point and selects the deprojection
105/// branch. The single source of truth for membership that `from_code`, `is_zenithal`,
106/// `is_conic`, and `reference_point` all derive from (via [`PROJECTIONS`]).
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108enum Family {
109    /// Fiducial point at the native pole (`θ₀ = 90°`), radial deprojection.
110    Zenithal,
111    /// `θ₀ = 90°` too, but a bespoke tilted/slant deprojection — `AZP`/`SZP`.
112    ZenithalPerspective,
113    /// `θ₀ = θ_a = PVi_1` — the conics.
114    Conic,
115    /// `θ₀ = 0°` — cylindrical, pseudo-cylindrical, polyconic, Bonne.
116    Other,
117}
118
119/// The `CTYPE` code, variant, and [`Family`] for every supported projection — the one
120/// membership table the classification methods consult, so adding a projection is a
121/// single row rather than edits to four functions.
122const PROJECTIONS: &[(&str, Projection, Family)] = &[
123    ("TAN", Projection::Tan, Family::Zenithal),
124    ("SIN", Projection::Sin, Family::Zenithal),
125    ("ARC", Projection::Arc, Family::Zenithal),
126    ("STG", Projection::Stg, Family::Zenithal),
127    ("ZEA", Projection::Zea, Family::Zenithal),
128    ("ZPN", Projection::Zpn, Family::Zenithal),
129    ("AIR", Projection::Air, Family::Zenithal),
130    ("AZP", Projection::Azp, Family::ZenithalPerspective),
131    ("SZP", Projection::Szp, Family::ZenithalPerspective),
132    ("COP", Projection::Cop, Family::Conic),
133    ("COE", Projection::Coe, Family::Conic),
134    ("COD", Projection::Cod, Family::Conic),
135    ("COO", Projection::Coo, Family::Conic),
136    ("CAR", Projection::Car, Family::Other),
137    ("CEA", Projection::Cea, Family::Other),
138    ("MER", Projection::Mer, Family::Other),
139    ("SFL", Projection::Sfl, Family::Other),
140    ("AIT", Projection::Ait, Family::Other),
141    ("MOL", Projection::Mol, Family::Other),
142    ("CYP", Projection::Cyp, Family::Other),
143    ("PAR", Projection::Par, Family::Other),
144    ("BON", Projection::Bon, Family::Other),
145    ("PCO", Projection::Pco, Family::Other),
146];
147
148impl Projection {
149    fn from_code(code: &str) -> Option<Projection> {
150        PROJECTIONS
151            .iter()
152            .find(|&&(c, ..)| c == code)
153            .map(|&(_, proj, _)| proj)
154    }
155
156    /// This projection's [`Family`] (every variant is listed in [`PROJECTIONS`]).
157    fn family(self) -> Family {
158        PROJECTIONS
159            .iter()
160            .find(|&&(_, proj, _)| proj == self)
161            .map(|&(.., fam)| fam)
162            .expect("every Projection variant is listed in PROJECTIONS")
163    }
164
165    /// Whether this is a zenithal projection (fiducial point at the native pole,
166    /// `θ₀ = 90°`); cylindrical projections have `θ₀ = 0°`.
167    fn is_zenithal(self) -> bool {
168        self.family() == Family::Zenithal
169    }
170
171    /// Whether this is a conic projection (`θ₀ = θ_a = PVi_1`).
172    fn is_conic(self) -> bool {
173        self.family() == Family::Conic
174    }
175
176    /// The fiducial point `(φ₀, θ₀)` in degrees. Zenithal (incl. the perspective
177    /// `AZP`/`SZP`): `(0, 90)`; conics: `(0, θ_a)` where `θ_a = PVi_1`; else `(0, 0)`.
178    fn reference_point(self, pv: &[f64]) -> (f64, f64) {
179        match self.family() {
180            Family::Zenithal | Family::ZenithalPerspective => (0.0, 90.0),
181            Family::Conic => (0.0, pv.get(1).copied().unwrap_or(0.0)),
182            Family::Other => (0.0, 0.0),
183        }
184    }
185
186    /// Deproject intermediate world `(x, y)` (deg) to native `(φ, θ)` (deg).
187    /// `pv` holds the latitude axis's `PVi_0…` projection parameters.
188    fn deproject(self, x: f64, y: f64, pv: &[f64]) -> (f64, f64) {
189        if matches!(self, Projection::Azp) {
190            // Tilted zenithal perspective (CG 2002 §5.1.1): undo the γ shear, then
191            // solve A·sinθ + B·cosθ = C for θ.
192            let (mu, gr) = (pv[1], pv[2] * D2R);
193            let yc = y * gr.cos();
194            let r = x.hypot(yc) / R2D;
195            let phi = x.atan2(-yc);
196            let (a, b, c) = (r, r * phi.cos() * gr.tan() - (mu + 1.0), -r * mu);
197            let rad = a.hypot(b);
198            let psi = b.atan2(a);
199            let base = (c / rad).clamp(-1.0, 1.0).asin();
200            // Pick the θ root nearest the native pole (θ = 90°).
201            let half_pi = FRAC_PI_2;
202            let cand = [base - psi, PI - base - psi];
203            let theta = cand
204                .into_iter()
205                .min_by(|p, q| {
206                    (p - half_pi)
207                        .abs()
208                        .partial_cmp(&(q - half_pi).abs())
209                        .unwrap()
210                })
211                .unwrap();
212            return (phi * R2D, theta * R2D);
213        }
214        if matches!(self, Projection::Szp) {
215            // Slant zenithal perspective (CG 2002 §5.1.2). With the vertex
216            // P = (xp, yp, zp), substitute σ = 1 − sinθ and reduce to a quadratic
217            // `zp²(2σ − σ²) = A² + B²` with A, B linear in σ.
218            let (xp, yp, zp) = szp_vertex(pv);
219            let (cx, cy) = (x / R2D, y / R2D);
220            // A = a0 + a1·σ, B = b0 + b1·σ.
221            let (a0, a1) = (cx * zp, -(cx - xp));
222            let (b0, b1) = (-cy * zp, cy - yp);
223            let qa = a1 * a1 + b1 * b1 + zp * zp;
224            let qb = 2.0 * (a0 * a1 + b0 * b1) - 2.0 * zp * zp;
225            let qc = a0 * a0 + b0 * b0;
226            let disc = (qb * qb - 4.0 * qa * qc).max(0.0).sqrt();
227            let s1 = (-qb - disc) / (2.0 * qa);
228            let s2 = (-qb + disc) / (2.0 * qa);
229            // σ ∈ [0, 2]; prefer the visible-hemisphere root (smaller σ).
230            let sigma = if (0.0..=2.0).contains(&s1) { s1 } else { s2 };
231            let theta = (1.0 - sigma).clamp(-1.0, 1.0).asin();
232            let (a, b) = (a0 + a1 * sigma, b0 + b1 * sigma);
233            let phi = a.atan2(b);
234            return (phi * R2D, theta * R2D);
235        }
236        if self.is_conic() {
237            let (c, y0) = self.conic_consts(pv);
238            let s = pv[1].signum();
239            let r = s * x.hypot(y0 - y);
240            let phi = (s * x).atan2(s * (y0 - y)) * R2D / c;
241            return (phi, self.conic_theta(r, pv, c));
242        }
243        if self.is_zenithal() {
244            let r = x.hypot(y);
245            let phi = if r == 0.0 { 0.0 } else { x.atan2(-y) * R2D };
246            // Colatitude ζ (rad) from the radius, per projection.
247            let u = r / R2D;
248            let zeta = match self {
249                Projection::Tan => u.atan(),
250                Projection::Sin => u.clamp(-1.0, 1.0).asin(),
251                Projection::Arc => u,
252                Projection::Zea => 2.0 * (u / 2.0).clamp(-1.0, 1.0).asin(),
253                Projection::Stg => 2.0 * (u / 2.0).atan(),
254                // ZPN: solve Σ Pₘ ζᵐ = u for ζ (Newton from ζ = u).
255                Projection::Zpn => zpn_zeta(u, pv),
256                // AIR: solve the transcendental radius for ζ (Newton).
257                Projection::Air => air_zeta(u, pv[1]),
258                _ => unreachable!(),
259            };
260            (phi, 90.0 - zeta * R2D)
261        } else {
262            match self {
263                Projection::Car => (x, y),
264                // CEA: λ = PVi_1 (default 1); θ = asin(λ·y/(180/π)).
265                Projection::Cea => {
266                    let lambda = pv.get(1).filter(|&&v| v != 0.0).copied().unwrap_or(1.0);
267                    (x, (lambda * y / R2D).clamp(-1.0, 1.0).asin() * R2D)
268                }
269                Projection::Mer => (x, (2.0 * (y / R2D).exp().atan()) * R2D - 90.0),
270                Projection::Sfl => (x / (y * D2R).cos(), y),
271                // Hammer–Aitoff inverse (CG 2002 eq. 51).
272                Projection::Ait => {
273                    let (u, v) = (x * D2R, y * D2R);
274                    let z2 = (1.0 - (u / 4.0).powi(2) - (v / 2.0).powi(2)).max(0.0);
275                    let z = z2.sqrt();
276                    let phi = 2.0 * (z * u / 2.0).atan2(2.0 * z2 - 1.0) * R2D;
277                    let theta = (v * z).clamp(-1.0, 1.0).asin() * R2D;
278                    (phi, theta)
279                }
280                // Mollweide inverse (CG 2002 eq. 55).
281                Projection::Mol => {
282                    let s2 = SQRT_2;
283                    let gamma = (y / (s2 * R2D)).clamp(-1.0, 1.0).asin();
284                    let theta = ((2.0 * gamma + (2.0 * gamma).sin()) / PI).asin() * R2D;
285                    let phi = PI * x / (2.0 * s2 * gamma.cos());
286                    (phi, theta)
287                }
288                // CYP inverse: φ = x/λ; θ from η = (y/(180/π))/(μ+λ).
289                Projection::Cyp => {
290                    let (mu, lambda) = (
291                        pv[1],
292                        pv.get(2).filter(|&&v| v != 0.0).copied().unwrap_or(1.0),
293                    );
294                    let eta = (y / R2D) / (mu + lambda);
295                    let theta = eta.atan2(1.0)
296                        + (eta * mu / (1.0 + eta * eta).sqrt())
297                            .clamp(-1.0, 1.0)
298                            .asin();
299                    (x / lambda, theta * R2D)
300                }
301                // PAR inverse (CG 2002 eq. 49).
302                Projection::Par => {
303                    let theta = 3.0 * (y / 180.0).clamp(-1.0, 1.0).asin();
304                    (x / (2.0 * (2.0 * theta / 3.0).cos() - 1.0), theta * R2D)
305                }
306                // Polyconic inverse (CG 2002 §5.6.1): Newton on
307                // f(θ) = X² + (Y−θ)² − 2(Y−θ)cotθ = 0, then recover φ.
308                Projection::Pco => {
309                    let (xr, yr) = (x * D2R, y * D2R);
310                    if yr.abs() < 1e-12 {
311                        return (x, 0.0);
312                    }
313                    let mut th = yr;
314                    for _ in 0..100 {
315                        let d = yr - th;
316                        let cot = 1.0 / th.tan();
317                        let f = xr * xr + d * d - 2.0 * d * cot;
318                        let fp = -2.0 * d + 2.0 * cot + 2.0 * d / th.sin().powi(2);
319                        let step = f / fp;
320                        th -= step;
321                        if step.abs() < 1e-13 {
322                            break;
323                        }
324                    }
325                    let d = yr - th;
326                    let tanth = th.tan();
327                    let omega = (xr * tanth).atan2(1.0 - d * tanth);
328                    (omega / th.sin() * R2D, th * R2D)
329                }
330                // Bonne's pseudoconic inverse (CG 2002 §5.5.1), θ₁ = PVi_1.
331                Projection::Bon => {
332                    // §5.5.1: BON degenerates to the sinusoidal SFL at θ₁ = 0
333                    // (avoiding the `1/tan 0` singularity below).
334                    if pv[1] == 0.0 {
335                        return (x / (y * D2R).cos(), y);
336                    }
337                    let t1 = pv[1] * D2R;
338                    let y0 = t1 + 1.0 / t1.tan();
339                    let s = pv[1].signum();
340                    let yc = y0 - y * D2R;
341                    let r = s * (x * D2R).hypot(yc);
342                    let tr = y0 - r;
343                    let aphi = (s * x * D2R).atan2(s * yc);
344                    (aphi * r / tr.cos() * R2D, tr * R2D)
345                }
346                _ => unreachable!(),
347            }
348        }
349    }
350
351    /// Project native `(φ, θ)` (deg) to intermediate world `(x, y)` (deg).
352    fn project(self, phi: f64, theta: f64, pv: &[f64]) -> (f64, f64) {
353        if matches!(self, Projection::Azp) {
354            let (mu, gr) = (pv[1], pv[2] * D2R);
355            let (tr, pr) = (theta * D2R, phi * D2R);
356            let denom = (mu + tr.sin()) + tr.cos() * pr.cos() * gr.tan();
357            let r = R2D * (mu + 1.0) * tr.cos() / denom;
358            return (r * pr.sin(), -r * pr.cos() / gr.cos());
359        }
360        if matches!(self, Projection::Szp) {
361            let (xp, yp, zp) = szp_vertex(pv);
362            let (tr, pr) = (theta * D2R, phi * D2R);
363            let sigma = 1.0 - tr.sin();
364            let denom = zp - sigma;
365            let x = R2D * (zp * tr.cos() * pr.sin() - xp * sigma) / denom;
366            let y = R2D * (-zp * tr.cos() * pr.cos() - yp * sigma) / denom;
367            return (x, y);
368        }
369        if self.is_conic() {
370            let (c, y0) = self.conic_consts(pv);
371            let r = self.conic_radius(theta, pv);
372            let cp = (c * phi) * D2R;
373            return (r * cp.sin(), y0 - r * cp.cos());
374        }
375        if self.is_zenithal() {
376            let zeta = (90.0 - theta) * D2R;
377            let r = match self {
378                Projection::Tan => R2D * zeta.tan(),
379                Projection::Sin => R2D * zeta.sin(),
380                Projection::Arc => R2D * zeta,
381                Projection::Zea => 2.0 * R2D * (zeta / 2.0).sin(),
382                Projection::Stg => 2.0 * R2D * (zeta / 2.0).tan(),
383                Projection::Zpn => R2D * zpn_poly(zeta, pv),
384                Projection::Air => R2D * air_radius_u(zeta, pv[1]),
385                _ => unreachable!(),
386            };
387            let p = phi * D2R;
388            (r * p.sin(), -r * p.cos())
389        } else {
390            let t = theta * D2R;
391            match self {
392                Projection::Car => (phi, theta),
393                Projection::Cea => {
394                    let lambda = pv.get(1).filter(|&&v| v != 0.0).copied().unwrap_or(1.0);
395                    (phi, R2D * t.sin() / lambda)
396                }
397                Projection::Mer => (phi, R2D * ((45.0 + theta / 2.0) * D2R).tan().ln()),
398                Projection::Sfl => (phi * t.cos(), theta),
399                Projection::Ait => {
400                    let pr = phi * D2R;
401                    let gamma = R2D * (2.0 / (1.0 + t.cos() * (pr / 2.0).cos())).sqrt();
402                    (2.0 * gamma * t.cos() * (pr / 2.0).sin(), gamma * t.sin())
403                }
404                Projection::Mol => {
405                    // Solve 2γ + sin2γ = π·sinθ for γ (Newton).
406                    let s2 = SQRT_2;
407                    let target = PI * t.sin();
408                    let mut g = t; // initial guess
409                    for _ in 0..100 {
410                        let f = 2.0 * g + (2.0 * g).sin() - target;
411                        let d = 2.0 + 2.0 * (2.0 * g).cos();
412                        let step = f / d;
413                        g -= step;
414                        if step.abs() < 1e-14 {
415                            break;
416                        }
417                    }
418                    ((2.0 * s2 / PI) * phi * g.cos(), s2 * R2D * g.sin())
419                }
420                Projection::Cyp => {
421                    let (mu, lambda) = (
422                        pv[1],
423                        pv.get(2).filter(|&&v| v != 0.0).copied().unwrap_or(1.0),
424                    );
425                    (lambda * phi, R2D * (mu + lambda) * t.sin() / (mu + t.cos()))
426                }
427                Projection::Par => (
428                    phi * (2.0 * (2.0 * t / 3.0).cos() - 1.0),
429                    180.0 * (t / 3.0).sin(),
430                ),
431                Projection::Bon => {
432                    // §5.5.1: BON degenerates to the sinusoidal SFL at θ₁ = 0.
433                    if pv[1] == 0.0 {
434                        return (phi * t.cos(), theta);
435                    }
436                    let t1 = pv[1] * D2R;
437                    let y0 = t1 + 1.0 / t1.tan();
438                    let r = y0 - t;
439                    let aphi = phi * D2R * t.cos() / r;
440                    (R2D * r * aphi.sin(), R2D * (y0 - r * aphi.cos()))
441                }
442                Projection::Pco => {
443                    if theta.abs() < 1e-12 {
444                        return (phi, 0.0);
445                    }
446                    let omega = phi * D2R * t.sin();
447                    let cot = 1.0 / t.tan();
448                    (
449                        R2D * cot * omega.sin(),
450                        theta + R2D * cot * (1.0 - omega.cos()),
451                    )
452                }
453                _ => unreachable!(),
454            }
455        }
456    }
457
458    /// Conic constants `(C, Y0)` (CG 2002 §3.4): `θ_a = PVi_1`, `η = PVi_2` (deg).
459    fn conic_consts(self, pv: &[f64]) -> (f64, f64) {
460        let (ta, eta) = (pv[1] * D2R, pv[2] * D2R);
461        let (t1, t2) = (ta - eta, ta + eta);
462        match self {
463            Projection::Cop => {
464                let c = ta.sin();
465                (c, R2D * eta.cos() / ta.tan())
466            }
467            Projection::Coe => {
468                let (s1, s2) = (t1.sin(), t2.sin());
469                let c = (s1 + s2) / 2.0;
470                let y0 = R2D / c * (1.0 + s1 * s2 - 2.0 * c * ta.sin()).max(0.0).sqrt();
471                (c, y0)
472            }
473            Projection::Cod => {
474                // Equidistant: C = sinθ_a·sinη/η; R = Y0 + (θ_a − θ) deg, with
475                // Y0 = (180/π)·(η/tanη)·cotθ_a (η→0 ⇒ η/tanη→1).
476                let (c, k) = if eta.abs() < 1e-12 {
477                    (ta.sin(), 1.0)
478                } else {
479                    (ta.sin() * eta.sin() / eta, eta / eta.tan())
480                };
481                (c, R2D * k / ta.tan())
482            }
483            Projection::Coo => {
484                let c = if eta.abs() < 1e-12 {
485                    ta.sin()
486                } else {
487                    (t2.cos() / t1.cos()).ln()
488                        / ((FRAC_PI_4 - t2 / 2.0).tan() / (FRAC_PI_4 - t1 / 2.0).tan()).ln()
489                };
490                let psi = R2D * t1.cos() / (c * (FRAC_PI_4 - t1 / 2.0).tan().powf(c));
491                (c, psi * (FRAC_PI_4 - ta / 2.0).tan().powf(c))
492            }
493            _ => unreachable!(),
494        }
495    }
496
497    /// Conic radius `R_θ` (deg) for a native latitude `θ` (deg).
498    fn conic_radius(self, theta: f64, pv: &[f64]) -> f64 {
499        let (c, y0) = self.conic_consts(pv);
500        let (ta, eta, t) = (pv[1] * D2R, pv[2] * D2R, theta * D2R);
501        let (t1, t2) = (ta - eta, ta + eta);
502        match self {
503            Projection::Cop => R2D * eta.cos() * (1.0 / ta.tan() - (t - ta).tan()),
504            Projection::Coe => {
505                let (s1, s2) = (t1.sin(), t2.sin());
506                R2D / c * (1.0 + s1 * s2 - 2.0 * c * t.sin()).max(0.0).sqrt()
507            }
508            Projection::Cod => y0 + (pv[1] - theta),
509            Projection::Coo => {
510                let psi = R2D * t1.cos() / (c * (FRAC_PI_4 - t1 / 2.0).tan().powf(c));
511                psi * (FRAC_PI_4 - t / 2.0).tan().powf(c)
512            }
513            _ => unreachable!(),
514        }
515    }
516
517    /// Native latitude `θ` (deg) for a conic radius `R_θ` (deg).
518    fn conic_theta(self, r: f64, pv: &[f64], c: f64) -> f64 {
519        let (ta, eta) = (pv[1] * D2R, pv[2] * D2R);
520        let (t1, t2) = (ta - eta, ta + eta);
521        match self {
522            Projection::Cop => {
523                let tan = 1.0 / ta.tan() - r / (R2D * eta.cos());
524                pv[1] + tan.atan() * R2D
525            }
526            Projection::Coe => {
527                let (s1, s2) = (t1.sin(), t2.sin());
528                let sin_t = (1.0 + s1 * s2 - (r * c / R2D).powi(2)) / (2.0 * c);
529                sin_t.clamp(-1.0, 1.0).asin() * R2D
530            }
531            Projection::Cod => {
532                let y0 = self.conic_consts(pv).1;
533                pv[1] - (r - y0)
534            }
535            Projection::Coo => {
536                let psi = R2D * t1.cos() / (c * (FRAC_PI_4 - t1 / 2.0).tan().powf(c));
537                90.0 - 2.0 * (r / psi).powf(1.0 / c).atan() * R2D
538            }
539            _ => unreachable!(),
540        }
541    }
542}
543
544/// SZP projection vertex `(x_p, y_p, z_p)` from `μ = PVi_1`, `φc = PVi_2`,
545/// `θc = PVi_3` (CG 2002 §5.1.2).
546fn szp_vertex(pv: &[f64]) -> (f64, f64, f64) {
547    let mu = pv[1];
548    let (phic, thetac) = (pv[2] * D2R, pv[3] * D2R);
549    (
550        -mu * thetac.cos() * phic.sin(),
551        mu * thetac.cos() * phic.cos(),
552        mu * thetac.sin() + 1.0,
553    )
554}
555
556/// AIR `K = ln(cos ξ_b)/tan²ξ_b` constant (`ξ_b = (90°−θ_b)/2`); the `θ_b = 90`
557/// limit is `−1/2`.
558fn air_k(theta_b: f64) -> f64 {
559    let xi_b = (90.0 - theta_b) * D2R / 2.0;
560    if xi_b.abs() < 1e-12 {
561        -0.5
562    } else {
563        xi_b.cos().ln() / xi_b.tan().powi(2)
564    }
565}
566
567/// AIR radius `R/(180/π)` for colatitude `ζ` (rad): `−2[ln(cos ξ)/tan ξ + K tan ξ]`,
568/// `ξ = ζ/2`.
569fn air_radius_u(zeta: f64, theta_b: f64) -> f64 {
570    let xi = zeta / 2.0;
571    if xi.abs() < 1e-12 {
572        return 0.0;
573    }
574    -2.0 * (xi.cos().ln() / xi.tan() + air_k(theta_b) * xi.tan())
575}
576
577/// Invert the AIR radius for ζ given `u = R/(180/π)` (Newton).
578fn air_zeta(u: f64, theta_b: f64) -> f64 {
579    let mut z = u.max(1e-6); // start near ζ ≈ u
580    for _ in 0..100 {
581        let f = air_radius_u(z, theta_b) - u;
582        let d = (air_radius_u(z + 1e-7, theta_b) - air_radius_u(z - 1e-7, theta_b)) / 2e-7;
583        if d == 0.0 {
584            break;
585        }
586        let step = f / d;
587        z -= step;
588        if step.abs() < 1e-13 {
589            break;
590        }
591    }
592    z
593}
594
595/// ZPN forward polynomial `R/(180/π) = Σ Pₘ ζᵐ` (ζ in radians, `pv[m] = PVi_m`).
596fn zpn_poly(zeta: f64, pv: &[f64]) -> f64 {
597    pv.iter().rev().fold(0.0, |acc, &p| acc * zeta + p)
598}
599
600/// Invert the ZPN polynomial for ζ given `u = R/(180/π)` (Newton from ζ = u).
601fn zpn_zeta(u: f64, pv: &[f64]) -> f64 {
602    let mut z = u;
603    for _ in 0..100 {
604        let f = zpn_poly(z, pv) - u;
605        // derivative Σ m·Pₘ ζ^(m-1)
606        let d: f64 = pv
607            .iter()
608            .enumerate()
609            .skip(1)
610            .map(|(m, &p)| m as f64 * p * z.powi(m as i32 - 1))
611            .sum();
612        if d == 0.0 {
613            break;
614        }
615        let step = f / d;
616        z -= step;
617        if step.abs() < 1e-14 {
618            break;
619        }
620    }
621    z
622}
623
624/// A parsed world coordinate system for one (optionally alternate) axis set.
625#[derive(Debug, Clone)]
626pub struct Wcs {
627    /// Number of WCS axes.
628    pub naxis: usize,
629    /// `CTYPEi` strings.
630    pub ctype: Vec<String>,
631    /// `CRVALi` — world coordinate at the reference pixel.
632    pub crval: Vec<f64>,
633    /// `CRPIXi` — reference pixel (1-based).
634    pub crpix: Vec<f64>,
635    /// Linear transform `A` mapping `(pixel − CRPIX)` to intermediate world
636    /// coordinates: `PCi_j × CDELTi`, or `CDi_j` directly. Row-major `naxis²`.
637    matrix: Vec<f64>,
638    /// Inverse of `matrix`, for world→pixel.
639    inverse: Vec<f64>,
640    /// The (longitude axis, latitude axis, projection, celestial pole) when a
641    /// celestial pair is present; `None` for an all-linear system.
642    celestial: Option<Celestial>,
643    /// Axes (0-based) whose non-linear transform is not evaluated — an unsupported
644    /// projection (quad-cube/HEALPix) or a non-linear spectral algorithm (§8.3/§8.4).
645    /// [`Wcs::pixel_to_world`] returns their *intermediate* world coordinate (the
646    /// linear stage only), not a fully decoded celestial/spectral value.
647    pub unsupported_axes: Vec<usize>,
648}
649
650/// The rotation from native to celestial coordinates: the celestial pole
651/// `(α_p, δ_p)` and the native longitude of the pole `φ_p` (LONPOLE), all degrees.
652#[derive(Debug, Clone, Copy, PartialEq)]
653struct CelestialPole {
654    ra: f64,
655    dec: f64,
656    lonpole: f64,
657}
658
659#[derive(Debug, Clone)]
660struct Celestial {
661    lng: usize,
662    lat: usize,
663    proj: Projection,
664    /// The native→celestial pole, computed from the fiducial point.
665    pole: CelestialPole,
666    /// Latitude-axis `PVi_0…` projection parameters.
667    pv: Vec<f64>,
668}
669
670impl Wcs {
671    /// Parse the primary WCS (`alt = None`) or an alternate description
672    /// (`alt = Some('A'..='Z')`) from `header`. The public entry point is
673    /// [`Header::wcs`](crate::Header::wcs), which forwards here.
674    pub(crate) fn from_header(header: &Header, alt: Option<char>) -> Result<Wcs> {
675        let a = alt.map(|c| c.to_string()).unwrap_or_default();
676        let naxis = header
677            .get_integer(key!("WCSAXES{a}").as_str())
678            .or_else(|| header.get_integer("NAXIS"))
679            .ok_or(FitsError::MissingKeyword { name: "WCSAXES" })?
680            .max(0) as usize;
681        if naxis == 0 {
682            return Err(FitsError::InvalidValue {
683                card: "WCSAXES = 0".to_string(),
684            });
685        }
686        // §4.4.1/§8: at most 999 axes. `naxis` is untrusted and sizes the transform
687        // matrix (`naxis²`) and every per-axis keyword loop below, so bound it like
688        // the table's `TFIELDS` and the reader's `ZNAXIS` before looping/allocating.
689        if naxis > 999 {
690            return Err(FitsError::KeywordOutOfRange { name: "WCSAXES" });
691        }
692
693        let ctype: Vec<String> = (1..=naxis)
694            .map(|i| {
695                header
696                    .get_text(key!("CTYPE{i}{a}").as_str())
697                    .unwrap_or("")
698                    .to_string()
699            })
700            .collect();
701        let mut crval = axis_vec(header, "CRVAL", &a, naxis, 0.0);
702        let crpix = axis_vec(header, "CRPIX", &a, naxis, 0.0);
703        let cdelt = axis_vec(header, "CDELT", &a, naxis, 1.0);
704        let cunit: Vec<String> = (1..=naxis)
705            .map(|i| {
706                header
707                    .get_text(key!("CUNIT{i}{a}").as_str())
708                    .unwrap_or("")
709                    .to_string()
710            })
711            .collect();
712        let celestial_axes = find_celestial(&ctype)?;
713
714        // Axes whose non-linear transform this library doesn't evaluate — an
715        // unsupported celestial projection (quad-cube `TSC`/`CSC`/`QSC`, HEALPix
716        // `HPX`/`XPH`) or a non-linearly-sampled spectral axis (§8.4). Rather than
717        // fail the whole WCS, these pass through the linear stage only, so
718        // `pixel_to_world` returns their *intermediate* world coordinate;
719        // `unsupported_axes` records them so a caller never mistakes that for a
720        // fully-decoded sky/spectral value.
721        let mut unsupported_axes = nonlinear_unsupported_axes(&ctype);
722
723        // Build the linear transform A. Precedence: CD, then PC×CDELT, then the
724        // legacy CROTA rotation, then a bare CDELT diagonal.
725        let has_cd = (1..=naxis)
726            .any(|i| (1..=naxis).any(|j| header.get_real(key!("CD{i}_{j}{a}").as_str()).is_some()));
727        let has_pc = (1..=naxis)
728            .any(|i| (1..=naxis).any(|j| header.get_real(key!("PC{i}_{j}{a}").as_str()).is_some()));
729        let has_crota =
730            (1..=naxis).any(|i| header.get_real(key!("CROTA{i}{a}").as_str()).is_some());
731        // §8: the PC/CDELT, CD, and legacy CROTA conventions are mutually exclusive.
732        if has_cd && has_pc {
733            return Err(FitsError::ConflictingWcsKeywords {
734                detail: "PC and CD both present",
735            });
736        }
737        if has_pc && has_crota {
738            return Err(FitsError::ConflictingWcsKeywords {
739                detail: "CROTA and PC both present",
740            });
741        }
742        let mut matrix = vec![0.0; naxis * naxis];
743        if has_cd {
744            for i in 0..naxis {
745                for j in 0..naxis {
746                    matrix[i * naxis + j] = header
747                        .get_real(key!("CD{}_{}{a}", i + 1, j + 1).as_str())
748                        .unwrap_or(0.0);
749                }
750            }
751        } else {
752            for i in 0..naxis {
753                for j in 0..naxis {
754                    let pc = header
755                        .get_real(key!("PC{}_{}{a}", i + 1, j + 1).as_str())
756                        .unwrap_or(if i == j { 1.0 } else { 0.0 });
757                    matrix[i * naxis + j] = cdelt[i] * pc;
758                }
759            }
760            // Legacy CROTA: rotate the celestial 2-axis sub-block (only when no PC
761            // was given, per the convention that CROTA and PC are exclusive).
762            if !has_pc && let Some((lng, lat, _)) = celestial_axes {
763                let rho = header
764                    .get_real(key!("CROTA{}{a}", lat + 1).as_str())
765                    .or_else(|| header.get_real(key!("CROTA{}{a}", lng + 1).as_str()))
766                    .unwrap_or(0.0);
767                if rho != 0.0 {
768                    let (c, s) = ((rho * D2R).cos(), (rho * D2R).sin());
769                    matrix[lng * naxis + lng] = cdelt[lng] * c;
770                    matrix[lng * naxis + lat] = -cdelt[lat] * s;
771                    matrix[lat * naxis + lng] = cdelt[lng] * s;
772                    matrix[lat * naxis + lat] = cdelt[lat] * c;
773                }
774            }
775        }
776        // §8.2: CRVAL/CDELT are in CUNITia units, but the projection math runs in
777        // degrees — scale each celestial axis's reference value and its matrix row
778        // (the inverse is computed after, so both directions stay consistent).
779        if let Some((lng, lat, _)) = celestial_axes {
780            for ax in [lng, lat] {
781                let f = unit_to_degrees(&cunit[ax]);
782                crval[ax] *= f;
783                for j in 0..naxis {
784                    matrix[ax * naxis + j] *= f;
785                }
786            }
787        }
788        let inverse = invert(&matrix, naxis).ok_or(FitsError::InvalidValue {
789            card: "singular WCS transform matrix".to_string(),
790        })?;
791
792        let celestial = match celestial_axes {
793            Some((lng, lat, proj)) => {
794                // Latitude-axis PVi_0..PVi_20 — the projection parameters.
795                let pv: Vec<f64> = (0..=20)
796                    .map(|m| {
797                        header
798                            .get_real(key!("PV{}_{m}{a}", lat + 1).as_str())
799                            .unwrap_or(0.0)
800                    })
801                    .collect();
802                // A conic's mid-latitude θ_a = PVi_1 is mandatory and must be
803                // non-zero; θ_a = 0 (absent, or explicitly 0) is a degenerate cone
804                // (`1/tan 0`). Treat it like an unimplemented projection — flag the
805                // axes and skip deprojection so they pass through the linear stage
806                // (an intermediate world coordinate) rather than returning NaN.
807                if proj.is_conic() && pv[1] == 0.0 {
808                    unsupported_axes.push(lng);
809                    unsupported_axes.push(lat);
810                    unsupported_axes.sort_unstable();
811                    None
812                } else {
813                    // Fiducial point: projection default, overridable by PVi_1a/
814                    // PVi_2a on the longitude axis (§8.3).
815                    let (mut phi0, mut theta0) = proj.reference_point(&pv);
816                    if let Some(v) = header.get_real(key!("PV{}_1{a}", lng + 1).as_str()) {
817                        phi0 = v;
818                    }
819                    if let Some(v) = header.get_real(key!("PV{}_2{a}", lng + 1).as_str()) {
820                        theta0 = v;
821                    }
822                    let (alpha0, delta0) = (crval[lng], crval[lat]);
823                    // LONPOLE (= LONPOLEa or PVi_3a): default φ0 if δ0 ≥ θ0, else φ0 + 180°.
824                    let phip = header
825                        .get_real(key!("LONPOLE{a}").as_str())
826                        .or_else(|| header.get_real(key!("PV{}_3{a}", lng + 1).as_str()))
827                        .unwrap_or(if delta0 >= theta0 { phi0 } else { phi0 + 180.0 });
828                    // LATPOLE (= LATPOLEa or PVi_4a): default 90°.
829                    let thetap = header
830                        .get_real(key!("LATPOLE{a}").as_str())
831                        .or_else(|| header.get_real(key!("PV{}_4{a}", lng + 1).as_str()))
832                        .unwrap_or(90.0);
833                    let pole = compute_pole(phi0, theta0, alpha0, delta0, phip, thetap);
834                    Some(Celestial {
835                        lng,
836                        lat,
837                        proj,
838                        pole,
839                        pv,
840                    })
841                }
842            }
843            None => None,
844        };
845
846        Ok(Wcs {
847            naxis,
848            ctype,
849            crval,
850            crpix,
851            matrix,
852            inverse,
853            celestial,
854            unsupported_axes,
855        })
856    }
857
858    /// Build a WCS for a binary-table **pixel list** (event list, §8.5, Table 22):
859    /// `columns` lists the 1-based table column numbers forming the coordinate axes
860    /// in order. Reads the column-indexed keyword family — `TCTYPn`/`TCRPXn`/
861    /// `TCRVLn`/`TCDLTn`/`TCROTn`/`TCUNIn`, the `TPCn_ka`/`TCDn_ka` matrices, and
862    /// `TPVn_ma` parameters — then evaluates it through the same pipeline as image
863    /// WCS (so projections, `CUNIT`, and the pole computation all apply).
864    pub(crate) fn from_pixel_list(
865        header: &Header,
866        columns: &[usize],
867        alt: Option<char>,
868    ) -> Result<Wcs> {
869        let a = alt.map(|c| c.to_string()).unwrap_or_default();
870        // Translate the column-indexed keywords into an equivalent image header,
871        // mapping column number `cN` → axis index `i+1`.
872        let mut h = Header::new();
873        h.set("WCSAXES", columns.len() as i64);
874        for (i, &c) in columns.iter().enumerate() {
875            let ax = i + 1;
876            if let Some(t) = header.get_text(key!("TCTYP{c}{a}").as_str()) {
877                h.set(key!("CTYPE{ax}").as_str(), t);
878            }
879            for (root, dst) in [
880                ("TCRPX", "CRPIX"),
881                ("TCRVL", "CRVAL"),
882                ("TCDLT", "CDELT"),
883                ("TCROT", "CROTA"),
884            ] {
885                if let Some(v) = header.get_real(key!("{root}{c}{a}").as_str()) {
886                    h.set(key!("{dst}{ax}").as_str(), v);
887                }
888            }
889            if let Some(t) = header.get_text(key!("TCUNI{c}{a}").as_str()) {
890                h.set(key!("CUNIT{ax}").as_str(), t);
891            }
892            for m in 0..=20 {
893                if let Some(v) = header.get_real(key!("TPV{c}_{m}{a}").as_str()) {
894                    h.set(key!("PV{ax}_{m}").as_str(), v);
895                }
896            }
897        }
898        // Linear-transform matrices: TPCn_ka / TCDn_ka, indexed by column pair.
899        for (i, &ci) in columns.iter().enumerate() {
900            for (j, &cj) in columns.iter().enumerate() {
901                if let Some(v) = header.get_real(key!("TPC{ci}_{cj}{a}").as_str()) {
902                    h.set(key!("PC{}_{}", i + 1, j + 1).as_str(), v);
903                }
904                if let Some(v) = header.get_real(key!("TCD{ci}_{cj}{a}").as_str()) {
905                    h.set(key!("CD{}_{}", i + 1, j + 1).as_str(), v);
906                }
907            }
908        }
909        if let Some(v) = header.get_real(key!("LONP{a}").as_str()) {
910            h.set("LONPOLE", v);
911        }
912        if let Some(v) = header.get_real(key!("LATP{a}").as_str()) {
913            h.set("LATPOLE", v);
914        }
915        Wcs::from_header(&h, None)
916    }
917
918    /// Build a WCS for an image stored in a binary-table **vector cell** (§8,
919    /// Table 22): `column` is the 1-based table column whose cells hold a
920    /// multidimensional array. Reads the axis-and-column-indexed keyword family —
921    /// `iCTYPn`/`iCRVLn`/`iCDLTn`/`jCRPXn`/`iCROTn`/`iCUNIn`, the `ijPCn`/`ijCDn`
922    /// matrices, and `iPVn_ma` (or abbreviated `iVn_ma`) parameters, where `i`/`j`
923    /// are the array axis and `n` the column — then evaluates it through the same
924    /// pipeline as image WCS. The rank is taken from `WCAXna`, else inferred from
925    /// the highest axis index present.
926    pub(crate) fn from_array_column(
927        header: &Header,
928        column: usize,
929        alt: Option<char>,
930    ) -> Result<Wcs> {
931        let a = alt.map(|c| c.to_string()).unwrap_or_default();
932        let naxis = header
933            .get_integer(key!("WCAX{column}{a}").as_str())
934            .map(|v| v.max(0) as usize)
935            .filter(|&n| n > 0)
936            .unwrap_or_else(|| {
937                (1..=99)
938                    .rev()
939                    .find(|&i| {
940                        header
941                            .get_text(key!("{i}CTYP{column}{a}").as_str())
942                            .is_some()
943                            || ["CRVL", "CDLT", "CRPX"].iter().any(|r| {
944                                header
945                                    .get_real(key!("{i}{r}{column}{a}").as_str())
946                                    .is_some()
947                            })
948                    })
949                    .unwrap_or(0)
950            });
951        if naxis == 0 {
952            return Err(FitsError::MissingKeyword { name: "iCTYPn" });
953        }
954        // Bound the untrusted rank before the per-axis synthesis loop below (a hostile
955        // `WCAXna` would otherwise drive an enormous loop); `from_header` re-checks too.
956        if naxis > 999 {
957            return Err(FitsError::KeywordOutOfRange { name: "WCAXn" });
958        }
959        let mut h = Header::new();
960        h.set("WCSAXES", naxis as i64);
961        for ax in 1..=naxis {
962            if let Some(t) = header.get_text(key!("{ax}CTYP{column}{a}").as_str()) {
963                h.set(key!("CTYPE{ax}").as_str(), t);
964            }
965            if let Some(t) = header.get_text(key!("{ax}CUNI{column}{a}").as_str()) {
966                h.set(key!("CUNIT{ax}").as_str(), t);
967            }
968            for (root, dst) in [
969                ("CRPX", "CRPIX"),
970                ("CRVL", "CRVAL"),
971                ("CDLT", "CDELT"),
972                ("CROT", "CROTA"),
973            ] {
974                if let Some(v) = header.get_real(key!("{ax}{root}{column}{a}").as_str()) {
975                    h.set(key!("{dst}{ax}").as_str(), v);
976                }
977            }
978            // PVi_m arrives as `iPVn_ma`, or the abbreviated `iVn_ma`.
979            for m in 0..=20 {
980                if let Some(v) = header
981                    .get_real(key!("{ax}PV{column}_{m}{a}").as_str())
982                    .or_else(|| header.get_real(key!("{ax}V{column}_{m}{a}").as_str()))
983                {
984                    h.set(key!("PV{ax}_{m}").as_str(), v);
985                }
986            }
987        }
988        // Linear-transform matrices: `ijPCn` / `ijCDn`, indexed by axis pair.
989        for i in 1..=naxis {
990            for j in 1..=naxis {
991                if let Some(v) = header.get_real(key!("{i}{j}PC{column}{a}").as_str()) {
992                    h.set(key!("PC{i}_{j}").as_str(), v);
993                }
994                if let Some(v) = header.get_real(key!("{i}{j}CD{column}{a}").as_str()) {
995                    h.set(key!("CD{i}_{j}").as_str(), v);
996                }
997            }
998        }
999        Wcs::from_header(&h, None)
1000    }
1001
1002    /// Map 1-based pixel coordinates to world coordinates. Celestial axes return
1003    /// `(α, δ)` in degrees; other axes return `CRVAL + ` the linear value.
1004    ///
1005    /// # Panics
1006    /// If `pixel.len() != self.naxis`. The coordinate count is a structural
1007    /// precondition (you know the WCS rank from [`Wcs::naxis`]), so a mismatch is a
1008    /// caller bug, not runtime data — hence an assert rather than a `Result`.
1009    pub fn pixel_to_world(&self, pixel: &[f64]) -> Vec<f64> {
1010        assert_eq!(pixel.len(), self.naxis, "pixel coordinate count");
1011        // Offset, then apply the linear transform → intermediate world coords.
1012        let offset: Vec<f64> = (0..self.naxis).map(|i| pixel[i] - self.crpix[i]).collect();
1013        let inter = matvec(&self.matrix, &offset, self.naxis);
1014
1015        let mut world = vec![0.0; self.naxis];
1016        for i in 0..self.naxis {
1017            world[i] = self.crval[i] + inter[i];
1018        }
1019        if let Some(c) = &self.celestial {
1020            let (phi, theta) = c.proj.deproject(inter[c.lng], inter[c.lat], &c.pv);
1021            let (ra, dec) = native_to_celestial(c.pole, phi, theta);
1022            world[c.lng] = ra;
1023            world[c.lat] = dec;
1024        }
1025        world
1026    }
1027
1028    /// Map world coordinates back to 1-based pixel coordinates (the inverse of
1029    /// [`Wcs::pixel_to_world`]).
1030    ///
1031    /// # Panics
1032    /// If `world.len() != self.naxis` (see [`Wcs::pixel_to_world`] — the count is a
1033    /// caller-controlled precondition, not runtime data).
1034    pub fn world_to_pixel(&self, world: &[f64]) -> Vec<f64> {
1035        assert_eq!(world.len(), self.naxis, "world coordinate count");
1036        // Recover the intermediate world coordinates.
1037        let mut inter = vec![0.0; self.naxis];
1038        for i in 0..self.naxis {
1039            inter[i] = world[i] - self.crval[i];
1040        }
1041        if let Some(c) = &self.celestial {
1042            let (phi, theta) = celestial_to_native(c.pole, world[c.lng], world[c.lat]);
1043            let (x, y) = c.proj.project(phi, theta, &c.pv);
1044            inter[c.lng] = x;
1045            inter[c.lat] = y;
1046        }
1047        // Invert the linear transform, then add back CRPIX.
1048        let offset = matvec(&self.inverse, &inter, self.naxis);
1049        (0..self.naxis).map(|i| offset[i] + self.crpix[i]).collect()
1050    }
1051}
1052
1053/// Which celestial coordinate an axis carries.
1054#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1055enum CelestialAxis {
1056    Longitude,
1057    Latitude,
1058}
1059
1060/// The celestial coordinate an axis carries, from its `CTYPE` head (§8.2): `RA` and
1061/// the `xLON`/`yzLN` forms are longitudes; `DEC` and `xLAT`/`yzLT` are latitudes;
1062/// `None` for any non-celestial axis. One classifier shared by [`find_celestial`]
1063/// and [`nonlinear_unsupported_axes`] so the two cannot drift.
1064fn celestial_axis(ctype: &str) -> Option<CelestialAxis> {
1065    let head = ctype.split('-').next().unwrap_or("").trim();
1066    if head == "RA" || head.ends_with("LON") || (head.len() == 4 && head.ends_with("LN")) {
1067        Some(CelestialAxis::Longitude)
1068    } else if head == "DEC" || head.ends_with("LAT") || (head.len() == 4 && head.ends_with("LT")) {
1069        Some(CelestialAxis::Latitude)
1070    } else {
1071        None
1072    }
1073}
1074
1075/// The trailing projection/algorithm code of a `CTYPE` (`RA---TAN` → `TAN`); `None`
1076/// when there is no hyphen-delimited suffix (a bare `RA`/`GLON`).
1077fn projection_code(ctype: &str) -> Option<&str> {
1078    ctype
1079        .rsplit_once('-')
1080        .map(|(_, code)| code)
1081        .filter(|c| !c.is_empty())
1082}
1083
1084/// Axis indices (0-based) whose non-linear transform this library does not
1085/// evaluate: a celestial axis whose 3-letter projection code is unimplemented
1086/// (quad-cube/HEALPix), or a non-linearly-sampled spectral axis (`TTTT-AAA`,
1087/// §8.4). Such an axis is taken through the linear stage only (its intermediate
1088/// world coordinate). The supported projections and a bare spectral type (which
1089/// is genuinely linear) are not flagged.
1090fn nonlinear_unsupported_axes(ctype: &[String]) -> Vec<usize> {
1091    let mut out = Vec::new();
1092    for (i, t) in ctype.iter().enumerate() {
1093        if celestial_axis(t).is_some() {
1094            if let Some(code) = projection_code(t)
1095                && code.len() == 3
1096                && Projection::from_code(code).is_none()
1097            {
1098                out.push(i);
1099            }
1100        } else {
1101            let head = t.split('-').next().unwrap_or("").trim_end();
1102            if SPECTRAL_TYPES.contains(&head)
1103                && t.get(5..).map(str::trim).is_some_and(|s| !s.is_empty())
1104            {
1105                out.push(i);
1106            }
1107        }
1108    }
1109    out
1110}
1111
1112/// Degrees per `CUNITia` angle unit; `1.0` for an absent, unknown, or `deg` unit.
1113fn unit_to_degrees(unit: &str) -> f64 {
1114    match unit.trim() {
1115        "arcmin" => 1.0 / 60.0,
1116        "arcsec" => 1.0 / 3600.0,
1117        "mas" => 1.0 / 3_600_000.0,
1118        "rad" => R2D,
1119        _ => 1.0, // "deg", "", or anything unrecognized
1120    }
1121}
1122
1123/// Locate the celestial longitude/latitude axis pair and their shared projection,
1124/// or `None` if the header has no complete celestial pair. Errors if the two axes
1125/// declare *different* projection codes — §8.2 requires them to match, so a
1126/// mismatch (or one axis projected and the other not) is a malformed header rather
1127/// than grounds to silently pick one.
1128fn find_celestial(ctype: &[String]) -> Result<Option<(usize, usize, Projection)>> {
1129    let mut lng = None;
1130    let mut lat = None;
1131    for (i, t) in ctype.iter().enumerate() {
1132        match celestial_axis(t) {
1133            Some(CelestialAxis::Longitude) => lng = lng.or(Some(i)),
1134            Some(CelestialAxis::Latitude) => lat = lat.or(Some(i)),
1135            None => {}
1136        }
1137    }
1138    let (Some(lng), Some(lat)) = (lng, lat) else {
1139        return Ok(None);
1140    };
1141    if projection_code(&ctype[lng]) != projection_code(&ctype[lat]) {
1142        return Err(FitsError::ConflictingWcsKeywords {
1143            detail: "celestial longitude and latitude axes declare different projections",
1144        });
1145    }
1146    Ok(projection_code(&ctype[lng])
1147        .and_then(Projection::from_code)
1148        .map(|proj| (lng, lat, proj)))
1149}
1150
1151/// Native spherical (φ, θ) → celestial (α, δ), all degrees, given the celestial
1152/// pole `(α_p, δ_p, φ_p)` (CG 2002 eq. 2).
1153fn native_to_celestial(pole: CelestialPole, phi: f64, theta: f64) -> (f64, f64) {
1154    let CelestialPole {
1155        ra: ap,
1156        dec: dp,
1157        lonpole: fp,
1158    } = pole;
1159    let (tr, dpr, dphi) = (theta * D2R, dp * D2R, (phi - fp) * D2R);
1160    let sin_d = tr.sin() * dpr.sin() + tr.cos() * dpr.cos() * dphi.cos();
1161    let dec = sin_d.clamp(-1.0, 1.0).asin() * R2D;
1162    let y = -tr.cos() * dphi.sin();
1163    let x = tr.sin() * dpr.cos() - tr.cos() * dpr.sin() * dphi.cos();
1164    (norm360(ap + y.atan2(x) * R2D), dec)
1165}
1166
1167/// Celestial (α, δ) → native spherical (φ, θ), all degrees (CG 2002 eq. 5).
1168fn celestial_to_native(pole: CelestialPole, ra: f64, dec: f64) -> (f64, f64) {
1169    let CelestialPole {
1170        ra: ap,
1171        dec: dp,
1172        lonpole: fp,
1173    } = pole;
1174    let (dr, dpr, dalpha) = (dec * D2R, dp * D2R, (ra - ap) * D2R);
1175    let sin_t = dr.sin() * dpr.sin() + dr.cos() * dpr.cos() * dalpha.cos();
1176    let theta = sin_t.clamp(-1.0, 1.0).asin() * R2D;
1177    let y = -dr.cos() * dalpha.sin();
1178    let x = dr.sin() * dpr.cos() - dr.cos() * dpr.sin() * dalpha.cos();
1179    (norm180(fp + y.atan2(x) * R2D), theta)
1180}
1181
1182/// Compute the celestial pole `(α_p, δ_p, φ_p)` from the fiducial point
1183/// `(φ₀, θ₀) → (α₀, δ₀)`, `φ_p` (LONPOLE), and `θ_p` (LATPOLE) (CG 2002 §2.4).
1184/// Zenithal (`θ₀ = 90°`) reduces to `(α₀, δ₀, φ_p)`.
1185fn compute_pole(phi0: f64, theta0: f64, a0: f64, d0: f64, phip: f64, thetap: f64) -> CelestialPole {
1186    if (theta0 - 90.0).abs() < 1e-12 {
1187        return CelestialPole {
1188            ra: a0,
1189            dec: d0,
1190            lonpole: phip,
1191        };
1192    }
1193    let (t0, d0r) = (theta0 * D2R, d0 * D2R);
1194    let dphi = (phip - phi0) * D2R;
1195    // sinδ0 = sinθ0·sinδ_p + cosθ0·cos(φ_p−φ0)·cosδ_p = R·cos(δ_p − β).
1196    let a = t0.sin();
1197    let b = t0.cos() * dphi.cos();
1198    let rmag = a.hypot(b);
1199    let beta = a.atan2(b);
1200    let ac = (d0r.sin() / rmag).clamp(-1.0, 1.0).acos();
1201    // Two δ_p solutions; pick the one in range nearest LATPOLE.
1202    let c1 = beta + ac;
1203    let c2 = beta - ac;
1204    let in_range = |x: f64| (-FRAC_PI_2..=FRAC_PI_2).contains(&x);
1205    let dpr = match (in_range(c1), in_range(c2)) {
1206        (true, true) => {
1207            if (c1 - thetap * D2R).abs() <= (c2 - thetap * D2R).abs() {
1208                c1
1209            } else {
1210                c2
1211            }
1212        }
1213        (true, false) => c1,
1214        (false, true) => c2,
1215        (false, false) => c1.clamp(-FRAC_PI_2, FRAC_PI_2),
1216    };
1217    let dp = dpr * R2D;
1218    // α_p from the fiducial constraint (inverting eq. 2 at (φ0, θ0)).
1219    let fphi = (phi0 - phip) * D2R;
1220    let y = -t0.cos() * fphi.sin();
1221    let x = t0.sin() * dpr.cos() - t0.cos() * dpr.sin() * fphi.cos();
1222    let ap = a0 - y.atan2(x) * R2D;
1223    CelestialPole {
1224        ra: norm360(ap),
1225        dec: dp,
1226        lonpole: phip,
1227    }
1228}
1229
1230/// Read `PREFIX1..PREFIXn` (with alternate suffix) into a vector, defaulting
1231/// missing entries.
1232fn axis_vec(header: &Header, prefix: &str, alt: &str, naxis: usize, default: f64) -> Vec<f64> {
1233    (1..=naxis)
1234        .map(|i| {
1235            header
1236                .get_real(key!("{prefix}{i}{alt}").as_str())
1237                .unwrap_or(default)
1238        })
1239        .collect()
1240}
1241
1242/// Multiply the row-major `n×n` matrix `m` by vector `v`.
1243fn matvec(m: &[f64], v: &[f64], n: usize) -> Vec<f64> {
1244    (0..n)
1245        .map(|i| (0..n).map(|j| m[i * n + j] * v[j]).sum())
1246        .collect()
1247}
1248
1249/// Invert a row-major `n×n` matrix by Gauss–Jordan elimination with partial
1250/// pivoting. Returns `None` if singular.
1251fn invert(m: &[f64], n: usize) -> Option<Vec<f64>> {
1252    let mut a = m.to_vec();
1253    let mut inv = vec![0.0; n * n];
1254    for i in 0..n {
1255        inv[i * n + i] = 1.0;
1256    }
1257    for col in 0..n {
1258        // Partial pivot: largest magnitude in this column at or below the diagonal.
1259        let mut pivot = col;
1260        for r in (col + 1)..n {
1261            if a[r * n + col].abs() > a[pivot * n + col].abs() {
1262                pivot = r;
1263            }
1264        }
1265        if a[pivot * n + col].abs() < 1e-300 {
1266            return None;
1267        }
1268        if pivot != col {
1269            for k in 0..n {
1270                a.swap(col * n + k, pivot * n + k);
1271                inv.swap(col * n + k, pivot * n + k);
1272            }
1273        }
1274        let d = a[col * n + col];
1275        for k in 0..n {
1276            a[col * n + k] /= d;
1277            inv[col * n + k] /= d;
1278        }
1279        for r in 0..n {
1280            if r == col {
1281                continue;
1282            }
1283            let f = a[r * n + col];
1284            if f != 0.0 {
1285                for k in 0..n {
1286                    a[r * n + k] -= f * a[col * n + k];
1287                    inv[r * n + k] -= f * inv[col * n + k];
1288                }
1289            }
1290        }
1291    }
1292    Some(inv)
1293}
1294
1295/// Normalize an angle to `[0, 360)` degrees.
1296fn norm360(a: f64) -> f64 {
1297    a.rem_euclid(360.0)
1298}
1299
1300/// Normalize an angle to `[−180, 180)` degrees.
1301fn norm180(a: f64) -> f64 {
1302    (a + 180.0).rem_euclid(360.0) - 180.0
1303}
1304
1305#[cfg(test)]
1306mod tests;