1use 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
29const FITS_BLOCK: u64 = 2880;
32
33fn round_up_to_block(n: u64) -> u64 {
34 n.div_ceil(FITS_BLOCK) * FITS_BLOCK
35}
36
37trait BeBytes: Copy {
43 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#[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 pub float_length: Option<u8>,
78 pub progress: bool,
81}
82
83fn 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
121fn 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
128fn 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 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
158struct AxisPlacement {
160 fits_idx: usize,
161}
162
163struct InitResult {
165 pixel_type: PixelType,
166 plane_len: usize,
168}
169
170#[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 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 let mut dims = geom.dims.clone();
224 if geom.is_2d() {
225 dims.push(n_chan); } 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 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 let has_cd = has_key(template, "CD1_1")?;
243 let has_pc = has_key(template, "PC1_1")?;
244
245 let mut fptr = create_mem_cube(template, out_bitpix, &dims)?;
251
252 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 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 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 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 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
318fn process_plane<T: CubeElem + num_traits::Float>(
321 path: &Path,
322 bbox: Option<&BoundingBox>,
323 invalidate_zeros: bool,
324) -> Result<Vec<T>> {
325 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 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 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#[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 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 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], };
418 tx.send((new_chan, plane))
419 .map_err(|e| FitsCubeError::Other(format!("channel send failed: {e}")))?;
420 Ok(())
421 });
422 drop(tx); res
424 });
425
426 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
454pub 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 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 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 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 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 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 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 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 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}