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,
133 pub beams: Vec<Option<Beam>>,
135 pub is_4d: bool,
137 pub unit: BrightnessUnit,
139 pub dtype: PixelType,
141}
142
143impl CubeMeta {
144 pub fn channel_range(&self, chan: usize) -> (usize, usize) {
148 let plane = self.ny * self.nx;
149 let start = chan * plane;
150 (start, start + plane)
151 }
152
153 pub fn beamlog_path(&self) -> PathBuf {
155 let dir = self.path.parent().unwrap_or(Path::new("."));
156 let stem = self.path.file_stem().unwrap_or_default();
157 dir.join(format!("beamlog.{}.txt", stem.to_string_lossy()))
158 }
159}
160
161pub fn read_cube_meta(path: &Path) -> Result<CubeMeta, CubeError> {
165 let path_str = path.to_string_lossy().into_owned();
166 let mut fptr = FitsFile::open(&path_str)?;
167 let hdu = fptr.primary_hdu()?;
168
169 let naxis: i64 = hdu.read_key(&mut fptr, "NAXIS")?;
170 if naxis != 3 && naxis != 4 {
171 return Err(CubeError::UnsupportedNaxis(naxis));
172 }
173
174 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")?;
177
178 let ctype3 = hdu
184 .read_key::<String>(&mut fptr, "CTYPE3")
185 .map(|s| s.trim().to_ascii_uppercase())
186 .unwrap_or_default();
187 let (nstokes, nfreq, freq_axis, is_4d) = if naxis == 4 {
188 let naxis4: i64 = hdu.read_key(&mut fptr, "NAXIS4")?;
189 if ctype3 == "STOKES" {
190 (naxis3 as usize, naxis4 as usize, 4, true)
191 } else {
192 (naxis4 as usize, naxis3 as usize, 3, true)
193 }
194 } else {
195 (1, naxis3 as usize, 3, false)
196 };
197
198 let nx = naxis1 as usize;
199 let ny = naxis2 as usize;
200
201 let cdelt1: f64 = hdu.read_key(&mut fptr, "CDELT1")?;
202 let cdelt2: f64 = hdu.read_key(&mut fptr, "CDELT2")?;
203 let dx_deg = cdelt1.abs();
204 let dy_deg = cdelt2.abs();
205
206 let crpix_freq: i64 = hdu
209 .read_key(&mut fptr, &format!("CRPIX{freq_axis}"))
210 .unwrap_or(1);
211
212 let bitpix: i64 = hdu.read_key(&mut fptr, "BITPIX").unwrap_or(-32);
215 let dtype = PixelType::from_bitpix(bitpix);
216 if bitpix > 0 {
217 tracing::warn!(
221 "{}: integer BITPIX={}; convolution runs in f32 but the output is \
222 written at integer precision (fractional flux is rounded). Convert \
223 to a floating-point cube (BITPIX=-32) to avoid this.",
224 path.display(),
225 bitpix
226 );
227 }
228
229 let unit = match hdu.read_key::<String>(&mut fptr, "BUNIT") {
231 Ok(s) => BrightnessUnit::from_bunit(&s),
232 Err(_) => {
233 tracing::warn!(
234 "No BUNIT keyword in {}; assuming Jy/beam (flux scaling applied).",
235 path.display()
236 );
237 BrightnessUnit::default()
238 }
239 };
240
241 let casambm = hdu
244 .read_key::<bool>(&mut fptr, "CASAMBM")
245 .ok()
246 .or_else(|| {
247 hdu.read_key::<String>(&mut fptr, "CASAMBM")
248 .ok()
249 .map(|s| matches!(s.trim(), "T" | "TRUE"))
250 })
251 .unwrap_or(false);
252 drop(fptr); let beams: Vec<Option<Beam>> = if casambm {
255 read_casambm_beams(path, nfreq)?
256 } else {
257 let beamlog = CubeMeta {
258 path: path.to_path_buf(),
259 nx,
260 ny,
261 nfreq,
262 nstokes,
263 dx_deg,
264 dy_deg,
265 crpix_freq,
266 beams: vec![],
267 is_4d,
268 unit,
269 dtype,
270 }
271 .beamlog_path();
272
273 if beamlog.exists() {
274 let parsed = read_beamlog(&beamlog)?;
275 if parsed.len() != nfreq {
276 return Err(CubeError::BeamCountMismatch {
277 expected: nfreq,
278 got: parsed.len(),
279 });
280 }
281 parsed.into_iter().map(Some).collect()
282 } else {
283 let mut fptr2 = FitsFile::open(path.to_string_lossy().into_owned())?;
285 let hdu2 = fptr2.primary_hdu()?;
286 let bmaj: f64 = hdu2
287 .read_key(&mut fptr2, "BMAJ")
288 .map_err(|_| CubeError::NoBeans)?;
289 let bmin: f64 = hdu2.read_key(&mut fptr2, "BMIN").unwrap_or(bmaj);
290 let bpa: f64 = hdu2.read_key(&mut fptr2, "BPA").unwrap_or(0.0);
291 let b = Beam::new(bmaj, bmin, bpa)?;
292 vec![Some(b); nfreq]
293 }
294 };
295
296 Ok(CubeMeta {
297 path: path.to_path_buf(),
298 nx,
299 ny,
300 nfreq,
301 nstokes,
302 dx_deg,
303 dy_deg,
304 crpix_freq,
305 beams,
306 is_4d,
307 unit,
308 dtype,
309 })
310}
311
312fn read_casambm_beams(path: &Path, nfreq: usize) -> Result<Vec<Option<Beam>>, CubeError> {
316 let path_str = path.to_string_lossy().into_owned();
317 let mut fptr = FitsFile::open(&path_str)?;
318 let hdu = fptr
319 .hdu("BEAMS")
320 .map_err(|_| CubeError::MissingKeyword("BEAMS extension".into()))?;
321
322 let bmaj: Vec<f32> = hdu.read_col(&mut fptr, "BMAJ")?;
323 let bmin: Vec<f32> = hdu.read_col(&mut fptr, "BMIN")?;
324 let bpa: Vec<f32> = hdu.read_col(&mut fptr, "BPA")?;
325
326 if bmaj.len() != nfreq {
327 return Err(CubeError::BeamCountMismatch {
328 expected: nfreq,
329 got: bmaj.len(),
330 });
331 }
332
333 let tiny = f32::MIN_POSITIVE as f64;
334 let beams = bmaj
335 .iter()
336 .zip(bmin.iter())
337 .zip(bpa.iter())
338 .map(|((&maj_as, &min_as), &pa_deg)| {
339 let maj_deg = maj_as as f64 / 3600.0;
340 let min_deg = min_as as f64 / 3600.0;
341 let pa = pa_deg as f64;
342 if maj_deg <= tiny || !maj_deg.is_finite() {
347 None
348 } else {
349 Beam::new(maj_deg, min_deg.max(tiny), pa).ok()
350 }
351 })
352 .collect();
353 Ok(beams)
354}
355
356pub fn read_channel_as<T: CubeElem>(
365 path: &Path,
366 chan: usize,
367 meta: &CubeMeta,
368) -> Result<Array2<T>, CubeError> {
369 let path_str = path.to_string_lossy().into_owned();
370 let mut fptr = FitsFile::open(&path_str)?;
371
372 let (start, end) = meta.channel_range(chan);
373 let data = T::read_section_vec(&mut fptr, start, end)?;
374 Ok(Array2::from_shape_vec((meta.ny, meta.nx), data)?)
375}
376
377pub fn read_channel(path: &Path, chan: usize, meta: &CubeMeta) -> Result<Array2<f32>, CubeError> {
379 read_channel_as::<f32>(path, chan, meta)
380}
381
382pub fn write_channel_as<T: CubeElem>(
387 path: &Path,
388 chan: usize,
389 data: &Array2<T>,
390 meta: &CubeMeta,
391) -> Result<(), CubeError> {
392 let path_str = path.to_string_lossy().into_owned();
393 let mut fptr = FitsFile::edit(&path_str)?;
394
395 let (start, end) = meta.channel_range(chan);
396 let flat = data.as_standard_layout();
397 let slice = flat.as_slice().expect("standard-layout plane");
398 T::write_section_vec(&mut fptr, start, end, slice)?;
399 Ok(())
400}
401
402pub fn write_channel(
404 path: &Path,
405 chan: usize,
406 data: &Array2<f32>,
407 meta: &CubeMeta,
408) -> Result<(), CubeError> {
409 write_channel_as::<f32>(path, chan, data, meta)
410}
411
412pub struct CubeWriter {
420 fptr: FitsFile,
421 pending_beams: Option<PendingBeams>,
429}
430
431struct PendingBeams {
433 beams: Vec<Option<Beam>>,
434 nfreq: usize,
435}
436
437impl CubeWriter {
438 pub fn create(
451 input_path: &Path,
452 output_path: &Path,
453 target_beams: &[Option<Beam>],
454 mode: CubeMode,
455 meta: &CubeMeta,
456 ) -> Result<Self, CubeError> {
457 let mut fptr = atfits_rs::copy_header_only_open(input_path, output_path)?;
461
462 let ref_beam = ref_beam_for(target_beams, meta);
463 write_primary_beam_keys(&mut fptr, ref_beam, mode == CubeMode::Natural)?;
464
465 fptr.primary_hdu()?;
468
469 let pending_beams = (mode == CubeMode::Natural).then(|| PendingBeams {
470 beams: target_beams.to_vec(),
471 nfreq: meta.nfreq,
472 });
473 Ok(Self {
474 fptr,
475 pending_beams,
476 })
477 }
478
479 pub fn open(path: &Path) -> Result<Self, CubeError> {
482 let fptr = FitsFile::edit(path.to_string_lossy().into_owned())?;
483 Ok(Self {
484 fptr,
485 pending_beams: None,
486 })
487 }
488
489 pub fn write_channel_as<T: CubeElem>(
491 &mut self,
492 chan: usize,
493 data: &Array2<T>,
494 meta: &CubeMeta,
495 ) -> Result<(), CubeError> {
496 let (start, end) = meta.channel_range(chan);
497 let flat = data.as_standard_layout();
498 let slice = flat.as_slice().expect("standard-layout plane");
499 T::write_section_vec(&mut self.fptr, start, end, slice)?;
500 Ok(())
501 }
502
503 pub fn write_channel(
505 &mut self,
506 chan: usize,
507 data: &Array2<f32>,
508 meta: &CubeMeta,
509 ) -> Result<(), CubeError> {
510 self.write_channel_as::<f32>(chan, data, meta)
511 }
512
513 pub fn finish(mut self) -> Result<(), CubeError> {
520 if let Some(p) = self.pending_beams.take() {
521 write_beams_table(&mut self.fptr, &p.beams, p.nfreq)?;
522 }
523 Ok(())
525 }
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
532pub enum CubeMode {
533 Natural,
535 Total,
537}
538
539fn ref_beam_for(target_beams: &[Option<Beam>], meta: &CubeMeta) -> Beam {
550 let last = meta.nfreq.saturating_sub(1) as i64;
551 let ref_idx = (meta.crpix_freq - 1).clamp(0, last) as usize;
552 target_beams[ref_idx].unwrap_or_else(|| {
553 target_beams.iter().find_map(|b| *b).unwrap_or(Beam::zero())
555 })
556}
557
558fn write_primary_beam_keys(
566 fptr: &mut FitsFile,
567 ref_beam: Beam,
568 natural: bool,
569) -> Result<(), CubeError> {
570 fptr.primary_hdu()?; update_key_f64(fptr, "BMAJ", ref_beam.major_deg)?;
572 update_key_f64(fptr, "BMIN", ref_beam.minor_deg)?;
573 update_key_f64(fptr, "BPA", ref_beam.pa_deg)?;
574 update_key_logical(fptr, "CASAMBM", natural)?;
575 Ok(())
576}
577
578fn write_beams_table(
584 fptr: &mut FitsFile,
585 target_beams: &[Option<Beam>],
586 nfreq: usize,
587) -> Result<(), CubeError> {
588 let tiny = f32::MIN_POSITIVE as f64;
589
590 let bmaj: Vec<f32> = target_beams
592 .iter()
593 .map(|b| b.map_or(tiny as f32, |b| b.major_arcsec() as f32))
594 .collect();
595 let bmin: Vec<f32> = target_beams
596 .iter()
597 .map(|b| b.map_or(tiny as f32, |b| b.minor_arcsec() as f32))
598 .collect();
599 let bpa: Vec<f32> = target_beams
600 .iter()
601 .map(|b| b.map_or(tiny as f32, |b| b.pa_deg as f32))
602 .collect();
603 let chan: Vec<i32> = (0..nfreq as i32).collect();
604 let pol: Vec<i32> = vec![0i32; nfreq];
605
606 let col_bmaj = ColumnDescription::new("BMAJ")
607 .with_type(ColumnDataType::Float)
608 .create()?;
609 let col_bmin = ColumnDescription::new("BMIN")
610 .with_type(ColumnDataType::Float)
611 .create()?;
612 let col_bpa = ColumnDescription::new("BPA")
613 .with_type(ColumnDataType::Float)
614 .create()?;
615 let col_chan = ColumnDescription::new("CHAN")
616 .with_type(ColumnDataType::Int)
617 .create()?;
618 let col_pol = ColumnDescription::new("POL")
619 .with_type(ColumnDataType::Int)
620 .create()?;
621
622 let table_hdu =
623 fptr.create_table("BEAMS", &[col_bmaj, col_bmin, col_bpa, col_chan, col_pol])?;
624 table_hdu.write_col(fptr, "BMAJ", &bmaj)?;
625 table_hdu.write_col(fptr, "BMIN", &bmin)?;
626 table_hdu.write_col(fptr, "BPA", &bpa)?;
627 table_hdu.write_col(fptr, "CHAN", &chan)?;
628 table_hdu.write_col(fptr, "POL", &pol)?;
629
630 let beam_hdu = fptr.hdu("BEAMS")?;
635 beam_hdu.write_key(fptr, "TUNIT1", "arcsec")?;
636 beam_hdu.write_key(fptr, "TUNIT2", "arcsec")?;
637 beam_hdu.write_key(fptr, "TUNIT3", "deg")?;
638 beam_hdu.write_key(fptr, "NCHAN", nfreq as i64)?;
639 beam_hdu.write_key(fptr, "NPOL", 1i64)?;
640 Ok(())
641}
642
643pub fn init_output_cube(
654 input_path: &Path,
655 output_path: &Path,
656 target_beams: &[Option<Beam>],
657 mode: CubeMode,
658 meta: &CubeMeta,
659) -> Result<(), CubeError> {
660 copy_header_only(input_path, output_path)?;
666
667 let ref_beam = ref_beam_for(target_beams, meta);
668
669 {
670 let path_str = output_path.to_string_lossy().into_owned();
671 let mut fptr = FitsFile::edit(&path_str)?;
672 write_primary_beam_keys(&mut fptr, ref_beam, mode == CubeMode::Natural)?;
673
674 if mode == CubeMode::Natural {
675 write_beams_table(&mut fptr, target_beams, meta.nfreq)?;
676 }
677 }
678
679 Ok(())
680}
681
682pub fn read_beamlog(path: &Path) -> Result<Vec<Beam>, CubeError> {
695 let content = std::fs::read_to_string(path)?;
696 let mut beams = Vec::new();
697 let tiny = f64::from(f32::MIN_POSITIVE);
698
699 for (i, line) in content.lines().enumerate() {
700 let trimmed = line.trim();
701 if trimmed.is_empty() || trimmed.starts_with('#') {
702 continue;
703 }
704 let fields: Vec<&str> = trimmed.split_whitespace().collect();
705 if fields.len() < 4 {
706 return Err(CubeError::BeamlogParse {
707 line: i + 1,
708 msg: format!("expected 4 fields, got {}", fields.len()),
709 });
710 }
711 let parse = |s: &str, n: &str| -> Result<f64, CubeError> {
712 s.parse::<f64>().map_err(|_| CubeError::BeamlogParse {
713 line: i + 1,
714 msg: format!("cannot parse {n}={s:?} as float"),
715 })
716 };
717 let bmaj_as = parse(fields[1], "BMAJ")?;
719 let bmin_as = parse(fields[2], "BMIN")?;
720 let bpa_deg = parse(fields[3], "BPA")?;
721
722 let beam = if bmaj_as <= tiny || !bmaj_as.is_finite() {
723 Beam::zero()
724 } else {
725 Beam::from_arcsec(bmaj_as, bmin_as.max(tiny), bpa_deg)?
726 };
727 beams.push(beam);
728 }
729 Ok(beams)
730}
731
732pub fn write_beamlog(path: &Path, beams: &[Option<Beam>]) -> Result<(), CubeError> {
734 use std::fmt::Write as _;
735 let mut out = String::new();
736 writeln!(out, "# Channel BMAJ[arcsec] BMIN[arcsec] BPA[deg]").unwrap();
737 for (i, b) in beams.iter().enumerate() {
738 match b {
739 Some(b) => writeln!(
740 out,
741 "{} {} {} {}",
742 i,
743 b.major_arcsec(),
744 b.minor_arcsec(),
745 b.pa_deg
746 ),
747 None => writeln!(out, "{i} nan nan nan"),
748 }
749 .unwrap();
750 }
751 std::fs::write(path, out)?;
752 Ok(())
753}