Skip to main content

nereids_io/
spectrum.rs

1//! Spectrum file parser for TOF/energy bin edges or centers.
2//!
3//! Parses CSV/TXT files containing TOF or energy values that define the
4//! spectral bins of a neutron imaging dataset.
5//!
6//! ## Supported formats
7//! - Single-column: one value per line
8//! - Two-column (CSV/TSV): first column used, rest ignored
9//! - Comment lines starting with `#` are skipped
10//! - First non-comment line skipped if it cannot be parsed as a number (header)
11//!
12//! ## VENUS `*_Spectra.txt` sidecars
13//!
14//! Autoreduced VENUS TIFF folders ship a `<run>_Spectra.txt` sidecar whose
15//! first column is each frame's *start time in seconds* (N rows for N
16//! frames; the second column is counts).  [`read_tof_sidecar`] converts it
17//! to the N+1 ascending TOF bin edges **in microseconds** that
18//! [`crate::tof::tof_edges_to_energy_centers`] expects.  The start-time =
19//! left-bin-edge semantics is established from measured autoreduce output
20//! (see [`parse_tof_sidecar_text`] for the evidence and for how NEREIDS
21//! differs from PLEIADES here).
22
23use std::path::Path;
24
25use crate::error::IoError;
26
27/// Microseconds per second — sidecar start times are recorded in seconds,
28/// while every NEREIDS TOF axis is in microseconds.
29pub const MICROSECONDS_PER_SECOND: f64 = 1e6;
30
31/// Read a VENUS `*_Spectra.txt` TOF sidecar into bin edges (µs).
32///
33/// See [`parse_tof_sidecar_text`] for format semantics and validation.
34///
35/// # Arguments
36/// * `path`     — Path to the sidecar file.
37/// * `n_frames` — When `Some(n)`, the resulting edge count is validated
38///   against the TIFF stack's frame count (`n + 1` edges for `n` frames).
39pub fn read_tof_sidecar(path: &Path, n_frames: Option<usize>) -> Result<Vec<f64>, IoError> {
40    let content = std::fs::read_to_string(path)
41        .map_err(|e| IoError::FileNotFound(path.to_string_lossy().into_owned(), e))?;
42    parse_tof_sidecar_text(&content, n_frames)
43}
44
45/// Parse VENUS `*_Spectra.txt` sidecar text into TOF bin edges (µs).
46///
47/// Format: CSV `shutter_time,counts` where column 0 is the frame **start
48/// time in seconds** — one row per TOF frame.  Comment lines (`#`), blank
49/// lines, and a single header row are tolerated (the
50/// [`parse_spectrum_text`] rules).
51///
52/// Processing:
53/// 1. start times must be finite, the first must be `>= 0`, and the
54///    sequence must be strictly increasing;
55/// 2. values are converted to microseconds ([`MICROSECONDS_PER_SECOND`]);
56/// 3. the closing edge of the last frame is synthesized by extrapolating
57///    the *last* frame width (`last + (last − prev)`), yielding N+1
58///    ascending edges for N rows.
59///
60/// Bin **uniformity is deliberately not enforced**: VENUS MCP shutter
61/// segments change the frame width mid-run, so a sidecar with several
62/// distinct widths is valid.  The last-segment-width extrapolation is
63/// exact whenever the final two frames belong to the same shutter segment
64/// (always the case in practice — segments are many frames long).
65///
66/// ## `shutter_time` is the frame START (left bin edge) — evidence
67///
68/// Verified on measured IPTS-37432 VENUS autoreduce output (OB run 19385,
69/// chunk id 116): the sidecar holds 4053 rows (exactly one per TIFF
70/// frame), starting at 1.12 µs — *not* zero; the autoreduce already drops
71/// the pre-trigger bins — in uniform 160 ns steps, and **every time value
72/// is an exact integer multiple of the 160 ns bin width**
73/// (1.12 µs = 7 × 0.16 µs).  Bin *centers* would sit at half-multiples,
74/// so `shutter_time` is definitively the frame start / left bin edge.
75/// PLEIADES's sidecar helper instead uses the values directly as frame
76/// TOFs, which differs from the true bin centers by half a bin width;
77/// NEREIDS uses edges here plus geometric-mean centers
78/// ([`crate::tof::tof_edges_to_energy_centers`]).  A constant offset of
79/// this kind is absorbed by the fitted t₀ in the energy-scale fit.
80///
81/// ## First edge exactly 0
82///
83/// A sidecar whose first start time is exactly 0 s parses successfully
84/// (0 is a valid TOF edge), but that edge cannot be energy-converted —
85/// E is undefined at t = 0.  Crop the first frame from **both** the
86/// stack and the edges (`stack[1:]`, `edges[1:]`) before conversion.
87/// Real autoreduce sidecars start after the pre-trigger bins, so this
88/// only arises for hand-made files.
89///
90/// The returned edges plug directly into
91/// [`crate::tof::tof_edges_to_energy_centers`].
92///
93/// # Errors
94/// [`IoError::InvalidParameter`] on fewer than 2 rows, non-finite or
95/// unparseable values, a negative first start time, a non-increasing
96/// sequence, or (when `n_frames` is `Some`) an edge/frame count mismatch.
97pub fn parse_tof_sidecar_text(text: &str, n_frames: Option<usize>) -> Result<Vec<f64>, IoError> {
98    let starts_s = parse_spectrum_text(text)?;
99    if starts_s[0] < 0.0 {
100        return Err(IoError::InvalidParameter(format!(
101            "TOF sidecar start times must be >= 0 s, but the first is {}",
102            starts_s[0],
103        )));
104    }
105    validate_monotonic(&starts_s)?;
106
107    let mut edges: Vec<f64> = Vec::with_capacity(starts_s.len() + 1);
108    edges.extend(starts_s.iter().map(|s| s * MICROSECONDS_PER_SECOND));
109    // parse_spectrum_text guarantees >= 2 values, so [n-2] is in bounds.
110    let last = edges[edges.len() - 1];
111    let last_width = last - edges[edges.len() - 2];
112    edges.push(last + last_width);
113
114    if let Some(frames) = n_frames {
115        validate_spectrum_frame_count(edges.len(), frames, SpectrumValueKind::BinEdges)?;
116    }
117    Ok(edges)
118}
119
120/// Whether spectrum values represent TOF or energy.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum SpectrumUnit {
123    /// Values are TOF bin edges/centers in microseconds.
124    TofMicroseconds,
125    /// Values are energy bin edges/centers in eV.
126    EnergyEv,
127}
128
129/// Whether values are bin edges (N+1 for N bins) or bin centers (N for N bins).
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum SpectrumValueKind {
132    /// N+1 values defining the boundaries of N bins.
133    BinEdges,
134    /// N values at the center of each bin.
135    BinCenters,
136}
137
138/// Parse a spectrum file from disk.
139///
140/// Returns the first column of numeric values, skipping comment and header lines.
141/// Supports comma, tab, and whitespace as delimiters.
142///
143/// # Assumptions
144///
145/// - **Column semantics**: only the first numeric column is extracted; any
146///   additional columns (e.g., counts, intensity) are silently ignored.
147/// - **Units are not inferred**: the caller must know whether values represent
148///   TOF in microseconds or energy in eV and set [`SpectrumUnit`] accordingly.
149/// - **Malformed lines**: comment lines (`#`-prefixed) and blank lines are
150///   skipped.  The first non-comment, non-numeric line is treated as a header
151///   and skipped; a second such line is a hard error.
152/// - **Non-finite values** (NaN, Inf) produce a hard error.
153/// - **Minimum length**: at least 2 values are required.
154pub fn parse_spectrum_file(path: &Path) -> Result<Vec<f64>, IoError> {
155    let content = std::fs::read_to_string(path)
156        .map_err(|e| IoError::FileNotFound(path.to_string_lossy().into_owned(), e))?;
157    parse_spectrum_text(&content)
158}
159
160/// Parse spectrum values from a string.
161///
162/// Extracts the first numeric column. Lines starting with `#` are comments.
163/// The first non-comment line that cannot be parsed as a number is treated
164/// as a header and skipped (only one such line is allowed).
165pub fn parse_spectrum_text(text: &str) -> Result<Vec<f64>, IoError> {
166    let mut values = Vec::new();
167    let mut skipped_header = false;
168
169    for line in text.lines() {
170        let trimmed = line.trim();
171        if trimmed.is_empty() || trimmed.starts_with('#') {
172            continue;
173        }
174        // Extract first token (split by comma, tab, or whitespace)
175        let first_token = trimmed
176            .split(|c: char| c == ',' || c == '\t' || c.is_ascii_whitespace())
177            .next()
178            .unwrap_or("")
179            .trim();
180
181        match first_token.parse::<f64>() {
182            Ok(val) => {
183                if !val.is_finite() {
184                    return Err(IoError::InvalidParameter(format!(
185                        "Non-finite value in spectrum file: {}",
186                        val
187                    )));
188                }
189                values.push(val);
190            }
191            Err(_) => {
192                if !skipped_header && values.is_empty() {
193                    skipped_header = true;
194                    continue;
195                }
196                return Err(IoError::InvalidParameter(format!(
197                    "Unparseable value in spectrum file: '{}'",
198                    first_token
199                )));
200            }
201        }
202    }
203
204    if values.len() < 2 {
205        return Err(IoError::InvalidParameter(
206            "Spectrum file must contain at least 2 values".into(),
207        ));
208    }
209
210    Ok(values)
211}
212
213/// Validate that spectrum values are compatible with the TIFF frame count.
214///
215/// For bin edges: `n_values == n_frames + 1`.
216/// For bin centers: `n_values == n_frames`.
217pub fn validate_spectrum_frame_count(
218    n_values: usize,
219    n_frames: usize,
220    kind: SpectrumValueKind,
221) -> Result<(), IoError> {
222    let expected = match kind {
223        SpectrumValueKind::BinEdges => n_frames + 1,
224        SpectrumValueKind::BinCenters => n_frames,
225    };
226    if n_values != expected {
227        return Err(IoError::InvalidParameter(format!(
228            "Spectrum has {} values but TIFF has {} frames (expected {} for {:?})",
229            n_values, n_frames, expected, kind,
230        )));
231    }
232    Ok(())
233}
234
235/// Validate that values are strictly monotonically increasing.
236pub fn validate_monotonic(values: &[f64]) -> Result<(), IoError> {
237    for window in values.windows(2) {
238        match window[0].partial_cmp(&window[1]) {
239            Some(std::cmp::Ordering::Less) => {} // strictly increasing — OK
240            _ => {
241                // Equal, decreasing, or NaN (partial_cmp returns None)
242                return Err(IoError::InvalidParameter(format!(
243                    "Spectrum values must be strictly increasing, but found {} >= {}",
244                    window[0], window[1],
245                )));
246            }
247        }
248    }
249    Ok(())
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_parse_single_column() {
258        let text = "1000.0\n2000.0\n3000.0\n4000.0\n";
259        let values = parse_spectrum_text(text).unwrap();
260        assert_eq!(values, vec![1000.0, 2000.0, 3000.0, 4000.0]);
261    }
262
263    #[test]
264    fn test_parse_two_column_csv() {
265        let text = "1000.0,0.5\n2000.0,0.6\n3000.0,0.7\n";
266        let values = parse_spectrum_text(text).unwrap();
267        assert_eq!(values, vec![1000.0, 2000.0, 3000.0]);
268    }
269
270    #[test]
271    fn test_parse_whitespace_separated() {
272        let text = "1000.0  0.5\n2000.0  0.6\n3000.0  0.7\n";
273        let values = parse_spectrum_text(text).unwrap();
274        assert_eq!(values, vec![1000.0, 2000.0, 3000.0]);
275    }
276
277    #[test]
278    fn test_parse_comments_and_header() {
279        let text = "\
280# This is a comment
281# Another comment
282TOF_us, intensity
2831000.0, 0.5
2842000.0, 0.6
2853000.0, 0.7
286";
287        let values = parse_spectrum_text(text).unwrap();
288        assert_eq!(values, vec![1000.0, 2000.0, 3000.0]);
289    }
290
291    #[test]
292    fn test_parse_tab_separated() {
293        let text = "1000.0\t0.5\n2000.0\t0.6\n3000.0\t0.7\n";
294        let values = parse_spectrum_text(text).unwrap();
295        assert_eq!(values, vec![1000.0, 2000.0, 3000.0]);
296    }
297
298    #[test]
299    fn test_parse_empty_lines_ignored() {
300        let text = "\n1000.0\n\n2000.0\n\n3000.0\n\n";
301        let values = parse_spectrum_text(text).unwrap();
302        assert_eq!(values, vec![1000.0, 2000.0, 3000.0]);
303    }
304
305    #[test]
306    fn test_parse_too_few_values() {
307        let text = "1000.0\n";
308        let result = parse_spectrum_text(text);
309        assert!(result.is_err());
310        assert!(
311            format!("{}", result.unwrap_err()).contains("at least 2"),
312            "Expected 'at least 2' error"
313        );
314    }
315
316    #[test]
317    fn test_parse_non_finite_value() {
318        let text = "1000.0\nNaN\n3000.0\n";
319        let result = parse_spectrum_text(text);
320        assert!(result.is_err());
321        assert!(
322            format!("{}", result.unwrap_err()).contains("Non-finite"),
323            "Expected non-finite error"
324        );
325    }
326
327    #[test]
328    fn test_parse_unparseable_after_data() {
329        let text = "1000.0\n2000.0\nbad_value\n";
330        let result = parse_spectrum_text(text);
331        assert!(result.is_err());
332        assert!(
333            format!("{}", result.unwrap_err()).contains("Unparseable"),
334            "Expected unparseable error"
335        );
336    }
337
338    #[test]
339    fn test_validate_frame_count_edges() {
340        // 5 frames need 6 edges
341        assert!(validate_spectrum_frame_count(6, 5, SpectrumValueKind::BinEdges).is_ok());
342        assert!(validate_spectrum_frame_count(5, 5, SpectrumValueKind::BinEdges).is_err());
343        assert!(validate_spectrum_frame_count(7, 5, SpectrumValueKind::BinEdges).is_err());
344    }
345
346    #[test]
347    fn test_validate_frame_count_centers() {
348        // 5 frames need 5 centers
349        assert!(validate_spectrum_frame_count(5, 5, SpectrumValueKind::BinCenters).is_ok());
350        assert!(validate_spectrum_frame_count(6, 5, SpectrumValueKind::BinCenters).is_err());
351    }
352
353    #[test]
354    fn test_validate_monotonic_ok() {
355        assert!(validate_monotonic(&[1.0, 2.0, 3.0, 4.0]).is_ok());
356    }
357
358    #[test]
359    fn test_validate_monotonic_equal() {
360        let result = validate_monotonic(&[1.0, 2.0, 2.0, 4.0]);
361        assert!(result.is_err());
362    }
363
364    #[test]
365    fn test_validate_monotonic_decreasing() {
366        let result = validate_monotonic(&[1.0, 3.0, 2.0, 4.0]);
367        assert!(result.is_err());
368    }
369
370    #[test]
371    fn test_validate_monotonic_nan() {
372        let result = validate_monotonic(&[1.0, f64::NAN, 3.0]);
373        assert!(result.is_err(), "NaN should fail monotonicity check");
374    }
375
376    #[test]
377    fn test_parse_spectrum_file_not_found() {
378        let result = parse_spectrum_file(Path::new("/nonexistent/spectrum.csv"));
379        assert!(result.is_err());
380    }
381
382    /// T23 (primary fixture): evidence-shaped VENUS sidecar mirroring the
383    /// structure of measured IPTS-37432 autoreduce output (OB run 19385):
384    /// header `shutter_time,counts`, first start time 1.12e-6 s — NOT
385    /// zero; the autoreduce drops the pre-trigger bins — and uniform
386    /// 1.6e-7 s (160 ns) steps, every value an exact integer multiple of
387    /// the bin width (1.12 µs = 7 × 0.16 µs).  Counts are synthetic.
388    /// 3 rows become 4 ascending µs edges.
389    #[test]
390    fn test_sidecar_evidence_shaped_edges() {
391        let text = "shutter_time,counts\n1.12e-6,100\n1.28e-6,200\n1.44e-6,300\n";
392        let edges = parse_tof_sidecar_text(text, None).unwrap();
393        assert_eq!(edges.len(), 3 + 1);
394        // Seconds → µs uses exactly this multiplication, so the first
395        // edge is bit-identical by construction.
396        assert_eq!(edges[0], 1.12e-6 * MICROSECONDS_PER_SECOND);
397        let expected = [1.12, 1.28, 1.44, 1.60];
398        for (edge, want) in edges.iter().zip(expected.iter()) {
399            assert!(
400                (edge - want).abs() < 1e-9,
401                "edge {} != expected {}",
402                edge,
403                want,
404            );
405        }
406    }
407
408    /// T23b: binary-exact start times convert to bit-exact µs edges
409    /// (pins the conversion constant and the synthesized closing edge).
410    #[test]
411    fn test_sidecar_three_rows_exact_edges() {
412        let text = "0.5,100\n1.0,200\n1.5,300\n";
413        let edges = parse_tof_sidecar_text(text, None).unwrap();
414        assert_eq!(
415            edges,
416            vec![500_000.0, 1_000_000.0, 1_500_000.0, 2_000_000.0]
417        );
418        assert_eq!(edges.len(), 3 + 1);
419    }
420
421    /// T24: a header row is tolerated (one non-numeric first line).
422    #[test]
423    fn test_sidecar_header_row_tolerated() {
424        let text = "shutter_time,counts\n0.5,100\n1.0,200\n1.5,300\n";
425        let edges = parse_tof_sidecar_text(text, None).unwrap();
426        assert_eq!(edges.len(), 4);
427        assert_eq!(edges[0], 500_000.0);
428    }
429
430    /// T25: n_frames validation — matching count passes, mismatch errors.
431    #[test]
432    fn test_sidecar_frame_count_validation() {
433        let text = "0.5,100\n1.0,200\n1.5,300\n";
434        assert!(parse_tof_sidecar_text(text, Some(3)).is_ok());
435        let err = parse_tof_sidecar_text(text, Some(4)).unwrap_err();
436        assert!(
437            matches!(err, IoError::InvalidParameter(_)),
438            "Expected InvalidParameter, got: {:?}",
439            err,
440        );
441    }
442
443    /// T26: non-monotonic start times are rejected.
444    #[test]
445    fn test_sidecar_non_monotonic_rejected() {
446        let text = "0.5,100\n1.5,200\n1.0,300\n";
447        assert!(parse_tof_sidecar_text(text, None).is_err());
448    }
449
450    /// T27: a NaN row is rejected.
451    #[test]
452    fn test_sidecar_nan_rejected() {
453        let text = "0.5,100\nNaN,200\n1.5,300\n";
454        assert!(parse_tof_sidecar_text(text, None).is_err());
455    }
456
457    /// T28: a single row cannot define a bin width — rejected.
458    #[test]
459    fn test_sidecar_single_row_rejected() {
460        let text = "0.5,100\n";
461        assert!(parse_tof_sidecar_text(text, None).is_err());
462    }
463
464    /// T29: a negative first start time is rejected.
465    #[test]
466    fn test_sidecar_negative_first_start_rejected() {
467        let text = "-0.5,100\n0.5,200\n1.0,300\n";
468        let err = parse_tof_sidecar_text(text, None).unwrap_err();
469        assert!(
470            format!("{}", err).contains(">= 0"),
471            "Expected >= 0 message, got: {}",
472            err,
473        );
474    }
475
476    /// T30: non-uniform shutter segments (64 µs then 128 µs frames) are
477    /// accepted, and the synthesized final edge extrapolates the *last*
478    /// segment's width.
479    ///
480    /// This fixture also pins that a first start time of exactly 0 s
481    /// *parses successfully* — 0 is a valid TOF edge.  Callers must crop
482    /// the first frame from both the stack and the edges (`stack[1:]`,
483    /// `edges[1:]`) before energy conversion, since E is undefined at
484    /// t = 0 (see [`parse_tof_sidecar_text`]).  Real autoreduce sidecars
485    /// start after the pre-trigger bins (T23), so 0-start files are
486    /// hand-made.
487    #[test]
488    fn test_sidecar_shutter_segments_last_width_extrapolation() {
489        // Starts (s): 0, 64 µs, 192 µs — widths 64 µs then 128 µs.
490        let text = "0.0,10\n0.000064,20\n0.000192,30\n";
491        let edges = parse_tof_sidecar_text(text, None).unwrap();
492        assert_eq!(edges.len(), 4);
493        // The synthesized edge uses exactly the last frame width
494        // (edges[2] - edges[1]), not the first segment's 64 µs.
495        assert_eq!(edges[3], edges[2] + (edges[2] - edges[1]));
496        let expected = [0.0, 64.0, 192.0, 320.0];
497        for (edge, want) in edges.iter().zip(expected.iter()) {
498            assert!(
499                (edge - want).abs() < 1e-9,
500                "edge {} != expected {}",
501                edge,
502                want,
503            );
504        }
505    }
506
507    /// T31: a missing sidecar file surfaces as FileNotFound.
508    #[test]
509    fn test_sidecar_missing_file() {
510        let err = read_tof_sidecar(Path::new("/nonexistent/run_Spectra.txt"), None).unwrap_err();
511        assert!(
512            matches!(err, IoError::FileNotFound(..)),
513            "Expected FileNotFound, got: {:?}",
514            err,
515        );
516    }
517}