Skip to main content

convolve_rs/
cube_io.rs

1//! FITS spectral cube reading and writing with per-channel beams.
2//!
3//! Cubes are 3D (RA, Dec, FREQ) or 4D with one Stokes plane, in either axis
4//! order. Per-channel beams come from, in order of preference:
5//!   1. a CASA BEAMS binary table (`CASAMBM = T`),
6//!   2. a beamlog text file `{dir}/beamlog.{stem}.txt`,
7//!   3. BMAJ/BMIN/BPA in the primary header, used for every channel.
8use std::path::{Path, PathBuf};
9
10use atfits_rs::{update_key_f64, update_key_logical};
11use fitsio::{
12    FitsFile,
13    hdu::FitsHdu,
14    tables::{ColumnDataType, ColumnDescription},
15};
16use ndarray::Array2;
17use thiserror::Error;
18
19use crate::beam::{Beam, BeamError};
20use crate::convolve_uv::FftFloat;
21use crate::fits_io::{
22    FitsError, create_output, pixel_scales, read_header_beam, read_pixels, read_unit,
23};
24use crate::smooth::BrightnessUnit;
25
26pub use atfits_rs::PixelType;
27
28/// Pixel types a plane is read, convolved and written in: `f32` or `f64`.
29pub trait CubeElem: atfits_rs::CubeElem + FftFloat {
30    /// Read pixels `start..end` of a float image without null checks.
31    fn read_float(
32        fptr: &mut FitsFile,
33        hdu: &FitsHdu,
34        start: usize,
35        end: usize,
36    ) -> Result<Vec<Self>, fitsio::errors::Error>;
37}
38
39impl CubeElem for f32 {
40    fn read_float(
41        fptr: &mut FitsFile,
42        hdu: &FitsHdu,
43        start: usize,
44        end: usize,
45    ) -> Result<Vec<Self>, fitsio::errors::Error> {
46        hdu.read_section(fptr, start, end)
47    }
48}
49
50impl CubeElem for f64 {
51    fn read_float(
52        fptr: &mut FitsFile,
53        hdu: &FitsHdu,
54        start: usize,
55        end: usize,
56    ) -> Result<Vec<Self>, fitsio::errors::Error> {
57        hdu.read_section(fptr, start, end)
58    }
59}
60
61#[derive(Debug, Error)]
62pub enum CubeError {
63    #[error("FITS I/O error: {0}")]
64    Fits(#[from] fitsio::errors::Error),
65    #[error(transparent)]
66    Header(#[from] FitsError),
67    #[error("I/O error: {0}")]
68    Io(#[from] std::io::Error),
69    #[error("shape error: {0}")]
70    Shape(#[from] ndarray::ShapeError),
71    #[error("invalid beam: {0}")]
72    Beam(#[from] BeamError),
73    #[error("unsupported NAXIS={0} (expected 3 or 4)")]
74    UnsupportedNaxis(i64),
75    #[error("missing header keyword: {0}")]
76    MissingKeyword(String),
77    #[error("channel count mismatch in beam table: expected {expected}, got {got}")]
78    BeamCountMismatch { expected: usize, got: usize },
79    #[error("beamlog parse error at line {line}: {msg}")]
80    BeamlogParse { line: usize, msg: String },
81    #[error("no per-channel beam source found (no CASAMBM, no beamlog, no header beam)")]
82    NoBeams,
83}
84
85impl From<atfits_rs::AtfitsError> for CubeError {
86    fn from(e: atfits_rs::AtfitsError) -> Self {
87        CubeError::Header(e.into())
88    }
89}
90
91/// Metadata of a FITS spectral cube.
92#[derive(Debug)]
93pub struct CubeMeta {
94    pub path: PathBuf,
95    /// NAXIS1 (RA).
96    pub nx: usize,
97    /// NAXIS2 (Dec).
98    pub ny: usize,
99    pub nfreq: usize,
100    pub nstokes: usize,
101    /// |CDELT1| in degrees.
102    pub dx_deg: f64,
103    /// |CDELT2| in degrees.
104    pub dy_deg: f64,
105    /// CRPIX of the spectral axis, truncated to a whole pixel as racs_tools
106    /// does. Picks the channel whose beam goes in the output header; may lie
107    /// outside `1..=nfreq`.
108    pub crpix_freq: f64,
109    /// Per-channel beams; `None` for a masked channel.
110    pub beams: Vec<Option<Beam>>,
111    pub is_4d: bool,
112    pub unit: BrightnessUnit,
113    pub dtype: PixelType,
114}
115
116impl CubeMeta {
117    /// Flat pixel range `[start, end)` of channel `chan` (first Stokes plane).
118    /// With one Stokes plane both axis orders put channel `c` at `c·ny·nx`.
119    pub fn channel_range(&self, chan: usize) -> (usize, usize) {
120        let plane = self.ny * self.nx;
121        (chan * plane, (chan + 1) * plane)
122    }
123}
124
125/// The beamlog next to a FITS file: `{dir}/beamlog.{stem}.txt`.
126pub fn beamlog_path(fits: &Path) -> PathBuf {
127    let dir = fits.parent().unwrap_or(Path::new("."));
128    let stem = fits.file_stem().unwrap_or_default();
129    dir.join(format!("beamlog.{}.txt", stem.to_string_lossy()))
130}
131
132/// Read the shape, pixel scale and per-channel beams of a cube.
133pub fn read_cube_meta(path: &Path) -> Result<CubeMeta, CubeError> {
134    let mut fptr = FitsFile::open(path)?;
135    let hdu = fptr.primary_hdu()?;
136
137    let naxis: i64 = hdu.read_key(&mut fptr, "NAXIS")?;
138    if naxis != 3 && naxis != 4 {
139        return Err(CubeError::UnsupportedNaxis(naxis));
140    }
141    let mut dims = Vec::with_capacity(naxis as usize);
142    for i in 1..=naxis {
143        dims.push(hdu.read_key::<i64>(&mut fptr, &format!("NAXIS{i}"))? as usize);
144    }
145
146    // CASA writes RA, DEC, STOKES, FREQ; ASKAP writes RA, DEC, FREQ, STOKES.
147    let ctype3 = hdu
148        .read_key::<String>(&mut fptr, "CTYPE3")
149        .map(|s| s.trim().to_ascii_uppercase())
150        .unwrap_or_default();
151    let (nstokes, nfreq, freq_axis) = match dims[..] {
152        [_, _, n3, n4] if ctype3 == "STOKES" => (n3, n4, 4),
153        [_, _, n3, n4] => (n4, n3, 3),
154        _ => (1, dims[2], 3),
155    };
156
157    let (dx_deg, dy_deg) = pixel_scales(&mut fptr, &hdu)?;
158    let crpix_freq = atfits_rs::key_f64(&mut fptr, &format!("CRPIX{freq_axis}"))?
159        .unwrap_or(1.0)
160        .floor();
161    let bitpix: i64 = hdu.read_key(&mut fptr, "BITPIX")?;
162    let unit = read_unit(&mut fptr, &hdu, path);
163
164    // CASA writes CASAMBM as a logical, but some tools write a string.
165    let casambm = hdu
166        .read_key::<bool>(&mut fptr, "CASAMBM")
167        .ok()
168        .or_else(|| {
169            hdu.read_key::<String>(&mut fptr, "CASAMBM")
170                .ok()
171                .map(|s| matches!(s.trim(), "T" | "TRUE"))
172        })
173        .unwrap_or(false);
174
175    let beamlog = beamlog_path(path);
176    let beams = if casambm {
177        read_casambm_beams(&mut fptr, nfreq, path)?
178    } else if beamlog.exists() {
179        let parsed = read_beamlog(&beamlog)?;
180        if parsed.len() != nfreq {
181            return Err(CubeError::BeamCountMismatch {
182                expected: nfreq,
183                got: parsed.len(),
184            });
185        }
186        parsed.into_iter().map(Some).collect()
187    } else {
188        let beam = read_header_beam(&mut fptr)?.ok_or(CubeError::NoBeams)?;
189        vec![Some(beam); nfreq]
190    };
191
192    Ok(CubeMeta {
193        path: path.to_path_buf(),
194        nx: dims[0],
195        ny: dims[1],
196        nfreq,
197        nstokes,
198        dx_deg,
199        dy_deg,
200        crpix_freq,
201        beams,
202        is_4d: naxis == 4,
203        unit,
204        dtype: PixelType::from_bitpix(bitpix),
205    })
206}
207
208/// Per-channel beams from the CASA BEAMS table (BMAJ, BMIN in arcsec; BPA in
209/// degrees).
210fn read_casambm_beams(
211    fptr: &mut FitsFile,
212    nfreq: usize,
213    path: &Path,
214) -> Result<Vec<Option<Beam>>, CubeError> {
215    let hdu = fptr
216        .hdu("BEAMS")
217        .map_err(|_| CubeError::MissingKeyword("BEAMS extension".into()))?;
218    let bmaj: Vec<f32> = hdu.read_col(fptr, "BMAJ")?;
219    let bmin: Vec<f32> = hdu.read_col(fptr, "BMIN")?;
220    let bpa: Vec<f32> = hdu.read_col(fptr, "BPA")?;
221    if bmaj.len() != nfreq || bmin.len() != nfreq || bpa.len() != nfreq {
222        return Err(CubeError::BeamCountMismatch {
223            expected: nfreq,
224            got: bmaj.len(),
225        });
226    }
227    Ok((0..nfreq)
228        .map(|c| {
229            beam_from_row(
230                f64::from(bmaj[c]),
231                f64::from(bmin[c]),
232                f64::from(bpa[c]),
233                c,
234                path,
235            )
236        })
237        .collect())
238}
239
240/// Beam from one row of a beam table (arcsec, arcsec, deg).
241///
242/// `None` for a masked channel: a non-finite value, or an axis no larger than
243/// the `f32::MIN_POSITIVE` written for masked channels. Swapped axes are
244/// fixed with a warning.
245fn beam_from_row(maj: f64, min: f64, pa: f64, chan: usize, path: &Path) -> Option<Beam> {
246    let tiny = f64::from(f32::MIN_POSITIVE);
247    if !(maj.is_finite() && min.is_finite() && pa.is_finite()) || maj <= tiny || min <= tiny {
248        return None;
249    }
250    if min > maj {
251        tracing::warn!(
252            "{}: channel {chan} has BMIN > BMAJ ({min}\" > {maj}\"); swapping the axes",
253            path.display()
254        );
255        let pa = if pa > 0.0 { pa - 90.0 } else { pa + 90.0 };
256        return Beam::from_arcsec(min, maj, pa).ok();
257    }
258    Beam::from_arcsec(maj, min, pa).ok()
259}
260
261/// An open input cube for reading channels. cfitsio handles are not `Send`,
262/// so each thread opens its own.
263pub struct CubeReader {
264    fptr: FitsFile,
265}
266
267impl CubeReader {
268    pub fn open(path: &Path) -> Result<Self, CubeError> {
269        Ok(Self {
270            fptr: FitsFile::open(path)?,
271        })
272    }
273
274    /// Channel `chan` (first Stokes plane) as an `ny × nx` array.
275    pub fn read_channel<T: CubeElem>(
276        &mut self,
277        chan: usize,
278        meta: &CubeMeta,
279    ) -> Result<Array2<T>, CubeError> {
280        let (start, end) = meta.channel_range(chan);
281        let data = read_pixels(&mut self.fptr, start, end)?;
282        Ok(Array2::from_shape_vec((meta.ny, meta.nx), data)?)
283    }
284}
285
286/// Mode for choosing the common beam of a cube.
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub enum CubeMode {
289    /// A common beam per channel, written to a BEAMS table.
290    Natural,
291    /// One common beam for every channel, written to the primary header.
292    Total,
293}
294
295/// An output cube held open while channels are written, so cfitsio writes the
296/// data unit once, on [`CubeWriter::finish`]. Use from a single thread.
297pub struct CubeWriter {
298    fptr: FitsFile,
299    /// Natural mode's BEAMS table. Creating it makes cfitsio flush the primary
300    /// data unit, so it is written after the channels.
301    pending_beams: Option<Vec<Option<Beam>>>,
302}
303
304impl CubeWriter {
305    /// Create `output_path` with the header of `input_path` and the beam
306    /// keywords for `target_beams`.
307    pub fn create(
308        input_path: &Path,
309        output_path: &Path,
310        target_beams: &[Option<Beam>],
311        mode: CubeMode,
312        meta: &CubeMeta,
313    ) -> Result<Self, CubeError> {
314        let mut fptr = create_output(input_path, output_path, None)?;
315        write_primary_beam_keys(
316            &mut fptr,
317            ref_beam_for(target_beams, meta),
318            mode == CubeMode::Natural,
319        )?;
320        Ok(Self {
321            fptr,
322            pending_beams: (mode == CubeMode::Natural).then(|| target_beams.to_vec()),
323        })
324    }
325
326    pub fn write_channel<T: CubeElem>(
327        &mut self,
328        chan: usize,
329        data: &Array2<T>,
330        meta: &CubeMeta,
331    ) -> Result<(), CubeError> {
332        let (start, end) = meta.channel_range(chan);
333        let flat = data.as_standard_layout();
334        let slice = flat
335            .as_slice()
336            .ok_or_else(|| std::io::Error::other("plane is not contiguous"))?;
337        T::write_section(&mut self.fptr, start, end, slice)?;
338        Ok(())
339    }
340
341    /// Append the BEAMS table (natural mode) and close the file. Channels never
342    /// written read as zero.
343    pub fn finish(mut self) -> Result<(), CubeError> {
344        if let Some(beams) = self.pending_beams.take() {
345            write_beams_table(&mut self.fptr, &beams)?;
346        }
347        Ok(())
348    }
349}
350
351/// The beam for the primary header: the target at the spectral CRPIX (clamped
352/// to the channel range), else the first valid one, else zero.
353fn ref_beam_for(target_beams: &[Option<Beam>], meta: &CubeMeta) -> Beam {
354    let last = meta.nfreq.saturating_sub(1) as f64;
355    let ref_idx = (meta.crpix_freq - 1.0).clamp(0.0, last) as usize;
356    target_beams[ref_idx]
357        .or_else(|| target_beams.iter().find_map(|b| *b))
358        .unwrap_or(Beam::zero())
359}
360
361/// Set BMAJ/BMIN/BPA and CASAMBM in the primary header, replacing the copied
362/// cards. CASAMBM must be a logical: casacore reads it with `asBool`.
363fn write_primary_beam_keys(
364    fptr: &mut FitsFile,
365    ref_beam: Beam,
366    natural: bool,
367) -> Result<(), CubeError> {
368    fptr.primary_hdu()?;
369    update_key_f64(fptr, "BMAJ", ref_beam.major_deg())?;
370    update_key_f64(fptr, "BMIN", ref_beam.minor_deg())?;
371    update_key_f64(fptr, "BPA", ref_beam.pa_deg())?;
372    update_key_logical(fptr, "CASAMBM", natural)?;
373    Ok(())
374}
375
376/// Append a CASA BEAMS table. Masked channels get `f32::MIN_POSITIVE`, which
377/// [`read_cube_meta`] reads back as masked.
378fn write_beams_table(fptr: &mut FitsFile, target_beams: &[Option<Beam>]) -> Result<(), CubeError> {
379    let nfreq = target_beams.len();
380    let tiny = f32::MIN_POSITIVE;
381    let column = |f: fn(&Beam) -> f64| -> Vec<f32> {
382        target_beams
383            .iter()
384            .map(|b| b.map_or(tiny, |b| f(&b) as f32))
385            .collect()
386    };
387    let bmaj = column(Beam::major_arcsec);
388    let bmin = column(Beam::minor_arcsec);
389    let bpa = column(Beam::pa_deg);
390    let chan: Vec<i32> = (0..nfreq as i32).collect();
391    let pol = vec![0i32; nfreq];
392
393    let col = |name: &str, ty: ColumnDataType| ColumnDescription::new(name).with_type(ty).create();
394    let cols = [
395        col("BMAJ", ColumnDataType::Float)?,
396        col("BMIN", ColumnDataType::Float)?,
397        col("BPA", ColumnDataType::Float)?,
398        col("CHAN", ColumnDataType::Int)?,
399        col("POL", ColumnDataType::Int)?,
400    ];
401    let table = fptr.create_table("BEAMS", &cols)?;
402    table.write_col(fptr, "BMAJ", &bmaj)?;
403    table.write_col(fptr, "BMIN", &bmin)?;
404    table.write_col(fptr, "BPA", &bpa)?;
405    table.write_col(fptr, "CHAN", &chan)?;
406    table.write_col(fptr, "POL", &pol)?;
407
408    // casacore needs the column units.
409    let table = fptr.hdu("BEAMS")?;
410    table.write_key(fptr, "TUNIT1", "arcsec")?;
411    table.write_key(fptr, "TUNIT2", "arcsec")?;
412    table.write_key(fptr, "TUNIT3", "deg")?;
413    table.write_key(fptr, "NCHAN", nfreq as i64)?;
414    table.write_key(fptr, "NPOL", 1i64)?;
415    Ok(())
416}
417
418/// Read a beamlog as written by RACS-tools or [`write_beamlog`]:
419///
420/// ```text
421/// # Channel BMAJ[arcsec] BMIN[arcsec] BPA[deg]
422/// 0  20.0  10.0  10.0
423/// ```
424///
425/// Masked rows come back as [`Beam::zero`].
426pub fn read_beamlog(path: &Path) -> Result<Vec<Beam>, CubeError> {
427    let content = std::fs::read_to_string(path)?;
428    let mut beams = Vec::new();
429    for (i, line) in content.lines().enumerate() {
430        let trimmed = line.trim();
431        if trimmed.is_empty() || trimmed.starts_with('#') {
432            continue;
433        }
434        let fields: Vec<&str> = trimmed.split_whitespace().collect();
435        if fields.len() < 4 {
436            return Err(CubeError::BeamlogParse {
437                line: i + 1,
438                msg: format!("expected 4 fields, got {}", fields.len()),
439            });
440        }
441        let parse = |s: &str, n: &str| -> Result<f64, CubeError> {
442            s.parse::<f64>().map_err(|_| CubeError::BeamlogParse {
443                line: i + 1,
444                msg: format!("cannot parse {n}={s:?} as float"),
445            })
446        };
447        let (bmaj, bmin, bpa) = (
448            parse(fields[1], "BMAJ")?,
449            parse(fields[2], "BMIN")?,
450            parse(fields[3], "BPA")?,
451        );
452        let chan = beams.len();
453        beams.push(beam_from_row(bmaj, bmin, bpa, chan, path).unwrap_or(Beam::zero()));
454    }
455    Ok(beams)
456}
457
458/// Write per-channel beams as a beamlog; masked channels are written as NaN.
459pub fn write_beamlog(path: &Path, beams: &[Option<Beam>]) -> Result<(), CubeError> {
460    let mut out = String::from("# Channel BMAJ[arcsec] BMIN[arcsec] BPA[deg]\n");
461    for (i, b) in beams.iter().enumerate() {
462        out += &match b {
463            Some(b) => format!(
464                "{i} {} {} {}\n",
465                b.major_arcsec(),
466                b.minor_arcsec(),
467                b.pa_deg()
468            ),
469            None => format!("{i} nan nan nan\n"),
470        };
471    }
472    std::fs::write(path, out)?;
473    Ok(())
474}