1use std::fmt;
14
15use nereids_core::types::Isotope;
16
17use crate::resonance::{
18 LGroup, RExternalEntry, Resonance, ResonanceData, ResonanceFormalism, ResonanceRange,
19};
20
21#[derive(Debug)]
25pub struct SammyParseError {
26 pub message: String,
27}
28
29impl fmt::Display for SammyParseError {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 write!(f, "SAMMY parse error: {}", self.message)
32 }
33}
34
35impl std::error::Error for SammyParseError {}
36
37impl SammyParseError {
38 fn new(msg: impl Into<String>) -> Self {
39 Self {
40 message: msg.into(),
41 }
42 }
43}
44
45#[derive(Debug, Clone)]
49pub struct SammyResonance {
50 pub energy_ev: f64,
52 pub gamma_gamma_ev: f64,
54 pub gamma_n_ev: f64,
56 pub gamma_f1_ev: f64,
58 pub gamma_f2_ev: f64,
60 pub spin_group: u32,
62}
63
64#[derive(Debug, Clone)]
69pub struct IsotopicMass {
70 pub awr: f64,
72 pub abundance: f64,
74 pub spin_groups: Vec<u32>,
76}
77
78#[derive(Debug, Clone)]
80pub struct SammyParFile {
81 pub resonances: Vec<SammyResonance>,
82 pub radius_overrides: std::collections::HashMap<u32, f64>,
86 pub r_external: std::collections::HashMap<u32, [f64; 7]>,
92 pub isotopic_masses: Vec<IsotopicMass>,
98}
99
100#[derive(Debug, Clone)]
104pub struct SammySpinGroup {
105 pub index: u32,
107 pub j: f64,
110 pub l: u32,
112 pub abundance: f64,
114 pub target_spin: f64,
117 pub isotope_label: Option<String>,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Default)]
128pub enum SammyObservationType {
129 #[default]
130 Transmission,
131 TotalCrossSection,
132 Fission,
133}
134
135#[derive(Debug, Clone)]
137pub struct SammyInpConfig {
138 pub title: String,
139 pub isotope_symbol: String,
141 pub awr: f64,
143 pub energy_min_ev: f64,
145 pub energy_max_ev: f64,
147 pub temperature_k: f64,
149 pub flight_path_m: f64,
151 pub delta_l_sammy: f64,
156 pub delta_e_sammy: f64,
161 pub delta_g_sammy: f64,
165 pub no_broadening: bool,
168 pub broadening_delta_l: Option<f64>,
172 pub broadening_delta_g: Option<f64>,
174 pub broadening_delta_e: Option<f64>,
176 pub scattering_radius_fm: f64,
178 pub thickness_atoms_barn: f64,
180 pub target_spin: f64,
185 pub spin_groups: Vec<SammySpinGroup>,
187 pub observation_type: SammyObservationType,
189}
190
191impl SammyInpConfig {
192 #[must_use]
194 pub fn effective_delta_l(&self) -> f64 {
195 self.broadening_delta_l
196 .filter(|&v| v != 0.0)
197 .unwrap_or(self.delta_l_sammy)
198 }
199
200 #[must_use]
202 pub fn effective_delta_g(&self) -> f64 {
203 self.broadening_delta_g
204 .filter(|&v| v != 0.0)
205 .unwrap_or(self.delta_g_sammy)
206 }
207
208 #[must_use]
210 pub fn effective_delta_e(&self) -> f64 {
211 self.broadening_delta_e
212 .filter(|&v| v != 0.0)
213 .unwrap_or(self.delta_e_sammy)
214 }
215}
216
217#[must_use]
239pub fn sammy_to_nereids_resolution(inp: &SammyInpConfig) -> Option<(f64, f64, f64, f64)> {
240 if inp.no_broadening {
241 return None;
242 }
243
244 let delta_l = inp.effective_delta_l();
245 let delta_g = inp.effective_delta_g();
246 let delta_e = inp.effective_delta_e();
247
248 if delta_l == 0.0 && delta_g == 0.0 && delta_e == 0.0 {
249 return None;
250 }
251
252 let delta_t_us = delta_g / (2.0 * 2.0_f64.ln().sqrt());
254 let delta_l_m = delta_l / 6.0_f64.sqrt();
255 Some((inp.flight_path_m, delta_t_us, delta_l_m, delta_e))
260}
261
262#[derive(Debug, Clone)]
266pub struct SammyPltRecord {
267 pub energy_kev: f64,
269 pub data: f64,
271 pub uncertainty: f64,
273 pub theory_initial: f64,
275 pub theory_final: f64,
277}
278
279pub fn parse_sammy_plt(content: &str) -> Result<Vec<SammyPltRecord>, SammyParseError> {
289 let mut records = Vec::new();
290 for (i, line) in content.lines().enumerate() {
291 let trimmed = line.trim();
292 if trimmed.is_empty() {
293 continue;
294 }
295 if trimmed.starts_with("Energy") || trimmed.contains("Th_initial") {
297 continue;
298 }
299 let fields: Vec<&str> = trimmed.split_whitespace().collect();
300 if fields.len() < 4 {
301 return Err(SammyParseError::new(format!(
302 "line {}: expected >=4 columns, got {}",
303 i + 1,
304 fields.len()
305 )));
306 }
307 let parse = |s: &str, col: &str| {
308 s.parse::<f64>().map_err(|e| {
309 SammyParseError::new(format!("line {}: cannot parse {col}: {e}", i + 1))
310 })
311 };
312 records.push(SammyPltRecord {
315 energy_kev: parse(fields[0], "energy")?,
316 data: parse(fields[1], "data")?,
317 uncertainty: parse(fields[2], "uncertainty")?,
318 theory_initial: parse(fields[3], "theory_initial")?,
319 theory_final: if fields.len() >= 5 {
320 parse(fields[4], "theory_final")?
321 } else {
322 0.0
323 },
324 });
325 }
326 if records.is_empty() {
327 return Err(SammyParseError::new("no data records found in .plt file"));
328 }
329 Ok(records)
330}
331
332fn parse_fortran_float(s: &str) -> Result<f64, std::num::ParseFloatError> {
340 if let Ok(v) = s.parse::<f64>() {
342 return Ok(v);
343 }
344 if let Some(pos) = s.find(['D', 'd']) {
346 let mut fixed = String::with_capacity(s.len());
347 fixed.push_str(&s[..pos]);
348 fixed.push('E');
349 fixed.push_str(&s[pos + 1..]);
350 return fixed.parse::<f64>();
351 }
352 if s.len() > 1 {
355 for (i, c) in s[1..].char_indices() {
356 let pos = i + 1; if (c == '+' || c == '-') && s.as_bytes()[pos - 1].is_ascii_digit() {
358 let mut fixed = String::with_capacity(s.len() + 1);
359 fixed.push_str(&s[..pos]);
360 fixed.push('E');
361 fixed.push_str(&s[pos..]);
362 return fixed.parse::<f64>();
363 }
364 }
365 }
366 s.parse::<f64>()
367}
368
369pub fn parse_sammy_par(content: &str) -> Result<SammyParFile, SammyParseError> {
384 let mut resonances = Vec::new();
385
386 for (i, line) in content.lines().enumerate() {
387 let trimmed = line.trim();
388 if trimmed.is_empty() || trimmed.starts_with("EXPLICIT") {
390 break;
391 }
392
393 if line.len() < 33 {
395 return Err(SammyParseError::new(format!(
396 "line {}: too short for .par format (need ≥33 chars, got {})",
397 i + 1,
398 line.len()
399 )));
400 }
401
402 let parse_col = |start: usize, end: usize, name: &str| -> Result<f64, SammyParseError> {
403 let s = if start >= line.len() {
404 ""
406 } else {
407 line[start..end.min(line.len())].trim()
408 };
409 if s.is_empty() {
410 return Ok(0.0);
411 }
412 parse_fortran_float(s).map_err(|e| {
413 SammyParseError::new(format!("line {}: cannot parse {name} ({s:?}): {e}", i + 1))
414 })
415 };
416
417 let energy_ev = parse_col(0, 11, "E_res")?;
418 let gamma_gamma_mev = parse_col(11, 22, "Γ_γ")?;
419 let gamma_n_mev = parse_col(22, 33, "Γ_n")?;
420 let gamma_f1_mev = parse_col(33, 44, "Γ_f1")?;
421 let gamma_f2_mev = parse_col(44, 55, "Γ_f2")?;
422
423 let spin_group_signed: i32 = if line.len() > 65 {
431 let end = line.len().min(67);
432 let sg_str = line[65..end].trim();
433 if sg_str.is_empty() {
434 1
435 } else {
436 sg_str.parse::<i32>().map_err(|e| {
437 SammyParseError::new(format!(
438 "line {}: cannot parse spin group ({sg_str:?}): {e}",
439 i + 1
440 ))
441 })?
442 }
443 } else {
444 1 };
446
447 if spin_group_signed < 0 {
448 continue;
450 }
451 let spin_group = spin_group_signed as u32;
452
453 resonances.push(SammyResonance {
454 energy_ev,
455 gamma_gamma_ev: gamma_gamma_mev / 1000.0,
456 gamma_n_ev: gamma_n_mev / 1000.0,
457 gamma_f1_ev: gamma_f1_mev / 1000.0,
458 gamma_f2_ev: gamma_f2_mev / 1000.0,
459 spin_group,
460 });
461 }
462
463 if resonances.is_empty() {
464 return Err(SammyParseError::new("no resonances found in .par file"));
465 }
466
467 let mut radius_overrides = std::collections::HashMap::new();
478 let lines_vec: Vec<&str> = content.lines().collect();
479 if let Some(rad_idx) = lines_vec
480 .iter()
481 .position(|l| l.trim().to_uppercase().starts_with("RADIUS"))
482 {
483 for rad_line in &lines_vec[rad_idx + 1..] {
484 let trimmed = rad_line.trim();
485 if trimmed.is_empty() || trimmed.starts_with(|c: char| c.is_alphabetic()) {
486 break; }
488
489 if rad_line.len() < 20 {
497 continue;
498 }
499 let r_eff: f64 = rad_line[..10].trim().parse().unwrap_or(0.0);
500 if r_eff <= 0.0 {
501 continue;
502 }
503
504 let group_str = if rad_line.len() > 24 {
506 &rad_line[24..]
507 } else {
508 ""
509 };
510 let mut pos = 0;
511 let mut found_group = false;
512 while pos + 2 <= group_str.len() {
513 let field = group_str[pos..pos + 2].trim();
514 pos += 2;
515 let val: i32 = match field.parse() {
516 Ok(v) => v,
517 Err(_) => continue,
518 };
519 if val > 0 {
520 radius_overrides.insert(val as u32, r_eff);
521 found_group = true;
522 } else if val == 0 && found_group {
523 break; }
525 }
526
527 if !found_group {
530 for tok in rad_line[20..].split_whitespace() {
531 if let Ok(v) = tok.parse::<i32>()
532 && v > 0
533 {
534 radius_overrides.insert(v as u32, r_eff);
535 }
536 }
537 }
538 }
539 }
540
541 let mut r_external = std::collections::HashMap::new();
553 if let Some(rext_idx) = lines_vec
554 .iter()
555 .position(|l| l.trim().starts_with("R-EXTERNAL PARAMETERS"))
556 {
557 for rext_line in &lines_vec[rext_idx + 1..] {
558 let trimmed = rext_line.trim();
559 if trimmed.is_empty() || trimmed.starts_with(|c: char| c.is_alphabetic()) {
560 break; }
562
563 if rext_line.len() < 40 {
565 continue;
566 }
567
568 let sg: u32 = match rext_line[..2].trim().parse() {
570 Ok(v) if v > 0 => v,
571 _ => continue,
572 };
573 let param_str = &rext_line[10..];
580 let mut params = [0.0f64; 7];
581 let mut parsed_ok = true;
582
583 if param_str.len() >= 70 {
585 for (i, chunk) in (0..7).map(|i| (i, ¶m_str[i * 10..(i + 1) * 10])) {
586 match chunk.trim().parse::<f64>() {
587 Ok(v) => params[i] = v,
588 Err(_) => {
589 parsed_ok = false;
590 break;
591 }
592 }
593 }
594 } else {
595 parsed_ok = false;
596 }
597
598 if !parsed_ok {
600 let tokens: Vec<f64> = param_str
601 .split_whitespace()
602 .filter_map(|t| t.parse().ok())
603 .collect();
604 if tokens.len() != 7 {
606 continue;
607 }
608 for (i, &v) in tokens.iter().enumerate().take(7) {
609 params[i] = v;
610 }
611 }
612
613 r_external.entry(sg).or_insert(params);
617 }
618 }
619
620 let mut isotopic_masses = Vec::new();
631 if let Some(iso_idx) = lines_vec
632 .iter()
633 .position(|l| l.trim().to_uppercase().starts_with("ISOTOPIC"))
634 {
635 for iso_line in &lines_vec[iso_idx + 1..] {
636 let trimmed = iso_line.trim();
637 if trimmed.is_empty() || trimmed.starts_with(|c: char| c.is_alphabetic()) {
638 break;
639 }
640 if iso_line.len() < 20 {
641 continue;
642 }
643
644 let awr: f64 = iso_line[..10].trim().parse().map_err(|_| {
645 SammyParseError::new(format!(
646 "ISOTOPIC MASSES: bad AWR field '{}'",
647 iso_line[..10].trim()
648 ))
649 })?;
650 let abundance: f64 = iso_line[10..20].trim().parse().map_err(|_| {
651 SammyParseError::new(format!(
652 "ISOTOPIC MASSES: bad abundance field '{}'",
653 iso_line[10..20].trim()
654 ))
655 })?;
656 if awr <= 0.0 {
657 continue;
658 }
659
660 let group_start = 32.min(iso_line.len());
663 let group_str = &iso_line[group_start..];
664 let mut spin_groups = Vec::new();
665 let mut pos = 0;
666 let mut found_group = false;
667 while pos + 2 <= group_str.len() {
668 let field = group_str[pos..pos + 2].trim();
669 pos += 2;
670 let val: i32 = match field.parse() {
671 Ok(v) => v,
672 Err(_) => continue,
673 };
674 if val > 0 {
675 spin_groups.push(val as u32);
676 found_group = true;
677 } else if val == 0 && found_group {
678 break;
679 }
680 }
681
682 if !found_group {
684 for tok in iso_line[group_start..].split_whitespace() {
685 if let Ok(v) = tok.parse::<i32>()
686 && v > 0
687 {
688 spin_groups.push(v as u32);
689 }
690 }
691 }
692
693 if !spin_groups.is_empty() {
694 isotopic_masses.push(IsotopicMass {
695 awr,
696 abundance,
697 spin_groups,
698 });
699 }
700 }
701 }
702
703 Ok(SammyParFile {
704 resonances,
705 radius_overrides,
706 r_external,
707 isotopic_masses,
708 })
709}
710
711pub fn parse_sammy_inp(content: &str) -> Result<SammyInpConfig, SammyParseError> {
718 let lines: Vec<&str> = content.lines().collect();
719 if lines.len() < 2 {
720 return Err(SammyParseError::new(
721 "file too short (need at least 2 lines)",
722 ));
723 }
724
725 let title = lines[0].trim().to_string();
727
728 let iso_line = lines[1];
738 if iso_line.len() < 30 {
739 return Err(SammyParseError::new(format!(
740 "line 2: too short for Card 2 fixed-width format (need ≥30 chars, got {})",
741 iso_line.len()
742 )));
743 }
744 let isotope_symbol = iso_line[..10.min(iso_line.len())].trim().to_string();
745 let awr = iso_line[10..20.min(iso_line.len())]
746 .trim()
747 .parse::<f64>()
748 .map_err(|e| SammyParseError::new(format!("line 2: AWR: {e}")))?;
749 let energy_min_ev = iso_line[20..30.min(iso_line.len())]
750 .trim()
751 .parse::<f64>()
752 .map_err(|e| SammyParseError::new(format!("line 2: Emin: {e}")))?;
753 let energy_max_ev = if iso_line.len() > 30 {
754 iso_line[30..40.min(iso_line.len())]
755 .trim()
756 .parse::<f64>()
757 .map_err(|e| SammyParseError::new(format!("line 2: Emax: {e}")))?
758 } else {
759 energy_min_ev
761 };
762
763 let mut temperature_k = 300.0;
766 let mut flight_path_m = 0.0;
767 let mut delta_l_sammy = 0.0;
768 let mut delta_e_sammy = 0.0;
769 let mut delta_g_sammy = 0.0;
770 let mut scattering_radius_fm = 0.0;
771 let mut thickness_atoms_barn = 0.0;
772 let mut target_spin = 0.0;
773 let mut spin_groups = Vec::new();
774 let mut no_broadening = false;
775
776 let mut idx = 2;
778 while idx < lines.len() {
780 let trimmed = lines[idx].trim();
781 if trimmed.is_empty() {
782 idx += 1;
783 break;
784 }
785 let upper = trimmed.to_uppercase();
787 if upper.starts_with("BROADENING IS NOT") || upper.contains("NO LOW-ENERGY BROADENING") {
789 no_broadening = true;
790 }
791 idx += 1;
795 }
796
797 let card5_present = if idx < lines.len() {
823 if no_broadening {
824 if idx + 1 < lines.len() {
826 let next_trimmed = lines[idx + 1].trim();
827 next_trimmed
828 .bytes()
829 .next()
830 .is_some_and(|b| b.is_ascii_digit() || b == b'.' || b == b'-' || b == b'+')
831 } else {
832 false
833 }
834 } else {
835 true }
837 } else {
838 false
839 };
840
841 if card5_present && idx < lines.len() {
842 let card5_fields: Vec<&str> = lines[idx].split_whitespace().collect();
843 if card5_fields.len() >= 2 {
844 temperature_k = card5_fields[0]
845 .parse::<f64>()
846 .map_err(|e| SammyParseError::new(format!("Card 5: temperature: {e}")))?;
847 flight_path_m = card5_fields[1]
848 .parse::<f64>()
849 .map_err(|e| SammyParseError::new(format!("Card 5: flight_path: {e}")))?;
850 if card5_fields.len() >= 3 {
853 delta_l_sammy = card5_fields[2].parse().unwrap_or(0.0);
854 }
855 if card5_fields.len() >= 4 {
856 delta_e_sammy = card5_fields[3].parse().unwrap_or(0.0);
857 }
858 if card5_fields.len() >= 5 {
859 delta_g_sammy = card5_fields[4].parse().unwrap_or(0.0);
860 }
861 }
862 idx += 1;
863 }
864
865 if idx < lines.len() {
867 let card6_fields: Vec<&str> = lines[idx].split_whitespace().collect();
868 if card6_fields.len() >= 2 {
869 scattering_radius_fm = card6_fields[0]
870 .parse::<f64>()
871 .map_err(|e| SammyParseError::new(format!("Card 6: scattering_radius: {e}")))?;
872 thickness_atoms_barn = card6_fields[1]
873 .parse::<f64>()
874 .map_err(|e| SammyParseError::new(format!("Card 6: thickness: {e}")))?;
875 }
876 idx += 1;
877 }
878
879 let mut broadening_delta_l: Option<f64> = None;
881 let mut broadening_delta_g: Option<f64> = None;
882 let mut broadening_delta_e: Option<f64> = None;
883 let mut observation_type = SammyObservationType::default();
884
885 while idx < lines.len() {
886 let trimmed = lines[idx].trim().to_uppercase();
887 if trimmed.starts_with("CAPTU") {
892 return Err(SammyParseError::new(
893 "unsupported observation type: CAPTURE (not yet implemented in NEREIDS)",
894 ));
895 }
896 if trimmed.starts_with("DIRAC") {
897 return Err(SammyParseError::new(
898 "unsupported observation type: DIRAC (not yet implemented in NEREIDS)",
899 ));
900 }
901 if trimmed.starts_with("TRANS")
902 || trimmed.starts_with("TOTAL")
903 || trimmed.starts_with("FISSI")
904 {
905 if trimmed.starts_with("TOTAL") {
906 observation_type = SammyObservationType::TotalCrossSection;
907 } else if trimmed.starts_with("FISSI") {
908 observation_type = SammyObservationType::Fission;
909 }
910 idx += 1;
911 while idx < lines.len() {
917 let l = lines[idx];
918 let field = if l.len() >= 5 {
919 l[..5].trim()
920 } else {
921 l.trim()
922 };
923 if field.is_empty() || field.parse::<u32>().is_ok() {
924 break;
925 }
926 idx += 1;
927 }
928 let (groups, parsed_target_spin) = parse_spin_groups(&lines[idx..])?;
930 spin_groups = groups;
931 target_spin = parsed_target_spin;
932 while idx < lines.len() && !lines[idx].trim().is_empty() {
934 idx += 1;
935 }
936 } else if trimmed.starts_with("BROADENING") {
937 idx += 1;
945 if idx < lines.len() {
946 let brd_fields: Vec<&str> = lines[idx].split_whitespace().collect();
947 if brd_fields.len() >= 6 {
948 if let Ok(v) = brd_fields[3].parse::<f64>()
949 && v != 0.0
950 {
951 broadening_delta_l = Some(v);
952 }
953 if let Ok(v) = brd_fields[4].parse::<f64>()
954 && v != 0.0
955 {
956 broadening_delta_g = Some(v);
957 }
958 if let Ok(v) = brd_fields[5].parse::<f64>()
959 && v != 0.0
960 {
961 broadening_delta_e = Some(v);
962 }
963 }
964 }
965 break;
968 }
969 idx += 1;
970 }
971
972 Ok(SammyInpConfig {
973 title,
974 isotope_symbol,
975 awr,
976 energy_min_ev,
977 energy_max_ev,
978 temperature_k,
979 flight_path_m,
980 delta_l_sammy,
981 delta_e_sammy,
982 delta_g_sammy,
983 no_broadening,
984 broadening_delta_l,
985 broadening_delta_g,
986 broadening_delta_e,
987 scattering_radius_fm,
988 thickness_atoms_barn,
989 target_spin,
990 spin_groups,
991 observation_type,
992 })
993}
994
995fn parse_spin_groups(lines: &[&str]) -> Result<(Vec<SammySpinGroup>, f64), SammyParseError> {
1019 let mut groups = Vec::new();
1020 let mut target_spin = 0.0;
1021 let mut i = 0;
1022
1023 fn col(line: &str, start: usize, end: usize) -> &str {
1025 if start >= line.len() {
1026 return "";
1027 }
1028 let actual_end = end.min(line.len());
1029 line[start..actual_end].trim()
1030 }
1031
1032 while i < lines.len() {
1033 let trimmed = lines[i].trim();
1034 if trimmed.is_empty() {
1035 break; }
1037
1038 let line = lines[i];
1039
1040 let index: u32 = col(line, 0, 5)
1052 .parse()
1053 .map_err(|e| SammyParseError::new(format!("spin group index: {e}")))?;
1054 let n_ent: u32 = {
1055 let s = col(line, 5, 10);
1056 if s.is_empty() {
1057 1
1058 } else {
1059 s.parse()
1060 .map_err(|e| SammyParseError::new(format!("spin group n_ent ({s:?}): {e}")))?
1061 }
1062 };
1063 let n_exit: u32 = {
1064 let s = col(line, 10, 15);
1065 if s.is_empty() {
1066 0
1067 } else {
1068 s.parse()
1069 .map_err(|e| SammyParseError::new(format!("spin group n_exit ({s:?}): {e}")))?
1070 }
1071 };
1072 let j: f64 = col(line, 15, 20)
1073 .parse()
1074 .map_err(|e| SammyParseError::new(format!("spin group J: {e}")))?;
1075 let abund_end = 30;
1082 let abundance: f64 = {
1083 let s = col(line, 20, abund_end);
1084 if s.is_empty() {
1085 1.0
1086 } else {
1087 s.parse().map_err(|e| {
1088 SammyParseError::new(format!("spin group abundance ({s:?}): {e}"))
1089 })?
1090 }
1091 };
1092
1093 let tspin_start = 30;
1096 let group_target_spin = {
1097 let s = col(line, tspin_start, tspin_start + 5);
1098 if s.is_empty() {
1099 0.0
1100 } else {
1101 s.parse::<f64>().map_err(|e| {
1102 SammyParseError::new(format!("spin group target spin ({s:?}): {e}"))
1103 })?
1104 }
1105 };
1106 if groups.is_empty() {
1108 target_spin = group_target_spin;
1109 }
1110
1111 let isotope_label = if line.len() > 36 {
1114 let s = line[36..].trim();
1115 if s.is_empty() {
1116 None
1117 } else {
1118 Some(s.to_string())
1119 }
1120 } else {
1121 None
1122 };
1123
1124 let n_channels = n_ent + n_exit;
1142 let mut l = 0u32;
1143 if i + 1 < lines.len() {
1144 let l_str = col(lines[i + 1], 18, 20);
1145 if !l_str.is_empty() {
1146 l = l_str.parse().unwrap_or(0);
1147 }
1148 }
1149
1150 groups.push(SammySpinGroup {
1151 index,
1152 j,
1153 l,
1154 abundance,
1155 target_spin: group_target_spin,
1156 isotope_label,
1157 });
1158
1159 i += 1 + n_channels as usize;
1161 }
1162
1163 Ok((groups, target_spin))
1164}
1165
1166const CHANNEL_OFFSET: f64 = 1e-6;
1171
1172fn compute_j_offsets(
1185 spin_groups: &[SammySpinGroup],
1186 target_spin: f64,
1187) -> std::collections::HashMap<u32, f64> {
1188 let mut sg_j_offset = std::collections::HashMap::new();
1189 if target_spin.abs() <= 1e-10 {
1190 return sg_j_offset;
1191 }
1192 let mut lj_seen: std::collections::HashMap<(u32, i64), u32> = std::collections::HashMap::new();
1193 for sg in spin_groups {
1194 let j_key = (sg.j.abs() * 1_000_000.0).round() as i64;
1195 let count = lj_seen.entry((sg.l, j_key)).or_insert(0);
1196 if *count > 0 {
1197 sg_j_offset.insert(sg.index, *count as f64 * CHANNEL_OFFSET);
1198 }
1199 *count += 1;
1200 }
1201 sg_j_offset
1202}
1203
1204pub fn sammy_to_resonance_data(
1209 inp: &SammyInpConfig,
1210 par: &SammyParFile,
1211) -> Result<ResonanceData, SammyParseError> {
1212 if inp.spin_groups.is_empty() {
1213 return Err(SammyParseError::new("no spin groups defined in .inp file"));
1214 }
1215
1216 let group_map: std::collections::HashMap<u32, &SammySpinGroup> =
1218 inp.spin_groups.iter().map(|sg| (sg.index, sg)).collect();
1219
1220 let mut l_group_map: std::collections::BTreeMap<u32, Vec<Resonance>> =
1223 std::collections::BTreeMap::new();
1224
1225 let sg_j_offset = compute_j_offsets(&inp.spin_groups, inp.target_spin);
1229
1230 for res in &par.resonances {
1231 let sg = group_map.get(&res.spin_group).ok_or_else(|| {
1232 SammyParseError::new(format!(
1233 "resonance at E={} eV references undefined spin group {}",
1234 res.energy_ev, res.spin_group
1235 ))
1236 })?;
1237
1238 let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1239 l_group_map.entry(sg.l).or_default().push(Resonance {
1240 energy: res.energy_ev,
1241 j,
1242 gn: res.gamma_n_ev,
1243 gg: res.gamma_gamma_ev,
1244 gfa: res.gamma_f1_ev,
1245 gfb: res.gamma_f2_ev,
1246 });
1247 }
1248
1249 let sg_indices_with_resonances: std::collections::HashSet<u32> =
1255 par.resonances.iter().map(|r| r.spin_group).collect();
1256 for sg in &inp.spin_groups {
1257 if !sg_indices_with_resonances.contains(&sg.index) {
1258 let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1259 l_group_map.entry(sg.l).or_default().push(Resonance {
1260 energy: 0.0,
1261 j,
1262 gn: 0.0,
1263 gg: 0.0,
1264 gfa: 0.0,
1265 gfb: 0.0,
1266 });
1267 }
1268 }
1269
1270 let mut l_radius_map: std::collections::HashMap<u32, f64> = std::collections::HashMap::new();
1278 for sg in &inp.spin_groups {
1279 if let Some(&r) = par.radius_overrides.get(&sg.index) {
1280 l_radius_map.entry(sg.l).or_insert(r);
1281 }
1282 }
1283
1284 let l_groups: Vec<LGroup> = l_group_map
1286 .into_iter()
1287 .map(|(l, resonances)| {
1288 let apl = l_radius_map.get(&l).copied().unwrap_or(0.0);
1291 LGroup {
1292 l,
1293 awr: inp.awr,
1294 apl,
1295 qx: 0.0,
1296 lrx: 0,
1297 resonances,
1298 }
1299 })
1300 .collect();
1301
1302 let target_spin = inp.target_spin;
1305
1306 let (z, mut a) = parse_isotope_symbol(&inp.isotope_symbol)?;
1309 if a == 0 {
1311 a = inp.awr.round() as u32;
1312 }
1313 let za = z * 1000 + a;
1314
1315 if l_groups.iter().all(|lg| lg.resonances.is_empty()) {
1331 return Err(SammyParseError::new(
1332 "SAMMY .par produced no resonances in any L-group; cross-sections \
1333 would be identically zero everywhere"
1334 .to_string(),
1335 ));
1336 }
1337
1338 let range = ResonanceRange {
1339 energy_low: 1e-5,
1340 energy_high: 2e7,
1341 resolved: true,
1342 formalism: ResonanceFormalism::ReichMoore,
1343 target_spin,
1344 scattering_radius: inp.scattering_radius_fm,
1345 naps: 1,
1347 ap_table: None,
1348 l_groups,
1349 r_external: vec![],
1350 };
1351
1352 Ok(ResonanceData {
1353 isotope: Isotope::new(z, a)
1354 .map_err(|e| SammyParseError::new(format!("invalid isotope: {e}")))?,
1355 za,
1356 awr: inp.awr,
1357 ranges: vec![range],
1358 })
1359}
1360
1361pub fn sammy_to_resonance_data_multi(
1370 inp: &SammyInpConfig,
1371 par: &SammyParFile,
1372) -> Result<Vec<(ResonanceData, f64)>, SammyParseError> {
1373 if inp.spin_groups.is_empty() {
1374 return Err(SammyParseError::new("no spin groups defined in .inp file"));
1375 }
1376
1377 let has_labels = inp.spin_groups.iter().any(|sg| sg.isotope_label.is_some());
1381
1382 let mut group_order: Vec<String> = Vec::new();
1384 let mut group_members: std::collections::HashMap<String, Vec<usize>> =
1385 std::collections::HashMap::new();
1386 let mut group_abundance: std::collections::HashMap<String, f64> =
1387 std::collections::HashMap::new();
1388 let mut group_target_spin: std::collections::HashMap<String, f64> =
1389 std::collections::HashMap::new();
1390
1391 for (i, sg) in inp.spin_groups.iter().enumerate() {
1392 let key = if has_labels {
1393 sg.isotope_label
1394 .as_deref()
1395 .unwrap_or("unknown")
1396 .to_uppercase()
1397 } else {
1398 format!("{:.6}_{:.1}", sg.abundance, sg.target_spin)
1399 };
1400
1401 if !group_members.contains_key(&key) {
1402 group_order.push(key.clone());
1403 }
1404 group_members.entry(key.clone()).or_default().push(i);
1405 if let Some(&existing) = group_abundance.get(&key)
1406 && (existing - sg.abundance).abs() >= 1e-12
1407 {
1408 return Err(SammyParseError::new(format!(
1409 "spin groups in isotope group '{}' disagree on abundance: {} vs {}",
1410 key, existing, sg.abundance
1411 )));
1412 }
1413 group_abundance.insert(key.clone(), sg.abundance);
1414 if let Some(&existing) = group_target_spin.get(&key)
1415 && (existing - sg.target_spin).abs() >= 1e-12
1416 {
1417 return Err(SammyParseError::new(format!(
1418 "spin groups in isotope group '{}' disagree on target_spin: {} vs {}",
1419 key, existing, sg.target_spin
1420 )));
1421 }
1422 group_target_spin.insert(key.clone(), sg.target_spin);
1423 }
1424
1425 let sg_map: std::collections::HashMap<u32, &SammySpinGroup> =
1427 inp.spin_groups.iter().map(|sg| (sg.index, sg)).collect();
1428
1429 let mut sg_iso_awr: std::collections::HashMap<u32, f64> = std::collections::HashMap::new();
1432 let mut sg_iso_abund: std::collections::HashMap<u32, f64> = std::collections::HashMap::new();
1433 for im in &par.isotopic_masses {
1434 for &sg_idx in &im.spin_groups {
1435 sg_iso_awr.insert(sg_idx, im.awr);
1436 sg_iso_abund.insert(sg_idx, im.abundance);
1437 }
1438 }
1439
1440 let (card2_z, card2_a) = parse_isotope_symbol(&inp.isotope_symbol)?;
1442
1443 let mut result = Vec::new();
1444
1445 for key in &group_order {
1446 let member_indices = &group_members[key];
1447 let target_spin = group_target_spin[key];
1448
1449 let first_sg_idx = inp.spin_groups[member_indices[0]].index;
1452 let abundance = sg_iso_abund
1453 .get(&first_sg_idx)
1454 .copied()
1455 .unwrap_or(group_abundance[key]);
1456
1457 let sg_indices: std::collections::HashSet<u32> = member_indices
1459 .iter()
1460 .map(|&i| inp.spin_groups[i].index)
1461 .collect();
1462
1463 let group_resonances: Vec<&SammyResonance> = par
1465 .resonances
1466 .iter()
1467 .filter(|r| sg_indices.contains(&r.spin_group))
1468 .collect();
1469
1470 let member_sgs: Vec<SammySpinGroup> = member_indices
1473 .iter()
1474 .map(|&i| inp.spin_groups[i].clone())
1475 .collect();
1476 let sg_j_offset = compute_j_offsets(&member_sgs, target_spin);
1477
1478 let mut l_group_map: std::collections::BTreeMap<u32, Vec<Resonance>> =
1480 std::collections::BTreeMap::new();
1481
1482 for res in &group_resonances {
1483 let sg = sg_map.get(&res.spin_group).ok_or_else(|| {
1484 SammyParseError::new(format!(
1485 "resonance at E={} eV references undefined spin group {}",
1486 res.energy_ev, res.spin_group
1487 ))
1488 })?;
1489
1490 let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1491 l_group_map.entry(sg.l).or_default().push(Resonance {
1492 energy: res.energy_ev,
1493 j,
1494 gn: res.gamma_n_ev,
1495 gg: res.gamma_gamma_ev,
1496 gfa: res.gamma_f1_ev,
1497 gfb: res.gamma_f2_ev,
1498 });
1499 }
1500
1501 let res_sg_indices: std::collections::HashSet<u32> =
1504 group_resonances.iter().map(|r| r.spin_group).collect();
1505 for &idx in &sg_indices {
1506 if !res_sg_indices.contains(&idx)
1507 && let Some(sg) = sg_map.get(&idx)
1508 {
1509 let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1510 l_group_map.entry(sg.l).or_default().push(Resonance {
1511 energy: 0.0,
1512 j,
1513 gn: 0.0,
1514 gg: 0.0,
1515 gfa: 0.0,
1516 gfb: 0.0,
1517 });
1518 }
1519 }
1520
1521 let mut l_radius_map: std::collections::HashMap<u32, f64> =
1523 std::collections::HashMap::new();
1524 for &idx in &sg_indices {
1525 if let Some(sg) = sg_map.get(&idx)
1526 && let Some(&r) = par.radius_overrides.get(&sg.index)
1527 {
1528 l_radius_map.entry(sg.l).or_insert(r);
1529 }
1530 }
1531
1532 let isotope_awr = sg_iso_awr.get(&first_sg_idx).copied().unwrap_or(inp.awr);
1535
1536 let l_groups: Vec<LGroup> = l_group_map
1537 .into_iter()
1538 .map(|(l, resonances)| {
1539 let apl = l_radius_map.get(&l).copied().unwrap_or(0.0);
1540 LGroup {
1541 l,
1542 awr: isotope_awr,
1543 apl,
1544 qx: 0.0,
1545 lrx: 0,
1546 resonances,
1547 }
1548 })
1549 .collect();
1550
1551 let (z, mut a) = if has_labels {
1553 parse_isotope_symbol(key).unwrap_or((card2_z, card2_a))
1555 } else {
1556 (card2_z, card2_a)
1557 };
1558 if a == 0 {
1560 a = isotope_awr.round() as u32;
1561 }
1562 let za = z * 1000 + a;
1563
1564 let mut r_external_entries = Vec::new();
1566 for &idx in &sg_indices {
1567 if let Some(sg) = sg_map.get(&idx)
1568 && let Some(params) = par.r_external.get(&sg.index)
1569 {
1570 let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1571 r_external_entries.push(RExternalEntry {
1572 l: sg.l,
1573 j,
1574 e_low: params[0],
1575 e_up: params[1],
1576 r_con: params[2],
1577 r_lin: params[3],
1578 s_con: params[4],
1579 s_lin: params[5],
1580 r_quad: params[6],
1581 });
1582 }
1583 }
1584
1585 if l_groups.iter().all(|lg| lg.resonances.is_empty()) {
1589 return Err(SammyParseError::new(format!(
1590 "SAMMY .par isotope group {key:?} produced no resonances in \
1591 any L-group; cross-sections would be identically zero \
1592 everywhere"
1593 )));
1594 }
1595
1596 let range = ResonanceRange {
1597 energy_low: 1e-5,
1598 energy_high: 2e7,
1599 resolved: true,
1600 formalism: ResonanceFormalism::ReichMoore,
1601 target_spin,
1602 scattering_radius: inp.scattering_radius_fm,
1603 naps: 1,
1605 ap_table: None,
1606 l_groups,
1607 r_external: r_external_entries,
1608 };
1609
1610 let resonance_data = ResonanceData {
1611 isotope: Isotope::new(z, a)
1612 .map_err(|e| SammyParseError::new(format!("invalid isotope: {e}")))?,
1613 za,
1614 awr: isotope_awr,
1615 ranges: vec![range],
1616 };
1617
1618 result.push((resonance_data, abundance));
1619 }
1620
1621 Ok(result)
1622}
1623
1624pub(crate) fn parse_isotope_symbol(symbol: &str) -> Result<(u32, u32), SammyParseError> {
1631 let joined: String = symbol
1633 .chars()
1634 .filter(|c| !c.is_whitespace() && *c != '-')
1635 .collect();
1636 let s = joined.to_uppercase();
1637
1638 if let Some(rest) = s.strip_prefix("NAT")
1640 && let Some(z) = element_symbol_to_z(rest)
1641 {
1642 return Ok((z, 0));
1643 }
1644
1645 if let Some(split_pos) = s.find(|c: char| c.is_ascii_alphabetic()) {
1649 let mass_str = &s[..split_pos];
1650 let remaining = &s[split_pos..];
1651 let alpha_len = remaining
1652 .find(|c: char| !c.is_ascii_alphabetic())
1653 .unwrap_or(remaining.len());
1654 let elem_str = &remaining[..alpha_len];
1655 if !mass_str.is_empty()
1656 && let Ok(a) = mass_str.parse::<u32>()
1657 && let Some(z) = element_symbol_to_z(elem_str)
1658 {
1659 return Ok((z, a));
1660 }
1661 }
1662
1663 if let Some(split_pos) = s.find(|c: char| c.is_ascii_digit()) {
1666 let elem_str = &s[..split_pos];
1667 let remaining = &s[split_pos..];
1668 let digit_len = remaining
1669 .find(|c: char| !c.is_ascii_digit())
1670 .unwrap_or(remaining.len());
1671 let mass_str = &remaining[..digit_len];
1672 if let Ok(a) = mass_str.parse::<u32>()
1673 && let Some(z) = element_symbol_to_z(elem_str)
1674 {
1675 return Ok((z, a));
1676 }
1677 }
1678
1679 if let Some(z) = element_symbol_to_z(&s) {
1681 return Ok((z, 0));
1682 }
1683
1684 if let Some(z) = element_name_to_z(&s) {
1686 return Ok((z, 0));
1687 }
1688
1689 Err(SammyParseError::new(format!(
1690 "cannot parse isotope symbol: {symbol}"
1691 )))
1692}
1693
1694fn element_symbol_to_z(symbol: &str) -> Option<u32> {
1698 match symbol {
1699 "H" => Some(1),
1700 "HE" => Some(2),
1701 "LI" => Some(3),
1702 "BE" => Some(4),
1703 "B" => Some(5),
1704 "C" => Some(6),
1705 "N" => Some(7),
1706 "O" => Some(8),
1707 "F" => Some(9),
1708 "NE" => Some(10),
1709 "NA" => Some(11),
1710 "MG" => Some(12),
1711 "AL" => Some(13),
1712 "SI" => Some(14),
1713 "P" => Some(15),
1714 "S" => Some(16),
1715 "CL" => Some(17),
1716 "AR" => Some(18),
1717 "K" => Some(19),
1718 "CA" => Some(20),
1719 "SC" => Some(21),
1720 "TI" => Some(22),
1721 "V" => Some(23),
1722 "CR" => Some(24),
1723 "MN" => Some(25),
1724 "FE" => Some(26),
1725 "CO" => Some(27),
1726 "NI" => Some(28),
1727 "CU" => Some(29),
1728 "ZN" => Some(30),
1729 "GA" => Some(31),
1730 "GE" => Some(32),
1731 "AS" => Some(33),
1732 "SE" => Some(34),
1733 "BR" => Some(35),
1734 "KR" => Some(36),
1735 "RB" => Some(37),
1736 "SR" => Some(38),
1737 "Y" => Some(39),
1738 "ZR" => Some(40),
1739 "NB" => Some(41),
1740 "MO" => Some(42),
1741 "TC" => Some(43),
1742 "RU" => Some(44),
1743 "RH" => Some(45),
1744 "PD" => Some(46),
1745 "AG" => Some(47),
1746 "CD" => Some(48),
1747 "IN" => Some(49),
1748 "SN" => Some(50),
1749 "SB" => Some(51),
1750 "TE" => Some(52),
1751 "I" => Some(53),
1752 "XE" => Some(54),
1753 "CS" => Some(55),
1754 "BA" => Some(56),
1755 "LA" => Some(57),
1756 "CE" => Some(58),
1757 "PR" => Some(59),
1758 "ND" => Some(60),
1759 "PM" => Some(61),
1760 "SM" => Some(62),
1761 "EU" => Some(63),
1762 "GD" => Some(64),
1763 "TB" => Some(65),
1764 "DY" => Some(66),
1765 "HO" => Some(67),
1766 "ER" => Some(68),
1767 "TM" => Some(69),
1768 "YB" => Some(70),
1769 "LU" => Some(71),
1770 "HF" => Some(72),
1771 "TA" => Some(73),
1772 "W" => Some(74),
1773 "RE" => Some(75),
1774 "OS" => Some(76),
1775 "IR" => Some(77),
1776 "PT" => Some(78),
1777 "AU" => Some(79),
1778 "HG" => Some(80),
1779 "TL" => Some(81),
1780 "PB" => Some(82),
1781 "BI" => Some(83),
1782 "PO" => Some(84),
1783 "AT" => Some(85),
1784 "RN" => Some(86),
1785 "FR" => Some(87),
1786 "RA" => Some(88),
1787 "AC" => Some(89),
1788 "TH" => Some(90),
1789 "PA" => Some(91),
1790 "U" => Some(92),
1791 "NP" => Some(93),
1792 "PU" => Some(94),
1793 "AM" => Some(95),
1794 "CM" => Some(96),
1795 "BK" => Some(97),
1796 "CF" => Some(98),
1797 "ES" => Some(99),
1798 "FM" => Some(100),
1799 _ => None,
1800 }
1801}
1802
1803fn element_name_to_z(name: &str) -> Option<u32> {
1808 match name {
1809 "HYDROGEN" => Some(1),
1810 "HELIUM" => Some(2),
1811 "LITHIUM" => Some(3),
1812 "BERYLLIUM" => Some(4),
1813 "BORON" => Some(5),
1814 "CARBON" => Some(6),
1815 "NITROGEN" => Some(7),
1816 "OXYGEN" => Some(8),
1817 "FLUORINE" => Some(9),
1818 "NEON" => Some(10),
1819 "SODIUM" => Some(11),
1820 "MAGNESIUM" => Some(12),
1821 "ALUMINUM" | "ALUMINIUM" => Some(13),
1822 "SILICON" => Some(14),
1823 "PHOSPHORUS" => Some(15),
1824 "SULFUR" | "SULPHUR" => Some(16),
1825 "CHLORINE" => Some(17),
1826 "ARGON" => Some(18),
1827 "POTASSIUM" => Some(19),
1828 "CALCIUM" => Some(20),
1829 "SCANDIUM" => Some(21),
1830 "TITANIUM" => Some(22),
1831 "VANADIUM" => Some(23),
1832 "CHROMIUM" => Some(24),
1833 "MANGANESE" => Some(25),
1834 "IRON" => Some(26),
1835 "COBALT" => Some(27),
1836 "NICKEL" => Some(28),
1837 "COPPER" => Some(29),
1838 "ZINC" => Some(30),
1839 "GALLIUM" => Some(31),
1840 "GERMANIUM" => Some(32),
1841 "ARSENIC" => Some(33),
1842 "SELENIUM" => Some(34),
1843 "BROMINE" => Some(35),
1844 "KRYPTON" => Some(36),
1845 "RUBIDIUM" => Some(37),
1846 "STRONTIUM" => Some(38),
1847 "YTTRIUM" => Some(39),
1848 "ZIRCONIUM" => Some(40),
1849 "NIOBIUM" => Some(41),
1850 "MOLYBDENUM" => Some(42),
1851 "TECHNETIUM" => Some(43),
1852 "RUTHENIUM" => Some(44),
1853 "RHODIUM" => Some(45),
1854 "PALLADIUM" => Some(46),
1855 "SILVER" => Some(47),
1856 "CADMIUM" => Some(48),
1857 "INDIUM" => Some(49),
1858 "TIN" => Some(50),
1859 "ANTIMONY" => Some(51),
1860 "TELLURIUM" => Some(52),
1861 "IODINE" => Some(53),
1862 "XENON" => Some(54),
1863 "CESIUM" | "CAESIUM" => Some(55),
1864 "BARIUM" => Some(56),
1865 "LANTHANUM" => Some(57),
1866 "CERIUM" => Some(58),
1867 "PRASEODYMIUM" => Some(59),
1868 "NEODYMIUM" => Some(60),
1869 "PROMETHIUM" => Some(61),
1870 "SAMARIUM" => Some(62),
1871 "EUROPIUM" => Some(63),
1872 "GADOLINIUM" => Some(64),
1873 "TERBIUM" => Some(65),
1874 "DYSPROSIUM" => Some(66),
1875 "HOLMIUM" => Some(67),
1876 "ERBIUM" => Some(68),
1877 "THULIUM" => Some(69),
1878 "YTTERBIUM" => Some(70),
1879 "LUTETIUM" => Some(71),
1880 "HAFNIUM" => Some(72),
1881 "TANTALUM" => Some(73),
1882 "TUNGSTEN" => Some(74),
1883 "RHENIUM" => Some(75),
1884 "OSMIUM" => Some(76),
1885 "IRIDIUM" => Some(77),
1886 "PLATINUM" => Some(78),
1887 "GOLD" => Some(79),
1888 "MERCURY" => Some(80),
1889 "THALLIUM" => Some(81),
1890 "LEAD" => Some(82),
1891 "BISMUTH" => Some(83),
1892 "THORIUM" => Some(90),
1893 "PROTACTINIUM" => Some(91),
1894 "URANIUM" => Some(92),
1895 "NEPTUNIUM" => Some(93),
1896 "PLUTONIUM" => Some(94),
1897 "AMERICIUM" => Some(95),
1898 "CURIUM" => Some(96),
1899 "BERKELIUM" => Some(97),
1900 "CALIFORNIUM" => Some(98),
1901 _ => None,
1902 }
1903}
1904
1905#[cfg(test)]
1908mod tests {
1909 use super::*;
1910
1911 const FE56_TR007_INP: &str = include_str!(
1917 "../../../tests/data/samtry/tr007_fe56_transmission_doppler_resolution/t007a.inp"
1918 );
1919
1920 fn tr007_with_observation_type(obs_type: &str) -> String {
1926 FE56_TR007_INP.replace("\nTRANSMISSION\n", &format!("\n{obs_type}\n"))
1927 }
1928
1929 #[test]
1930 fn test_parse_plt_header_and_data() {
1931 let content = "\
1932 Energy Data Uncertainty Th_initial Th_final
1933 1.1330168 8.9078373 0.32005507 8.4964191 8.5014004
1934 1.1336480 8.5677882 0.31321707 8.5003345 8.5048604
1935";
1936 let records = parse_sammy_plt(content).unwrap();
1937 assert_eq!(records.len(), 2);
1938 assert!((records[0].energy_kev - 1.1330168).abs() < 1e-7);
1939 assert!((records[0].theory_initial - 8.4964191).abs() < 1e-5);
1940 assert!((records[1].energy_kev - 1.1336480).abs() < 1e-7);
1941 }
1942
1943 #[test]
1944 fn test_parse_par_tr007() {
1945 let content = "\
1946-2070. 1450. 186600. 0. 0. 0 0 0 0 0 1
194727650. 1400. 1480000. 0. 0. 1 0 1 0 0 1
19481151.07 600. 62. 0. 0. 1 1 1 0 0 2
1949
1950
1951EXPLICIT
1952";
1953 let par = parse_sammy_par(content).unwrap();
1954 assert_eq!(par.resonances.len(), 3);
1955
1956 let r0 = &par.resonances[0];
1958 assert!((r0.energy_ev - (-2070.0)).abs() < 1e-6);
1959 assert!((r0.gamma_gamma_ev - 1.45).abs() < 1e-6); assert!((r0.gamma_n_ev - 186.6).abs() < 1e-6); assert_eq!(r0.spin_group, 1);
1962
1963 let r2 = &par.resonances[2];
1965 assert!((r2.energy_ev - 1151.07).abs() < 1e-6);
1966 assert!((r2.gamma_gamma_ev - 0.6).abs() < 1e-6); assert!((r2.gamma_n_ev - 0.062).abs() < 1e-6); assert_eq!(r2.spin_group, 2);
1969 }
1970
1971 #[test]
1972 fn test_parse_inp_tr007() {
1973 let inp = parse_sammy_inp(FE56_TR007_INP).unwrap();
1974 assert_eq!(inp.isotope_symbol, "FE56");
1975 assert!((inp.awr - 55.9).abs() < 1e-6);
1976 assert!((inp.energy_min_ev - 1133.01).abs() < 1e-6);
1977 assert!((inp.energy_max_ev - 1170.517).abs() < 1e-6);
1978 assert!((inp.temperature_k - 329.0).abs() < 1e-6);
1979 assert!((inp.flight_path_m - 80.263).abs() < 1e-6);
1980 assert!((inp.delta_l_sammy - 0.0301).abs() < 1e-6);
1982 assert!((inp.delta_e_sammy - 0.0).abs() < 1e-6);
1983 assert!((inp.delta_g_sammy - 0.021994).abs() < 1e-6);
1984 assert_eq!(inp.broadening_delta_l, Some(0.025));
1986 assert_eq!(inp.broadening_delta_g, Some(0.022));
1987 assert_eq!(inp.broadening_delta_e, Some(0.022));
1988 assert!((inp.effective_delta_l() - 0.025).abs() < 1e-6);
1990 assert!((inp.effective_delta_g() - 0.022).abs() < 1e-6);
1991 assert!((inp.effective_delta_e() - 0.022).abs() < 1e-6);
1992 assert!((inp.scattering_radius_fm - 6.0).abs() < 1e-6);
1993 assert!((inp.thickness_atoms_barn - 0.2179).abs() < 1e-6);
1994 assert!((inp.target_spin - 0.0).abs() < 1e-6);
1996
1997 assert_eq!(inp.spin_groups.len(), 2);
1999 assert_eq!(inp.spin_groups[0].index, 1);
2000 assert!((inp.spin_groups[0].j - 0.5).abs() < 1e-6);
2001 assert_eq!(inp.spin_groups[0].l, 0);
2002 assert_eq!(inp.spin_groups[1].index, 2);
2003 assert!((inp.spin_groups[1].j - (-0.5)).abs() < 1e-6); assert_eq!(inp.spin_groups[1].l, 1);
2008 }
2009
2010 #[test]
2011 fn test_parse_isotope_symbol() {
2012 assert_eq!(parse_isotope_symbol("60NI").unwrap(), (28, 60));
2013 assert_eq!(parse_isotope_symbol("FE56").unwrap(), (26, 56));
2014 assert_eq!(parse_isotope_symbol("58NI").unwrap(), (28, 58));
2015 assert!(parse_isotope_symbol("UNKNOWN99").is_err());
2016 assert_eq!(parse_isotope_symbol("Cu65").unwrap(), (29, 65));
2018 assert_eq!(parse_isotope_symbol("Cu63").unwrap(), (29, 63));
2019 assert_eq!(parse_isotope_symbol("CU 65").unwrap(), (29, 65));
2020 assert_eq!(parse_isotope_symbol("NatFE").unwrap(), (26, 0));
2022 assert_eq!(parse_isotope_symbol("NatFe").unwrap(), (26, 0));
2023 }
2024
2025 #[test]
2026 fn test_sammy_to_resonance_data_tr007() {
2027 let inp = SammyInpConfig {
2028 title: "test".to_string(),
2029 isotope_symbol: "FE56".to_string(),
2030 awr: 55.9,
2031 energy_min_ev: 1133.01,
2032 energy_max_ev: 1170.517,
2033 temperature_k: 329.0,
2034 flight_path_m: 80.263,
2035 delta_l_sammy: 0.0301,
2036 delta_e_sammy: 0.0,
2037 delta_g_sammy: 0.021994,
2038 no_broadening: false,
2039 broadening_delta_l: None,
2040 broadening_delta_g: None,
2041 broadening_delta_e: None,
2042 scattering_radius_fm: 6.0,
2043 thickness_atoms_barn: 0.2179,
2044 target_spin: 0.0,
2045 observation_type: SammyObservationType::default(),
2046 spin_groups: vec![
2047 SammySpinGroup {
2048 index: 1,
2049 j: 0.5,
2050 l: 0,
2051 abundance: 0.9999,
2052 target_spin: 0.0,
2053 isotope_label: None,
2054 },
2055 SammySpinGroup {
2056 index: 2,
2057 j: -0.5, l: 1, abundance: 0.9172,
2060 target_spin: 0.0,
2061 isotope_label: None,
2062 },
2063 ],
2064 };
2065 let par = SammyParFile {
2066 resonances: vec![
2067 SammyResonance {
2068 energy_ev: -2070.0,
2069 gamma_gamma_ev: 1.45,
2070 gamma_n_ev: 186.6,
2071 gamma_f1_ev: 0.0,
2072 gamma_f2_ev: 0.0,
2073 spin_group: 1,
2074 },
2075 SammyResonance {
2076 energy_ev: 1151.07,
2077 gamma_gamma_ev: 0.6,
2078 gamma_n_ev: 0.062,
2079 gamma_f1_ev: 0.0,
2080 gamma_f2_ev: 0.0,
2081 spin_group: 2,
2082 },
2083 ],
2084 radius_overrides: std::collections::HashMap::new(),
2085 r_external: std::collections::HashMap::new(),
2086 isotopic_masses: Vec::new(),
2087 };
2088 let rd = sammy_to_resonance_data(&inp, &par).unwrap();
2089 assert_eq!(rd.za, 26056); assert_eq!(rd.ranges.len(), 1);
2091 assert_eq!(rd.ranges[0].formalism, ResonanceFormalism::ReichMoore);
2092 assert!((rd.ranges[0].scattering_radius - 6.0).abs() < 1e-6);
2093 assert_eq!(rd.ranges[0].l_groups.len(), 2);
2095 assert_eq!(rd.ranges[0].l_groups[0].l, 0);
2097 assert_eq!(rd.ranges[0].l_groups[0].resonances.len(), 1);
2098 assert_eq!(rd.ranges[0].l_groups[1].l, 1);
2099 assert_eq!(rd.ranges[0].l_groups[1].resonances.len(), 1);
2100 let res_l0 = &rd.ranges[0].l_groups[0].resonances[0];
2102 let res_l1 = &rd.ranges[0].l_groups[1].resonances[0];
2103 assert!((res_l0.j - 0.5).abs() < 1e-6, "spin group 1 → J=+0.5");
2104 assert!((res_l1.j - (-0.5)).abs() < 1e-6, "spin group 2 → J=-0.5");
2105 }
2106
2107 #[test]
2108 fn test_sammy_to_nereids_resolution_conversion() {
2109 let inp = SammyInpConfig {
2111 title: String::new(),
2112 isotope_symbol: "FE56".to_string(),
2113 awr: 55.9,
2114 energy_min_ev: 1133.0,
2115 energy_max_ev: 1170.0,
2116 temperature_k: 329.0,
2117 flight_path_m: 80.263,
2118 delta_l_sammy: 0.0301,
2119 delta_e_sammy: 0.0,
2120 delta_g_sammy: 0.021994,
2121 no_broadening: false,
2122 broadening_delta_l: Some(0.025),
2123 broadening_delta_g: Some(0.022),
2124 broadening_delta_e: Some(0.022),
2125 scattering_radius_fm: 6.0,
2126 thickness_atoms_barn: 0.2179,
2127 target_spin: 0.0,
2128 observation_type: SammyObservationType::default(),
2129 spin_groups: vec![],
2130 };
2131
2132 let (flight_path, delta_t, delta_l, delta_e) =
2133 sammy_to_nereids_resolution(&inp).expect("should return Some for non-zero params");
2134
2135 assert!((flight_path - 80.263).abs() < 1e-10);
2136 let expected_dt = 0.022 / (2.0 * 2.0_f64.ln().sqrt());
2138 assert!(
2139 (delta_t - expected_dt).abs() < 1e-12,
2140 "delta_t={delta_t}, expected={expected_dt}"
2141 );
2142 let expected_dl = 0.025 / 6.0_f64.sqrt();
2144 assert!(
2145 (delta_l - expected_dl).abs() < 1e-12,
2146 "delta_l={delta_l}, expected={expected_dl}"
2147 );
2148 assert!(
2150 (delta_e - 0.022).abs() < 1e-12,
2151 "delta_e={delta_e}, expected=0.022"
2152 );
2153 }
2154
2155 #[test]
2156 fn test_sammy_to_nereids_resolution_zero_params() {
2157 let inp = SammyInpConfig {
2158 title: String::new(),
2159 isotope_symbol: "FE56".to_string(),
2160 awr: 55.9,
2161 energy_min_ev: 1133.0,
2162 energy_max_ev: 1170.0,
2163 temperature_k: 329.0,
2164 flight_path_m: 80.263,
2165 delta_l_sammy: 0.0,
2166 delta_e_sammy: 0.0,
2167 delta_g_sammy: 0.0,
2168 no_broadening: false,
2169 broadening_delta_l: None,
2170 broadening_delta_g: None,
2171 broadening_delta_e: None,
2172 scattering_radius_fm: 6.0,
2173 thickness_atoms_barn: 0.2179,
2174 target_spin: 0.0,
2175 observation_type: SammyObservationType::default(),
2176 spin_groups: vec![],
2177 };
2178
2179 assert!(
2180 sammy_to_nereids_resolution(&inp).is_none(),
2181 "should return None when both Deltal and Deltag are zero"
2182 );
2183 }
2184
2185 #[test]
2186 fn test_parse_fortran_d_exponent() {
2187 assert!((parse_fortran_float("1.23D-5").unwrap() - 1.23e-5).abs() < 1e-20);
2188 assert!((parse_fortran_float("1.23d-5").unwrap() - 1.23e-5).abs() < 1e-20);
2189 assert!((parse_fortran_float("5.4D+3").unwrap() - 5.4e3).abs() < 1e-6);
2190 assert!((parse_fortran_float("1.0D0").unwrap() - 1.0).abs() < 1e-15);
2191 }
2192
2193 #[test]
2195 fn test_capture_observation_type_rejected() {
2196 let content = tr007_with_observation_type("CAPTURE CROSS SECTION");
2197 let result = parse_sammy_inp(&content);
2198 assert!(result.is_err(), "CAPTURE should be rejected");
2199 let msg = result.unwrap_err().message;
2200 assert!(
2201 msg.contains("CAPTURE"),
2202 "Error message should mention CAPTURE, got: {msg}"
2203 );
2204 }
2205
2206 #[test]
2208 fn test_dirac_observation_type_rejected() {
2209 let content = tr007_with_observation_type("DIRAC CROSS SECTION");
2210 let result = parse_sammy_inp(&content);
2211 assert!(result.is_err(), "DIRAC should be rejected");
2212 let msg = result.unwrap_err().message;
2213 assert!(
2214 msg.contains("DIRAC"),
2215 "Error message should mention DIRAC, got: {msg}"
2216 );
2217 }
2218}