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/// Fundamental channel step: the gcd of the observed diffs.
136///
137/// On a regular grid with missing channels every diff is an integer multiple
138/// of the channel width, so the gcd recovers that width even when no two
139/// surviving channels are adjacent — the case where `min(diffs)` overestimates
140/// the step (e.g. present channels 0, 3, 5 give `min = 2` but the true step is
141/// 1). Falls back to `min(diffs)` if the gcd is numerically unstable or does
142/// not divide every diff, so the common case is bit-for-bit unchanged (any
143/// surviving adjacent pair makes the gcd equal the minimum diff exactly).
144fn grid_step(diffs: &[f64]) -> f64 {
145    let min_diff = diffs.iter().cloned().fold(f64::INFINITY, f64::min);
146    let max_diff = diffs.iter().map(|d| d.abs()).fold(0.0_f64, f64::max);
147    let tol = max_diff * 1e-6;
148
149    let mut g = diffs[0].abs();
150    for &d in &diffs[1..] {
151        let (mut a, mut b) = if g >= d.abs() {
152            (g, d.abs())
153        } else {
154            (d.abs(), g)
155        };
156        // Tolerant Euclid: stop once the remainder is within the noise floor.
157        while b > tol {
158            let r = a % b;
159            a = b;
160            b = r;
161        }
162        g = a;
163    }
164
165    let divides_all = diffs.iter().all(|&d| {
166        let q = d.abs() / g;
167        (q - q.round()).abs() <= 1e-3
168    });
169    if g <= tol || !divides_all {
170        min_diff
171    } else {
172        g
173    }
174}
175
176/// Build an evenly-spaced axis from possibly-uneven input values, flagging the
177/// interpolated gaps as missing channels. Mirrors `even_spacing`.
178pub fn even_spacing(specs: &[f64], time_domain_mode: bool) -> (Vec<f64>, Vec<bool>) {
179    assert!(specs.len() >= 2, "need at least two values to space evenly");
180    let diffs: Vec<f64> = specs.windows(2).map(|w| w[1] - w[0]).collect();
181    let step = grid_step(&diffs);
182    let new_specs = np_arange_fix(specs[0], specs[specs.len() - 1], step);
183    let present = isin_close(&new_specs, specs, time_domain_mode);
184    let missing: Vec<bool> = present.iter().map(|&p| !p).collect();
185    (new_specs, missing)
186}
187
188/// Parse the spectral/temporal axis for a set of input files.
189///
190/// Mirrors `parse_specs_coro`. `spec_list` (the CLI `--specs`) supplies the
191/// per-file values directly; `spec_file` reads them one-per-line from a text
192/// file; otherwise they are read from each header.
193pub fn parse_specs(
194    file_list: &[std::path::PathBuf],
195    spec_file: Option<&Path>,
196    spec_list: Option<&[f64]>,
197    ignore_spec: bool,
198    create_blanks: bool,
199    time_domain_mode: bool,
200) -> Result<SpecInfo> {
201    let n = file_list.len();
202
203    if ignore_spec {
204        tracing::info!("Ignoring frequency/time information");
205        let seq: Vec<f64> = (0..n).map(|i| i as f64).collect();
206        return Ok(SpecInfo {
207            file_specs: seq.clone(),
208            specs: seq,
209            missing: vec![false; n],
210        });
211    }
212
213    if spec_file.is_some() && spec_list.is_some() {
214        return Err(FitsCubeError::InvalidSpec(
215            "Must specify either spec_file or spec_list, not both".to_string(),
216        ));
217    }
218
219    let mut file_specs: Vec<f64> = if let Some(path) = spec_file {
220        tracing::info!("Reading spec values from {}", path.display());
221        let text = std::fs::read_to_string(path)?;
222        let vals: Vec<f64> = text
223            .split_whitespace()
224            .map(|t| t.parse::<f64>())
225            .collect::<std::result::Result<_, _>>()
226            .map_err(|e| FitsCubeError::InvalidSpec(format!("parsing {}: {e}", path.display())))?;
227        if vals.len() != n {
228            return Err(FitsCubeError::InvalidSpec(format!(
229                "Number of values in {} ({}) does not match number of images ({n})",
230                path.display(),
231                vals.len()
232            )));
233        }
234        vals
235    } else if let Some(list) = spec_list {
236        if list.len() != n {
237            return Err(FitsCubeError::InvalidSpec(format!(
238                "Number of --specs values ({}) does not match number of images ({n})",
239                list.len()
240            )));
241        }
242        list.to_vec()
243    } else {
244        file_list
245            .iter()
246            .map(|p| read_spec_from_header(p, time_domain_mode))
247            .collect::<Result<Vec<f64>>>()?
248    };
249
250    let (specs, missing) = if create_blanks {
251        tracing::info!("Creating an evenly-spaced axis with interpolated blanks");
252        even_spacing(&file_specs, time_domain_mode)
253    } else {
254        (file_specs.clone(), vec![false; file_specs.len()])
255    };
256
257    // Keep `file_specs` length == number of files.
258    file_specs.truncate(n);
259
260    Ok(SpecInfo {
261        file_specs,
262        specs,
263        missing,
264    })
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use approx::assert_relative_eq;
271
272    #[test]
273    fn even_axis_has_no_missing() {
274        let specs = vec![1.0e9, 2.0e9, 3.0e9, 4.0e9];
275        let (new, missing) = even_spacing(&specs, false);
276        assert_eq!(new.len(), 4);
277        assert!(missing.iter().all(|&m| !m));
278    }
279
280    #[test]
281    fn gap_is_flagged_missing() {
282        // Missing the 3 GHz channel.
283        let specs = vec![1.0e9, 2.0e9, 4.0e9];
284        let (new, missing) = even_spacing(&specs, false);
285        assert_eq!(new.len(), 4);
286        // The interpolated 3 GHz slot is the only missing one.
287        assert_eq!(missing.iter().filter(|&&m| m).count(), 1);
288        assert!(missing[2]);
289    }
290
291    #[test]
292    fn non_adjacent_gaps_recover_true_step() {
293        // Present channels 0, 3, 5 on a unit grid (1, 2, 4 missing). No two
294        // surviving channels are adjacent, so `min(diffs) == 2` would mis-grid
295        // the axis to [0, 2, 4] and drop the real channels. The gcd of the
296        // diffs [3, 2] recovers the true step of 1.
297        let specs = vec![0.0, 3.0, 5.0];
298        let (new, missing) = even_spacing(&specs, false);
299        assert_eq!(new.len(), 6, "should rebuild the full 0..=5 grid");
300        assert_eq!(missing.iter().filter(|&&m| m).count(), 3);
301        let expected = [false, true, true, false, true, false];
302        assert_eq!(missing, expected);
303    }
304
305    #[test]
306    fn mjdsec_round_number() {
307        // 1858-11-17T00:00:00 is MJD 0.
308        let s = utc_to_mjdsec("1858-11-17T00:00:00").unwrap();
309        assert_relative_eq!(s, 0.0, epsilon = 1e-3);
310    }
311
312    #[test]
313    fn mjdsec_one_day() {
314        let s = utc_to_mjdsec("1858-11-18T00:00:00").unwrap();
315        assert_relative_eq!(s, 86400.0, epsilon = 1e-3);
316    }
317}