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 let range = ResonanceRange {
1326 energy_low: 1e-5,
1327 energy_high: 2e7,
1328 resolved: true,
1329 formalism: ResonanceFormalism::ReichMoore,
1330 target_spin,
1331 scattering_radius: inp.scattering_radius_fm,
1332 naps: 1,
1334 ap_table: None,
1335 l_groups,
1336 rml: None,
1337 urr: None,
1338 r_external: vec![],
1339 };
1340
1341 Ok(ResonanceData {
1342 isotope: Isotope::new(z, a)
1343 .map_err(|e| SammyParseError::new(format!("invalid isotope: {e}")))?,
1344 za,
1345 awr: inp.awr,
1346 ranges: vec![range],
1347 })
1348}
1349
1350pub fn sammy_to_resonance_data_multi(
1359 inp: &SammyInpConfig,
1360 par: &SammyParFile,
1361) -> Result<Vec<(ResonanceData, f64)>, SammyParseError> {
1362 if inp.spin_groups.is_empty() {
1363 return Err(SammyParseError::new("no spin groups defined in .inp file"));
1364 }
1365
1366 let has_labels = inp.spin_groups.iter().any(|sg| sg.isotope_label.is_some());
1370
1371 let mut group_order: Vec<String> = Vec::new();
1373 let mut group_members: std::collections::HashMap<String, Vec<usize>> =
1374 std::collections::HashMap::new();
1375 let mut group_abundance: std::collections::HashMap<String, f64> =
1376 std::collections::HashMap::new();
1377 let mut group_target_spin: std::collections::HashMap<String, f64> =
1378 std::collections::HashMap::new();
1379
1380 for (i, sg) in inp.spin_groups.iter().enumerate() {
1381 let key = if has_labels {
1382 sg.isotope_label
1383 .as_deref()
1384 .unwrap_or("unknown")
1385 .to_uppercase()
1386 } else {
1387 format!("{:.6}_{:.1}", sg.abundance, sg.target_spin)
1388 };
1389
1390 if !group_members.contains_key(&key) {
1391 group_order.push(key.clone());
1392 }
1393 group_members.entry(key.clone()).or_default().push(i);
1394 if let Some(&existing) = group_abundance.get(&key)
1395 && (existing - sg.abundance).abs() >= 1e-12
1396 {
1397 return Err(SammyParseError::new(format!(
1398 "spin groups in isotope group '{}' disagree on abundance: {} vs {}",
1399 key, existing, sg.abundance
1400 )));
1401 }
1402 group_abundance.insert(key.clone(), sg.abundance);
1403 if let Some(&existing) = group_target_spin.get(&key)
1404 && (existing - sg.target_spin).abs() >= 1e-12
1405 {
1406 return Err(SammyParseError::new(format!(
1407 "spin groups in isotope group '{}' disagree on target_spin: {} vs {}",
1408 key, existing, sg.target_spin
1409 )));
1410 }
1411 group_target_spin.insert(key.clone(), sg.target_spin);
1412 }
1413
1414 let sg_map: std::collections::HashMap<u32, &SammySpinGroup> =
1416 inp.spin_groups.iter().map(|sg| (sg.index, sg)).collect();
1417
1418 let mut sg_iso_awr: std::collections::HashMap<u32, f64> = std::collections::HashMap::new();
1421 let mut sg_iso_abund: std::collections::HashMap<u32, f64> = std::collections::HashMap::new();
1422 for im in &par.isotopic_masses {
1423 for &sg_idx in &im.spin_groups {
1424 sg_iso_awr.insert(sg_idx, im.awr);
1425 sg_iso_abund.insert(sg_idx, im.abundance);
1426 }
1427 }
1428
1429 let (card2_z, card2_a) = parse_isotope_symbol(&inp.isotope_symbol)?;
1431
1432 let mut result = Vec::new();
1433
1434 for key in &group_order {
1435 let member_indices = &group_members[key];
1436 let target_spin = group_target_spin[key];
1437
1438 let first_sg_idx = inp.spin_groups[member_indices[0]].index;
1441 let abundance = sg_iso_abund
1442 .get(&first_sg_idx)
1443 .copied()
1444 .unwrap_or(group_abundance[key]);
1445
1446 let sg_indices: std::collections::HashSet<u32> = member_indices
1448 .iter()
1449 .map(|&i| inp.spin_groups[i].index)
1450 .collect();
1451
1452 let group_resonances: Vec<&SammyResonance> = par
1454 .resonances
1455 .iter()
1456 .filter(|r| sg_indices.contains(&r.spin_group))
1457 .collect();
1458
1459 let member_sgs: Vec<SammySpinGroup> = member_indices
1462 .iter()
1463 .map(|&i| inp.spin_groups[i].clone())
1464 .collect();
1465 let sg_j_offset = compute_j_offsets(&member_sgs, target_spin);
1466
1467 let mut l_group_map: std::collections::BTreeMap<u32, Vec<Resonance>> =
1469 std::collections::BTreeMap::new();
1470
1471 for res in &group_resonances {
1472 let sg = sg_map.get(&res.spin_group).ok_or_else(|| {
1473 SammyParseError::new(format!(
1474 "resonance at E={} eV references undefined spin group {}",
1475 res.energy_ev, res.spin_group
1476 ))
1477 })?;
1478
1479 let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1480 l_group_map.entry(sg.l).or_default().push(Resonance {
1481 energy: res.energy_ev,
1482 j,
1483 gn: res.gamma_n_ev,
1484 gg: res.gamma_gamma_ev,
1485 gfa: res.gamma_f1_ev,
1486 gfb: res.gamma_f2_ev,
1487 });
1488 }
1489
1490 let res_sg_indices: std::collections::HashSet<u32> =
1493 group_resonances.iter().map(|r| r.spin_group).collect();
1494 for &idx in &sg_indices {
1495 if !res_sg_indices.contains(&idx)
1496 && let Some(sg) = sg_map.get(&idx)
1497 {
1498 let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1499 l_group_map.entry(sg.l).or_default().push(Resonance {
1500 energy: 0.0,
1501 j,
1502 gn: 0.0,
1503 gg: 0.0,
1504 gfa: 0.0,
1505 gfb: 0.0,
1506 });
1507 }
1508 }
1509
1510 let mut l_radius_map: std::collections::HashMap<u32, f64> =
1512 std::collections::HashMap::new();
1513 for &idx in &sg_indices {
1514 if let Some(sg) = sg_map.get(&idx)
1515 && let Some(&r) = par.radius_overrides.get(&sg.index)
1516 {
1517 l_radius_map.entry(sg.l).or_insert(r);
1518 }
1519 }
1520
1521 let isotope_awr = sg_iso_awr.get(&first_sg_idx).copied().unwrap_or(inp.awr);
1524
1525 let l_groups: Vec<LGroup> = l_group_map
1526 .into_iter()
1527 .map(|(l, resonances)| {
1528 let apl = l_radius_map.get(&l).copied().unwrap_or(0.0);
1529 LGroup {
1530 l,
1531 awr: isotope_awr,
1532 apl,
1533 qx: 0.0,
1534 lrx: 0,
1535 resonances,
1536 }
1537 })
1538 .collect();
1539
1540 let (z, mut a) = if has_labels {
1542 parse_isotope_symbol(key).unwrap_or((card2_z, card2_a))
1544 } else {
1545 (card2_z, card2_a)
1546 };
1547 if a == 0 {
1549 a = isotope_awr.round() as u32;
1550 }
1551 let za = z * 1000 + a;
1552
1553 let mut r_external_entries = Vec::new();
1555 for &idx in &sg_indices {
1556 if let Some(sg) = sg_map.get(&idx)
1557 && let Some(params) = par.r_external.get(&sg.index)
1558 {
1559 let j = sg.j + sg_j_offset.get(&sg.index).copied().unwrap_or(0.0);
1560 r_external_entries.push(RExternalEntry {
1561 l: sg.l,
1562 j,
1563 e_low: params[0],
1564 e_up: params[1],
1565 r_con: params[2],
1566 r_lin: params[3],
1567 s_con: params[4],
1568 s_lin: params[5],
1569 r_quad: params[6],
1570 });
1571 }
1572 }
1573
1574 let range = ResonanceRange {
1575 energy_low: 1e-5,
1576 energy_high: 2e7,
1577 resolved: true,
1578 formalism: ResonanceFormalism::ReichMoore,
1579 target_spin,
1580 scattering_radius: inp.scattering_radius_fm,
1581 naps: 1,
1583 ap_table: None,
1584 l_groups,
1585 rml: None,
1586 urr: None,
1587 r_external: r_external_entries,
1588 };
1589
1590 let resonance_data = ResonanceData {
1591 isotope: Isotope::new(z, a)
1592 .map_err(|e| SammyParseError::new(format!("invalid isotope: {e}")))?,
1593 za,
1594 awr: isotope_awr,
1595 ranges: vec![range],
1596 };
1597
1598 result.push((resonance_data, abundance));
1599 }
1600
1601 Ok(result)
1602}
1603
1604pub(crate) fn parse_isotope_symbol(symbol: &str) -> Result<(u32, u32), SammyParseError> {
1611 let joined: String = symbol
1613 .chars()
1614 .filter(|c| !c.is_whitespace() && *c != '-')
1615 .collect();
1616 let s = joined.to_uppercase();
1617
1618 if let Some(rest) = s.strip_prefix("NAT")
1620 && let Some(z) = element_symbol_to_z(rest)
1621 {
1622 return Ok((z, 0));
1623 }
1624
1625 if let Some(split_pos) = s.find(|c: char| c.is_ascii_alphabetic()) {
1629 let mass_str = &s[..split_pos];
1630 let remaining = &s[split_pos..];
1631 let alpha_len = remaining
1632 .find(|c: char| !c.is_ascii_alphabetic())
1633 .unwrap_or(remaining.len());
1634 let elem_str = &remaining[..alpha_len];
1635 if !mass_str.is_empty()
1636 && let Ok(a) = mass_str.parse::<u32>()
1637 && let Some(z) = element_symbol_to_z(elem_str)
1638 {
1639 return Ok((z, a));
1640 }
1641 }
1642
1643 if let Some(split_pos) = s.find(|c: char| c.is_ascii_digit()) {
1646 let elem_str = &s[..split_pos];
1647 let remaining = &s[split_pos..];
1648 let digit_len = remaining
1649 .find(|c: char| !c.is_ascii_digit())
1650 .unwrap_or(remaining.len());
1651 let mass_str = &remaining[..digit_len];
1652 if let Ok(a) = mass_str.parse::<u32>()
1653 && let Some(z) = element_symbol_to_z(elem_str)
1654 {
1655 return Ok((z, a));
1656 }
1657 }
1658
1659 if let Some(z) = element_symbol_to_z(&s) {
1661 return Ok((z, 0));
1662 }
1663
1664 if let Some(z) = element_name_to_z(&s) {
1666 return Ok((z, 0));
1667 }
1668
1669 Err(SammyParseError::new(format!(
1670 "cannot parse isotope symbol: {symbol}"
1671 )))
1672}
1673
1674fn element_symbol_to_z(symbol: &str) -> Option<u32> {
1678 match symbol {
1679 "H" => Some(1),
1680 "HE" => Some(2),
1681 "LI" => Some(3),
1682 "BE" => Some(4),
1683 "B" => Some(5),
1684 "C" => Some(6),
1685 "N" => Some(7),
1686 "O" => Some(8),
1687 "F" => Some(9),
1688 "NE" => Some(10),
1689 "NA" => Some(11),
1690 "MG" => Some(12),
1691 "AL" => Some(13),
1692 "SI" => Some(14),
1693 "P" => Some(15),
1694 "S" => Some(16),
1695 "CL" => Some(17),
1696 "AR" => Some(18),
1697 "K" => Some(19),
1698 "CA" => Some(20),
1699 "SC" => Some(21),
1700 "TI" => Some(22),
1701 "V" => Some(23),
1702 "CR" => Some(24),
1703 "MN" => Some(25),
1704 "FE" => Some(26),
1705 "CO" => Some(27),
1706 "NI" => Some(28),
1707 "CU" => Some(29),
1708 "ZN" => Some(30),
1709 "GA" => Some(31),
1710 "GE" => Some(32),
1711 "AS" => Some(33),
1712 "SE" => Some(34),
1713 "BR" => Some(35),
1714 "KR" => Some(36),
1715 "RB" => Some(37),
1716 "SR" => Some(38),
1717 "Y" => Some(39),
1718 "ZR" => Some(40),
1719 "NB" => Some(41),
1720 "MO" => Some(42),
1721 "TC" => Some(43),
1722 "RU" => Some(44),
1723 "RH" => Some(45),
1724 "PD" => Some(46),
1725 "AG" => Some(47),
1726 "CD" => Some(48),
1727 "IN" => Some(49),
1728 "SN" => Some(50),
1729 "SB" => Some(51),
1730 "TE" => Some(52),
1731 "I" => Some(53),
1732 "XE" => Some(54),
1733 "CS" => Some(55),
1734 "BA" => Some(56),
1735 "LA" => Some(57),
1736 "CE" => Some(58),
1737 "PR" => Some(59),
1738 "ND" => Some(60),
1739 "PM" => Some(61),
1740 "SM" => Some(62),
1741 "EU" => Some(63),
1742 "GD" => Some(64),
1743 "TB" => Some(65),
1744 "DY" => Some(66),
1745 "HO" => Some(67),
1746 "ER" => Some(68),
1747 "TM" => Some(69),
1748 "YB" => Some(70),
1749 "LU" => Some(71),
1750 "HF" => Some(72),
1751 "TA" => Some(73),
1752 "W" => Some(74),
1753 "RE" => Some(75),
1754 "OS" => Some(76),
1755 "IR" => Some(77),
1756 "PT" => Some(78),
1757 "AU" => Some(79),
1758 "HG" => Some(80),
1759 "TL" => Some(81),
1760 "PB" => Some(82),
1761 "BI" => Some(83),
1762 "PO" => Some(84),
1763 "AT" => Some(85),
1764 "RN" => Some(86),
1765 "FR" => Some(87),
1766 "RA" => Some(88),
1767 "AC" => Some(89),
1768 "TH" => Some(90),
1769 "PA" => Some(91),
1770 "U" => Some(92),
1771 "NP" => Some(93),
1772 "PU" => Some(94),
1773 "AM" => Some(95),
1774 "CM" => Some(96),
1775 "BK" => Some(97),
1776 "CF" => Some(98),
1777 "ES" => Some(99),
1778 "FM" => Some(100),
1779 _ => None,
1780 }
1781}
1782
1783fn element_name_to_z(name: &str) -> Option<u32> {
1788 match name {
1789 "HYDROGEN" => Some(1),
1790 "HELIUM" => Some(2),
1791 "LITHIUM" => Some(3),
1792 "BERYLLIUM" => Some(4),
1793 "BORON" => Some(5),
1794 "CARBON" => Some(6),
1795 "NITROGEN" => Some(7),
1796 "OXYGEN" => Some(8),
1797 "FLUORINE" => Some(9),
1798 "NEON" => Some(10),
1799 "SODIUM" => Some(11),
1800 "MAGNESIUM" => Some(12),
1801 "ALUMINUM" | "ALUMINIUM" => Some(13),
1802 "SILICON" => Some(14),
1803 "PHOSPHORUS" => Some(15),
1804 "SULFUR" | "SULPHUR" => Some(16),
1805 "CHLORINE" => Some(17),
1806 "ARGON" => Some(18),
1807 "POTASSIUM" => Some(19),
1808 "CALCIUM" => Some(20),
1809 "SCANDIUM" => Some(21),
1810 "TITANIUM" => Some(22),
1811 "VANADIUM" => Some(23),
1812 "CHROMIUM" => Some(24),
1813 "MANGANESE" => Some(25),
1814 "IRON" => Some(26),
1815 "COBALT" => Some(27),
1816 "NICKEL" => Some(28),
1817 "COPPER" => Some(29),
1818 "ZINC" => Some(30),
1819 "GALLIUM" => Some(31),
1820 "GERMANIUM" => Some(32),
1821 "ARSENIC" => Some(33),
1822 "SELENIUM" => Some(34),
1823 "BROMINE" => Some(35),
1824 "KRYPTON" => Some(36),
1825 "RUBIDIUM" => Some(37),
1826 "STRONTIUM" => Some(38),
1827 "YTTRIUM" => Some(39),
1828 "ZIRCONIUM" => Some(40),
1829 "NIOBIUM" => Some(41),
1830 "MOLYBDENUM" => Some(42),
1831 "TECHNETIUM" => Some(43),
1832 "RUTHENIUM" => Some(44),
1833 "RHODIUM" => Some(45),
1834 "PALLADIUM" => Some(46),
1835 "SILVER" => Some(47),
1836 "CADMIUM" => Some(48),
1837 "INDIUM" => Some(49),
1838 "TIN" => Some(50),
1839 "ANTIMONY" => Some(51),
1840 "TELLURIUM" => Some(52),
1841 "IODINE" => Some(53),
1842 "XENON" => Some(54),
1843 "CESIUM" | "CAESIUM" => Some(55),
1844 "BARIUM" => Some(56),
1845 "LANTHANUM" => Some(57),
1846 "CERIUM" => Some(58),
1847 "PRASEODYMIUM" => Some(59),
1848 "NEODYMIUM" => Some(60),
1849 "PROMETHIUM" => Some(61),
1850 "SAMARIUM" => Some(62),
1851 "EUROPIUM" => Some(63),
1852 "GADOLINIUM" => Some(64),
1853 "TERBIUM" => Some(65),
1854 "DYSPROSIUM" => Some(66),
1855 "HOLMIUM" => Some(67),
1856 "ERBIUM" => Some(68),
1857 "THULIUM" => Some(69),
1858 "YTTERBIUM" => Some(70),
1859 "LUTETIUM" => Some(71),
1860 "HAFNIUM" => Some(72),
1861 "TANTALUM" => Some(73),
1862 "TUNGSTEN" => Some(74),
1863 "RHENIUM" => Some(75),
1864 "OSMIUM" => Some(76),
1865 "IRIDIUM" => Some(77),
1866 "PLATINUM" => Some(78),
1867 "GOLD" => Some(79),
1868 "MERCURY" => Some(80),
1869 "THALLIUM" => Some(81),
1870 "LEAD" => Some(82),
1871 "BISMUTH" => Some(83),
1872 "THORIUM" => Some(90),
1873 "PROTACTINIUM" => Some(91),
1874 "URANIUM" => Some(92),
1875 "NEPTUNIUM" => Some(93),
1876 "PLUTONIUM" => Some(94),
1877 "AMERICIUM" => Some(95),
1878 "CURIUM" => Some(96),
1879 "BERKELIUM" => Some(97),
1880 "CALIFORNIUM" => Some(98),
1881 _ => None,
1882 }
1883}
1884
1885#[cfg(test)]
1888mod tests {
1889 use super::*;
1890
1891 const FE56_TR007_INP: &str = include_str!(
1897 "../../../tests/data/samtry/tr007_fe56_transmission_doppler_resolution/t007a.inp"
1898 );
1899
1900 fn tr007_with_observation_type(obs_type: &str) -> String {
1906 FE56_TR007_INP.replace("\nTRANSMISSION\n", &format!("\n{obs_type}\n"))
1907 }
1908
1909 #[test]
1910 fn test_parse_plt_header_and_data() {
1911 let content = "\
1912 Energy Data Uncertainty Th_initial Th_final
1913 1.1330168 8.9078373 0.32005507 8.4964191 8.5014004
1914 1.1336480 8.5677882 0.31321707 8.5003345 8.5048604
1915";
1916 let records = parse_sammy_plt(content).unwrap();
1917 assert_eq!(records.len(), 2);
1918 assert!((records[0].energy_kev - 1.1330168).abs() < 1e-7);
1919 assert!((records[0].theory_initial - 8.4964191).abs() < 1e-5);
1920 assert!((records[1].energy_kev - 1.1336480).abs() < 1e-7);
1921 }
1922
1923 #[test]
1924 fn test_parse_par_tr007() {
1925 let content = "\
1926-2070. 1450. 186600. 0. 0. 0 0 0 0 0 1
192727650. 1400. 1480000. 0. 0. 1 0 1 0 0 1
19281151.07 600. 62. 0. 0. 1 1 1 0 0 2
1929
1930
1931EXPLICIT
1932";
1933 let par = parse_sammy_par(content).unwrap();
1934 assert_eq!(par.resonances.len(), 3);
1935
1936 let r0 = &par.resonances[0];
1938 assert!((r0.energy_ev - (-2070.0)).abs() < 1e-6);
1939 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);
1942
1943 let r2 = &par.resonances[2];
1945 assert!((r2.energy_ev - 1151.07).abs() < 1e-6);
1946 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);
1949 }
1950
1951 #[test]
1952 fn test_parse_inp_tr007() {
1953 let inp = parse_sammy_inp(FE56_TR007_INP).unwrap();
1954 assert_eq!(inp.isotope_symbol, "FE56");
1955 assert!((inp.awr - 55.9).abs() < 1e-6);
1956 assert!((inp.energy_min_ev - 1133.01).abs() < 1e-6);
1957 assert!((inp.energy_max_ev - 1170.517).abs() < 1e-6);
1958 assert!((inp.temperature_k - 329.0).abs() < 1e-6);
1959 assert!((inp.flight_path_m - 80.263).abs() < 1e-6);
1960 assert!((inp.delta_l_sammy - 0.0301).abs() < 1e-6);
1962 assert!((inp.delta_e_sammy - 0.0).abs() < 1e-6);
1963 assert!((inp.delta_g_sammy - 0.021994).abs() < 1e-6);
1964 assert_eq!(inp.broadening_delta_l, Some(0.025));
1966 assert_eq!(inp.broadening_delta_g, Some(0.022));
1967 assert_eq!(inp.broadening_delta_e, Some(0.022));
1968 assert!((inp.effective_delta_l() - 0.025).abs() < 1e-6);
1970 assert!((inp.effective_delta_g() - 0.022).abs() < 1e-6);
1971 assert!((inp.effective_delta_e() - 0.022).abs() < 1e-6);
1972 assert!((inp.scattering_radius_fm - 6.0).abs() < 1e-6);
1973 assert!((inp.thickness_atoms_barn - 0.2179).abs() < 1e-6);
1974 assert!((inp.target_spin - 0.0).abs() < 1e-6);
1976
1977 assert_eq!(inp.spin_groups.len(), 2);
1979 assert_eq!(inp.spin_groups[0].index, 1);
1980 assert!((inp.spin_groups[0].j - 0.5).abs() < 1e-6);
1981 assert_eq!(inp.spin_groups[0].l, 0);
1982 assert_eq!(inp.spin_groups[1].index, 2);
1983 assert!((inp.spin_groups[1].j - (-0.5)).abs() < 1e-6); assert_eq!(inp.spin_groups[1].l, 1);
1988 }
1989
1990 #[test]
1991 fn test_parse_isotope_symbol() {
1992 assert_eq!(parse_isotope_symbol("60NI").unwrap(), (28, 60));
1993 assert_eq!(parse_isotope_symbol("FE56").unwrap(), (26, 56));
1994 assert_eq!(parse_isotope_symbol("58NI").unwrap(), (28, 58));
1995 assert!(parse_isotope_symbol("UNKNOWN99").is_err());
1996 assert_eq!(parse_isotope_symbol("Cu65").unwrap(), (29, 65));
1998 assert_eq!(parse_isotope_symbol("Cu63").unwrap(), (29, 63));
1999 assert_eq!(parse_isotope_symbol("CU 65").unwrap(), (29, 65));
2000 assert_eq!(parse_isotope_symbol("NatFE").unwrap(), (26, 0));
2002 assert_eq!(parse_isotope_symbol("NatFe").unwrap(), (26, 0));
2003 }
2004
2005 #[test]
2006 fn test_sammy_to_resonance_data_tr007() {
2007 let inp = SammyInpConfig {
2008 title: "test".to_string(),
2009 isotope_symbol: "FE56".to_string(),
2010 awr: 55.9,
2011 energy_min_ev: 1133.01,
2012 energy_max_ev: 1170.517,
2013 temperature_k: 329.0,
2014 flight_path_m: 80.263,
2015 delta_l_sammy: 0.0301,
2016 delta_e_sammy: 0.0,
2017 delta_g_sammy: 0.021994,
2018 no_broadening: false,
2019 broadening_delta_l: None,
2020 broadening_delta_g: None,
2021 broadening_delta_e: None,
2022 scattering_radius_fm: 6.0,
2023 thickness_atoms_barn: 0.2179,
2024 target_spin: 0.0,
2025 observation_type: SammyObservationType::default(),
2026 spin_groups: vec![
2027 SammySpinGroup {
2028 index: 1,
2029 j: 0.5,
2030 l: 0,
2031 abundance: 0.9999,
2032 target_spin: 0.0,
2033 isotope_label: None,
2034 },
2035 SammySpinGroup {
2036 index: 2,
2037 j: -0.5, l: 1, abundance: 0.9172,
2040 target_spin: 0.0,
2041 isotope_label: None,
2042 },
2043 ],
2044 };
2045 let par = SammyParFile {
2046 resonances: vec![
2047 SammyResonance {
2048 energy_ev: -2070.0,
2049 gamma_gamma_ev: 1.45,
2050 gamma_n_ev: 186.6,
2051 gamma_f1_ev: 0.0,
2052 gamma_f2_ev: 0.0,
2053 spin_group: 1,
2054 },
2055 SammyResonance {
2056 energy_ev: 1151.07,
2057 gamma_gamma_ev: 0.6,
2058 gamma_n_ev: 0.062,
2059 gamma_f1_ev: 0.0,
2060 gamma_f2_ev: 0.0,
2061 spin_group: 2,
2062 },
2063 ],
2064 radius_overrides: std::collections::HashMap::new(),
2065 r_external: std::collections::HashMap::new(),
2066 isotopic_masses: Vec::new(),
2067 };
2068 let rd = sammy_to_resonance_data(&inp, &par).unwrap();
2069 assert_eq!(rd.za, 26056); assert_eq!(rd.ranges.len(), 1);
2071 assert_eq!(rd.ranges[0].formalism, ResonanceFormalism::ReichMoore);
2072 assert!((rd.ranges[0].scattering_radius - 6.0).abs() < 1e-6);
2073 assert_eq!(rd.ranges[0].l_groups.len(), 2);
2075 assert_eq!(rd.ranges[0].l_groups[0].l, 0);
2077 assert_eq!(rd.ranges[0].l_groups[0].resonances.len(), 1);
2078 assert_eq!(rd.ranges[0].l_groups[1].l, 1);
2079 assert_eq!(rd.ranges[0].l_groups[1].resonances.len(), 1);
2080 let res_l0 = &rd.ranges[0].l_groups[0].resonances[0];
2082 let res_l1 = &rd.ranges[0].l_groups[1].resonances[0];
2083 assert!((res_l0.j - 0.5).abs() < 1e-6, "spin group 1 → J=+0.5");
2084 assert!((res_l1.j - (-0.5)).abs() < 1e-6, "spin group 2 → J=-0.5");
2085 }
2086
2087 #[test]
2088 fn test_sammy_to_nereids_resolution_conversion() {
2089 let inp = SammyInpConfig {
2091 title: String::new(),
2092 isotope_symbol: "FE56".to_string(),
2093 awr: 55.9,
2094 energy_min_ev: 1133.0,
2095 energy_max_ev: 1170.0,
2096 temperature_k: 329.0,
2097 flight_path_m: 80.263,
2098 delta_l_sammy: 0.0301,
2099 delta_e_sammy: 0.0,
2100 delta_g_sammy: 0.021994,
2101 no_broadening: false,
2102 broadening_delta_l: Some(0.025),
2103 broadening_delta_g: Some(0.022),
2104 broadening_delta_e: Some(0.022),
2105 scattering_radius_fm: 6.0,
2106 thickness_atoms_barn: 0.2179,
2107 target_spin: 0.0,
2108 observation_type: SammyObservationType::default(),
2109 spin_groups: vec![],
2110 };
2111
2112 let (flight_path, delta_t, delta_l, delta_e) =
2113 sammy_to_nereids_resolution(&inp).expect("should return Some for non-zero params");
2114
2115 assert!((flight_path - 80.263).abs() < 1e-10);
2116 let expected_dt = 0.022 / (2.0 * 2.0_f64.ln().sqrt());
2118 assert!(
2119 (delta_t - expected_dt).abs() < 1e-12,
2120 "delta_t={delta_t}, expected={expected_dt}"
2121 );
2122 let expected_dl = 0.025 / 6.0_f64.sqrt();
2124 assert!(
2125 (delta_l - expected_dl).abs() < 1e-12,
2126 "delta_l={delta_l}, expected={expected_dl}"
2127 );
2128 assert!(
2130 (delta_e - 0.022).abs() < 1e-12,
2131 "delta_e={delta_e}, expected=0.022"
2132 );
2133 }
2134
2135 #[test]
2136 fn test_sammy_to_nereids_resolution_zero_params() {
2137 let inp = SammyInpConfig {
2138 title: String::new(),
2139 isotope_symbol: "FE56".to_string(),
2140 awr: 55.9,
2141 energy_min_ev: 1133.0,
2142 energy_max_ev: 1170.0,
2143 temperature_k: 329.0,
2144 flight_path_m: 80.263,
2145 delta_l_sammy: 0.0,
2146 delta_e_sammy: 0.0,
2147 delta_g_sammy: 0.0,
2148 no_broadening: false,
2149 broadening_delta_l: None,
2150 broadening_delta_g: None,
2151 broadening_delta_e: None,
2152 scattering_radius_fm: 6.0,
2153 thickness_atoms_barn: 0.2179,
2154 target_spin: 0.0,
2155 observation_type: SammyObservationType::default(),
2156 spin_groups: vec![],
2157 };
2158
2159 assert!(
2160 sammy_to_nereids_resolution(&inp).is_none(),
2161 "should return None when both Deltal and Deltag are zero"
2162 );
2163 }
2164
2165 #[test]
2166 fn test_parse_fortran_d_exponent() {
2167 assert!((parse_fortran_float("1.23D-5").unwrap() - 1.23e-5).abs() < 1e-20);
2168 assert!((parse_fortran_float("1.23d-5").unwrap() - 1.23e-5).abs() < 1e-20);
2169 assert!((parse_fortran_float("5.4D+3").unwrap() - 5.4e3).abs() < 1e-6);
2170 assert!((parse_fortran_float("1.0D0").unwrap() - 1.0).abs() < 1e-15);
2171 }
2172
2173 #[test]
2175 fn test_capture_observation_type_rejected() {
2176 let content = tr007_with_observation_type("CAPTURE CROSS SECTION");
2177 let result = parse_sammy_inp(&content);
2178 assert!(result.is_err(), "CAPTURE should be rejected");
2179 let msg = result.unwrap_err().message;
2180 assert!(
2181 msg.contains("CAPTURE"),
2182 "Error message should mention CAPTURE, got: {msg}"
2183 );
2184 }
2185
2186 #[test]
2188 fn test_dirac_observation_type_rejected() {
2189 let content = tr007_with_observation_type("DIRAC CROSS SECTION");
2190 let result = parse_sammy_inp(&content);
2191 assert!(result.is_err(), "DIRAC should be rejected");
2192 let msg = result.unwrap_err().message;
2193 assert!(
2194 msg.contains("DIRAC"),
2195 "Error message should mention DIRAC, got: {msg}"
2196 );
2197 }
2198}