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}