1use std::path::{Path, PathBuf};
9
10use atfits_rs::{key_bool, pixel_scale_deg, 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::fits_io::{FitsError, create_output, read_header_beam, read_unit};
21use crate::smooth::BrightnessUnit;
22
23pub use atfits_rs::PixelType;
24
25pub trait CubeElem: atfits_rs::CubeElem + FftFloat {}
27
28impl<T: atfits_rs::CubeElem + FftFloat> CubeElem for T {}
29
30#[derive(Debug, Error)]
31pub enum CubeError {
32 #[error("FITS I/O error: {0}")]
33 Fits(#[from] fitsio::errors::Error),
34 #[error(transparent)]
35 Header(#[from] FitsError),
36 #[error("I/O error: {0}")]
37 Io(#[from] std::io::Error),
38 #[error("shape error: {0}")]
39 Shape(#[from] ndarray::ShapeError),
40 #[error("invalid beam: {0}")]
41 Beam(#[from] BeamError),
42 #[error("unsupported NAXIS={0} (expected 3 or 4)")]
43 UnsupportedNaxis(i64),
44 #[error("missing header keyword: {0}")]
45 MissingKeyword(String),
46 #[error("channel count mismatch in beam table: expected {expected}, got {got}")]
47 BeamCountMismatch { expected: usize, got: usize },
48 #[error("beamlog parse error at line {line}: {msg}")]
49 BeamlogParse { line: usize, msg: String },
50 #[error("no per-channel beam source found (no CASAMBM, no beamlog, no header beam)")]
51 NoBeams,
52}
53
54impl From<atfits_rs::AtfitsError> for CubeError {
55 fn from(e: atfits_rs::AtfitsError) -> Self {
56 CubeError::Header(e.into())
57 }
58}
59
60#[derive(Debug)]
62pub struct CubeMeta {
63 pub path: PathBuf,
64 pub nx: usize,
66 pub ny: usize,
68 pub nfreq: usize,
69 pub nstokes: usize,
70 pub dx_deg: f64,
72 pub dy_deg: f64,
74 pub crpix_freq: f64,
78 pub beams: Vec<Option<Beam>>,
80 pub is_4d: bool,
81 pub unit: BrightnessUnit,
82 pub dtype: PixelType,
83}
84
85impl CubeMeta {
86 pub fn channel_range(&self, chan: usize) -> (usize, usize) {
89 let plane = self.ny * self.nx;
90 (chan * plane, (chan + 1) * plane)
91 }
92}
93
94pub fn beamlog_path(fits: &Path) -> PathBuf {
96 let dir = fits.parent().unwrap_or(Path::new("."));
97 let stem = fits.file_stem().unwrap_or_default();
98 dir.join(format!("beamlog.{}.txt", stem.to_string_lossy()))
99}
100
101pub fn read_cube_meta(path: &Path) -> Result<CubeMeta, CubeError> {
103 let mut fptr = FitsFile::open(path)?;
104 let hdu = fptr.primary_hdu()?;
105
106 let naxis: i64 = hdu.read_key(&mut fptr, "NAXIS")?;
107 if naxis != 3 && naxis != 4 {
108 return Err(CubeError::UnsupportedNaxis(naxis));
109 }
110 let mut dims = Vec::with_capacity(naxis as usize);
111 for i in 1..=naxis {
112 dims.push(hdu.read_key::<i64>(&mut fptr, &format!("NAXIS{i}"))? as usize);
113 }
114
115 let ctype3 = hdu
117 .read_key::<String>(&mut fptr, "CTYPE3")
118 .map(|s| s.trim().to_ascii_uppercase())
119 .unwrap_or_default();
120 let (nstokes, nfreq, freq_axis) = match dims[..] {
121 [_, _, n3, n4] if ctype3 == "STOKES" => (n3, n4, 4),
122 [_, _, n3, n4] => (n4, n3, 3),
123 _ => (1, dims[2], 3),
124 };
125
126 let (dx_deg, dy_deg) = pixel_scale_deg(&mut fptr)?;
127 let crpix_freq = atfits_rs::key_f64(&mut fptr, &format!("CRPIX{freq_axis}"))?
128 .unwrap_or(1.0)
129 .floor();
130 let bitpix: i64 = hdu.read_key(&mut fptr, "BITPIX")?;
131 let unit = read_unit(&mut fptr, &hdu, path);
132
133 let casambm = key_bool(&mut fptr, "CASAMBM")?.unwrap_or(false);
135
136 let beamlog = beamlog_path(path);
137 let beams = if casambm {
138 read_casambm_beams(&mut fptr, nfreq, path)?
139 } else if beamlog.exists() {
140 let parsed = read_beamlog(&beamlog)?;
141 if parsed.len() != nfreq {
142 return Err(CubeError::BeamCountMismatch {
143 expected: nfreq,
144 got: parsed.len(),
145 });
146 }
147 parsed.into_iter().map(Some).collect()
148 } else {
149 let beam = read_header_beam(&mut fptr)?.ok_or(CubeError::NoBeams)?;
150 vec![Some(beam); nfreq]
151 };
152
153 Ok(CubeMeta {
154 path: path.to_path_buf(),
155 nx: dims[0],
156 ny: dims[1],
157 nfreq,
158 nstokes,
159 dx_deg,
160 dy_deg,
161 crpix_freq,
162 beams,
163 is_4d: naxis == 4,
164 unit,
165 dtype: PixelType::from_bitpix(bitpix),
166 })
167}
168
169fn read_casambm_beams(
172 fptr: &mut FitsFile,
173 nfreq: usize,
174 path: &Path,
175) -> Result<Vec<Option<Beam>>, CubeError> {
176 let hdu = fptr
177 .hdu("BEAMS")
178 .map_err(|_| CubeError::MissingKeyword("BEAMS extension".into()))?;
179 let bmaj: Vec<f32> = hdu.read_col(fptr, "BMAJ")?;
180 let bmin: Vec<f32> = hdu.read_col(fptr, "BMIN")?;
181 let bpa: Vec<f32> = hdu.read_col(fptr, "BPA")?;
182 if bmaj.len() != nfreq || bmin.len() != nfreq || bpa.len() != nfreq {
183 return Err(CubeError::BeamCountMismatch {
184 expected: nfreq,
185 got: bmaj.len(),
186 });
187 }
188 Ok((0..nfreq)
189 .map(|c| {
190 beam_from_row(
191 f64::from(bmaj[c]),
192 f64::from(bmin[c]),
193 f64::from(bpa[c]),
194 c,
195 path,
196 )
197 })
198 .collect())
199}
200
201fn beam_from_row(maj: f64, min: f64, pa: f64, chan: usize, path: &Path) -> Option<Beam> {
207 let tiny = f64::from(f32::MIN_POSITIVE);
208 if !(maj.is_finite() && min.is_finite() && pa.is_finite()) || maj <= tiny || min <= tiny {
209 return None;
210 }
211 if min > maj {
212 tracing::warn!(
213 "{}: channel {chan} has BMIN > BMAJ ({min}\" > {maj}\"); swapping the axes",
214 path.display()
215 );
216 let pa = if pa > 0.0 { pa - 90.0 } else { pa + 90.0 };
217 return Beam::from_arcsec(min, maj, pa).ok();
218 }
219 Beam::from_arcsec(maj, min, pa).ok()
220}
221
222pub struct CubeReader {
225 fptr: FitsFile,
226}
227
228impl CubeReader {
229 pub fn open(path: &Path) -> Result<Self, CubeError> {
230 Ok(Self {
231 fptr: FitsFile::open(path)?,
232 })
233 }
234
235 pub fn read_channel<T: CubeElem>(
237 &mut self,
238 chan: usize,
239 meta: &CubeMeta,
240 ) -> Result<Array2<T>, CubeError> {
241 let (start, end) = meta.channel_range(chan);
242 let data = T::read_section(&mut self.fptr, start, end)?;
243 Ok(Array2::from_shape_vec((meta.ny, meta.nx), data)?)
244 }
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum CubeMode {
250 Natural,
252 Total,
254}
255
256pub struct CubeWriter {
259 fptr: FitsFile,
260 pending_beams: Option<Vec<Option<Beam>>>,
263}
264
265impl CubeWriter {
266 pub fn create(
269 input_path: &Path,
270 output_path: &Path,
271 target_beams: &[Option<Beam>],
272 mode: CubeMode,
273 meta: &CubeMeta,
274 ) -> Result<Self, CubeError> {
275 let mut fptr = create_output(input_path, output_path, None)?;
276 write_primary_beam_keys(
277 &mut fptr,
278 ref_beam_for(target_beams, meta),
279 mode == CubeMode::Natural,
280 )?;
281 Ok(Self {
282 fptr,
283 pending_beams: (mode == CubeMode::Natural).then(|| target_beams.to_vec()),
284 })
285 }
286
287 pub fn write_channel<T: CubeElem>(
288 &mut self,
289 chan: usize,
290 data: &Array2<T>,
291 meta: &CubeMeta,
292 ) -> Result<(), CubeError> {
293 let (start, end) = meta.channel_range(chan);
294 let flat = data.as_standard_layout();
295 let slice = flat
296 .as_slice()
297 .ok_or_else(|| std::io::Error::other("plane is not contiguous"))?;
298 T::write_section(&mut self.fptr, start, end, slice)?;
299 Ok(())
300 }
301
302 pub fn finish(mut self) -> Result<(), CubeError> {
305 if let Some(beams) = self.pending_beams.take() {
306 write_beams_table(&mut self.fptr, &beams)?;
307 }
308 Ok(())
309 }
310}
311
312fn ref_beam_for(target_beams: &[Option<Beam>], meta: &CubeMeta) -> Beam {
315 let last = meta.nfreq.saturating_sub(1) as f64;
316 let ref_idx = (meta.crpix_freq - 1.0).clamp(0.0, last) as usize;
317 target_beams[ref_idx]
318 .or_else(|| target_beams.iter().find_map(|b| *b))
319 .unwrap_or(Beam::zero())
320}
321
322fn write_primary_beam_keys(
325 fptr: &mut FitsFile,
326 ref_beam: Beam,
327 natural: bool,
328) -> Result<(), CubeError> {
329 fptr.primary_hdu()?;
330 update_key_f64(fptr, "BMAJ", ref_beam.major_deg())?;
331 update_key_f64(fptr, "BMIN", ref_beam.minor_deg())?;
332 update_key_f64(fptr, "BPA", ref_beam.pa_deg())?;
333 update_key_logical(fptr, "CASAMBM", natural)?;
334 Ok(())
335}
336
337fn write_beams_table(fptr: &mut FitsFile, target_beams: &[Option<Beam>]) -> Result<(), CubeError> {
340 let nfreq = target_beams.len();
341 let tiny = f32::MIN_POSITIVE;
342 let column = |f: fn(&Beam) -> f64| -> Vec<f32> {
343 target_beams
344 .iter()
345 .map(|b| b.map_or(tiny, |b| f(&b) as f32))
346 .collect()
347 };
348 let bmaj = column(Beam::major_arcsec);
349 let bmin = column(Beam::minor_arcsec);
350 let bpa = column(Beam::pa_deg);
351 let chan: Vec<i32> = (0..nfreq as i32).collect();
352 let pol = vec![0i32; nfreq];
353
354 let col = |name: &str, ty: ColumnDataType| ColumnDescription::new(name).with_type(ty).create();
355 let cols = [
356 col("BMAJ", ColumnDataType::Float)?,
357 col("BMIN", ColumnDataType::Float)?,
358 col("BPA", ColumnDataType::Float)?,
359 col("CHAN", ColumnDataType::Int)?,
360 col("POL", ColumnDataType::Int)?,
361 ];
362 let table = fptr.create_table("BEAMS", &cols)?;
363 table.write_col(fptr, "BMAJ", &bmaj)?;
364 table.write_col(fptr, "BMIN", &bmin)?;
365 table.write_col(fptr, "BPA", &bpa)?;
366 table.write_col(fptr, "CHAN", &chan)?;
367 table.write_col(fptr, "POL", &pol)?;
368
369 let table = fptr.hdu("BEAMS")?;
371 table.write_key(fptr, "TUNIT1", "arcsec")?;
372 table.write_key(fptr, "TUNIT2", "arcsec")?;
373 table.write_key(fptr, "TUNIT3", "deg")?;
374 table.write_key(fptr, "NCHAN", nfreq as i64)?;
375 table.write_key(fptr, "NPOL", 1i64)?;
376 Ok(())
377}
378
379pub fn read_beamlog(path: &Path) -> Result<Vec<Beam>, CubeError> {
388 let content = std::fs::read_to_string(path)?;
389 let mut beams = Vec::new();
390 for (i, line) in content.lines().enumerate() {
391 let trimmed = line.trim();
392 if trimmed.is_empty() || trimmed.starts_with('#') {
393 continue;
394 }
395 let fields: Vec<&str> = trimmed.split_whitespace().collect();
396 if fields.len() < 4 {
397 return Err(CubeError::BeamlogParse {
398 line: i + 1,
399 msg: format!("expected 4 fields, got {}", fields.len()),
400 });
401 }
402 let parse = |s: &str, n: &str| -> Result<f64, CubeError> {
403 s.parse::<f64>().map_err(|_| CubeError::BeamlogParse {
404 line: i + 1,
405 msg: format!("cannot parse {n}={s:?} as float"),
406 })
407 };
408 let (bmaj, bmin, bpa) = (
409 parse(fields[1], "BMAJ")?,
410 parse(fields[2], "BMIN")?,
411 parse(fields[3], "BPA")?,
412 );
413 let chan = beams.len();
414 beams.push(beam_from_row(bmaj, bmin, bpa, chan, path).unwrap_or(Beam::zero()));
415 }
416 Ok(beams)
417}
418
419pub fn write_beamlog(path: &Path, beams: &[Option<Beam>]) -> Result<(), CubeError> {
421 let mut out = String::from("# Channel BMAJ[arcsec] BMIN[arcsec] BPA[deg]\n");
422 for (i, b) in beams.iter().enumerate() {
423 out += &match b {
424 Some(b) => format!(
425 "{i} {} {} {}\n",
426 b.major_arcsec(),
427 b.minor_arcsec(),
428 b.pa_deg()
429 ),
430 None => format!("{i} nan nan nan\n"),
431 };
432 }
433 std::fs::write(path, out)?;
434 Ok(())
435}