1use std::path::{Path, PathBuf};
9
10use atfits_rs::{copy_header_only, update_key_f64, update_key_logical};
11use fitsio::{
12 FitsFile,
13 tables::{ColumnDataType, ColumnDescription},
14};
15use ndarray::Array2;
16use thiserror::Error;
17
18use crate::beam::{Beam, BeamError};
19use crate::convolve_uv::FftFloat;
20use crate::smooth::BrightnessUnit;
21
22pub use atfits_rs::PixelType;
27
28pub trait CubeElem: FftFloat {
35 fn read_section_vec(
36 fptr: &mut FitsFile,
37 start: usize,
38 end: usize,
39 ) -> Result<Vec<Self>, CubeError>;
40 fn write_section_vec(
41 fptr: &mut FitsFile,
42 start: usize,
43 end: usize,
44 data: &[Self],
45 ) -> Result<(), CubeError>;
46}
47
48macro_rules! impl_cube_elem {
49 ($t:ty) => {
50 impl CubeElem for $t {
51 fn read_section_vec(
52 fptr: &mut FitsFile,
53 start: usize,
54 end: usize,
55 ) -> Result<Vec<Self>, CubeError> {
56 Ok(<$t as atfits_rs::CubeElem>::read_section(fptr, start, end)?)
57 }
58 fn write_section_vec(
59 fptr: &mut FitsFile,
60 start: usize,
61 end: usize,
62 data: &[Self],
63 ) -> Result<(), CubeError> {
64 <$t as atfits_rs::CubeElem>::write_section(fptr, start, end, data)?;
65 Ok(())
66 }
67 }
68 };
69}
70impl_cube_elem!(f32);
71impl_cube_elem!(f64);
72
73#[derive(Debug, Error)]
76pub enum CubeError {
77 #[error("FITS I/O error: {0}")]
78 Fits(#[from] fitsio::errors::Error),
79 #[error("I/O error: {0}")]
80 Io(#[from] std::io::Error),
81 #[error("shape error: {0}")]
82 Shape(#[from] ndarray::ShapeError),
83 #[error("invalid beam: {0}")]
84 Beam(#[from] BeamError),
85 #[error("unsupported NAXIS={0} (expected 3 or 4)")]
86 UnsupportedNaxis(i64),
87 #[error("missing header keyword: {0}")]
88 MissingKeyword(String),
89 #[error("channel count mismatch in BEAMS extension: expected {expected}, got {got}")]
90 BeamCountMismatch { expected: usize, got: usize },
91 #[error("beamlog parse error at line {line}: {msg}")]
92 BeamlogParse { line: usize, msg: String },
93 #[error("no per-channel beam source found (no CASAMBM, no beamlog, no header beam)")]
94 NoBeans,
95}
96
97impl From<atfits_rs::AtfitsError> for CubeError {
100 fn from(e: atfits_rs::AtfitsError) -> Self {
101 use atfits_rs::AtfitsError as A;
102 match e {
103 A::Fits(e) => CubeError::Fits(e),
104 A::Io(e) => CubeError::Io(e),
105 A::MissingKeyword(s) | A::TargetAxisMissing(s) => CubeError::MissingKeyword(s),
106 A::UnsupportedNaxis(n) => CubeError::UnsupportedNaxis(n),
107 A::Other(s) => CubeError::Io(std::io::Error::other(s)),
108 }
109 }
110}
111
112#[derive(Debug)]
116pub struct CubeMeta {
117 pub path: PathBuf,
118 pub nx: usize,
120 pub ny: usize,
122 pub nfreq: usize,
124 pub nstokes: usize,
126 pub dx_deg: f64,
128 pub dy_deg: f64,
130 pub crpix_freq: i64,
132 pub beams: Vec<Option<Beam>>,
134 pub is_4d: bool,
136 pub unit: BrightnessUnit,
138 pub dtype: PixelType,
140}
141
142impl CubeMeta {
143 pub fn channel_range(&self, chan: usize) -> (usize, usize) {
147 let plane = self.ny * self.nx;
148 let start = chan * plane;
149 (start, start + plane)
150 }
151
152 pub fn beamlog_path(&self) -> PathBuf {
154 let dir = self.path.parent().unwrap_or(Path::new("."));
155 let stem = self.path.file_stem().unwrap_or_default();
156 dir.join(format!("beamlog.{}.txt", stem.to_string_lossy()))
157 }
158}
159
160pub fn read_cube_meta(path: &Path) -> Result<CubeMeta, CubeError> {
164 let path_str = path.to_string_lossy().into_owned();
165 let mut fptr = FitsFile::open(&path_str)?;
166 let hdu = fptr.primary_hdu()?;
167
168 let naxis: i64 = hdu.read_key(&mut fptr, "NAXIS")?;
169 if naxis != 3 && naxis != 4 {
170 return Err(CubeError::UnsupportedNaxis(naxis));
171 }
172
173 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 {
178 let naxis4: i64 = hdu.read_key(&mut fptr, "NAXIS4")?;
179 (naxis4 as usize, naxis3 as usize, true)
180 } else {
181 (1, naxis3 as usize, false)
182 };
183
184 let nx = naxis1 as usize;
185 let ny = naxis2 as usize;
186
187 let cdelt1: f64 = hdu.read_key(&mut fptr, "CDELT1")?;
188 let cdelt2: f64 = hdu.read_key(&mut fptr, "CDELT2")?;
189 let dx_deg = cdelt1.abs();
190 let dy_deg = cdelt2.abs();
191
192 let crpix_freq: i64 = hdu.read_key(&mut fptr, "CRPIX3").unwrap_or(1);
194
195 let bitpix: i64 = hdu.read_key(&mut fptr, "BITPIX").unwrap_or(-32);
198 let dtype = PixelType::from_bitpix(bitpix);
199 if bitpix > 0 {
200 tracing::warn!(
204 "{}: integer BITPIX={}; convolution runs in f32 but the output is \
205 written at integer precision (fractional flux is rounded). Convert \
206 to a floating-point cube (BITPIX=-32) to avoid this.",
207 path.display(),
208 bitpix
209 );
210 }
211
212 let unit = match hdu.read_key::<String>(&mut fptr, "BUNIT") {
214 Ok(s) => BrightnessUnit::from_bunit(&s),
215 Err(_) => {
216 tracing::warn!(
217 "No BUNIT keyword in {}; assuming Jy/beam (flux scaling applied).",
218 path.display()
219 );
220 BrightnessUnit::default()
221 }
222 };
223
224 let casambm = hdu
227 .read_key::<bool>(&mut fptr, "CASAMBM")
228 .ok()
229 .or_else(|| {
230 hdu.read_key::<String>(&mut fptr, "CASAMBM")
231 .ok()
232 .map(|s| matches!(s.trim(), "T" | "TRUE"))
233 })
234 .unwrap_or(false);
235 drop(fptr); let beams: Vec<Option<Beam>> = if casambm {
238 read_casambm_beams(path, nfreq)?
239 } else {
240 let beamlog = CubeMeta {
241 path: path.to_path_buf(),
242 nx,
243 ny,
244 nfreq,
245 nstokes,
246 dx_deg,
247 dy_deg,
248 crpix_freq,
249 beams: vec![],
250 is_4d,
251 unit,
252 dtype,
253 }
254 .beamlog_path();
255
256 if beamlog.exists() {
257 let parsed = read_beamlog(&beamlog)?;
258 if parsed.len() != nfreq {
259 return Err(CubeError::BeamCountMismatch {
260 expected: nfreq,
261 got: parsed.len(),
262 });
263 }
264 parsed.into_iter().map(Some).collect()
265 } else {
266 let mut fptr2 = FitsFile::open(path.to_string_lossy().into_owned())?;
268 let hdu2 = fptr2.primary_hdu()?;
269 let bmaj: f64 = hdu2
270 .read_key(&mut fptr2, "BMAJ")
271 .map_err(|_| CubeError::NoBeans)?;
272 let bmin: f64 = hdu2.read_key(&mut fptr2, "BMIN").unwrap_or(bmaj);
273 let bpa: f64 = hdu2.read_key(&mut fptr2, "BPA").unwrap_or(0.0);
274 let b = Beam::new(bmaj, bmin, bpa)?;
275 vec![Some(b); nfreq]
276 }
277 };
278
279 Ok(CubeMeta {
280 path: path.to_path_buf(),
281 nx,
282 ny,
283 nfreq,
284 nstokes,
285 dx_deg,
286 dy_deg,
287 crpix_freq,
288 beams,
289 is_4d,
290 unit,
291 dtype,
292 })
293}
294
295fn read_casambm_beams(path: &Path, nfreq: usize) -> Result<Vec<Option<Beam>>, CubeError> {
299 let path_str = path.to_string_lossy().into_owned();
300 let mut fptr = FitsFile::open(&path_str)?;
301 let hdu = fptr
302 .hdu("BEAMS")
303 .map_err(|_| CubeError::MissingKeyword("BEAMS extension".into()))?;
304
305 let bmaj: Vec<f32> = hdu.read_col(&mut fptr, "BMAJ")?;
306 let bmin: Vec<f32> = hdu.read_col(&mut fptr, "BMIN")?;
307 let bpa: Vec<f32> = hdu.read_col(&mut fptr, "BPA")?;
308
309 if bmaj.len() != nfreq {
310 return Err(CubeError::BeamCountMismatch {
311 expected: nfreq,
312 got: bmaj.len(),
313 });
314 }
315
316 let tiny = f32::MIN_POSITIVE as f64;
317 let beams = bmaj
318 .iter()
319 .zip(bmin.iter())
320 .zip(bpa.iter())
321 .map(|((&maj_as, &min_as), &pa_deg)| {
322 let maj_deg = maj_as as f64 / 3600.0;
323 let min_deg = min_as as f64 / 3600.0;
324 let pa = pa_deg as f64;
325 if maj_deg <= tiny || !maj_deg.is_finite() {
330 None
331 } else {
332 Beam::new(maj_deg, min_deg.max(tiny), pa).ok()
333 }
334 })
335 .collect();
336 Ok(beams)
337}
338
339pub fn read_channel_as<T: CubeElem>(
347 path: &Path,
348 chan: usize,
349 meta: &CubeMeta,
350) -> Result<Array2<T>, CubeError> {
351 let path_str = path.to_string_lossy().into_owned();
352 let mut fptr = FitsFile::open(&path_str)?;
353
354 let (start, end) = meta.channel_range(chan);
355 let data = T::read_section_vec(&mut fptr, start, end)?;
356 Ok(Array2::from_shape_vec((meta.ny, meta.nx), data)?)
357}
358
359pub fn read_channel(path: &Path, chan: usize, meta: &CubeMeta) -> Result<Array2<f32>, CubeError> {
361 read_channel_as::<f32>(path, chan, meta)
362}
363
364pub fn write_channel_as<T: CubeElem>(
369 path: &Path,
370 chan: usize,
371 data: &Array2<T>,
372 meta: &CubeMeta,
373) -> Result<(), CubeError> {
374 let path_str = path.to_string_lossy().into_owned();
375 let mut fptr = FitsFile::edit(&path_str)?;
376
377 let (start, end) = meta.channel_range(chan);
378 let flat = data.as_standard_layout();
379 let slice = flat.as_slice().expect("standard-layout plane");
380 T::write_section_vec(&mut fptr, start, end, slice)?;
381 Ok(())
382}
383
384pub fn write_channel(
386 path: &Path,
387 chan: usize,
388 data: &Array2<f32>,
389 meta: &CubeMeta,
390) -> Result<(), CubeError> {
391 write_channel_as::<f32>(path, chan, data, meta)
392}
393
394pub struct CubeWriter {
402 fptr: FitsFile,
403 pending_beams: Option<PendingBeams>,
411}
412
413struct PendingBeams {
415 beams: Vec<Option<Beam>>,
416 nfreq: usize,
417}
418
419impl CubeWriter {
420 pub fn create(
433 input_path: &Path,
434 output_path: &Path,
435 target_beams: &[Option<Beam>],
436 mode: CubeMode,
437 meta: &CubeMeta,
438 ) -> Result<Self, CubeError> {
439 let mut fptr = atfits_rs::copy_header_only_open(input_path, output_path)?;
443
444 let ref_beam = ref_beam_for(target_beams, meta);
445 write_primary_beam_keys(&mut fptr, ref_beam, mode == CubeMode::Natural)?;
446
447 fptr.primary_hdu()?;
450
451 let pending_beams = (mode == CubeMode::Natural).then(|| PendingBeams {
452 beams: target_beams.to_vec(),
453 nfreq: meta.nfreq,
454 });
455 Ok(Self {
456 fptr,
457 pending_beams,
458 })
459 }
460
461 pub fn open(path: &Path) -> Result<Self, CubeError> {
464 let fptr = FitsFile::edit(path.to_string_lossy().into_owned())?;
465 Ok(Self {
466 fptr,
467 pending_beams: None,
468 })
469 }
470
471 pub fn write_channel_as<T: CubeElem>(
473 &mut self,
474 chan: usize,
475 data: &Array2<T>,
476 meta: &CubeMeta,
477 ) -> Result<(), CubeError> {
478 let (start, end) = meta.channel_range(chan);
479 let flat = data.as_standard_layout();
480 let slice = flat.as_slice().expect("standard-layout plane");
481 T::write_section_vec(&mut self.fptr, start, end, slice)?;
482 Ok(())
483 }
484
485 pub fn write_channel(
487 &mut self,
488 chan: usize,
489 data: &Array2<f32>,
490 meta: &CubeMeta,
491 ) -> Result<(), CubeError> {
492 self.write_channel_as::<f32>(chan, data, meta)
493 }
494
495 pub fn finish(mut self) -> Result<(), CubeError> {
502 if let Some(p) = self.pending_beams.take() {
503 write_beams_table(&mut self.fptr, &p.beams, p.nfreq)?;
504 }
505 Ok(())
507 }
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
514pub enum CubeMode {
515 Natural,
517 Total,
519}
520
521fn ref_beam_for(target_beams: &[Option<Beam>], meta: &CubeMeta) -> Beam {
530 let ref_idx = ((meta.crpix_freq - 1) as usize).min(meta.nfreq.saturating_sub(1));
531 target_beams[ref_idx].unwrap_or_else(|| {
532 target_beams.iter().find_map(|b| *b).unwrap_or(Beam::zero())
534 })
535}
536
537fn write_primary_beam_keys(
545 fptr: &mut FitsFile,
546 ref_beam: Beam,
547 natural: bool,
548) -> Result<(), CubeError> {
549 fptr.primary_hdu()?; update_key_f64(fptr, "BMAJ", ref_beam.major_deg)?;
551 update_key_f64(fptr, "BMIN", ref_beam.minor_deg)?;
552 update_key_f64(fptr, "BPA", ref_beam.pa_deg)?;
553 update_key_logical(fptr, "CASAMBM", natural)?;
554 Ok(())
555}
556
557fn write_beams_table(
563 fptr: &mut FitsFile,
564 target_beams: &[Option<Beam>],
565 nfreq: usize,
566) -> Result<(), CubeError> {
567 let tiny = f32::MIN_POSITIVE as f64;
568
569 let bmaj: Vec<f32> = target_beams
571 .iter()
572 .map(|b| b.map_or(tiny as f32, |b| b.major_arcsec() as f32))
573 .collect();
574 let bmin: Vec<f32> = target_beams
575 .iter()
576 .map(|b| b.map_or(tiny as f32, |b| b.minor_arcsec() as f32))
577 .collect();
578 let bpa: Vec<f32> = target_beams
579 .iter()
580 .map(|b| b.map_or(tiny as f32, |b| b.pa_deg as f32))
581 .collect();
582 let chan: Vec<i32> = (0..nfreq as i32).collect();
583 let pol: Vec<i32> = vec![0i32; nfreq];
584
585 let col_bmaj = ColumnDescription::new("BMAJ")
586 .with_type(ColumnDataType::Float)
587 .create()?;
588 let col_bmin = ColumnDescription::new("BMIN")
589 .with_type(ColumnDataType::Float)
590 .create()?;
591 let col_bpa = ColumnDescription::new("BPA")
592 .with_type(ColumnDataType::Float)
593 .create()?;
594 let col_chan = ColumnDescription::new("CHAN")
595 .with_type(ColumnDataType::Int)
596 .create()?;
597 let col_pol = ColumnDescription::new("POL")
598 .with_type(ColumnDataType::Int)
599 .create()?;
600
601 let table_hdu =
602 fptr.create_table("BEAMS", &[col_bmaj, col_bmin, col_bpa, col_chan, col_pol])?;
603 table_hdu.write_col(fptr, "BMAJ", &bmaj)?;
604 table_hdu.write_col(fptr, "BMIN", &bmin)?;
605 table_hdu.write_col(fptr, "BPA", &bpa)?;
606 table_hdu.write_col(fptr, "CHAN", &chan)?;
607 table_hdu.write_col(fptr, "POL", &pol)?;
608
609 let beam_hdu = fptr.hdu("BEAMS")?;
614 beam_hdu.write_key(fptr, "TUNIT1", "arcsec")?;
615 beam_hdu.write_key(fptr, "TUNIT2", "arcsec")?;
616 beam_hdu.write_key(fptr, "TUNIT3", "deg")?;
617 beam_hdu.write_key(fptr, "NCHAN", nfreq as i64)?;
618 beam_hdu.write_key(fptr, "NPOL", 1i64)?;
619 Ok(())
620}
621
622pub fn init_output_cube(
633 input_path: &Path,
634 output_path: &Path,
635 target_beams: &[Option<Beam>],
636 mode: CubeMode,
637 meta: &CubeMeta,
638) -> Result<(), CubeError> {
639 copy_header_only(input_path, output_path)?;
645
646 let ref_beam = ref_beam_for(target_beams, meta);
647
648 {
649 let path_str = output_path.to_string_lossy().into_owned();
650 let mut fptr = FitsFile::edit(&path_str)?;
651 write_primary_beam_keys(&mut fptr, ref_beam, mode == CubeMode::Natural)?;
652
653 if mode == CubeMode::Natural {
654 write_beams_table(&mut fptr, target_beams, meta.nfreq)?;
655 }
656 }
657
658 Ok(())
659}
660
661pub fn read_beamlog(path: &Path) -> Result<Vec<Beam>, CubeError> {
674 let content = std::fs::read_to_string(path)?;
675 let mut beams = Vec::new();
676 let tiny = f64::from(f32::MIN_POSITIVE);
677
678 for (i, line) in content.lines().enumerate() {
679 let trimmed = line.trim();
680 if trimmed.is_empty() || trimmed.starts_with('#') {
681 continue;
682 }
683 let fields: Vec<&str> = trimmed.split_whitespace().collect();
684 if fields.len() < 4 {
685 return Err(CubeError::BeamlogParse {
686 line: i + 1,
687 msg: format!("expected 4 fields, got {}", fields.len()),
688 });
689 }
690 let parse = |s: &str, n: &str| -> Result<f64, CubeError> {
691 s.parse::<f64>().map_err(|_| CubeError::BeamlogParse {
692 line: i + 1,
693 msg: format!("cannot parse {n}={s:?} as float"),
694 })
695 };
696 let bmaj_as = parse(fields[1], "BMAJ")?;
698 let bmin_as = parse(fields[2], "BMIN")?;
699 let bpa_deg = parse(fields[3], "BPA")?;
700
701 let beam = if bmaj_as <= tiny || !bmaj_as.is_finite() {
702 Beam::zero()
703 } else {
704 Beam::from_arcsec(bmaj_as, bmin_as.max(tiny), bpa_deg)?
705 };
706 beams.push(beam);
707 }
708 Ok(beams)
709}
710
711pub fn write_beamlog(path: &Path, beams: &[Option<Beam>]) -> Result<(), CubeError> {
713 use std::fmt::Write as _;
714 let mut out = String::new();
715 writeln!(out, "# Channel BMAJ[arcsec] BMIN[arcsec] BPA[deg]").unwrap();
716 for (i, b) in beams.iter().enumerate() {
717 match b {
718 Some(b) => writeln!(
719 out,
720 "{} {} {} {}",
721 i,
722 b.major_arcsec(),
723 b.minor_arcsec(),
724 b.pa_deg
725 ),
726 None => writeln!(out, "{i} nan nan nan"),
727 }
728 .unwrap();
729 }
730 std::fs::write(path, out)?;
731 Ok(())
732}