1use 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
37const OPAQUE_THRESHOLD: f64 = 1e-15;
44
45#[derive(Debug, Clone)]
47pub struct TraceDetectabilityReport {
48 pub peak_delta_t_per_ppm: f64,
55 pub peak_energy_ev: f64,
57 pub peak_snr: f64,
59 pub detectable: bool,
61 pub delta_t_spectrum: Vec<f64>,
63 pub energies: Vec<f64>,
65 pub opaque_fraction: f64,
72}
73
74pub struct TraceDetectabilityConfig<'a> {
76 pub matrix_isotopes: &'a [(ResonanceData, f64)],
82 pub energies: &'a [f64],
84 pub i0: f64,
86 pub temperature_k: f64,
88 pub resolution: Option<&'a ResolutionFunction>,
90 pub snr_threshold: f64,
92}
93
94fn build_instrument(config: &TraceDetectabilityConfig) -> Option<InstrumentParams> {
96 config.resolution.map(|r| InstrumentParams {
97 resolution: r.clone(),
98 })
99}
100
101fn total_matrix_density(config: &TraceDetectabilityConfig) -> f64 {
103 config.matrix_isotopes.iter().map(|(_, d)| d).sum()
104}
105
106fn 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
123fn 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 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 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 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 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 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 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
206pub 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
246pub fn trace_detectability_survey(
259 config: &TraceDetectabilityConfig,
260 trace_candidates: &[ResonanceData],
261 trace_ppm: f64,
262) -> Result<Vec<(String, TraceDetectabilityReport)>, PipelineError> {
263 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 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 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 #[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)], 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 #[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 #[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)], 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 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 let results = trace_detectability_survey(&config, &[fe56, hf178], 500.0).unwrap();
433
434 assert_eq!(results.len(), 2);
435 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 assert_eq!(
444 results[0].0, "Hf-178",
445 "Hf-178 should rank highest, got '{}'",
446 results[0].0,
447 );
448 }
449
450 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, 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 #[test]
491 fn test_synthetic_peak_snr() {
492 let matrix = synthetic_isotope(74, 182, 20.0, 1e-3, 0.05);
494 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 assert_eq!(report.delta_t_spectrum.len(), energies.len());
512 assert_eq!(report.energies.len(), energies.len());
513
514 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 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 assert!(report.peak_delta_t_per_ppm > 0.0);
531 }
532
533 #[test]
535 fn test_synthetic_survey_sorting() {
536 let matrix = synthetic_isotope(74, 182, 20.0, 1e-3, 0.05);
537 let strong_trace = synthetic_isotope(72, 178, 8.0, 0.5, 0.05);
539 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 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 assert!(
566 results[0].0.starts_with("Hf"),
567 "Strong trace (Hf) should rank first, got '{}'",
568 results[0].0,
569 );
570 }
571
572 #[test]
575 fn test_multi_matrix_baseline() {
576 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 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 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 assert_eq!(report_single.delta_t_spectrum.len(), energies.len());
608 assert_eq!(report_multi.delta_t_spectrum.len(), energies.len());
609
610 assert!(
612 report_multi.peak_snr > 0.0,
613 "Multi-matrix SNR should be positive"
614 );
615
616 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 #[test]
629 fn test_opaque_matrix_flags_high_opaque_fraction() {
630 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)], 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 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 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 #[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)], 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}