1use 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#[derive(Debug, Clone)]
15pub struct SpecInfo {
16 pub file_specs: Vec<f64>,
18 pub specs: Vec<f64>,
21 pub missing: Vec<bool>,
23}
24
25fn 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
37pub 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
52pub 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 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
93pub 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
115fn 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 while v < stop {
129 out.push(v);
130 v += step;
131 }
132 out
133}
134
135fn 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 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
176pub 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
188pub 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 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 let specs = vec![1.0e9, 2.0e9, 4.0e9];
284 let (new, missing) = even_spacing(&specs, false);
285 assert_eq!(new.len(), 4);
286 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 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 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}