#!/usr/bin/env python
from typing import List
from pydantic import BaseModel
from pleiades.nuclear.isotopes.models import IsotopeInfo, IsotopeMassData
from pleiades.nuclear.models import IsotopeParameters, ResonanceEntry, SpinGroups # Needed to store resonance data
from pleiades.sammy.fitting.config import FitConfig # FitConfig object to contain list of resonance entries
from pleiades.utils.helper import ( # VaryFlag and check_pseudo_scientific for parameter parsing
VaryFlag,
check_pseudo_scientific,
format_float,
format_vary,
)
from pleiades.utils.logger import loguru_logger # Logger for debugging
logger = loguru_logger.bind(name=__name__)
# Column ranges for parsing resonance data from fixed-width format
RESONANCE_ENERGY_RANGE = slice(0, 11)
CAPTURE_WIDTH_RANGE = slice(11, 22)
CHANNEL1_WIDTH_RANGE = slice(22, 33)
CHANNEL2_WIDTH_RANGE = slice(33, 44)
CHANNEL3_WIDTH_RANGE = slice(44, 55)
VARY_ENERGY_RANGE = slice(55, 57)
VARY_CAPTURE_WIDTH_RANGE = slice(57, 59)
VARY_CHANNEL1_RANGE = slice(59, 61)
VARY_CHANNEL2_RANGE = slice(61, 63)
VARY_CHANNEL3_RANGE = slice(63, 65)
IGROUP_RANGE = slice(65, 67)
[docs]
class Card01(BaseModel):
"""
Class representing the Card 1 format in the SAMMY parameter file.
This class is used to extract resonance information from the SAMMY parameter file.
"""
[docs]
@classmethod
def from_lines(cls, lines: List[str], fit_config: FitConfig = None) -> None:
"""Parse a complete isotope parameter card set from lines.
Args:
lines: List of input lines including header and blank terminator
FitConfig: FitConfig object to read isotopes into.
Raises:
ValueError: If no valid header found or invalid format
"""
if not lines:
message = "No lines provided"
logger.error(message)
raise ValueError(message)
if fit_config is None or not isinstance(fit_config, FitConfig):
message = "fit_config must be an instance of FitConfig"
logger.error(message)
raise ValueError(message)
# Check to see if the the first line is a header line, if so skip it
if cls.is_header_line(lines[0]):
header_line = lines[0]
logger.info(f"Header line found: {header_line.strip()}")
# Skip the header line
lines = lines[1:]
else:
# If no header line, log a warning
logger.warning("No header line found, assuming first line is data")
if not lines:
message = "No content lines found after header and blank lines"
logger.error(message)
raise ValueError(message)
# Set flag based on assuming there is only one isotope in the fit_config
multiple_isotopes = False
# Check if there are multiple isotopes in the fit_config object
if fit_config.nuclear_params.isotopes and len(fit_config.nuclear_params.isotopes) > 1:
multiple_isotopes = True
# If there are no isotopes in the fit_config, create a "unknown" [UNKN] isotope
if not fit_config.nuclear_params.isotopes:
logger.warning("No isotopes found in fit_config, creating a default UNKN isotope")
fit_config.nuclear_params.isotopes.append(
IsotopeParameters(
isotope_information=IsotopeInfo(
name="UNKN",
mass_data=IsotopeMassData(atomic_mass=0),
)
)
)
for line in lines:
if not line.strip():
break
if line.strip().startswith("#") or not any(c.isdigit() for c in line):
continue
try:
resonance_energy = check_pseudo_scientific(line[RESONANCE_ENERGY_RANGE].strip())
capture_width = (
check_pseudo_scientific(line[CAPTURE_WIDTH_RANGE].strip())
if line[CAPTURE_WIDTH_RANGE].strip()
else None
)
channel1_width = (
check_pseudo_scientific(line[CHANNEL1_WIDTH_RANGE].strip())
if line[CHANNEL1_WIDTH_RANGE].strip()
else None
)
channel2_width = (
check_pseudo_scientific(line[CHANNEL2_WIDTH_RANGE].strip())
if line[CHANNEL2_WIDTH_RANGE].strip()
else None
)
channel3_width = (
check_pseudo_scientific(line[CHANNEL3_WIDTH_RANGE].strip())
if line[CHANNEL3_WIDTH_RANGE].strip()
else None
)
vary_energy = (
VaryFlag(int(line[VARY_ENERGY_RANGE].strip())) if line[VARY_ENERGY_RANGE].strip() else VaryFlag.NO
)
vary_capture_width = (
VaryFlag(int(line[VARY_CAPTURE_WIDTH_RANGE].strip()))
if line[VARY_CAPTURE_WIDTH_RANGE].strip()
else VaryFlag.NO
)
vary_channel1 = (
VaryFlag(int(line[VARY_CHANNEL1_RANGE].strip()))
if line[VARY_CHANNEL1_RANGE].strip()
else VaryFlag.NO
)
vary_channel2 = (
VaryFlag(int(line[VARY_CHANNEL2_RANGE].strip()))
if line[VARY_CHANNEL2_RANGE].strip()
else VaryFlag.NO
)
vary_channel3 = (
VaryFlag(int(line[VARY_CHANNEL3_RANGE].strip()))
if line[VARY_CHANNEL3_RANGE].strip()
else VaryFlag.NO
)
igroup = int(line[IGROUP_RANGE].strip()) if line[IGROUP_RANGE].strip() else 0
except Exception as e:
logger.warning(f"Failed to parse resonance line: {line.strip()} ({e})")
continue
resonance = ResonanceEntry(
resonance_energy=resonance_energy,
capture_width=capture_width,
channel1_width=channel1_width,
channel2_width=channel2_width,
channel3_width=channel3_width,
vary_energy=vary_energy,
vary_capture_width=vary_capture_width,
vary_channel1=vary_channel1,
vary_channel2=vary_channel2,
vary_channel3=vary_channel3,
igroup=igroup,
)
# If multiple isotopes are present, add the resonance entry to corresponding isotopes by
# matching spin group in the fit_config with the igroup in the resonance entry
if multiple_isotopes:
# Check if igroup is in the spin groups of any isotope
for isotope in fit_config.nuclear_params.isotopes:
# Check if the igroup is one of the spin group numbers in the isotope's spin groups
if not hasattr(isotope, "spin_groups"):
logger.warning(f"Isotope {isotope.isotope_information.name} has no spin groups defined.")
continue
if not isinstance(isotope.spin_groups, list) or not all(
isinstance(sg, SpinGroups) for sg in isotope.spin_groups
):
logger.warning(f"Isotope {isotope.isotope_information.name} has no valid spin groups defined.")
continue
if not isotope.spin_groups:
logger.warning(f"Isotope {isotope.isotope_information.name} has an empty spin group list.")
continue
if igroup in [sg.spin_group_number for sg in isotope.spin_groups]:
# Add the resonance entry to the isotope's resonance list
isotope.resonances.append(resonance)
break
else:
# If only one isotope, add the resonance entry to that isotope's resonance list
# This assumes that there is only one isotope in the fit_config
# and that it has a single spin group.
fit_config.nuclear_params.isotopes[0].resonances.append(resonance)
[docs]
@classmethod
def to_lines(cls, fit_config: FitConfig) -> List[str]:
"""Convert a fit_config object to Card 1 list of lines.
Returns:
List[str]: List of lines representing the Card 1 object.
"""
# if fit_config is none or not an instance of FitConfig, raise an error
if fit_config is None or not isinstance(fit_config, FitConfig):
message = "fit_config must be an instance of FitConfig"
logger.error(message)
raise ValueError(message)
lines = []
# Header line
lines.append("RESONANCES")
# Iterate over isotopes and their resonances
for isotope in fit_config.nuclear_params.isotopes:
if not hasattr(isotope, "resonances") or not isotope.resonances:
logger.warning(f"No resonances found for isotope {isotope.isotope_information.name}, skipping.")
continue
for resonance in isotope.resonances:
line = (
f"{format_float(resonance.resonance_energy, 11)}"
f"{format_float(resonance.capture_width, 11)}"
f"{format_float(resonance.channel1_width, 11)}"
f"{format_float(resonance.channel2_width, 11)}"
f"{format_float(resonance.channel3_width, 11)}"
f"{format_vary(resonance.vary_energy):>2} "
f"{format_vary(resonance.vary_capture_width):>2} "
f"{format_vary(resonance.vary_channel1):>2} "
f"{format_vary(resonance.vary_channel2):>2} "
f"{format_vary(resonance.vary_channel3):>2} "
f"{resonance.igroup:2d}"
)
lines.append(line)
lines.append("") # Blank line to terminate the card
return lines