Skip to main content

fitscube_rs/
extract.rs

1//! Extract a single plane (channel or timestep) from a FITS cube.
2//!
3//! Port of `fitscube.extract`. Finds the FREQ/TIME axis, slices the requested
4//! index out of the cube (keeping the axis with length 1, as `np.take` +
5//! `np.expand_dims` do), rewrites the axis WCS for the extracted plane, copies
6//! the per-channel beam from the `BEAMS` table when present, and writes a new
7//! FITS image.
8use std::path::{Path, PathBuf};
9
10use fitsio::FitsFile;
11use ndarray::{ArrayD, Axis, IxDyn};
12
13use crate::error::{FitsCubeError, Result};
14use crate::fits_io::{
15    PixelType, copy_header_only, delete_key, resize_image, update_key_f64, update_key_i64,
16    update_key_str,
17};
18
19/// What to pull out of the cube.
20#[derive(Debug, Clone, Default)]
21pub struct ExtractOptions {
22    pub hdu_index: usize,
23    pub channel_index: Option<usize>,
24    pub time_index: Option<usize>,
25    pub overwrite: bool,
26    pub output_path: Option<PathBuf>,
27}
28
29/// The resolved extraction target.
30struct TargetIndex {
31    axis_name: &'static str,
32    axis_index: usize,
33    output_name: &'static str,
34}
35
36/// Resolve the target axis/index, enforcing exactly one of channel/time.
37fn create_target_index(opts: &ExtractOptions) -> Result<TargetIndex> {
38    match (opts.channel_index, opts.time_index) {
39        (Some(_), Some(_)) => Err(FitsCubeError::InvalidSpec(
40            "Both time and channel index are set. Only one may be set.".to_string(),
41        )),
42        (None, None) => Err(FitsCubeError::InvalidSpec(
43            "Both channel index and time index are None. One needs to be set.".to_string(),
44        )),
45        (Some(c), None) => Ok(TargetIndex {
46            axis_name: "FREQ",
47            axis_index: c,
48            output_name: "channel",
49        }),
50        (None, Some(t)) => Ok(TargetIndex {
51            axis_name: "TIME",
52            axis_index: t,
53            output_name: "timestep",
54        }),
55    }
56}
57
58/// Build the default output path: `cube.fits` → `cube.channel-5.fits`. Mirrors
59/// `get_output_path`.
60fn default_output_path(input: &Path, target: &TargetIndex) -> PathBuf {
61    let parent = input.parent().unwrap_or_else(|| Path::new("."));
62    let stem = input.file_stem().unwrap_or_default().to_string_lossy();
63    let ext = input.extension().unwrap_or_default().to_string_lossy();
64    parent.join(format!(
65        "{stem}.{}-{}.{ext}",
66        target.output_name, target.axis_index
67    ))
68}
69
70/// FREQ/TIME axis location within a cube header (read from a given HDU).
71struct AxisWcs {
72    fits_idx: usize,
73    ctype: String,
74    crval: f64,
75    cdelt: f64,
76    cunit: Option<String>,
77}
78
79fn find_axis(fptr: &mut FitsFile, hdu_index: usize, name: &str) -> Result<AxisWcs> {
80    let hdu = fptr.hdu(hdu_index)?;
81    let naxis: i64 = hdu.read_key(fptr, "NAXIS")?;
82    for axis in 1..=naxis {
83        let ctype: String = match hdu.read_key(fptr, &format!("CTYPE{axis}")) {
84            Ok(c) => c,
85            Err(_) => continue,
86        };
87        if ctype.contains(name) {
88            let crval: f64 = hdu.read_key(fptr, &format!("CRVAL{axis}")).unwrap_or(0.0);
89            let cdelt: f64 = hdu.read_key(fptr, &format!("CDELT{axis}")).unwrap_or(1.0);
90            let cunit: Option<String> = hdu.read_key(fptr, &format!("CUNIT{axis}")).ok();
91            return Ok(AxisWcs {
92                fits_idx: axis as usize,
93                ctype,
94                crval,
95                cdelt,
96                cunit,
97            });
98        }
99    }
100    Err(FitsCubeError::TargetAxisMissing(format!(
101        "Did not find the {name} axis"
102    )))
103}
104
105/// Does the header advertise a CASA beam table (`CASAMBM = T`)?
106fn contains_beam_table(fptr: &mut FitsFile, hdu_index: usize) -> bool {
107    let Ok(hdu) = fptr.hdu(hdu_index) else {
108        return false;
109    };
110    if let Ok(b) = hdu.read_key::<bool>(fptr, "CASAMBM") {
111        return b;
112    }
113    if let Ok(s) = hdu.read_key::<String>(fptr, "CASAMBM") {
114        return matches!(s.trim(), "T" | "TRUE");
115    }
116    false
117}
118
119/// Read the dims (FITS order) and BITPIX of the chosen HDU.
120fn read_geom(fptr: &mut FitsFile, hdu_index: usize) -> Result<(Vec<usize>, i64)> {
121    let hdu = fptr.hdu(hdu_index)?;
122    let naxis: i64 = hdu.read_key(fptr, "NAXIS")?;
123    let bitpix: i64 = hdu.read_key(fptr, "BITPIX")?;
124    let mut dims = Vec::with_capacity(naxis as usize);
125    for i in 1..=naxis {
126        let n: i64 = hdu.read_key(fptr, &format!("NAXIS{i}"))?;
127        dims.push(n as usize);
128    }
129    Ok((dims, bitpix))
130}
131
132/// Beam (degrees) for a channel, read from the `BEAMS` binary table.
133struct BeamRow {
134    bmaj_deg: f64,
135    bmin_deg: f64,
136    bpa_deg: f64,
137}
138
139fn extract_beam_row(path: &Path, index: usize) -> Result<BeamRow> {
140    let mut fptr = FitsFile::open(path.to_string_lossy().as_ref())?;
141    let hdu = fptr
142        .hdu("BEAMS")
143        .map_err(|_| FitsCubeError::Other("Beam table was not found".to_string()))?;
144    let bmaj: Vec<f32> = hdu.read_col(&mut fptr, "BMAJ")?;
145    let bmin: Vec<f32> = hdu.read_col(&mut fptr, "BMIN")?;
146    let bpa: Vec<f32> = hdu.read_col(&mut fptr, "BPA")?;
147    if index >= bmaj.len() {
148        return Err(FitsCubeError::ChannelMissing(format!(
149            "beam table has {} rows, requested index {index}",
150            bmaj.len()
151        )));
152    }
153    // Table stores BMAJ/BMIN in arcsec, BPA in deg.
154    Ok(BeamRow {
155        bmaj_deg: bmaj[index] as f64 / 3600.0,
156        bmin_deg: bmin[index] as f64 / 3600.0,
157        bpa_deg: bpa[index] as f64,
158    })
159}
160
161/// Extract a plane buffer at `axis_index` along the cube's `array_idx`
162/// (numpy-order) axis, keeping that axis with length 1.
163fn take_plane<T: Clone + 'static>(
164    flat: Vec<T>,
165    dims_fits: &[usize],
166    array_idx: usize,
167    axis_index: usize,
168) -> Result<Vec<T>> {
169    // numpy (C-order) shape is the reverse of the FITS dim order.
170    let shape_np: Vec<usize> = dims_fits.iter().rev().copied().collect();
171    let arr = ArrayD::from_shape_vec(IxDyn(&shape_np), flat)?;
172    let plane = arr.index_axis(Axis(array_idx), axis_index); // axis removed
173    let plane = plane.insert_axis(Axis(array_idx)); // restore length-1 axis
174    let std = plane.as_standard_layout();
175    Ok(std.iter().cloned().collect())
176}
177
178#[allow(clippy::too_many_arguments)]
179fn write_plane<T: CubeImage>(
180    out_path: &Path,
181    cube: &Path,
182    hdu_index: usize,
183    dims_out_fits: &[usize],
184    bitpix: i64,
185    plane: &[T],
186    axis: &AxisWcs,
187    axis_index: usize,
188    beam: Option<&BeamRow>,
189    drop_casambm: bool,
190    overwrite: bool,
191) -> Result<()> {
192    if out_path.exists() {
193        if overwrite {
194            std::fs::remove_file(out_path)?;
195        } else {
196            return Err(FitsCubeError::OutputExists(out_path.to_path_buf()));
197        }
198    }
199    // Copy the (chosen-HDU) header, resize to the plane shape, rewrite the axis.
200    copy_header_only_hdu(cube, out_path, hdu_index)?;
201    let mut fptr = FitsFile::edit(out_path.to_string_lossy().as_ref())?;
202    fptr.primary_hdu()?;
203    resize_image(&mut fptr, bitpix, dims_out_fits)?;
204
205    let fi = axis.fits_idx;
206    let new_crval = axis.crval + (axis_index as f64) * axis.cdelt;
207    update_key_str(&mut fptr, &format!("CTYPE{fi}"), &axis.ctype)?;
208    update_key_i64(&mut fptr, &format!("CRPIX{fi}"), 1)?;
209    update_key_f64(&mut fptr, &format!("CRVAL{fi}"), new_crval)?;
210    update_key_f64(&mut fptr, &format!("CDELT{fi}"), axis.cdelt)?;
211    if let Some(cunit) = &axis.cunit {
212        update_key_str(&mut fptr, &format!("CUNIT{fi}"), cunit)?;
213    }
214
215    if let Some(b) = beam {
216        update_key_f64(&mut fptr, "BMAJ", b.bmaj_deg)?;
217        update_key_f64(&mut fptr, "BMIN", b.bmin_deg)?;
218        update_key_f64(&mut fptr, "BPA", b.bpa_deg)?;
219    }
220    if drop_casambm {
221        delete_key(&mut fptr, "CASAMBM")?;
222    }
223
224    T::write_primary(&mut fptr, plane)?;
225    Ok(())
226}
227
228/// Copy only the header of a specific input HDU into a fresh primary HDU.
229fn copy_header_only_hdu(input: &Path, output: &Path, hdu_index: usize) -> Result<()> {
230    if hdu_index == 0 {
231        return Ok(copy_header_only(input, output)?);
232    }
233    // For non-primary HDUs, fall back to copying via the safe API path: open the
234    // HDU, then reuse the primary-copy machinery is not applicable, so error
235    // clearly (cubes are stored in the primary HDU in practice).
236    Err(FitsCubeError::Other(format!(
237        "extracting from non-primary HDU index {hdu_index} is not supported"
238    )))
239}
240
241/// Minimal write helper so extract can be generic over pixel precision.
242trait CubeImage: Clone + ndarray::ScalarOperand + 'static {
243    fn read_primary(fptr: &mut FitsFile, hdu_index: usize) -> Result<Vec<Self>>;
244    fn write_primary(fptr: &mut FitsFile, data: &[Self]) -> Result<()>;
245}
246
247macro_rules! impl_cube_image {
248    ($t:ty) => {
249        impl CubeImage for $t {
250            fn read_primary(fptr: &mut FitsFile, hdu_index: usize) -> Result<Vec<Self>> {
251                let hdu = fptr.hdu(hdu_index)?;
252                Ok(hdu.read_image(fptr)?)
253            }
254            fn write_primary(fptr: &mut FitsFile, data: &[Self]) -> Result<()> {
255                let hdu = fptr.primary_hdu()?;
256                hdu.write_image(fptr, data)?;
257                Ok(())
258            }
259        }
260    };
261}
262impl_cube_image!(f32);
263impl_cube_image!(f64);
264
265#[allow(clippy::too_many_arguments)]
266fn extract_typed<T: CubeImage>(
267    cube: &Path,
268    opts: &ExtractOptions,
269    target: &TargetIndex,
270    out_path: &Path,
271    dims: &[usize],
272    bitpix: i64,
273    axis: AxisWcs,
274    beam: Option<BeamRow>,
275    drop_casambm: bool,
276) -> Result<()> {
277    let array_idx = dims.len() - axis.fits_idx; // numpy axis = len - fits_idx
278    if target.axis_index >= dims[array_idx] {
279        return Err(FitsCubeError::ChannelMissing(format!(
280            "index {} outside cube axis of length {}",
281            target.axis_index, dims[array_idx]
282        )));
283    }
284
285    let mut fptr = FitsFile::open(cube.to_string_lossy().as_ref())?;
286    let flat: Vec<T> = T::read_primary(&mut fptr, opts.hdu_index)?;
287    drop(fptr);
288
289    let plane = take_plane(flat, dims, array_idx, target.axis_index)?;
290
291    let mut dims_out = dims.to_vec();
292    dims_out[axis.fits_idx - 1] = 1;
293
294    write_plane::<T>(
295        out_path,
296        cube,
297        opts.hdu_index,
298        &dims_out,
299        bitpix,
300        &plane,
301        &axis,
302        target.axis_index,
303        beam.as_ref(),
304        drop_casambm,
305        opts.overwrite,
306    )
307}
308
309/// Extract a plane from `cube`, writing a new FITS image and returning its path.
310pub fn extract_plane_from_cube(cube: &Path, opts: &ExtractOptions) -> Result<PathBuf> {
311    let target = create_target_index(opts)?;
312    let out_path = opts
313        .output_path
314        .clone()
315        .unwrap_or_else(|| default_output_path(cube, &target));
316
317    let mut fptr = FitsFile::open(cube.to_string_lossy().as_ref())?;
318    let axis = find_axis(&mut fptr, opts.hdu_index, target.axis_name)?;
319    let (dims, bitpix) = read_geom(&mut fptr, opts.hdu_index)?;
320    let has_beam_table = contains_beam_table(&mut fptr, opts.hdu_index);
321    drop(fptr);
322
323    let beam = if has_beam_table {
324        extract_beam_row(cube, target.axis_index).ok()
325    } else {
326        None
327    };
328    let drop_casambm = has_beam_table;
329
330    match PixelType::from_bitpix(bitpix) {
331        PixelType::F32 => extract_typed::<f32>(
332            cube,
333            opts,
334            &target,
335            &out_path,
336            &dims,
337            bitpix,
338            axis,
339            beam,
340            drop_casambm,
341        )?,
342        PixelType::F64 => extract_typed::<f64>(
343            cube,
344            opts,
345            &target,
346            &out_path,
347            &dims,
348            bitpix,
349            axis,
350            beam,
351            drop_casambm,
352        )?,
353    }
354    Ok(out_path)
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn output_path_default() {
363        let target = TargetIndex {
364            axis_name: "FREQ",
365            axis_index: 5,
366            output_name: "channel",
367        };
368        let p = default_output_path(Path::new("/data/cube.fits"), &target);
369        assert_eq!(p, PathBuf::from("/data/cube.channel-5.fits"));
370    }
371
372    #[test]
373    fn both_indices_errors() {
374        let opts = ExtractOptions {
375            channel_index: Some(1),
376            time_index: Some(2),
377            ..Default::default()
378        };
379        assert!(create_target_index(&opts).is_err());
380    }
381
382    #[test]
383    fn neither_index_errors() {
384        let opts = ExtractOptions::default();
385        assert!(create_target_index(&opts).is_err());
386    }
387
388    #[test]
389    fn take_middle_plane_of_cube() {
390        // FITS dims [nx=2, ny=2, nchan=3]; numpy shape [3,2,2].
391        // Channel values: chan c filled with c.
392        let dims_fits = vec![2usize, 2, 3];
393        let mut flat = vec![0.0f64; 12];
394        // numpy C-order index = ((c)*2 + r)*2 + col, with shape [3,2,2].
395        for c in 0..3 {
396            for r in 0..2 {
397                for col in 0..2 {
398                    flat[(c * 2 + r) * 2 + col] = c as f64;
399                }
400            }
401        }
402        // FREQ axis is fits_idx 3 → array_idx = 3 - 3 = 0.
403        let plane = take_plane(flat, &dims_fits, 0, 1).unwrap();
404        assert_eq!(plane, vec![1.0; 4]);
405    }
406}