Skip to main content

fitscube_rs/
combine.rs

1//! Combine single-plane FITS images into a cube.
2//!
3//! Port of `fitscube.combine_fits`. The pipeline: parse the spectral/temporal
4//! axis ([`crate::specs`]), read beams ([`crate::beams`]), sort the inputs,
5//! optionally compute a common bounding box, build the output header, then
6//! stream each plane into the cube with raw I/O (reading/decoding in parallel,
7//! writing on a single thread). The data unit bypasses cfitsio entirely to avoid
8//! its zero-fill pass — see [`mem_header`] and [`write_cube_raw`].
9use std::fs::OpenOptions;
10use std::os::unix::fs::FileExt;
11use std::path::{Path, PathBuf};
12use std::sync::mpsc::sync_channel;
13
14use fitsio::FitsFile;
15use ndarray::{Array2, ArrayView2};
16use rayon::prelude::*;
17
18use crate::beams::{self, Beam};
19use crate::bounding_box::{BoundingBox, create_bound_box_plane, extract_common_bounding_box};
20use crate::error::{FitsCubeError, Result};
21use crate::fits_io::{
22    CubeElem, CubeLayout, HeaderGeom, PixelType, create_mem_cube, delete_key,
23    extract_header_layout, find_target_axis, has_key, update_key_f64, update_key_i64,
24    update_key_logical, update_key_str, write_comment,
25};
26use crate::progress::{progress_bar, spinner};
27use crate::specs::parse_specs;
28
29/// FITS records are 2880 bytes; headers and the data unit are each padded up to
30/// a whole number of these blocks.
31const FITS_BLOCK: u64 = 2880;
32
33fn round_up_to_block(n: u64) -> u64 {
34    n.div_ceil(FITS_BLOCK) * FITS_BLOCK
35}
36
37/// A pixel value serialisable to big-endian FITS byte order.
38///
39/// The combine writer bypasses cfitsio for the data unit (see [`write_cube_raw`]),
40/// so it byte-swaps each plane itself — exactly as the Python reference does with
41/// `ndarray.astype(">f4")` before a raw `tofile`.
42trait BeBytes: Copy {
43    /// On-disk width in bytes (FITS `|BITPIX|/8`).
44    const WIDTH: usize;
45    fn extend_be(self, buf: &mut Vec<u8>);
46}
47
48impl BeBytes for f32 {
49    const WIDTH: usize = 4;
50    fn extend_be(self, buf: &mut Vec<u8>) {
51        buf.extend_from_slice(&self.to_bits().to_be_bytes());
52    }
53}
54
55impl BeBytes for f64 {
56    const WIDTH: usize = 8;
57    fn extend_be(self, buf: &mut Vec<u8>) {
58        buf.extend_from_slice(&self.to_bits().to_be_bytes());
59    }
60}
61
62/// Options for [`combine_fits`], mirroring the keyword arguments of the Python
63/// `combine_fits`.
64#[derive(Debug, Clone, Default)]
65pub struct CombineOptions {
66    pub spec_file: Option<PathBuf>,
67    pub spec_list: Option<Vec<f64>>,
68    pub ignore_spec: bool,
69    pub create_blanks: bool,
70    pub overwrite: bool,
71    pub max_workers: Option<usize>,
72    pub time_domain_mode: bool,
73    pub bounding_box: bool,
74    pub invalidate_zeros: bool,
75    /// Output floating-point precision in bits. Only 32 and 64 are valid FITS
76    /// float widths (BITPIX −32 / −64); other values are rejected.
77    pub float_length: Option<u8>,
78    /// Draw progress bars/spinners to stderr. The CLI sets this; the Python
79    /// bindings leave it off so importing the module stays silent.
80    pub progress: bool,
81}
82
83/// Validate `float_length` and map it to a FITS BITPIX, or `None` to inherit
84/// the input precision.
85fn float_length_to_bitpix(float_length: Option<u8>) -> Result<Option<i64>> {
86    match float_length {
87        None => Ok(None),
88        Some(32) => Ok(Some(-32)),
89        Some(64) => Ok(Some(-64)),
90        Some(other) => Err(FitsCubeError::Other(format!(
91            "floating={other} is not a valid FITS float precision; use 32 or 64 \
92             (FITS defines only −32 and −64 bit IEEE floats)"
93        ))),
94    }
95}
96
97fn median(values: &[f64]) -> f64 {
98    let mut v = values.to_vec();
99    v.sort_by(|a, b| a.partial_cmp(b).unwrap());
100    let n = v.len();
101    if n == 0 {
102        return f64::NAN;
103    }
104    if n % 2 == 1 {
105        v[n / 2]
106    } else {
107        0.5 * (v[n / 2 - 1] + v[n / 2])
108    }
109}
110
111fn std_dev(values: &[f64]) -> f64 {
112    let n = values.len() as f64;
113    if n == 0.0 {
114        return 0.0;
115    }
116    let mean = values.iter().sum::<f64>() / n;
117    let var = values.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n;
118    var.sqrt()
119}
120
121/// `argsort`: indices that would sort `keys` ascending (stable).
122fn argsort(keys: &[f64]) -> Vec<usize> {
123    let mut idx: Vec<usize> = (0..keys.len()).collect();
124    idx.sort_by(|&a, &b| keys[a].partial_cmp(&keys[b]).unwrap());
125    idx
126}
127
128/// Decide whether the axis is evenly spaced enough to encode as a regular
129/// CDELT, mirroring the `even_spec` test in `create_output_cube`.
130fn is_evenly_spaced(specs: &[f64], time_domain_mode: bool) -> bool {
131    if specs.len() < 2 {
132        return true;
133    }
134    let mut sorted = specs.to_vec();
135    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
136    let diff: Vec<f64> = sorted.windows(2).map(|w| w[1] - w[0]).collect();
137
138    if time_domain_mode {
139        // Constrain the accumulated deviation of the second-order differences:
140        // small running total ⇒ close enough to regular spacing to encode.
141        if diff.len() < 2 {
142            return true;
143        }
144        let diff_diff: Vec<f64> = diff.windows(2).map(|w| w[1] - w[0]).collect();
145        let mut cumsum = 0.0;
146        let mut max_dev = 0.0_f64;
147        for d in &diff_diff {
148            cumsum += d;
149            max_dev = max_dev.max(cumsum.abs());
150        }
151        let mean_diff = diff.iter().sum::<f64>() / diff.len() as f64;
152        max_dev < mean_diff * 0.02
153    } else {
154        std_dev(&diff) < 1e-4
155    }
156}
157
158/// Where the spectral/temporal axis lives in the output cube.
159struct AxisPlacement {
160    fits_idx: usize,
161}
162
163/// Result of initialising the output cube.
164struct InitResult {
165    pixel_type: PixelType,
166    /// Output plane length (NAXIS1 × NAXIS2 after any bounding-box trim).
167    plane_len: usize,
168}
169
170/// Build the complete primary header for the output cube and return its on-disk
171/// byte layout ([`CubeLayout`]) — no data unit is written here (see
172/// [`crate::mem_header`]).
173#[allow(clippy::too_many_arguments)]
174fn create_output_cube(
175    template: &Path,
176    out_cube: &Path,
177    specs: &[f64],
178    ignore_spec: bool,
179    has_beams: bool,
180    single_beam: bool,
181    overwrite: bool,
182    time_domain_mode: bool,
183    bbox: Option<&BoundingBox>,
184    float_length: Option<u8>,
185) -> Result<(InitResult, CubeLayout)> {
186    if out_cube.exists() && !overwrite {
187        return Err(FitsCubeError::OutputExists(out_cube.to_path_buf()));
188    }
189
190    let unit = if time_domain_mode { "s" } else { "Hz" };
191    let ctype = if time_domain_mode { "TIME" } else { "FREQ" };
192
193    let geom = HeaderGeom::read(template)?;
194    let n_chan = specs.len();
195    let even_spec = is_evenly_spaced(specs, time_domain_mode);
196    if !even_spec {
197        tracing::warn!(
198            "{} are not evenly spaced; encoding axis as CHAN",
199            if time_domain_mode {
200                "Times"
201            } else {
202                "Frequencies"
203            }
204        );
205    }
206
207    // Locate the spectral axis (existing axis for a cube input, or a new one).
208    let placement = if geom.is_2d() {
209        AxisPlacement { fits_idx: 3 }
210    } else {
211        match find_target_axis(template, ctype) {
212            Ok(axis) => AxisPlacement {
213                fits_idx: axis.fits_idx,
214            },
215            Err(_) => AxisPlacement {
216                fits_idx: geom.naxis + 1,
217            },
218        }
219    };
220    let fi = placement.fits_idx;
221
222    // Output dimensions in FITS order (NAXIS1 fastest).
223    let mut dims = geom.dims.clone();
224    if geom.is_2d() {
225        dims.push(n_chan); // new NAXIS3
226    } else if fi <= dims.len() {
227        dims[fi - 1] = n_chan;
228    } else {
229        dims.resize(fi, 1);
230        dims[fi - 1] = n_chan;
231    }
232    if let Some(bb) = bbox {
233        // NAXIS1 (fast/cols) ← y-span, NAXIS2 (slow/rows) ← x-span.
234        dims[0] = bb.y_span;
235        dims[1] = bb.x_span;
236    }
237
238    let in_bitpix = geom.bitpix;
239    let out_bitpix = float_length_to_bitpix(float_length)?.unwrap_or(in_bitpix);
240
241    // Detect transform-matrix presence before we clobber anything.
242    let has_cd = has_key(template, "CD1_1")?;
243    let has_pc = has_key(template, "PC1_1")?;
244
245    // Build the header in memory (no disk, so cfitsio never zero-fills the data
246    // unit) at its final shape/BITPIX, copying the template's WCS cards. The
247    // caller writes the header bytes and streams planes with raw I/O, so the data
248    // unit is written exactly once and its untouched tail stays sparse — see
249    // [`crate::mem_header`] and [`write_cube_raw`].
250    let mut fptr = create_mem_cube(template, out_bitpix, &dims)?;
251
252    // Spectral/temporal axis cards.
253    update_key_i64(&mut fptr, &format!("CRPIX{fi}"), 1)?;
254    update_key_f64(&mut fptr, &format!("CRVAL{fi}"), specs[0])?;
255    let cdelt = if n_chan > 1 {
256        let diffs: Vec<f64> = specs.windows(2).map(|w| w[1] - w[0]).collect();
257        median(&diffs)
258    } else {
259        1.0
260    };
261    update_key_f64(&mut fptr, &format!("CDELT{fi}"), cdelt)?;
262    update_key_str(&mut fptr, &format!("CUNIT{fi}"), unit)?;
263    update_key_str(&mut fptr, &format!("CTYPE{fi}"), ctype)?;
264
265    // Diagonal transform term for the new axis, for consistency.
266    if (has_cd || has_pc) && fi != 1 {
267        let kind = if has_cd { "CD" } else { "PC" };
268        update_key_f64(&mut fptr, &format!("{kind}{fi}_{fi}"), 1.0)?;
269    }
270
271    // Unevenly spaced or ignored ⇒ encode a plain channel index.
272    if ignore_spec || !even_spec {
273        update_key_f64(&mut fptr, &format!("CDELT{fi}"), 1.0)?;
274        delete_key(&mut fptr, &format!("CUNIT{fi}"))?;
275        update_key_str(&mut fptr, &format!("CTYPE{fi}"), "CHAN")?;
276        update_key_f64(&mut fptr, &format!("CRVAL{fi}"), 1.0)?;
277    }
278
279    // Varying beams ⇒ drop the single-beam keywords; the BEAMS table holds
280    // the per-channel values.
281    if has_beams && !single_beam {
282        let tiny = f32::MIN_POSITIVE;
283        update_key_logical(&mut fptr, "CASAMBM", true)?;
284        write_comment(&mut fptr, "The PSF in each image plane varies.")?;
285        write_comment(
286            &mut fptr,
287            "Full beam information is stored in the second FITS extension.",
288        )?;
289        write_comment(
290            &mut fptr,
291            &format!("The value '{tiny}' repsenents a NaN PSF in the beamtable."),
292        )?;
293        delete_key(&mut fptr, "BMAJ")?;
294        delete_key(&mut fptr, "BMIN")?;
295        delete_key(&mut fptr, "BPA")?;
296    }
297
298    // Bounding box shifts the spatial reference pixel.
299    if let Some(bb) = bbox {
300        let hdu = fptr.primary_hdu()?;
301        let crpix1: f64 = hdu.read_key(&mut fptr, "CRPIX1").unwrap_or(1.0);
302        let crpix2: f64 = hdu.read_key(&mut fptr, "CRPIX2").unwrap_or(1.0);
303        update_key_f64(&mut fptr, "CRPIX1", crpix1 - bb.ymin as f64)?;
304        update_key_f64(&mut fptr, "CRPIX2", crpix2 - bb.xmin as f64)?;
305    }
306
307    let plane_len = dims[0] * dims.get(1).copied().unwrap_or(1);
308    let layout = extract_header_layout(&mut fptr)?;
309    Ok((
310        InitResult {
311            pixel_type: PixelType::from_bitpix(out_bitpix),
312            plane_len,
313        },
314        layout,
315    ))
316}
317
318/// Read one input plane as type `T`, apply bounding box / zero-invalidation, and
319/// return the flat (row-major) plane buffer ready for `write_section`.
320fn process_plane<T: CubeElem + num_traits::Float>(
321    path: &Path,
322    bbox: Option<&BoundingBox>,
323    invalidate_zeros: bool,
324) -> Result<Vec<T>> {
325    // Single open per plane. Only read the spatial dims (extra header keys) when
326    // a bounding box actually needs them.
327    let mut fptr = FitsFile::open(path.to_string_lossy().as_ref())?;
328    let dims = if bbox.is_some() {
329        let hdu = fptr.primary_hdu()?;
330        // FITS order: NAXIS1 = cols (fast), NAXIS2 = rows.
331        let ncols: i64 = hdu.read_key(&mut fptr, "NAXIS1")?;
332        let nrows: i64 = hdu.read_key(&mut fptr, "NAXIS2")?;
333        Some((nrows as usize, ncols as usize))
334    } else {
335        None
336    };
337    let flat: Vec<T> = T::read_full(&mut fptr)?;
338
339    let mut plane: Vec<T> = if let Some(bb) = bbox {
340        let (nrows, ncols) = dims.expect("dims read when bbox is set");
341        let view: ArrayView2<T> = ArrayView2::from_shape((nrows, ncols), &flat)?;
342        // Slice rows xmin:xmax, cols ymin:ymax (matches numpy `[..., x, y]`).
343        let sub = view.slice(ndarray::s![bb.xmin..bb.xmax, bb.ymin..bb.ymax]);
344        let owned: Array2<T> = sub.to_owned();
345        owned.into_raw_vec_and_offset().0
346    } else {
347        flat
348    };
349
350    if invalidate_zeros {
351        let zero = T::zero();
352        let nan = T::nan();
353        for v in &mut plane {
354            if *v == zero {
355                *v = nan;
356            }
357        }
358    }
359    Ok(plane)
360}
361
362/// Stream all channels into the output cube using raw I/O.
363///
364/// Bypasses cfitsio for the data unit: the file is created with the prebuilt
365/// header ([`CubeLayout`]) and sparsely extended to its final length, then each
366/// decoded plane is byte-swapped to big-endian ([`BeBytes`]) and written at its
367/// offset. This mirrors the Python reference (`astype(">f4")` + raw `tofile`),
368/// which is markedly faster than cfitsio's per-block write path and never pays
369/// the zero-fill pass cfitsio does on close.
370///
371/// Planes are decoded by the rayon pool (parallel readers) and written on this
372/// single thread; `write_all_at` is positional, so out-of-order arrival is fine.
373#[allow(clippy::too_many_arguments)]
374fn write_cube_raw<T: CubeElem + num_traits::Float + BeBytes>(
375    out_cube: &Path,
376    layout: &CubeLayout,
377    file_list: &[PathBuf],
378    new_to_old: &[Option<usize>],
379    plane_len: usize,
380    bbox: Option<&BoundingBox>,
381    invalidate_zeros: bool,
382    max_workers: Option<usize>,
383    progress: bool,
384) -> Result<()> {
385    let n_chan = new_to_old.len();
386    let plane_bytes = (plane_len * T::WIDTH) as u64;
387
388    // Lay down the header and size the file. `set_len` past the header leaves the
389    // data unit (and its 2880-padded tail) sparse — zero-backed on demand — so no
390    // zeros are physically written; the planes below cover the real data.
391    let file = OpenOptions::new()
392        .read(true)
393        .write(true)
394        .create(true)
395        .truncate(true)
396        .open(out_cube)?;
397    file.write_all_at(&layout.header, 0)?;
398    let data_len = plane_bytes * n_chan as u64;
399    file.set_len(layout.datastart + round_up_to_block(data_len))?;
400
401    // Buffer enough decoded planes that the parallel readers stay ahead of the
402    // single (serial) writer instead of blocking on a tiny queue.
403    let default_bound = std::thread::available_parallelism()
404        .map(|n| n.get() * 2)
405        .unwrap_or(8);
406    let bound = max_workers.unwrap_or(default_bound).max(1);
407    let (tx, rx) = sync_channel::<(usize, Vec<T>)>(bound);
408
409    std::thread::scope(|scope| -> Result<()> {
410        let producer = scope.spawn(move || -> Result<()> {
411            let res = (0..n_chan)
412                .into_par_iter()
413                .try_for_each(|new_chan| -> Result<()> {
414                    let plane = match new_to_old[new_chan] {
415                        Some(old) => process_plane::<T>(&file_list[old], bbox, invalidate_zeros)?,
416                        None => vec![T::nan(); plane_len], // missing → blank plane
417                    };
418                    tx.send((new_chan, plane))
419                        .map_err(|e| FitsCubeError::Other(format!("channel send failed: {e}")))?;
420                    Ok(())
421                });
422            drop(tx); // close the channel so the writer loop below ends
423            res
424        });
425
426        // Writer (this thread): byte-swap each plane and write it at its offset.
427        let pb = progress.then(|| {
428            let bar = progress_bar(n_chan as u64);
429            bar.set_message("writing planes");
430            bar
431        });
432        let mut buf: Vec<u8> = Vec::with_capacity(plane_bytes as usize);
433        for (chan, data) in rx {
434            buf.clear();
435            for v in &data {
436                v.extend_be(&mut buf);
437            }
438            let offset = layout.datastart + chan as u64 * plane_bytes;
439            file.write_all_at(&buf, offset)?;
440            if let Some(bar) = &pb {
441                bar.inc(1);
442            }
443        }
444        if let Some(bar) = &pb {
445            bar.finish_with_message("planes written");
446        }
447
448        producer
449            .join()
450            .map_err(|_| FitsCubeError::Other("reader thread panicked".to_string()))?
451    })
452}
453
454/// Combine `file_list` into the cube at `out_cube`. Returns the output-axis
455/// values (Hz for frequency mode, MJD seconds for time mode).
456pub fn combine_fits(
457    file_list: &[PathBuf],
458    out_cube: &Path,
459    options: &CombineOptions,
460) -> Result<Vec<f64>> {
461    if file_list.is_empty() {
462        return Err(FitsCubeError::Other("file_list is empty".to_string()));
463    }
464    // Validate precision early.
465    float_length_to_bitpix(options.float_length)?;
466
467    let spec_info = parse_specs(
468        file_list,
469        options.spec_file.as_deref(),
470        options.spec_list.as_deref(),
471        options.ignore_spec,
472        options.create_blanks,
473        options.time_domain_mode,
474    )?;
475
476    // Beams (parsed in input order, matching the original).
477    let has_beams = has_key(&file_list[0], "BMAJ")?;
478    let (beams_vec, single_beam): (Option<Vec<Beam>>, bool) = if has_beams {
479        let beams = beams::parse_beams(file_list)?;
480        let single = beams::is_single_beam(&beams);
481        (Some(beams), single)
482    } else {
483        (None, false)
484    };
485
486    // Sort files by their per-file value; sort the output axis independently.
487    let old_sort = argsort(&spec_info.file_specs);
488    let sorted_files: Vec<PathBuf> = old_sort.iter().map(|&i| file_list[i].clone()).collect();
489
490    let new_sort = argsort(&spec_info.specs);
491    let specs: Vec<f64> = new_sort.iter().map(|&i| spec_info.specs[i]).collect();
492    let missing: Vec<bool> = new_sort.iter().map(|&i| spec_info.missing[i]).collect();
493
494    // Optional common bounding box (computed from the sorted files).
495    let final_bbox: Option<BoundingBox> = if options.bounding_box {
496        let spin = options
497            .progress
498            .then(|| spinner("solving for common bounding box"));
499        let boxes: Vec<Option<BoundingBox>> = sorted_files
500            .par_iter()
501            .map(|p| -> Result<Option<BoundingBox>> {
502                let plane = process_plane::<f64>(p, None, options.invalidate_zeros)?;
503                let geom = HeaderGeom::read(p)?;
504                let ncols = geom.dims.first().copied().unwrap_or(1);
505                let nrows = geom.dims.get(1).copied().unwrap_or(1);
506                let view = ArrayView2::from_shape((nrows, ncols), &plane)?;
507                Ok(create_bound_box_plane(&view))
508            })
509            .collect::<Result<Vec<_>>>()?;
510        let bb = extract_common_bounding_box(&boxes)?;
511        if let Some(spin) = spin {
512            spin.finish_and_clear();
513        }
514        tracing::info!("The final bounding box is: {bb:?}");
515        Some(bb)
516    } else {
517        None
518    };
519
520    // Build the output header (in memory) and its on-disk byte layout.
521    let (init, layout) = create_output_cube(
522        &sorted_files[0],
523        out_cube,
524        &specs,
525        options.ignore_spec,
526        has_beams,
527        single_beam,
528        options.overwrite,
529        options.time_domain_mode,
530        final_bbox.as_ref(),
531        options.float_length,
532    )?;
533
534    // Map each output channel to an input index (None ⇒ blank/missing plane).
535    let mut new_to_old: Vec<Option<usize>> = Vec::with_capacity(specs.len());
536    let mut next_old = 0usize;
537    for &is_missing in &missing {
538        if is_missing {
539            new_to_old.push(None);
540        } else {
541            new_to_old.push(Some(next_old));
542            next_old += 1;
543        }
544    }
545    if next_old != sorted_files.len() {
546        return Err(FitsCubeError::ChannelMissing(format!(
547            "channel/file count mismatch: {} present channels for {} files",
548            next_old,
549            sorted_files.len()
550        )));
551    }
552
553    // Stream planes in the output precision.
554    match init.pixel_type {
555        PixelType::F32 => write_cube_raw::<f32>(
556            out_cube,
557            &layout,
558            &sorted_files,
559            &new_to_old,
560            init.plane_len,
561            final_bbox.as_ref(),
562            options.invalidate_zeros,
563            options.max_workers,
564            options.progress,
565        )?,
566        PixelType::F64 => write_cube_raw::<f64>(
567            out_cube,
568            &layout,
569            &sorted_files,
570            &new_to_old,
571            init.plane_len,
572            final_bbox.as_ref(),
573            options.invalidate_zeros,
574            options.max_workers,
575            options.progress,
576        )?,
577    }
578
579    // Append the per-channel beam table when beams vary.
580    if has_beams
581        && !single_beam
582        && let Some(beams) = beams_vec
583    {
584        let pol = beams::get_polarisation(&sorted_files[0])?;
585        let mut fptr = FitsFile::edit(out_cube.to_string_lossy().as_ref())?;
586        beams::write_beam_table(&mut fptr, &beams, pol)?;
587    }
588
589    Ok(specs)
590}