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 #[test]
1892 fn test_parse_plt_header_and_data() {
1893 let content = "\
1894 Energy Data Uncertainty Th_initial Th_final
1895 1.1330168 8.9078373 0.32005507 8.4964191 8.5014004
1896 1.1336480 8.5677882 0.31321707 8.5003345 8.5048604
1897";
1898 let records = parse_sammy_plt(content).unwrap();
1899 assert_eq!(records.len(), 2);
1900 assert!((records[0].energy_kev - 1.1330168).abs() < 1e-7);
1901 assert!((records[0].theory_initial - 8.4964191).abs() < 1e-5);
1902 assert!((records[1].energy_kev - 1.1336480).abs() < 1e-7);
1903 }
1904
1905 #[test]
1906 fn test_parse_par_tr007() {
1907 let content = "\
1908-2070. 1450. 186600. 0. 0. 0 0 0 0 0 1
190927650. 1400. 1480000. 0. 0. 1 0 1 0 0 1
19101151.07 600. 62. 0. 0. 1 1 1 0 0 2
1911
1912
1913EXPLICIT
1914";
1915 let par = parse_sammy_par(content).unwrap();
1916 assert_eq!(par.resonances.len(), 3);
1917
1918 let r0 = &par.resonances[0];
1920 assert!((r0.energy_ev - (-2070.0)).abs() < 1e-6);
1921 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);
1924
1925 let r2 = &par.resonances[2];
1927 assert!((r2.energy_ev - 1151.07).abs() < 1e-6);
1928 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);
1931 }
1932
1933 #[test]
1934 fn test_parse_inp_tr007() {
1935 let content = "\
1936 FE TRANSMISSION FROM 1.13 TO 1.18 KEV
1937 FE56 55.9 1133.01 1170.517 99
1938PRINT WEIGHTED RESIDUALS
1939DATA COVARIANCE FILE IS NAMED t007a.dcv
1940PRINT THEORETICAL VALUES
1941PRINT ALL INPUT PARAMETERS
1942PRINT INPUT DATA
1943WRITE NEW INPUT FILE = temp1_thick1.inp
1944#DO NOT SUPPRESS ANY INTERMEDIATE RESULTS
1945GENERATE PLOT FILE AUTOMATICALLY
1946
1947329. 80.263 0.0301 0.0 .021994
19486.0 0.2179
1949TRANSMISSION
1950 1 1 0 0.5 0.9999 .0
1951 1 1 0 0 .500 .000 .000
1952 2 1 0 -0.5 0.9172 .0
1953 1 1 0 1 .500 .000 .000
1954
1955BROADENING
19566.00 329.00 0.2179 0.025 0.022 0.022 1 1 1 1 1 1
1957";
1958 let inp = parse_sammy_inp(content).unwrap();
1959 assert_eq!(inp.isotope_symbol, "FE56");
1960 assert!((inp.awr - 55.9).abs() < 1e-6);
1961 assert!((inp.energy_min_ev - 1133.01).abs() < 1e-6);
1962 assert!((inp.energy_max_ev - 1170.517).abs() < 1e-6);
1963 assert!((inp.temperature_k - 329.0).abs() < 1e-6);
1964 assert!((inp.flight_path_m - 80.263).abs() < 1e-6);
1965 assert!((inp.delta_l_sammy - 0.0301).abs() < 1e-6);
1967 assert!((inp.delta_e_sammy - 0.0).abs() < 1e-6);
1968 assert!((inp.delta_g_sammy - 0.021994).abs() < 1e-6);
1969 assert_eq!(inp.broadening_delta_l, Some(0.025));
1971 assert_eq!(inp.broadening_delta_g, Some(0.022));
1972 assert_eq!(inp.broadening_delta_e, Some(0.022));
1973 assert!((inp.effective_delta_l() - 0.025).abs() < 1e-6);
1975 assert!((inp.effective_delta_g() - 0.022).abs() < 1e-6);
1976 assert!((inp.effective_delta_e() - 0.022).abs() < 1e-6);
1977 assert!((inp.scattering_radius_fm - 6.0).abs() < 1e-6);
1978 assert!((inp.thickness_atoms_barn - 0.2179).abs() < 1e-6);
1979 assert!((inp.target_spin - 0.0).abs() < 1e-6);
1981
1982 assert_eq!(inp.spin_groups.len(), 2);
1984 assert_eq!(inp.spin_groups[0].index, 1);
1985 assert!((inp.spin_groups[0].j - 0.5).abs() < 1e-6);
1986 assert_eq!(inp.spin_groups[0].l, 0);
1987 assert_eq!(inp.spin_groups[1].index, 2);
1988 assert!((inp.spin_groups[1].j - (-0.5)).abs() < 1e-6); assert_eq!(inp.spin_groups[1].l, 1);
1993 }
1994
1995 #[test]
1996 fn test_parse_isotope_symbol() {
1997 assert_eq!(parse_isotope_symbol("60NI").unwrap(), (28, 60));
1998 assert_eq!(parse_isotope_symbol("FE56").unwrap(), (26, 56));
1999 assert_eq!(parse_isotope_symbol("58NI").unwrap(), (28, 58));
2000 assert!(parse_isotope_symbol("UNKNOWN99").is_err());
2001 assert_eq!(parse_isotope_symbol("Cu65").unwrap(), (29, 65));
2003 assert_eq!(parse_isotope_symbol("Cu63").unwrap(), (29, 63));
2004 assert_eq!(parse_isotope_symbol("CU 65").unwrap(), (29, 65));
2005 assert_eq!(parse_isotope_symbol("NatFE").unwrap(), (26, 0));
2007 assert_eq!(parse_isotope_symbol("NatFe").unwrap(), (26, 0));
2008 }
2009
2010 #[test]
2011 fn test_sammy_to_resonance_data_tr007() {
2012 let inp = SammyInpConfig {
2013 title: "test".to_string(),
2014 isotope_symbol: "FE56".to_string(),
2015 awr: 55.9,
2016 energy_min_ev: 1133.01,
2017 energy_max_ev: 1170.517,
2018 temperature_k: 329.0,
2019 flight_path_m: 80.263,
2020 delta_l_sammy: 0.0301,
2021 delta_e_sammy: 0.0,
2022 delta_g_sammy: 0.021994,
2023 no_broadening: false,
2024 broadening_delta_l: None,
2025 broadening_delta_g: None,
2026 broadening_delta_e: None,
2027 scattering_radius_fm: 6.0,
2028 thickness_atoms_barn: 0.2179,
2029 target_spin: 0.0,
2030 observation_type: SammyObservationType::default(),
2031 spin_groups: vec![
2032 SammySpinGroup {
2033 index: 1,
2034 j: 0.5,
2035 l: 0,
2036 abundance: 0.9999,
2037 target_spin: 0.0,
2038 isotope_label: None,
2039 },
2040 SammySpinGroup {
2041 index: 2,
2042 j: -0.5, l: 1, abundance: 0.9172,
2045 target_spin: 0.0,
2046 isotope_label: None,
2047 },
2048 ],
2049 };
2050 let par = SammyParFile {
2051 resonances: vec![
2052 SammyResonance {
2053 energy_ev: -2070.0,
2054 gamma_gamma_ev: 1.45,
2055 gamma_n_ev: 186.6,
2056 gamma_f1_ev: 0.0,
2057 gamma_f2_ev: 0.0,
2058 spin_group: 1,
2059 },
2060 SammyResonance {
2061 energy_ev: 1151.07,
2062 gamma_gamma_ev: 0.6,
2063 gamma_n_ev: 0.062,
2064 gamma_f1_ev: 0.0,
2065 gamma_f2_ev: 0.0,
2066 spin_group: 2,
2067 },
2068 ],
2069 radius_overrides: std::collections::HashMap::new(),
2070 r_external: std::collections::HashMap::new(),
2071 isotopic_masses: Vec::new(),
2072 };
2073 let rd = sammy_to_resonance_data(&inp, &par).unwrap();
2074 assert_eq!(rd.za, 26056); assert_eq!(rd.ranges.len(), 1);
2076 assert_eq!(rd.ranges[0].formalism, ResonanceFormalism::ReichMoore);
2077 assert!((rd.ranges[0].scattering_radius - 6.0).abs() < 1e-6);
2078 assert_eq!(rd.ranges[0].l_groups.len(), 2);
2080 assert_eq!(rd.ranges[0].l_groups[0].l, 0);
2082 assert_eq!(rd.ranges[0].l_groups[0].resonances.len(), 1);
2083 assert_eq!(rd.ranges[0].l_groups[1].l, 1);
2084 assert_eq!(rd.ranges[0].l_groups[1].resonances.len(), 1);
2085 let res_l0 = &rd.ranges[0].l_groups[0].resonances[0];
2087 let res_l1 = &rd.ranges[0].l_groups[1].resonances[0];
2088 assert!((res_l0.j - 0.5).abs() < 1e-6, "spin group 1 → J=+0.5");
2089 assert!((res_l1.j - (-0.5)).abs() < 1e-6, "spin group 2 → J=-0.5");
2090 }
2091
2092 #[test]
2093 fn test_sammy_to_nereids_resolution_conversion() {
2094 let inp = SammyInpConfig {
2096 title: String::new(),
2097 isotope_symbol: "FE56".to_string(),
2098 awr: 55.9,
2099 energy_min_ev: 1133.0,
2100 energy_max_ev: 1170.0,
2101 temperature_k: 329.0,
2102 flight_path_m: 80.263,
2103 delta_l_sammy: 0.0301,
2104 delta_e_sammy: 0.0,
2105 delta_g_sammy: 0.021994,
2106 no_broadening: false,
2107 broadening_delta_l: Some(0.025),
2108 broadening_delta_g: Some(0.022),
2109 broadening_delta_e: Some(0.022),
2110 scattering_radius_fm: 6.0,
2111 thickness_atoms_barn: 0.2179,
2112 target_spin: 0.0,
2113 observation_type: SammyObservationType::default(),
2114 spin_groups: vec![],
2115 };
2116
2117 let (flight_path, delta_t, delta_l, delta_e) =
2118 sammy_to_nereids_resolution(&inp).expect("should return Some for non-zero params");
2119
2120 assert!((flight_path - 80.263).abs() < 1e-10);
2121 let expected_dt = 0.022 / (2.0 * 2.0_f64.ln().sqrt());
2123 assert!(
2124 (delta_t - expected_dt).abs() < 1e-12,
2125 "delta_t={delta_t}, expected={expected_dt}"
2126 );
2127 let expected_dl = 0.025 / 6.0_f64.sqrt();
2129 assert!(
2130 (delta_l - expected_dl).abs() < 1e-12,
2131 "delta_l={delta_l}, expected={expected_dl}"
2132 );
2133 assert!(
2135 (delta_e - 0.022).abs() < 1e-12,
2136 "delta_e={delta_e}, expected=0.022"
2137 );
2138 }
2139
2140 #[test]
2141 fn test_sammy_to_nereids_resolution_zero_params() {
2142 let inp = SammyInpConfig {
2143 title: String::new(),
2144 isotope_symbol: "FE56".to_string(),
2145 awr: 55.9,
2146 energy_min_ev: 1133.0,
2147 energy_max_ev: 1170.0,
2148 temperature_k: 329.0,
2149 flight_path_m: 80.263,
2150 delta_l_sammy: 0.0,
2151 delta_e_sammy: 0.0,
2152 delta_g_sammy: 0.0,
2153 no_broadening: false,
2154 broadening_delta_l: None,
2155 broadening_delta_g: None,
2156 broadening_delta_e: None,
2157 scattering_radius_fm: 6.0,
2158 thickness_atoms_barn: 0.2179,
2159 target_spin: 0.0,
2160 observation_type: SammyObservationType::default(),
2161 spin_groups: vec![],
2162 };
2163
2164 assert!(
2165 sammy_to_nereids_resolution(&inp).is_none(),
2166 "should return None when both Deltal and Deltag are zero"
2167 );
2168 }
2169
2170 #[test]
2171 fn test_parse_fortran_d_exponent() {
2172 assert!((parse_fortran_float("1.23D-5").unwrap() - 1.23e-5).abs() < 1e-20);
2173 assert!((parse_fortran_float("1.23d-5").unwrap() - 1.23e-5).abs() < 1e-20);
2174 assert!((parse_fortran_float("5.4D+3").unwrap() - 5.4e3).abs() < 1e-6);
2175 assert!((parse_fortran_float("1.0D0").unwrap() - 1.0).abs() < 1e-15);
2176 }
2177
2178 #[test]
2180 fn test_capture_observation_type_rejected() {
2181 let content = "\
2182 FE TRANSMISSION FROM 1.13 TO 1.18 KEV
2183 FE56 55.9 1133.01 1170.517 99
2184
2185329. 80.263 0.0301 0.0 .021994
21866.0 0.2179
2187CAPTURE CROSS SECTION
2188 1 1 0 0.5 0.9999 .0
2189 1 1 0 0 .500 .000 .000
2190";
2191 let result = parse_sammy_inp(content);
2192 assert!(result.is_err(), "CAPTURE should be rejected");
2193 let msg = result.unwrap_err().message;
2194 assert!(
2195 msg.contains("CAPTURE"),
2196 "Error message should mention CAPTURE, got: {msg}"
2197 );
2198 }
2199
2200 #[test]
2202 fn test_dirac_observation_type_rejected() {
2203 let content = "\
2204 FE TRANSMISSION FROM 1.13 TO 1.18 KEV
2205 FE56 55.9 1133.01 1170.517 99
2206
2207329. 80.263 0.0301 0.0 .021994
22086.0 0.2179
2209DIRAC CROSS SECTION
2210 1 1 0 0.5 0.9999 .0
2211 1 1 0 0 .500 .000 .000
2212";
2213 let result = parse_sammy_inp(content);
2214 assert!(result.is_err(), "DIRAC should be rejected");
2215 let msg = result.unwrap_err().message;
2216 assert!(
2217 msg.contains("DIRAC"),
2218 "Error message should mention DIRAC, got: {msg}"
2219 );
2220 }
2221}