1use std::path::{Path, PathBuf};
9
10use atfits_rs::{update_key_f64, update_key_logical};
11use fitsio::{
12 FitsFile,
13 hdu::FitsHdu,
14 tables::{ColumnDataType, ColumnDescription},
15};
16use ndarray::Array2;
17use thiserror::Error;
18
19use crate::beam::{Beam, BeamError};
20use crate::convolve_uv::FftFloat;
21use crate::fits_io::{
22 FitsError, create_output, pixel_scales, read_header_beam, read_pixels, read_unit,
23};
24use crate::smooth::BrightnessUnit;
25
26pub use atfits_rs::PixelType;
27
28pub trait CubeElem: atfits_rs::CubeElem + FftFloat {
30 fn read_float(
32 fptr: &mut FitsFile,
33 hdu: &FitsHdu,
34 start: usize,
35 end: usize,
36 ) -> Result<Vec<Self>, fitsio::errors::Error>;
37}
38
39impl CubeElem for f32 {
40 fn read_float(
41 fptr: &mut FitsFile,
42 hdu: &FitsHdu,
43 start: usize,
44 end: usize,
45 ) -> Result<Vec<Self>, fitsio::errors::Error> {
46 hdu.read_section(fptr, start, end)
47 }
48}
49
50impl CubeElem for f64 {
51 fn read_float(
52 fptr: &mut FitsFile,
53 hdu: &FitsHdu,
54 start: usize,
55 end: usize,
56 ) -> Result<Vec<Self>, fitsio::errors::Error> {
57 hdu.read_section(fptr, start, end)
58 }
59}
60
61#[derive(Debug, Error)]
62pub enum CubeError {
63 #[error("FITS I/O error: {0}")]
64 Fits(#[from] fitsio::errors::Error),
65 #[error(transparent)]
66 Header(#[from] FitsError),
67 #[error("I/O error: {0}")]
68 Io(#[from] std::io::Error),
69 #[error("shape error: {0}")]
70 Shape(#[from] ndarray::ShapeError),
71 #[error("invalid beam: {0}")]
72 Beam(#[from] BeamError),
73 #[error("unsupported NAXIS={0} (expected 3 or 4)")]
74 UnsupportedNaxis(i64),
75 #[error("missing header keyword: {0}")]
76 MissingKeyword(String),
77 #[error("channel count mismatch in beam table: expected {expected}, got {got}")]
78 BeamCountMismatch { expected: usize, got: usize },
79 #[error("beamlog parse error at line {line}: {msg}")]
80 BeamlogParse { line: usize, msg: String },
81 #[error("no per-channel beam source found (no CASAMBM, no beamlog, no header beam)")]
82 NoBeams,
83}
84
85impl From<atfits_rs::AtfitsError> for CubeError {
86 fn from(e: atfits_rs::AtfitsError) -> Self {
87 CubeError::Header(e.into())
88 }
89}
90
91#[derive(Debug)]
93pub struct CubeMeta {
94 pub path: PathBuf,
95 pub nx: usize,
97 pub ny: usize,
99 pub nfreq: usize,
100 pub nstokes: usize,
101 pub dx_deg: f64,
103 pub dy_deg: f64,
105 pub crpix_freq: f64,
109 pub beams: Vec<Option<Beam>>,
111 pub is_4d: bool,
112 pub unit: BrightnessUnit,
113 pub dtype: PixelType,
114}
115
116impl CubeMeta {
117 pub fn channel_range(&self, chan: usize) -> (usize, usize) {
120 let plane = self.ny * self.nx;
121 (chan * plane, (chan + 1) * plane)
122 }
123}
124
125pub fn beamlog_path(fits: &Path) -> PathBuf {
127 let dir = fits.parent().unwrap_or(Path::new("."));
128 let stem = fits.file_stem().unwrap_or_default();
129 dir.join(format!("beamlog.{}.txt", stem.to_string_lossy()))
130}
131
132pub fn read_cube_meta(path: &Path) -> Result<CubeMeta, CubeError> {
134 let mut fptr = FitsFile::open(path)?;
135 let hdu = fptr.primary_hdu()?;
136
137 let naxis: i64 = hdu.read_key(&mut fptr, "NAXIS")?;
138 if naxis != 3 && naxis != 4 {
139 return Err(CubeError::UnsupportedNaxis(naxis));
140 }
141 let mut dims = Vec::with_capacity(naxis as usize);
142 for i in 1..=naxis {
143 dims.push(hdu.read_key::<i64>(&mut fptr, &format!("NAXIS{i}"))? as usize);
144 }
145
146 let ctype3 = hdu
148 .read_key::<String>(&mut fptr, "CTYPE3")
149 .map(|s| s.trim().to_ascii_uppercase())
150 .unwrap_or_default();
151 let (nstokes, nfreq, freq_axis) = match dims[..] {
152 [_, _, n3, n4] if ctype3 == "STOKES" => (n3, n4, 4),
153 [_, _, n3, n4] => (n4, n3, 3),
154 _ => (1, dims[2], 3),
155 };
156
157 let (dx_deg, dy_deg) = pixel_scales(&mut fptr, &hdu)?;
158 let crpix_freq = atfits_rs::key_f64(&mut fptr, &format!("CRPIX{freq_axis}"))?
159 .unwrap_or(1.0)
160 .floor();
161 let bitpix: i64 = hdu.read_key(&mut fptr, "BITPIX")?;
162 let unit = read_unit(&mut fptr, &hdu, path);
163
164 let casambm = hdu
166 .read_key::<bool>(&mut fptr, "CASAMBM")
167 .ok()
168 .or_else(|| {
169 hdu.read_key::<String>(&mut fptr, "CASAMBM")
170 .ok()
171 .map(|s| matches!(s.trim(), "T" | "TRUE"))
172 })
173 .unwrap_or(false);
174
175 let beamlog = beamlog_path(path);
176 let beams = if casambm {
177 read_casambm_beams(&mut fptr, nfreq, path)?
178 } else if beamlog.exists() {
179 let parsed = read_beamlog(&beamlog)?;
180 if parsed.len() != nfreq {
181 return Err(CubeError::BeamCountMismatch {
182 expected: nfreq,
183 got: parsed.len(),
184 });
185 }
186 parsed.into_iter().map(Some).collect()
187 } else {
188 let beam = read_header_beam(&mut fptr)?.ok_or(CubeError::NoBeams)?;
189 vec![Some(beam); nfreq]
190 };
191
192 Ok(CubeMeta {
193 path: path.to_path_buf(),
194 nx: dims[0],
195 ny: dims[1],
196 nfreq,
197 nstokes,
198 dx_deg,
199 dy_deg,
200 crpix_freq,
201 beams,
202 is_4d: naxis == 4,
203 unit,
204 dtype: PixelType::from_bitpix(bitpix),
205 })
206}
207
208fn read_casambm_beams(
211 fptr: &mut FitsFile,
212 nfreq: usize,
213 path: &Path,
214) -> Result<Vec<Option<Beam>>, CubeError> {
215 let hdu = fptr
216 .hdu("BEAMS")
217 .map_err(|_| CubeError::MissingKeyword("BEAMS extension".into()))?;
218 let bmaj: Vec<f32> = hdu.read_col(fptr, "BMAJ")?;
219 let bmin: Vec<f32> = hdu.read_col(fptr, "BMIN")?;
220 let bpa: Vec<f32> = hdu.read_col(fptr, "BPA")?;
221 if bmaj.len() != nfreq || bmin.len() != nfreq || bpa.len() != nfreq {
222 return Err(CubeError::BeamCountMismatch {
223 expected: nfreq,
224 got: bmaj.len(),
225 });
226 }
227 Ok((0..nfreq)
228 .map(|c| {
229 beam_from_row(
230 f64::from(bmaj[c]),
231 f64::from(bmin[c]),
232 f64::from(bpa[c]),
233 c,
234 path,
235 )
236 })
237 .collect())
238}
239
240fn beam_from_row(maj: f64, min: f64, pa: f64, chan: usize, path: &Path) -> Option<Beam> {
246 let tiny = f64::from(f32::MIN_POSITIVE);
247 if !(maj.is_finite() && min.is_finite() && pa.is_finite()) || maj <= tiny || min <= tiny {
248 return None;
249 }
250 if min > maj {
251 tracing::warn!(
252 "{}: channel {chan} has BMIN > BMAJ ({min}\" > {maj}\"); swapping the axes",
253 path.display()
254 );
255 let pa = if pa > 0.0 { pa - 90.0 } else { pa + 90.0 };
256 return Beam::from_arcsec(min, maj, pa).ok();
257 }
258 Beam::from_arcsec(maj, min, pa).ok()
259}
260
261pub struct CubeReader {
264 fptr: FitsFile,
265}
266
267impl CubeReader {
268 pub fn open(path: &Path) -> Result<Self, CubeError> {
269 Ok(Self {
270 fptr: FitsFile::open(path)?,
271 })
272 }
273
274 pub fn read_channel<T: CubeElem>(
276 &mut self,
277 chan: usize,
278 meta: &CubeMeta,
279 ) -> Result<Array2<T>, CubeError> {
280 let (start, end) = meta.channel_range(chan);
281 let data = read_pixels(&mut self.fptr, start, end)?;
282 Ok(Array2::from_shape_vec((meta.ny, meta.nx), data)?)
283 }
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub enum CubeMode {
289 Natural,
291 Total,
293}
294
295pub struct CubeWriter {
298 fptr: FitsFile,
299 pending_beams: Option<Vec<Option<Beam>>>,
302}
303
304impl CubeWriter {
305 pub fn create(
308 input_path: &Path,
309 output_path: &Path,
310 target_beams: &[Option<Beam>],
311 mode: CubeMode,
312 meta: &CubeMeta,
313 ) -> Result<Self, CubeError> {
314 let mut fptr = create_output(input_path, output_path, None)?;
315 write_primary_beam_keys(
316 &mut fptr,
317 ref_beam_for(target_beams, meta),
318 mode == CubeMode::Natural,
319 )?;
320 Ok(Self {
321 fptr,
322 pending_beams: (mode == CubeMode::Natural).then(|| target_beams.to_vec()),
323 })
324 }
325
326 pub fn write_channel<T: CubeElem>(
327 &mut self,
328 chan: usize,
329 data: &Array2<T>,
330 meta: &CubeMeta,
331 ) -> Result<(), CubeError> {
332 let (start, end) = meta.channel_range(chan);
333 let flat = data.as_standard_layout();
334 let slice = flat
335 .as_slice()
336 .ok_or_else(|| std::io::Error::other("plane is not contiguous"))?;
337 T::write_section(&mut self.fptr, start, end, slice)?;
338 Ok(())
339 }
340
341 pub fn finish(mut self) -> Result<(), CubeError> {
344 if let Some(beams) = self.pending_beams.take() {
345 write_beams_table(&mut self.fptr, &beams)?;
346 }
347 Ok(())
348 }
349}
350
351fn ref_beam_for(target_beams: &[Option<Beam>], meta: &CubeMeta) -> Beam {
354 let last = meta.nfreq.saturating_sub(1) as f64;
355 let ref_idx = (meta.crpix_freq - 1.0).clamp(0.0, last) as usize;
356 target_beams[ref_idx]
357 .or_else(|| target_beams.iter().find_map(|b| *b))
358 .unwrap_or(Beam::zero())
359}
360
361fn write_primary_beam_keys(
364 fptr: &mut FitsFile,
365 ref_beam: Beam,
366 natural: bool,
367) -> Result<(), CubeError> {
368 fptr.primary_hdu()?;
369 update_key_f64(fptr, "BMAJ", ref_beam.major_deg())?;
370 update_key_f64(fptr, "BMIN", ref_beam.minor_deg())?;
371 update_key_f64(fptr, "BPA", ref_beam.pa_deg())?;
372 update_key_logical(fptr, "CASAMBM", natural)?;
373 Ok(())
374}
375
376fn write_beams_table(fptr: &mut FitsFile, target_beams: &[Option<Beam>]) -> Result<(), CubeError> {
379 let nfreq = target_beams.len();
380 let tiny = f32::MIN_POSITIVE;
381 let column = |f: fn(&Beam) -> f64| -> Vec<f32> {
382 target_beams
383 .iter()
384 .map(|b| b.map_or(tiny, |b| f(&b) as f32))
385 .collect()
386 };
387 let bmaj = column(Beam::major_arcsec);
388 let bmin = column(Beam::minor_arcsec);
389 let bpa = column(Beam::pa_deg);
390 let chan: Vec<i32> = (0..nfreq as i32).collect();
391 let pol = vec![0i32; nfreq];
392
393 let col = |name: &str, ty: ColumnDataType| ColumnDescription::new(name).with_type(ty).create();
394 let cols = [
395 col("BMAJ", ColumnDataType::Float)?,
396 col("BMIN", ColumnDataType::Float)?,
397 col("BPA", ColumnDataType::Float)?,
398 col("CHAN", ColumnDataType::Int)?,
399 col("POL", ColumnDataType::Int)?,
400 ];
401 let table = fptr.create_table("BEAMS", &cols)?;
402 table.write_col(fptr, "BMAJ", &bmaj)?;
403 table.write_col(fptr, "BMIN", &bmin)?;
404 table.write_col(fptr, "BPA", &bpa)?;
405 table.write_col(fptr, "CHAN", &chan)?;
406 table.write_col(fptr, "POL", &pol)?;
407
408 let table = fptr.hdu("BEAMS")?;
410 table.write_key(fptr, "TUNIT1", "arcsec")?;
411 table.write_key(fptr, "TUNIT2", "arcsec")?;
412 table.write_key(fptr, "TUNIT3", "deg")?;
413 table.write_key(fptr, "NCHAN", nfreq as i64)?;
414 table.write_key(fptr, "NPOL", 1i64)?;
415 Ok(())
416}
417
418pub fn read_beamlog(path: &Path) -> Result<Vec<Beam>, CubeError> {
427 let content = std::fs::read_to_string(path)?;
428 let mut beams = Vec::new();
429 for (i, line) in content.lines().enumerate() {
430 let trimmed = line.trim();
431 if trimmed.is_empty() || trimmed.starts_with('#') {
432 continue;
433 }
434 let fields: Vec<&str> = trimmed.split_whitespace().collect();
435 if fields.len() < 4 {
436 return Err(CubeError::BeamlogParse {
437 line: i + 1,
438 msg: format!("expected 4 fields, got {}", fields.len()),
439 });
440 }
441 let parse = |s: &str, n: &str| -> Result<f64, CubeError> {
442 s.parse::<f64>().map_err(|_| CubeError::BeamlogParse {
443 line: i + 1,
444 msg: format!("cannot parse {n}={s:?} as float"),
445 })
446 };
447 let (bmaj, bmin, bpa) = (
448 parse(fields[1], "BMAJ")?,
449 parse(fields[2], "BMIN")?,
450 parse(fields[3], "BPA")?,
451 );
452 let chan = beams.len();
453 beams.push(beam_from_row(bmaj, bmin, bpa, chan, path).unwrap_or(Beam::zero()));
454 }
455 Ok(beams)
456}
457
458pub fn write_beamlog(path: &Path, beams: &[Option<Beam>]) -> Result<(), CubeError> {
460 let mut out = String::from("# Channel BMAJ[arcsec] BMIN[arcsec] BPA[deg]\n");
461 for (i, b) in beams.iter().enumerate() {
462 out += &match b {
463 Some(b) => format!(
464 "{i} {} {} {}\n",
465 b.major_arcsec(),
466 b.minor_arcsec(),
467 b.pa_deg()
468 ),
469 None => format!("{i} nan nan nan\n"),
470 };
471 }
472 std::fs::write(path, out)?;
473 Ok(())
474}