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}