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