1use 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
46const SPECTRAL_TYPES: &[&str] = &[
50 "FREQ", "ENER", "WAVN", "VRAD", "WAVE", "VOPT", "ZOPT", "AWAV", "VELO", "BETA",
51];
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Projection {
56 Tan,
58 Sin,
60 Arc,
62 Stg,
64 Zea,
66 Car,
68 Cea,
70 Mer,
72 Sfl,
74 Ait,
76 Mol,
78 Zpn,
80 Cyp,
82 Par,
84 Cop,
86 Coe,
88 Cod,
90 Coo,
92 Bon,
94 Air,
96 Azp,
98 Pco,
100 Szp,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108enum Family {
109 Zenithal,
111 ZenithalPerspective,
113 Conic,
115 Other,
117}
118
119const 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 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 fn is_zenithal(self) -> bool {
168 self.family() == Family::Zenithal
169 }
170
171 fn is_conic(self) -> bool {
173 self.family() == Family::Conic
174 }
175
176 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 fn deproject(self, x: f64, y: f64, pv: &[f64]) -> (f64, f64) {
189 if matches!(self, Projection::Azp) {
190 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 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 let (xp, yp, zp) = szp_vertex(pv);
219 let (cx, cy) = (x / R2D, y / R2D);
220 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 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 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 Projection::Zpn => zpn_zeta(u, pv),
256 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 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 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 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 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 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 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 Projection::Bon => {
332 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 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 let s2 = SQRT_2;
407 let target = PI * t.sin();
408 let mut g = t; 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 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 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 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 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 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
544fn 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
556fn 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
567fn 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
577fn air_zeta(u: f64, theta_b: f64) -> f64 {
579 let mut z = u.max(1e-6); 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
595fn zpn_poly(zeta: f64, pv: &[f64]) -> f64 {
597 pv.iter().rev().fold(0.0, |acc, &p| acc * zeta + p)
598}
599
600fn 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 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#[derive(Debug, Clone)]
626pub struct Wcs {
627 pub naxis: usize,
629 pub ctype: Vec<String>,
631 pub crval: Vec<f64>,
633 pub crpix: Vec<f64>,
635 matrix: Vec<f64>,
638 inverse: Vec<f64>,
640 celestial: Option<Celestial>,
643 pub unsupported_axes: Vec<usize>,
648}
649
650#[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 pole: CelestialPole,
666 pv: Vec<f64>,
668}
669
670impl Wcs {
671 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 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 let mut unsupported_axes = nonlinear_unsupported_axes(&ctype);
722
723 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn pixel_to_world(&self, pixel: &[f64]) -> Vec<f64> {
1010 assert_eq!(pixel.len(), self.naxis, "pixel coordinate count");
1011 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 pub fn world_to_pixel(&self, world: &[f64]) -> Vec<f64> {
1035 assert_eq!(world.len(), self.naxis, "world coordinate count");
1036 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 let offset = matvec(&self.inverse, &inter, self.naxis);
1049 (0..self.naxis).map(|i| offset[i] + self.crpix[i]).collect()
1050 }
1051}
1052
1053#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1055enum CelestialAxis {
1056 Longitude,
1057 Latitude,
1058}
1059
1060fn 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
1075fn projection_code(ctype: &str) -> Option<&str> {
1078 ctype
1079 .rsplit_once('-')
1080 .map(|(_, code)| code)
1081 .filter(|c| !c.is_empty())
1082}
1083
1084fn 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
1112fn 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, }
1121}
1122
1123fn 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
1151fn 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
1167fn 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
1182fn 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 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 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 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
1230fn 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
1242fn 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
1249fn 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 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
1295fn norm360(a: f64) -> f64 {
1297 a.rem_euclid(360.0)
1298}
1299
1300fn norm180(a: f64) -> f64 {
1302 (a + 180.0).rem_euclid(360.0) - 180.0
1303}
1304
1305#[cfg(test)]
1306mod tests;