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