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