1use 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#[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#[derive(Debug)]
48pub struct CubeMeta {
49 pub path: PathBuf,
50 pub nx: usize,
52 pub ny: usize,
54 pub nfreq: usize,
56 pub nstokes: usize,
58 pub dx_deg: f64,
60 pub dy_deg: f64,
62 pub crpix_freq: i64,
64 pub beams: Vec<Option<Beam>>,
66 pub is_4d: bool,
68 pub unit: BrightnessUnit,
70}
71
72impl CubeMeta {
73 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
81pub 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")?; 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 {
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 let crpix_freq: i64 = hdu.read_key(&mut fptr, "CRPIX3").unwrap_or(1);
115
116 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 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); 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 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
197fn 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 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
238pub 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
257pub 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
279pub struct CubeWriter {
287 fptr: FitsFile,
288}
289
290impl CubeWriter {
291 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub enum CubeMode {
320 Natural,
322 Total,
324}
325
326fn 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()?; 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 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
370fn 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, std::ptr::null_mut(),
387 &mut status,
388 );
389 }
390 fitsio::errors::check_status(status)?;
391 Ok(())
392}
393
394#[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
415fn 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
438pub 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 copy_header_only(input_path, output_path)?;
455
456 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 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()?; 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 update_key_logical(&mut fptr, "CASAMBM", mode == CubeMode::Natural)?;
479 }
480
481 if mode == CubeMode::Natural {
482 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 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
540pub 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 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
590pub 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}