Skip to main content

fitscube_rs/
specs.rs

1//! Spectral/temporal axis parsing ("spequency" = frequency *or* time).
2//!
3//! Port of the metadata-parsing half of `fitscube.combine_fits`: pull a
4//! frequency (Hz) or time (MJD seconds) value out of each input image, optionally
5//! synthesise an evenly-spaced axis with gaps flagged as missing channels.
6use std::path::Path;
7
8use hifitime::Epoch;
9
10use crate::error::{FitsCubeError, Result};
11use crate::fits_io::{HeaderGeom, find_target_axis, read_key_f64, read_key_string};
12
13/// Result of parsing the spectral/temporal axis.
14#[derive(Debug, Clone)]
15pub struct SpecInfo {
16    /// One value per input file (Hz or s), used to sort the files.
17    pub file_specs: Vec<f64>,
18    /// Output-axis values (Hz or s); longer than `file_specs` when blanks are
19    /// interpolated.
20    pub specs: Vec<f64>,
21    /// `true` for each output channel that has no backing file (a blank plane).
22    pub missing: Vec<bool>,
23}
24
25/// Convert a CUNIT string for a frequency axis to a factor that scales the axis
26/// value to Hz. Defaults to 1.0 (assume Hz) for unknown/absent units.
27fn freq_unit_to_hz(cunit: Option<&str>) -> f64 {
28    match cunit.map(|s| s.trim().to_ascii_uppercase()) {
29        Some(ref u) if u == "HZ" => 1.0,
30        Some(ref u) if u == "KHZ" => 1e3,
31        Some(ref u) if u == "MHZ" => 1e6,
32        Some(ref u) if u == "GHZ" => 1e9,
33        _ => 1.0,
34    }
35}
36
37/// Convert an ISO-8601 (`isot`) DATE-OBS timestamp to MJD seconds.
38///
39/// Matches `Time(utc_time, format="isot").mjd * 86400` from the original.
40pub fn utc_to_mjdsec(utc_time: &str) -> Result<f64> {
41    const SEC_PER_DAY: f64 = 86400.0;
42    let trimmed = utc_time.trim().trim_matches('\'').trim();
43    let epoch = Epoch::from_gregorian_str(trimmed)
44        .or_else(|_| trimmed.parse::<Epoch>())
45        .map_err(|e| FitsCubeError::TimeParse {
46            value: utc_time.to_string(),
47            msg: e.to_string(),
48        })?;
49    Ok(epoch.to_mjd_utc_days() * SEC_PER_DAY)
50}
51
52/// Read the frequency (Hz) or time (s) of a single image from its header.
53///
54/// * time mode: `DATE-OBS` → MJD seconds (2D or N-D).
55/// * frequency mode, 2D: the `REFFREQ` keyword (Hz).
56/// * frequency mode, N-D: the spectral world value at pixel 0,
57///   `CRVAL + (1 − CRPIX)·CDELT`, scaled to Hz by CUNIT.
58pub fn read_spec_from_header(path: &Path, time_domain_mode: bool) -> Result<f64> {
59    if time_domain_mode {
60        let date_obs = read_key_string(path, "DATE-OBS")?.ok_or_else(|| {
61            FitsCubeError::MissingKeyword(format!(
62                "DATE-OBS not in header of {} (needed for time-domain mode)",
63                path.display()
64            ))
65        })?;
66        return utc_to_mjdsec(&date_obs);
67    }
68
69    let geom = HeaderGeom::read(path)?;
70    if geom.is_2d() {
71        let reffreq = read_key_f64(path, "REFFREQ")?.ok_or_else(|| {
72            FitsCubeError::MissingKeyword(format!(
73                "REFFREQ not in header of {}. Cannot combine 2D images without \
74                 frequency information.",
75                path.display()
76            ))
77        })?;
78        return Ok(reffreq);
79    }
80
81    // N-D: locate the FREQ axis and evaluate its world coordinate at pixel 0.
82    let axis = find_target_axis(path, "FREQ").map_err(|_| {
83        FitsCubeError::TargetAxisMissing(format!(
84            "No FREQ axis found in WCS of {}. Cannot combine N-D images without \
85             frequency information.",
86            path.display()
87        ))
88    })?;
89    let world = axis.crval + (1.0 - axis.crpix) * axis.cdelt;
90    Ok(world * freq_unit_to_hz(axis.cunit.as_deref()))
91}
92
93/// `isin_close`: for each element, is it close to *any* test element?
94///
95/// NOTE: the original calls `np.isclose(element, test, atol, rtol)` with the
96/// `rtol`/`atol` positions swapped, so the effective tolerances differ from the
97/// variable names. We replicate the *effective* behaviour for bit-for-bit
98/// parity with `fitscube`:
99///   * frequency: `atol = 1e-5`, `rtol = 1e-9`
100///   * time:      `atol = 1e-10`, `rtol = 1e-9`
101///
102/// Closeness test: `|e − b| ≤ atol + rtol·|b|`.
103pub fn isin_close(elements: &[f64], test: &[f64], time_domain_mode: bool) -> Vec<bool> {
104    let (atol, rtol) = if time_domain_mode {
105        (1e-10, 1e-9)
106    } else {
107        (1e-5, 1e-9)
108    };
109    elements
110        .iter()
111        .map(|&e| test.iter().any(|&b| (e - b).abs() <= atol + rtol * b.abs()))
112        .collect()
113}
114
115/// `np_arange_fix`: `np.arange` with a nudge so the stop endpoint is included
116/// when floating-point rounding would otherwise drop it.
117fn np_arange_fix(start: f64, stop: f64, step: f64) -> Vec<f64> {
118    let n = (stop - start) / step + 1.0;
119    let x = n - n.trunc();
120    let stop = if x < 0.5 {
121        stop + step * f64::max(0.1, x)
122    } else {
123        stop
124    };
125    let mut out = Vec::new();
126    let mut v = start;
127    // Match numpy: stop is exclusive.
128    while v < stop {
129        out.push(v);
130        v += step;
131    }
132    out
133}
134
135/// Build an evenly-spaced axis from possibly-uneven input values, flagging the
136/// interpolated gaps as missing channels. Mirrors `even_spacing`.
137pub fn even_spacing(specs: &[f64], time_domain_mode: bool) -> (Vec<f64>, Vec<bool>) {
138    assert!(specs.len() >= 2, "need at least two values to space evenly");
139    let diffs: Vec<f64> = specs.windows(2).map(|w| w[1] - w[0]).collect();
140    let min_diff = diffs.iter().cloned().fold(f64::INFINITY, f64::min);
141    let new_specs = np_arange_fix(specs[0], specs[specs.len() - 1], min_diff);
142    let present = isin_close(&new_specs, specs, time_domain_mode);
143    let missing: Vec<bool> = present.iter().map(|&p| !p).collect();
144    (new_specs, missing)
145}
146
147/// Parse the spectral/temporal axis for a set of input files.
148///
149/// Mirrors `parse_specs_coro`. `spec_list` (the CLI `--specs`) supplies the
150/// per-file values directly; `spec_file` reads them one-per-line from a text
151/// file; otherwise they are read from each header.
152pub fn parse_specs(
153    file_list: &[std::path::PathBuf],
154    spec_file: Option<&Path>,
155    spec_list: Option<&[f64]>,
156    ignore_spec: bool,
157    create_blanks: bool,
158    time_domain_mode: bool,
159) -> Result<SpecInfo> {
160    let n = file_list.len();
161
162    if ignore_spec {
163        tracing::info!("Ignoring frequency/time information");
164        let seq: Vec<f64> = (0..n).map(|i| i as f64).collect();
165        return Ok(SpecInfo {
166            file_specs: seq.clone(),
167            specs: seq,
168            missing: vec![false; n],
169        });
170    }
171
172    if spec_file.is_some() && spec_list.is_some() {
173        return Err(FitsCubeError::InvalidSpec(
174            "Must specify either spec_file or spec_list, not both".to_string(),
175        ));
176    }
177
178    let mut file_specs: Vec<f64> = if let Some(path) = spec_file {
179        tracing::info!("Reading spec values from {}", path.display());
180        let text = std::fs::read_to_string(path)?;
181        let vals: Vec<f64> = text
182            .split_whitespace()
183            .map(|t| t.parse::<f64>())
184            .collect::<std::result::Result<_, _>>()
185            .map_err(|e| FitsCubeError::InvalidSpec(format!("parsing {}: {e}", path.display())))?;
186        if vals.len() != n {
187            return Err(FitsCubeError::InvalidSpec(format!(
188                "Number of values in {} ({}) does not match number of images ({n})",
189                path.display(),
190                vals.len()
191            )));
192        }
193        vals
194    } else if let Some(list) = spec_list {
195        if list.len() != n {
196            return Err(FitsCubeError::InvalidSpec(format!(
197                "Number of --specs values ({}) does not match number of images ({n})",
198                list.len()
199            )));
200        }
201        list.to_vec()
202    } else {
203        file_list
204            .iter()
205            .map(|p| read_spec_from_header(p, time_domain_mode))
206            .collect::<Result<Vec<f64>>>()?
207    };
208
209    let (specs, missing) = if create_blanks {
210        tracing::info!("Creating an evenly-spaced axis with interpolated blanks");
211        even_spacing(&file_specs, time_domain_mode)
212    } else {
213        (file_specs.clone(), vec![false; file_specs.len()])
214    };
215
216    // Keep `file_specs` length == number of files.
217    file_specs.truncate(n);
218
219    Ok(SpecInfo {
220        file_specs,
221        specs,
222        missing,
223    })
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use approx::assert_relative_eq;
230
231    #[test]
232    fn even_axis_has_no_missing() {
233        let specs = vec![1.0e9, 2.0e9, 3.0e9, 4.0e9];
234        let (new, missing) = even_spacing(&specs, false);
235        assert_eq!(new.len(), 4);
236        assert!(missing.iter().all(|&m| !m));
237    }
238
239    #[test]
240    fn gap_is_flagged_missing() {
241        // Missing the 3 GHz channel.
242        let specs = vec![1.0e9, 2.0e9, 4.0e9];
243        let (new, missing) = even_spacing(&specs, false);
244        assert_eq!(new.len(), 4);
245        // The interpolated 3 GHz slot is the only missing one.
246        assert_eq!(missing.iter().filter(|&&m| m).count(), 1);
247        assert!(missing[2]);
248    }
249
250    #[test]
251    fn mjdsec_round_number() {
252        // 1858-11-17T00:00:00 is MJD 0.
253        let s = utc_to_mjdsec("1858-11-17T00:00:00").unwrap();
254        assert_relative_eq!(s, 0.0, epsilon = 1e-3);
255    }
256
257    #[test]
258    fn mjdsec_one_day() {
259        let s = utc_to_mjdsec("1858-11-18T00:00:00").unwrap();
260        assert_relative_eq!(s, 86400.0, epsilon = 1e-3);
261    }
262}