Skip to main content

nereids_io/
tof.rs

1//! Time-of-flight to energy conversion for imaging data.
2//!
3//! VENUS beamline data is recorded in time-of-flight bins. This module converts
4//! TOF bin edges/centers to neutron energy using E = ½·m_n·(L/t)².
5//!
6//! The core conversion functions live in `nereids_core::constants`; this module
7//! provides the imaging-specific wrappers for working with TOF bin arrays and
8//! spectra aligned to energy grids.
9
10use ndarray::Array1;
11use nereids_core::constants;
12
13use crate::error::IoError;
14
15/// VENUS beamline parameters for TOF-to-energy conversion.
16#[derive(Debug, Clone, Copy)]
17pub struct BeamlineParams {
18    /// Total flight path in meters (source to detector).
19    pub flight_path_m: f64,
20    /// Delay time in microseconds (electronic/moderator delay).
21    pub delay_us: f64,
22}
23
24impl Default for BeamlineParams {
25    fn default() -> Self {
26        Self {
27            flight_path_m: 25.0, // VENUS nominal flight path
28            delay_us: 0.0,
29        }
30    }
31}
32
33/// Convert an array of TOF bin edges (μs) to energy bin edges (eV).
34///
35/// Energies are returned in descending order (longer TOF = lower energy,
36/// but the array is reversed so energies are ascending for physics routines).
37///
38/// # Arguments
39/// * `tof_edges` — TOF bin edges in microseconds, ascending.
40/// * `params` — Beamline parameters (flight path, delay).
41///
42/// # Returns
43/// Energy bin edges in eV, ascending order.
44pub fn tof_edges_to_energy(
45    tof_edges: &[f64],
46    params: &BeamlineParams,
47) -> Result<Array1<f64>, IoError> {
48    if tof_edges.len() < 2 {
49        return Err(IoError::InvalidParameter(
50            "Need at least 2 TOF bin edges".into(),
51        ));
52    }
53
54    if !params.flight_path_m.is_finite() || params.flight_path_m <= 0.0 {
55        return Err(IoError::InvalidParameter(
56            "Flight path must be finite and positive".into(),
57        ));
58    }
59
60    if !params.delay_us.is_finite() {
61        return Err(IoError::InvalidParameter(
62            "Delay time must be finite".into(),
63        ));
64    }
65
66    let mut energies: Vec<f64> = Vec::with_capacity(tof_edges.len());
67    for (i, &tof) in tof_edges.iter().enumerate() {
68        if !tof.is_finite() {
69            return Err(IoError::InvalidParameter(format!(
70                "TOF edge is non-finite ({}) at index {}",
71                tof, i
72            )));
73        }
74        let corrected_tof = tof - params.delay_us;
75        if !corrected_tof.is_finite() || corrected_tof <= 0.0 {
76            return Err(IoError::InvalidParameter(format!(
77                "TOF edge {:.6} us minus delay {:.6} us is non-positive or non-finite at index {}",
78                tof, params.delay_us, i
79            )));
80        }
81        energies.push(constants::tof_to_energy(
82            corrected_tof,
83            params.flight_path_m,
84        ));
85    }
86
87    // TOF ascending → energy descending. Reverse to get ascending energies.
88    energies.reverse();
89    Ok(Array1::from_vec(energies))
90}
91
92/// Convert TOF bin edges to energy bin centers.
93///
94/// Returns the geometric mean of adjacent energy bin edges, which is
95/// more physically appropriate for log-spaced energy grids.
96///
97/// # Arguments
98/// * `tof_edges` — TOF bin edges in μs, ascending.
99/// * `params` — Beamline parameters.
100///
101/// # Returns
102/// Energy bin centers in eV, ascending order. Length = len(tof_edges) - 1.
103pub fn tof_edges_to_energy_centers(
104    tof_edges: &[f64],
105    params: &BeamlineParams,
106) -> Result<Array1<f64>, IoError> {
107    let edges = tof_edges_to_energy(tof_edges, params)?;
108    let n = edges.len() - 1;
109    let centers: Vec<f64> = (0..n).map(|i| (edges[i] * edges[i + 1]).sqrt()).collect();
110    Ok(Array1::from_vec(centers))
111}
112
113/// Generate linearly-spaced TOF bin edges.
114///
115/// # Arguments
116/// * `tof_min` — Minimum TOF in μs.
117/// * `tof_max` — Maximum TOF in μs.
118/// * `n_bins` — Number of bins (returns n_bins + 1 edges).
119///
120/// # Errors
121/// Returns `IoError::InvalidParameter` if:
122/// - `n_bins` is zero
123/// - `tof_min` or `tof_max` is non-finite (NaN or Inf)
124/// - `tof_min` is negative
125/// - `tof_max <= tof_min`
126pub fn linspace_tof_edges(tof_min: f64, tof_max: f64, n_bins: usize) -> Result<Vec<f64>, IoError> {
127    if n_bins == 0 {
128        return Err(IoError::InvalidParameter("n_bins must be positive".into()));
129    }
130    if !tof_min.is_finite() || !tof_max.is_finite() {
131        return Err(IoError::InvalidParameter(
132            "TOF bounds must be finite".into(),
133        ));
134    }
135    if tof_min < 0.0 {
136        return Err(IoError::InvalidParameter(format!(
137            "tof_min ({}) must be non-negative",
138            tof_min
139        )));
140    }
141    if tof_max <= tof_min {
142        return Err(IoError::InvalidParameter(format!(
143            "tof_max ({}) must be greater than tof_min ({})",
144            tof_max, tof_min
145        )));
146    }
147    let dt = (tof_max - tof_min) / n_bins as f64;
148    Ok((0..=n_bins).map(|i| tof_min + i as f64 * dt).collect())
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn test_tof_to_energy_roundtrip() {
157        let params = BeamlineParams {
158            flight_path_m: 25.0,
159            delay_us: 0.0,
160        };
161
162        // Create TOF edges covering 1-100 eV energy range
163        let e_low = 1.0;
164        let e_high = 100.0;
165        let tof_high = constants::energy_to_tof(e_low, params.flight_path_m);
166        let tof_low = constants::energy_to_tof(e_high, params.flight_path_m);
167
168        let tof_edges = linspace_tof_edges(tof_low, tof_high, 100).unwrap();
169        let energy_edges = tof_edges_to_energy(&tof_edges, &params).unwrap();
170
171        // After reversing: first energy ≈ e_low, last energy ≈ e_high (ascending)
172        assert!(
173            (energy_edges[0] - e_low).abs() / e_low < 0.01,
174            "First energy {} != expected {}",
175            energy_edges[0],
176            e_low,
177        );
178
179        let last = energy_edges[energy_edges.len() - 1];
180        assert!(
181            (last - e_high).abs() / e_high < 0.01,
182            "Last energy {} != expected {}",
183            last,
184            e_high,
185        );
186
187        // Energies should be monotonically decreasing (since TOF edges
188        // were linspaced from low TOF to high TOF → energies go high to low,
189        // then reversed → ascending)
190        // Actually after reverse they should be ascending
191        for i in 1..energy_edges.len() {
192            assert!(
193                energy_edges[i] >= energy_edges[i - 1],
194                "Energies not ascending at index {}: {} < {}",
195                i,
196                energy_edges[i],
197                energy_edges[i - 1],
198            );
199        }
200    }
201
202    #[test]
203    fn test_tof_to_energy_with_delay() {
204        let params_no_delay = BeamlineParams {
205            flight_path_m: 25.0,
206            delay_us: 0.0,
207        };
208        let params_with_delay = BeamlineParams {
209            flight_path_m: 25.0,
210            delay_us: 10.0,
211        };
212
213        let tof_edges = vec![100.0, 200.0, 300.0];
214
215        let e_no_delay = tof_edges_to_energy(&tof_edges, &params_no_delay).unwrap();
216        let e_with_delay = tof_edges_to_energy(&tof_edges, &params_with_delay).unwrap();
217
218        // With delay subtracted, effective TOF is shorter → energies are higher
219        for i in 0..e_no_delay.len() {
220            assert!(
221                e_with_delay[i] >= e_no_delay[i],
222                "Delayed energy should be >= no-delay at index {}",
223                i,
224            );
225        }
226    }
227
228    #[test]
229    fn test_energy_centers_geometric_mean() {
230        let params = BeamlineParams {
231            flight_path_m: 25.0,
232            delay_us: 0.0,
233        };
234        let tof_edges = vec![100.0, 200.0, 300.0, 400.0];
235        let edges = tof_edges_to_energy(&tof_edges, &params).unwrap();
236        let centers = tof_edges_to_energy_centers(&tof_edges, &params).unwrap();
237
238        assert_eq!(centers.len(), edges.len() - 1);
239        for i in 0..centers.len() {
240            let expected = (edges[i] * edges[i + 1]).sqrt();
241            assert!(
242                (centers[i] - expected).abs() < 1e-10,
243                "Center[{}] = {}, expected {}",
244                i,
245                centers[i],
246                expected,
247            );
248        }
249    }
250
251    #[test]
252    fn test_linspace_tof_edges() {
253        let edges = linspace_tof_edges(100.0, 200.0, 5).unwrap();
254        assert_eq!(edges.len(), 6);
255        assert!((edges[0] - 100.0).abs() < 1e-10);
256        assert!((edges[5] - 200.0).abs() < 1e-10);
257        assert!((edges[1] - 120.0).abs() < 1e-10);
258    }
259
260    #[test]
261    fn test_insufficient_edges() {
262        let params = BeamlineParams::default();
263        let result = tof_edges_to_energy(&[100.0], &params);
264        assert!(result.is_err());
265    }
266
267    #[test]
268    fn test_linspace_tof_edges_rejects_inf() {
269        let result = linspace_tof_edges(f64::INFINITY, 200.0, 10);
270        assert!(result.is_err(), "should reject Inf tof_min");
271
272        let result = linspace_tof_edges(100.0, f64::INFINITY, 10);
273        assert!(result.is_err(), "should reject Inf tof_max");
274
275        let result = linspace_tof_edges(f64::NEG_INFINITY, 200.0, 10);
276        assert!(result.is_err(), "should reject -Inf tof_min");
277    }
278
279    #[test]
280    fn test_linspace_tof_edges_rejects_nan() {
281        let result = linspace_tof_edges(f64::NAN, 200.0, 10);
282        assert!(result.is_err(), "should reject NaN tof_min");
283
284        let result = linspace_tof_edges(100.0, f64::NAN, 10);
285        assert!(result.is_err(), "should reject NaN tof_max");
286    }
287
288    #[test]
289    fn test_tof_edges_to_energy_rejects_nan_flight_path() {
290        let tof_edges = vec![100.0, 200.0, 300.0];
291
292        let params_nan = BeamlineParams {
293            flight_path_m: f64::NAN,
294            delay_us: 0.0,
295        };
296        assert!(
297            tof_edges_to_energy(&tof_edges, &params_nan).is_err(),
298            "should reject NaN flight path"
299        );
300
301        let params_inf = BeamlineParams {
302            flight_path_m: f64::INFINITY,
303            delay_us: 0.0,
304        };
305        assert!(
306            tof_edges_to_energy(&tof_edges, &params_inf).is_err(),
307            "should reject Inf flight path"
308        );
309
310        let params_nan_delay = BeamlineParams {
311            flight_path_m: 25.0,
312            delay_us: f64::NAN,
313        };
314        assert!(
315            tof_edges_to_energy(&tof_edges, &params_nan_delay).is_err(),
316            "should reject NaN delay"
317        );
318    }
319
320    #[test]
321    fn test_tof_edges_to_energy_rejects_nan_inf_edges() {
322        let params = BeamlineParams {
323            flight_path_m: 25.0,
324            delay_us: 0.0,
325        };
326
327        // NaN in TOF edges should be rejected (not silently propagate)
328        let nan_edges = vec![100.0, f64::NAN, 300.0];
329        assert!(
330            tof_edges_to_energy(&nan_edges, &params).is_err(),
331            "should reject NaN in TOF edges"
332        );
333
334        // Inf in TOF edges should be rejected
335        let inf_edges = vec![100.0, f64::INFINITY, 300.0];
336        assert!(
337            tof_edges_to_energy(&inf_edges, &params).is_err(),
338            "should reject Inf in TOF edges"
339        );
340
341        // -Inf in TOF edges should be rejected
342        let neg_inf_edges = vec![f64::NEG_INFINITY, 200.0, 300.0];
343        assert!(
344            tof_edges_to_energy(&neg_inf_edges, &params).is_err(),
345            "should reject -Inf in TOF edges"
346        );
347    }
348
349    #[test]
350    fn test_linspace_tof_edges_rejects_zero_bins() {
351        let result = linspace_tof_edges(100.0, 200.0, 0);
352        assert!(result.is_err(), "should reject n_bins == 0");
353    }
354
355    #[test]
356    fn test_linspace_tof_edges_rejects_reversed_range() {
357        let result = linspace_tof_edges(200.0, 100.0, 10);
358        assert!(result.is_err(), "should reject tof_max <= tof_min");
359
360        let result = linspace_tof_edges(100.0, 100.0, 10);
361        assert!(result.is_err(), "should reject tof_max == tof_min");
362    }
363
364    #[test]
365    fn test_linspace_tof_edges_rejects_negative() {
366        let result = linspace_tof_edges(-10.0, 200.0, 10);
367        assert!(result.is_err(), "should reject negative tof_min");
368    }
369}