Source code for pleiades.nuclear.isotopes.manager

"""Manages access to isotope data files packaged with PLEIADES."""

import functools
import re
from pathlib import Path
from typing import Dict, List, Optional, Set

from pleiades.nuclear.isotopes.models import FileCategory, IsotopeInfo, IsotopeMassData
from pleiades.nuclear.models import IsotopeParameters
from pleiades.utils.logger import loguru_logger

logger = loguru_logger.bind(name=__name__)


[docs] class IsotopeManager: """ Manages access to isotope data files packaged with PLEIADES. This class provides a centralized interface for accessing isotope data files that are distributed with the PLEIADES package. It handles path resolution, validates file existence, and caches results for improved performance. """ # Mapping of file categories to their valid file extensions _CATEGORY_FILE_EXTENSIONS = {FileCategory.ISOTOPES: {".info", ".mas20", ".list"}}
[docs] def __init__(self): """Initialize the IsotopeManager.""" self._cached_files: Dict[FileCategory, Set[Path]] = {} self._initialize_cache()
def _initialize_cache(self) -> None: """Initialize the cache of available files for each category.""" base_path = Path(__file__).parent / "files" # Reference the 'files' directory for category in FileCategory: try: self._cached_files[category] = { item for item in base_path.iterdir() if item.suffix in self._CATEGORY_FILE_EXTENSIONS[category] } except Exception as e: logger.error(f"Failed to initialize cache for {category}: {str(e)}") self._cached_files[category] = set() @staticmethod def _get_category_path(category: FileCategory) -> str: """Get the filesystem path for a category.""" return FileCategory.to_path(category)
[docs] @functools.lru_cache(maxsize=128) def get_file_path(self, category: FileCategory, filename: str) -> Path: """ Get the path to a specific data file. Args: category: The category of the data file filename: The name of the file to retrieve Returns: Path to the requested file Raises: FileNotFoundError: If the file doesn't exist ValueError: If the category is invalid or file extension is not allowed """ if not isinstance(category, FileCategory): raise ValueError(f"Invalid category: {category}") file_path = Path(filename) if file_path.suffix not in self._CATEGORY_FILE_EXTENSIONS[category]: raise ValueError( f"Invalid file extension for {category}. Allowed extensions: {self._CATEGORY_FILE_EXTENSIONS[category]}" ) logger.info(f"Searching for {filename} in cached files for {category}: {self._cached_files[category]}") for file in self._cached_files[category]: logger.info(f"Checking file: {file.name}") if file.name == filename: logger.info(f"Found file: {file}") return file raise FileNotFoundError(f"File {filename} not found in {category}")
[docs] def list_files(self, category: Optional[FileCategory] = None) -> Dict[FileCategory, List[str]]: """ List available data files. Args: category: Optional specific category to list files for Returns: Dictionary mapping categories to lists of available filenames Raises: ValueError: If specified category is invalid """ if category is not None: if not isinstance(category, FileCategory): raise ValueError(f"Invalid category: {category}") return {category: sorted(self._cached_files[category])} return {cat: sorted(self._cached_files[cat]) for cat in FileCategory}
[docs] def validate_file(self, category: FileCategory, filename: str) -> bool: """ Validate that a file exists and has the correct extension. Args: category: The category of the file filename: The name of the file to validate Returns: True if the file is valid, False otherwise """ try: path = Path(filename) return path.suffix in self._CATEGORY_FILE_EXTENSIONS[category] and any( file.name == filename for file in self._cached_files[category] ) except Exception: return False
[docs] def get_istotpe_info_from_mass(self, mass: float) -> Optional[IsotopeInfo]: """ Extract isotope information from the mass.mas20 file based on the given mass and return the corresponding IsotopeInfo object. NOTE: This function is not used in the current implementation but is provided for future use or testing purposes. We will need to figure out how to use masses provided by SAMMY to look up the exact isotope in the mass.mas20 file. Args: mass: The mass of the isotope Returns: IsotopeInfo object containing isotope details if found, None otherwise """ # Iterate through the mass.mas20 file to find the isotope with the given mass try: with self.get_file_path(FileCategory.ISOTOPES, "mass.mas20").open() as f: # Skip header lines for _ in range(36): next(f) for line in f: if str(mass) in line: # Parse the line according to mass.mas20 format element = line[0:2].strip() mass_number = int(line[3:6].strip()) return self.get_isotope_info(f"{element}-{mass_number}") return None except Exception as e: logger.error(f"Error reading mass data for mass {mass}: {str(e)}") raise
[docs] def get_isotope_info(self, isotope_str: str) -> Optional[IsotopeInfo]: """ Extract isotope information from the isotopes.info file. Args: isotope_str: String representation of the isotope (e.g., "U-238") Returns: IsotopeInfo containing isotope details if found, None otherwise """ logger.info(f"Getting isotope parameters for {isotope_str}") # Create a IsotopeInfo instance from the isotope string isotope = IsotopeInfo.from_string(isotope_str) # get the mass of the isotope from the mass.mas20 file isotope.mass_data = self.check_and_get_mass_data(isotope.element, isotope.mass_number) # check if the isotope is a stable isotope with known abundance and spin self.check_and_set_abundance_and_spins(isotope) # get the material number isotope.material_number = self.get_mat_number(isotope) return isotope
[docs] def check_and_get_mass_data(self, element: str, mass_number: int) -> Optional[IsotopeMassData]: """ Extract mass data for an isotope from the mass.mas20 file. Args: element (str): Element symbol mass_number (int): Mass number Returns: IsotopeMassData containing atomic mass, mass uncertainty Raises: ValueError: If data cannot be parsed """ try: with self.get_file_path(FileCategory.ISOTOPES, "mass.mas20").open() as f: # Skip header lines for _ in range(36): next(f) for line in f: if (element in line[:25]) and (str(mass_number) in line[:25]): # Parse the line according to mass.mas20 format atomic_mass_coarse = line[106:109].replace("*", "nan").replace("#", ".0") atomic_mass_fine = line[110:124].replace("*", "nan").replace("#", ".0") if "nan" not in [atomic_mass_coarse, atomic_mass_fine]: atomic_mass = float(atomic_mass_coarse + atomic_mass_fine) / 1e6 else: atomic_mass = float("nan") return IsotopeMassData( atomic_mass=atomic_mass, mass_uncertainty=float(line[124:136].replace("*", "nan").replace("#", ".0")), binding_energy=float(line[54:66].replace("*", "nan").replace("#", ".0")), beta_decay_energy=float(line[81:93].replace("*", "nan").replace("#", ".0")), ) raise ValueError(f"Mass data for {element}-{mass_number} not found") except Exception as e: logger.error(f"Error reading mass data for {element}-{mass_number}: {str(e)}") raise
[docs] def check_and_set_abundance_and_spins(self, isotope_info: IsotopeInfo) -> None: """ Set the abundance and spin of an isotope from the isotopes.info file. Args: isotope_info: IsotopeInfo object to modify """ element = isotope_info.element mass_number = isotope_info.mass_number # Check if isotope is a stable isotope with a known abundance and spin with self.get_file_path(FileCategory.ISOTOPES, "isotopes.info").open() as f: for line in f: line = line.strip() if line and line[0].isdigit(): data = line.split() # if the isotope (Element-MassNum) is found in the isotopes.info file then set abundance and spin if data[3] == element and int(data[1]) == mass_number: isotope_info.atomic_number = int(data[0]) isotope_info.abundance = float(data[7]) isotope_info.spin = float(data[5]) return
[docs] def get_mat_number(self, isotope: IsotopeInfo) -> Optional[int]: """ Get ENDF MAT number for an isotope. Args: isotope: IsotopeInfo instance Returns: ENDF MAT number if found, None otherwise Raises: ValueError: If isotope format is invalid """ # Setting isotope string to search file. isotope_string = str(isotope.element) + "-" + str(isotope.mass_number) try: with self.get_file_path(FileCategory.ISOTOPES, "neutrons.list").open() as f: # Line matching breakdown: # "496) 92-U -238 IAEA EVAL-DEC14 IAEA Consortium 9237" # When line matches pattern # We get groups: # match.group(1) = "92" # match.group(2) = "U" # match.group(3) = "238" # Check if constructed string matches input: # match.expand(r"\2-\3") = "U-238" # If match found, get MAT number: # Take last 5 characters of line " 9237" -> 9237 pattern = r"\b\s*(\d+)\s*-\s*([A-Za-z]+)\s*-\s*(\d+)([A-Za-z]*)\b" for line in f: match = re.search(pattern, line) if match and match.expand(r"\2-\3").lower() == str(isotope_string).lower(): return int(line[-5:]) return None except Exception as e: logger.error(f"Error getting MAT number for {isotope}: {str(e)}") raise
[docs] def get_isotope_parameters_from_isotope_string(self, isotope_str: str) -> Optional[IsotopeParameters]: """ Get isotope parameters from an isotope string. Args: isotope_str: String representation of the isotope (e.g., "U-238") Returns: IsotopeParameters containing nuclear data if found, None otherwise """ try: return IsotopeParameters(isotope_information=self.get_isotope_info(isotope_str)) except Exception as e: logger.error(f"Error getting isotope parameters for {isotope_str}: {str(e)}") raise