use std::f64::consts::FRAC_PI_2;
use std::f64::consts::FRAC_PI_4;
use std::f64::consts::PI;
use std::f64::consts::SQRT_2;
use crate::error::FitsError;
use crate::error::Result;
use crate::header::Header;
use crate::keyword::key;
const R2D: f64 = 180.0 / PI;
const D2R: f64 = PI / 180.0;
const SPECTRAL_TYPES: &[&str] = &[
"FREQ", "ENER", "WAVN", "VRAD", "WAVE", "VOPT", "ZOPT", "AWAV", "VELO", "BETA",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Projection {
Tan,
Sin,
Arc,
Stg,
Zea,
Car,
Cea,
Mer,
Sfl,
Ait,
Mol,
Zpn,
Cyp,
Par,
Cop,
Coe,
Cod,
Coo,
Bon,
Air,
Azp,
Pco,
Szp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Family {
Zenithal,
ZenithalPerspective,
Conic,
Other,
}
const PROJECTIONS: &[(&str, Projection, Family)] = &[
("TAN", Projection::Tan, Family::Zenithal),
("SIN", Projection::Sin, Family::Zenithal),
("ARC", Projection::Arc, Family::Zenithal),
("STG", Projection::Stg, Family::Zenithal),
("ZEA", Projection::Zea, Family::Zenithal),
("ZPN", Projection::Zpn, Family::Zenithal),
("AIR", Projection::Air, Family::Zenithal),
("AZP", Projection::Azp, Family::ZenithalPerspective),
("SZP", Projection::Szp, Family::ZenithalPerspective),
("COP", Projection::Cop, Family::Conic),
("COE", Projection::Coe, Family::Conic),
("COD", Projection::Cod, Family::Conic),
("COO", Projection::Coo, Family::Conic),
("CAR", Projection::Car, Family::Other),
("CEA", Projection::Cea, Family::Other),
("MER", Projection::Mer, Family::Other),
("SFL", Projection::Sfl, Family::Other),
("AIT", Projection::Ait, Family::Other),
("MOL", Projection::Mol, Family::Other),
("CYP", Projection::Cyp, Family::Other),
("PAR", Projection::Par, Family::Other),
("BON", Projection::Bon, Family::Other),
("PCO", Projection::Pco, Family::Other),
];
impl Projection {
fn from_code(code: &str) -> Option<Projection> {
PROJECTIONS
.iter()
.find(|&&(c, ..)| c == code)
.map(|&(_, proj, _)| proj)
}
fn family(self) -> Family {
PROJECTIONS
.iter()
.find(|&&(_, proj, _)| proj == self)
.map(|&(.., fam)| fam)
.expect("every Projection variant is listed in PROJECTIONS")
}
fn is_zenithal(self) -> bool {
self.family() == Family::Zenithal
}
fn is_conic(self) -> bool {
self.family() == Family::Conic
}
fn reference_point(self, pv: &[f64]) -> (f64, f64) {
match self.family() {
Family::Zenithal | Family::ZenithalPerspective => (0.0, 90.0),
Family::Conic => (0.0, pv.get(1).copied().unwrap_or(0.0)),
Family::Other => (0.0, 0.0),
}
}
fn deproject(self, x: f64, y: f64, pv: &[f64]) -> (f64, f64) {
if matches!(self, Projection::Azp) {
let (mu, gr) = (pv[1], pv[2] * D2R);
let yc = y * gr.cos();
let r = x.hypot(yc) / R2D;
let phi = x.atan2(-yc);
let (a, b, c) = (r, r * phi.cos() * gr.tan() - (mu + 1.0), -r * mu);
let rad = a.hypot(b);
let psi = b.atan2(a);
let base = (c / rad).clamp(-1.0, 1.0).asin();
let half_pi = FRAC_PI_2;
let cand = [base - psi, PI - base - psi];
let theta = cand
.into_iter()
.min_by(|p, q| {
(p - half_pi)
.abs()
.partial_cmp(&(q - half_pi).abs())
.unwrap()
})
.unwrap();
return (phi * R2D, theta * R2D);
}
if matches!(self, Projection::Szp) {
let (xp, yp, zp) = szp_vertex(pv);
let (cx, cy) = (x / R2D, y / R2D);
let (a0, a1) = (cx * zp, -(cx - xp));
let (b0, b1) = (-cy * zp, cy - yp);
let qa = a1 * a1 + b1 * b1 + zp * zp;
let qb = 2.0 * (a0 * a1 + b0 * b1) - 2.0 * zp * zp;
let qc = a0 * a0 + b0 * b0;
let disc = (qb * qb - 4.0 * qa * qc).max(0.0).sqrt();
let s1 = (-qb - disc) / (2.0 * qa);
let s2 = (-qb + disc) / (2.0 * qa);
let sigma = if (0.0..=2.0).contains(&s1) { s1 } else { s2 };
let theta = (1.0 - sigma).clamp(-1.0, 1.0).asin();
let (a, b) = (a0 + a1 * sigma, b0 + b1 * sigma);
let phi = a.atan2(b);
return (phi * R2D, theta * R2D);
}
if self.is_conic() {
let (c, y0) = self.conic_consts(pv);
let s = pv[1].signum();
let r = s * x.hypot(y0 - y);
let phi = (s * x).atan2(s * (y0 - y)) * R2D / c;
return (phi, self.conic_theta(r, pv, c));
}
if self.is_zenithal() {
let r = x.hypot(y);
let phi = if r == 0.0 { 0.0 } else { x.atan2(-y) * R2D };
let u = r / R2D;
let zeta = match self {
Projection::Tan => u.atan(),
Projection::Sin => u.clamp(-1.0, 1.0).asin(),
Projection::Arc => u,
Projection::Zea => 2.0 * (u / 2.0).clamp(-1.0, 1.0).asin(),
Projection::Stg => 2.0 * (u / 2.0).atan(),
Projection::Zpn => zpn_zeta(u, pv),
Projection::Air => air_zeta(u, pv[1]),
_ => unreachable!(),
};
(phi, 90.0 - zeta * R2D)
} else {
match self {
Projection::Car => (x, y),
Projection::Cea => {
let lambda = pv.get(1).filter(|&&v| v != 0.0).copied().unwrap_or(1.0);
(x, (lambda * y / R2D).clamp(-1.0, 1.0).asin() * R2D)
}
Projection::Mer => (x, (2.0 * (y / R2D).exp().atan()) * R2D - 90.0),
Projection::Sfl => (x / (y * D2R).cos(), y),
Projection::Ait => {
let (u, v) = (x * D2R, y * D2R);
let z2 = (1.0 - (u / 4.0).powi(2) - (v / 2.0).powi(2)).max(0.0);
let z = z2.sqrt();
let phi = 2.0 * (z * u / 2.0).atan2(2.0 * z2 - 1.0) * R2D;
let theta = (v * z).clamp(-1.0, 1.0).asin() * R2D;
(phi, theta)
}
Projection::Mol => {
let s2 = SQRT_2;
let gamma = (y / (s2 * R2D)).clamp(-1.0, 1.0).asin();
let theta = ((2.0 * gamma + (2.0 * gamma).sin()) / PI).asin() * R2D;
let phi = PI * x / (2.0 * s2 * gamma.cos());
(phi, theta)
}
Projection::Cyp => {
let (mu, lambda) = (
pv[1],
pv.get(2).filter(|&&v| v != 0.0).copied().unwrap_or(1.0),
);
let eta = (y / R2D) / (mu + lambda);
let theta = eta.atan2(1.0)
+ (eta * mu / (1.0 + eta * eta).sqrt())
.clamp(-1.0, 1.0)
.asin();
(x / lambda, theta * R2D)
}
Projection::Par => {
let theta = 3.0 * (y / 180.0).clamp(-1.0, 1.0).asin();
(x / (2.0 * (2.0 * theta / 3.0).cos() - 1.0), theta * R2D)
}
Projection::Pco => {
let (xr, yr) = (x * D2R, y * D2R);
if yr.abs() < 1e-12 {
return (x, 0.0);
}
let mut th = yr;
for _ in 0..100 {
let d = yr - th;
let cot = 1.0 / th.tan();
let f = xr * xr + d * d - 2.0 * d * cot;
let fp = -2.0 * d + 2.0 * cot + 2.0 * d / th.sin().powi(2);
let step = f / fp;
th -= step;
if step.abs() < 1e-13 {
break;
}
}
let d = yr - th;
let tanth = th.tan();
let omega = (xr * tanth).atan2(1.0 - d * tanth);
(omega / th.sin() * R2D, th * R2D)
}
Projection::Bon => {
if pv[1] == 0.0 {
return (x / (y * D2R).cos(), y);
}
let t1 = pv[1] * D2R;
let y0 = t1 + 1.0 / t1.tan();
let s = pv[1].signum();
let yc = y0 - y * D2R;
let r = s * (x * D2R).hypot(yc);
let tr = y0 - r;
let aphi = (s * x * D2R).atan2(s * yc);
(aphi * r / tr.cos() * R2D, tr * R2D)
}
_ => unreachable!(),
}
}
}
fn project(self, phi: f64, theta: f64, pv: &[f64]) -> (f64, f64) {
if matches!(self, Projection::Azp) {
let (mu, gr) = (pv[1], pv[2] * D2R);
let (tr, pr) = (theta * D2R, phi * D2R);
let denom = (mu + tr.sin()) + tr.cos() * pr.cos() * gr.tan();
let r = R2D * (mu + 1.0) * tr.cos() / denom;
return (r * pr.sin(), -r * pr.cos() / gr.cos());
}
if matches!(self, Projection::Szp) {
let (xp, yp, zp) = szp_vertex(pv);
let (tr, pr) = (theta * D2R, phi * D2R);
let sigma = 1.0 - tr.sin();
let denom = zp - sigma;
let x = R2D * (zp * tr.cos() * pr.sin() - xp * sigma) / denom;
let y = R2D * (-zp * tr.cos() * pr.cos() - yp * sigma) / denom;
return (x, y);
}
if self.is_conic() {
let (c, y0) = self.conic_consts(pv);
let r = self.conic_radius(theta, pv);
let cp = (c * phi) * D2R;
return (r * cp.sin(), y0 - r * cp.cos());
}
if self.is_zenithal() {
let zeta = (90.0 - theta) * D2R;
let r = match self {
Projection::Tan => R2D * zeta.tan(),
Projection::Sin => R2D * zeta.sin(),
Projection::Arc => R2D * zeta,
Projection::Zea => 2.0 * R2D * (zeta / 2.0).sin(),
Projection::Stg => 2.0 * R2D * (zeta / 2.0).tan(),
Projection::Zpn => R2D * zpn_poly(zeta, pv),
Projection::Air => R2D * air_radius_u(zeta, pv[1]),
_ => unreachable!(),
};
let p = phi * D2R;
(r * p.sin(), -r * p.cos())
} else {
let t = theta * D2R;
match self {
Projection::Car => (phi, theta),
Projection::Cea => {
let lambda = pv.get(1).filter(|&&v| v != 0.0).copied().unwrap_or(1.0);
(phi, R2D * t.sin() / lambda)
}
Projection::Mer => (phi, R2D * ((45.0 + theta / 2.0) * D2R).tan().ln()),
Projection::Sfl => (phi * t.cos(), theta),
Projection::Ait => {
let pr = phi * D2R;
let gamma = R2D * (2.0 / (1.0 + t.cos() * (pr / 2.0).cos())).sqrt();
(2.0 * gamma * t.cos() * (pr / 2.0).sin(), gamma * t.sin())
}
Projection::Mol => {
let s2 = SQRT_2;
let target = PI * t.sin();
let mut g = t; for _ in 0..100 {
let f = 2.0 * g + (2.0 * g).sin() - target;
let d = 2.0 + 2.0 * (2.0 * g).cos();
let step = f / d;
g -= step;
if step.abs() < 1e-14 {
break;
}
}
((2.0 * s2 / PI) * phi * g.cos(), s2 * R2D * g.sin())
}
Projection::Cyp => {
let (mu, lambda) = (
pv[1],
pv.get(2).filter(|&&v| v != 0.0).copied().unwrap_or(1.0),
);
(lambda * phi, R2D * (mu + lambda) * t.sin() / (mu + t.cos()))
}
Projection::Par => (
phi * (2.0 * (2.0 * t / 3.0).cos() - 1.0),
180.0 * (t / 3.0).sin(),
),
Projection::Bon => {
if pv[1] == 0.0 {
return (phi * t.cos(), theta);
}
let t1 = pv[1] * D2R;
let y0 = t1 + 1.0 / t1.tan();
let r = y0 - t;
let aphi = phi * D2R * t.cos() / r;
(R2D * r * aphi.sin(), R2D * (y0 - r * aphi.cos()))
}
Projection::Pco => {
if theta.abs() < 1e-12 {
return (phi, 0.0);
}
let omega = phi * D2R * t.sin();
let cot = 1.0 / t.tan();
(
R2D * cot * omega.sin(),
theta + R2D * cot * (1.0 - omega.cos()),
)
}
_ => unreachable!(),
}
}
}
fn conic_consts(self, pv: &[f64]) -> (f64, f64) {
let (ta, eta) = (pv[1] * D2R, pv[2] * D2R);
let (t1, t2) = (ta - eta, ta + eta);
match self {
Projection::Cop => {
let c = ta.sin();
(c, R2D * eta.cos() / ta.tan())
}
Projection::Coe => {
let (s1, s2) = (t1.sin(), t2.sin());
let c = (s1 + s2) / 2.0;
let y0 = R2D / c * (1.0 + s1 * s2 - 2.0 * c * ta.sin()).max(0.0).sqrt();
(c, y0)
}
Projection::Cod => {
let (c, k) = if eta.abs() < 1e-12 {
(ta.sin(), 1.0)
} else {
(ta.sin() * eta.sin() / eta, eta / eta.tan())
};
(c, R2D * k / ta.tan())
}
Projection::Coo => {
let c = if eta.abs() < 1e-12 {
ta.sin()
} else {
(t2.cos() / t1.cos()).ln()
/ ((FRAC_PI_4 - t2 / 2.0).tan() / (FRAC_PI_4 - t1 / 2.0).tan()).ln()
};
let psi = R2D * t1.cos() / (c * (FRAC_PI_4 - t1 / 2.0).tan().powf(c));
(c, psi * (FRAC_PI_4 - ta / 2.0).tan().powf(c))
}
_ => unreachable!(),
}
}
fn conic_radius(self, theta: f64, pv: &[f64]) -> f64 {
let (c, y0) = self.conic_consts(pv);
let (ta, eta, t) = (pv[1] * D2R, pv[2] * D2R, theta * D2R);
let (t1, t2) = (ta - eta, ta + eta);
match self {
Projection::Cop => R2D * eta.cos() * (1.0 / ta.tan() - (t - ta).tan()),
Projection::Coe => {
let (s1, s2) = (t1.sin(), t2.sin());
R2D / c * (1.0 + s1 * s2 - 2.0 * c * t.sin()).max(0.0).sqrt()
}
Projection::Cod => y0 + (pv[1] - theta),
Projection::Coo => {
let psi = R2D * t1.cos() / (c * (FRAC_PI_4 - t1 / 2.0).tan().powf(c));
psi * (FRAC_PI_4 - t / 2.0).tan().powf(c)
}
_ => unreachable!(),
}
}
fn conic_theta(self, r: f64, pv: &[f64], c: f64) -> f64 {
let (ta, eta) = (pv[1] * D2R, pv[2] * D2R);
let (t1, t2) = (ta - eta, ta + eta);
match self {
Projection::Cop => {
let tan = 1.0 / ta.tan() - r / (R2D * eta.cos());
pv[1] + tan.atan() * R2D
}
Projection::Coe => {
let (s1, s2) = (t1.sin(), t2.sin());
let sin_t = (1.0 + s1 * s2 - (r * c / R2D).powi(2)) / (2.0 * c);
sin_t.clamp(-1.0, 1.0).asin() * R2D
}
Projection::Cod => {
let y0 = self.conic_consts(pv).1;
pv[1] - (r - y0)
}
Projection::Coo => {
let psi = R2D * t1.cos() / (c * (FRAC_PI_4 - t1 / 2.0).tan().powf(c));
90.0 - 2.0 * (r / psi).powf(1.0 / c).atan() * R2D
}
_ => unreachable!(),
}
}
}
fn szp_vertex(pv: &[f64]) -> (f64, f64, f64) {
let mu = pv[1];
let (phic, thetac) = (pv[2] * D2R, pv[3] * D2R);
(
-mu * thetac.cos() * phic.sin(),
mu * thetac.cos() * phic.cos(),
mu * thetac.sin() + 1.0,
)
}
fn air_k(theta_b: f64) -> f64 {
let xi_b = (90.0 - theta_b) * D2R / 2.0;
if xi_b.abs() < 1e-12 {
-0.5
} else {
xi_b.cos().ln() / xi_b.tan().powi(2)
}
}
fn air_radius_u(zeta: f64, theta_b: f64) -> f64 {
let xi = zeta / 2.0;
if xi.abs() < 1e-12 {
return 0.0;
}
-2.0 * (xi.cos().ln() / xi.tan() + air_k(theta_b) * xi.tan())
}
fn air_zeta(u: f64, theta_b: f64) -> f64 {
let mut z = u.max(1e-6); for _ in 0..100 {
let f = air_radius_u(z, theta_b) - u;
let d = (air_radius_u(z + 1e-7, theta_b) - air_radius_u(z - 1e-7, theta_b)) / 2e-7;
if d == 0.0 {
break;
}
let step = f / d;
z -= step;
if step.abs() < 1e-13 {
break;
}
}
z
}
fn zpn_poly(zeta: f64, pv: &[f64]) -> f64 {
pv.iter().rev().fold(0.0, |acc, &p| acc * zeta + p)
}
fn zpn_zeta(u: f64, pv: &[f64]) -> f64 {
let mut z = u;
for _ in 0..100 {
let f = zpn_poly(z, pv) - u;
let d: f64 = pv
.iter()
.enumerate()
.skip(1)
.map(|(m, &p)| m as f64 * p * z.powi(m as i32 - 1))
.sum();
if d == 0.0 {
break;
}
let step = f / d;
z -= step;
if step.abs() < 1e-14 {
break;
}
}
z
}
#[derive(Debug, Clone)]
pub struct Wcs {
pub naxis: usize,
pub ctype: Vec<String>,
pub crval: Vec<f64>,
pub crpix: Vec<f64>,
matrix: Vec<f64>,
inverse: Vec<f64>,
celestial: Option<Celestial>,
pub unsupported_axes: Vec<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct CelestialPole {
ra: f64,
dec: f64,
lonpole: f64,
}
#[derive(Debug, Clone)]
struct Celestial {
lng: usize,
lat: usize,
proj: Projection,
pole: CelestialPole,
pv: Vec<f64>,
}
impl Wcs {
pub(crate) fn from_header(header: &Header, alt: Option<char>) -> Result<Wcs> {
let a = alt.map(|c| c.to_string()).unwrap_or_default();
let naxis = header
.get_integer(key!("WCSAXES{a}").as_str())
.or_else(|| header.get_integer("NAXIS"))
.ok_or(FitsError::MissingKeyword { name: "WCSAXES" })?
.max(0) as usize;
if naxis == 0 {
return Err(FitsError::InvalidValue {
card: "WCSAXES = 0".to_string(),
});
}
if naxis > 999 {
return Err(FitsError::KeywordOutOfRange { name: "WCSAXES" });
}
let ctype: Vec<String> = (1..=naxis)
.map(|i| {
header
.get_text(key!("CTYPE{i}{a}").as_str())
.unwrap_or("")
.to_string()
})
.collect();
let mut crval = axis_vec(header, "CRVAL", &a, naxis, 0.0);
let crpix = axis_vec(header, "CRPIX", &a, naxis, 0.0);
let cdelt = axis_vec(header, "CDELT", &a, naxis, 1.0);
let cunit: Vec<String> = (1..=naxis)
.map(|i| {
header
.get_text(key!("CUNIT{i}{a}").as_str())
.unwrap_or("")
.to_string()
})
.collect();
let celestial_axes = find_celestial(&ctype)?;
let mut unsupported_axes = nonlinear_unsupported_axes(&ctype);
let has_cd = (1..=naxis)
.any(|i| (1..=naxis).any(|j| header.get_real(key!("CD{i}_{j}{a}").as_str()).is_some()));
let has_pc = (1..=naxis)
.any(|i| (1..=naxis).any(|j| header.get_real(key!("PC{i}_{j}{a}").as_str()).is_some()));
let has_crota =
(1..=naxis).any(|i| header.get_real(key!("CROTA{i}{a}").as_str()).is_some());
if has_cd && has_pc {
return Err(FitsError::ConflictingWcsKeywords {
detail: "PC and CD both present",
});
}
if has_pc && has_crota {
return Err(FitsError::ConflictingWcsKeywords {
detail: "CROTA and PC both present",
});
}
let mut matrix = vec![0.0; naxis * naxis];
if has_cd {
for i in 0..naxis {
for j in 0..naxis {
matrix[i * naxis + j] = header
.get_real(key!("CD{}_{}{a}", i + 1, j + 1).as_str())
.unwrap_or(0.0);
}
}
} else {
for i in 0..naxis {
for j in 0..naxis {
let pc = header
.get_real(key!("PC{}_{}{a}", i + 1, j + 1).as_str())
.unwrap_or(if i == j { 1.0 } else { 0.0 });
matrix[i * naxis + j] = cdelt[i] * pc;
}
}
if !has_pc && let Some((lng, lat, _)) = celestial_axes {
let rho = header
.get_real(key!("CROTA{}{a}", lat + 1).as_str())
.or_else(|| header.get_real(key!("CROTA{}{a}", lng + 1).as_str()))
.unwrap_or(0.0);
if rho != 0.0 {
let (c, s) = ((rho * D2R).cos(), (rho * D2R).sin());
matrix[lng * naxis + lng] = cdelt[lng] * c;
matrix[lng * naxis + lat] = -cdelt[lat] * s;
matrix[lat * naxis + lng] = cdelt[lng] * s;
matrix[lat * naxis + lat] = cdelt[lat] * c;
}
}
}
if let Some((lng, lat, _)) = celestial_axes {
for ax in [lng, lat] {
let f = unit_to_degrees(&cunit[ax]);
crval[ax] *= f;
for j in 0..naxis {
matrix[ax * naxis + j] *= f;
}
}
}
let inverse = invert(&matrix, naxis).ok_or(FitsError::InvalidValue {
card: "singular WCS transform matrix".to_string(),
})?;
let celestial = match celestial_axes {
Some((lng, lat, proj)) => {
let pv: Vec<f64> = (0..=20)
.map(|m| {
header
.get_real(key!("PV{}_{m}{a}", lat + 1).as_str())
.unwrap_or(0.0)
})
.collect();
if proj.is_conic() && pv[1] == 0.0 {
unsupported_axes.push(lng);
unsupported_axes.push(lat);
unsupported_axes.sort_unstable();
None
} else {
let (mut phi0, mut theta0) = proj.reference_point(&pv);
if let Some(v) = header.get_real(key!("PV{}_1{a}", lng + 1).as_str()) {
phi0 = v;
}
if let Some(v) = header.get_real(key!("PV{}_2{a}", lng + 1).as_str()) {
theta0 = v;
}
let (alpha0, delta0) = (crval[lng], crval[lat]);
let phip = header
.get_real(key!("LONPOLE{a}").as_str())
.or_else(|| header.get_real(key!("PV{}_3{a}", lng + 1).as_str()))
.unwrap_or(if delta0 >= theta0 { phi0 } else { phi0 + 180.0 });
let thetap = header
.get_real(key!("LATPOLE{a}").as_str())
.or_else(|| header.get_real(key!("PV{}_4{a}", lng + 1).as_str()))
.unwrap_or(90.0);
let pole = compute_pole(phi0, theta0, alpha0, delta0, phip, thetap);
Some(Celestial {
lng,
lat,
proj,
pole,
pv,
})
}
}
None => None,
};
Ok(Wcs {
naxis,
ctype,
crval,
crpix,
matrix,
inverse,
celestial,
unsupported_axes,
})
}
pub(crate) fn from_pixel_list(
header: &Header,
columns: &[usize],
alt: Option<char>,
) -> Result<Wcs> {
let a = alt.map(|c| c.to_string()).unwrap_or_default();
let mut h = Header::new();
h.set("WCSAXES", columns.len() as i64);
for (i, &c) in columns.iter().enumerate() {
let ax = i + 1;
if let Some(t) = header.get_text(key!("TCTYP{c}{a}").as_str()) {
h.set(key!("CTYPE{ax}").as_str(), t);
}
for (root, dst) in [
("TCRPX", "CRPIX"),
("TCRVL", "CRVAL"),
("TCDLT", "CDELT"),
("TCROT", "CROTA"),
] {
if let Some(v) = header.get_real(key!("{root}{c}{a}").as_str()) {
h.set(key!("{dst}{ax}").as_str(), v);
}
}
if let Some(t) = header.get_text(key!("TCUNI{c}{a}").as_str()) {
h.set(key!("CUNIT{ax}").as_str(), t);
}
for m in 0..=20 {
if let Some(v) = header.get_real(key!("TPV{c}_{m}{a}").as_str()) {
h.set(key!("PV{ax}_{m}").as_str(), v);
}
}
}
for (i, &ci) in columns.iter().enumerate() {
for (j, &cj) in columns.iter().enumerate() {
if let Some(v) = header.get_real(key!("TPC{ci}_{cj}{a}").as_str()) {
h.set(key!("PC{}_{}", i + 1, j + 1).as_str(), v);
}
if let Some(v) = header.get_real(key!("TCD{ci}_{cj}{a}").as_str()) {
h.set(key!("CD{}_{}", i + 1, j + 1).as_str(), v);
}
}
}
if let Some(v) = header.get_real(key!("LONP{a}").as_str()) {
h.set("LONPOLE", v);
}
if let Some(v) = header.get_real(key!("LATP{a}").as_str()) {
h.set("LATPOLE", v);
}
Wcs::from_header(&h, None)
}
pub(crate) fn from_array_column(
header: &Header,
column: usize,
alt: Option<char>,
) -> Result<Wcs> {
let a = alt.map(|c| c.to_string()).unwrap_or_default();
let naxis = header
.get_integer(key!("WCAX{column}{a}").as_str())
.map(|v| v.max(0) as usize)
.filter(|&n| n > 0)
.unwrap_or_else(|| {
(1..=99)
.rev()
.find(|&i| {
header
.get_text(key!("{i}CTYP{column}{a}").as_str())
.is_some()
|| ["CRVL", "CDLT", "CRPX"].iter().any(|r| {
header
.get_real(key!("{i}{r}{column}{a}").as_str())
.is_some()
})
})
.unwrap_or(0)
});
if naxis == 0 {
return Err(FitsError::MissingKeyword { name: "iCTYPn" });
}
if naxis > 999 {
return Err(FitsError::KeywordOutOfRange { name: "WCAXn" });
}
let mut h = Header::new();
h.set("WCSAXES", naxis as i64);
for ax in 1..=naxis {
if let Some(t) = header.get_text(key!("{ax}CTYP{column}{a}").as_str()) {
h.set(key!("CTYPE{ax}").as_str(), t);
}
if let Some(t) = header.get_text(key!("{ax}CUNI{column}{a}").as_str()) {
h.set(key!("CUNIT{ax}").as_str(), t);
}
for (root, dst) in [
("CRPX", "CRPIX"),
("CRVL", "CRVAL"),
("CDLT", "CDELT"),
("CROT", "CROTA"),
] {
if let Some(v) = header.get_real(key!("{ax}{root}{column}{a}").as_str()) {
h.set(key!("{dst}{ax}").as_str(), v);
}
}
for m in 0..=20 {
if let Some(v) = header
.get_real(key!("{ax}PV{column}_{m}{a}").as_str())
.or_else(|| header.get_real(key!("{ax}V{column}_{m}{a}").as_str()))
{
h.set(key!("PV{ax}_{m}").as_str(), v);
}
}
}
for i in 1..=naxis {
for j in 1..=naxis {
if let Some(v) = header.get_real(key!("{i}{j}PC{column}{a}").as_str()) {
h.set(key!("PC{i}_{j}").as_str(), v);
}
if let Some(v) = header.get_real(key!("{i}{j}CD{column}{a}").as_str()) {
h.set(key!("CD{i}_{j}").as_str(), v);
}
}
}
Wcs::from_header(&h, None)
}
pub fn pixel_to_world(&self, pixel: &[f64]) -> Vec<f64> {
assert_eq!(pixel.len(), self.naxis, "pixel coordinate count");
let offset: Vec<f64> = (0..self.naxis).map(|i| pixel[i] - self.crpix[i]).collect();
let inter = matvec(&self.matrix, &offset, self.naxis);
let mut world = vec![0.0; self.naxis];
for i in 0..self.naxis {
world[i] = self.crval[i] + inter[i];
}
if let Some(c) = &self.celestial {
let (phi, theta) = c.proj.deproject(inter[c.lng], inter[c.lat], &c.pv);
let (ra, dec) = native_to_celestial(c.pole, phi, theta);
world[c.lng] = ra;
world[c.lat] = dec;
}
world
}
pub fn world_to_pixel(&self, world: &[f64]) -> Vec<f64> {
assert_eq!(world.len(), self.naxis, "world coordinate count");
let mut inter = vec![0.0; self.naxis];
for i in 0..self.naxis {
inter[i] = world[i] - self.crval[i];
}
if let Some(c) = &self.celestial {
let (phi, theta) = celestial_to_native(c.pole, world[c.lng], world[c.lat]);
let (x, y) = c.proj.project(phi, theta, &c.pv);
inter[c.lng] = x;
inter[c.lat] = y;
}
let offset = matvec(&self.inverse, &inter, self.naxis);
(0..self.naxis).map(|i| offset[i] + self.crpix[i]).collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CelestialAxis {
Longitude,
Latitude,
}
fn celestial_axis(ctype: &str) -> Option<CelestialAxis> {
let head = ctype.split('-').next().unwrap_or("").trim();
if head == "RA" || head.ends_with("LON") || (head.len() == 4 && head.ends_with("LN")) {
Some(CelestialAxis::Longitude)
} else if head == "DEC" || head.ends_with("LAT") || (head.len() == 4 && head.ends_with("LT")) {
Some(CelestialAxis::Latitude)
} else {
None
}
}
fn projection_code(ctype: &str) -> Option<&str> {
ctype
.rsplit_once('-')
.map(|(_, code)| code)
.filter(|c| !c.is_empty())
}
fn nonlinear_unsupported_axes(ctype: &[String]) -> Vec<usize> {
let mut out = Vec::new();
for (i, t) in ctype.iter().enumerate() {
if celestial_axis(t).is_some() {
if let Some(code) = projection_code(t)
&& code.len() == 3
&& Projection::from_code(code).is_none()
{
out.push(i);
}
} else {
let head = t.split('-').next().unwrap_or("").trim_end();
if SPECTRAL_TYPES.contains(&head)
&& t.get(5..).map(str::trim).is_some_and(|s| !s.is_empty())
{
out.push(i);
}
}
}
out
}
fn unit_to_degrees(unit: &str) -> f64 {
match unit.trim() {
"arcmin" => 1.0 / 60.0,
"arcsec" => 1.0 / 3600.0,
"mas" => 1.0 / 3_600_000.0,
"rad" => R2D,
_ => 1.0, }
}
fn find_celestial(ctype: &[String]) -> Result<Option<(usize, usize, Projection)>> {
let mut lng = None;
let mut lat = None;
for (i, t) in ctype.iter().enumerate() {
match celestial_axis(t) {
Some(CelestialAxis::Longitude) => lng = lng.or(Some(i)),
Some(CelestialAxis::Latitude) => lat = lat.or(Some(i)),
None => {}
}
}
let (Some(lng), Some(lat)) = (lng, lat) else {
return Ok(None);
};
if projection_code(&ctype[lng]) != projection_code(&ctype[lat]) {
return Err(FitsError::ConflictingWcsKeywords {
detail: "celestial longitude and latitude axes declare different projections",
});
}
Ok(projection_code(&ctype[lng])
.and_then(Projection::from_code)
.map(|proj| (lng, lat, proj)))
}
fn native_to_celestial(pole: CelestialPole, phi: f64, theta: f64) -> (f64, f64) {
let CelestialPole {
ra: ap,
dec: dp,
lonpole: fp,
} = pole;
let (tr, dpr, dphi) = (theta * D2R, dp * D2R, (phi - fp) * D2R);
let sin_d = tr.sin() * dpr.sin() + tr.cos() * dpr.cos() * dphi.cos();
let dec = sin_d.clamp(-1.0, 1.0).asin() * R2D;
let y = -tr.cos() * dphi.sin();
let x = tr.sin() * dpr.cos() - tr.cos() * dpr.sin() * dphi.cos();
(norm360(ap + y.atan2(x) * R2D), dec)
}
fn celestial_to_native(pole: CelestialPole, ra: f64, dec: f64) -> (f64, f64) {
let CelestialPole {
ra: ap,
dec: dp,
lonpole: fp,
} = pole;
let (dr, dpr, dalpha) = (dec * D2R, dp * D2R, (ra - ap) * D2R);
let sin_t = dr.sin() * dpr.sin() + dr.cos() * dpr.cos() * dalpha.cos();
let theta = sin_t.clamp(-1.0, 1.0).asin() * R2D;
let y = -dr.cos() * dalpha.sin();
let x = dr.sin() * dpr.cos() - dr.cos() * dpr.sin() * dalpha.cos();
(norm180(fp + y.atan2(x) * R2D), theta)
}
fn compute_pole(phi0: f64, theta0: f64, a0: f64, d0: f64, phip: f64, thetap: f64) -> CelestialPole {
if (theta0 - 90.0).abs() < 1e-12 {
return CelestialPole {
ra: a0,
dec: d0,
lonpole: phip,
};
}
let (t0, d0r) = (theta0 * D2R, d0 * D2R);
let dphi = (phip - phi0) * D2R;
let a = t0.sin();
let b = t0.cos() * dphi.cos();
let rmag = a.hypot(b);
let beta = a.atan2(b);
let ac = (d0r.sin() / rmag).clamp(-1.0, 1.0).acos();
let c1 = beta + ac;
let c2 = beta - ac;
let in_range = |x: f64| (-FRAC_PI_2..=FRAC_PI_2).contains(&x);
let dpr = match (in_range(c1), in_range(c2)) {
(true, true) => {
if (c1 - thetap * D2R).abs() <= (c2 - thetap * D2R).abs() {
c1
} else {
c2
}
}
(true, false) => c1,
(false, true) => c2,
(false, false) => c1.clamp(-FRAC_PI_2, FRAC_PI_2),
};
let dp = dpr * R2D;
let fphi = (phi0 - phip) * D2R;
let y = -t0.cos() * fphi.sin();
let x = t0.sin() * dpr.cos() - t0.cos() * dpr.sin() * fphi.cos();
let ap = a0 - y.atan2(x) * R2D;
CelestialPole {
ra: norm360(ap),
dec: dp,
lonpole: phip,
}
}
fn axis_vec(header: &Header, prefix: &str, alt: &str, naxis: usize, default: f64) -> Vec<f64> {
(1..=naxis)
.map(|i| {
header
.get_real(key!("{prefix}{i}{alt}").as_str())
.unwrap_or(default)
})
.collect()
}
fn matvec(m: &[f64], v: &[f64], n: usize) -> Vec<f64> {
(0..n)
.map(|i| (0..n).map(|j| m[i * n + j] * v[j]).sum())
.collect()
}
fn invert(m: &[f64], n: usize) -> Option<Vec<f64>> {
let mut a = m.to_vec();
let mut inv = vec![0.0; n * n];
for i in 0..n {
inv[i * n + i] = 1.0;
}
for col in 0..n {
let mut pivot = col;
for r in (col + 1)..n {
if a[r * n + col].abs() > a[pivot * n + col].abs() {
pivot = r;
}
}
if a[pivot * n + col].abs() < 1e-300 {
return None;
}
if pivot != col {
for k in 0..n {
a.swap(col * n + k, pivot * n + k);
inv.swap(col * n + k, pivot * n + k);
}
}
let d = a[col * n + col];
for k in 0..n {
a[col * n + k] /= d;
inv[col * n + k] /= d;
}
for r in 0..n {
if r == col {
continue;
}
let f = a[r * n + col];
if f != 0.0 {
for k in 0..n {
a[r * n + k] -= f * a[col * n + k];
inv[r * n + k] -= f * inv[col * n + k];
}
}
}
}
Some(inv)
}
fn norm360(a: f64) -> f64 {
a.rem_euclid(360.0)
}
fn norm180(a: f64) -> f64 {
(a + 180.0).rem_euclid(360.0) - 180.0
}
#[cfg(test)]
mod tests;