Skip to main content

nereids_pipeline/
detectability.rs

1//! Trace-detectability analysis for neutron resonance imaging.
2//!
3//! Answers the pre-experiment question: *"Can I detect X ppm of isotope B
4//! in a matrix of isotope(s) A across a given energy window?"*
5//!
6//! ## Core concept
7//!
8//! For a given matrix + trace isotope pair, compute the **peak spectral SNR**
9//! as a function of trace concentration:
10//!
11//!   SNR_peak(c) = max_E |ΔT(E, c)| / σ_noise
12//!
13//! where ΔT is the *signed* transmission difference:
14//!
15//!   ΔT(E, c) = T(E, n_matrix, 0) − T(E, n_matrix, n_trace = c·n_matrix)
16//!
17//! and σ_noise ≈ 1/√I₀ (off-resonance Poisson approximation).
18//!
19//! The stored `delta_t_spectrum` and all derived metrics (peak_delta_t_per_ppm,
20//! peak_snr) use **|ΔT|**, discarding the sign.
21//!
22//! ## Reference
23//!
24//! Motivated by the observation that Fe-56 + Mn-55 have no resolved resonances
25//! in 1–50 eV, while W-182 + Hf-178 give strong contrast in the same window.
26//! VENUS can resolve up to ~1 keV, so many more pairs become accessible at
27//! higher energies (e.g., Mn-55 resonance at ~337 eV).
28
29use nereids_core::elements;
30use nereids_endf::resonance::ResonanceData;
31use nereids_physics::resolution::ResolutionFunction;
32use nereids_physics::transmission::{self, InstrumentParams, SampleParams, TransmissionError};
33use rayon::prelude::*;
34
35use crate::error::PipelineError;
36
37/// Transmission threshold below which a bin is considered opaque.
38///
39/// At `T < 1e-15`, `exp(-n·σ)` is effectively zero in f64 arithmetic.
40/// Any ΔT computation between two opaque transmissions yields 0.0 due to
41/// floating-point underflow, making trace detection impossible regardless
42/// of the actual physics.
43const OPAQUE_THRESHOLD: f64 = 1e-15;
44
45/// Result of a trace-detectability analysis for a single matrix+trace pair.
46#[derive(Debug, Clone)]
47pub struct TraceDetectabilityReport {
48    /// Peak |ΔT| per ppm concentration at the most sensitive energy.
49    ///
50    /// Linearized sensitivity metric: `peak_delta_t / trace_ppm`.
51    /// Accurate in the dilute-trace limit; at high concentrations the
52    /// actual per-ppm sensitivity is smaller due to Beer-Lambert
53    /// saturation (T = exp(−n·σ) is sub-linear in n).
54    pub peak_delta_t_per_ppm: f64,
55    /// Energy at which peak contrast occurs (eV).
56    pub peak_energy_ev: f64,
57    /// Estimated peak SNR at the given concentration and I₀.
58    pub peak_snr: f64,
59    /// Whether the combination is detectable (SNR > threshold).
60    pub detectable: bool,
61    /// Energy-resolved |ΔT| spectrum for the given concentration.
62    pub delta_t_spectrum: Vec<f64>,
63    /// Energies used (eV).
64    pub energies: Vec<f64>,
65    /// Fraction of energy bins where the matrix baseline transmission is
66    /// below `OPAQUE_THRESHOLD` (effectively zero due to underflow).
67    ///
68    /// When this is close to 1.0, the matrix is essentially opaque across
69    /// the entire energy range, and trace detection is physically impossible
70    /// regardless of concentration or I₀.
71    pub opaque_fraction: f64,
72}
73
74/// Configuration for a trace-detectability analysis.
75pub struct TraceDetectabilityConfig<'a> {
76    /// Matrix isotopes with their areal densities in atoms/barn.
77    ///
78    /// Supports multi-isotope matrices (e.g., an alloy). The total matrix
79    /// density (sum of all densities) is used to convert trace ppm to
80    /// areal density: `trace_density = trace_ppm × 1e-6 × total_matrix_density`.
81    pub matrix_isotopes: &'a [(ResonanceData, f64)],
82    /// Energy grid in eV (sorted ascending).
83    pub energies: &'a [f64],
84    /// Expected counts per bin (for Poisson noise estimate).
85    pub i0: f64,
86    /// Sample temperature in Kelvin.
87    pub temperature_k: f64,
88    /// Optional resolution broadening function.
89    pub resolution: Option<&'a ResolutionFunction>,
90    /// Detection threshold (3.0 = standard 3σ detection limit).
91    pub snr_threshold: f64,
92}
93
94/// Build an `InstrumentParams` from the config's optional resolution function.
95fn build_instrument(config: &TraceDetectabilityConfig) -> Option<InstrumentParams> {
96    config.resolution.map(|r| InstrumentParams {
97        resolution: r.clone(),
98    })
99}
100
101/// Total matrix areal density (sum of all matrix isotope densities).
102fn total_matrix_density(config: &TraceDetectabilityConfig) -> f64 {
103    config.matrix_isotopes.iter().map(|(_, d)| d).sum()
104}
105
106/// Compute the matrix-only baseline transmission.
107///
108/// NOTE: `SampleParams` owns its `ResonanceData`, so we clone here.
109/// For typical ENDF isotopes the data is small (a few KB) and the clone
110/// cost is negligible compared to the cross-section evaluation.  If
111/// `SampleParams` gains `Arc` support in the future, these clones can
112/// be eliminated.
113fn matrix_baseline(
114    config: &TraceDetectabilityConfig,
115    instrument: Option<&InstrumentParams>,
116) -> Result<Vec<f64>, TransmissionError> {
117    let isotopes: Vec<_> = config.matrix_isotopes.to_vec();
118    let sample_matrix = SampleParams::new(config.temperature_k, isotopes)
119        .map_err(|e| TransmissionError::InputMismatch(e.to_string()))?;
120    transmission::forward_model(config.energies, &sample_matrix, instrument)
121}
122
123/// Build a report from a precomputed matrix-only baseline and a trace isotope.
124fn report_from_baseline(
125    config: &TraceDetectabilityConfig,
126    instrument: Option<&InstrumentParams>,
127    t_matrix: &[f64],
128    trace: &ResonanceData,
129    trace_ppm: f64,
130) -> Result<TraceDetectabilityReport, TransmissionError> {
131    // T_combined: transmission through matrix + trace
132    let trace_density = trace_ppm * 1e-6 * total_matrix_density(config);
133    let mut isotopes: Vec<_> = config.matrix_isotopes.to_vec();
134    isotopes.push((trace.clone(), trace_density));
135    let sample_combined = SampleParams::new(config.temperature_k, isotopes)
136        .map_err(|e| TransmissionError::InputMismatch(e.to_string()))?;
137    let t_combined = transmission::forward_model(config.energies, &sample_combined, instrument)?;
138
139    // Opaque fraction: bins where matrix baseline underflows to ~0.
140    let n_opaque = t_matrix.iter().filter(|&&t| t < OPAQUE_THRESHOLD).count();
141    let opaque_fraction = if t_matrix.is_empty() {
142        0.0
143    } else {
144        n_opaque as f64 / t_matrix.len() as f64
145    };
146
147    // |ΔT| spectrum — absolute difference, sign discarded (see module docs).
148    let delta_t_spectrum: Vec<f64> = t_matrix
149        .iter()
150        .zip(t_combined.iter())
151        .map(|(&tm, &tc)| (tm - tc).abs())
152        .collect();
153
154    // Find peak |ΔT| and corresponding energy
155    let (peak_idx, &peak_delta_t) = delta_t_spectrum
156        .iter()
157        .enumerate()
158        .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
159        .unwrap_or((0, &0.0));
160
161    let peak_energy_ev = if config.energies.is_empty() {
162        0.0
163    } else {
164        config.energies[peak_idx]
165    };
166
167    // P-8: SNR uses energy-dependent noise σ(E) = √(T_matrix(E) / I₀).
168    //
169    // Previously used the off-resonance approximation σ ≈ 1/√I₀ (T≈1),
170    // which underestimates SNR at resonance dips where T<<1 and noise
171    // is correspondingly smaller.  The energy-dependent formula gives
172    // the correct Poisson counting noise at each bin.
173    //
174    // Peak SNR = max_E |ΔT(E)| / σ(E)  where σ(E) = √(T_matrix(E) / I₀)
175    let peak_snr = if config.i0 > 0.0 {
176        delta_t_spectrum
177            .iter()
178            .zip(t_matrix.iter())
179            .map(|(&dt, &tm)| {
180                let sigma_e = (tm.max(OPAQUE_THRESHOLD) / config.i0).sqrt();
181                dt / sigma_e
182            })
183            .fold(0.0f64, f64::max)
184    } else {
185        0.0
186    };
187
188    // Per-ppm normalisation
189    let peak_delta_t_per_ppm = if trace_ppm > 0.0 {
190        peak_delta_t / trace_ppm
191    } else {
192        0.0
193    };
194
195    Ok(TraceDetectabilityReport {
196        peak_delta_t_per_ppm,
197        peak_energy_ev,
198        peak_snr,
199        detectable: peak_snr > config.snr_threshold,
200        delta_t_spectrum,
201        energies: config.energies.to_vec(),
202        opaque_fraction,
203    })
204}
205
206/// Compute trace-detectability for a single matrix+trace isotope pair.
207///
208/// Two `forward_model` calls (with/without trace) + argmax over the
209/// difference spectrum. Resolution broadening is included when provided,
210/// so the SNR reflects realistic peak spreading at VENUS.
211///
212/// # Arguments
213/// * `config`    — Shared analysis parameters (matrix isotopes, energies, I₀, etc.).
214/// * `trace`     — Resonance data for the trace isotope.
215/// * `trace_ppm` — Trace concentration in ppm by atom.
216///
217/// # Preconditions
218/// * `config.energies` must be non-empty and sorted ascending.
219/// * `config.i0` must be positive (used as `1/√I₀` for noise estimate).
220/// * `config.matrix_isotopes` must be non-empty with positive densities.
221/// * `config.snr_threshold` must be non-negative.
222/// * `trace_ppm` must be non-negative.
223///
224/// The Python bindings validate all of these; Rust callers are responsible
225/// for ensuring valid inputs.
226///
227/// # Returns
228/// [`TraceDetectabilityReport`] with peak SNR, peak energy, detectability
229/// flag, and the full |ΔT| spectrum.
230pub fn trace_detectability(
231    config: &TraceDetectabilityConfig,
232    trace: &ResonanceData,
233    trace_ppm: f64,
234) -> Result<TraceDetectabilityReport, PipelineError> {
235    let instrument = build_instrument(config);
236    let t_matrix = matrix_baseline(config, instrument.as_ref())?;
237    Ok(report_from_baseline(
238        config,
239        instrument.as_ref(),
240        &t_matrix,
241        trace,
242        trace_ppm,
243    )?)
244}
245
246/// Survey multiple trace candidates against a matrix (single or multi-isotope).
247///
248/// The matrix-only baseline transmission and instrument resolution are
249/// computed once and reused for all candidates. Each candidate is then
250/// evaluated in parallel with rayon.
251///
252/// # Preconditions
253/// Same as [`trace_detectability`], plus `trace_candidates` must be
254/// non-empty.
255///
256/// # Returns
257/// Vec of (isotope_name, report) sorted by `peak_snr` descending.
258pub fn trace_detectability_survey(
259    config: &TraceDetectabilityConfig,
260    trace_candidates: &[ResonanceData],
261    trace_ppm: f64,
262) -> Result<Vec<(String, TraceDetectabilityReport)>, PipelineError> {
263    // Build instrument and matrix baseline once for all candidates.
264    let instrument = build_instrument(config);
265    let t_matrix = matrix_baseline(config, instrument.as_ref())?;
266
267    let mut results: Vec<(String, TraceDetectabilityReport)> = trace_candidates
268        .par_iter()
269        .map(|trace| {
270            let name = elements::element_symbol(trace.isotope.z())
271                .map(|sym| format!("{}-{}", sym, trace.isotope.a()))
272                .unwrap_or_else(|| format!("Z{}-{}", trace.isotope.z(), trace.isotope.a()));
273
274            let report =
275                report_from_baseline(config, instrument.as_ref(), &t_matrix, trace, trace_ppm)?;
276
277            Ok((name, report))
278        })
279        .collect::<Result<Vec<_>, TransmissionError>>()?;
280
281    // Sort by peak_snr descending
282    results.sort_by(|(_, a), (_, b)| {
283        b.peak_snr
284            .partial_cmp(&a.peak_snr)
285            .unwrap_or(std::cmp::Ordering::Equal)
286    });
287
288    Ok(results)
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use nereids_endf::resonance::test_support::synthetic_isotope;
295
296    // --- Offline synthetic tests (no network required) ---
297
298    /// Peak selection and SNR computation with synthetic data (offline).
299    #[test]
300    fn test_synthetic_peak_snr() {
301        // Matrix: weak resonance at 20 eV
302        let matrix = synthetic_isotope(74, 182, 20.0, 1e-3, 0.05);
303        // Trace: strong resonance at 8 eV (should dominate the |ΔT| spectrum)
304        let trace = synthetic_isotope(72, 178, 8.0, 0.5, 0.05);
305
306        let energies: Vec<f64> = (0..500).map(|i| 1.0 + (i as f64) * 49.0 / 499.0).collect();
307
308        let config = TraceDetectabilityConfig {
309            matrix_isotopes: &[(matrix.clone(), 6e-3)],
310            energies: &energies,
311            i0: 1000.0,
312            temperature_k: 293.6,
313            resolution: None,
314            snr_threshold: 3.0,
315        };
316
317        let report = trace_detectability(&config, &trace, 1000.0).unwrap();
318
319        // Basic sanity: spectrum has correct length
320        assert_eq!(report.delta_t_spectrum.len(), energies.len());
321        assert_eq!(report.energies.len(), energies.len());
322
323        // Peak should be near the trace resonance at 8 eV (not the matrix at 20 eV)
324        assert!(
325            report.peak_energy_ev > 5.0 && report.peak_energy_ev < 12.0,
326            "Peak energy should be near 8 eV, got {:.1} eV",
327            report.peak_energy_ev,
328        );
329
330        // With a strong trace resonance at 1000 ppm, SNR should be well above 3
331        assert!(
332            report.peak_snr > 3.0,
333            "Expected detectable (SNR > 3), got SNR={:.2}",
334            report.peak_snr,
335        );
336        assert!(report.detectable);
337
338        // peak_delta_t_per_ppm should be positive and consistent
339        assert!(report.peak_delta_t_per_ppm > 0.0);
340    }
341
342    /// Survey sorting with synthetic isotopes (offline).
343    #[test]
344    fn test_synthetic_survey_sorting() {
345        let matrix = synthetic_isotope(74, 182, 20.0, 1e-3, 0.05);
346        // Strong trace (big resonance at 8 eV)
347        let strong_trace = synthetic_isotope(72, 178, 8.0, 0.5, 0.05);
348        // Weak trace (tiny resonance at 30 eV)
349        let weak_trace = synthetic_isotope(26, 56, 30.0, 1e-5, 0.01);
350
351        let energies: Vec<f64> = (0..500).map(|i| 1.0 + (i as f64) * 49.0 / 499.0).collect();
352
353        let config = TraceDetectabilityConfig {
354            matrix_isotopes: &[(matrix.clone(), 6e-3)],
355            energies: &energies,
356            i0: 1000.0,
357            temperature_k: 293.6,
358            resolution: None,
359            snr_threshold: 3.0,
360        };
361
362        // Pass weak first — survey should reorder by SNR descending
363        let results =
364            trace_detectability_survey(&config, &[weak_trace, strong_trace], 500.0).unwrap();
365
366        assert_eq!(results.len(), 2);
367        assert!(
368            results[0].1.peak_snr >= results[1].1.peak_snr,
369            "Survey should sort by SNR descending: {:.2} >= {:.2}",
370            results[0].1.peak_snr,
371            results[1].1.peak_snr,
372        );
373        // The strong isotope (Z=72) should rank first
374        assert!(
375            results[0].0.starts_with("Hf"),
376            "Strong trace (Hf) should rank first, got '{}'",
377            results[0].0,
378        );
379    }
380
381    /// Multi-matrix baseline: two matrix isotopes produce a deeper baseline
382    /// than either alone, reducing trace detectability.
383    #[test]
384    fn test_multi_matrix_baseline() {
385        // Two matrix isotopes with resonances at different energies
386        let matrix_a = synthetic_isotope(74, 182, 20.0, 1e-3, 0.05);
387        let matrix_b = synthetic_isotope(26, 56, 35.0, 2e-3, 0.04);
388        let trace = synthetic_isotope(72, 178, 8.0, 0.5, 0.05);
389
390        let energies: Vec<f64> = (0..500).map(|i| 1.0 + (i as f64) * 49.0 / 499.0).collect();
391
392        // Single-matrix config
393        let config_single = TraceDetectabilityConfig {
394            matrix_isotopes: &[(matrix_a.clone(), 6e-3)],
395            energies: &energies,
396            i0: 1000.0,
397            temperature_k: 293.6,
398            resolution: None,
399            snr_threshold: 3.0,
400        };
401
402        // Multi-matrix config (same total density split across two isotopes)
403        let config_multi = TraceDetectabilityConfig {
404            matrix_isotopes: &[(matrix_a, 3e-3), (matrix_b, 3e-3)],
405            energies: &energies,
406            i0: 1000.0,
407            temperature_k: 293.6,
408            resolution: None,
409            snr_threshold: 3.0,
410        };
411
412        let report_single = trace_detectability(&config_single, &trace, 1000.0).unwrap();
413        let report_multi = trace_detectability(&config_multi, &trace, 1000.0).unwrap();
414
415        // Both should produce valid spectra of the correct length
416        assert_eq!(report_single.delta_t_spectrum.len(), energies.len());
417        assert_eq!(report_multi.delta_t_spectrum.len(), energies.len());
418
419        // Multi-matrix should still detect the trace (strong resonance at 8 eV)
420        assert!(
421            report_multi.peak_snr > 0.0,
422            "Multi-matrix SNR should be positive"
423        );
424
425        // The two configs produce different SNR values (different baselines)
426        assert!(
427            (report_single.peak_snr - report_multi.peak_snr).abs() > 1e-10,
428            "Single vs multi-matrix should produce different SNR values"
429        );
430    }
431
432    /// Opaque matrix: extremely high density causes exp(-n·σ) underflow.
433    ///
434    /// Reproduces #278: 1.0 at/barn matrix with a strong resonance gives
435    /// T ≈ 0 (underflow), so ΔT = 0 and SNR = 0. The report should flag
436    /// the matrix as opaque via `opaque_fraction > 0.5`.
437    #[test]
438    fn test_opaque_matrix_flags_high_opaque_fraction() {
439        // Matrix with a strong resonance at 20 eV and very high density (1.0 at/barn)
440        let matrix = synthetic_isotope(74, 182, 20.0, 0.5, 0.05);
441        let trace = synthetic_isotope(72, 178, 8.0, 0.5, 0.05);
442
443        let energies: Vec<f64> = (0..500).map(|i| 1.0 + (i as f64) * 49.0 / 499.0).collect();
444
445        let config = TraceDetectabilityConfig {
446            matrix_isotopes: &[(matrix, 1.0)], // 1.0 at/barn — extremely thick
447            energies: &energies,
448            i0: 10000.0,
449            temperature_k: 293.6,
450            resolution: None,
451            snr_threshold: 3.0,
452        };
453
454        let report = trace_detectability(&config, &trace, 2350.0).unwrap();
455
456        // With such a thick matrix, most bins should be opaque
457        assert!(
458            report.opaque_fraction > 0.5,
459            "Expected opaque_fraction > 0.5 for 1.0 at/barn matrix, got {:.3}",
460            report.opaque_fraction,
461        );
462
463        // SNR should be 0 or very close to 0 due to underflow
464        assert!(
465            report.peak_snr < 1.0,
466            "Expected near-zero SNR for opaque matrix, got {:.2}",
467            report.peak_snr,
468        );
469    }
470
471    /// Normal density: opaque_fraction should be 0 or very small.
472    #[test]
473    fn test_normal_density_not_opaque() {
474        let matrix = synthetic_isotope(74, 182, 20.0, 1e-3, 0.05);
475        let trace = synthetic_isotope(72, 178, 8.0, 0.5, 0.05);
476
477        let energies: Vec<f64> = (0..500).map(|i| 1.0 + (i as f64) * 49.0 / 499.0).collect();
478
479        let config = TraceDetectabilityConfig {
480            matrix_isotopes: &[(matrix, 6e-3)], // typical thin sample
481            energies: &energies,
482            i0: 1000.0,
483            temperature_k: 293.6,
484            resolution: None,
485            snr_threshold: 3.0,
486        };
487
488        let report = trace_detectability(&config, &trace, 1000.0).unwrap();
489
490        assert!(
491            report.opaque_fraction < 0.01,
492            "Expected opaque_fraction < 0.01 for normal density, got {:.3}",
493            report.opaque_fraction,
494        );
495    }
496}