use std::path::{Path, PathBuf};
use fitsio::{
FitsFile,
tables::{ColumnDataType, ColumnDescription},
};
use ndarray::Array2;
use thiserror::Error;
use crate::beam::{Beam, BeamError};
use crate::smooth::BrightnessUnit;
#[derive(Debug, Error)]
pub enum CubeError {
#[error("FITS I/O error: {0}")]
Fits(#[from] fitsio::errors::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("shape error: {0}")]
Shape(#[from] ndarray::ShapeError),
#[error("invalid beam: {0}")]
Beam(#[from] BeamError),
#[error("unsupported NAXIS={0} (expected 3 or 4)")]
UnsupportedNaxis(i64),
#[error("missing header keyword: {0}")]
MissingKeyword(String),
#[error("channel count mismatch in BEAMS extension: expected {expected}, got {got}")]
BeamCountMismatch { expected: usize, got: usize },
#[error("beamlog parse error at line {line}: {msg}")]
BeamlogParse { line: usize, msg: String },
#[error("no per-channel beam source found (no CASAMBM, no beamlog, no header beam)")]
NoBeans,
}
#[derive(Debug)]
pub struct CubeMeta {
pub path: PathBuf,
pub nx: usize,
pub ny: usize,
pub nfreq: usize,
pub nstokes: usize,
pub dx_deg: f64,
pub dy_deg: f64,
pub crpix_freq: i64,
pub beams: Vec<Option<Beam>>,
pub is_4d: bool,
pub unit: BrightnessUnit,
}
impl CubeMeta {
pub fn beamlog_path(&self) -> PathBuf {
let dir = self.path.parent().unwrap_or(Path::new("."));
let stem = self.path.file_stem().unwrap_or_default();
dir.join(format!("beamlog.{}.txt", stem.to_string_lossy()))
}
}
pub fn read_cube_meta(path: &Path) -> Result<CubeMeta, CubeError> {
let path_str = path.to_string_lossy().into_owned();
let mut fptr = FitsFile::open(&path_str)?;
let hdu = fptr.primary_hdu()?;
let naxis: i64 = hdu.read_key(&mut fptr, "NAXIS")?;
if naxis != 3 && naxis != 4 {
return Err(CubeError::UnsupportedNaxis(naxis));
}
let naxis1: i64 = hdu.read_key(&mut fptr, "NAXIS1")?; let naxis2: i64 = hdu.read_key(&mut fptr, "NAXIS2")?; let naxis3: i64 = hdu.read_key(&mut fptr, "NAXIS3")?;
let (nstokes, nfreq, is_4d) = if naxis == 4 {
let naxis4: i64 = hdu.read_key(&mut fptr, "NAXIS4")?;
(naxis4 as usize, naxis3 as usize, true)
} else {
(1, naxis3 as usize, false)
};
let nx = naxis1 as usize;
let ny = naxis2 as usize;
let cdelt1: f64 = hdu.read_key(&mut fptr, "CDELT1")?;
let cdelt2: f64 = hdu.read_key(&mut fptr, "CDELT2")?;
let dx_deg = cdelt1.abs();
let dy_deg = cdelt2.abs();
let crpix_freq: i64 = hdu.read_key(&mut fptr, "CRPIX3").unwrap_or(1);
let unit = match hdu.read_key::<String>(&mut fptr, "BUNIT") {
Ok(s) => BrightnessUnit::from_bunit(&s),
Err(_) => {
tracing::warn!(
"No BUNIT keyword in {}; assuming Jy/beam (flux scaling applied).",
path.display()
);
BrightnessUnit::default()
}
};
let casambm = hdu
.read_key::<bool>(&mut fptr, "CASAMBM")
.ok()
.or_else(|| {
hdu.read_key::<String>(&mut fptr, "CASAMBM")
.ok()
.map(|s| matches!(s.trim(), "T" | "TRUE"))
})
.unwrap_or(false);
drop(fptr);
let beams: Vec<Option<Beam>> = if casambm {
read_casambm_beams(path, nfreq)?
} else {
let beamlog = CubeMeta {
path: path.to_path_buf(),
nx,
ny,
nfreq,
nstokes,
dx_deg,
dy_deg,
crpix_freq,
beams: vec![],
is_4d,
unit,
}
.beamlog_path();
if beamlog.exists() {
let parsed = read_beamlog(&beamlog)?;
if parsed.len() != nfreq {
return Err(CubeError::BeamCountMismatch {
expected: nfreq,
got: parsed.len(),
});
}
parsed.into_iter().map(Some).collect()
} else {
let mut fptr2 = FitsFile::open(path.to_string_lossy().into_owned())?;
let hdu2 = fptr2.primary_hdu()?;
let bmaj: f64 = hdu2
.read_key(&mut fptr2, "BMAJ")
.map_err(|_| CubeError::NoBeans)?;
let bmin: f64 = hdu2.read_key(&mut fptr2, "BMIN").unwrap_or(bmaj);
let bpa: f64 = hdu2.read_key(&mut fptr2, "BPA").unwrap_or(0.0);
let b = Beam::new(bmaj, bmin, bpa)?;
vec![Some(b); nfreq]
}
};
Ok(CubeMeta {
path: path.to_path_buf(),
nx,
ny,
nfreq,
nstokes,
dx_deg,
dy_deg,
crpix_freq,
beams,
is_4d,
unit,
})
}
fn read_casambm_beams(path: &Path, nfreq: usize) -> Result<Vec<Option<Beam>>, CubeError> {
let path_str = path.to_string_lossy().into_owned();
let mut fptr = FitsFile::open(&path_str)?;
let hdu = fptr
.hdu("BEAMS")
.map_err(|_| CubeError::MissingKeyword("BEAMS extension".into()))?;
let bmaj: Vec<f32> = hdu.read_col(&mut fptr, "BMAJ")?;
let bmin: Vec<f32> = hdu.read_col(&mut fptr, "BMIN")?;
let bpa: Vec<f32> = hdu.read_col(&mut fptr, "BPA")?;
if bmaj.len() != nfreq {
return Err(CubeError::BeamCountMismatch {
expected: nfreq,
got: bmaj.len(),
});
}
let tiny = f32::MIN_POSITIVE as f64;
let beams = bmaj
.iter()
.zip(bmin.iter())
.zip(bpa.iter())
.map(|((&maj_as, &min_as), &pa_deg)| {
let maj_deg = maj_as as f64 / 3600.0;
let min_deg = min_as as f64 / 3600.0;
let pa = pa_deg as f64;
if maj_deg < tiny || !maj_deg.is_finite() {
None
} else {
Beam::new(maj_deg, min_deg.max(tiny), pa).ok()
}
})
.collect();
Ok(beams)
}
pub fn read_channel(path: &Path, chan: usize, meta: &CubeMeta) -> Result<Array2<f32>, CubeError> {
let path_str = path.to_string_lossy().into_owned();
let mut fptr = FitsFile::open(&path_str)?;
let hdu = fptr.primary_hdu()?;
let plane = meta.ny * meta.nx;
let start = chan * plane;
let end = start + plane;
let data: Vec<f32> = hdu.read_section(&mut fptr, start, end)?;
Ok(Array2::from_shape_vec((meta.ny, meta.nx), data)?)
}
pub fn write_channel(
path: &Path,
chan: usize,
data: &Array2<f32>,
meta: &CubeMeta,
) -> Result<(), CubeError> {
let path_str = path.to_string_lossy().into_owned();
let mut fptr = FitsFile::edit(&path_str)?;
let hdu = fptr.primary_hdu()?;
let plane = meta.ny * meta.nx;
let start = chan * plane;
let end = start + plane;
let flat: Vec<f32> = data.iter().copied().collect();
hdu.write_section(&mut fptr, start, end, &flat)?;
Ok(())
}
pub struct CubeWriter {
fptr: FitsFile,
}
impl CubeWriter {
pub fn open(path: &Path) -> Result<Self, CubeError> {
let fptr = FitsFile::edit(path.to_string_lossy().into_owned())?;
Ok(Self { fptr })
}
pub fn write_channel(
&mut self,
chan: usize,
data: &Array2<f32>,
meta: &CubeMeta,
) -> Result<(), CubeError> {
let hdu = self.fptr.primary_hdu()?;
let plane = meta.ny * meta.nx;
let start = chan * plane;
let end = start + plane;
let flat: Vec<f32> = data.iter().copied().collect();
hdu.write_section(&mut self.fptr, start, end, &flat)?;
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CubeMode {
Natural,
Total,
}
fn copy_header_only(input: &Path, output: &Path) -> Result<(), CubeError> {
use std::ffi::CString;
let mut in_fptr = FitsFile::open(input.to_string_lossy().into_owned())?;
in_fptr.primary_hdu()?;
if output.exists() {
std::fs::remove_file(output)?;
}
let out_name = CString::new(output.to_string_lossy().into_owned())
.map_err(|e| CubeError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, e)))?;
let mut status = 0;
let mut raw_out: *mut fitsio::sys::fitsfile = std::ptr::null_mut();
unsafe {
fitsio::sys::ffinit(&mut raw_out, out_name.as_ptr(), &mut status);
fitsio::errors::check_status(status)?;
fitsio::sys::ffcphd(in_fptr.as_raw(), raw_out, &mut status);
let copy_status = fitsio::errors::check_status(status);
let mut close_status = 0;
fitsio::sys::ffclos(raw_out, &mut close_status);
copy_status?;
fitsio::errors::check_status(close_status)?;
}
Ok(())
}
fn update_key_f64(fptr: &mut FitsFile, name: &str, value: f64) -> Result<(), CubeError> {
let c_name = std::ffi::CString::new(name)
.map_err(|e| CubeError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, e)))?;
let mut status = 0;
unsafe {
fitsio::sys::ffukyd(
fptr.as_raw(),
c_name.as_ptr(),
value,
-15, std::ptr::null_mut(),
&mut status,
);
}
fitsio::errors::check_status(status)?;
Ok(())
}
#[allow(dead_code)]
fn update_key_str(fptr: &mut FitsFile, name: &str, value: &str) -> Result<(), CubeError> {
let c_name = std::ffi::CString::new(name)
.map_err(|e| CubeError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, e)))?;
let c_value = std::ffi::CString::new(value)
.map_err(|e| CubeError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, e)))?;
let mut status = 0;
unsafe {
fitsio::sys::ffukys(
fptr.as_raw(),
c_name.as_ptr(),
c_value.as_ptr(),
std::ptr::null_mut(),
&mut status,
);
}
fitsio::errors::check_status(status)?;
Ok(())
}
fn update_key_logical(fptr: &mut FitsFile, name: &str, value: bool) -> Result<(), CubeError> {
let c_name = std::ffi::CString::new(name)
.map_err(|e| CubeError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, e)))?;
let mut status = 0;
unsafe {
fitsio::sys::ffukyl(
fptr.as_raw(),
c_name.as_ptr(),
value as std::os::raw::c_int,
std::ptr::null_mut(),
&mut status,
);
}
fitsio::errors::check_status(status)?;
Ok(())
}
pub fn init_output_cube(
input_path: &Path,
output_path: &Path,
target_beams: &[Option<Beam>],
mode: CubeMode,
meta: &CubeMeta,
) -> Result<(), CubeError> {
copy_header_only(input_path, output_path)?;
let ref_idx = ((meta.crpix_freq - 1) as usize).min(meta.nfreq.saturating_sub(1));
let ref_beam = target_beams[ref_idx].unwrap_or_else(|| {
target_beams.iter().find_map(|b| *b).unwrap_or(Beam::zero())
});
let tiny = f32::MIN_POSITIVE as f64;
{
let path_str = output_path.to_string_lossy().into_owned();
let mut fptr = FitsFile::edit(&path_str)?;
fptr.primary_hdu()?;
update_key_f64(&mut fptr, "BMAJ", ref_beam.major_deg)?;
update_key_f64(&mut fptr, "BMIN", ref_beam.minor_deg)?;
update_key_f64(&mut fptr, "BPA", ref_beam.pa_deg)?;
update_key_logical(&mut fptr, "CASAMBM", mode == CubeMode::Natural)?;
}
if mode == CubeMode::Natural {
let bmaj: Vec<f32> = target_beams
.iter()
.map(|b| b.map_or(tiny as f32, |b| b.major_arcsec() as f32))
.collect();
let bmin: Vec<f32> = target_beams
.iter()
.map(|b| b.map_or(tiny as f32, |b| b.minor_arcsec() as f32))
.collect();
let bpa: Vec<f32> = target_beams
.iter()
.map(|b| b.map_or(tiny as f32, |b| b.pa_deg as f32))
.collect();
let chan: Vec<i32> = (0..meta.nfreq as i32).collect();
let pol: Vec<i32> = vec![0i32; meta.nfreq];
let col_bmaj = ColumnDescription::new("BMAJ")
.with_type(ColumnDataType::Float)
.create()?;
let col_bmin = ColumnDescription::new("BMIN")
.with_type(ColumnDataType::Float)
.create()?;
let col_bpa = ColumnDescription::new("BPA")
.with_type(ColumnDataType::Float)
.create()?;
let col_chan = ColumnDescription::new("CHAN")
.with_type(ColumnDataType::Int)
.create()?;
let col_pol = ColumnDescription::new("POL")
.with_type(ColumnDataType::Int)
.create()?;
let path_str = output_path.to_string_lossy().into_owned();
let mut fptr = FitsFile::edit(&path_str)?;
let table_hdu =
fptr.create_table("BEAMS", &[col_bmaj, col_bmin, col_bpa, col_chan, col_pol])?;
table_hdu.write_col(&mut fptr, "BMAJ", &bmaj)?;
table_hdu.write_col(&mut fptr, "BMIN", &bmin)?;
table_hdu.write_col(&mut fptr, "BPA", &bpa)?;
table_hdu.write_col(&mut fptr, "CHAN", &chan)?;
table_hdu.write_col(&mut fptr, "POL", &pol)?;
let beam_hdu = fptr.hdu("BEAMS")?;
beam_hdu.write_key(&mut fptr, "TUNIT1", "arcsec")?;
beam_hdu.write_key(&mut fptr, "TUNIT2", "arcsec")?;
beam_hdu.write_key(&mut fptr, "TUNIT3", "deg")?;
beam_hdu.write_key(&mut fptr, "NCHAN", meta.nfreq as i64)?;
beam_hdu.write_key(&mut fptr, "NPOL", 1i64)?;
}
Ok(())
}
pub fn read_beamlog(path: &Path) -> Result<Vec<Beam>, CubeError> {
let content = std::fs::read_to_string(path)?;
let mut beams = Vec::new();
let tiny = f64::from(f32::MIN_POSITIVE);
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let fields: Vec<&str> = trimmed.split_whitespace().collect();
if fields.len() < 4 {
return Err(CubeError::BeamlogParse {
line: i + 1,
msg: format!("expected 4 fields, got {}", fields.len()),
});
}
let parse = |s: &str, n: &str| -> Result<f64, CubeError> {
s.parse::<f64>().map_err(|_| CubeError::BeamlogParse {
line: i + 1,
msg: format!("cannot parse {n}={s:?} as float"),
})
};
let bmaj_as = parse(fields[1], "BMAJ")?;
let bmin_as = parse(fields[2], "BMIN")?;
let bpa_deg = parse(fields[3], "BPA")?;
let beam = if bmaj_as < tiny || !bmaj_as.is_finite() {
Beam::zero()
} else {
Beam::from_arcsec(bmaj_as, bmin_as.max(tiny), bpa_deg)?
};
beams.push(beam);
}
Ok(beams)
}
pub fn write_beamlog(path: &Path, beams: &[Option<Beam>]) -> Result<(), CubeError> {
use std::fmt::Write as _;
let mut out = String::new();
writeln!(out, "# Channel BMAJ[arcsec] BMIN[arcsec] BPA[deg]").unwrap();
for (i, b) in beams.iter().enumerate() {
match b {
Some(b) => writeln!(
out,
"{} {} {} {}",
i,
b.major_arcsec(),
b.minor_arcsec(),
b.pa_deg
),
None => writeln!(out, "{i} nan nan nan"),
}
.unwrap();
}
std::fs::write(path, out)?;
Ok(())
}