1use std::path::{Path, PathBuf};
6
7use atfits_rs::{HeaderGeom, PixelType, create_cube_open, pixel_scale_deg, update_key_f64};
8use fitsio::FitsFile;
9use fitsio::hdu::FitsHdu;
10use ndarray::Array2;
11use thiserror::Error;
12
13use crate::beam::{Beam, BeamError};
14use crate::cube_io::CubeElem;
15use crate::smooth::BrightnessUnit;
16
17pub use atfits_rs::output_path;
18
19#[derive(Debug, Error)]
20pub enum FitsError {
21 #[error("FITS I/O error: {0}")]
22 Fitsio(#[from] fitsio::errors::Error),
23 #[error("missing keyword: {0}")]
24 MissingKeyword(String),
25 #[error("unsupported NAXIS={0} (expected 2 or 4)")]
26 UnsupportedNaxis(i64),
27 #[error("invalid header beam: {0}")]
28 Beam(#[from] BeamError),
29 #[error("shape error: {0}")]
30 Shape(#[from] ndarray::ShapeError),
31 #[error("I/O error: {0}")]
32 Io(#[from] std::io::Error),
33}
34
35impl From<atfits_rs::AtfitsError> for FitsError {
36 fn from(e: atfits_rs::AtfitsError) -> Self {
37 use atfits_rs::AtfitsError as A;
38 match e {
39 A::Fits(e) => FitsError::Fitsio(e),
40 A::Io(e) => FitsError::Io(e),
41 A::MissingKeyword(s) | A::TargetAxisMissing(s) => FitsError::MissingKeyword(s),
42 A::UnsupportedNaxis(n) => FitsError::UnsupportedNaxis(n),
43 A::Other(s) => FitsError::Io(std::io::Error::other(s)),
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct FitsImageHeader {
51 pub path: PathBuf,
52 pub nx: usize,
53 pub ny: usize,
54 pub nplanes: usize,
56 pub dx_deg: f64,
58 pub dy_deg: f64,
60 pub beam: Beam,
61 pub unit: BrightnessUnit,
62 pub dtype: PixelType,
63}
64
65pub struct FitsImageData<T = f32> {
66 pub header: FitsImageHeader,
67 pub image: Array2<T>,
68}
69
70pub fn read_fits_header(path: &Path) -> Result<FitsImageHeader, FitsError> {
72 let mut fptr = FitsFile::open(path)?;
73 let geom = HeaderGeom::from_file(&mut fptr)?;
74 if geom.naxis != 2 && geom.naxis != 4 {
75 return Err(FitsError::UnsupportedNaxis(geom.naxis as i64));
76 }
77 let nplanes = geom.dims[2..].iter().product();
78 if nplanes > 1 {
79 tracing::warn!(
80 "{}: {nplanes} planes beyond the image axes; only the first is smoothed",
81 path.display()
82 );
83 }
84 let hdu = fptr.primary_hdu()?;
85 let (dx_deg, dy_deg) = pixel_scale_deg(&mut fptr)?;
86 let beam = read_header_beam(&mut fptr)?.ok_or(FitsError::MissingKeyword("BMAJ".into()))?;
87 Ok(FitsImageHeader {
88 path: path.to_path_buf(),
89 nx: geom.dims[0],
90 ny: geom.dims[1],
91 nplanes,
92 dx_deg,
93 dy_deg,
94 beam,
95 unit: read_unit(&mut fptr, &hdu, path),
96 dtype: PixelType::from_bitpix(geom.bitpix),
97 })
98}
99
100pub fn read_fits<T: CubeElem>(path: &Path) -> Result<FitsImageData<T>, FitsError> {
102 let header = read_fits_header(path)?;
103 let image = read_fits_plane(&header)?;
104 Ok(FitsImageData { header, image })
105}
106
107pub fn read_fits_plane<T: CubeElem>(header: &FitsImageHeader) -> Result<Array2<T>, FitsError> {
109 let mut fptr = FitsFile::open(&header.path)?;
110 let data = T::read_section(&mut fptr, 0, header.nx * header.ny)?;
111 Ok(Array2::from_shape_vec((header.ny, header.nx), data)?)
112}
113
114pub fn write_fits<T: CubeElem>(
117 image: &Array2<T>,
118 out_path: &Path,
119 template_path: &Path,
120 new_beam: &Beam,
121) -> Result<(), FitsError> {
122 let (ny, nx) = image.dim();
123 let mut dims = vec![1; HeaderGeom::read(template_path)?.naxis];
124 dims[0] = nx;
125 dims[1] = ny;
126 let mut fptr = create_output(template_path, out_path, Some(&dims))?;
127 update_key_f64(&mut fptr, "BMAJ", new_beam.major_deg())?;
128 update_key_f64(&mut fptr, "BMIN", new_beam.minor_deg())?;
129 update_key_f64(&mut fptr, "BPA", new_beam.pa_deg())?;
130
131 let flat = image.as_standard_layout();
132 let slice = flat
133 .as_slice()
134 .ok_or_else(|| FitsError::Io(std::io::Error::other("image is not contiguous")))?;
135 T::write_section(&mut fptr, 0, nx * ny, slice)?;
136 Ok(())
137}
138
139pub(crate) fn create_output(
143 template: &Path,
144 output: &Path,
145 dims: Option<&[usize]>,
146) -> Result<FitsFile, atfits_rs::AtfitsError> {
147 let geom = HeaderGeom::read(template)?;
148 let bitpix = if geom.bitpix > 0 { -32 } else { geom.bitpix };
149 create_cube_open(template, output, bitpix, dims.unwrap_or(&geom.dims))
150}
151
152pub(crate) fn read_header_beam(fptr: &mut FitsFile) -> Result<Option<Beam>, FitsError> {
155 let Some(bmaj) = atfits_rs::key_f64(fptr, "BMAJ")? else {
156 return Ok(None);
157 };
158 let bmin = atfits_rs::key_f64(fptr, "BMIN")?.unwrap_or(bmaj);
159 let bpa = atfits_rs::key_f64(fptr, "BPA")?.unwrap_or(0.0);
160 Ok(Some(Beam::new(bmaj, bmin, bpa)?))
161}
162
163pub(crate) fn read_unit(fptr: &mut FitsFile, hdu: &FitsHdu, path: &Path) -> BrightnessUnit {
166 match hdu.read_key::<String>(fptr, "BUNIT") {
167 Ok(s) => BrightnessUnit::from_bunit(&s),
168 Err(_) => {
169 tracing::warn!(
170 "No BUNIT keyword in {}; assuming Jy/beam (flux scaling applied).",
171 path.display()
172 );
173 BrightnessUnit::default()
174 }
175 }
176}