Skip to main content

convolve_rs/
cube_io.rs

1//! FITS spectral cube reading and writing with per-channel beam support.
2//!
3//! Supports 3D cubes (NAXIS=3: freq×dec×ra) and 4D cubes (NAXIS=4: stokes×freq×dec×ra).
4//! Per-channel beams are read from, in priority order:
5//!   1. CASA BEAMS binary-table extension (CASAMBM=T in header)
6//!   2. Co-located beamlog text file: `{dir}/beamlog.{stem}.txt`
7//!   3. Single BMAJ/BMIN/BPA from the primary header (broadcast to all channels)
8use std::path::{Path, PathBuf};
9
10use atfits_rs::{copy_header_only, update_key_f64, update_key_logical};
11use fitsio::{
12    FitsFile,
13    tables::{ColumnDataType, ColumnDescription},
14};
15use ndarray::Array2;
16use thiserror::Error;
17
18use crate::beam::{Beam, BeamError};
19use crate::convolve_uv::FftFloat;
20use crate::smooth::BrightnessUnit;
21
22// ── Pixel element type ──────────────────────────────────────────────────────────
23
24/// In-memory pixel precision for streaming a cube, derived from FITS `BITPIX`
25/// (re-exported from [`atfits_rs`]): `-64` → f64, everything else → f32.
26pub use atfits_rs::PixelType;
27
28/// Pixel element types a cube can be streamed in: `f32` or `f64`.
29///
30/// Implemented only for `f32`/`f64`. Bundles the FITS section read/write (which
31/// delegate to the shared monomorphic cfitsio I/O in [`atfits_rs`]) behind the
32/// [`FftFloat`] bound, so the convolution pipeline can stay generic over
33/// precision and run the FFT at the data's native precision.
34pub trait CubeElem: FftFloat {
35    fn read_section_vec(
36        fptr: &mut FitsFile,
37        start: usize,
38        end: usize,
39    ) -> Result<Vec<Self>, CubeError>;
40    fn write_section_vec(
41        fptr: &mut FitsFile,
42        start: usize,
43        end: usize,
44        data: &[Self],
45    ) -> Result<(), CubeError>;
46}
47
48macro_rules! impl_cube_elem {
49    ($t:ty) => {
50        impl CubeElem for $t {
51            fn read_section_vec(
52                fptr: &mut FitsFile,
53                start: usize,
54                end: usize,
55            ) -> Result<Vec<Self>, CubeError> {
56                Ok(<$t as atfits_rs::CubeElem>::read_section(fptr, start, end)?)
57            }
58            fn write_section_vec(
59                fptr: &mut FitsFile,
60                start: usize,
61                end: usize,
62                data: &[Self],
63            ) -> Result<(), CubeError> {
64                <$t as atfits_rs::CubeElem>::write_section(fptr, start, end, data)?;
65                Ok(())
66            }
67        }
68    };
69}
70impl_cube_elem!(f32);
71impl_cube_elem!(f64);
72
73// ── Error type ────────────────────────────────────────────────────────────────
74
75#[derive(Debug, Error)]
76pub enum CubeError {
77    #[error("FITS I/O error: {0}")]
78    Fits(#[from] fitsio::errors::Error),
79    #[error("I/O error: {0}")]
80    Io(#[from] std::io::Error),
81    #[error("shape error: {0}")]
82    Shape(#[from] ndarray::ShapeError),
83    #[error("invalid beam: {0}")]
84    Beam(#[from] BeamError),
85    #[error("unsupported NAXIS={0} (expected 3 or 4)")]
86    UnsupportedNaxis(i64),
87    #[error("missing header keyword: {0}")]
88    MissingKeyword(String),
89    #[error("channel count mismatch in BEAMS extension: expected {expected}, got {got}")]
90    BeamCountMismatch { expected: usize, got: usize },
91    #[error("beamlog parse error at line {line}: {msg}")]
92    BeamlogParse { line: usize, msg: String },
93    #[error("no per-channel beam source found (no CASAMBM, no beamlog, no header beam)")]
94    NoBeans,
95}
96
97/// Map the shared [`atfits_rs::AtfitsError`] (from the low-level cfitsio helpers)
98/// onto the convolve-rs error hierarchy.
99impl From<atfits_rs::AtfitsError> for CubeError {
100    fn from(e: atfits_rs::AtfitsError) -> Self {
101        use atfits_rs::AtfitsError as A;
102        match e {
103            A::Fits(e) => CubeError::Fits(e),
104            A::Io(e) => CubeError::Io(e),
105            A::MissingKeyword(s) | A::TargetAxisMissing(s) => CubeError::MissingKeyword(s),
106            A::UnsupportedNaxis(n) => CubeError::UnsupportedNaxis(n),
107            A::Other(s) => CubeError::Io(std::io::Error::other(s)),
108        }
109    }
110}
111
112// ── Public metadata struct ────────────────────────────────────────────────────
113
114/// Metadata for a FITS spectral cube (3D or 4D).
115#[derive(Debug)]
116pub struct CubeMeta {
117    pub path: PathBuf,
118    /// Fastest-varying spatial axis size (RA/x pixels).
119    pub nx: usize,
120    /// Slower spatial axis size (Dec/y pixels).
121    pub ny: usize,
122    /// Number of frequency channels.
123    pub nfreq: usize,
124    /// Number of Stokes planes (1 for most ASKAP data).
125    pub nstokes: usize,
126    /// |CDELT1| in degrees.
127    pub dx_deg: f64,
128    /// |CDELT2| in degrees.
129    pub dy_deg: f64,
130    /// FITS 1-based CRPIX for the spectral axis (used as the header reference
131    /// channel). May lie outside `1..=nfreq`.
132    pub crpix_freq: i64,
133    /// Per-channel beams.  `None` means the channel is masked / has no valid beam.
134    pub beams: Vec<Option<Beam>>,
135    /// True for 4D input (has a Stokes axis in the header).
136    pub is_4d: bool,
137    /// Brightness unit from BUNIT (defaults to Jy/beam if absent).
138    pub unit: BrightnessUnit,
139    /// Working pixel precision, derived from FITS `BITPIX`.
140    pub dtype: PixelType,
141}
142
143impl CubeMeta {
144    /// Flat element range `[start, end)` of frequency channel `chan` (Stokes 0)
145    /// in the primary data unit. For 3D `[nfreq, ny, nx]` and 4D
146    /// `[nstokes=1, nfreq, ny, nx]` cubes the offset is `chan * ny * nx`.
147    pub fn channel_range(&self, chan: usize) -> (usize, usize) {
148        let plane = self.ny * self.nx;
149        let start = chan * plane;
150        (start, start + plane)
151    }
152
153    /// Beamlog path co-located with the FITS file.
154    pub fn beamlog_path(&self) -> PathBuf {
155        let dir = self.path.parent().unwrap_or(Path::new("."));
156        let stem = self.path.file_stem().unwrap_or_default();
157        dir.join(format!("beamlog.{}.txt", stem.to_string_lossy()))
158    }
159}
160
161// ── Reading cube metadata ─────────────────────────────────────────────────────
162
163/// Read metadata (shape, pixel scale, per-channel beams) from a FITS cube.
164pub fn read_cube_meta(path: &Path) -> Result<CubeMeta, CubeError> {
165    let path_str = path.to_string_lossy().into_owned();
166    let mut fptr = FitsFile::open(&path_str)?;
167    let hdu = fptr.primary_hdu()?;
168
169    let naxis: i64 = hdu.read_key(&mut fptr, "NAXIS")?;
170    if naxis != 3 && naxis != 4 {
171        return Err(CubeError::UnsupportedNaxis(naxis));
172    }
173
174    let naxis1: i64 = hdu.read_key(&mut fptr, "NAXIS1")?; // x / RA
175    let naxis2: i64 = hdu.read_key(&mut fptr, "NAXIS2")?; // y / Dec
176    let naxis3: i64 = hdu.read_key(&mut fptr, "NAXIS3")?;
177
178    // Which of axes 3/4 is the spectral one. ASKAP and beamcon write RA, DEC,
179    // FREQ, STOKES, but CASA writes RA, DEC, STOKES, FREQ; like racs_tools (which
180    // asks astropy's WCS for the spectral axis) accept either, keyed on CTYPE.
181    // With a single Stokes plane the two orders lay the channels out identically
182    // (channel c starts at c·ny·nx), so only the sizes and CRPIX differ.
183    let ctype3 = hdu
184        .read_key::<String>(&mut fptr, "CTYPE3")
185        .map(|s| s.trim().to_ascii_uppercase())
186        .unwrap_or_default();
187    let (nstokes, nfreq, freq_axis, is_4d) = if naxis == 4 {
188        let naxis4: i64 = hdu.read_key(&mut fptr, "NAXIS4")?;
189        if ctype3 == "STOKES" {
190            (naxis3 as usize, naxis4 as usize, 4, true)
191        } else {
192            (naxis4 as usize, naxis3 as usize, 3, true)
193        }
194    } else {
195        (1, naxis3 as usize, 3, false)
196    };
197
198    let nx = naxis1 as usize;
199    let ny = naxis2 as usize;
200
201    let cdelt1: f64 = hdu.read_key(&mut fptr, "CDELT1")?;
202    let cdelt2: f64 = hdu.read_key(&mut fptr, "CDELT2")?;
203    let dx_deg = cdelt1.abs();
204    let dy_deg = cdelt2.abs();
205
206    // Reference channel for the spectral axis. cfitsio truncates a fractional
207    // CRPIX (e.g. 144.5) to an integer, as racs_tools' `int(crpix)` does.
208    let crpix_freq: i64 = hdu
209        .read_key(&mut fptr, &format!("CRPIX{freq_axis}"))
210        .unwrap_or(1);
211
212    // Pixel precision: convolve in the data's native precision (f32 for -32 and
213    // integer cubes, f64 for -64) instead of always upcasting to f64.
214    let bitpix: i64 = hdu.read_key(&mut fptr, "BITPIX").unwrap_or(-32);
215    let dtype = PixelType::from_bitpix(bitpix);
216    if bitpix > 0 {
217        // Integer cubes are convolved in f32, but the output header (BITPIX) is
218        // copied verbatim, so the floating-point result is rounded back to
219        // integers on write. Warn rather than silently lose precision.
220        tracing::warn!(
221            "{}: integer BITPIX={}; convolution runs in f32 but the output is \
222             written at integer precision (fractional flux is rounded). Convert \
223             to a floating-point cube (BITPIX=-32) to avoid this.",
224            path.display(),
225            bitpix
226        );
227    }
228
229    // Brightness unit (BUNIT); warn and default to Jy/beam when absent.
230    let unit = match hdu.read_key::<String>(&mut fptr, "BUNIT") {
231        Ok(s) => BrightnessUnit::from_bunit(&s),
232        Err(_) => {
233            tracing::warn!(
234                "No BUNIT keyword in {}; assuming Jy/beam (flux scaling applied).",
235                path.display()
236            );
237            BrightnessUnit::default()
238        }
239    };
240
241    // Check for CASAMBM.  CASA/beamcon write it as a FITS logical, but some tools
242    // (and our own older outputs) wrote a quoted string — accept both.
243    let casambm = hdu
244        .read_key::<bool>(&mut fptr, "CASAMBM")
245        .ok()
246        .or_else(|| {
247            hdu.read_key::<String>(&mut fptr, "CASAMBM")
248                .ok()
249                .map(|s| matches!(s.trim(), "T" | "TRUE"))
250        })
251        .unwrap_or(false);
252    drop(fptr); // close for next reads
253
254    let beams: Vec<Option<Beam>> = if casambm {
255        read_casambm_beams(path, nfreq)?
256    } else {
257        let beamlog = CubeMeta {
258            path: path.to_path_buf(),
259            nx,
260            ny,
261            nfreq,
262            nstokes,
263            dx_deg,
264            dy_deg,
265            crpix_freq,
266            beams: vec![],
267            is_4d,
268            unit,
269            dtype,
270        }
271        .beamlog_path();
272
273        if beamlog.exists() {
274            let parsed = read_beamlog(&beamlog)?;
275            if parsed.len() != nfreq {
276                return Err(CubeError::BeamCountMismatch {
277                    expected: nfreq,
278                    got: parsed.len(),
279                });
280            }
281            parsed.into_iter().map(Some).collect()
282        } else {
283            // Fall back to single header beam broadcast to all channels.
284            let mut fptr2 = FitsFile::open(path.to_string_lossy().into_owned())?;
285            let hdu2 = fptr2.primary_hdu()?;
286            let bmaj: f64 = hdu2
287                .read_key(&mut fptr2, "BMAJ")
288                .map_err(|_| CubeError::NoBeans)?;
289            let bmin: f64 = hdu2.read_key(&mut fptr2, "BMIN").unwrap_or(bmaj);
290            let bpa: f64 = hdu2.read_key(&mut fptr2, "BPA").unwrap_or(0.0);
291            let b = Beam::new(bmaj, bmin, bpa)?;
292            vec![Some(b); nfreq]
293        }
294    };
295
296    Ok(CubeMeta {
297        path: path.to_path_buf(),
298        nx,
299        ny,
300        nfreq,
301        nstokes,
302        dx_deg,
303        dy_deg,
304        crpix_freq,
305        beams,
306        is_4d,
307        unit,
308        dtype,
309    })
310}
311
312/// Read per-channel beams from the CASA BEAMS binary-table extension.
313///
314/// Columns: BMAJ [arcsec], BMIN [arcsec], BPA [deg], CHAN [int], POL [int].
315fn read_casambm_beams(path: &Path, nfreq: usize) -> Result<Vec<Option<Beam>>, CubeError> {
316    let path_str = path.to_string_lossy().into_owned();
317    let mut fptr = FitsFile::open(&path_str)?;
318    let hdu = fptr
319        .hdu("BEAMS")
320        .map_err(|_| CubeError::MissingKeyword("BEAMS extension".into()))?;
321
322    let bmaj: Vec<f32> = hdu.read_col(&mut fptr, "BMAJ")?;
323    let bmin: Vec<f32> = hdu.read_col(&mut fptr, "BMIN")?;
324    let bpa: Vec<f32> = hdu.read_col(&mut fptr, "BPA")?;
325
326    if bmaj.len() != nfreq {
327        return Err(CubeError::BeamCountMismatch {
328            expected: nfreq,
329            got: bmaj.len(),
330        });
331    }
332
333    let tiny = f32::MIN_POSITIVE as f64;
334    let beams = bmaj
335        .iter()
336        .zip(bmin.iter())
337        .zip(bpa.iter())
338        .map(|((&maj_as, &min_as), &pa_deg)| {
339            let maj_deg = maj_as as f64 / 3600.0;
340            let min_deg = min_as as f64 / 3600.0;
341            let pa = pa_deg as f64;
342            // Treat tiny/zero beams as masked. `<=` (not `<`) so the `tiny`
343            // sentinel that `init_output_cube` writes for a masked channel is
344            // detected as masked on read-back — otherwise it round-trips to a
345            // bogus ~1e-38° beam.
346            if maj_deg <= tiny || !maj_deg.is_finite() {
347                None
348            } else {
349                Beam::new(maj_deg, min_deg.max(tiny), pa).ok()
350            }
351        })
352        .collect();
353    Ok(beams)
354}
355
356// ── Reading / writing channel planes ─────────────────────────────────────────
357
358/// Read a single frequency channel from a cube into a 2D array (ny × nx), in the
359/// requested precision `T`.
360///
361/// Reads stokes=0 (the first Stokes plane).  For 3D [nfreq, ny, nx] and 4D
362/// [nstokes=1, nfreq, ny, nx] or [nfreq, nstokes=1, ny, nx] cubes the flat
363/// offset is identical: `chan * ny * nx`.
364pub fn read_channel_as<T: CubeElem>(
365    path: &Path,
366    chan: usize,
367    meta: &CubeMeta,
368) -> Result<Array2<T>, CubeError> {
369    let path_str = path.to_string_lossy().into_owned();
370    let mut fptr = FitsFile::open(&path_str)?;
371
372    let (start, end) = meta.channel_range(chan);
373    let data = T::read_section_vec(&mut fptr, start, end)?;
374    Ok(Array2::from_shape_vec((meta.ny, meta.nx), data)?)
375}
376
377/// Read a single frequency channel as `f32` (see [`read_channel_as`]).
378pub fn read_channel(path: &Path, chan: usize, meta: &CubeMeta) -> Result<Array2<f32>, CubeError> {
379    read_channel_as::<f32>(path, chan, meta)
380}
381
382/// Write a single frequency channel plane (precision `T`) back into an existing
383/// FITS cube.
384///
385/// The output cube must have already been initialised by `init_output_cube`.
386pub fn write_channel_as<T: CubeElem>(
387    path: &Path,
388    chan: usize,
389    data: &Array2<T>,
390    meta: &CubeMeta,
391) -> Result<(), CubeError> {
392    let path_str = path.to_string_lossy().into_owned();
393    let mut fptr = FitsFile::edit(&path_str)?;
394
395    let (start, end) = meta.channel_range(chan);
396    let flat = data.as_standard_layout();
397    let slice = flat.as_slice().expect("standard-layout plane");
398    T::write_section_vec(&mut fptr, start, end, slice)?;
399    Ok(())
400}
401
402/// Write a single `f32` frequency channel plane (see [`write_channel_as`]).
403pub fn write_channel(
404    path: &Path,
405    chan: usize,
406    data: &Array2<f32>,
407    meta: &CubeMeta,
408) -> Result<(), CubeError> {
409    write_channel_as::<f32>(path, chan, data, meta)
410}
411
412/// A streaming writer that holds an initialised output cube open for the lifetime
413/// of a processing run, so channels can be written one at a time without the
414/// per-call file open/close overhead of [`write_channel`].
415///
416/// cfitsio drives a single file through one internal cursor and is **not**
417/// thread-safe, so a `CubeWriter` must be owned and driven by a single thread
418/// (the consumer end of the streaming pipeline in `main`).
419pub struct CubeWriter {
420    fptr: FitsFile,
421    /// BEAMS table to append on [`CubeWriter::finish`] (Natural mode only).
422    ///
423    /// Deferred so the extension lands *after* the primary data unit (matching
424    /// [`init_output_cube`]'s on-disk layout) and the data unit is written in a
425    /// single pass: creating the extension forces cfitsio to flush the primary
426    /// data unit, so it must happen after every channel is written, not before.
427    /// `None` in Total mode or when opened against an already-initialised cube.
428    pending_beams: Option<PendingBeams>,
429}
430
431/// Per-channel beam table buffered until [`CubeWriter::finish`].
432struct PendingBeams {
433    beams: Vec<Option<Beam>>,
434    nfreq: usize,
435}
436
437impl CubeWriter {
438    /// Create a fresh output cube from `input_path`'s primary header and hold the
439    /// FITS handle open for streaming channel writes.
440    ///
441    /// Unlike [`init_output_cube`] (create → close → reopen), the single handle
442    /// stays open from creation through every [`CubeWriter::write_channel_as`]
443    /// until [`CubeWriter::finish`], so cfitsio writes the data unit exactly once
444    /// — avoiding the wasted full zero-fill pass a create-close incurs on the data
445    /// unit of a multi-GB cube.  Only data-unit gaps no channel covered are
446    /// zero-filled on the final close.
447    ///
448    /// Primary-header beam keywords (BMAJ/BMIN/BPA/CASAMBM) are written up front;
449    /// in `Natural` mode the BEAMS extension is buffered and appended by `finish`.
450    pub fn create(
451        input_path: &Path,
452        output_path: &Path,
453        target_beams: &[Option<Beam>],
454        mode: CubeMode,
455        meta: &CubeMeta,
456    ) -> Result<Self, CubeError> {
457        // Copy the primary header from the input and keep the handle open (no
458        // data written yet). cfitsio defines the data unit from the copied NAXIS
459        // keywords; it is written once, when this handle is finally dropped.
460        let mut fptr = atfits_rs::copy_header_only_open(input_path, output_path)?;
461
462        let ref_beam = ref_beam_for(target_beams, meta);
463        write_primary_beam_keys(&mut fptr, ref_beam, mode == CubeMode::Natural)?;
464
465        // Reposition at the primary HDU so subsequent channel writes target the
466        // primary data unit (key updates above already sit there, but be explicit).
467        fptr.primary_hdu()?;
468
469        let pending_beams = (mode == CubeMode::Natural).then(|| PendingBeams {
470            beams: target_beams.to_vec(),
471            nfreq: meta.nfreq,
472        });
473        Ok(Self {
474            fptr,
475            pending_beams,
476        })
477    }
478
479    /// Open an already-initialised output cube (see [`init_output_cube`]) for
480    /// sequential channel writes.
481    pub fn open(path: &Path) -> Result<Self, CubeError> {
482        let fptr = FitsFile::edit(path.to_string_lossy().into_owned())?;
483        Ok(Self {
484            fptr,
485            pending_beams: None,
486        })
487    }
488
489    /// Write one frequency channel plane (precision `T`) into the open cube.
490    pub fn write_channel_as<T: CubeElem>(
491        &mut self,
492        chan: usize,
493        data: &Array2<T>,
494        meta: &CubeMeta,
495    ) -> Result<(), CubeError> {
496        let (start, end) = meta.channel_range(chan);
497        let flat = data.as_standard_layout();
498        let slice = flat.as_slice().expect("standard-layout plane");
499        T::write_section_vec(&mut self.fptr, start, end, slice)?;
500        Ok(())
501    }
502
503    /// Write one `f32` frequency channel plane (see [`CubeWriter::write_channel_as`]).
504    pub fn write_channel(
505        &mut self,
506        chan: usize,
507        data: &Array2<f32>,
508        meta: &CubeMeta,
509    ) -> Result<(), CubeError> {
510        self.write_channel_as::<f32>(chan, data, meta)
511    }
512
513    /// Finish the cube: append the buffered BEAMS extension (Natural mode) and
514    /// close the FITS file exactly once.
515    ///
516    /// Must be called after the final channel write. The single close zero-fills
517    /// only the data-unit gaps no channel covered; creating the BEAMS extension
518    /// here (not at `create`) keeps the primary data unit written in one pass.
519    pub fn finish(mut self) -> Result<(), CubeError> {
520        if let Some(p) = self.pending_beams.take() {
521            write_beams_table(&mut self.fptr, &p.beams, p.nfreq)?;
522        }
523        // `self.fptr` drops at end of scope → cfitsio closes and flushes once.
524        Ok(())
525    }
526}
527
528// ── Output cube initialisation ────────────────────────────────────────────────
529
530/// Mode for common-beam determination.
531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
532pub enum CubeMode {
533    /// Each channel gets its own common beam (written to BEAMS extension).
534    Natural,
535    /// All channels share a single common beam (written to primary header only).
536    Total,
537}
538
539// The header-only copy (`copy_header_only`) and update-in-place keyword editors
540// (`update_key_f64`, `update_key_logical`) now live in `atfits_rs` and are
541// imported at the top of this module. For the streaming write path that keeps
542// the handle open so the data unit is written exactly once, see
543// [`atfits_rs::copy_header_only_open`].
544
545/// Reference beam for the primary header: the beam at the spectral CRPIX
546/// (clamped to the channel range, so a reference pixel off either end of the
547/// axis picks the nearest channel), falling back to the first valid beam, then
548/// to a zero beam.
549fn ref_beam_for(target_beams: &[Option<Beam>], meta: &CubeMeta) -> Beam {
550    let last = meta.nfreq.saturating_sub(1) as i64;
551    let ref_idx = (meta.crpix_freq - 1).clamp(0, last) as usize;
552    target_beams[ref_idx].unwrap_or_else(|| {
553        // Find first valid beam if the reference channel is masked.
554        target_beams.iter().find_map(|b| *b).unwrap_or(Beam::zero())
555    })
556}
557
558/// Write the primary-header PSF keywords (BMAJ/BMIN/BPA + CASAMBM) in place.
559///
560/// `fptr` must be positioned at the primary HDU. Uses `update_key_*` (ffuky*),
561/// which overwrites in place — the input header is copied verbatim and may
562/// already carry these cards, so appending would duplicate them. CASAMBM is
563/// written as a FITS *logical* (not a quoted string), or casacore/CARTA fail to
564/// open the cube (they read it with `asBool`).
565fn write_primary_beam_keys(
566    fptr: &mut FitsFile,
567    ref_beam: Beam,
568    natural: bool,
569) -> Result<(), CubeError> {
570    fptr.primary_hdu()?; // position at the primary HDU
571    update_key_f64(fptr, "BMAJ", ref_beam.major_deg)?;
572    update_key_f64(fptr, "BMIN", ref_beam.minor_deg)?;
573    update_key_f64(fptr, "BPA", ref_beam.pa_deg)?;
574    update_key_logical(fptr, "CASAMBM", natural)?;
575    Ok(())
576}
577
578/// Append the CASA BEAMS binary-table extension (per-channel beams) to `fptr`.
579///
580/// Creating this extension forces cfitsio to flush the primary data unit, so on
581/// the streaming write path it must be called *after* every channel is written
582/// (see [`CubeWriter::finish`]) to keep the data unit written in a single pass.
583fn write_beams_table(
584    fptr: &mut FitsFile,
585    target_beams: &[Option<Beam>],
586    nfreq: usize,
587) -> Result<(), CubeError> {
588    let tiny = f32::MIN_POSITIVE as f64;
589
590    // Build per-channel beam arrays (BMAJ/BMIN in arcsec, BPA in deg).
591    let bmaj: Vec<f32> = target_beams
592        .iter()
593        .map(|b| b.map_or(tiny as f32, |b| b.major_arcsec() as f32))
594        .collect();
595    let bmin: Vec<f32> = target_beams
596        .iter()
597        .map(|b| b.map_or(tiny as f32, |b| b.minor_arcsec() as f32))
598        .collect();
599    let bpa: Vec<f32> = target_beams
600        .iter()
601        .map(|b| b.map_or(tiny as f32, |b| b.pa_deg as f32))
602        .collect();
603    let chan: Vec<i32> = (0..nfreq as i32).collect();
604    let pol: Vec<i32> = vec![0i32; nfreq];
605
606    let col_bmaj = ColumnDescription::new("BMAJ")
607        .with_type(ColumnDataType::Float)
608        .create()?;
609    let col_bmin = ColumnDescription::new("BMIN")
610        .with_type(ColumnDataType::Float)
611        .create()?;
612    let col_bpa = ColumnDescription::new("BPA")
613        .with_type(ColumnDataType::Float)
614        .create()?;
615    let col_chan = ColumnDescription::new("CHAN")
616        .with_type(ColumnDataType::Int)
617        .create()?;
618    let col_pol = ColumnDescription::new("POL")
619        .with_type(ColumnDataType::Int)
620        .create()?;
621
622    let table_hdu =
623        fptr.create_table("BEAMS", &[col_bmaj, col_bmin, col_bpa, col_chan, col_pol])?;
624    table_hdu.write_col(fptr, "BMAJ", &bmaj)?;
625    table_hdu.write_col(fptr, "BMIN", &bmin)?;
626    table_hdu.write_col(fptr, "BPA", &bpa)?;
627    table_hdu.write_col(fptr, "CHAN", &chan)?;
628    table_hdu.write_col(fptr, "POL", &pol)?;
629
630    // Standard BEAMS extension keywords.  `create_table` already wrote EXTNAME,
631    // so we do not re-write it (that would append a duplicate card).  Column
632    // units (TUNITn) are required by casacore/CARTA to interpret the beam table:
633    // BMAJ/BMIN in arcsec, BPA in deg.
634    let beam_hdu = fptr.hdu("BEAMS")?;
635    beam_hdu.write_key(fptr, "TUNIT1", "arcsec")?;
636    beam_hdu.write_key(fptr, "TUNIT2", "arcsec")?;
637    beam_hdu.write_key(fptr, "TUNIT3", "deg")?;
638    beam_hdu.write_key(fptr, "NCHAN", nfreq as i64)?;
639    beam_hdu.write_key(fptr, "NPOL", 1i64)?;
640    Ok(())
641}
642
643/// Initialise an output cube by copying the input header, then updating the beam
644/// headers, closing the file once. The data unit is zero-filled by the close.
645///
646/// For `Natural` mode a BEAMS binary-table extension is appended.
647/// For `Total` mode only the primary BMAJ/BMIN/BPA keywords are updated.
648///
649/// The streaming cube write path uses [`CubeWriter::create`] instead, which keeps
650/// the handle open so the data unit is written a single time. This function
651/// remains for callers that initialise then write planes through a separate
652/// handle (e.g. [`write_channel`]).
653pub fn init_output_cube(
654    input_path: &Path,
655    output_path: &Path,
656    target_beams: &[Option<Beam>],
657    mode: CubeMode,
658    meta: &CubeMeta,
659) -> Result<(), CubeError> {
660    // Initialise the output on disk cheaply: copy only the primary-HDU header
661    // from the input (NAXIS/WCS/HISTORY/etc.), not the pixel data.  cfitsio
662    // defines the data unit from the copied NAXIS keywords and zero-fills it
663    // (sparsely) on close, so we never read the multi-GB input cube — every
664    // plane is overwritten by `write_channel` anyway.
665    copy_header_only(input_path, output_path)?;
666
667    let ref_beam = ref_beam_for(target_beams, meta);
668
669    {
670        let path_str = output_path.to_string_lossy().into_owned();
671        let mut fptr = FitsFile::edit(&path_str)?;
672        write_primary_beam_keys(&mut fptr, ref_beam, mode == CubeMode::Natural)?;
673
674        if mode == CubeMode::Natural {
675            write_beams_table(&mut fptr, target_beams, meta.nfreq)?;
676        }
677    }
678
679    Ok(())
680}
681
682// ── Beamlog ───────────────────────────────────────────────────────────────────
683
684/// Read per-channel beams from a plain-text beamlog.
685///
686/// Format (produced by RACS-tools or our own writer):
687/// ```text
688/// # Channel BMAJ[arcsec] BMIN[arcsec] BPA[deg]
689/// 0  20.0  10.0  10.0
690/// 1  21.0  10.5  10.0
691/// ```
692/// Column names may include bracketed units (stripped automatically).
693/// Returns beams in channel order; returns `Beam::zero()` for masked/zero rows.
694pub fn read_beamlog(path: &Path) -> Result<Vec<Beam>, CubeError> {
695    let content = std::fs::read_to_string(path)?;
696    let mut beams = Vec::new();
697    let tiny = f64::from(f32::MIN_POSITIVE);
698
699    for (i, line) in content.lines().enumerate() {
700        let trimmed = line.trim();
701        if trimmed.is_empty() || trimmed.starts_with('#') {
702            continue;
703        }
704        let fields: Vec<&str> = trimmed.split_whitespace().collect();
705        if fields.len() < 4 {
706            return Err(CubeError::BeamlogParse {
707                line: i + 1,
708                msg: format!("expected 4 fields, got {}", fields.len()),
709            });
710        }
711        let parse = |s: &str, n: &str| -> Result<f64, CubeError> {
712            s.parse::<f64>().map_err(|_| CubeError::BeamlogParse {
713                line: i + 1,
714                msg: format!("cannot parse {n}={s:?} as float"),
715            })
716        };
717        // fields[0] = channel index (ignored)
718        let bmaj_as = parse(fields[1], "BMAJ")?;
719        let bmin_as = parse(fields[2], "BMIN")?;
720        let bpa_deg = parse(fields[3], "BPA")?;
721
722        let beam = if bmaj_as <= tiny || !bmaj_as.is_finite() {
723            Beam::zero()
724        } else {
725            Beam::from_arcsec(bmaj_as, bmin_as.max(tiny), bpa_deg)?
726        };
727        beams.push(beam);
728    }
729    Ok(beams)
730}
731
732/// Write per-channel beams to a plain-text beamlog.
733pub fn write_beamlog(path: &Path, beams: &[Option<Beam>]) -> Result<(), CubeError> {
734    use std::fmt::Write as _;
735    let mut out = String::new();
736    writeln!(out, "# Channel BMAJ[arcsec] BMIN[arcsec] BPA[deg]").unwrap();
737    for (i, b) in beams.iter().enumerate() {
738        match b {
739            Some(b) => writeln!(
740                out,
741                "{} {} {} {}",
742                i,
743                b.major_arcsec(),
744                b.minor_arcsec(),
745                b.pa_deg
746            ),
747            None => writeln!(out, "{i} nan nan nan"),
748        }
749        .unwrap();
750    }
751    std::fs::write(path, out)?;
752    Ok(())
753}