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::convolve_uv::FftFloat;
19use crate::smooth::BrightnessUnit;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum PixelType {
31 F32,
32 F64,
33}
34
35impl PixelType {
36 pub fn from_bitpix(bitpix: i64) -> Self {
39 if bitpix == -64 {
40 PixelType::F64
41 } else {
42 PixelType::F32
43 }
44 }
45}
46
47pub 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#[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#[derive(Debug)]
122pub struct CubeMeta {
123 pub path: PathBuf,
124 pub nx: usize,
126 pub ny: usize,
128 pub nfreq: usize,
130 pub nstokes: usize,
132 pub dx_deg: f64,
134 pub dy_deg: f64,
136 pub crpix_freq: i64,
138 pub beams: Vec<Option<Beam>>,
140 pub is_4d: bool,
142 pub unit: BrightnessUnit,
144 pub dtype: PixelType,
146}
147
148impl CubeMeta {
149 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 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
166pub 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")?; 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 {
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 let crpix_freq: i64 = hdu.read_key(&mut fptr, "CRPIX3").unwrap_or(1);
200
201 let bitpix: i64 = hdu.read_key(&mut fptr, "BITPIX").unwrap_or(-32);
204 let dtype = PixelType::from_bitpix(bitpix);
205 if bitpix > 0 {
206 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 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 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); 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 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
301fn 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 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
345pub 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
365pub fn read_channel(path: &Path, chan: usize, meta: &CubeMeta) -> Result<Array2<f32>, CubeError> {
367 read_channel_as::<f32>(path, chan, meta)
368}
369
370pub 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
390pub 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
400pub struct CubeWriter {
408 fptr: FitsFile,
409}
410
411impl CubeWriter {
412 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448pub enum CubeMode {
449 Natural,
451 Total,
453}
454
455fn copy_header_only(input: &Path, output: &Path) -> Result<(), CubeError> {
462 use std::ffi::CString;
463
464 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()?; 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 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
517fn 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, std::ptr::null_mut(),
534 &mut status,
535 );
536 }
537 fitsio::errors::check_status(status)?;
538 Ok(())
539}
540
541fn 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
564pub 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 copy_header_only(input_path, output_path)?;
581
582 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 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()?; 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 update_key_logical(&mut fptr, "CASAMBM", mode == CubeMode::Natural)?;
605 }
606
607 if mode == CubeMode::Natural {
608 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 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
666pub 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 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
716pub 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}