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::{LGroup, Resonance, ResonanceFormalism, ResonanceRange};
295    use nereids_endf::retrieval::{EndfLibrary, EndfRetriever, mat_number};
296
297    /// Helper: load real ENDF data for a given (Z, A).
298    fn load_endf_data(z: u32, a: u32) -> ResonanceData {
299        use nereids_core::types::Isotope;
300        use nereids_endf::parser::parse_endf_file2;
301
302        let isotope = Isotope::new(z, a).unwrap();
303        let mat = mat_number(&isotope).unwrap_or_else(|| {
304            panic!("No MAT number for Z={z} A={a}");
305        });
306        let retriever = EndfRetriever::new();
307        let (_path, contents) = retriever
308            .get_endf_file(&isotope, EndfLibrary::EndfB8_1, mat)
309            .unwrap_or_else(|e| panic!("Failed to load ENDF for Z={z} A={a}: {e}"));
310        parse_endf_file2(&contents)
311            .unwrap_or_else(|e| panic!("Failed to parse ENDF for Z={z} A={a}: {e}"))
312    }
313
314    /// Fe-56 + Mn-55 in 1–50 eV: no resolved resonances → NOT detectable at 2000 ppm.
315    #[test]
316    #[ignore = "requires network: downloads ENDF data from IAEA"]
317    fn test_fe56_mn55_narrow_window_not_detectable() {
318        let fe56 = load_endf_data(26, 56);
319        let mn55 = load_endf_data(25, 55);
320
321        let energies: Vec<f64> = (0..5000)
322            .map(|i| 1.0 + (i as f64) * 49.0 / 4999.0)
323            .collect();
324
325        let config = TraceDetectabilityConfig {
326            matrix_isotopes: &[(fe56, 8.47e-3)], // atoms/barn (1 mm Fe)
327            energies: &energies,
328            i0: 1000.0,
329            temperature_k: 293.6,
330            resolution: None,
331            snr_threshold: 3.0,
332        };
333
334        let report = trace_detectability(&config, &mn55, 2000.0).unwrap();
335
336        assert!(
337            !report.detectable,
338            "Fe-56 + Mn-55 should NOT be detectable in 1–50 eV at 2000 ppm (SNR={:.4})",
339            report.peak_snr,
340        );
341    }
342
343    /// W-182 + Hf-178 in 1–5 eV: Hf-178 resonance at ~7.8 eV is outside window → NOT detectable.
344    ///
345    /// Demonstrates that a narrow window can miss resonances.
346    /// Compare with `test_w182_hf178_wide_detectable` below.
347    #[test]
348    #[ignore = "requires network: downloads ENDF data from IAEA"]
349    fn test_w182_hf178_narrow_not_detectable() {
350        let w182 = load_endf_data(74, 182);
351        let hf178 = load_endf_data(72, 178);
352
353        let energies: Vec<f64> = (0..5000).map(|i| 1.0 + (i as f64) * 4.0 / 4999.0).collect();
354
355        let config = TraceDetectabilityConfig {
356            matrix_isotopes: &[(w182, 6.38e-3)],
357            energies: &energies,
358            i0: 1000.0,
359            temperature_k: 293.6,
360            resolution: None,
361            snr_threshold: 3.0,
362        };
363
364        let report = trace_detectability(&config, &hf178, 500.0).unwrap();
365
366        assert!(
367            !report.detectable,
368            "W-182 + Hf-178 should NOT be detectable in 1–5 eV at 500 ppm (SNR={:.4})",
369            report.peak_snr,
370        );
371    }
372
373    /// W-182 + Hf-178 in 1–50 eV: Hf-178 resonance at ~7.8 eV is now in range → detectable.
374    ///
375    /// The wider window includes the Hf-178 resonance, enabling detection that was
376    /// impossible in the 1–5 eV window. This is the core VENUS use case.
377    #[test]
378    #[ignore = "requires network: downloads ENDF data from IAEA"]
379    fn test_w182_hf178_wide_detectable() {
380        let w182 = load_endf_data(74, 182);
381        let hf178 = load_endf_data(72, 178);
382
383        let energies: Vec<f64> = (0..5000)
384            .map(|i| 1.0 + (i as f64) * 49.0 / 4999.0)
385            .collect();
386
387        let config = TraceDetectabilityConfig {
388            matrix_isotopes: &[(w182, 6.38e-3)], // atoms/barn (1 mm W)
389            energies: &energies,
390            i0: 1000.0,
391            temperature_k: 293.6,
392            resolution: None,
393            snr_threshold: 3.0,
394        };
395
396        let report = trace_detectability(&config, &hf178, 500.0).unwrap();
397
398        assert!(
399            report.detectable,
400            "W-182 + Hf-178 should be detectable in 1–50 eV at 500 ppm (SNR={:.4})",
401            report.peak_snr,
402        );
403        // Hf-178 has a strong resonance near 7.8 eV
404        assert!(
405            report.peak_energy_ev > 5.0 && report.peak_energy_ev < 15.0,
406            "Peak energy should be near the Hf-178 resonance (~7.8 eV), got {:.1} eV",
407            report.peak_energy_ev,
408        );
409    }
410
411    #[test]
412    #[ignore = "requires network: downloads ENDF data from IAEA"]
413    fn test_survey_returns_sorted_by_snr() {
414        let w182 = load_endf_data(74, 182);
415        let hf178 = load_endf_data(72, 178);
416        let fe56 = load_endf_data(26, 56);
417
418        let energies: Vec<f64> = (0..5000)
419            .map(|i| 1.0 + (i as f64) * 49.0 / 4999.0)
420            .collect();
421
422        let config = TraceDetectabilityConfig {
423            matrix_isotopes: &[(w182, 6.38e-3)],
424            energies: &energies,
425            i0: 1000.0,
426            temperature_k: 293.6,
427            resolution: None,
428            snr_threshold: 3.0,
429        };
430
431        // Hf-178 has strong resonances in 1-50 eV; Fe-56 does not
432        let results = trace_detectability_survey(&config, &[fe56, hf178], 500.0).unwrap();
433
434        assert_eq!(results.len(), 2);
435        // Results should be sorted by peak_snr descending
436        assert!(
437            results[0].1.peak_snr >= results[1].1.peak_snr,
438            "Survey results should be sorted by SNR descending: {} >= {}",
439            results[0].1.peak_snr,
440            results[1].1.peak_snr,
441        );
442        // Hf-178 should be first (higher SNR)
443        assert_eq!(
444            results[0].0, "Hf-178",
445            "Hf-178 should rank highest, got '{}'",
446            results[0].0,
447        );
448    }
449
450    // --- Offline synthetic tests (no network required) ---
451
452    /// Build a minimal single-resonance isotope for offline testing.
453    fn synthetic_isotope(z: u32, a: u32, res_energy: f64, gn: f64, gg: f64) -> ResonanceData {
454        ResonanceData {
455            isotope: nereids_core::types::Isotope::new(z, a).unwrap(),
456            za: z * 1000 + a,
457            awr: a as f64 - 0.009, // approximate
458            ranges: vec![ResonanceRange {
459                energy_low: 1e-5,
460                energy_high: 1e4,
461                resolved: true,
462                formalism: ResonanceFormalism::ReichMoore,
463                target_spin: 0.0,
464                scattering_radius: 6.0,
465                naps: 1,
466                l_groups: vec![LGroup {
467                    l: 0,
468                    awr: a as f64 - 0.009,
469                    apl: 0.0,
470                    qx: 0.0,
471                    lrx: 0,
472                    resonances: vec![Resonance {
473                        energy: res_energy,
474                        j: 0.5,
475                        gn,
476                        gg,
477                        gfa: 0.0,
478                        gfb: 0.0,
479                    }],
480                }],
481                rml: None,
482                urr: None,
483                ap_table: None,
484                r_external: vec![],
485            }],
486        }
487    }
488
489    /// Peak selection and SNR computation with synthetic data (offline).
490    #[test]
491    fn test_synthetic_peak_snr() {
492        // Matrix: weak resonance at 20 eV
493        let matrix = synthetic_isotope(74, 182, 20.0, 1e-3, 0.05);
494        // Trace: strong resonance at 8 eV (should dominate the |ΔT| spectrum)
495        let trace = synthetic_isotope(72, 178, 8.0, 0.5, 0.05);
496
497        let energies: Vec<f64> = (0..500).map(|i| 1.0 + (i as f64) * 49.0 / 499.0).collect();
498
499        let config = TraceDetectabilityConfig {
500            matrix_isotopes: &[(matrix.clone(), 6e-3)],
501            energies: &energies,
502            i0: 1000.0,
503            temperature_k: 293.6,
504            resolution: None,
505            snr_threshold: 3.0,
506        };
507
508        let report = trace_detectability(&config, &trace, 1000.0).unwrap();
509
510        // Basic sanity: spectrum has correct length
511        assert_eq!(report.delta_t_spectrum.len(), energies.len());
512        assert_eq!(report.energies.len(), energies.len());
513
514        // Peak should be near the trace resonance at 8 eV (not the matrix at 20 eV)
515        assert!(
516            report.peak_energy_ev > 5.0 && report.peak_energy_ev < 12.0,
517            "Peak energy should be near 8 eV, got {:.1} eV",
518            report.peak_energy_ev,
519        );
520
521        // With a strong trace resonance at 1000 ppm, SNR should be well above 3
522        assert!(
523            report.peak_snr > 3.0,
524            "Expected detectable (SNR > 3), got SNR={:.2}",
525            report.peak_snr,
526        );
527        assert!(report.detectable);
528
529        // peak_delta_t_per_ppm should be positive and consistent
530        assert!(report.peak_delta_t_per_ppm > 0.0);
531    }
532
533    /// Survey sorting with synthetic isotopes (offline).
534    #[test]
535    fn test_synthetic_survey_sorting() {
536        let matrix = synthetic_isotope(74, 182, 20.0, 1e-3, 0.05);
537        // Strong trace (big resonance at 8 eV)
538        let strong_trace = synthetic_isotope(72, 178, 8.0, 0.5, 0.05);
539        // Weak trace (tiny resonance at 30 eV)
540        let weak_trace = synthetic_isotope(26, 56, 30.0, 1e-5, 0.01);
541
542        let energies: Vec<f64> = (0..500).map(|i| 1.0 + (i as f64) * 49.0 / 499.0).collect();
543
544        let config = TraceDetectabilityConfig {
545            matrix_isotopes: &[(matrix.clone(), 6e-3)],
546            energies: &energies,
547            i0: 1000.0,
548            temperature_k: 293.6,
549            resolution: None,
550            snr_threshold: 3.0,
551        };
552
553        // Pass weak first — survey should reorder by SNR descending
554        let results =
555            trace_detectability_survey(&config, &[weak_trace, strong_trace], 500.0).unwrap();
556
557        assert_eq!(results.len(), 2);
558        assert!(
559            results[0].1.peak_snr >= results[1].1.peak_snr,
560            "Survey should sort by SNR descending: {:.2} >= {:.2}",
561            results[0].1.peak_snr,
562            results[1].1.peak_snr,
563        );
564        // The strong isotope (Z=72) should rank first
565        assert!(
566            results[0].0.starts_with("Hf"),
567            "Strong trace (Hf) should rank first, got '{}'",
568            results[0].0,
569        );
570    }
571
572    /// Multi-matrix baseline: two matrix isotopes produce a deeper baseline
573    /// than either alone, reducing trace detectability.
574    #[test]
575    fn test_multi_matrix_baseline() {
576        // Two matrix isotopes with resonances at different energies
577        let matrix_a = synthetic_isotope(74, 182, 20.0, 1e-3, 0.05);
578        let matrix_b = synthetic_isotope(26, 56, 35.0, 2e-3, 0.04);
579        let trace = synthetic_isotope(72, 178, 8.0, 0.5, 0.05);
580
581        let energies: Vec<f64> = (0..500).map(|i| 1.0 + (i as f64) * 49.0 / 499.0).collect();
582
583        // Single-matrix config
584        let config_single = TraceDetectabilityConfig {
585            matrix_isotopes: &[(matrix_a.clone(), 6e-3)],
586            energies: &energies,
587            i0: 1000.0,
588            temperature_k: 293.6,
589            resolution: None,
590            snr_threshold: 3.0,
591        };
592
593        // Multi-matrix config (same total density split across two isotopes)
594        let config_multi = TraceDetectabilityConfig {
595            matrix_isotopes: &[(matrix_a, 3e-3), (matrix_b, 3e-3)],
596            energies: &energies,
597            i0: 1000.0,
598            temperature_k: 293.6,
599            resolution: None,
600            snr_threshold: 3.0,
601        };
602
603        let report_single = trace_detectability(&config_single, &trace, 1000.0).unwrap();
604        let report_multi = trace_detectability(&config_multi, &trace, 1000.0).unwrap();
605
606        // Both should produce valid spectra of the correct length
607        assert_eq!(report_single.delta_t_spectrum.len(), energies.len());
608        assert_eq!(report_multi.delta_t_spectrum.len(), energies.len());
609
610        // Multi-matrix should still detect the trace (strong resonance at 8 eV)
611        assert!(
612            report_multi.peak_snr > 0.0,
613            "Multi-matrix SNR should be positive"
614        );
615
616        // The two configs produce different SNR values (different baselines)
617        assert!(
618            (report_single.peak_snr - report_multi.peak_snr).abs() > 1e-10,
619            "Single vs multi-matrix should produce different SNR values"
620        );
621    }
622
623    /// Opaque matrix: extremely high density causes exp(-n·σ) underflow.
624    ///
625    /// Reproduces #278: 1.0 at/barn matrix with a strong resonance gives
626    /// T ≈ 0 (underflow), so ΔT = 0 and SNR = 0. The report should flag
627    /// the matrix as opaque via `opaque_fraction > 0.5`.
628    #[test]
629    fn test_opaque_matrix_flags_high_opaque_fraction() {
630        // Matrix with a strong resonance at 20 eV and very high density (1.0 at/barn)
631        let matrix = synthetic_isotope(74, 182, 20.0, 0.5, 0.05);
632        let trace = synthetic_isotope(72, 178, 8.0, 0.5, 0.05);
633
634        let energies: Vec<f64> = (0..500).map(|i| 1.0 + (i as f64) * 49.0 / 499.0).collect();
635
636        let config = TraceDetectabilityConfig {
637            matrix_isotopes: &[(matrix, 1.0)], // 1.0 at/barn — extremely thick
638            energies: &energies,
639            i0: 10000.0,
640            temperature_k: 293.6,
641            resolution: None,
642            snr_threshold: 3.0,
643        };
644
645        let report = trace_detectability(&config, &trace, 2350.0).unwrap();
646
647        // With such a thick matrix, most bins should be opaque
648        assert!(
649            report.opaque_fraction > 0.5,
650            "Expected opaque_fraction > 0.5 for 1.0 at/barn matrix, got {:.3}",
651            report.opaque_fraction,
652        );
653
654        // SNR should be 0 or very close to 0 due to underflow
655        assert!(
656            report.peak_snr < 1.0,
657            "Expected near-zero SNR for opaque matrix, got {:.2}",
658            report.peak_snr,
659        );
660    }
661
662    /// Normal density: opaque_fraction should be 0 or very small.
663    #[test]
664    fn test_normal_density_not_opaque() {
665        let matrix = synthetic_isotope(74, 182, 20.0, 1e-3, 0.05);
666        let trace = synthetic_isotope(72, 178, 8.0, 0.5, 0.05);
667
668        let energies: Vec<f64> = (0..500).map(|i| 1.0 + (i as f64) * 49.0 / 499.0).collect();
669
670        let config = TraceDetectabilityConfig {
671            matrix_isotopes: &[(matrix, 6e-3)], // typical thin sample
672            energies: &energies,
673            i0: 1000.0,
674            temperature_k: 293.6,
675            resolution: None,
676            snr_threshold: 3.0,
677        };
678
679        let report = trace_detectability(&config, &trace, 1000.0).unwrap();
680
681        assert!(
682            report.opaque_fraction < 0.01,
683            "Expected opaque_fraction < 0.01 for normal density, got {:.3}",
684            report.opaque_fraction,
685        );
686    }
687}