From bc25267ba3f4a35614cb85799f486b9df85d0bef Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Fri, 10 Apr 2026 09:24:05 -0700 Subject: [PATCH 01/64] decode_ace_name works for protons --- tools/data_library_generator/util.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/data_library_generator/util.py b/tools/data_library_generator/util.py index eca313517..a2f51be27 100644 --- a/tools/data_library_generator/util.py +++ b/tools/data_library_generator/util.py @@ -43,7 +43,12 @@ def decode_ace_name(name: str): S = offset // 100 A = offset % 100 - T = ACE_TEMPERATURE_LIB81[extension] + # Proton data: ENDF70PROT + if extension == "70h": + T = 293.6 + + else: + T = ACE_TEMPERATURE_LIB81[extension] return Z, A, S, T From 216440c9ce97d3b2e6d57165b3bbb359e57b5b22 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Tue, 14 Apr 2026 15:27:27 -0700 Subject: [PATCH 02/64] generate hdf5 files for proton data --- tools/data_library_generator/generate.py | 2 +- .../parse_endf70prot.py | 34 ++ .../data_library_generator/proton_generate.py | 485 ++++++++++++++++++ 3 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 tools/data_library_generator/parse_endf70prot.py create mode 100644 tools/data_library_generator/proton_generate.py diff --git a/tools/data_library_generator/generate.py b/tools/data_library_generator/generate.py index 06a63dc76..3c0aabab9 100644 --- a/tools/data_library_generator/generate.py +++ b/tools/data_library_generator/generate.py @@ -1,8 +1,8 @@ -import ACEtk import argparse import h5py import numpy as np import os +import ACEtk from tqdm import tqdm diff --git a/tools/data_library_generator/parse_endf70prot.py b/tools/data_library_generator/parse_endf70prot.py new file mode 100644 index 000000000..f62b45888 --- /dev/null +++ b/tools/data_library_generator/parse_endf70prot.py @@ -0,0 +1,34 @@ +# This script was written by ChatGPT with Ethan Lame's instructions +import os + +input_file = "/home/ethan_lame/MCDC/acelib/endf70prot" # your big file +output_dir = "/home/ethan_lame/MCDC/acelib/" # where split files go + +os.makedirs(output_dir, exist_ok=True) + +current_file = None + +with open(input_file, "r") as f: + for line in f: + # Check for start of new isotope block + if ".70h" in line: + # Close previous file if open + if current_file is not None: + current_file.close() + + # Extract filename (first token) + filename = line.strip().split()[0] + + # Open new file + filepath = os.path.join(output_dir, filename) + current_file = open(filepath, "w") + + print(f"Creating {filename}") + + # Write line if a file is open + if current_file is not None: + current_file.write(line) + +# Close last file +if current_file is not None: + current_file.close() \ No newline at end of file diff --git a/tools/data_library_generator/proton_generate.py b/tools/data_library_generator/proton_generate.py new file mode 100644 index 000000000..af4cf6da5 --- /dev/null +++ b/tools/data_library_generator/proton_generate.py @@ -0,0 +1,485 @@ +import argparse +import h5py +import numpy as np +import os +import ACEtk + +from tqdm import tqdm + +#### + +import util +from util import print_error, print_note + +parser = argparse.ArgumentParser(description="MC/DC data generator") +parser.add_argument("--rewrite", dest="rewrite", action="store_true", default=False) +parser.add_argument("--verbose", dest="verbose", action="store_true", default=False) +args, unargs = parser.parse_known_args() +rewrite = args.rewrite +verbose = args.verbose + +# Directories +output_dir = os.getenv("MCDC_LIB") +ace_dir = os.getenv("MCDC_ACELIB") + +if output_dir is None: + print_error("Environment variable $MCDC_LIB is not set") +if ace_dir is None: + print_error("Environment variable $MCDC_ACELIB is not set") + +# Create output directory if needed +os.makedirs(output_dir, exist_ok=True) +print(f"\nACE directory: {ace_dir}") +print(f"Output directory: {output_dir}\n") + +# Select the files +if rewrite: + target_files = os.listdir(ace_dir) +else: + target_files = [] + for file_name in os.listdir(ace_dir): + # File header + with open(f"{ace_dir}/{file_name}", "r") as f: + header = ACEtk.Header.from_string(f.readline()) + + # Decode ACE name to MC/DC name + Z, A, S, T = util.decode_ace_name(header.zaid) + symbol = util.Z_TO_SYMBOL[Z] + nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" + mcdc_name = f"{nuclide_name}-{T}K.h5" + + if not os.path.exists(f"{output_dir}/{mcdc_name}"): + target_files.append(file_name) + +# Loop over all files +pbar = tqdm( + target_files, + disable=verbose, + bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}{postfix}", +) +for ace_name in pbar: + # File header + with open(f"{ace_dir}/{ace_name}", "r") as f: + header = ACEtk.Header.from_string(f.readline()) + + # Decode ACE name to MC/DC name + Z, A, S, T = util.decode_ace_name(header.zaid) + symbol = util.Z_TO_SYMBOL[Z] + nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" + mcdc_name = f"{nuclide_name}-{T}K.h5" + + if not rewrite and os.path.exists(f"{output_dir}/{mcdc_name}"): + continue + + # Create MC/DC file + if verbose: + print("\n" + "=" * 80 + "\n") + print(f"Create {mcdc_name} from {ace_name}\n") + pbar.set_postfix_str(f"{mcdc_name[:-3]} from {ace_name}") + file = h5py.File(f"{output_dir}/{mcdc_name}", "w") + + # ================================================================================== + # Basic properties + # ================================================================================== + + # Load ACE tables + ace_table = ACEtk.ContinuousEnergyTable.from_file(f"{ace_dir}/{ace_name}") + + # ACE data source description + header = ace_table.header + file.attrs["source_title"] = header.title + file.attrs["source_version"] = header.version + file.attrs["source_date"] = header.date + if "comments" in dir(header): + file.attrs["source_comments"] = header.comments + + # Name and excitation level + file.create_dataset("nuclide_name", data=nuclide_name) + file.create_dataset("excitation_level", data=S) + + # Temperature + temperature = file.create_dataset("temperature", data=T) + temperature.attrs["unit"] = "K" + + # Atomic number and weight ratio + atomic_number = ace_table.atom_number + atomic_weight_ratio = ace_table.atomic_weight_ratio + file.create_dataset("atomic_number", data=atomic_number) + file.create_dataset("atomic_weight_ratio", data=atomic_weight_ratio) + + # Fissionable? + fissionable = ace_table.fission_multiplicity_block is not None + file.create_dataset("fissionable", data=fissionable) + + # ================================================================================== + # Reaction groups + # ================================================================================== + # Elastic scattering: MT=2 + # Capture: Reactions with zero multiplicity + # Fission: MT=18 or MT=(19, 20, 21, and 38) if given + # Inelastic: Non-fission reactions with non-zero multiplicity + # Ignored: MT=(1, 3, 4, 10) and MT>117 + + proton_reactions = file.create_group("proton_reactions") + + # ACE blocks + nu_block = ace_table.frame_and_multiplicity_block + rx_block = ace_table.reaction_number_block + N_reaction = nu_block.number_reactions + + if nu_block.number_reactions != rx_block.number_reactions: + print_error("Non-equal reaction number in reaction and multiplicity blocks") + + # The groups + elastic_group = proton_reactions.create_group("elastic_scattering") + capture_group = proton_reactions.create_group("capture") + inelastic_group = proton_reactions.create_group("inelastic_scattering") + fission_group = proton_reactions.create_group("fission") + + # MT groups + elastic_MTs = [2] + capture_MTs = [] + inelastic_MTs = [] + fission_MTs = [] + + # Redundant MTs + fission_chance_MTs = [19, 20, 21, 38] + redundant_MTs = [1, 3, 4, 10] + + # Set fission MTs + total_fission_given = rx_block.has_MT(18) + if total_fission_given: + fission_MTs = [18] + # The component should not be given + for MT in fission_chance_MTs: + if rx_block.has_MT(MT): + print_error("Both total fission and its components are given") + else: + for MT in fission_chance_MTs: + if rx_block.has_MT(MT): + fission_MTs.append(MT) + + # Capture and inelastic MTs + for i in range(N_reaction): + idx = i + 1 + MT = rx_block.MT(idx) + + if MT in redundant_MTs + elastic_MTs + fission_MTs or MT > 117: + continue + + nu = nu_block.multiplicity(idx) + + if type(nu) != int: + print_error(f"Non-integer multiplicity for inelastic scattering") + + if nu == 0: + capture_MTs.append(MT) + elif nu > 0: + inelastic_MTs.append(MT) + else: + print_error(f"Negative multiplicity for MT-{MT:03}") + + # Create MTs + for rx_group, rx_MTs in [ + (elastic_group, elastic_MTs), + (capture_group, capture_MTs), + (inelastic_group, inelastic_MTs), + (fission_group, fission_MTs), + ]: + for MT in rx_MTs: + MT_group = rx_group.create_group(f"MT-{MT:03}") + MT_group.attrs["MT"] = MT + + # Report MT groups + if verbose: + print(f" Reaction group MTs") + print(f" - Elastic scattering MTs: {elastic_MTs}") + print(f" - Capture MTs: {capture_MTs}") + print(f" - Inelastic scattering MTs: {inelastic_MTs}") + if fissionable: + print(f" - Fission MT: {fission_MTs}") + + # Delete empty groups + if not fissionable: + del file["proton_reactions/fission"] + if len(inelastic_MTs) == 0: + del file["proton_reactions/inelastic_scattering"] + + # ================================================================================== + # Cross-sections + # ================================================================================== + + xs0_block = ace_table.principal_cross_section_block + xs_block = ace_table.cross_section_block + + xs_energy = xs0_block.energies + xs_elastic = xs0_block.elastic + cross_sections = xs_block.cross_sections + offsets = xs_block.energy_index + + # Energy grid + xs_energy = np.array(xs_energy) + dataset = proton_reactions.create_dataset("xs_energy_grid", data=xs_energy) + dataset.attrs["unit"] = "MeV" + + # Elastic scattering + xs = elastic_group.create_dataset("MT-002/xs", data=xs_elastic) + xs.attrs["offset"] = 0 + xs.attrs["unit"] = "barns" + + # Capture, inelastic scattering, and fission + for MTs, group in [ + (capture_MTs, capture_group), + (inelastic_MTs, inelastic_group), + (fission_MTs, fission_group), + ]: + for MT in MTs: + print(f'MT = {MT}') + idx = rx_block.index(MT) + xs = group.create_dataset(f"MT-{MT:03}/xs", data=cross_sections(idx)) + xs.attrs["offset"] = offsets(idx) - 1 + xs.attrs["unit"] = "barns" + + # ================================================================================== + # Q-value + # ================================================================================== + + q_value_block = ace_table.reaction_qvalue_block + + # Elastic scattering: zero Q-value + for MT in elastic_MTs: + dataset = elastic_group.create_dataset(f"MT-{MT:03}/Q-value", data=0.0) + dataset.attrs["unit"] = "MeV" + + for MTs, group in [ + (capture_MTs, capture_group), + (inelastic_MTs, inelastic_group), + (fission_MTs, fission_group), + ]: + for MT in MTs: + idx = rx_block.index(MT) + dataset = group.create_dataset( + f"MT-{MT:03}/Q-value", data=q_value_block.q_value(idx) + ) + dataset.attrs["unit"] = "MeV" + + # ================================================================================== + # Reference frames and inelastic scattering multiplicities + # ================================================================================== + # Elastic is always in COM frame (per ACE standard) + + # Elastic scattering reference frame + for MT in elastic_MTs: + elastic_group.create_dataset(f"MT-{MT:03}/reference_frame", data="COM") + + # Reference frames of the others + for MTs, group in [ + (capture_MTs, capture_group), + (inelastic_MTs, inelastic_group), + (fission_MTs, fission_group), + ]: + for MT in MTs: + idx = rx_block.index(MT) + reference_frame = nu_block.reference_frame(idx) + if reference_frame == ACEtk.ReferenceFrame.Laboratory: + reference_frame = "LAB" + elif reference_frame == ACEtk.ReferenceFrame.CentreOfMass: + reference_frame = "COM" + else: + print_error(f"Unknown reaction reference frame type for MT-{MT:03}") + group.create_dataset(f"MT-{MT:03}/reference_frame", data=reference_frame) + + # Inelastic multiplicity + for MT in inelastic_MTs: + idx = rx_block.index(MT) + nu = nu_block.multiplicity(idx) + inelastic_group.create_dataset(f"MT-{MT:03}/multiplicity", data=nu) + + # ================================================================================== + # Angular distributions + # ================================================================================== + + angle_block = ace_table.angular_distribution_block + + # Elastic scattering + angle_group = elastic_group.create_group("MT-002/angular_cosine_distribution") + data = angle_block.angular_distribution_data(0) + for subdata in data.distributions: + if not isinstance(subdata, ACEtk.continuous.TabulatedAngularDistribution): + print_error("Unsupported elastic scattering angular distribution") + util.load_cosine_distribution(data, angle_group) + + # Inelastic scattering and fission + for MTs, group in [ + (inelastic_MTs, inelastic_group), + (fission_MTs, fission_group), + ]: + for MT in MTs: + idx = rx_block.index(MT) + angle_group = group.create_group(f"MT-{MT:03}/angular_cosine_distribution") + data = angle_block.angular_distribution_data(idx) + util.load_cosine_distribution(data, angle_group) + + # ================================================================================== + # Energy distributions + # ================================================================================== + + energy_block = ace_table.energy_distribution_block + + for MTs, group in [ + (inelastic_MTs, inelastic_group), + (fission_MTs, fission_group), + ]: + for MT in MTs: + idx = rx_block.index(MT) + data = energy_block.energy_distribution_data(idx) + + if not isinstance(data, ACEtk.continuous.MultiDistributionData): + # Probabilities + dataset = group.create_dataset( + f"MT-{MT:03}/spectrum_probability_grid", data=np.array([0.0, 30.0]) + ) + dataset.attrs["unit"] = "MeV" + dataset = group.create_dataset( + f"MT-{MT:03}/spectrum_probability", data=np.array([[1.0]]) + ) + + # The distributions + energy_group = group.create_group(f"MT-{MT:03}/energy_spectrum-1") + util.load_energy_distribution(data, energy_group) + + else: + N_dist = data.number_distributions + + # ====================================================================== + # Probabilities + # ====================================================================== + + # Constant probability + if all( + np.array( + [x.number_interpolation_regions for x in data.probabilities] + ) + == 0 + ): + probability_grid = np.array([0.0, 30.0]) + probability = np.zeros((1, N_dist)) + for i in range(N_dist): + probability[0, i] = max(data.probability(i + 1).probabilities) + + # Histogram probability + elif all( + np.array( + [x.number_interpolation_regions for x in data.probabilities] + ) + == 1 + ) and all(np.array([x.interpolants for x in data.probabilities]) == 1): + probability_grid = np.array(data.probability(1).energies) + probability = np.zeros((len(probability_grid) - 1, N_dist)) + for i in range(N_dist): + if not all( + probability_grid + == np.array(data.probability(i + 1).energies) + ): + print_error("Unsupported multi-distribution energy spetrum") + probability[:, i] = np.array( + data.probability(i + 1).probabilities[:-1] + ) + + else: + print_error("Unsupported multi-distribution energy spetrum") + + dataset = group.create_dataset( + f"MT-{MT:03}/spectrum_probability_grid", data=probability_grid + ) + dataset.attrs["unit"] = "MeV" + dataset = group.create_dataset( + f"MT-{MT:03}/spectrum_probability", data=probability + ) + + # ====================================================================== + # The disributions + # ====================================================================== + + for i in range(N_dist): + energy_group = group.create_group( + f"MT-{MT:03}/energy_spectrum-{i+1}" + ) + distribution = data.distribution(i + 1) + util.load_energy_distribution(distribution, energy_group) + + # Fissionable zone below + if not fissionable: + continue + + # ================================================================================== + # Fission multiplicities and delayed neutron precursor fractions and decay rates + # ================================================================================== + + prompt_block = ace_table.fission_multiplicity_block + delayed_block = ace_table.delayed_fission_multiplicity_block + dnp_block = ace_table.delayed_neutron_precursor_block + + # Prompt multiplicity + data = prompt_block.multiplicity + h5_group = fission_group.create_group("prompt_multiplicity") + util.load_fission_multiplicity(data, h5_group) + + # Delayed multiplicity + if delayed_block is not None: + data = delayed_block.multiplicity + h5_group = fission_group.create_group("delayed_multiplicity") + util.load_fission_multiplicity(data, h5_group) + + # Delayed neutron precursor fractions and decay rates + if dnp_block is not None: + N_DNP = dnp_block.number_delayed_precursors + fractions = np.zeros(N_DNP) + decay_rates = np.zeros(N_DNP) + + for i in range(N_DNP): + idx = 1 + 1 + data = dnp_block.precursor_group_data(idx) + + if ( + not data.number_interpolation_regions == 0 + or not len(data.probabilities[:]) == 2 + or not data.probabilities[0] == data.probabilities[1] + ): + print_error("Non-constant delayed neutron precursor fraction") + + fractions[i] = data.probabilities[0] + decay_rates[i] = data.decay_constant + + precursors = fission_group.create_group("delayed_neutron_precursors") + precursors.create_dataset("fractions", data=fractions) + decay_rates = precursors.create_dataset("decay_rates", data=decay_rates) + decay_rates.attrs["unit"] = "/s" + + # ================================================================================== + # Delayed fission spectra + # ================================================================================== + + delayed_spectrum_block = ace_table.delayed_neutron_energy_distribution_block + if dnp_block is not None: + N_DNP = dnp_block.number_delayed_precursors + + for i in range(N_DNP): + idx = 1 + 1 + data = delayed_spectrum_block.energy_distribution_data(idx) + + if not isinstance(data, ACEtk.continuous.OutgoingEnergyDistributionData): + print_error(f"Unsupported delayed fission neutron spectrum: {data}") + + energy_group = fission_group.create_group( + f"delayed_neutron_precursors/energy_spectrum-{i+1}" + ) + util.load_energy_distribution(data, energy_group) + + # ================================================================================== + # Finalize + # ================================================================================== + + file.close() + +print("") From cd3a63afe387a0b5d5db59a265a6b30ffceb0a3a Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 15 Apr 2026 10:04:29 -0700 Subject: [PATCH 03/64] proton values in constant.py --- mcdc/constant.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mcdc/constant.py b/mcdc/constant.py index 0b7130f16..3527dab58 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -110,6 +110,10 @@ ELECTRON_REACTION_IONIZATION = 104 ELECTRON_REACTION_BREMSSTRAHLUNG = 105 ELECTRON_REACTION_EXCITATION = 106 +PROTON_REACTION_TOTAL = 200 +PROTON_REACTION_ELASTIC_SCATTERING = 201 +PROTON_REACTION_CAPTURE = 202 +PROTON_REACTION_INELASTIC_SCATTERING = 203 # Particle types PARTICLE_NEUTRON = 0 @@ -184,6 +188,7 @@ LIGHT_SPEED = 2.99792458e10 # cm/s NEUTRON_MASS = 939.565413e6 # eV/c^2 ELECTRON_MASS = 510.99895069e3 # eV/c^2 +PROTON_MASS = 938.27208943e6 # eV/c^2 BOLTZMANN_K = 8.61733326e-5 # eV/K ELECTRON_CUTOFF_ENERGY = 100 # eV MU_CUTOFF = 0.999999 From 6a088704c7affcf80b59951221c8a7fc26068743 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 15 Apr 2026 10:41:36 -0700 Subject: [PATCH 04/64] add proton_reaction --- mcdc/object_/proton_reaction.py | 340 ++++++++++++++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 mcdc/object_/proton_reaction.py diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py new file mode 100644 index 000000000..dd2758822 --- /dev/null +++ b/mcdc/object_/proton_reaction.py @@ -0,0 +1,340 @@ +from typing import Annotated +from numpy import float64 +from numpy.typing import NDArray + +#### + +import mcdc.object_.distribution as distribution + +from mcdc.constant import ( + ANGLE_ISOTROPIC, + ANGLE_ENERGY_CORRELATED, + ANGLE_DISTRIBUTED, + INTERPOLATION_LINEAR, + INTERPOLATION_LOG, + PROTON_REACTION_CAPTURE, + PROTON_REACTION_ELASTIC_SCATTERING, + PROTON_REACTION_INELASTIC_SCATTERING, + REFERENCE_FRAME_COM, + REFERENCE_FRAME_LAB, +) +from mcdc.object_.base import ObjectPolymorphic +from mcdc.object_.distribution import ( + DistributionBase, + DistributionMultiTable, + DistributionLevelScattering, + DistributionEvaporation, + DistributionMaxwellian, + DistributionKalbachMann, + DistributionTabulatedEnergyAngle, + DistributionNBody, +) +from mcdc.object_.simulation import simulation +from mcdc.print_ import print_1d_array, print_error + +# ====================================================================================== +# Proton reaction base class +# ====================================================================================== + + +class ProtonReactionBase(ObjectPolymorphic): + # Annotations for Numba mode + label: str = "proton_reaction" + # + MT: int + xs: NDArray[float64] + xs_offset_: int # "xs_offset" ir reserved for "xs" + reference_frame: int + q_value: float64 + + def __init__(self, type_, MT, xs, xs_offset, reference_frame, q_value): + super().__init__(type_) + self.MT = MT + self.xs = xs + self.xs_offset_ = xs_offset + self.reference_frame = reference_frame + self.q_value = q_value + + def __repr__(self): + text = "\n" + text += f"{decode_type(self.type)}\n" + text += f" - ID: {self.ID}\n" + text += f" - MT: {self.MT}\n" + text += f" - XS {print_1d_array(self.xs)} barn\n" + text += f" - Reference frame: {decode_reference_frame(self.reference_frame)}\n" + text += f" - Q-value: {self.q_value}\n" + return text + + +def decode_type(type_): + if type_ == PROTON_REACTION_ELASTIC_SCATTERING: + return "Proton elastic scattering" + elif type_ == PROTON_REACTION_CAPTURE: + return "Proton capture" + elif type_ == PROTON_REACTION_INELASTIC_SCATTERING: + return "Proton inelastic scattering" + + +def decode_reference_frame(type_): + if type_ == REFERENCE_FRAME_LAB: + return "Laboratory" + elif type_ == REFERENCE_FRAME_COM: + return "Center of mass" + + +# ====================================================================================== +# Proton elastic scattering +# ====================================================================================== + + +class ProtonReactionElasticScattering(ProtonReactionBase): + # Annotations for Numba mode + label: str = "proton_elastic_scattering_reaction" + # + mu_table: DistributionMultiTable + + def __init__(self, MT, xs, xs_offset, reference_frame, mu): + type_ = PROTON_REACTION_ELASTIC_SCATTERING + super().__init__(type_, MT, xs, xs_offset, reference_frame, 0.0) + self.mu_table = mu + + @classmethod + def from_h5_group(cls, h5_group): + MT, xs, xs_offset, reference_frame, _ = set_basic_properties(h5_group) + _, mu = set_angular_distribution(h5_group["angular_cosine_distribution"]) + return cls(MT, xs, xs_offset, reference_frame, mu) + + def __repr__(self): + text = super().__repr__() + text += f" - Scattering cosine: {distribution.decode_type(self.mu_table.type)} [ID: {self.mu_table.ID}]\n" + return text + + +# ====================================================================================== +# Proton capture +# ====================================================================================== + + +class ProtonReactionCapture(ProtonReactionBase): + # Annotations for Numba mode + label: str = "proton_capture_reaction" + + def __init__(self, MT, xs, xs_offset, reference_frame, q_value): + type_ = PROTON_REACTION_CAPTURE + super().__init__(type_, MT, xs, xs_offset, reference_frame, q_value) + + @classmethod + def from_h5_group(cls, h5_group): + MT, xs, xs_offset, reference_frame, q_value = set_basic_properties(h5_group) + return cls(MT, xs, xs_offset, reference_frame, q_value) + + +# ====================================================================================== +# Proton inelastic scattering +# ====================================================================================== + + +class ProtonReactionInelasticScattering(ProtonReactionBase): + # Annotations for Numba mode + label: str = "proton_inelastic_scattering_reaction" + # + multiplicity: int + angle_type: int + mu: DistributionBase + N_spectrum_probability_bin: int + N_spectrum: int + spectrum_probability_grid: NDArray[float64] + spectrum_probability: Annotated[ + NDArray[float64], ("N_spectrum_probability_bin", "N_spectrum") + ] + energy_spectra: list[DistributionBase] + + def __init__( + self, + MT, + xs, + xs_offset, + reference_frame, + q_value, + multiplicity, + angle_type, + mu, + spectrum_probability_grid, + spectrum_probability, + energy_spectra, + ): + type_ = PROTON_REACTION_INELASTIC_SCATTERING + super().__init__(type_, MT, xs, xs_offset, reference_frame, q_value) + + self.multiplicity = multiplicity + self.angle_type = angle_type + self.mu = mu + self.N_spectrum_probability_bin = len(spectrum_probability_grid) - 1 + self.N_spectrum = len(energy_spectra) + self.spectrum_probability_grid = spectrum_probability_grid + self.spectrum_probability = spectrum_probability + self.energy_spectra = energy_spectra + + @classmethod + def from_h5_group(cls, h5_group): + MT, xs, xs_offset, reference_frame, q_value = set_basic_properties(h5_group) + multiplicity = int(h5_group["multiplicity"][()]) + + angle_type, mu = set_angular_distribution( + h5_group["angular_cosine_distribution"] + ) + + # Energy spectra + spectrum_probability_grid = ( + h5_group[f"spectrum_probability_grid"][()] * 1e6 + ) # MeV to eV + spectrum_probability = h5_group[f"spectrum_probability"][()] + energy_spectra = [] + spectrum_names = [x for x in h5_group if x.startswith("energy_spectrum-")] + for spectrum_name in spectrum_names: + energy_spectra.append(set_energy_distribution(h5_group[f"{spectrum_name}"])) + + return cls( + MT, + xs, + xs_offset, + reference_frame, + q_value, + multiplicity, + angle_type, + mu, + spectrum_probability_grid, + spectrum_probability, + energy_spectra, + ) + + def __repr__(self): + text = super().__repr__() + if self.angle_type == ANGLE_ISOTROPIC: + text += f" - Scattering cosine: Isotropic\n" + elif self.angle_type == ANGLE_ENERGY_CORRELATED: + text += f" - Scattering cosine: Energy-correlated\n" + else: + text += f" - Scattering cosine: {distribution.decode_type(self.mu.type)} [ID: {self.mu.ID}]\n" + text += f" - Energy spectra\n" + text += f" - Probability energy grid {print_1d_array(self.spectrum_probability_grid)}\n" + for i in range(len(self.energy_spectra)): + text += f" - Spectrum {i+1}: {distribution.decode_type(self.energy_spectra[i])} [{print_1d_array(self.spectrum_probability[:,i])}] [ID: {self.energy_spectra[i].ID}]\n" + return text + + +# ====================================================================================== +# Helper functions +# ====================================================================================== + + +def set_basic_properties(h5_group): + MT = h5_group.attrs["MT"][()] + xs = h5_group["xs"][()] + xs_offset = h5_group["xs"].attrs["offset"] + reference_frame = h5_group["reference_frame"][()].decode("utf-8") + if reference_frame == "LAB": + reference_frame = REFERENCE_FRAME_LAB + elif reference_frame == "COM": + reference_frame = REFERENCE_FRAME_COM + q_value = h5_group["Q-value"][()] + return MT, xs, xs_offset, reference_frame, q_value + + +def set_angular_distribution(h5_group): + mu_type = h5_group.attrs["type"] + if mu_type == "isotropic": + angle_type = ANGLE_ISOTROPIC + mu = simulation.distributions[0] + elif mu_type == "energy-correlated": + angle_type = ANGLE_ENERGY_CORRELATED + mu = simulation.distributions[0] + else: + angle_type = ANGLE_DISTRIBUTED + grid = h5_group[f"energy"][()] * 1e6 # MeV to eV + offset = h5_group[f"offset"][()] + value = h5_group[f"value"][()] + pdf = h5_group[f"pdf"][()] + mu = DistributionMultiTable(grid, offset, value, pdf) + + return angle_type, mu + + +def set_energy_distribution(h5_group): + spectrum_type = h5_group.attrs["type"] + + if spectrum_type == "tabulated": + grid = h5_group[f"energy"][()] * 1e6 # MeV to eV + offset = h5_group[f"offset"][()] + value = h5_group[f"value"][()] * 1e6 # MeV to eV + pdf = h5_group[f"pdf"][()] / 1e6 # /MeV to /eV + energy_spectrum = DistributionMultiTable(grid, offset, value, pdf) + + elif spectrum_type == "level-scattering": + C1 = h5_group["C1"][()] * 1e6 # MeV to eV + C2 = h5_group["C2"][()] + + energy_spectrum = DistributionLevelScattering(C1, C2) + + elif spectrum_type == "evaporation": + energy = h5_group[f"temperature_energy_grid"][()] * 1e6 # MeV to eV + temperature = h5_group[f"temperature"][()] * 1e6 # MeV to eV + restriction_energy = h5_group[f"restriction_energy"][()] * 1e6 # MeV to eV + + energy_spectrum = DistributionEvaporation( + energy, temperature, restriction_energy + ) + + elif spectrum_type == "maxwellian": + energy = h5_group[f"temperature_energy_grid"][()] * 1e6 # MeV to eV + temperature = h5_group[f"temperature"][()] * 1e6 # MeV to eV + restriction_energy = h5_group[f"restriction_energy"][()] * 1e6 # MeV to eV + interpolation = h5_group[f"temperature_interpolation"][()].decode("utf-8") + if interpolation == "linear": + interpolation = INTERPOLATION_LINEAR + elif interpolation == "log": + interpolation = INTERPOLATION_LOG + + energy_spectrum = DistributionMaxwellian( + energy, temperature, restriction_energy, interpolation + ) + + elif spectrum_type == "kalbach-mann": + energy = h5_group[f"energy"][()] * 1e6 # MeV to eV + offset = h5_group[f"offset"][()] + + energy_out = h5_group[f"energy_out"][()] * 1e6 # MeV to eV + pdf = h5_group[f"pdf"][()] / 1e6 # /MeV to /eV + + precompound_factor = h5_group[f"precompound_factor"][()] + angular_slope = h5_group[f"angular_slope"][()] + + energy_spectrum = DistributionKalbachMann( + energy, offset, energy_out, pdf, precompound_factor, angular_slope + ) + + elif spectrum_type == "energy-angle-tabulated": + energy = h5_group[f"energy"][()] * 1e6 # MeV to eV + offset = h5_group[f"offset"][()] + + energy_out = h5_group[f"energy_out"][()] * 1e6 # MeV to eV + pdf = h5_group[f"pdf"][()] / 1e6 # /MeV to /eV + cosine_offset = h5_group[f"cosine_offset"][()] + + cosine = h5_group[f"cosine"][()] + cosine_pdf = h5_group[f"cosine_pdf"][()] + + energy_spectrum = DistributionTabulatedEnergyAngle( + energy, offset, energy_out, pdf, cosine_offset, cosine, cosine_pdf + ) + + elif spectrum_type == "N-body": + value = h5_group["value"][()] * 1e6 # MeV to eV + pdf = h5_group["pdf"][()] / 1e6 # /MeV to /eV + + energy_spectrum = DistributionNBody(value, pdf) + + else: + print_error(f"Unsupported energy spectrum of type {spectrum_type}") + + return energy_spectrum From aacb60df973107b6c13ba444cfc99d59f22c72f9 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 15 Apr 2026 11:07:25 -0700 Subject: [PATCH 05/64] add proton reactions to nuclide.py --- mcdc/object_/nuclide.py | 103 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index db4b2f196..fe5e86e4c 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -17,6 +17,12 @@ NeutronReactionInelasticScattering, set_energy_distribution, ) +from mcdc.object_.proton_reaction import( + ProtonReactionCapture, + ProtonReactionElasticScattering, + ProtonReactionInelasticScattering, + set_energy_distribution, +) from mcdc.object_.simulation import simulation from mcdc.print_ import print_1d_array, print_error @@ -42,11 +48,19 @@ class Nuclide(ObjectNonSingleton): neutron_capture_xs: NDArray[float64] neutron_inelastic_xs: NDArray[float64] neutron_fission_xs: NDArray[float64] + proton_xs_energy_grid: NDArray[float64] + proton_total_xs: NDArray[float64] + proton_elastic_xs: NDArray[float64] + proton_capture_xs: NDArray[float64] + proton_inelastic_xs: NDArray[float64] # neutron_elastic_scattering_reactions: list[NeutronReactionElasticScattering] neutron_capture_reactions: list[NeutronReactionCapture] neutron_inelastic_scattering_reactions: list[NeutronReactionInelasticScattering] neutron_fission_reactions: list[NeutronReactionFission] + proton_elastic_scattering_reactions: list[ProtonReactionElasticScattering] + proton_capture_reactions: list[ProtonReactionCapture] + proton_inelastic_scattering_reactions: list[ProtonReactionInelasticScattering] # neutron_fission_prompt_multiplicity: DataBase neutron_fission_delayed_multiplicity: DataBase @@ -210,6 +224,95 @@ def set_neutron_data(self): file.close() + def set_proton_data(self): + nuclide_name = self.name + # All proton data in ENDF70PROT is at 293.6K + temperature = 293.6 + + # Load data library + dir_name = os.getenv("MCDC_LIB") + file_name = f"{nuclide_name}-{temperature}K.h5" + file = h5py.File(f"{dir_name}/{file_name}", "r") + + rx_names = [ + "elastic_scattering", + "capture", + "inelastic_scattering", + ] + + # The reaction MTs + MTs = {} + for name in rx_names: + if name not in file["proton_reactions"]: + MTs[name] = [] + continue + + MTs[name] = [ + x for x in file[f"proton_reactions/{name}"] if x.startswith("MT") + ] + + # ========================================================================== + # Reaction XS + # ========================================================================== + + # Energy grid + xs_energy = file["proton_reactions/xs_energy_grid"][()] * 1e6 # MeV to eV + self.proton_xs_energy_grid = xs_energy + + # The total XS + self.proton_total_xs = np.zeros_like(self.proton_xs_energy_grid) + self.proton_elastic_xs = np.zeros_like(self.proton_xs_energy_grid) + self.proton_capture_xs = np.zeros_like(self.proton_xs_energy_grid) + self.proton_inelastic_xs = np.zeros_like(self.proton_xs_energy_grid) + + xs_containers = [ + self.proton_elastic_xs, + self.proton_capture_xs, + self.proton_inelastic_xs, + ] + + for xs_container, rx_name in list(zip(xs_containers, rx_names)): + for MT in MTs[rx_name]: + xs = file[f"proton_reactions/{rx_name}/{MT}/xs"] + xs_container[xs.attrs["offset"] :] += xs[()] + + self.proton_total_xs = ( + self.proton_elastic_xs + + self.proton_capture_xs + + self.proton_inelastic_xs + ) + + + # ========================================================================== + # The reactions + # ========================================================================== + + self.proton_elastic_scattering_reactions = [] + self.proton_capture_reactions = [] + self.proton_inelastic_scattering_reactions = [] + + rx_containers = [ + self.proton_elastic_scattering_reactions, + self.proton_capture_reactions, + self.proton_inelastic_scattering_reactions, + ] + rx_classes = [ + ProtonReactionElasticScattering, + ProtonReactionCapture, + ProtonReactionInelasticScattering, + ] + for rx_container, rx_name, rx_class in list( + zip(rx_containers, rx_names, rx_classes) + ): + for MT in MTs[rx_name]: + h5_group = file[f"proton_reactions/{rx_name}/{MT}"] + reaction = rx_class.from_h5_group(h5_group) + rx_container.append(reaction) + + file.close() + + + ## UPDATE this for protons def __repr__(self): text = "\n" text += f"Nuclide\n" From e3869d9a1a288f863d1d706a99ba649526d99ce8 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 15 Apr 2026 11:42:14 -0700 Subject: [PATCH 06/64] add protons to mcdc/transport/physics --- mcdc/transport/physics/__init__.py | 1 + mcdc/transport/physics/interface.py | 9 + mcdc/transport/physics/proton/__init__.py | 8 + mcdc/transport/physics/proton/interface.py | 61 ++ mcdc/transport/physics/proton/multigroup.py | 375 +++++++++++ mcdc/transport/physics/proton/native.py | 687 ++++++++++++++++++++ mcdc/transport/physics/util.py | 12 + 7 files changed, 1153 insertions(+) create mode 100644 mcdc/transport/physics/proton/__init__.py create mode 100644 mcdc/transport/physics/proton/interface.py create mode 100644 mcdc/transport/physics/proton/multigroup.py create mode 100644 mcdc/transport/physics/proton/native.py diff --git a/mcdc/transport/physics/__init__.py b/mcdc/transport/physics/__init__.py index 66133009c..72a579f30 100644 --- a/mcdc/transport/physics/__init__.py +++ b/mcdc/transport/physics/__init__.py @@ -7,3 +7,4 @@ ) import mcdc.transport.physics.electron as electron import mcdc.transport.physics.neutron as neutron +import mcdc.transport.physics.proton as proton diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index ef3cef349..9d5ed9f02 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -7,6 +7,7 @@ import mcdc.transport.rng as rng import mcdc.transport.physics.electron as electron import mcdc.transport.physics.neutron as neutron +import mcdc.transport.physics.proton as proton from mcdc.constant import * @@ -22,6 +23,8 @@ def particle_speed(particle_container, simulation, data): return neutron.particle_speed(particle_container, simulation, data) elif particle["particle_type"] == PARTICLE_ELECTRON: return electron.particle_speed(particle_container, simulation, data) + elif particle["particle_type"] == PARTICLE_PROTON: + return proton.particle_speed(particle_container, simulation, data) return -1.0 @@ -37,6 +40,8 @@ def macro_xs(reaction_type, particle_container, simulation, data): return neutron.macro_xs(reaction_type, particle_container, simulation, data) elif particle["particle_type"] == PARTICLE_ELECTRON: return electron.macro_xs(reaction_type, particle_container, simulation, data) + elif particle["particle_type"] == PARTICLE_PROTON: + return proton.macro_xs(reaction_type, particle_container, simulation, data) return -1.0 @@ -65,6 +70,8 @@ def collision_distance(particle_container, simulation, data): SigmaT = macro_xs(NEUTRON_REACTION_TOTAL, particle_container, simulation, data) elif particle["particle_type"] == PARTICLE_ELECTRON: SigmaT = macro_xs(ELECTRON_REACTION_TOTAL, particle_container, simulation, data) + elif particle["particle_type"] == PARTICLE_PROTON: + SigmaT = macro_xs(PROTON_REACTION_TOTAL, particle_container, simulation, data) # Vacuum material? if SigmaT == 0.0: @@ -84,3 +91,5 @@ def collision(particle_container, collision_data_container, program, data): neutron.collision(particle_container, collision_data_container, program, data) elif particle["particle_type"] == PARTICLE_ELECTRON: electron.collision(particle_container, collision_data_container, program, data) + elif particle["particle_type"] == PARTICLE_PROTON: + proton.collision(particle_container, collision_data_container, program, data) diff --git a/mcdc/transport/physics/proton/__init__.py b/mcdc/transport/physics/proton/__init__.py new file mode 100644 index 000000000..d95186ada --- /dev/null +++ b/mcdc/transport/physics/proton/__init__.py @@ -0,0 +1,8 @@ +from .interface import ( + particle_speed, + macro_xs, + # proton_production_xs, + collision, +) +import mcdc.transport.physics.proton.native as native +import mcdc.transport.physics.proton.multigroup as multigroup diff --git a/mcdc/transport/physics/proton/interface.py b/mcdc/transport/physics/proton/interface.py new file mode 100644 index 000000000..ebbc1b99b --- /dev/null +++ b/mcdc/transport/physics/proton/interface.py @@ -0,0 +1,61 @@ +from numba import njit + +#### + +import mcdc.transport.physics.proton.multigroup as multigroup +import mcdc.transport.physics.proton.native as native +import mcdc.transport.util as util + +# ====================================================================================== +# Particle attributes +# ====================================================================================== + + +@njit +def particle_speed(particle_container, simulation, data): + if simulation["settings"]["proton_multigroup_mode"]: + return multigroup.particle_speed(particle_container, simulation, data) + else: + return native.particle_speed(particle_container) + + +# ====================================================================================== +# Material properties +# ====================================================================================== + + +@njit +def macro_xs(reaction_type, particle_container, simulation, data): + if simulation["settings"]["proton_multigroup_mode"]: + return multigroup.macro_xs(reaction_type, particle_container, simulation, data) + else: + return native.macro_xs(reaction_type, particle_container, simulation, data) + + +# @njit +# def proton_production_xs(reaction_type, particle_container, simulation, data): +# if simulation["settings"]["proton_multigroup_mode"]: +# return multigroup.proton_production_xs( +# reaction_type, particle_container, simulation, data +# ) +# else: +# return native.proton_production_xs( +# reaction_type, particle_container, simulation, data +# ) + + +# ====================================================================================== +# Collision +# ====================================================================================== + + +@njit +def collision(particle_container, collision_data_container, program, data): + simulation = util.access_simulation(program) + + if simulation["settings"]["proton_multigroup_mode"]: + multigroup.collision( + particle_container, collision_data_container, program, data + ) + else: + native.collision(particle_container, collision_data_container, program, data) diff --git a/mcdc/transport/physics/proton/multigroup.py b/mcdc/transport/physics/proton/multigroup.py new file mode 100644 index 000000000..f91aae924 --- /dev/null +++ b/mcdc/transport/physics/proton/multigroup.py @@ -0,0 +1,375 @@ +import numpy as np +import math + +from numba import njit + +#### + +import mcdc.mcdc_get as mcdc_get +import mcdc.numba_types as type_ +import mcdc.transport.particle as particle_module +import mcdc.transport.particle_bank as particle_bank_module +import mcdc.transport.rng as rng +import mcdc.transport.util as util + +from mcdc.constant import ( + PI, + PROTON_REACTION_TOTAL, + PROTON_REACTION_CAPTURE, + PROTON_REACTION_ELASTIC_SCATTERING, + ) +from mcdc.transport.physics.util import scatter_direction +from mcdc.transport.distribution import sample_isotropic_direction + +# ====================================================================================== +# Particle attributes +# ====================================================================================== + + +@njit +def particle_speed(particle_container, simulation, data): + particle = particle_container[0] + material = simulation["multigroup_materials"][particle["material_ID"]] + return mcdc_get.multigroup_material.mgxs_speed(particle["g"], material, data) + + +# ====================================================================================== +# Material properties +# ====================================================================================== + + +@njit +def macro_xs(reaction_type, particle_container, simulation, data): + particle = particle_container[0] + material = simulation["multigroup_materials"][particle["material_ID"]] + g = particle["g"] + + if reaction_type == PROTON_REACTION_TOTAL: + return mcdc_get.multigroup_material.mgxs_total(g, material, data) + elif reaction_type == PROTON_REACTION_CAPTURE: + return mcdc_get.multigroup_material.mgxs_capture(g, material, data) + elif reaction_type == PROTON_REACTION_ELASTIC_SCATTERING: + return mcdc_get.multigroup_material.mgxs_scatter(g, material, data) + return 0.0 + + +# @njit +# def proton_production_xs(reaction_type, particle_container, simulation, data): +# particle = particle_container[0] +# material = simulation["multigroup_materials"][particle["material_ID"]] +# g = particle["g"] + +# # Total production +# if reaction_type == PROTON_REACTION_TOTAL: +# total = 0.0 + +# # Scattering production +# nu = mcdc_get.multigroup_material.mgxs_nu_s(g, material, data) +# xs = mcdc_get.multigroup_material.mgxs_scatter(g, material, data) +# total += nu * xs + +# # Fission production +# nu = mcdc_get.multigroup_material.mgxs_nu_f(g, material, data) +# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) +# total += nu * xs +# return total + +# # Capture production (none) +# elif reaction_type == PROTON_REACTION_CAPTURE: +# return 0.0 + +# # Scattering production +# elif reaction_type == NEUTRON_REACTION_ELASTIC_SCATTERING: +# nu = mcdc_get.multigroup_material.mgxs_nu_s(g, material, data) +# xs = mcdc_get.multigroup_material.mgxs_scatter(g, material, data) +# return nu * xs + +# # Fission production +# elif reaction_type == NEUTRON_REACTION_FISSION: +# nu = mcdc_get.multigroup_material.mgxs_nu_f(g, material, data) +# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) +# return nu * xs + +# # Prompt fission production +# elif reaction_type == NEUTRON_REACTION_FISSION_PROMPT: +# nu = mcdc_get.multigroup_material.mgxs_nu_p(g, material, data) +# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) +# return nu * xs + +# # Delayed neutron production +# elif reaction_type == NEUTRON_REACTION_FISSION_DELAYED: +# nu = mcdc_get.multigroup_material.mgxs_nu_d_total(g, material, data) +# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) +# return nu * xs + +# # Unsupported default +# return 0.0 + + +# ====================================================================================== +# Collision +# ====================================================================================== + + +@njit +def collision(particle_container, collision_data_container, program, data): + simulation = util.access_simulation(program) + particle = particle_container[0] + + # Get the reaction cross-sections + SigmaT = macro_xs(PROTON_REACTION_TOTAL, particle_container, simulation, data) + SigmaS = macro_xs( + PROTON_REACTION_ELASTIC_SCATTERING, particle_container, simulation, data + ) + SigmaC = macro_xs(PROTON_REACTION_CAPTURE, particle_container, simulation, data) + + # Implicit capture + if simulation["implicit_capture"]["active"]: + particle["w"] *= (SigmaT - SigmaC) / SigmaT + SigmaT -= SigmaC + + # Sample reaction type and perform the reaction + xi = rng.lcg(particle_container) * SigmaT + total = SigmaS + if total > xi: + scattering(particle_container, program, data) + else: + particle["alive"] = False + + +# ====================================================================================== +# Reactions +# ====================================================================================== + + +@njit +def scattering(particle_container, program, data): + simulation = util.access_simulation(program) + + # Particle attributes + particle = particle_container[0] + g = particle["g"] + ux = particle["ux"] + uy = particle["uy"] + uz = particle["uz"] + + # Material attributes + material = simulation["multigroup_materials"][particle["material_ID"]] + G = material["G"] + + # Kill the current particle + particle["alive"] = False + + # Adjust production and product weights if weighted emission + weight_production = 1.0 + weight_product = particle["w"] + if simulation["weighted_emission"]["active"]: + weight_target = simulation["weighted_emission"]["weight_target"] + weight_production = particle["w"] / weight_target + weight_product = weight_target + + # Get number of secondaries + nu_s = mcdc_get.multigroup_material.mgxs_nu_s(g, material, data) + N = int(math.floor(weight_production * nu_s + rng.lcg(particle_container))) + + # Set up secondary partice container + particle_container_new = util.local_array(1, type_.particle_data) + particle_new = particle_container_new[0] + + # Create the secondaries + for n in range(N): + # Set default attributes + particle_module.copy_as_child(particle_container_new, particle_container) + + # Set weight + particle_new["w"] = weight_product + + # Sample scattering angle + mu0 = 2.0 * rng.lcg(particle_container_new) - 1.0 + + # Scatter direction + azi = 2.0 * PI * rng.lcg(particle_container_new) + ux_new, uy_new, uz_new = scatter_direction(ux, uy, uz, mu0, azi) + particle_new["ux"] = ux_new + particle_new["uy"] = uy_new + particle_new["uz"] = uz_new + + # Get outgoing spectrum + stride = material["G"] + start = material["mgxs_chi_s_offset"] + g * stride + chi_s = data[start : start + stride] + # Above is equivalent to: chi_s = mcdc_get.multigroup_material.mgxs_chi_s_vector(g, material, data) + + # Sample outgoing energy + xi = rng.lcg(particle_container_new) + total = 0.0 + for g_out in range(G): + total += chi_s[g_out] + if total > xi: + break + particle_new["g"] = g_out + + # Bank, but keep it if it is the last particle + if n == N - 1: + particle["alive"] = True + particle["ux"] = particle_new["ux"] + particle["uy"] = particle_new["uy"] + particle["uz"] = particle_new["uz"] + particle["g"] = particle_new["g"] + particle["E"] = particle_new["E"] + particle["w"] = particle_new["w"] + else: + particle_bank_module.bank_active_particle(particle_container_new, program) + + +# @njit +# def fission(particle_container, program, data): +# simulation = util.access_simulation(program) +# settings = simulation["settings"] + +# # Particle properties +# particle = particle_container[0] +# g = particle["g"] + +# # Material properties +# material = simulation["multigroup_materials"][particle["material_ID"]] +# G = material["G"] +# J = material["J"] + +# # Kill the current particle +# particle["alive"] = False + +# # Adjust production and product weights if weighted emission +# weight_production = 1.0 +# weight_product = particle["w"] +# if simulation["weighted_emission"]["active"]: +# weight_target = simulation["weighted_emission"]["weight_target"] +# weight_production = particle["w"] / weight_target +# weight_product = weight_target + +# # Fission yields +# nu = mcdc_get.multigroup_material.mgxs_nu_f(g, material, data) +# nu_p = mcdc_get.multigroup_material.mgxs_nu_p(g, material, data) +# if J > 0: +# stride = material["J"] +# start = material["mgxs_nu_d_offset"] + g * stride +# nu_d = data[start : start + stride] +# # Above is equivalent to: nu_d = mcdc_get.multigroup_material.mgxs_nu_d_vector(g, material, data) + +# # Get number of secondaries +# N = int( +# math.floor( +# weight_production * nu / simulation["k_eff"] + rng.lcg(particle_container) +# ) +# ) + +# # Set up secondary partice container +# particle_container_new = util.local_array(1, type_.particle_data) +# particle_new = particle_container_new[0] + +# # Create the secondaries +# for n in range(N): +# # Set default attributes +# particle_module.copy_as_child(particle_container_new, particle_container) + +# # Set weight +# particle_new["w"] = weight_product + +# # Sample isotropic direction +# ux_new, uy_new, uz_new = sample_isotropic_direction(particle_container_new) +# particle_new["ux"] = ux_new +# particle_new["uy"] = uy_new +# particle_new["uz"] = uz_new + +# # Prompt or delayed? +# xi = rng.lcg(particle_container_new) * nu +# total = nu_p +# if xi < total: +# prompt = True +# stride = material["G"] +# start = material["mgxs_chi_p_offset"] + g * stride +# spectrum = data[start : start + stride] +# # Above is equivalent to: spectrum = mcdc_get.multigroup_material.mgxs_chi_p_vector(g, material, data) +# else: +# prompt = False + +# # Determine delayed group, decay constant, and spectrum +# for j in range(J): +# total += nu_d[j] +# if xi < total: +# stride = material["G"] +# start = material["mgxs_chi_d_offset"] + j * stride +# spectrum = data[start : start + stride] +# # Above is equivalent to: +# # spectrum = mcdc_get.multigroup_material.mgxs_chi_d_vector( +# # j, material, data +# # ) +# decay = mcdc_get.multigroup_material.mgxs_decay_rate( +# j, material, data +# ) +# break + +# # Sample outgoing energy +# xi = rng.lcg(particle_container_new) +# tot = 0.0 +# for g_out in range(G): +# tot += spectrum[g_out] +# if tot > xi: +# break +# particle_new["g"] = g_out + +# # Sample emission time +# if not prompt: +# xi = rng.lcg(particle_container_new) +# particle_new["t"] -= math.log(xi) / decay + +# # Eigenvalue mode: bank right away +# if settings["neutron_eigenvalue_mode"]: +# particle_bank_module.bank_census_particle(particle_container_new, program) +# continue +# # Below is only relevant for fixed-source problem + +# # Skip if it's beyond time boundary +# if particle_new["t"] > settings["time_boundary"]: +# continue + +# # Check if it hits current or next census times +# hit_current_census = False +# hit_future_census = False +# idx_census = simulation["idx_census"] +# if settings["N_census"] > 1: +# if particle_new["t"] > mcdc_get.settings.census_time( +# idx_census, settings, data +# ): +# hit_current_census = True +# if particle_new["t"] > mcdc_get.settings.census_time( +# idx_census + 1, settings, data +# ): +# hit_future_census = True + +# # Not hitting census --> add to active bank +# if not hit_current_census: +# # Keep it if it is the last particle +# if n == N - 1: +# particle["alive"] = True +# particle["ux"] = particle_new["ux"] +# particle["uy"] = particle_new["uy"] +# particle["uz"] = particle_new["uz"] +# particle["t"] = particle_new["t"] +# particle["g"] = particle_new["g"] +# particle["E"] = particle_new["E"] +# particle["w"] = particle_new["w"] +# else: +# particle_bank_module.bank_active_particle( +# particle_container_new, program +# ) + +# # Hit future census --> add to future bank +# elif hit_future_census: +# # Particle will participate in the future +# particle_bank_module.bank_future_particle(particle_container_new, program) + +# # Hit current census --> add to census bank +# else: +# # Particle will participate after the current census is completed +# particle_bank_module.bank_census_particle(particle_container_new, program) diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py new file mode 100644 index 000000000..4ef71f3a3 --- /dev/null +++ b/mcdc/transport/physics/proton/native.py @@ -0,0 +1,687 @@ +import math + +from numba import njit + +#### + +import mcdc.mcdc_get as mcdc_get +import mcdc.numba_types as type_ +import mcdc.transport.particle as particle_module +import mcdc.transport.particle_bank as particle_bank_module +import mcdc.transport.rng as rng +import mcdc.transport.util as util + +from mcdc.constant import ( + ANGLE_DISTRIBUTED, + ANGLE_ENERGY_CORRELATED, + ANGLE_ISOTROPIC, + BOLTZMANN_K, + THERMAL_THRESHOLD_FACTOR, + LIGHT_SPEED, + PROTON_MASS, + PI, + PI_HALF, + PI_SQRT, + PROTON_REACTION_INELASTIC_SCATTERING, + PROTON_REACTION_TOTAL, + PROTON_REACTION_CAPTURE, + PROTON_REACTION_ELASTIC_SCATTERING, + REFERENCE_FRAME_COM, +) +from mcdc.transport.data import evaluate_data +from mcdc.transport.distribution import ( + sample_correlated_distribution_with_scale, + sample_distribution_with_scale, + sample_isotropic_cosine, + sample_isotropic_direction, + sample_multi_table, +) +from mcdc.transport.physics.util import ( + evaluate_proton_xs_energy_grid, + scatter_direction, +) +from mcdc.transport.util import find_bin, linear_interpolation + +# ====================================================================================== +# Particle attributes +# ====================================================================================== + + +@njit +def particle_speed(particle_container): + particle = particle_container[0] + E = particle["E"] + mass = PROTON_MASS + return LIGHT_SPEED * math.sqrt(E * (E + 2.0 * mass)) / (E + mass) + + +@njit +def particle_energy_from_speed(speed): + beta = speed / LIGHT_SPEED + gamma = 1.0 / math.sqrt(1.0 - beta * beta) + mass = PROTON_MASS + return mass * (gamma - 1.0) + + +# ====================================================================================== +# Material properties +# ====================================================================================== + + +@njit +def macro_xs(reaction_type, particle_container, simulation, data): + particle = particle_container[0] + material = simulation["native_materials"][particle["material_ID"]] + E = particle["E"] + + total = 0.0 + + for i in range(material["N_nuclide"]): + nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + nuclide = simulation["nuclides"][nuclide_ID] + + nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + xs = total_micro_xs(reaction_type, E, nuclide, data) + + total += nuclide_density * xs + + return total + + +@njit +def total_micro_xs(reaction_type, E, nuclide, data): + idx, E0, E1 = evaluate_proton_xs_energy_grid(E, nuclide, data) + if reaction_type == PROTON_REACTION_TOTAL: + xs0 = mcdc_get.nuclide.proton_total_xs(idx, nuclide, data) + xs1 = mcdc_get.nuclide.proton_total_xs(idx + 1, nuclide, data) + elif reaction_type == PROTON_REACTION_ELASTIC_SCATTERING: + xs0 = mcdc_get.nuclide.proton_elastic_xs(idx, nuclide, data) + xs1 = mcdc_get.nuclide.proton_elastic_xs(idx + 1, nuclide, data) + elif reaction_type == PROTON_REACTION_CAPTURE: + xs0 = mcdc_get.nuclide.proton_capture_xs(idx, nuclide, data) + xs1 = mcdc_get.nuclide.proton_capture_xs(idx + 1, nuclide, data) + elif reaction_type == PROTON_REACTION_INELASTIC_SCATTERING: + xs0 = mcdc_get.nuclide.proton_inelastic_xs(idx, nuclide, data) + xs1 = mcdc_get.nuclide.proton_inelastic_xs(idx + 1, nuclide, data) + else: + # Should be unreachable + xs0 = 0.0 + xs1 = 0.0 + return linear_interpolation(E, E0, E1, xs0, xs1) + + +@njit +def reaction_micro_xs(E, reaction_base, nuclide, data): + idx, E0, E1 = evaluate_proton_xs_energy_grid(E, nuclide, data) + + # Apply offset + offset = reaction_base["xs_offset_"] + if idx < offset: + return 0.0 + else: + idx -= offset + + xs0 = mcdc_get.proton_reaction.xs(idx, reaction_base, data) + xs1 = mcdc_get.proton_reaction.xs(idx + 1, reaction_base, data) + return linear_interpolation(E, E0, E1, xs0, xs1) + + +# @njit +# def proton_production_xs(reaction_type, particle_container, simulation, data): +# # Total production +# if reaction_type == PROTON_REACTION_TOTAL: +# elastic_xs = macro_xs( +# PROTON_REACTION_ELASTIC_SCATTERING, particle_container, simulation, data +# ) +# inelastic_xs = _proton_inelastic_scattering_production_xs( +# particle_container, simulation, data +# ) +# return elastic_xs + inelastic_xs + +# # Elastic scattering production +# elif reaction_type == PROTON_REACTION_ELASTIC_SCATTERING: +# return macro_xs(reaction_type, particle_container, simulation, data) + +# # Capture production (none) +# elif reaction_type == PROTON_REACTION_CAPTURE: +# return 0.0 + +# # Inelastic scattering production +# elif reaction_type == PROTON_REACTION_INELASTIC_SCATTERING: +# return _proton_inelastic_scattering_production_xs( +# particle_container, simulation, data +# ) + +# # Unsupported default +# else: +# return 0.0 + + +# @njit +# def _proton_inelastic_scattering_production_xs(particle_container, simulation, data): +# particle = particle_container[0] +# material_base = simulation["materials"][particle["material_ID"]] +# material = simulation["native_materials"][material_base["child_ID"]] + +# total = 0.0 +# for i in range(material["N_nuclide"]): +# nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) +# nuclide = simulation["nuclides"][nuclide_ID] + +# E = particle["E"] +# nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + +# for j in range(nuclide["N_proton_inelastic_scattering_reaction"]): +# reaction_ID = int( +# mcdc_get.nuclide.proton_inelastic_scattering_reaction_IDs( +# j, nuclide, data +# ) +# ) +# reaction_base = simulation["proton_reactions"][reaction_ID] +# reaction = simulation["proton_inelastic_scattering_reactions"][ +# reaction_base["child_ID"] +# ] + +# xs = reaction_micro_xs(E, reaction_base, nuclide, data) +# nu = reaction["multiplicity"] +# total += nuclide_density * nu * xs + +# return total + + +# ====================================================================================== +# Collision +# ====================================================================================== + + +@njit +def collision(particle_container, collision_data_container, program, data): + simulation = util.access_simulation(program) + particle = particle_container[0] + collision_data = collision_data_container[0] + material = simulation["native_materials"][particle["material_ID"]] + + # Particle properties + E = particle["E"] + + # ================================================================================== + # Sample colliding nuclide + # ================================================================================== + + SigmaT = macro_xs(PROTON_REACTION_TOTAL, particle_container, simulation, data) + + # Implicit capture + if simulation["implicit_capture"]["active"]: + # Calculate capture fraction + SigmaC = macro_xs( + PROTON_REACTION_CAPTURE, particle_container, simulation, data + ) + capture_fraction = SigmaC / SigmaT + + # Deposit energy captured + collision_data["energy_deposition"] += E * particle["w"] * capture_fraction + + # Q-value: xs-weighted average over all nuclides and capture reactions + for i in range(material["N_nuclide"]): + nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + nuclide = simulation["nuclides"][nuclide_ID] + nuclide_density = mcdc_get.native_material.nuclide_densities( + i, material, data + ) + for j in range(nuclide["N_proton_capture_reaction"]): + reaction_ID = int( + mcdc_get.nuclide.proton_capture_reaction_IDs(j, nuclide, data) + ) + reaction = simulation["proton_capture_reactions"][reaction_ID] + reaction_base_ID = reaction["parent_ID"] + reaction_base = simulation["proton_reactions"][reaction_base_ID] + xs = reaction_micro_xs(E, reaction_base, nuclide, data) + Sigma_rx = nuclide_density * xs + collision_data["energy_deposition"] += ( + reaction_base["q_value"] * 1e6 * particle["w"] * Sigma_rx / SigmaT + ) + + # Capture particle weight + particle["w"] *= 1.0 - capture_fraction + + # Adjust total XS + SigmaT -= SigmaC + + xi = rng.lcg(particle_container) * SigmaT + total = 0.0 + for i in range(material["N_nuclide"]): + nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + nuclide = simulation["nuclides"][nuclide_ID] + + nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + sigmaT = total_micro_xs(PROTON_REACTION_TOTAL, E, nuclide, data) + + if simulation["implicit_capture"]["active"]: + sigmaC = total_micro_xs(PROTON_REACTION_CAPTURE, E, nuclide, data) + sigmaT -= sigmaC + + SigmaT_nuclide = nuclide_density * sigmaT + total += SigmaT_nuclide + + if total > xi: + break + + # ================================================================================== + # Sample and perform reaction + # ================================================================================== + + sigma_elastic = total_micro_xs( + PROTON_REACTION_ELASTIC_SCATTERING, E, nuclide, data + ) + sigma_inelastic = total_micro_xs( + PROTON_REACTION_INELASTIC_SCATTERING, E, nuclide, data + ) + xi = rng.lcg(particle_container) * sigmaT + + # Elastic scattering + total = sigma_elastic + if xi < total: + # Sample the actual reaction from the group + total -= sigma_elastic + for i in range(nuclide["N_proton_elastic_scattering_reaction"]): + reaction_ID = int( + mcdc_get.nuclide.proton_elastic_scattering_reaction_IDs( + i, nuclide, data + ) + ) + reaction = simulation["proton_elastic_scattering_reactions"][reaction_ID] + reaction_base_ID = reaction["parent_ID"] + reaction_base = simulation["proton_reactions"][reaction_base_ID] + total += reaction_micro_xs(E, reaction_base, nuclide, data) + + # Execute the reaction + if xi < total: + elastic_scattering( + reaction, + particle_container, + collision_data_container, + nuclide, + simulation, + data, + ) + return + + # Capture + if not simulation["implicit_capture"]["active"]: + sigma_capture = total_micro_xs(PROTON_REACTION_CAPTURE, E, nuclide, data) + total += sigma_capture + if xi < total: + # Sample the actual reaction from the group + total -= sigma_capture + for i in range(nuclide["N_proton_capture_reaction"]): + reaction_ID = int( + mcdc_get.nuclide.proton_capture_reaction_IDs(i, nuclide, data) + ) + reaction = simulation["proton_capture_reactions"][reaction_ID] + reaction_base_ID = reaction["parent_ID"] + reaction_base = simulation["proton_reactions"][reaction_base_ID] + xs = reaction_micro_xs(E, reaction_base, nuclide, data) + total += xs + + # Execute the reaction + if xi < total: + capture( + reaction, + particle_container, + collision_data_container, + nuclide, + simulation, + data, + ) + return + + # Inelastic scattering + total += sigma_inelastic + if xi < total: + # Sample the actual reaction from the group + total -= sigma_inelastic + for i in range(nuclide["N_proton_inelastic_scattering_reaction"]): + reaction_ID = int( + mcdc_get.nuclide.proton_inelastic_scattering_reaction_IDs( + i, nuclide, data + ) + ) + reaction = simulation["proton_inelastic_scattering_reactions"][reaction_ID] + reaction_base_ID = reaction["parent_ID"] + reaction_base = simulation["proton_reactions"][reaction_base_ID] + xs = reaction_micro_xs(E, reaction_base, nuclide, data) + total += xs + + # Execute the reaction + if xi < total: + inelastic_scattering( + reaction, + particle_container, + collision_data_container, + nuclide, + program, + data, + ) + return + +# ====================================================================================== +# Capture +# ====================================================================================== + + +@njit +def capture( + reaction, particle_container, collision_data_container, nuclide, simulation, data +): + particle = particle_container[0] + collision_data = collision_data_container[0] + + reaction_base_ID = reaction["parent_ID"] + reaction_base = simulation["proton_reactions"][reaction_base_ID] + + # Terminate the particle + particle["alive"] = False + + # Energy deposition + E = particle["E"] + q_value = reaction_base["q_value"] * 1e6 + collision_data["energy_deposition"] += (E + q_value) * particle["w"] + + +# ====================================================================================== +# Elastic scattering +# ====================================================================================== + + +@njit +def elastic_scattering( + reaction, particle_container, collision_data_container, nuclide, simulation, data +): + particle = particle_container[0] + collision_data = collision_data_container[0] + + # Particle attributes + E = particle["E"] + ux = particle["ux"] + uy = particle["uy"] + uz = particle["uz"] + + # Energy deposition + collision_data["energy_deposition"] += E * particle["w"] + # Note: Q-value is zero in elastic scattering + + # Sample nucleus thermal velocity + A = nuclide["atomic_weight_ratio"] + temperature = nuclide["temperature"] + if E > THERMAL_THRESHOLD_FACTOR * BOLTZMANN_K * temperature: + Vx = 0.0 + Vy = 0.0 + Vz = 0.0 + else: + Vx, Vy, Vz = sample_nucleus_velocity(A, particle_container) + + # ========================================================================= + # COM kinematics + # ========================================================================= + + # Particle speed + speed = particle_speed(particle_container) + + # Proton velocity - LAB + vx = speed * ux + vy = speed * uy + vz = speed * uz + + # COM velocity + COM_x = (vx + A * Vx) / (1.0 + A) + COM_y = (vy + A * Vy) / (1.0 + A) + COM_z = (vz + A * Vz) / (1.0 + A) + + # Proton velocity - COM + vx = vx - COM_x + vy = vy - COM_y + vz = vz - COM_z + + # Proton speed - COM + speed = math.sqrt(vx * vx + vy * vy + vz * vz) + + # Proton initial direction - COM + ux = vx / speed + uy = vy / speed + uz = vz / speed + + # Sample the scattering cosine from the multi-PDF distribution + multi_table = simulation["multi_table_distributions"][reaction["mu_table_ID"]] + mu0 = sample_multi_table(E, particle_container, multi_table, data) + + # Scatter the direction in COM + azi = 2.0 * PI * rng.lcg(particle_container) + ux_new, uy_new, uz_new = scatter_direction(ux, uy, uz, mu0, azi) + + # Proton final velocity - COM + vx = speed * ux_new + vy = speed * uy_new + vz = speed * uz_new + + # ========================================================================= + # COM to LAB + # ========================================================================= + + # Final velocity - LAB + vx = vx + COM_x + vy = vy + COM_y + vz = vz + COM_z + + # Final energy - LAB + speed = math.sqrt(vx * vx + vy * vy + vz * vz) + particle["E"] = particle_energy_from_speed(speed) + + # Final direction - LAB + particle["ux"] = vx / speed + particle["uy"] = vy / speed + particle["uz"] = vz / speed + + # Subtract outgoing energy from energy deposition + collision_data["energy_deposition"] -= particle["E"] * particle["w"] + + +@njit +def sample_nucleus_velocity(A, particle_container): + particle = particle_container[0] + + # Particle speed + speed = particle_speed(particle_container) + + # Maxwellian parameter + beta = math.sqrt(2.0659834e-11 * A) + # The constant above is + # (1.674927471e-27 kg) / (1.38064852e-19 cm^2 kg s^-2 K^-1) / (293.6 K)/2 + + # Sample nuclide speed candidate V_tilda and + # nuclide-proton polar cosine candidate mu_tilda via + # rejection sampling + y = beta * speed + while True: + if rng.lcg(particle_container) < 2.0 / (2.0 + PI_SQRT * y): + x = math.sqrt( + -math.log(rng.lcg(particle_container) * rng.lcg(particle_container)) + ) + else: + cos_val = math.cos(PI_HALF * rng.lcg(particle_container)) + x = math.sqrt( + -math.log(rng.lcg(particle_container)) + - math.log(rng.lcg(particle_container)) * cos_val * cos_val + ) + V_tilda = x / beta + mu_tilda = 2.0 * rng.lcg(particle_container) - 1.0 + + # Accept candidate V_tilda and mu_tilda? + if rng.lcg(particle_container) > math.sqrt( + speed * speed + V_tilda * V_tilda - 2.0 * speed * V_tilda * mu_tilda + ) / (speed + V_tilda): + break + + # Set nuclide velocity - LAB + azi = 2.0 * PI * rng.lcg(particle_container) + ux, uy, uz = scatter_direction( + particle["ux"], particle["uy"], particle["uz"], mu_tilda, azi + ) + Vx = ux * V_tilda + Vy = uy * V_tilda + Vz = uz * V_tilda + + return Vx, Vy, Vz + + +# ====================================================================================== +# Inelastic scattering +# ====================================================================================== + + +@njit +def inelastic_scattering( + reaction, particle_container, collision_data_container, nuclide, program, data +): + simulation = util.access_simulation(program) + particle = particle_container[0] + collision_data = collision_data_container[0] + + reaction_base_ID = reaction["parent_ID"] + reaction_base = simulation["proton_reactions"][reaction_base_ID] + + # Particle attributes + E = particle["E"] + ux = particle["ux"] + uy = particle["uy"] + uz = particle["uz"] + + # Kill the current particle + particle["alive"] = False + + # Energy deposition + q_value = reaction_base["q_value"] * 1e6 + collision_data["energy_deposition"] += (E + q_value) * particle["w"] + + # Number of secondaries and spectra + N = reaction["multiplicity"] + N_spectrum = reaction["N_spectrum"] + use_all_spectrum = N == N_spectrum + + # Set up secondary partice container + particle_container_new = util.local_array(1, type_.particle_data) + particle_new = particle_container_new[0] + + # Create the secondaries + for n in range(N): + # Set default attributes + particle_module.copy_as_child(particle_container_new, particle_container) + + # ============================================================================== + # Sample angle (if not energy-correlated) + # ============================================================================== + + angle_type = reaction["angle_type"] + if angle_type == ANGLE_ENERGY_CORRELATED: + pass + elif angle_type == ANGLE_ISOTROPIC: + mu = sample_isotropic_cosine(particle_container_new) + elif angle_type == ANGLE_DISTRIBUTED: + distribution_base = simulation["distributions"][reaction["mu_ID"]] + multi_table = simulation["multi_table_distributions"][ + distribution_base["child_ID"] + ] + mu = sample_multi_table(E, particle_container_new, multi_table, data) + + # ============================================================================== + # Sample energy (also angle if correlated) + # ============================================================================== + + # Get energy spectrum + if use_all_spectrum: + ID = int( + mcdc_get.proton_inelastic_scattering_reaction.energy_spectrum_IDs( + n, reaction, data + ) + ) + spectrum_base = simulation["distributions"][ID] + else: + offset = reaction["spectrum_probability_grid_offset"] + length = reaction["spectrum_probability_grid_length"] + probability_grid = data[offset : offset + length] + # Above is equivalent to: + # probability_grid = mcdc_get.proton_inelastic_scattering_reaction.spectrum_probability_grid_all( + # reaction, data + # ) + probability_idx = find_bin(E, probability_grid) + xi = rng.lcg(particle_container_new) + total = 0.0 + for j in range(N_spectrum): + probability = ( + mcdc_get.proton_inelastic_scattering_reaction.spectrum_probability( + probability_idx, j, reaction, data + ) + ) + total += probability + if xi < total: + ID = int( + mcdc_get.proton_inelastic_scattering_reaction.energy_spectrum_IDs( + j, reaction, data + ) + ) + spectrum_base = simulation["distributions"][ID] + break + + # Sample energy + if not angle_type == ANGLE_ENERGY_CORRELATED: + E_new = sample_distribution_with_scale( + E, spectrum_base, particle_container_new, simulation, data + ) + else: + E_new, mu = sample_correlated_distribution_with_scale( + E, spectrum_base, particle_container_new, simulation, data + ) + + # ============================================================================== + # Frame transformation + # ============================================================================== + + reaction_base = simulation["proton_reactions"][int(reaction["parent_ID"])] + reference_frame = reaction_base["reference_frame"] + if reference_frame == REFERENCE_FRAME_COM: + A = nuclide["atomic_weight_ratio"] + mu_COM = mu + E_COM = E_new + + E_new = ( + E_COM + (E + 2 * mu_COM * (A + 1) * math.sqrt(E * E_COM)) / (A + 1) ** 2 + ) + mu = mu_COM * math.sqrt(E_COM / E_new) + math.sqrt(E / E_new) / (A + 1) + + azi = 2.0 * PI * rng.lcg(particle_container_new) + ux_new, uy_new, uz_new = scatter_direction(ux, uy, uz, mu, azi) + + # Now the secondary angle and energy are finalized + particle_new["ux"] = ux_new + particle_new["uy"] = uy_new + particle_new["uz"] = uz_new + particle_new["E"] = E_new + + # Subtract outgoing energy from energy deposition + collision_data["energy_deposition"] -= particle_new["E"] * particle_new["w"] + + # ============================================================================== + # Bank the new particle + # ============================================================================== + + # Keep it if it is the last particle + if n == N - 1: + particle["alive"] = True + particle["ux"] = particle_new["ux"] + particle["uy"] = particle_new["uy"] + particle["uz"] = particle_new["uz"] + particle["E"] = particle_new["E"] + else: + particle_bank_module.bank_active_particle(particle_container_new, program) + + +# No fission for protons \ No newline at end of file diff --git a/mcdc/transport/physics/util.py b/mcdc/transport/physics/util.py index 3475a1510..8788aefce 100644 --- a/mcdc/transport/physics/util.py +++ b/mcdc/transport/physics/util.py @@ -31,6 +31,18 @@ def evaluate_electron_xs_energy_grid(e, element, data): return idx, e0, e1 +@njit +def evaluate_proton_xs_energy_grid(e, nuclide, data): + offset = nuclide["proton_xs_energy_grid_offset"] + length = nuclide["proton_xs_energy_grid_length"] + energy_grid = data[offset : offset + length] + + idx = find_bin(e, energy_grid) + e0 = energy_grid[idx] + e1 = energy_grid[idx + 1] + return idx, e0, e1 + + @njit def scatter_direction(ux, uy, uz, mu0, azi): cos_azi = math.cos(azi) From 3e37c2975e1190978c1431175b356093b69695ba Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 23 Apr 2026 14:15:15 -0700 Subject: [PATCH 07/64] fixed an issue with ProtonReactionBase's MT assignment --- mcdc/object_/proton_reaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py index dd2758822..4df621073 100644 --- a/mcdc/object_/proton_reaction.py +++ b/mcdc/object_/proton_reaction.py @@ -48,12 +48,12 @@ class ProtonReactionBase(ObjectPolymorphic): q_value: float64 def __init__(self, type_, MT, xs, xs_offset, reference_frame, q_value): - super().__init__(type_) self.MT = MT self.xs = xs self.xs_offset_ = xs_offset self.reference_frame = reference_frame self.q_value = q_value + super().__init__(type_) def __repr__(self): text = "\n" From ea7873aaeb642c88bd3cfb3a1ae1da981b59f1b0 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 23 Apr 2026 14:16:48 -0700 Subject: [PATCH 08/64] use ACEtk to generate the hdf5 files from the proton ENDF70PROT data --- tools/data_library_generator/proton_generate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/data_library_generator/proton_generate.py b/tools/data_library_generator/proton_generate.py index af4cf6da5..90058bf5c 100644 --- a/tools/data_library_generator/proton_generate.py +++ b/tools/data_library_generator/proton_generate.py @@ -234,7 +234,6 @@ (fission_MTs, fission_group), ]: for MT in MTs: - print(f'MT = {MT}') idx = rx_block.index(MT) xs = group.create_dataset(f"MT-{MT:03}/xs", data=cross_sections(idx)) xs.attrs["offset"] = offsets(idx) - 1 From d05ef5c2eebccfe3a7007a4d7c29ebf623d34a10 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 23 Apr 2026 14:20:16 -0700 Subject: [PATCH 09/64] more proton stuff --- mcdc/main.py | 4 + mcdc/mcdc_get/__init__.py | 8 + mcdc/mcdc_get/nuclide.py | 232 ++++++++++++++++++ mcdc/mcdc_get/proton_capture_reaction.py | 3 + .../proton_elastic_scattering_reaction.py | 3 + .../proton_inelastic_scattering_reaction.py | 84 +++++++ mcdc/mcdc_get/proton_reaction.py | 32 +++ mcdc/mcdc_set/__init__.py | 8 + mcdc/mcdc_set/nuclide.py | 232 ++++++++++++++++++ mcdc/mcdc_set/proton_capture_reaction.py | 3 + .../proton_elastic_scattering_reaction.py | 3 + .../proton_inelastic_scattering_reaction.py | 84 +++++++ mcdc/mcdc_set/proton_reaction.py | 32 +++ mcdc/object_/simulation.py | 3 + 14 files changed, 731 insertions(+) create mode 100644 mcdc/mcdc_get/proton_capture_reaction.py create mode 100644 mcdc/mcdc_get/proton_elastic_scattering_reaction.py create mode 100644 mcdc/mcdc_get/proton_inelastic_scattering_reaction.py create mode 100644 mcdc/mcdc_get/proton_reaction.py create mode 100644 mcdc/mcdc_set/proton_capture_reaction.py create mode 100644 mcdc/mcdc_set/proton_elastic_scattering_reaction.py create mode 100644 mcdc/mcdc_set/proton_inelastic_scattering_reaction.py create mode 100644 mcdc/mcdc_set/proton_reaction.py diff --git a/mcdc/main.py b/mcdc/main.py index 1e9ff34a0..f45af825a 100644 --- a/mcdc/main.py +++ b/mcdc/main.py @@ -168,6 +168,10 @@ def preparation(): if isinstance(material, Material): update_fissionable_from_nuclides(material) + if settings.proton_transport: + for nuclide in simulationPy.nuclides: + nuclide.set_proton_data() + if settings.electron_transport: for element in simulationPy.elements: element.set_electron_data() diff --git a/mcdc/mcdc_get/__init__.py b/mcdc/mcdc_get/__init__.py index d3b533388..8e85964c2 100644 --- a/mcdc/mcdc_get/__init__.py +++ b/mcdc/mcdc_get/__init__.py @@ -80,10 +80,18 @@ import mcdc.mcdc_get.neutron_inelastic_scattering_reaction as neutron_inelastic_scattering_reaction +import mcdc.mcdc_get.proton_capture_reaction as proton_capture_reaction + +import mcdc.mcdc_get.proton_elastic_scattering_reaction as proton_elastic_scattering_reaction + +import mcdc.mcdc_get.proton_inelastic_scattering_reaction as proton_inelastic_scattering_reaction + import mcdc.mcdc_get.collision_data as collision_data import mcdc.mcdc_get.particle_bank as particle_bank +import mcdc.mcdc_get.proton_reaction as proton_reaction + import mcdc.mcdc_get.settings as settings import mcdc.mcdc_get.implicit_capture as implicit_capture diff --git a/mcdc/mcdc_get/nuclide.py b/mcdc/mcdc_get/nuclide.py index af9593f10..7e0541666 100644 --- a/mcdc/mcdc_get/nuclide.py +++ b/mcdc/mcdc_get/nuclide.py @@ -177,6 +177,151 @@ def neutron_fission_xs_chunk(start, length, nuclide, data): return data[start:end] +@njit +def proton_xs_energy_grid(index, nuclide, data): + offset = nuclide["proton_xs_energy_grid_offset"] + return data[offset + index] + + +@njit +def proton_xs_energy_grid_all(nuclide, data): + start = nuclide["proton_xs_energy_grid_offset"] + size = nuclide["proton_xs_energy_grid_length"] + end = start + size + return data[start:end] + + +@njit +def proton_xs_energy_grid_last(nuclide, data): + start = nuclide["proton_xs_energy_grid_offset"] + size = nuclide["proton_xs_energy_grid_length"] + end = start + size + return data[end - 1] + + +@njit +def proton_xs_energy_grid_chunk(start, length, nuclide, data): + start += nuclide["proton_xs_energy_grid_offset"] + end = start + length + return data[start:end] + + +@njit +def proton_total_xs(index, nuclide, data): + offset = nuclide["proton_total_xs_offset"] + return data[offset + index] + + +@njit +def proton_total_xs_all(nuclide, data): + start = nuclide["proton_total_xs_offset"] + size = nuclide["proton_total_xs_length"] + end = start + size + return data[start:end] + + +@njit +def proton_total_xs_last(nuclide, data): + start = nuclide["proton_total_xs_offset"] + size = nuclide["proton_total_xs_length"] + end = start + size + return data[end - 1] + + +@njit +def proton_total_xs_chunk(start, length, nuclide, data): + start += nuclide["proton_total_xs_offset"] + end = start + length + return data[start:end] + + +@njit +def proton_elastic_xs(index, nuclide, data): + offset = nuclide["proton_elastic_xs_offset"] + return data[offset + index] + + +@njit +def proton_elastic_xs_all(nuclide, data): + start = nuclide["proton_elastic_xs_offset"] + size = nuclide["proton_elastic_xs_length"] + end = start + size + return data[start:end] + + +@njit +def proton_elastic_xs_last(nuclide, data): + start = nuclide["proton_elastic_xs_offset"] + size = nuclide["proton_elastic_xs_length"] + end = start + size + return data[end - 1] + + +@njit +def proton_elastic_xs_chunk(start, length, nuclide, data): + start += nuclide["proton_elastic_xs_offset"] + end = start + length + return data[start:end] + + +@njit +def proton_capture_xs(index, nuclide, data): + offset = nuclide["proton_capture_xs_offset"] + return data[offset + index] + + +@njit +def proton_capture_xs_all(nuclide, data): + start = nuclide["proton_capture_xs_offset"] + size = nuclide["proton_capture_xs_length"] + end = start + size + return data[start:end] + + +@njit +def proton_capture_xs_last(nuclide, data): + start = nuclide["proton_capture_xs_offset"] + size = nuclide["proton_capture_xs_length"] + end = start + size + return data[end - 1] + + +@njit +def proton_capture_xs_chunk(start, length, nuclide, data): + start += nuclide["proton_capture_xs_offset"] + end = start + length + return data[start:end] + + +@njit +def proton_inelastic_xs(index, nuclide, data): + offset = nuclide["proton_inelastic_xs_offset"] + return data[offset + index] + + +@njit +def proton_inelastic_xs_all(nuclide, data): + start = nuclide["proton_inelastic_xs_offset"] + size = nuclide["proton_inelastic_xs_length"] + end = start + size + return data[start:end] + + +@njit +def proton_inelastic_xs_last(nuclide, data): + start = nuclide["proton_inelastic_xs_offset"] + size = nuclide["proton_inelastic_xs_length"] + end = start + size + return data[end - 1] + + +@njit +def proton_inelastic_xs_chunk(start, length, nuclide, data): + start += nuclide["proton_inelastic_xs_offset"] + end = start + length + return data[start:end] + + @njit def neutron_elastic_scattering_reaction_IDs(index, nuclide, data): offset = nuclide["neutron_elastic_scattering_reaction_IDs_offset"] @@ -293,6 +438,93 @@ def neutron_fission_reaction_IDs_chunk(start, length, nuclide, data): return data[start:end] +@njit +def proton_elastic_scattering_reaction_IDs(index, nuclide, data): + offset = nuclide["proton_elastic_scattering_reaction_IDs_offset"] + return data[offset + index] + + +@njit +def proton_elastic_scattering_reaction_IDs_all(nuclide, data): + start = nuclide["proton_elastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_elastic_scattering_reaction"] + end = start + size + return data[start:end] + + +@njit +def proton_elastic_scattering_reaction_IDs_last(nuclide, data): + start = nuclide["proton_elastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_elastic_scattering_reaction"] + end = start + size + return data[end - 1] + + +@njit +def proton_elastic_scattering_reaction_IDs_chunk(start, length, nuclide, data): + start += nuclide["proton_elastic_scattering_reaction_IDs_offset"] + end = start + length + return data[start:end] + + +@njit +def proton_capture_reaction_IDs(index, nuclide, data): + offset = nuclide["proton_capture_reaction_IDs_offset"] + return data[offset + index] + + +@njit +def proton_capture_reaction_IDs_all(nuclide, data): + start = nuclide["proton_capture_reaction_IDs_offset"] + size = nuclide["N_proton_capture_reaction"] + end = start + size + return data[start:end] + + +@njit +def proton_capture_reaction_IDs_last(nuclide, data): + start = nuclide["proton_capture_reaction_IDs_offset"] + size = nuclide["N_proton_capture_reaction"] + end = start + size + return data[end - 1] + + +@njit +def proton_capture_reaction_IDs_chunk(start, length, nuclide, data): + start += nuclide["proton_capture_reaction_IDs_offset"] + end = start + length + return data[start:end] + + +@njit +def proton_inelastic_scattering_reaction_IDs(index, nuclide, data): + offset = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + return data[offset + index] + + +@njit +def proton_inelastic_scattering_reaction_IDs_all(nuclide, data): + start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_inelastic_scattering_reaction"] + end = start + size + return data[start:end] + + +@njit +def proton_inelastic_scattering_reaction_IDs_last(nuclide, data): + start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_inelastic_scattering_reaction"] + end = start + size + return data[end - 1] + + +@njit +def proton_inelastic_scattering_reaction_IDs_chunk(start, length, nuclide, data): + start += nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + end = start + length + return data[start:end] + + @njit def neutron_fission_delayed_fractions(index, nuclide, data): offset = nuclide["neutron_fission_delayed_fractions_offset"] diff --git a/mcdc/mcdc_get/proton_capture_reaction.py b/mcdc/mcdc_get/proton_capture_reaction.py new file mode 100644 index 000000000..fdbf8e750 --- /dev/null +++ b/mcdc/mcdc_get/proton_capture_reaction.py @@ -0,0 +1,3 @@ +# The following is automatically generated by code_factory.py + +from numba import njit diff --git a/mcdc/mcdc_get/proton_elastic_scattering_reaction.py b/mcdc/mcdc_get/proton_elastic_scattering_reaction.py new file mode 100644 index 000000000..fdbf8e750 --- /dev/null +++ b/mcdc/mcdc_get/proton_elastic_scattering_reaction.py @@ -0,0 +1,3 @@ +# The following is automatically generated by code_factory.py + +from numba import njit diff --git a/mcdc/mcdc_get/proton_inelastic_scattering_reaction.py b/mcdc/mcdc_get/proton_inelastic_scattering_reaction.py new file mode 100644 index 000000000..abb8e860a --- /dev/null +++ b/mcdc/mcdc_get/proton_inelastic_scattering_reaction.py @@ -0,0 +1,84 @@ +# The following is automatically generated by code_factory.py + +from numba import njit + + +@njit +def spectrum_probability_grid(index, proton_inelastic_scattering_reaction, data): + offset = proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + return data[offset + index] + + +@njit +def spectrum_probability_grid_all(proton_inelastic_scattering_reaction, data): + start = proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + size = proton_inelastic_scattering_reaction["spectrum_probability_grid_length"] + end = start + size + return data[start:end] + + +@njit +def spectrum_probability_grid_last(proton_inelastic_scattering_reaction, data): + start = proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + size = proton_inelastic_scattering_reaction["spectrum_probability_grid_length"] + end = start + size + return data[end - 1] + + +@njit +def spectrum_probability_grid_chunk(start, length, proton_inelastic_scattering_reaction, data): + start += proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + end = start + length + return data[start:end] + + +@njit +def spectrum_probability_vector(index_1, proton_inelastic_scattering_reaction, data): + offset = proton_inelastic_scattering_reaction["spectrum_probability_offset"] + stride = proton_inelastic_scattering_reaction["N_spectrum"] + start = offset + index_1 * stride + end = start + stride + return data[start:end] + + +@njit +def spectrum_probability(index_1, index_2, proton_inelastic_scattering_reaction, data): + offset = proton_inelastic_scattering_reaction["spectrum_probability_offset"] + stride = proton_inelastic_scattering_reaction["N_spectrum"] + return data[offset + index_1 * stride + index_2] + + +@njit +def spectrum_probability_chunk(start, length, proton_inelastic_scattering_reaction, data): + start += proton_inelastic_scattering_reaction["spectrum_probability_offset"] + end = start + length + return data[start:end] + + +@njit +def energy_spectrum_IDs(index, proton_inelastic_scattering_reaction, data): + offset = proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + return data[offset + index] + + +@njit +def energy_spectrum_IDs_all(proton_inelastic_scattering_reaction, data): + start = proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + size = proton_inelastic_scattering_reaction["N_energy_spectrum"] + end = start + size + return data[start:end] + + +@njit +def energy_spectrum_IDs_last(proton_inelastic_scattering_reaction, data): + start = proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + size = proton_inelastic_scattering_reaction["N_energy_spectrum"] + end = start + size + return data[end - 1] + + +@njit +def energy_spectrum_IDs_chunk(start, length, proton_inelastic_scattering_reaction, data): + start += proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + end = start + length + return data[start:end] diff --git a/mcdc/mcdc_get/proton_reaction.py b/mcdc/mcdc_get/proton_reaction.py new file mode 100644 index 000000000..94fd03787 --- /dev/null +++ b/mcdc/mcdc_get/proton_reaction.py @@ -0,0 +1,32 @@ +# The following is automatically generated by code_factory.py + +from numba import njit + + +@njit +def xs(index, proton_reaction, data): + offset = proton_reaction["xs_offset"] + return data[offset + index] + + +@njit +def xs_all(proton_reaction, data): + start = proton_reaction["xs_offset"] + size = proton_reaction["xs_length"] + end = start + size + return data[start:end] + + +@njit +def xs_last(proton_reaction, data): + start = proton_reaction["xs_offset"] + size = proton_reaction["xs_length"] + end = start + size + return data[end - 1] + + +@njit +def xs_chunk(start, length, proton_reaction, data): + start += proton_reaction["xs_offset"] + end = start + length + return data[start:end] diff --git a/mcdc/mcdc_set/__init__.py b/mcdc/mcdc_set/__init__.py index 38ce0dddd..96a9bd655 100644 --- a/mcdc/mcdc_set/__init__.py +++ b/mcdc/mcdc_set/__init__.py @@ -80,10 +80,18 @@ import mcdc.mcdc_set.neutron_inelastic_scattering_reaction as neutron_inelastic_scattering_reaction +import mcdc.mcdc_set.proton_capture_reaction as proton_capture_reaction + +import mcdc.mcdc_set.proton_elastic_scattering_reaction as proton_elastic_scattering_reaction + +import mcdc.mcdc_set.proton_inelastic_scattering_reaction as proton_inelastic_scattering_reaction + import mcdc.mcdc_set.collision_data as collision_data import mcdc.mcdc_set.particle_bank as particle_bank +import mcdc.mcdc_set.proton_reaction as proton_reaction + import mcdc.mcdc_set.settings as settings import mcdc.mcdc_set.implicit_capture as implicit_capture diff --git a/mcdc/mcdc_set/nuclide.py b/mcdc/mcdc_set/nuclide.py index 257d62580..994d6eb4b 100644 --- a/mcdc/mcdc_set/nuclide.py +++ b/mcdc/mcdc_set/nuclide.py @@ -177,6 +177,151 @@ def neutron_fission_xs_chunk(start, length, nuclide, data, value): data[start:end] = value +@njit +def proton_xs_energy_grid(index, nuclide, data, value): + offset = nuclide["proton_xs_energy_grid_offset"] + data[offset + index] = value + + +@njit +def proton_xs_energy_grid_all(nuclide, data, value): + start = nuclide["proton_xs_energy_grid_offset"] + size = nuclide["proton_xs_energy_grid_length"] + end = start + size + data[start:end] = value + + +@njit +def proton_xs_energy_grid_last(nuclide, data, value): + start = nuclide["proton_xs_energy_grid_offset"] + size = nuclide["proton_xs_energy_grid_length"] + end = start + size + data[end - 1] = value + + +@njit +def proton_xs_energy_grid_chunk(start, length, nuclide, data, value): + start += nuclide["proton_xs_energy_grid_offset"] + end = start + length + data[start:end] = value + + +@njit +def proton_total_xs(index, nuclide, data, value): + offset = nuclide["proton_total_xs_offset"] + data[offset + index] = value + + +@njit +def proton_total_xs_all(nuclide, data, value): + start = nuclide["proton_total_xs_offset"] + size = nuclide["proton_total_xs_length"] + end = start + size + data[start:end] = value + + +@njit +def proton_total_xs_last(nuclide, data, value): + start = nuclide["proton_total_xs_offset"] + size = nuclide["proton_total_xs_length"] + end = start + size + data[end - 1] = value + + +@njit +def proton_total_xs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_total_xs_offset"] + end = start + length + data[start:end] = value + + +@njit +def proton_elastic_xs(index, nuclide, data, value): + offset = nuclide["proton_elastic_xs_offset"] + data[offset + index] = value + + +@njit +def proton_elastic_xs_all(nuclide, data, value): + start = nuclide["proton_elastic_xs_offset"] + size = nuclide["proton_elastic_xs_length"] + end = start + size + data[start:end] = value + + +@njit +def proton_elastic_xs_last(nuclide, data, value): + start = nuclide["proton_elastic_xs_offset"] + size = nuclide["proton_elastic_xs_length"] + end = start + size + data[end - 1] = value + + +@njit +def proton_elastic_xs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_elastic_xs_offset"] + end = start + length + data[start:end] = value + + +@njit +def proton_capture_xs(index, nuclide, data, value): + offset = nuclide["proton_capture_xs_offset"] + data[offset + index] = value + + +@njit +def proton_capture_xs_all(nuclide, data, value): + start = nuclide["proton_capture_xs_offset"] + size = nuclide["proton_capture_xs_length"] + end = start + size + data[start:end] = value + + +@njit +def proton_capture_xs_last(nuclide, data, value): + start = nuclide["proton_capture_xs_offset"] + size = nuclide["proton_capture_xs_length"] + end = start + size + data[end - 1] = value + + +@njit +def proton_capture_xs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_capture_xs_offset"] + end = start + length + data[start:end] = value + + +@njit +def proton_inelastic_xs(index, nuclide, data, value): + offset = nuclide["proton_inelastic_xs_offset"] + data[offset + index] = value + + +@njit +def proton_inelastic_xs_all(nuclide, data, value): + start = nuclide["proton_inelastic_xs_offset"] + size = nuclide["proton_inelastic_xs_length"] + end = start + size + data[start:end] = value + + +@njit +def proton_inelastic_xs_last(nuclide, data, value): + start = nuclide["proton_inelastic_xs_offset"] + size = nuclide["proton_inelastic_xs_length"] + end = start + size + data[end - 1] = value + + +@njit +def proton_inelastic_xs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_inelastic_xs_offset"] + end = start + length + data[start:end] = value + + @njit def neutron_elastic_scattering_reaction_IDs(index, nuclide, data, value): offset = nuclide["neutron_elastic_scattering_reaction_IDs_offset"] @@ -293,6 +438,93 @@ def neutron_fission_reaction_IDs_chunk(start, length, nuclide, data, value): data[start:end] = value +@njit +def proton_elastic_scattering_reaction_IDs(index, nuclide, data, value): + offset = nuclide["proton_elastic_scattering_reaction_IDs_offset"] + data[offset + index] = value + + +@njit +def proton_elastic_scattering_reaction_IDs_all(nuclide, data, value): + start = nuclide["proton_elastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_elastic_scattering_reaction"] + end = start + size + data[start:end] = value + + +@njit +def proton_elastic_scattering_reaction_IDs_last(nuclide, data, value): + start = nuclide["proton_elastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_elastic_scattering_reaction"] + end = start + size + data[end - 1] = value + + +@njit +def proton_elastic_scattering_reaction_IDs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_elastic_scattering_reaction_IDs_offset"] + end = start + length + data[start:end] = value + + +@njit +def proton_capture_reaction_IDs(index, nuclide, data, value): + offset = nuclide["proton_capture_reaction_IDs_offset"] + data[offset + index] = value + + +@njit +def proton_capture_reaction_IDs_all(nuclide, data, value): + start = nuclide["proton_capture_reaction_IDs_offset"] + size = nuclide["N_proton_capture_reaction"] + end = start + size + data[start:end] = value + + +@njit +def proton_capture_reaction_IDs_last(nuclide, data, value): + start = nuclide["proton_capture_reaction_IDs_offset"] + size = nuclide["N_proton_capture_reaction"] + end = start + size + data[end - 1] = value + + +@njit +def proton_capture_reaction_IDs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_capture_reaction_IDs_offset"] + end = start + length + data[start:end] = value + + +@njit +def proton_inelastic_scattering_reaction_IDs(index, nuclide, data, value): + offset = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + data[offset + index] = value + + +@njit +def proton_inelastic_scattering_reaction_IDs_all(nuclide, data, value): + start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_inelastic_scattering_reaction"] + end = start + size + data[start:end] = value + + +@njit +def proton_inelastic_scattering_reaction_IDs_last(nuclide, data, value): + start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_inelastic_scattering_reaction"] + end = start + size + data[end - 1] = value + + +@njit +def proton_inelastic_scattering_reaction_IDs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + end = start + length + data[start:end] = value + + @njit def neutron_fission_delayed_fractions(index, nuclide, data, value): offset = nuclide["neutron_fission_delayed_fractions_offset"] diff --git a/mcdc/mcdc_set/proton_capture_reaction.py b/mcdc/mcdc_set/proton_capture_reaction.py new file mode 100644 index 000000000..fdbf8e750 --- /dev/null +++ b/mcdc/mcdc_set/proton_capture_reaction.py @@ -0,0 +1,3 @@ +# The following is automatically generated by code_factory.py + +from numba import njit diff --git a/mcdc/mcdc_set/proton_elastic_scattering_reaction.py b/mcdc/mcdc_set/proton_elastic_scattering_reaction.py new file mode 100644 index 000000000..fdbf8e750 --- /dev/null +++ b/mcdc/mcdc_set/proton_elastic_scattering_reaction.py @@ -0,0 +1,3 @@ +# The following is automatically generated by code_factory.py + +from numba import njit diff --git a/mcdc/mcdc_set/proton_inelastic_scattering_reaction.py b/mcdc/mcdc_set/proton_inelastic_scattering_reaction.py new file mode 100644 index 000000000..86a9da0dc --- /dev/null +++ b/mcdc/mcdc_set/proton_inelastic_scattering_reaction.py @@ -0,0 +1,84 @@ +# The following is automatically generated by code_factory.py + +from numba import njit + + +@njit +def spectrum_probability_grid(index, proton_inelastic_scattering_reaction, data, value): + offset = proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + data[offset + index] = value + + +@njit +def spectrum_probability_grid_all(proton_inelastic_scattering_reaction, data, value): + start = proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + size = proton_inelastic_scattering_reaction["spectrum_probability_grid_length"] + end = start + size + data[start:end] = value + + +@njit +def spectrum_probability_grid_last(proton_inelastic_scattering_reaction, data, value): + start = proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + size = proton_inelastic_scattering_reaction["spectrum_probability_grid_length"] + end = start + size + data[end - 1] = value + + +@njit +def spectrum_probability_grid_chunk(start, length, proton_inelastic_scattering_reaction, data, value): + start += proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + end = start + length + data[start:end] = value + + +@njit +def spectrum_probability_vector(index_1, proton_inelastic_scattering_reaction, data, value): + offset = proton_inelastic_scattering_reaction["spectrum_probability_offset"] + stride = proton_inelastic_scattering_reaction["N_spectrum"] + start = offset + index_1 * stride + end = start + stride + data[start:end] - value + + +@njit +def spectrum_probability(index_1, index_2, proton_inelastic_scattering_reaction, data, value): + offset = proton_inelastic_scattering_reaction["spectrum_probability_offset"] + stride = proton_inelastic_scattering_reaction["N_spectrum"] + data[offset + index_1 * stride + index_2] = value + + +@njit +def spectrum_probability_chunk(start, length, proton_inelastic_scattering_reaction, data, value): + start += proton_inelastic_scattering_reaction["spectrum_probability_offset"] + end = start + length + data[start:end] = value + + +@njit +def energy_spectrum_IDs(index, proton_inelastic_scattering_reaction, data, value): + offset = proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + data[offset + index] = value + + +@njit +def energy_spectrum_IDs_all(proton_inelastic_scattering_reaction, data, value): + start = proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + size = proton_inelastic_scattering_reaction["N_energy_spectrum"] + end = start + size + data[start:end] = value + + +@njit +def energy_spectrum_IDs_last(proton_inelastic_scattering_reaction, data, value): + start = proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + size = proton_inelastic_scattering_reaction["N_energy_spectrum"] + end = start + size + data[end - 1] = value + + +@njit +def energy_spectrum_IDs_chunk(start, length, proton_inelastic_scattering_reaction, data, value): + start += proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + end = start + length + data[start:end] = value diff --git a/mcdc/mcdc_set/proton_reaction.py b/mcdc/mcdc_set/proton_reaction.py new file mode 100644 index 000000000..5221e64d5 --- /dev/null +++ b/mcdc/mcdc_set/proton_reaction.py @@ -0,0 +1,32 @@ +# The following is automatically generated by code_factory.py + +from numba import njit + + +@njit +def xs(index, proton_reaction, data, value): + offset = proton_reaction["xs_offset"] + data[offset + index] = value + + +@njit +def xs_all(proton_reaction, data, value): + start = proton_reaction["xs_offset"] + size = proton_reaction["xs_length"] + end = start + size + data[start:end] = value + + +@njit +def xs_last(proton_reaction, data, value): + start = proton_reaction["xs_offset"] + size = proton_reaction["xs_length"] + end = start + size + data[end - 1] = value + + +@njit +def xs_chunk(start, length, proton_reaction, data, value): + start += proton_reaction["xs_offset"] + end = start + length + data[start:end] = value diff --git a/mcdc/object_/simulation.py b/mcdc/object_/simulation.py index fa5b5f9bd..2df013532 100644 --- a/mcdc/object_/simulation.py +++ b/mcdc/object_/simulation.py @@ -15,6 +15,7 @@ from mcdc.object_.material import MaterialBase from mcdc.object_.nuclide import Nuclide from mcdc.object_.neutron_reaction import NeutronReactionBase + from mcdc.object_.proton_reaction import ProtonReactionBase from mcdc.object_.source import Source from mcdc.object_.surface import Surface from mcdc.object_.tally import Tally @@ -63,6 +64,7 @@ class Simulation(ObjectSingleton): nuclides: list[Nuclide] neutron_reactions: list[NeutronReactionBase] sources: list[Source] + proton_reactions: list[ProtonReactionBase] # Geometry cells: list[Cell] @@ -148,6 +150,7 @@ def __init__(self): self.nuclides = [] self.neutron_reactions = [] self.sources = [] + self.proton_reactions = [] # Geometry self.cells = [] From 488eb85e228e9aa06db3f35e9bc677d154210877 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 23 Apr 2026 14:20:32 -0700 Subject: [PATCH 10/64] numba_types for proton stuff --- mcdc/numba_types.py | 63 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/mcdc/numba_types.py b/mcdc/numba_types.py index 8ca7ba4f4..c3ce65d99 100644 --- a/mcdc/numba_types.py +++ b/mcdc/numba_types.py @@ -414,6 +414,16 @@ ('neutron_inelastic_xs_length', int64), ('neutron_fission_xs_offset', int64), ('neutron_fission_xs_length', int64), + ('proton_xs_energy_grid_offset', int64), + ('proton_xs_energy_grid_length', int64), + ('proton_total_xs_offset', int64), + ('proton_total_xs_length', int64), + ('proton_elastic_xs_offset', int64), + ('proton_elastic_xs_length', int64), + ('proton_capture_xs_offset', int64), + ('proton_capture_xs_length', int64), + ('proton_inelastic_xs_offset', int64), + ('proton_inelastic_xs_length', int64), ('N_neutron_elastic_scattering_reaction', int64), ('neutron_elastic_scattering_reaction_IDs_offset', int64), ('N_neutron_capture_reaction', int64), @@ -422,6 +432,12 @@ ('neutron_inelastic_scattering_reaction_IDs_offset', int64), ('N_neutron_fission_reaction', int64), ('neutron_fission_reaction_IDs_offset', int64), + ('N_proton_elastic_scattering_reaction', int64), + ('proton_elastic_scattering_reaction_IDs_offset', int64), + ('N_proton_capture_reaction', int64), + ('proton_capture_reaction_IDs_offset', int64), + ('N_proton_inelastic_scattering_reaction', int64), + ('proton_inelastic_scattering_reaction_IDs_offset', int64), ('neutron_fission_prompt_multiplicity_ID', int64), ('neutron_fission_delayed_multiplicity_ID', int64), ('N_neutron_fission_delayed_precursor', int64), @@ -517,6 +533,33 @@ ('parent_ID', int64), ]) +proton_capture_reaction = into_dtype([ + ('ID', int64), + ('parent_ID', int64), +]) + +proton_elastic_scattering_reaction = into_dtype([ + ('mu_table_ID', int64), + ('ID', int64), + ('parent_ID', int64), +]) + +proton_inelastic_scattering_reaction = into_dtype([ + ('multiplicity', int64), + ('angle_type', int64), + ('mu_ID', int64), + ('N_spectrum_probability_bin', int64), + ('N_spectrum', int64), + ('spectrum_probability_grid_offset', int64), + ('spectrum_probability_grid_length', int64), + ('spectrum_probability_offset', int64), + ('spectrum_probability_length', int64), + ('N_energy_spectrum', int64), + ('energy_spectrum_IDs_offset', int64), + ('ID', int64), + ('parent_ID', int64), +]) + collision_data = into_dtype([ ('energy_deposition', float64), ]) @@ -526,6 +569,18 @@ ('tag', 'U32'), ]) +proton_reaction = into_dtype([ + ('MT', int64), + ('xs_offset', int64), + ('xs_length', int64), + ('xs_offset_', int64), + ('reference_frame', int64), + ('q_value', float64), + ('ID', int64), + ('child_type', int64), + ('child_ID', int64), +]) + settings = into_dtype([ ('N_particle', int64), ('N_batch', int64), @@ -789,6 +844,14 @@ def set_simulation(N: dict): ('N_neutron_inelastic_scattering_reaction', int64), ('sources', source, (N['source'])), ('N_source', int64), + ('proton_capture_reactions', proton_capture_reaction, (N['proton_capture_reaction'])), + ('N_proton_capture_reaction', int64), + ('proton_elastic_scattering_reactions', proton_elastic_scattering_reaction, (N['proton_elastic_scattering_reaction'])), + ('N_proton_elastic_scattering_reaction', int64), + ('proton_inelastic_scattering_reactions', proton_inelastic_scattering_reaction, (N['proton_inelastic_scattering_reaction'])), + ('N_proton_inelastic_scattering_reaction', int64), + ('proton_reactions', proton_reaction, (N['proton_reaction'])), + ('N_proton_reaction', int64), ('cells', cell, (N['cell'])), ('N_cell', int64), ('lattices', lattice, (N['lattice'])), From 815d521b0e07a28232142b97d4e55bacc86c6e9c Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 23 Apr 2026 14:24:55 -0700 Subject: [PATCH 11/64] get proton particle speed --- mcdc/transport/physics/interface.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index 9d5ed9f02..3d895c5e4 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -24,6 +24,7 @@ def particle_speed(particle_container, simulation, data): elif particle["particle_type"] == PARTICLE_ELECTRON: return electron.particle_speed(particle_container, simulation, data) elif particle["particle_type"] == PARTICLE_PROTON: + print(f'proton E = {particle["E"]}') return proton.particle_speed(particle_container, simulation, data) return -1.0 From 9a1295a662300d476d729eaea726970933f69b57 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 23 Apr 2026 17:50:08 -0700 Subject: [PATCH 12/64] initialize Nuclide attributes to reduce errors when running with only protons or neutrons not in MG --- mcdc/object_/base.py | 3 +++ mcdc/object_/nuclide.py | 30 ++++++++++++++++++++++++++++++ mcdc/object_/proton_reaction.py | 2 +- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/mcdc/object_/base.py b/mcdc/object_/base.py index 11d87e8ad..bc52e3a05 100644 --- a/mcdc/object_/base.py +++ b/mcdc/object_/base.py @@ -70,6 +70,7 @@ def register_object(object_): from mcdc.object_.mesh import MeshBase from mcdc.object_.nuclide import Nuclide from mcdc.object_.neutron_reaction import NeutronReactionBase + from mcdc.object_.proton_reaction import ProtonReactionBase from mcdc.object_.source import Source from mcdc.object_.surface import Surface from mcdc.object_.tally import Tally @@ -95,6 +96,8 @@ def register_object(object_): object_list = simulation.nuclides elif isinstance(object_, NeutronReactionBase): object_list = simulation.neutron_reactions + elif isinstance(object_, ProtonReactionBase): + object_list = simulation.proton_reactions elif isinstance(object_, Region): object_list = simulation.regions elif isinstance(object_, Source): diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index fe5e86e4c..3c8a5aa2f 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -85,6 +85,36 @@ def __init__(self, nuclide_name, temperature): self.excitation_level = int(file["excitation_level"][()]) file.close() + # Initialize all attributes to defaults + # Neutron XS + self.neutron_xs_energy_grid = np.zeros(0) + self.neutron_total_xs = np.zeros(0) + self.neutron_elastic_xs = np.zeros(0) + self.neutron_capture_xs = np.zeros(0) + self.neutron_inelastic_xs = np.zeros(0) + self.neutron_fission_xs = np.zeros(0) + # Proton XS + self.proton_xs_energy_grid = np.zeros(0) + self.proton_total_xs = np.zeros(0) + self.proton_elastic_xs = np.zeros(0) + self.proton_capture_xs = np.zeros(0) + self.proton_inelastic_xs = np.zeros(0) + # Reactions + self.neutron_elastic_scattering_reactions = [] + self.neutron_capture_reactions = [] + self.neutron_inelastic_scattering_reactions = [] + self.neutron_fission_reactions = [] + self.proton_elastic_scattering_reactions = [] + self.proton_capture_reactions = [] + self.proton_inelastic_scattering_reactions = [] + # Fission + self.neutron_fission_prompt_multiplicity = 0 + self.neutron_fission_delayed_multiplicity = 0 + self.N_neutron_fission_delayed_precursor = 0 + self.neutron_fission_delayed_fractions = np.zeros(0) + self.neutron_fission_delayed_decay_rates = np.zeros(0) + self.neutron_fission_delayed_spectra = [] + def set_neutron_data(self): nuclide_name = self.name temperature = self.temperature diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py index 4df621073..f9f1093c6 100644 --- a/mcdc/object_/proton_reaction.py +++ b/mcdc/object_/proton_reaction.py @@ -95,8 +95,8 @@ class ProtonReactionElasticScattering(ProtonReactionBase): def __init__(self, MT, xs, xs_offset, reference_frame, mu): type_ = PROTON_REACTION_ELASTIC_SCATTERING - super().__init__(type_, MT, xs, xs_offset, reference_frame, 0.0) self.mu_table = mu + super().__init__(type_, MT, xs, xs_offset, reference_frame, 0.0) @classmethod def from_h5_group(cls, h5_group): From 06b40c6d82c7191e13a6268d891860aaa4873bea Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Fri, 8 May 2026 11:15:57 -0700 Subject: [PATCH 13/64] more proton capabilities; elastic & nonelastic rxns --- mcdc/constant.py | 3 +- mcdc/mcdc_get/__init__.py | 4 +- mcdc/mcdc_get/nuclide.py | 98 +- mcdc/mcdc_get/proton_nonelastic_reaction.py | 84 ++ mcdc/mcdc_set/__init__.py | 4 +- mcdc/mcdc_set/nuclide.py | 98 +- mcdc/mcdc_set/proton_nonelastic_reaction.py | 84 ++ mcdc/numba_types.py | 25 +- mcdc/object_/material.py | 4 +- mcdc/object_/nuclide.py | 75 +- mcdc/object_/proton_reaction.py | 201 +++- mcdc/transport/physics/interface.py | 1 - mcdc/transport/physics/proton/interface.py | 32 +- mcdc/transport/physics/proton/multigroup.py | 4 +- mcdc/transport/physics/proton/native.py | 266 ++--- .../tendl_generate_v2.py | 913 ++++++++++++++++++ 16 files changed, 1417 insertions(+), 479 deletions(-) create mode 100644 mcdc/mcdc_get/proton_nonelastic_reaction.py create mode 100644 mcdc/mcdc_set/proton_nonelastic_reaction.py create mode 100644 tools/data_library_generator/tendl_generate_v2.py diff --git a/mcdc/constant.py b/mcdc/constant.py index 3527dab58..5bfbc9d00 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -112,8 +112,7 @@ ELECTRON_REACTION_EXCITATION = 106 PROTON_REACTION_TOTAL = 200 PROTON_REACTION_ELASTIC_SCATTERING = 201 -PROTON_REACTION_CAPTURE = 202 -PROTON_REACTION_INELASTIC_SCATTERING = 203 +PROTON_REACTION_NONELASTIC = 202 # Particle types PARTICLE_NEUTRON = 0 diff --git a/mcdc/mcdc_get/__init__.py b/mcdc/mcdc_get/__init__.py index 8e85964c2..e902005b0 100644 --- a/mcdc/mcdc_get/__init__.py +++ b/mcdc/mcdc_get/__init__.py @@ -80,11 +80,9 @@ import mcdc.mcdc_get.neutron_inelastic_scattering_reaction as neutron_inelastic_scattering_reaction -import mcdc.mcdc_get.proton_capture_reaction as proton_capture_reaction - import mcdc.mcdc_get.proton_elastic_scattering_reaction as proton_elastic_scattering_reaction -import mcdc.mcdc_get.proton_inelastic_scattering_reaction as proton_inelastic_scattering_reaction +import mcdc.mcdc_get.proton_nonelastic_reaction as proton_nonelastic_reaction import mcdc.mcdc_get.collision_data as collision_data diff --git a/mcdc/mcdc_get/nuclide.py b/mcdc/mcdc_get/nuclide.py index 7e0541666..c5237c645 100644 --- a/mcdc/mcdc_get/nuclide.py +++ b/mcdc/mcdc_get/nuclide.py @@ -265,59 +265,30 @@ def proton_elastic_xs_chunk(start, length, nuclide, data): @njit -def proton_capture_xs(index, nuclide, data): - offset = nuclide["proton_capture_xs_offset"] +def proton_nonelastic_xs(index, nuclide, data): + offset = nuclide["proton_nonelastic_xs_offset"] return data[offset + index] @njit -def proton_capture_xs_all(nuclide, data): - start = nuclide["proton_capture_xs_offset"] - size = nuclide["proton_capture_xs_length"] +def proton_nonelastic_xs_all(nuclide, data): + start = nuclide["proton_nonelastic_xs_offset"] + size = nuclide["proton_nonelastic_xs_length"] end = start + size return data[start:end] @njit -def proton_capture_xs_last(nuclide, data): - start = nuclide["proton_capture_xs_offset"] - size = nuclide["proton_capture_xs_length"] +def proton_nonelastic_xs_last(nuclide, data): + start = nuclide["proton_nonelastic_xs_offset"] + size = nuclide["proton_nonelastic_xs_length"] end = start + size return data[end - 1] @njit -def proton_capture_xs_chunk(start, length, nuclide, data): - start += nuclide["proton_capture_xs_offset"] - end = start + length - return data[start:end] - - -@njit -def proton_inelastic_xs(index, nuclide, data): - offset = nuclide["proton_inelastic_xs_offset"] - return data[offset + index] - - -@njit -def proton_inelastic_xs_all(nuclide, data): - start = nuclide["proton_inelastic_xs_offset"] - size = nuclide["proton_inelastic_xs_length"] - end = start + size - return data[start:end] - - -@njit -def proton_inelastic_xs_last(nuclide, data): - start = nuclide["proton_inelastic_xs_offset"] - size = nuclide["proton_inelastic_xs_length"] - end = start + size - return data[end - 1] - - -@njit -def proton_inelastic_xs_chunk(start, length, nuclide, data): - start += nuclide["proton_inelastic_xs_offset"] +def proton_nonelastic_xs_chunk(start, length, nuclide, data): + start += nuclide["proton_nonelastic_xs_offset"] end = start + length return data[start:end] @@ -468,59 +439,30 @@ def proton_elastic_scattering_reaction_IDs_chunk(start, length, nuclide, data): @njit -def proton_capture_reaction_IDs(index, nuclide, data): - offset = nuclide["proton_capture_reaction_IDs_offset"] - return data[offset + index] - - -@njit -def proton_capture_reaction_IDs_all(nuclide, data): - start = nuclide["proton_capture_reaction_IDs_offset"] - size = nuclide["N_proton_capture_reaction"] - end = start + size - return data[start:end] - - -@njit -def proton_capture_reaction_IDs_last(nuclide, data): - start = nuclide["proton_capture_reaction_IDs_offset"] - size = nuclide["N_proton_capture_reaction"] - end = start + size - return data[end - 1] - - -@njit -def proton_capture_reaction_IDs_chunk(start, length, nuclide, data): - start += nuclide["proton_capture_reaction_IDs_offset"] - end = start + length - return data[start:end] - - -@njit -def proton_inelastic_scattering_reaction_IDs(index, nuclide, data): - offset = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] +def proton_nonelastic_reaction_IDs(index, nuclide, data): + offset = nuclide["proton_nonelastic_reaction_IDs_offset"] return data[offset + index] @njit -def proton_inelastic_scattering_reaction_IDs_all(nuclide, data): - start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] - size = nuclide["N_proton_inelastic_scattering_reaction"] +def proton_nonelastic_reaction_IDs_all(nuclide, data): + start = nuclide["proton_nonelastic_reaction_IDs_offset"] + size = nuclide["N_proton_nonelastic_reaction"] end = start + size return data[start:end] @njit -def proton_inelastic_scattering_reaction_IDs_last(nuclide, data): - start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] - size = nuclide["N_proton_inelastic_scattering_reaction"] +def proton_nonelastic_reaction_IDs_last(nuclide, data): + start = nuclide["proton_nonelastic_reaction_IDs_offset"] + size = nuclide["N_proton_nonelastic_reaction"] end = start + size return data[end - 1] @njit -def proton_inelastic_scattering_reaction_IDs_chunk(start, length, nuclide, data): - start += nuclide["proton_inelastic_scattering_reaction_IDs_offset"] +def proton_nonelastic_reaction_IDs_chunk(start, length, nuclide, data): + start += nuclide["proton_nonelastic_reaction_IDs_offset"] end = start + length return data[start:end] diff --git a/mcdc/mcdc_get/proton_nonelastic_reaction.py b/mcdc/mcdc_get/proton_nonelastic_reaction.py new file mode 100644 index 000000000..619a68430 --- /dev/null +++ b/mcdc/mcdc_get/proton_nonelastic_reaction.py @@ -0,0 +1,84 @@ +# The following is automatically generated by code_factory.py + +from numba import njit + + +@njit +def spectrum_probability_grid(index, proton_nonelastic_reaction, data): + offset = proton_nonelastic_reaction["spectrum_probability_grid_offset"] + return data[offset + index] + + +@njit +def spectrum_probability_grid_all(proton_nonelastic_reaction, data): + start = proton_nonelastic_reaction["spectrum_probability_grid_offset"] + size = proton_nonelastic_reaction["spectrum_probability_grid_length"] + end = start + size + return data[start:end] + + +@njit +def spectrum_probability_grid_last(proton_nonelastic_reaction, data): + start = proton_nonelastic_reaction["spectrum_probability_grid_offset"] + size = proton_nonelastic_reaction["spectrum_probability_grid_length"] + end = start + size + return data[end - 1] + + +@njit +def spectrum_probability_grid_chunk(start, length, proton_nonelastic_reaction, data): + start += proton_nonelastic_reaction["spectrum_probability_grid_offset"] + end = start + length + return data[start:end] + + +@njit +def spectrum_probability_vector(index_1, proton_nonelastic_reaction, data): + offset = proton_nonelastic_reaction["spectrum_probability_offset"] + stride = proton_nonelastic_reaction["N_spectrum"] + start = offset + index_1 * stride + end = start + stride + return data[start:end] + + +@njit +def spectrum_probability(index_1, index_2, proton_nonelastic_reaction, data): + offset = proton_nonelastic_reaction["spectrum_probability_offset"] + stride = proton_nonelastic_reaction["N_spectrum"] + return data[offset + index_1 * stride + index_2] + + +@njit +def spectrum_probability_chunk(start, length, proton_nonelastic_reaction, data): + start += proton_nonelastic_reaction["spectrum_probability_offset"] + end = start + length + return data[start:end] + + +@njit +def energy_spectrum_IDs(index, proton_nonelastic_reaction, data): + offset = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + return data[offset + index] + + +@njit +def energy_spectrum_IDs_all(proton_nonelastic_reaction, data): + start = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + size = proton_nonelastic_reaction["N_energy_spectrum"] + end = start + size + return data[start:end] + + +@njit +def energy_spectrum_IDs_last(proton_nonelastic_reaction, data): + start = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + size = proton_nonelastic_reaction["N_energy_spectrum"] + end = start + size + return data[end - 1] + + +@njit +def energy_spectrum_IDs_chunk(start, length, proton_nonelastic_reaction, data): + start += proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + end = start + length + return data[start:end] diff --git a/mcdc/mcdc_set/__init__.py b/mcdc/mcdc_set/__init__.py index 96a9bd655..7d1258198 100644 --- a/mcdc/mcdc_set/__init__.py +++ b/mcdc/mcdc_set/__init__.py @@ -80,11 +80,9 @@ import mcdc.mcdc_set.neutron_inelastic_scattering_reaction as neutron_inelastic_scattering_reaction -import mcdc.mcdc_set.proton_capture_reaction as proton_capture_reaction - import mcdc.mcdc_set.proton_elastic_scattering_reaction as proton_elastic_scattering_reaction -import mcdc.mcdc_set.proton_inelastic_scattering_reaction as proton_inelastic_scattering_reaction +import mcdc.mcdc_set.proton_nonelastic_reaction as proton_nonelastic_reaction import mcdc.mcdc_set.collision_data as collision_data diff --git a/mcdc/mcdc_set/nuclide.py b/mcdc/mcdc_set/nuclide.py index 994d6eb4b..18536bcf2 100644 --- a/mcdc/mcdc_set/nuclide.py +++ b/mcdc/mcdc_set/nuclide.py @@ -265,59 +265,30 @@ def proton_elastic_xs_chunk(start, length, nuclide, data, value): @njit -def proton_capture_xs(index, nuclide, data, value): - offset = nuclide["proton_capture_xs_offset"] +def proton_nonelastic_xs(index, nuclide, data, value): + offset = nuclide["proton_nonelastic_xs_offset"] data[offset + index] = value @njit -def proton_capture_xs_all(nuclide, data, value): - start = nuclide["proton_capture_xs_offset"] - size = nuclide["proton_capture_xs_length"] +def proton_nonelastic_xs_all(nuclide, data, value): + start = nuclide["proton_nonelastic_xs_offset"] + size = nuclide["proton_nonelastic_xs_length"] end = start + size data[start:end] = value @njit -def proton_capture_xs_last(nuclide, data, value): - start = nuclide["proton_capture_xs_offset"] - size = nuclide["proton_capture_xs_length"] +def proton_nonelastic_xs_last(nuclide, data, value): + start = nuclide["proton_nonelastic_xs_offset"] + size = nuclide["proton_nonelastic_xs_length"] end = start + size data[end - 1] = value @njit -def proton_capture_xs_chunk(start, length, nuclide, data, value): - start += nuclide["proton_capture_xs_offset"] - end = start + length - data[start:end] = value - - -@njit -def proton_inelastic_xs(index, nuclide, data, value): - offset = nuclide["proton_inelastic_xs_offset"] - data[offset + index] = value - - -@njit -def proton_inelastic_xs_all(nuclide, data, value): - start = nuclide["proton_inelastic_xs_offset"] - size = nuclide["proton_inelastic_xs_length"] - end = start + size - data[start:end] = value - - -@njit -def proton_inelastic_xs_last(nuclide, data, value): - start = nuclide["proton_inelastic_xs_offset"] - size = nuclide["proton_inelastic_xs_length"] - end = start + size - data[end - 1] = value - - -@njit -def proton_inelastic_xs_chunk(start, length, nuclide, data, value): - start += nuclide["proton_inelastic_xs_offset"] +def proton_nonelastic_xs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_nonelastic_xs_offset"] end = start + length data[start:end] = value @@ -468,59 +439,30 @@ def proton_elastic_scattering_reaction_IDs_chunk(start, length, nuclide, data, v @njit -def proton_capture_reaction_IDs(index, nuclide, data, value): - offset = nuclide["proton_capture_reaction_IDs_offset"] - data[offset + index] = value - - -@njit -def proton_capture_reaction_IDs_all(nuclide, data, value): - start = nuclide["proton_capture_reaction_IDs_offset"] - size = nuclide["N_proton_capture_reaction"] - end = start + size - data[start:end] = value - - -@njit -def proton_capture_reaction_IDs_last(nuclide, data, value): - start = nuclide["proton_capture_reaction_IDs_offset"] - size = nuclide["N_proton_capture_reaction"] - end = start + size - data[end - 1] = value - - -@njit -def proton_capture_reaction_IDs_chunk(start, length, nuclide, data, value): - start += nuclide["proton_capture_reaction_IDs_offset"] - end = start + length - data[start:end] = value - - -@njit -def proton_inelastic_scattering_reaction_IDs(index, nuclide, data, value): - offset = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] +def proton_nonelastic_reaction_IDs(index, nuclide, data, value): + offset = nuclide["proton_nonelastic_reaction_IDs_offset"] data[offset + index] = value @njit -def proton_inelastic_scattering_reaction_IDs_all(nuclide, data, value): - start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] - size = nuclide["N_proton_inelastic_scattering_reaction"] +def proton_nonelastic_reaction_IDs_all(nuclide, data, value): + start = nuclide["proton_nonelastic_reaction_IDs_offset"] + size = nuclide["N_proton_nonelastic_reaction"] end = start + size data[start:end] = value @njit -def proton_inelastic_scattering_reaction_IDs_last(nuclide, data, value): - start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] - size = nuclide["N_proton_inelastic_scattering_reaction"] +def proton_nonelastic_reaction_IDs_last(nuclide, data, value): + start = nuclide["proton_nonelastic_reaction_IDs_offset"] + size = nuclide["N_proton_nonelastic_reaction"] end = start + size data[end - 1] = value @njit -def proton_inelastic_scattering_reaction_IDs_chunk(start, length, nuclide, data, value): - start += nuclide["proton_inelastic_scattering_reaction_IDs_offset"] +def proton_nonelastic_reaction_IDs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_nonelastic_reaction_IDs_offset"] end = start + length data[start:end] = value diff --git a/mcdc/mcdc_set/proton_nonelastic_reaction.py b/mcdc/mcdc_set/proton_nonelastic_reaction.py new file mode 100644 index 000000000..7105064c9 --- /dev/null +++ b/mcdc/mcdc_set/proton_nonelastic_reaction.py @@ -0,0 +1,84 @@ +# The following is automatically generated by code_factory.py + +from numba import njit + + +@njit +def spectrum_probability_grid(index, proton_nonelastic_reaction, data, value): + offset = proton_nonelastic_reaction["spectrum_probability_grid_offset"] + data[offset + index] = value + + +@njit +def spectrum_probability_grid_all(proton_nonelastic_reaction, data, value): + start = proton_nonelastic_reaction["spectrum_probability_grid_offset"] + size = proton_nonelastic_reaction["spectrum_probability_grid_length"] + end = start + size + data[start:end] = value + + +@njit +def spectrum_probability_grid_last(proton_nonelastic_reaction, data, value): + start = proton_nonelastic_reaction["spectrum_probability_grid_offset"] + size = proton_nonelastic_reaction["spectrum_probability_grid_length"] + end = start + size + data[end - 1] = value + + +@njit +def spectrum_probability_grid_chunk(start, length, proton_nonelastic_reaction, data, value): + start += proton_nonelastic_reaction["spectrum_probability_grid_offset"] + end = start + length + data[start:end] = value + + +@njit +def spectrum_probability_vector(index_1, proton_nonelastic_reaction, data, value): + offset = proton_nonelastic_reaction["spectrum_probability_offset"] + stride = proton_nonelastic_reaction["N_spectrum"] + start = offset + index_1 * stride + end = start + stride + data[start:end] - value + + +@njit +def spectrum_probability(index_1, index_2, proton_nonelastic_reaction, data, value): + offset = proton_nonelastic_reaction["spectrum_probability_offset"] + stride = proton_nonelastic_reaction["N_spectrum"] + data[offset + index_1 * stride + index_2] = value + + +@njit +def spectrum_probability_chunk(start, length, proton_nonelastic_reaction, data, value): + start += proton_nonelastic_reaction["spectrum_probability_offset"] + end = start + length + data[start:end] = value + + +@njit +def energy_spectrum_IDs(index, proton_nonelastic_reaction, data, value): + offset = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + data[offset + index] = value + + +@njit +def energy_spectrum_IDs_all(proton_nonelastic_reaction, data, value): + start = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + size = proton_nonelastic_reaction["N_energy_spectrum"] + end = start + size + data[start:end] = value + + +@njit +def energy_spectrum_IDs_last(proton_nonelastic_reaction, data, value): + start = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + size = proton_nonelastic_reaction["N_energy_spectrum"] + end = start + size + data[end - 1] = value + + +@njit +def energy_spectrum_IDs_chunk(start, length, proton_nonelastic_reaction, data, value): + start += proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + end = start + length + data[start:end] = value diff --git a/mcdc/numba_types.py b/mcdc/numba_types.py index c3ce65d99..5c0dca6f8 100644 --- a/mcdc/numba_types.py +++ b/mcdc/numba_types.py @@ -420,10 +420,8 @@ ('proton_total_xs_length', int64), ('proton_elastic_xs_offset', int64), ('proton_elastic_xs_length', int64), - ('proton_capture_xs_offset', int64), - ('proton_capture_xs_length', int64), - ('proton_inelastic_xs_offset', int64), - ('proton_inelastic_xs_length', int64), + ('proton_nonelastic_xs_offset', int64), + ('proton_nonelastic_xs_length', int64), ('N_neutron_elastic_scattering_reaction', int64), ('neutron_elastic_scattering_reaction_IDs_offset', int64), ('N_neutron_capture_reaction', int64), @@ -434,10 +432,8 @@ ('neutron_fission_reaction_IDs_offset', int64), ('N_proton_elastic_scattering_reaction', int64), ('proton_elastic_scattering_reaction_IDs_offset', int64), - ('N_proton_capture_reaction', int64), - ('proton_capture_reaction_IDs_offset', int64), - ('N_proton_inelastic_scattering_reaction', int64), - ('proton_inelastic_scattering_reaction_IDs_offset', int64), + ('N_proton_nonelastic_reaction', int64), + ('proton_nonelastic_reaction_IDs_offset', int64), ('neutron_fission_prompt_multiplicity_ID', int64), ('neutron_fission_delayed_multiplicity_ID', int64), ('N_neutron_fission_delayed_precursor', int64), @@ -533,18 +529,13 @@ ('parent_ID', int64), ]) -proton_capture_reaction = into_dtype([ - ('ID', int64), - ('parent_ID', int64), -]) - proton_elastic_scattering_reaction = into_dtype([ ('mu_table_ID', int64), ('ID', int64), ('parent_ID', int64), ]) -proton_inelastic_scattering_reaction = into_dtype([ +proton_nonelastic_reaction = into_dtype([ ('multiplicity', int64), ('angle_type', int64), ('mu_ID', int64), @@ -844,12 +835,10 @@ def set_simulation(N: dict): ('N_neutron_inelastic_scattering_reaction', int64), ('sources', source, (N['source'])), ('N_source', int64), - ('proton_capture_reactions', proton_capture_reaction, (N['proton_capture_reaction'])), - ('N_proton_capture_reaction', int64), ('proton_elastic_scattering_reactions', proton_elastic_scattering_reaction, (N['proton_elastic_scattering_reaction'])), ('N_proton_elastic_scattering_reaction', int64), - ('proton_inelastic_scattering_reactions', proton_inelastic_scattering_reaction, (N['proton_inelastic_scattering_reaction'])), - ('N_proton_inelastic_scattering_reaction', int64), + ('proton_nonelastic_reactions', proton_nonelastic_reaction, (N['proton_nonelastic_reaction'])), + ('N_proton_nonelastic_reaction', int64), ('proton_reactions', proton_reaction, (N['proton_reaction'])), ('N_proton_reaction', int64), ('cells', cell, (N['cell'])), diff --git a/mcdc/object_/material.py b/mcdc/object_/material.py index c2b2fbfbe..a71800e79 100644 --- a/mcdc/object_/material.py +++ b/mcdc/object_/material.py @@ -69,9 +69,9 @@ class Material(MaterialBase): name : str, optional User label. nuclide_composition : dict - Dictionary mapping nuclide names (str) to atom densities (float). + Dictionary mapping nuclide names (str) to atom densities in units of atoms/barn-cm (float). element_composition : dict - Dictionary mapping element names (str) to atom densities (float). + Dictionary mapping element names (str) to atom densities in units of atoms/barn-cm (float). temperature : float, optional Temperature in Kelvin (default 293.6 K). diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index 3c8a5aa2f..534753bb2 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -18,9 +18,9 @@ set_energy_distribution, ) from mcdc.object_.proton_reaction import( - ProtonReactionCapture, ProtonReactionElasticScattering, - ProtonReactionInelasticScattering, + ProtonReactionNonelasticReaction, + ProtonSecondaryChannel, set_energy_distribution, ) from mcdc.object_.simulation import simulation @@ -51,16 +51,16 @@ class Nuclide(ObjectNonSingleton): proton_xs_energy_grid: NDArray[float64] proton_total_xs: NDArray[float64] proton_elastic_xs: NDArray[float64] - proton_capture_xs: NDArray[float64] - proton_inelastic_xs: NDArray[float64] + proton_nonelastic_xs: NDArray[float64] # neutron_elastic_scattering_reactions: list[NeutronReactionElasticScattering] neutron_capture_reactions: list[NeutronReactionCapture] neutron_inelastic_scattering_reactions: list[NeutronReactionInelasticScattering] neutron_fission_reactions: list[NeutronReactionFission] proton_elastic_scattering_reactions: list[ProtonReactionElasticScattering] - proton_capture_reactions: list[ProtonReactionCapture] - proton_inelastic_scattering_reactions: list[ProtonReactionInelasticScattering] + proton_nonelastic_reactions: list[ProtonReactionNonelasticReaction] + proton_secondary_channels: dict[int, list[ProtonSecondaryChannel]] + non_numba: list[str] = ["proton_secondary_channels"] # neutron_fission_prompt_multiplicity: DataBase neutron_fission_delayed_multiplicity: DataBase @@ -97,19 +97,17 @@ def __init__(self, nuclide_name, temperature): self.proton_xs_energy_grid = np.zeros(0) self.proton_total_xs = np.zeros(0) self.proton_elastic_xs = np.zeros(0) - self.proton_capture_xs = np.zeros(0) - self.proton_inelastic_xs = np.zeros(0) + self.proton_nonelastic_xs = np.zeros(0) # Reactions self.neutron_elastic_scattering_reactions = [] self.neutron_capture_reactions = [] self.neutron_inelastic_scattering_reactions = [] self.neutron_fission_reactions = [] self.proton_elastic_scattering_reactions = [] - self.proton_capture_reactions = [] - self.proton_inelastic_scattering_reactions = [] + self.proton_nonelastic_reactions = [] # Fission - self.neutron_fission_prompt_multiplicity = 0 - self.neutron_fission_delayed_multiplicity = 0 + self.neutron_fission_prompt_multiplicity = DataPolynomial(np.array([0.0])) + self.neutron_fission_delayed_multiplicity = DataPolynomial(np.array([0.0])) self.N_neutron_fission_delayed_precursor = 0 self.neutron_fission_delayed_fractions = np.zeros(0) self.neutron_fission_delayed_decay_rates = np.zeros(0) @@ -256,8 +254,7 @@ def set_neutron_data(self): def set_proton_data(self): nuclide_name = self.name - # All proton data in ENDF70PROT is at 293.6K - temperature = 293.6 + temperature = self.temperature # Load data library dir_name = os.getenv("MCDC_LIB") @@ -266,8 +263,7 @@ def set_proton_data(self): rx_names = [ "elastic_scattering", - "capture", - "inelastic_scattering", + "nonelastic_reaction", ] # The reaction MTs @@ -292,13 +288,11 @@ def set_proton_data(self): # The total XS self.proton_total_xs = np.zeros_like(self.proton_xs_energy_grid) self.proton_elastic_xs = np.zeros_like(self.proton_xs_energy_grid) - self.proton_capture_xs = np.zeros_like(self.proton_xs_energy_grid) - self.proton_inelastic_xs = np.zeros_like(self.proton_xs_energy_grid) + self.proton_nonelastic_xs = np.zeros_like(self.proton_xs_energy_grid) xs_containers = [ self.proton_elastic_xs, - self.proton_capture_xs, - self.proton_inelastic_xs, + self.proton_nonelastic_xs, ] for xs_container, rx_name in list(zip(xs_containers, rx_names)): @@ -308,8 +302,7 @@ def set_proton_data(self): self.proton_total_xs = ( self.proton_elastic_xs - + self.proton_capture_xs - + self.proton_inelastic_xs + + self.proton_nonelastic_xs ) @@ -318,18 +311,15 @@ def set_proton_data(self): # ========================================================================== self.proton_elastic_scattering_reactions = [] - self.proton_capture_reactions = [] - self.proton_inelastic_scattering_reactions = [] + self.proton_nonelastic_reactions = [] rx_containers = [ self.proton_elastic_scattering_reactions, - self.proton_capture_reactions, - self.proton_inelastic_scattering_reactions, + self.proton_nonelastic_reactions, ] rx_classes = [ ProtonReactionElasticScattering, - ProtonReactionCapture, - ProtonReactionInelasticScattering, + ProtonReactionNonelasticReaction, ] for rx_container, rx_name, rx_class in list( zip(rx_containers, rx_names, rx_classes) @@ -339,10 +329,37 @@ def set_proton_data(self): reaction = rx_class.from_h5_group(h5_group) rx_container.append(reaction) + # # ========================================================================== + # # Secondary particles + # # ========================================================================== + + # self.proton_secondary_channels = {} + # if "secondary_particles" in file: + # sec_group = file["secondary_particles"] + # for zap_name in sec_group.keys(): + # if not zap_name.startswith("ZAP_"): + # continue + # zap = int(zap_name.split("_")[1]) + # zap_group = sec_group[zap_name] + + # # Iterate over MT numbers for this secondary particle type + # for mt_name in zap_group.keys(): + # if not mt_name.startswith("MT-"): + # continue + # MT = int(mt_name.split("-")[1]) + # mt_group = zap_group[mt_name] + + # # Load secondary channel + # channel = ProtonSecondaryChannel.from_h5_group(mt_group, zap) + + # if MT not in self.proton_secondary_channels: + # self.proton_secondary_channels[MT] = [] + # self.proton_secondary_channels[MT].append(channel) + file.close() - ## UPDATE this for protons + ## TODO: UPDATE this to handle protons as well as neutrons def __repr__(self): text = "\n" text += f"Nuclide\n" diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py index f9f1093c6..db05e9344 100644 --- a/mcdc/object_/proton_reaction.py +++ b/mcdc/object_/proton_reaction.py @@ -1,4 +1,5 @@ from typing import Annotated +import numpy as np from numpy import float64 from numpy.typing import NDArray @@ -12,11 +13,12 @@ ANGLE_DISTRIBUTED, INTERPOLATION_LINEAR, INTERPOLATION_LOG, - PROTON_REACTION_CAPTURE, PROTON_REACTION_ELASTIC_SCATTERING, - PROTON_REACTION_INELASTIC_SCATTERING, + PROTON_REACTION_NONELASTIC, REFERENCE_FRAME_COM, REFERENCE_FRAME_LAB, + PARTICLE_NEUTRON, + PARTICLE_PROTON, ) from mcdc.object_.base import ObjectPolymorphic from mcdc.object_.distribution import ( @@ -32,6 +34,15 @@ from mcdc.object_.simulation import simulation from mcdc.print_ import print_1d_array, print_error +# ====================================================================================== +# ZAP to particle type mapping +# ====================================================================================== + +ZAP_TO_PARTICLE = { + 1: PARTICLE_NEUTRON, + 31: PARTICLE_PROTON, +} + # ====================================================================================== # Proton reaction base class # ====================================================================================== @@ -69,10 +80,8 @@ def __repr__(self): def decode_type(type_): if type_ == PROTON_REACTION_ELASTIC_SCATTERING: return "Proton elastic scattering" - elif type_ == PROTON_REACTION_CAPTURE: - return "Proton capture" - elif type_ == PROTON_REACTION_INELASTIC_SCATTERING: - return "Proton inelastic scattering" + elif type_ == PROTON_REACTION_NONELASTIC: + return "Proton nonelastic reaction" def decode_reference_frame(type_): @@ -91,7 +100,7 @@ class ProtonReactionElasticScattering(ProtonReactionBase): # Annotations for Numba mode label: str = "proton_elastic_scattering_reaction" # - mu_table: DistributionMultiTable + mu_table: DistributionBase def __init__(self, MT, xs, xs_offset, reference_frame, mu): type_ = PROTON_REACTION_ELASTIC_SCATTERING @@ -111,32 +120,12 @@ def __repr__(self): # ====================================================================================== -# Proton capture -# ====================================================================================== - - -class ProtonReactionCapture(ProtonReactionBase): - # Annotations for Numba mode - label: str = "proton_capture_reaction" - - def __init__(self, MT, xs, xs_offset, reference_frame, q_value): - type_ = PROTON_REACTION_CAPTURE - super().__init__(type_, MT, xs, xs_offset, reference_frame, q_value) - - @classmethod - def from_h5_group(cls, h5_group): - MT, xs, xs_offset, reference_frame, q_value = set_basic_properties(h5_group) - return cls(MT, xs, xs_offset, reference_frame, q_value) - - -# ====================================================================================== -# Proton inelastic scattering +# Proton nonelastic reaction # ====================================================================================== - -class ProtonReactionInelasticScattering(ProtonReactionBase): +class ProtonReactionNonelasticReaction(ProtonReactionBase): # Annotations for Numba mode - label: str = "proton_inelastic_scattering_reaction" + label: str = "proton_nonelastic_reaction" # multiplicity: int angle_type: int @@ -163,7 +152,7 @@ def __init__( spectrum_probability, energy_spectra, ): - type_ = PROTON_REACTION_INELASTIC_SCATTERING + type_ = PROTON_REACTION_NONELASTIC super().__init__(type_, MT, xs, xs_offset, reference_frame, q_value) self.multiplicity = multiplicity @@ -229,7 +218,7 @@ def __repr__(self): def set_basic_properties(h5_group): - MT = h5_group.attrs["MT"][()] + MT = int(h5_group.attrs["MT"][()]) xs = h5_group["xs"][()] xs_offset = h5_group["xs"].attrs["offset"] reference_frame = h5_group["reference_frame"][()].decode("utf-8") @@ -242,19 +231,64 @@ def set_basic_properties(h5_group): def set_angular_distribution(h5_group): - mu_type = h5_group.attrs["type"] + # Handle missing type attribute + if "type" not in h5_group.attrs: + mu_type = "isotropic" + else: + mu_type = h5_group.attrs["type"] + if mu_type == "isotropic": angle_type = ANGLE_ISOTROPIC mu = simulation.distributions[0] elif mu_type == "energy-correlated": angle_type = ANGLE_ENERGY_CORRELATED mu = simulation.distributions[0] - else: + elif mu_type == "given_in_energy_distribution": + # Angular information comes from the Kalbach-Mann energy distribution. + angle_type = ANGLE_ENERGY_CORRELATED + mu = simulation.distributions[0] + elif mu_type == "tabulated": angle_type = ANGLE_DISTRIBUTED - grid = h5_group[f"energy"][()] * 1e6 # MeV to eV - offset = h5_group[f"offset"][()] - value = h5_group[f"value"][()] - pdf = h5_group[f"pdf"][()] + + # Check if data is in flattened format or subgroup format + if "energy" in h5_group: + # Flattened format + grid = h5_group[f"energy"][()] * 1e6 # MeV to eV + offset = h5_group[f"offset"][()] + value = h5_group[f"value"][()] + pdf = h5_group[f"pdf"][()] + else: + # Subgroup format: E_in_1, E_in_2, etc. + incident_energies = h5_group["incident_energies"][()] * 1e6 # MeV to eV + + # Collect all cosines and pdfs into flattened arrays + cosines_list = [] + pdf_list = [] + offset = np.zeros(len(incident_energies), dtype=np.int32) + + for i, energy in enumerate(incident_energies): + subgroup_name = f"E_in_{i + 1}" + if subgroup_name in h5_group: + subgroup = h5_group[subgroup_name] + if subgroup.attrs.get("type", "tabulated") == "tabulated": + cosines_list.extend(subgroup["cosines"][()]) + pdf_list.extend(subgroup["pdf"][()]) + else: + # Isotropic - use dummy values + cosines_list.extend([0.0]) # isotropic cosine + pdf_list.extend([1.0]) # uniform pdf + else: + # Missing subgroup - assume isotropic + cosines_list.extend([0.0]) + pdf_list.extend([1.0]) + + if i < len(incident_energies) - 1: + offset[i + 1] = len(cosines_list) + + grid = incident_energies + value = np.array(cosines_list) + pdf = np.array(pdf_list) + mu = DistributionMultiTable(grid, offset, value, pdf) return angle_type, mu @@ -338,3 +372,94 @@ def set_energy_distribution(h5_group): print_error(f"Unsupported energy spectrum of type {spectrum_type}") return energy_spectrum + + +# ====================================================================================== +# Proton secondary particle channel +# ====================================================================================== + + +class ProtonSecondaryChannel(ObjectPolymorphic): + """ + Data container for a proton secondary particle channel. + Plain helper object. + """ + particle_type: int + MT: int + multiplicity: float64 # Multiplicity of particles produced per reaction + production_xs: NDArray[float64] + production_xs_offset_: int + reference_frame: int # COM or LAB + energy_spectrum: DistributionBase + + def __init__( + self, + particle_type, + MT, + multiplicity, + production_xs, + production_xs_offset, + reference_frame, + energy_spectrum, + ): + self.particle_type = particle_type + self.MT = MT + self.multiplicity = multiplicity + self.production_xs = production_xs + self.production_xs_offset_ = production_xs_offset + self.reference_frame = reference_frame + self.energy_spectrum = energy_spectrum + super().__init__(type_=0, register=False) + + @classmethod + def from_h5_group(cls, h5_group, zap): + """ + Load a secondary particle channel from HDF5 group. + zap: ZAP code (1=neutron, 31=proton, etc.) + """ + if zap not in ZAP_TO_PARTICLE: + raise ValueError(f"zap {zap} not in ZAP_TO_PARTICLE") + particle_type = ZAP_TO_PARTICLE.get(zap) + MT = h5_group.attrs["MT"] + multiplicity = h5_group.attrs["multiplicity"] + + reference_frame_str = h5_group.attrs["reference_frame"] + if reference_frame_str == "LAB": + reference_frame = REFERENCE_FRAME_LAB + elif reference_frame_str == "COM": + reference_frame = REFERENCE_FRAME_COM + else: + reference_frame = REFERENCE_FRAME_COM # default + + # Production cross section (optional) + if "production_xs" in h5_group: + production_xs = h5_group["production_xs"][()] + production_xs_offset = h5_group["production_xs"].attrs["offset"] + else: + production_xs = np.zeros(0, dtype=float) + production_xs_offset = 0 + + # Energy spectrum (currently assume Kalbach-Mann) + energy_spectrum = set_energy_distribution(h5_group["kalbach_mann"]) + + return cls( + particle_type, + MT, + multiplicity, + production_xs, + production_xs_offset, + reference_frame, + energy_spectrum, + ) + + def __repr__(self): + particle_name = "Neutron" if self.particle_type == PARTICLE_NEUTRON else "Proton" + text = "\n" + text += f"Proton secondary channel ({particle_name})\n" + text += f" - ID: {self.ID}\n" + text += f" - MT: {self.MT}\n" + text += f" - Multiplicity: {self.multiplicity}\n" + text += f" - Production XS: {print_1d_array(self.production_xs)} barn\n" + text += f" - Reference frame: {decode_reference_frame(self.reference_frame)}\n" + text += f" - Energy spectrum: {distribution.decode_type(self.energy_spectrum.type)} [ID: {self.energy_spectrum.ID}]\n" + return text diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index 3d895c5e4..9d5ed9f02 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -24,7 +24,6 @@ def particle_speed(particle_container, simulation, data): elif particle["particle_type"] == PARTICLE_ELECTRON: return electron.particle_speed(particle_container, simulation, data) elif particle["particle_type"] == PARTICLE_PROTON: - print(f'proton E = {particle["E"]}') return proton.particle_speed(particle_container, simulation, data) return -1.0 diff --git a/mcdc/transport/physics/proton/interface.py b/mcdc/transport/physics/proton/interface.py index ebbc1b99b..eda6c9013 100644 --- a/mcdc/transport/physics/proton/interface.py +++ b/mcdc/transport/physics/proton/interface.py @@ -13,11 +13,7 @@ @njit def particle_speed(particle_container, simulation, data): - if simulation["settings"]["proton_multigroup_mode"]: - return multigroup.particle_speed(particle_container, simulation, data) - else: - return native.particle_speed(particle_container) - + return native.particle_speed(particle_container) # ====================================================================================== # Material properties @@ -26,23 +22,7 @@ def particle_speed(particle_container, simulation, data): @njit def macro_xs(reaction_type, particle_container, simulation, data): - if simulation["settings"]["proton_multigroup_mode"]: - return multigroup.macro_xs(reaction_type, particle_container, simulation, data) - else: - return native.macro_xs(reaction_type, particle_container, simulation, data) - - -# @njit -# def proton_production_xs(reaction_type, particle_container, simulation, data): -# if simulation["settings"]["proton_multigroup_mode"]: -# return multigroup.proton_production_xs( -# reaction_type, particle_container, simulation, data -# ) -# else: -# return native.proton_production_xs( -# reaction_type, particle_container, simulation, data -# ) - + return native.macro_xs(reaction_type, particle_container, simulation, data) # ====================================================================================== # Collision @@ -52,10 +32,4 @@ def macro_xs(reaction_type, particle_container, simulation, data): @njit def collision(particle_container, collision_data_container, program, data): simulation = util.access_simulation(program) - - if simulation["settings"]["proton_multigroup_mode"]: - multigroup.collision( - particle_container, collision_data_container, program, data - ) - else: - native.collision(particle_container, collision_data_container, program, data) + native.collision(particle_container, collision_data_container, program, data) \ No newline at end of file diff --git a/mcdc/transport/physics/proton/multigroup.py b/mcdc/transport/physics/proton/multigroup.py index f91aae924..9b94c5d4d 100644 --- a/mcdc/transport/physics/proton/multigroup.py +++ b/mcdc/transport/physics/proton/multigroup.py @@ -15,8 +15,8 @@ from mcdc.constant import ( PI, PROTON_REACTION_TOTAL, - PROTON_REACTION_CAPTURE, PROTON_REACTION_ELASTIC_SCATTERING, + PROTON_REACTION_NONELASTIC, ) from mcdc.transport.physics.util import scatter_direction from mcdc.transport.distribution import sample_isotropic_direction @@ -46,8 +46,6 @@ def macro_xs(reaction_type, particle_container, simulation, data): if reaction_type == PROTON_REACTION_TOTAL: return mcdc_get.multigroup_material.mgxs_total(g, material, data) - elif reaction_type == PROTON_REACTION_CAPTURE: - return mcdc_get.multigroup_material.mgxs_capture(g, material, data) elif reaction_type == PROTON_REACTION_ELASTIC_SCATTERING: return mcdc_get.multigroup_material.mgxs_scatter(g, material, data) return 0.0 diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 4ef71f3a3..a0272fac8 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -22,11 +22,13 @@ PI, PI_HALF, PI_SQRT, - PROTON_REACTION_INELASTIC_SCATTERING, PROTON_REACTION_TOTAL, - PROTON_REACTION_CAPTURE, PROTON_REACTION_ELASTIC_SCATTERING, + PROTON_REACTION_NONELASTIC, REFERENCE_FRAME_COM, + PARTICLE_ELECTRON, + PARTICLE_NEUTRON, + PARTICLE_PROTON, ) from mcdc.transport.data import evaluate_data from mcdc.transport.distribution import ( @@ -35,6 +37,7 @@ sample_isotropic_cosine, sample_isotropic_direction, sample_multi_table, + sample_kalbach_mann, ) from mcdc.transport.physics.util import ( evaluate_proton_xs_energy_grid, @@ -97,12 +100,9 @@ def total_micro_xs(reaction_type, E, nuclide, data): elif reaction_type == PROTON_REACTION_ELASTIC_SCATTERING: xs0 = mcdc_get.nuclide.proton_elastic_xs(idx, nuclide, data) xs1 = mcdc_get.nuclide.proton_elastic_xs(idx + 1, nuclide, data) - elif reaction_type == PROTON_REACTION_CAPTURE: - xs0 = mcdc_get.nuclide.proton_capture_xs(idx, nuclide, data) - xs1 = mcdc_get.nuclide.proton_capture_xs(idx + 1, nuclide, data) - elif reaction_type == PROTON_REACTION_INELASTIC_SCATTERING: - xs0 = mcdc_get.nuclide.proton_inelastic_xs(idx, nuclide, data) - xs1 = mcdc_get.nuclide.proton_inelastic_xs(idx + 1, nuclide, data) + elif reaction_type == PROTON_REACTION_NONELASTIC: + xs0 = mcdc_get.nuclide.proton_nonelastic_xs(idx, nuclide, data) + xs1 = mcdc_get.nuclide.proton_nonelastic_xs(idx + 1, nuclide, data) else: # Should be unreachable xs0 = 0.0 @@ -125,70 +125,6 @@ def reaction_micro_xs(E, reaction_base, nuclide, data): xs1 = mcdc_get.proton_reaction.xs(idx + 1, reaction_base, data) return linear_interpolation(E, E0, E1, xs0, xs1) - -# @njit -# def proton_production_xs(reaction_type, particle_container, simulation, data): -# # Total production -# if reaction_type == PROTON_REACTION_TOTAL: -# elastic_xs = macro_xs( -# PROTON_REACTION_ELASTIC_SCATTERING, particle_container, simulation, data -# ) -# inelastic_xs = _proton_inelastic_scattering_production_xs( -# particle_container, simulation, data -# ) -# return elastic_xs + inelastic_xs - -# # Elastic scattering production -# elif reaction_type == PROTON_REACTION_ELASTIC_SCATTERING: -# return macro_xs(reaction_type, particle_container, simulation, data) - -# # Capture production (none) -# elif reaction_type == PROTON_REACTION_CAPTURE: -# return 0.0 - -# # Inelastic scattering production -# elif reaction_type == PROTON_REACTION_INELASTIC_SCATTERING: -# return _proton_inelastic_scattering_production_xs( -# particle_container, simulation, data -# ) - -# # Unsupported default -# else: -# return 0.0 - - -# @njit -# def _proton_inelastic_scattering_production_xs(particle_container, simulation, data): -# particle = particle_container[0] -# material_base = simulation["materials"][particle["material_ID"]] -# material = simulation["native_materials"][material_base["child_ID"]] - -# total = 0.0 -# for i in range(material["N_nuclide"]): -# nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) -# nuclide = simulation["nuclides"][nuclide_ID] - -# E = particle["E"] -# nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) - -# for j in range(nuclide["N_proton_inelastic_scattering_reaction"]): -# reaction_ID = int( -# mcdc_get.nuclide.proton_inelastic_scattering_reaction_IDs( -# j, nuclide, data -# ) -# ) -# reaction_base = simulation["proton_reactions"][reaction_ID] -# reaction = simulation["proton_inelastic_scattering_reactions"][ -# reaction_base["child_ID"] -# ] - -# xs = reaction_micro_xs(E, reaction_base, nuclide, data) -# nu = reaction["multiplicity"] -# total += nuclide_density * nu * xs - -# return total - - # ====================================================================================== # Collision # ====================================================================================== @@ -210,42 +146,7 @@ def collision(particle_container, collision_data_container, program, data): SigmaT = macro_xs(PROTON_REACTION_TOTAL, particle_container, simulation, data) - # Implicit capture - if simulation["implicit_capture"]["active"]: - # Calculate capture fraction - SigmaC = macro_xs( - PROTON_REACTION_CAPTURE, particle_container, simulation, data - ) - capture_fraction = SigmaC / SigmaT - - # Deposit energy captured - collision_data["energy_deposition"] += E * particle["w"] * capture_fraction - - # Q-value: xs-weighted average over all nuclides and capture reactions - for i in range(material["N_nuclide"]): - nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) - nuclide = simulation["nuclides"][nuclide_ID] - nuclide_density = mcdc_get.native_material.nuclide_densities( - i, material, data - ) - for j in range(nuclide["N_proton_capture_reaction"]): - reaction_ID = int( - mcdc_get.nuclide.proton_capture_reaction_IDs(j, nuclide, data) - ) - reaction = simulation["proton_capture_reactions"][reaction_ID] - reaction_base_ID = reaction["parent_ID"] - reaction_base = simulation["proton_reactions"][reaction_base_ID] - xs = reaction_micro_xs(E, reaction_base, nuclide, data) - Sigma_rx = nuclide_density * xs - collision_data["energy_deposition"] += ( - reaction_base["q_value"] * 1e6 * particle["w"] * Sigma_rx / SigmaT - ) - - # Capture particle weight - particle["w"] *= 1.0 - capture_fraction - - # Adjust total XS - SigmaT -= SigmaC + # No implicit capture for protons (there's no capture xs) xi = rng.lcg(particle_container) * SigmaT total = 0.0 @@ -256,10 +157,6 @@ def collision(particle_container, collision_data_container, program, data): nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) sigmaT = total_micro_xs(PROTON_REACTION_TOTAL, E, nuclide, data) - if simulation["implicit_capture"]["active"]: - sigmaC = total_micro_xs(PROTON_REACTION_CAPTURE, E, nuclide, data) - sigmaT -= sigmaC - SigmaT_nuclide = nuclide_density * sigmaT total += SigmaT_nuclide @@ -273,8 +170,8 @@ def collision(particle_container, collision_data_container, program, data): sigma_elastic = total_micro_xs( PROTON_REACTION_ELASTIC_SCATTERING, E, nuclide, data ) - sigma_inelastic = total_micro_xs( - PROTON_REACTION_INELASTIC_SCATTERING, E, nuclide, data + sigma_nonelastic = total_micro_xs( + PROTON_REACTION_NONELASTIC, E, nuclide, data ) xi = rng.lcg(particle_container) * sigmaT @@ -306,47 +203,18 @@ def collision(particle_container, collision_data_container, program, data): ) return - # Capture - if not simulation["implicit_capture"]["active"]: - sigma_capture = total_micro_xs(PROTON_REACTION_CAPTURE, E, nuclide, data) - total += sigma_capture - if xi < total: - # Sample the actual reaction from the group - total -= sigma_capture - for i in range(nuclide["N_proton_capture_reaction"]): - reaction_ID = int( - mcdc_get.nuclide.proton_capture_reaction_IDs(i, nuclide, data) - ) - reaction = simulation["proton_capture_reactions"][reaction_ID] - reaction_base_ID = reaction["parent_ID"] - reaction_base = simulation["proton_reactions"][reaction_base_ID] - xs = reaction_micro_xs(E, reaction_base, nuclide, data) - total += xs - - # Execute the reaction - if xi < total: - capture( - reaction, - particle_container, - collision_data_container, - nuclide, - simulation, - data, - ) - return - - # Inelastic scattering - total += sigma_inelastic + # Noelastic reaction + total += sigma_nonelastic if xi < total: # Sample the actual reaction from the group - total -= sigma_inelastic - for i in range(nuclide["N_proton_inelastic_scattering_reaction"]): + total -= sigma_nonelastic + for i in range(nuclide["N_proton_nonelastic_reaction"]): reaction_ID = int( - mcdc_get.nuclide.proton_inelastic_scattering_reaction_IDs( + mcdc_get.nuclide.proton_nonelastic_reaction_IDs( i, nuclide, data ) ) - reaction = simulation["proton_inelastic_scattering_reactions"][reaction_ID] + reaction = simulation["proton_nonelastic_reactions"][reaction_ID] reaction_base_ID = reaction["parent_ID"] reaction_base = simulation["proton_reactions"][reaction_base_ID] xs = reaction_micro_xs(E, reaction_base, nuclide, data) @@ -354,7 +222,7 @@ def collision(particle_container, collision_data_container, program, data): # Execute the reaction if xi < total: - inelastic_scattering( + nonelastic_reaction( reaction, particle_container, collision_data_container, @@ -364,29 +232,6 @@ def collision(particle_container, collision_data_container, program, data): ) return -# ====================================================================================== -# Capture -# ====================================================================================== - - -@njit -def capture( - reaction, particle_container, collision_data_container, nuclide, simulation, data -): - particle = particle_container[0] - collision_data = collision_data_container[0] - - reaction_base_ID = reaction["parent_ID"] - reaction_base = simulation["proton_reactions"][reaction_base_ID] - - # Terminate the particle - particle["alive"] = False - - # Energy deposition - E = particle["E"] - q_value = reaction_base["q_value"] * 1e6 - collision_data["energy_deposition"] += (E + q_value) * particle["w"] - # ====================================================================================== # Elastic scattering @@ -451,7 +296,12 @@ def elastic_scattering( uz = vz / speed # Sample the scattering cosine from the multi-PDF distribution - multi_table = simulation["multi_table_distributions"][reaction["mu_table_ID"]] + mu_table_ID = reaction["mu_table_ID"] + if mu_table_ID >= len(simulation["multi_table_distributions"]): + mu_table_ID = 0 # Fallback to first distribution + multi_table = simulation["multi_table_distributions"][mu_table_ID] + + # multi_table = simulation["multi_table_distributions"][reaction["mu_table_ID"]] mu0 = sample_multi_table(E, particle_container, multi_table, data) # Scatter the direction in COM @@ -534,14 +384,22 @@ def sample_nucleus_velocity(A, particle_container): # ====================================================================================== -# Inelastic scattering +# Nonelastic scattering # ====================================================================================== @njit -def inelastic_scattering( +def nonelastic_reaction( reaction, particle_container, collision_data_container, nuclide, program, data ): + + """ + Proton nonelastic scattering with secondary particle production. + + Samples: + 1. Outgoing proton from proton_reactions/inelastic/MT-005 + 2. Secondary particles from secondary_particles/ZAP_x/MT-005 + """ simulation = util.access_simulation(program) particle = particle_container[0] collision_data = collision_data_container[0] @@ -554,26 +412,34 @@ def inelastic_scattering( ux = particle["ux"] uy = particle["uy"] uz = particle["uz"] + w = particle["w"] - # Kill the current particle + # Kill the incident proton particle["alive"] = False - # Energy deposition + # Q-value energy available q_value = reaction_base["q_value"] * 1e6 - collision_data["energy_deposition"] += (E + q_value) * particle["w"] + total_energy = E + q_value - # Number of secondaries and spectra - N = reaction["multiplicity"] + # =========================================================================== + # 1. Sample outgoing PROTON + # =========================================================================== + + # Number of outgoing protons and spectra + N_proton = reaction["multiplicity"] N_spectrum = reaction["N_spectrum"] - use_all_spectrum = N == N_spectrum + use_all_spectrum = N_proton == N_spectrum - # Set up secondary partice container + # Set up secondary particle container particle_container_new = util.local_array(1, type_.particle_data) particle_new = particle_container_new[0] - # Create the secondaries - for n in range(N): - # Set default attributes + # Energy deposition (will be adjusted as we create secondaries) + collision_data["energy_deposition"] += total_energy * w + + # Create outgoing protons + for n in range(N_proton): + # Set default attributes (copy incident proton) particle_module.copy_as_child(particle_container_new, particle_container) # ============================================================================== @@ -599,7 +465,7 @@ def inelastic_scattering( # Get energy spectrum if use_all_spectrum: ID = int( - mcdc_get.proton_inelastic_scattering_reaction.energy_spectrum_IDs( + mcdc_get.proton_nonelastic_reaction.energy_spectrum_IDs( n, reaction, data ) ) @@ -608,23 +474,19 @@ def inelastic_scattering( offset = reaction["spectrum_probability_grid_offset"] length = reaction["spectrum_probability_grid_length"] probability_grid = data[offset : offset + length] - # Above is equivalent to: - # probability_grid = mcdc_get.proton_inelastic_scattering_reaction.spectrum_probability_grid_all( - # reaction, data - # ) probability_idx = find_bin(E, probability_grid) xi = rng.lcg(particle_container_new) total = 0.0 for j in range(N_spectrum): probability = ( - mcdc_get.proton_inelastic_scattering_reaction.spectrum_probability( + mcdc_get.proton_nonelastic_reaction.spectrum_probability( probability_idx, j, reaction, data ) ) total += probability if xi < total: ID = int( - mcdc_get.proton_inelastic_scattering_reaction.energy_spectrum_IDs( + mcdc_get.proton_nonelastic_reaction.energy_spectrum_IDs( j, reaction, data ) ) @@ -645,7 +507,6 @@ def inelastic_scattering( # Frame transformation # ============================================================================== - reaction_base = simulation["proton_reactions"][int(reaction["parent_ID"])] reference_frame = reaction_base["reference_frame"] if reference_frame == REFERENCE_FRAME_COM: A = nuclide["atomic_weight_ratio"] @@ -665,6 +526,7 @@ def inelastic_scattering( particle_new["uy"] = uy_new particle_new["uz"] = uz_new particle_new["E"] = E_new + particle_new["particle_type"] = PARTICLE_PROTON # Subtract outgoing energy from energy deposition collision_data["energy_deposition"] -= particle_new["E"] * particle_new["w"] @@ -674,14 +536,28 @@ def inelastic_scattering( # ============================================================================== # Keep it if it is the last particle - if n == N - 1: + if n == N_proton - 1: particle["alive"] = True particle["ux"] = particle_new["ux"] particle["uy"] = particle_new["uy"] particle["uz"] = particle_new["uz"] particle["E"] = particle_new["E"] + particle["particle_type"] = PARTICLE_PROTON else: particle_bank_module.bank_active_particle(particle_container_new, program) + # =========================================================================== + # 2. Sample SECONDARY PARTICLES from secondary_particles groups + # =========================================================================== + + # Get secondary channels for this MT (if any) + # MT = int(reaction_base["MT"]) + # nuclide_ID = particle["nuclide_ID"] + + # Check if nuclide has secondary particle data + # (This requires access to nuclide secondary_channels dict, which needs to be added) + # For now, we'll skip this part and it can be added when the data structure supports it + # TODO: Add secondary particle sampling when nuclide.proton_secondary_channels is accessible + # No fission for protons \ No newline at end of file diff --git a/tools/data_library_generator/tendl_generate_v2.py b/tools/data_library_generator/tendl_generate_v2.py new file mode 100644 index 000000000..fec0d4d17 --- /dev/null +++ b/tools/data_library_generator/tendl_generate_v2.py @@ -0,0 +1,913 @@ +# The majority of this script was written by Anthropic's Claude + +""" +ace_to_hdf5.py +============== +Convert a directory of proton ACE files (e.g. TENDL) into per-nuclide HDF5 files +suitable for use in MC/DC or similar Monte Carlo transport codes. + +Usage +----- + python ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 + python ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 --rewrite + python ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 --verbose + +Environment variable fallback (compatible with original MC/DC conventions): + $MCDC_ACELIB → ace_dir + $MCDC_LIB → output_dir + +HDF5 layout produced +-------------------- +/-K.h5 + attrs: + source_title, source_version, source_date + nuclide_name (str) + excitation_level (int) + temperature (float, K) + atomic_number (int) + atomic_weight_ratio (float) + fissionable (bool) + + proton_reactions/ + xs_energy_grid (float array, MeV) + + elastic_scattering/ + MT-002/ + xs (float array, barns) attrs: offset, unit + Q-value (float, MeV) + reference_frame (str: "COM") + angular_cosine_distribution/ (tabulated cosine distributions) + + capture/ + MT-{NNN}/ + xs, Q-value, reference_frame + + nonelastic_reaction/ + MT-{NNN}/ + xs, Q-value, reference_frame + multiplicity (int) + angular_cosine_distribution/ + energy_spectrum-{k}/ (one per distribution in a MultiDistributionData) + + fission/ (only if fissionable) + ... + + secondary_particles/ + ZAP_{zap}/ + attrs: ZAP (int), particle_name (str) + MT-{NNN}/ + attrs: MT (int), multiplicity (int), reference_frame (str) + production_xs (float array, barns) attrs: offset, unit + kalbach_mann/ + incident_energies (float array, MeV) + interpolation_boundaries (int array) + interpolation_types (int array) + E_in_{k}/ (one group per incident energy point) + outgoing_energies (float array, MeV) + pdf (float array) + cdf (float array) + r (float array) Kalbach-Mann precompound fraction + a (float array) Kalbach-Mann slope parameter + +Notes +----- +* The Kalbach-Mann property names on TabulatedKalbachMannDistribution are + introspected at runtime the first time a distribution is encountered, so + this script will work even if ACEtk renames them between versions. +* ZAP particle identity: 1=n, 31=p, 32=d, 33=t, 34=alpha +""" + +import argparse +import os +import sys + +import h5py +import numpy as np +from tqdm import tqdm + +import ACEtk + +# ────────────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────────────── + +# TODO: THIS IS UNCERTAIN - NEED TO VERIFY ZAP NUMBERS/PARTICLE TYPE CORRESPONDANCE + +ZAP_NAMES = { + 0: "photon", + 1: "neutron", + 31: "proton", + 32: "deuteron", + 33: "triton", + 34: "alpha", +} + +# Candidate property names for TabulatedKalbachMannDistribution fields. +# We try each list in order and use the first one that exists on the object. +_KM_CANDIDATES = { + "outgoing_energies": ["outgoing_energies", "energies", "energy"], + "pdf": ["pdf", "probabilities", "probability_density"], + "cdf": ["cdf", "cumulative_probabilities", "cumulative_distribution"], + "r": ["precompound_fraction_values", "precompound_fractions", "r", "R"], + "a": ["angular_distribution_slope_values", "slopes", "a", "A"], +} +# Cache resolved names so introspection only happens once. +_km_resolved: dict[str, str] = {} + + +def _resolve_km_attr(dist_obj, field: str) -> str: + """Return the actual attribute name on dist_obj for the given logical field.""" + if field in _km_resolved: + return _km_resolved[field] + for candidate in _KM_CANDIDATES[field]: + if hasattr(dist_obj, candidate): + _km_resolved[field] = candidate + return candidate + raise AttributeError( + f"Cannot find attribute for '{field}' on " + f"{type(dist_obj).__name__}. " + f"Tried: {_KM_CANDIDATES[field]}. " + f"Available: {[x for x in dir(dist_obj) if not x.startswith('_')]}" + ) + + +def get_km_field(dist_obj, field: str): + """Get a logical Kalbach-Mann field from a TabulatedKalbachMannDistribution.""" + attr = _resolve_km_attr(dist_obj, field) + return getattr(dist_obj, attr) + + +def print_error(msg: str): + print(f"\n[ERROR] {msg}", file=sys.stderr) + sys.exit(1) + + +def print_note(msg: str): + print(f" [note] {msg}") + + +# ────────────────────────────────────────────────────────────────────────────── +# ZAP / name decoding +# ────────────────────────────────────────────────────────────────────────────── + +# Periodic table symbol lookup (Z → symbol) +Z_TO_SYMBOL = { + 1: "H", 2: "He", 3: "Li", 4: "Be", 5: "B", 6: "C", 7: "N", 8: "O", + 9: "F", 10: "Ne",11: "Na",12: "Mg",13: "Al",14: "Si",15: "P", 16: "S", + 17:"Cl", 18:"Ar", 19:"K", 20:"Ca", 21:"Sc", 22:"Ti", 23:"V", 24:"Cr", + 25:"Mn", 26:"Fe", 27:"Co", 28:"Ni", 29:"Cu", 30:"Zn", 31:"Ga", 32:"Ge", + 33:"As", 34:"Se", 35:"Br", 36:"Kr", 37:"Rb", 38:"Sr", 39:"Y", 40:"Zr", + 41:"Nb", 42:"Mo", 43:"Tc", 44:"Ru", 45:"Rh", 46:"Pd", 47:"Ag", 48:"Cd", + 49:"In", 50:"Sn", 51:"Sb", 52:"Te", 53:"I", 54:"Xe", 55:"Cs", 56:"Ba", + 57:"La", 58:"Ce", 59:"Pr", 60:"Nd", 61:"Pm", 62:"Sm", 63:"Eu", 64:"Gd", + 65:"Tb", 66:"Dy", 67:"Ho", 68:"Er", 69:"Tm", 70:"Yb", 71:"Lu", 72:"Hf", + 73:"Ta", 74:"W", 75:"Re", 76:"Os", 77:"Ir", 78:"Pt", 79:"Au", 80:"Hg", + 81:"Tl", 82:"Pb", 83:"Bi", 84:"Po", 85:"At", 86:"Rn", 87:"Fr", 88:"Ra", + 89:"Ac", 90:"Th", 91:"Pa", 92:"U", 93:"Np", 94:"Pu", 95:"Am", 96:"Cm", + 97:"Bk", 98:"Cf", 99:"Es",100:"Fm",101:"Md",102:"No",103:"Lr", +} + + +def decode_ace_zaid(zaid: str): + """ + Decode an ACE ZAID string into (Z, A, S, T). + Handles both legacy '1001.70h' and modern '1001.710h' style ZAIDs. + Returns Z (atomic number), A (mass number), S (isomeric state), T (temperature K). + """ + # Strip trailing whitespace and split on '.' + parts = zaid.strip().split(".") + za_str = parts[0] + # ZA = Z*1000 + A, possibly with S encoded as ZA > 600000 (isomers) + za = int(za_str) + if za >= 600000: + # metastable: ZAID = Z*1000 + A + S*400 (legacy MCNP convention, approximate) + S = (za % 1000) // 400 # rough extraction + za = za - S * 400 + else: + S = 0 + Z = za // 1000 + A = za % 1000 + + # Temperature from suffix, e.g. '70h' → 293 K, '710h' → custom + # The conventional mapping is suffix_number * ~(1/100) * some factor. + # Most TENDL proton files just use a nominal 0K or room temperature. + # Use the header temperature value instead (set to 0 as default here). + T = 0 + return Z, A, S, T + + +# ────────────────────────────────────────────────────────────────────────────── +# Angular distribution loading (from original MC/DC approach) +# ────────────────────────────────────────────────────────────────────────────── + +def load_cosine_distribution(data, h5_group): + """ + Write a tabulated angular (cosine) distribution into an HDF5 group. + data is an AngularDistributionData object from ACEtk. + + Returns True if angular data was written, False if it is encoded + elsewhere (i.e. inside the Kalbach-Mann energy distribution block). + """ + # DistributionGivenElsewhere means the angular data is embedded in the + # LAW 44 Kalbach-Mann energy distribution via the r and a parameters. + # There is nothing to store here — the sampling code must use the + # Kalbach-Mann block instead. + if isinstance(data, ACEtk.continuous.DistributionGivenElsewhere): + h5_group.attrs["type"] = "given_in_energy_distribution" + return False + + energies = np.array(data.incident_energies) + h5_group.create_dataset("incident_energies", data=energies) + h5_group.attrs["unit"] = "MeV" + # Set type on root group (default to tabulated if we get here) + h5_group.attrs["type"] = "tabulated" + + for i, subdist in enumerate(data.distributions): + eg = h5_group.create_group(f"E_in_{i + 1}") + if isinstance(subdist, ACEtk.continuous.TabulatedAngularDistribution): + eg.attrs["type"] = "tabulated" + eg.create_dataset("cosines", data=np.array(subdist.cosines)) + eg.create_dataset("pdf", data=np.array(subdist.pdf)) + eg.create_dataset("cdf", data=np.array(subdist.cdf)) + else: + # Isotropic or unsupported — mark it so sampling code knows + eg.attrs["type"] = "isotropic" + + return True + + +# ────────────────────────────────────────────────────────────────────────────── +# Energy distribution loading (neutron/primary particle, existing reactions) +# ────────────────────────────────────────────────────────────────────────────── + +def load_energy_distribution(data, h5_group): + """ + Write a primary-particle outgoing energy distribution into an HDF5 group. + Handles the most common ACE law types encountered in proton libraries. + """ + if isinstance(data, ACEtk.continuous.KalbachMannDistributionData): + h5_group.attrs["law"] = 44 + _write_kalbach_mann(data, h5_group) + + elif isinstance(data, ACEtk.continuous.OutgoingEnergyDistributionData): + h5_group.attrs["law"] = 4 + h5_group.create_dataset( + "incident_energies", data=np.array(data.incident_energies) + ) + for k, dist in enumerate(data.distributions): + eg = h5_group.create_group(f"E_in_{k + 1}") + eg.create_dataset("outgoing_energies", data=np.array(dist.outgoing_energies)) + eg.create_dataset("pdf", data=np.array(dist.pdf)) + eg.create_dataset("cdf", data=np.array(dist.cdf)) + + elif isinstance(data, ACEtk.continuous.LevelScatteringData): + h5_group.attrs["law"] = 3 + h5_group.create_dataset("C1", data=data.C1) + h5_group.create_dataset("C2", data=data.C2) + + elif isinstance(data, ACEtk.continuous.EquiprobableOutgoingEnergyBins): + h5_group.attrs["law"] = 1 + h5_group.create_dataset( + "incident_energies", data=np.array(data.incident_energies) + ) + for k, dist in enumerate(data.distributions): + eg = h5_group.create_group(f"E_in_{k + 1}") + eg.create_dataset("energies", data=np.array(dist.energies)) + + else: + # Unknown law — store the raw XSS array so nothing is silently lost + h5_group.attrs["law"] = -1 + h5_group.attrs["type_name"] = type(data).__name__ + try: + h5_group.create_dataset("xss_array", data=np.array(data.xss_array)) + except Exception: + pass + + +def _write_kalbach_mann(km_data, h5_group): + """ + Write a KalbachMannDistributionData block into an open HDF5 group. + Uses MCDC-compatible format with flattened arrays and offset indices. + """ + h5_group.attrs["type"] = "kalbach-mann" + + NE = km_data.number_incident_energies + + # Incident energy grid + energy = np.array(km_data.incident_energies) + energy_ds = h5_group.create_dataset("energy", data=energy) + energy_ds.attrs["unit"] = "MeV" + + # Collect all outgoing energy points and build offset array + offset = np.zeros(NE, dtype=np.int32) + energy_out = [] + pdf = [] + precompound_factor = [] + angular_slope = [] + + for i in range(1, NE + 1): + dist = km_data.distribution(i) + offset[i - 1] = len(pdf) + energy_out.extend(get_km_field(dist, "outgoing_energies")) + pdf.extend(get_km_field(dist, "pdf")) + precompound_factor.extend(get_km_field(dist, "r")) + angular_slope.extend(get_km_field(dist, "a")) + + # Create flattened datasets + h5_group.create_dataset("offset", data=offset) + energy_out_ds = h5_group.create_dataset("energy_out", data=np.array(energy_out)) + energy_out_ds.attrs["unit"] = "MeV" + h5_group.create_dataset("pdf", data=np.array(pdf)) + h5_group.create_dataset("precompound_factor", data=np.array(precompound_factor)) + h5_group.create_dataset("angular_slope", data=np.array(angular_slope)) + + +# ────────────────────────────────────────────────────────────────────────────── +# Fission multiplicity loading +# ────────────────────────────────────────────────────────────────────────────── + +def load_fission_multiplicity(data, h5_group): + if isinstance(data, ACEtk.continuous.TabulatedFissionMultiplicity): + h5_group.attrs["type"] = "tabulated" + h5_group.create_dataset("energies", data=np.array(data.energies)) + h5_group.create_dataset("multiplicities", data=np.array(data.multiplicities)) + elif isinstance(data, ACEtk.continuous.PolynomialFissionMultiplicity): + h5_group.attrs["type"] = "polynomial" + h5_group.create_dataset("coefficients", data=np.array(data.coefficients)) + else: + h5_group.attrs["type"] = "unknown" + h5_group.attrs["type_name"] = type(data).__name__ + + +# ────────────────────────────────────────────────────────────────────────────── +# Secondary particle block extraction +# ────────────────────────────────────────────────────────────────────────────── + +def load_secondary_particles(ace_table, file, verbose=False): + """ + Extract all secondary particle production data from a proton ACE table + and write it into file['secondary_particles/ZAP_{zap}/MT-{MT:03}/...']. + """ + n_types = ace_table.number_secondary_particle_types + if n_types == 0: + return + + # ── Top-level block handles ─────────────────────────────────────────────── + # The secondary particle blocks are callable by type index — rx_block(i) + # returns the ReactionNumberBlock for type i, tyr_block(i) returns the + # FrameAndMultiplicityBlock for type i, etc. + type_block = ace_table.secondary_particle_type_block + info_block = ace_table.secondary_particle_information_block + rx_block = ace_table.secondary_particle_reaction_number_block + tyr_block = ace_table.secondary_particle_frame_and_multiplicity_block + xs_block = ace_table.secondary_particle_production_cross_section_block + edy_block = ace_table.secondary_particle_energy_distribution_block + + # angular block is optional for secondary particles in some libraries + try: + ang_block = ace_table.secondary_particle_angular_distribution_block + has_ang = True + except Exception: + has_ang = False + + sec_group = file.create_group("secondary_particles") + + # ── Introspect particle_identifier method name once ─────────────────────── + _pi_candidates = ["particle_identifier", "ZAP", "type", "particle_type"] + _pi_method = None + for cand in _pi_candidates: + if hasattr(type_block, cand): + _pi_method = cand + break + if _pi_method is None: + raise AttributeError( + f"Cannot find particle identifier method on " + f"{type(type_block).__name__}. " + f"Available: {[x for x in dir(type_block) if not x.startswith('_')]}" + ) + + # ── Loop over secondary particle types ──────────────────────────────────── + for i in range(1, n_types + 1): + + zap = getattr(type_block, _pi_method)(i) + name = ZAP_NAMES.get(zap, f"ZAP_{zap}") + + # number_reactions is a sequence property on info_block, 0-based + n_rx = int(info_block.number_reactions[i - 1]) + + if verbose: + print(f" Secondary particle type {i}: ZAP={zap} ({name}), " + f"{n_rx} reactions") + + zap_group = sec_group.create_group(f"ZAP_{zap}") + zap_group.attrs["ZAP"] = zap + zap_group.attrs["particle_name"] = name + + # Per-type sub-blocks: call the top-level block with the type index + # to get the per-type block, then call methods on that. + rx_i = rx_block(i) # ReactionNumberBlock for type i + tyr_i = tyr_block(i) # FrameAndMultiplicityBlock for type i + xs_i = xs_block(i) # production cross section block for type i + edy_i = edy_block(i) # energy distribution block for type i + ang_i = ang_block(i) if has_ang else None + + # Introspect xs sub-block method names (once, on first type) + _xs_candidates = [ + "production_xs", + "production_cross_sections", + "cross_sections", + "cross_section", + "cross_section_values", + "xs", + "xss", + ] + _off_candidates = ["energy_index", "offset", "locator", "index"] + _xs_method = next((c for c in _xs_candidates if hasattr(xs_i, c)), None) + _off_method = next((c for c in _off_candidates if hasattr(xs_i, c)), None) + + # Introspect energy distribution method name + _edy_candidates = ["energy_distribution_data", "distribution_data", "distribution"] + _edy_method = next((c for c in _edy_candidates if hasattr(edy_i, c)), None) + + for j in range(1, n_rx + 1): + + MT = rx_i.MT(j) + + # ── Multiplicity ───────────────────────────────────────────────── + nu_raw = tyr_i.multiplicity(j) + nu = nu_raw - 100 if nu_raw >= 100 else nu_raw + + # ── Reference frame ─────────────────────────────────────────────── + rf_raw = tyr_i.reference_frame(j) + if rf_raw == ACEtk.ReferenceFrame.Laboratory: + rf = "LAB" + elif rf_raw == ACEtk.ReferenceFrame.CentreOfMass: + rf = "COM" + else: + rf = str(rf_raw) + + mt_group = zap_group.create_group(f"MT-{MT:03}") + mt_group.attrs["MT"] = MT + mt_group.attrs["multiplicity"] = nu + mt_group.attrs["reference_frame"] = rf + + if verbose: + print(f" MT={MT:03} nu_raw={nu_raw} nu={nu} frame={rf}") + + # ── Production cross section ────────────────────────────────────── + if _xs_method and _off_method: + try: + xs_vals = np.array(getattr(xs_i, _xs_method)(j)) + xs_offset = int(getattr(xs_i, _off_method)(j)) + xs_ds = mt_group.create_dataset("production_xs", data=xs_vals) + xs_ds.attrs["offset"] = xs_offset - 1 # convert to 0-based + xs_ds.attrs["unit"] = "barns" + except Exception as exc: + xs_ds = mt_group.create_dataset("production_xs", data=np.zeros(0, dtype=float)) + xs_ds.attrs["offset"] = 0 + xs_ds.attrs["unit"] = "barns" + if verbose: + print(f" [warn] Could not read production xs: {exc}") + else: + xs_ds = mt_group.create_dataset("production_xs", data=np.zeros(0, dtype=float)) + xs_ds.attrs["offset"] = 0 + xs_ds.attrs["unit"] = "barns" + if verbose: + print(f" [warn] production xs block methods not resolved: " + f"{[x for x in dir(xs_i) if not x.startswith('_')]}") + + # ── Kalbach-Mann energy-angle distribution ──────────────────────── + if _edy_method: + try: + km_data = getattr(edy_i, _edy_method)(j) + km_group = mt_group.create_group("kalbach_mann") + _write_kalbach_mann(km_data, km_group) + except Exception as exc: + if verbose: + print(f" [warn] Could not read energy distribution: {exc}") + else: + if verbose: + print(f" [warn] energy distribution method not resolved: " + f"{[x for x in dir(edy_i) if not x.startswith('_')]}") + + # ── Angular distribution (if present) ──────────────────────────── + if ang_i is not None: + try: + ang_data = ang_i.angular_distribution_data(j) + ang_group = mt_group.create_group("angular_cosine_distribution") + load_cosine_distribution(ang_data, ang_group) + except Exception: + pass # not all secondary types have explicit angular data + + +# ────────────────────────────────────────────────────────────────────────────── +# Per-file processing +# ────────────────────────────────────────────────────────────────────────────── + +def process_ace_file(ace_path: str, output_dir: str, verbose: bool = False) -> str: + """ + Convert a single ACE proton file to HDF5. Returns the output filename. + """ + + # ── Header ──────────────────────────────────────────────────────────────── + with open(ace_path, "r") as f: + header = ACEtk.Header.from_string(f.readline()) + + Z, A, S, T = decode_ace_zaid(header.zaid) + symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") + nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" + + # Get temperature from the table itself (more reliable than ZAID suffix) + ace_table = ACEtk.ContinuousEnergyTable.from_file(ace_path) + T_kelvin = float(ace_table.temperature) if hasattr(ace_table, "temperature") else T + + # Forcing to be room temperature, as 0K from the file is a placeholder + T_kelvin = 293.6 + + mcdc_name = f"{nuclide_name}-{T_kelvin}K.h5" + out_path = os.path.join(output_dir, mcdc_name) + + if verbose: + print(f"\n{'='*80}") + print(f" {os.path.basename(ace_path)} → {mcdc_name}") + print(f" Z={Z} A={A} S={S} T={T_kelvin} K") + + file = h5py.File(out_path, "w") + + # ── Basic metadata ──────────────────────────────────────────────────────── + hdr = ace_table.header + file.attrs["source_title"] = hdr.title + file.attrs["source_version"] = hdr.version + file.attrs["source_date"] = hdr.date + if hasattr(hdr, "comments"): + file.attrs["source_comments"] = hdr.comments + + file.create_dataset("nuclide_name", data=nuclide_name) + file.create_dataset("excitation_level", data=S) + ds = file.create_dataset("temperature", data=T_kelvin) + ds.attrs["unit"] = "K" + file.create_dataset("atomic_number", data=ace_table.atom_number) + file.create_dataset("atomic_weight_ratio", data=ace_table.atomic_weight_ratio) + + fissionable = ace_table.fission_multiplicity_block is not None + file.create_dataset("fissionable", data=fissionable) + + # ── Reaction classification ─────────────────────────────────────────────── + proton_reactions = file.create_group("proton_reactions") + + nu_block = ace_table.frame_and_multiplicity_block + rx_block = ace_table.reaction_number_block + N_reaction = nu_block.number_reactions + + elastic_group = proton_reactions.create_group("elastic_scattering") + capture_group = proton_reactions.create_group("capture") + nonelastic_group = proton_reactions.create_group("nonelastic_reaction") + fission_group = proton_reactions.create_group("fission") + + elastic_MTs = [2] + capture_MTs = [] + nonelastic_MTs = [] + fission_MTs = [] + + fission_chance_MTs = [19, 20, 21, 38] + # Genuine redundant sum MTs — do not double-count these + redundant_MTs = [1, 3, 4, 10, 101, 103, 104, 105, 106, 107] + + total_fission_given = rx_block.has_MT(18) + if total_fission_given: + fission_MTs = [18] + else: + for MT in fission_chance_MTs: + if rx_block.has_MT(MT): + fission_MTs.append(MT) + + for i in range(N_reaction): + idx = i + 1 + MT = rx_block.MT(idx) + + if MT in redundant_MTs + elastic_MTs + fission_MTs: + continue + if MT > 891: # above the defined charged-particle range + continue + + nu_raw = nu_block.multiplicity(idx) + if not isinstance(nu_raw, int): + print_error(f"Non-integer multiplicity for MT-{MT:03} in {ace_path}") + + nu = nu_raw - 100 if nu_raw >= 100 else nu_raw + + if nu == 0: + capture_MTs.append(MT) + elif nu > 0: + nonelastic_MTs.append(MT) + else: + print_error(f"Negative decoded multiplicity for MT-{MT:03} in {ace_path}") + + # Create MT subgroups + for rx_group, rx_MTs in [ + (elastic_group, elastic_MTs), + (capture_group, capture_MTs), + (nonelastic_group, nonelastic_MTs), + (fission_group, fission_MTs), + ]: + for MT in rx_MTs: + g = rx_group.create_group(f"MT-{MT:03}") + g.attrs["MT"] = MT + + if verbose: + print(f" Elastic: {elastic_MTs}") + print(f" Capture: {capture_MTs}") + print(f" Nonelastic: {nonelastic_MTs}") + if fissionable: + print(f" Fission: {fission_MTs}") + + # Remove empty groups + if not fissionable: + del file["proton_reactions/fission"] + if len(nonelastic_MTs) == 0: + del file["proton_reactions/nonelastic_reaction"] + + # ── Cross sections ──────────────────────────────────────────────────────── + xs0_block = ace_table.principal_cross_section_block + xs_block_main = ace_table.cross_section_block + + xs_energy = np.array(xs0_block.energies) + ds = proton_reactions.create_dataset("xs_energy_grid", data=xs_energy) + ds.attrs["unit"] = "MeV" + + xs_ds = elastic_group.create_dataset("MT-002/xs", data=np.array(xs0_block.elastic)) + xs_ds.attrs["offset"] = 0 + xs_ds.attrs["unit"] = "barns" + + for MTs, group in [ + (capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if group is None: + continue + for MT in MTs: + idx = rx_block.index(MT) + xs_ds = group.create_dataset( + f"MT-{MT:03}/xs", + data=np.array(xs_block_main.cross_sections(idx)) + ) + xs_ds.attrs["offset"] = xs_block_main.energy_index(idx) - 1 + xs_ds.attrs["unit"] = "barns" + + # ── Q-values ────────────────────────────────────────────────────────────── + q_block = ace_table.reaction_qvalue_block + + elastic_group.create_dataset("MT-002/Q-value", data=0.0).attrs["unit"] = "MeV" + + for MTs, group in [ + (capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if group is None: + continue + for MT in MTs: + idx = rx_block.index(MT) + ds = group.create_dataset( + f"MT-{MT:03}/Q-value", data=q_block.q_value(idx) + ) + ds.attrs["unit"] = "MeV" + + # ── Reference frames ────────────────────────────────────────────────────── + elastic_group.create_dataset("MT-002/reference_frame", data="COM") + + for MTs, group in [ + (capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if group is None: + continue + for MT in MTs: + idx = rx_block.index(MT) + rf = nu_block.reference_frame(idx) + rf_str = ( + "LAB" if rf == ACEtk.ReferenceFrame.Laboratory else + "COM" if rf == ACEtk.ReferenceFrame.CentreOfMass else + str(rf) + ) + group.create_dataset(f"MT-{MT:03}/reference_frame", data=rf_str) + + # ── Nonelastic reaction multiplicities ───────────────────────────────────── + for MT in nonelastic_MTs: + idx = rx_block.index(MT) + nu_raw = nu_block.multiplicity(idx) + nu = nu_raw - 100 if nu_raw >= 100 else nu_raw + nonelastic_group.create_dataset(f"MT-{MT:03}/multiplicity", data=nu) + + # ── Angular distributions ───────────────────────────────────────────────── + angle_block = ace_table.angular_distribution_block + + ang_group = elastic_group.create_group("MT-002/angular_cosine_distribution") + ang_group.attrs["type"] = "energy-correlated" + data = angle_block.angular_distribution_data(0) + written = load_cosine_distribution(data, ang_group) + if not written and verbose: + print_note("MT-002 elastic angular distribution is given in energy block") + + for MTs, group in [ + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if group is None: + continue + for MT in MTs: + idx = rx_block.index(MT) + ang_group = group.create_group(f"MT-{MT:03}/angular_cosine_distribution") + data = angle_block.angular_distribution_data(idx) + written = load_cosine_distribution(data, ang_group) + if not written and verbose: + print_note(f"MT-{MT:03} angular distribution is given in energy block") + + # ── Primary energy distributions ────────────────────────────────────────── + energy_block = ace_table.energy_distribution_block + + for MTs, group in [ + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if group is None: + continue + for MT in MTs: + idx = rx_block.index(MT) + data = energy_block.energy_distribution_data(idx) + + if not isinstance(data, ACEtk.continuous.MultiDistributionData): + eg = group.create_group(f"MT-{MT:03}/energy_spectrum-1") + group.create_dataset( + f"MT-{MT:03}/spectrum_probability_grid", + data=np.array([0.0, 30.0]) + ).attrs["unit"] = "MeV" + group.create_dataset( + f"MT-{MT:03}/spectrum_probability", + data=np.array([[1.0]]) + ) + load_energy_distribution(data, eg) + else: + N_dist = data.number_distributions + # Probability grid + if all(np.array([x.number_interpolation_regions + for x in data.probabilities]) == 0): + prob_grid = np.array([0.0, 30.0]) + prob = np.zeros((1, N_dist)) + for k in range(N_dist): + prob[0, k] = max(data.probability(k + 1).probabilities) + elif (all(np.array([x.number_interpolation_regions + for x in data.probabilities]) == 1) + and all(np.array([x.interpolants + for x in data.probabilities]) == 1)): + prob_grid = np.array(data.probability(1).energies) + prob = np.zeros((len(prob_grid) - 1, N_dist)) + for k in range(N_dist): + prob[:, k] = np.array( + data.probability(k + 1).probabilities[:-1] + ) + else: + print_error(f"Unsupported multi-distribution probability for " + f"MT-{MT:03} in {ace_path}") + + group.create_dataset( + f"MT-{MT:03}/spectrum_probability_grid", data=prob_grid + ).attrs["unit"] = "MeV" + group.create_dataset( + f"MT-{MT:03}/spectrum_probability", data=prob + ) + for k in range(N_dist): + eg = group.create_group(f"MT-{MT:03}/energy_spectrum-{k+1}") + load_energy_distribution(data.distribution(k + 1), eg) + + # ── Secondary particles ─────────────────────────────────────────────────── + load_secondary_particles(ace_table, file, verbose=verbose) + + # ── Fission data (if applicable) ────────────────────────────────────────── + if fissionable: + prompt_block = ace_table.fission_multiplicity_block + delayed_block = ace_table.delayed_fission_multiplicity_block + dnp_block = ace_table.delayed_neutron_precursor_block + + h5g = fission_group.create_group("prompt_multiplicity") + load_fission_multiplicity(prompt_block.multiplicity, h5g) + + if delayed_block is not None: + h5g = fission_group.create_group("delayed_multiplicity") + load_fission_multiplicity(delayed_block.multiplicity, h5g) + + if dnp_block is not None: + N_DNP = dnp_block.number_delayed_precursors + fractions = np.zeros(N_DNP) + decay_rates = np.zeros(N_DNP) + for k in range(N_DNP): + d = dnp_block.precursor_group_data(k + 1) + if (d.number_interpolation_regions != 0 + or len(d.probabilities[:]) != 2 + or d.probabilities[0] != d.probabilities[1]): + print_error("Non-constant delayed neutron precursor fraction") + fractions[k] = d.probabilities[0] + decay_rates[k] = d.decay_constant + + prec = fission_group.create_group("delayed_neutron_precursors") + prec.create_dataset("fractions", data=fractions) + dr_ds = prec.create_dataset("decay_rates", data=decay_rates) + dr_ds.attrs["unit"] = "/s" + + delayed_spectrum_block = ace_table.delayed_neutron_energy_distribution_block + for k in range(N_DNP): + d = delayed_spectrum_block.energy_distribution_data(k + 1) + eg = prec.create_group(f"energy_spectrum-{k+1}") + load_energy_distribution(d, eg) + + file.close() + return mcdc_name + + +# ────────────────────────────────────────────────────────────────────────────── +# Main +# ────────────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Convert proton ACE files to MC/DC-compatible HDF5" + ) + parser.add_argument("--ace_dir", default=os.getenv("MCDC_ACELIB"), + help="Directory containing ACE files " + "(default: $MCDC_ACELIB)") + parser.add_argument("--output_dir", default=os.getenv("MCDC_LIB"), + help="Output directory for HDF5 files " + "(default: $MCDC_LIB)") + parser.add_argument("--rewrite", action="store_true", default=False, + help="Rewrite existing HDF5 files") + parser.add_argument("--verbose", action="store_true", default=False, + help="Print detailed per-reaction info") + args = parser.parse_args() + + if args.ace_dir is None: + print_error("No ACE directory specified. Use --ace_dir or set $MCDC_ACELIB.") + if args.output_dir is None: + print_error("No output directory specified. Use --output_dir or set $MCDC_LIB.") + + os.makedirs(args.output_dir, exist_ok=True) + print(f"\nACE directory : {args.ace_dir}") + print(f"Output directory: {args.output_dir}\n") + + all_files = sorted(os.listdir(args.ace_dir)) + + # Filter to only unprocessed files unless --rewrite + if args.rewrite: + target_files = all_files + else: + target_files = [] + for fname in all_files: + ace_path = os.path.join(args.ace_dir, fname) + try: + with open(ace_path, "r") as f: + hdr = ACEtk.Header.from_string(f.readline()) + Z, A, S, _ = decode_ace_zaid(hdr.zaid) + symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") + nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" + # We don't know T yet without loading the full table, so check + # for any existing file matching the nuclide name pattern. + existing = [ + f for f in os.listdir(args.output_dir) + if f.startswith(nuclide_name + "-") + ] + if not existing: + target_files.append(fname) + except Exception: + target_files.append(fname) # include if we can't read header + + errors = [] + pbar = tqdm( + target_files, + disable=args.verbose, + bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} {postfix}", + ) + + for ace_name in pbar: + ace_path = os.path.join(args.ace_dir, ace_name) + pbar.set_postfix_str(ace_name) + try: + out = process_ace_file(ace_path, args.output_dir, verbose=args.verbose) + if args.verbose: + print(f" → wrote {out}") + except Exception as exc: + errors.append((ace_name, str(exc))) + if args.verbose: + import traceback + traceback.print_exc() + + print(f"\nDone. {len(target_files) - len(errors)} succeeded, " + f"{len(errors)} failed.") + if errors: + print("\nFailed files:") + for name, msg in errors: + print(f" {name}: {msg}") + + +if __name__ == "__main__": + main() \ No newline at end of file From 420a6fb563ab20e51a871870e65634547e692997 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Mon, 11 May 2026 15:12:31 -0700 Subject: [PATCH 14/64] proton energy cutoff --- mcdc/constant.py | 5 +++-- mcdc/transport/physics/proton/native.py | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/mcdc/constant.py b/mcdc/constant.py index 5bfbc9d00..d737db611 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -136,12 +136,12 @@ DISTRIBUTION_TABULATED_ENERGY_ANGLE = 8 DISTRIBUTION_N_BODY = 9 -# Anguler distribution type +# Angular distribution type ANGLE_ISOTROPIC = 0 ANGLE_DISTRIBUTED = 1 ANGLE_ENERGY_CORRELATED = 2 -# Referance frame +# Reference frame REFERENCE_FRAME_LAB = 0 REFERENCE_FRAME_COM = 1 @@ -190,6 +190,7 @@ PROTON_MASS = 938.27208943e6 # eV/c^2 BOLTZMANN_K = 8.61733326e-5 # eV/K ELECTRON_CUTOFF_ENERGY = 100 # eV +PROTON_CUTOFF_ENERGY = 1000 # eV - this is dictated by the TENDL data; minimum of 1000 eV on the energy grid MU_CUTOFF = 0.999999 THERMAL_THRESHOLD_FACTOR = 400 diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index a0272fac8..4abf3feb1 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -29,6 +29,7 @@ PARTICLE_ELECTRON, PARTICLE_NEUTRON, PARTICLE_PROTON, + PROTON_CUTOFF_ENERGY, ) from mcdc.transport.data import evaluate_data from mcdc.transport.distribution import ( @@ -140,6 +141,13 @@ def collision(particle_container, collision_data_container, program, data): # Particle properties E = particle["E"] + # Check for cutoff energy + if E <= PROTON_CUTOFF_ENERGY: + collision_data["energy_deposition"] += E * particle["w"] + particle["alive"] = False + particle["E"] = 0.0 + return + # ================================================================================== # Sample colliding nuclide # ================================================================================== From f6d88dceaa58f773216f856c40aed8d3750c5b39 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Mon, 25 May 2026 17:30:18 -0700 Subject: [PATCH 15/64] CSDA support for proton transport --- mcdc/constant.py | 2 + mcdc/mcdc_get/nuclide.py | 58 +++++++++++++ mcdc/mcdc_set/nuclide.py | 58 +++++++++++++ mcdc/numba_types.py | 4 + mcdc/object_/nuclide.py | 13 +++ mcdc/transport/physics/__init__.py | 2 + mcdc/transport/physics/interface.py | 40 +++++++++ mcdc/transport/physics/proton/__init__.py | 2 +- mcdc/transport/physics/proton/interface.py | 7 +- mcdc/transport/physics/proton/native.py | 31 +++++++ mcdc/transport/simulation.py | 97 ++++++++++++++++++++++ 11 files changed, 312 insertions(+), 2 deletions(-) diff --git a/mcdc/constant.py b/mcdc/constant.py index d737db611..2191e1b32 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -89,6 +89,7 @@ # Miscellanies EVENT_TIME_CENSUS = 1 << 5 EVENT_TIME_BOUNDARY = 1 << 6 +EVENT_CSDA_EDEP = 1 << 7 # Materials MATERIAL = 0 @@ -176,6 +177,7 @@ PI_SQRT = math.sqrt(PI) PI_HALF = PI / 2.0 BANKMAX = 100 # Default maximum active bank +CSDA_MAX_FRACTIONAL_E_LOSS = 0.01 # Axes AXIS_X = 0 diff --git a/mcdc/mcdc_get/nuclide.py b/mcdc/mcdc_get/nuclide.py index c5237c645..d86a2a3bb 100644 --- a/mcdc/mcdc_get/nuclide.py +++ b/mcdc/mcdc_get/nuclide.py @@ -552,3 +552,61 @@ def neutron_fission_delayed_spectrum_IDs_chunk(start, length, nuclide, data): start += nuclide["neutron_fission_delayed_spectrum_IDs_offset"] end = start + length return data[start:end] + + +@njit +def stopping_power(index, nuclide, data): + offset = nuclide["stopping_power_offset"] + return data[offset + index] + + +@njit +def stopping_power_all(nuclide, data): + start = nuclide["stopping_power_offset"] + size = nuclide["stopping_power_length"] + end = start + size + return data[start:end] + + +@njit +def stopping_power_last(nuclide, data): + start = nuclide["stopping_power_offset"] + size = nuclide["stopping_power_length"] + end = start + size + return data[end - 1] + + +@njit +def stopping_power_chunk(start, length, nuclide, data): + start += nuclide["stopping_power_offset"] + end = start + length + return data[start:end] + + +@njit +def stopping_power_energy_grid(index, nuclide, data): + offset = nuclide["stopping_power_energy_grid_offset"] + return data[offset + index] + + +@njit +def stopping_power_energy_grid_all(nuclide, data): + start = nuclide["stopping_power_energy_grid_offset"] + size = nuclide["stopping_power_energy_grid_length"] + end = start + size + return data[start:end] + + +@njit +def stopping_power_energy_grid_last(nuclide, data): + start = nuclide["stopping_power_energy_grid_offset"] + size = nuclide["stopping_power_energy_grid_length"] + end = start + size + return data[end - 1] + + +@njit +def stopping_power_energy_grid_chunk(start, length, nuclide, data): + start += nuclide["stopping_power_energy_grid_offset"] + end = start + length + return data[start:end] diff --git a/mcdc/mcdc_set/nuclide.py b/mcdc/mcdc_set/nuclide.py index 18536bcf2..f8d6e7b62 100644 --- a/mcdc/mcdc_set/nuclide.py +++ b/mcdc/mcdc_set/nuclide.py @@ -552,3 +552,61 @@ def neutron_fission_delayed_spectrum_IDs_chunk(start, length, nuclide, data, val start += nuclide["neutron_fission_delayed_spectrum_IDs_offset"] end = start + length data[start:end] = value + + +@njit +def stopping_power(index, nuclide, data, value): + offset = nuclide["stopping_power_offset"] + data[offset + index] = value + + +@njit +def stopping_power_all(nuclide, data, value): + start = nuclide["stopping_power_offset"] + size = nuclide["stopping_power_length"] + end = start + size + data[start:end] = value + + +@njit +def stopping_power_last(nuclide, data, value): + start = nuclide["stopping_power_offset"] + size = nuclide["stopping_power_length"] + end = start + size + data[end - 1] = value + + +@njit +def stopping_power_chunk(start, length, nuclide, data, value): + start += nuclide["stopping_power_offset"] + end = start + length + data[start:end] = value + + +@njit +def stopping_power_energy_grid(index, nuclide, data, value): + offset = nuclide["stopping_power_energy_grid_offset"] + data[offset + index] = value + + +@njit +def stopping_power_energy_grid_all(nuclide, data, value): + start = nuclide["stopping_power_energy_grid_offset"] + size = nuclide["stopping_power_energy_grid_length"] + end = start + size + data[start:end] = value + + +@njit +def stopping_power_energy_grid_last(nuclide, data, value): + start = nuclide["stopping_power_energy_grid_offset"] + size = nuclide["stopping_power_energy_grid_length"] + end = start + size + data[end - 1] = value + + +@njit +def stopping_power_energy_grid_chunk(start, length, nuclide, data, value): + start += nuclide["stopping_power_energy_grid_offset"] + end = start + length + data[start:end] = value diff --git a/mcdc/numba_types.py b/mcdc/numba_types.py index 5c0dca6f8..90b373798 100644 --- a/mcdc/numba_types.py +++ b/mcdc/numba_types.py @@ -443,6 +443,10 @@ ('neutron_fission_delayed_decay_rates_length', int64), ('N_neutron_fission_delayed_spectrum', int64), ('neutron_fission_delayed_spectrum_IDs_offset', int64), + ('stopping_power_offset', int64), + ('stopping_power_length', int64), + ('stopping_power_energy_grid_offset', int64), + ('stopping_power_energy_grid_length', int64), ('ID', int64), ]) diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index 534753bb2..1f1c9e0d9 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -48,6 +48,7 @@ class Nuclide(ObjectNonSingleton): neutron_capture_xs: NDArray[float64] neutron_inelastic_xs: NDArray[float64] neutron_fission_xs: NDArray[float64] + # proton_xs_energy_grid: NDArray[float64] proton_total_xs: NDArray[float64] proton_elastic_xs: NDArray[float64] @@ -57,6 +58,7 @@ class Nuclide(ObjectNonSingleton): neutron_capture_reactions: list[NeutronReactionCapture] neutron_inelastic_scattering_reactions: list[NeutronReactionInelasticScattering] neutron_fission_reactions: list[NeutronReactionFission] + # proton_elastic_scattering_reactions: list[ProtonReactionElasticScattering] proton_nonelastic_reactions: list[ProtonReactionNonelasticReaction] proton_secondary_channels: dict[int, list[ProtonSecondaryChannel]] @@ -68,6 +70,9 @@ class Nuclide(ObjectNonSingleton): neutron_fission_delayed_fractions: NDArray[float64] neutron_fission_delayed_decay_rates: NDArray[float64] neutron_fission_delayed_spectra: list[DistributionBase] + # + stopping_power: NDArray[float64] + stopping_power_energy_grid: NDArray[float64] def __init__(self, nuclide_name, temperature): super().__init__() @@ -329,6 +334,14 @@ def set_proton_data(self): reaction = rx_class.from_h5_group(h5_group) rx_container.append(reaction) + + # ========================================================================== + # Stopping power for protons + # ========================================================================== + self.stopping_power = file["stopping_power"]["total_stopping_power"][()] + self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] + + # # ========================================================================== # # Secondary particles # # ========================================================================== diff --git a/mcdc/transport/physics/__init__.py b/mcdc/transport/physics/__init__.py index 72a579f30..c0e782318 100644 --- a/mcdc/transport/physics/__init__.py +++ b/mcdc/transport/physics/__init__.py @@ -4,6 +4,8 @@ neutron_production_xs, collision_distance, collision, + csda_distance, + csda_edep, ) import mcdc.transport.physics.electron as electron import mcdc.transport.physics.neutron as neutron diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index 9d5ed9f02..03c159edd 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -1,4 +1,5 @@ import math +import numpy as np from numba import njit @@ -9,6 +10,8 @@ import mcdc.transport.physics.neutron as neutron import mcdc.transport.physics.proton as proton +import mcdc.mcdc_get as mcdc_get + from mcdc.constant import * # ====================================================================================== @@ -55,6 +58,32 @@ def neutron_production_xs(reaction_type, particle_container, simulation, data): return -1.0 +@njit +def csda_distance(particle_container, simulation, data): + particle = particle_container[0] + material = simulation["native_materials"][particle["material_ID"]] + E = particle["E"] + total_rho = 0.0 + total_dedx = 0.0 + + for i in range(material["N_nuclide"]): + nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + nuclide = simulation["nuclides"][nuclide_ID] + dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) + dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) + dedx = np.interp(E/1e6, dedx_energies, dedx_values) + total_dedx += dedx*1e6 + + atomic_mass = nuclide["atomic_weight_ratio"] + nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) + total_rho += density_gcm3 + + print(f'dedx at energy {E} is {total_dedx}, max energy deposited is {E * CSDA_MAX_FRACTIONAL_E_LOSS}') + + return CSDA_MAX_FRACTIONAL_E_LOSS * E / total_dedx / total_rho + + # ====================================================================================== # Collision # ====================================================================================== @@ -93,3 +122,14 @@ def collision(particle_container, collision_data_container, program, data): electron.collision(particle_container, collision_data_container, program, data) elif particle["particle_type"] == PARTICLE_PROTON: proton.collision(particle_container, collision_data_container, program, data) + + +@njit +def csda_edep(particle_container, collision_data_container, program, data): + particle = particle_container[0] + if particle["particle_type"] == PARTICLE_NEUTRON: + raise ValueError("CSDA not supported for neutrons") + if particle["particle_type"] == PARTICLE_ELECTRON: + raise ValueError("CSDA not supported for electrons") + if particle["particle_type"] == PARTICLE_PROTON: + proton.csda_edep(particle_container, collision_data_container, program, data) \ No newline at end of file diff --git a/mcdc/transport/physics/proton/__init__.py b/mcdc/transport/physics/proton/__init__.py index d95186ada..6fed2a1e0 100644 --- a/mcdc/transport/physics/proton/__init__.py +++ b/mcdc/transport/physics/proton/__init__.py @@ -1,8 +1,8 @@ from .interface import ( particle_speed, macro_xs, - # proton_production_xs, collision, + csda_edep, ) import mcdc.transport.physics.proton.native as native import mcdc.transport.physics.proton.multigroup as multigroup diff --git a/mcdc/transport/physics/proton/interface.py b/mcdc/transport/physics/proton/interface.py index eda6c9013..e1f689095 100644 --- a/mcdc/transport/physics/proton/interface.py +++ b/mcdc/transport/physics/proton/interface.py @@ -32,4 +32,9 @@ def macro_xs(reaction_type, particle_container, simulation, data): @njit def collision(particle_container, collision_data_container, program, data): simulation = util.access_simulation(program) - native.collision(particle_container, collision_data_container, program, data) \ No newline at end of file + native.collision(particle_container, collision_data_container, program, data) + + +@njit +def csda_edep(particle_container, collision_data_container, program, data): + native.csda_edep(particle_container, collision_data_container, program, data) \ No newline at end of file diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 4abf3feb1..c52e7d25f 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -30,6 +30,7 @@ PARTICLE_NEUTRON, PARTICLE_PROTON, PROTON_CUTOFF_ENERGY, + CSDA_MAX_FRACTIONAL_E_LOSS, ) from mcdc.transport.data import evaluate_data from mcdc.transport.distribution import ( @@ -241,6 +242,31 @@ def collision(particle_container, collision_data_container, program, data): return +@njit +def csda_edep(particle_container, collision_data_container, program, data): + simulation = util.access_simulation(program) + particle = particle_container[0] + collision_data = collision_data_container[0] + material = simulation["native_materials"][particle["material_ID"]] + + # Particle properties + E = particle["E"] + + # Check for cutoff energy + if E <= PROTON_CUTOFF_ENERGY: + collision_data["energy_deposition"] += E * particle["w"] + particle["alive"] = False + particle["E"] = 0.0 + return + + # if particle makes it to this function, it will be losing CSDA_MAX_FRACTIONAL_E_LOSS of its energy + collision_data["energy_deposition"] += E * CSDA_MAX_FRACTIONAL_E_LOSS + particle["E"] -= E * CSDA_MAX_FRACTIONAL_E_LOSS + + return + + + # ====================================================================================== # Elastic scattering # ====================================================================================== @@ -261,6 +287,8 @@ def elastic_scattering( # Energy deposition collision_data["energy_deposition"] += E * particle["w"] + #print(f'\ndeposited {E * particle["w"]} eV at x={particle["x"]} from elastic scattering') + # Note: Q-value is zero in elastic scattering # Sample nucleus thermal velocity @@ -343,6 +371,7 @@ def elastic_scattering( collision_data["energy_deposition"] -= particle["E"] * particle["w"] + @njit def sample_nucleus_velocity(A, particle_container): particle = particle_container[0] @@ -444,6 +473,8 @@ def nonelastic_reaction( # Energy deposition (will be adjusted as we create secondaries) collision_data["energy_deposition"] += total_energy * w + #print(f'\ndeposited {total_energy * particle["w"]} eV at x={particle["x"]} from nonelastic_rxn') + # Create outgoing protons for n in range(N_proton): diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index 6f2e3f06f..e329e52b8 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -346,6 +346,49 @@ def step_particle(particle_container, program, data): if particle["event"] & EVENT_TIME_BOUNDARY: particle["alive"] = False + # CSDA energy depostiion + if particle["event"] & EVENT_CSDA_EDEP: + collision_data_container = np.zeros(1, type_.collision_data) + physics.csda_edep(particle_container, collision_data_container, simulation, data) + + # Score collision tallies + if simulation["cycle_active"]: + # Cell tallies + cell = simulation["cells"][particle["cell_ID"]] + for i in range(cell["N_tally"]): + tally_base_ID = int(mcdc_get.cell.tally_IDs(i, cell, data)) + tally_base = simulation["tallies"][tally_base_ID] + + # Skip non-collision tallies + if tally_base["child_type"] != TALLY_COLLISION: + continue + + tally = simulation["collision_tallies"][tally_base["child_ID"]] + tally_module.score.collision_tally( + particle_container, + collision_data_container, + tally, + simulation, + data, + ) + + # Other collision tallies + for i in range(simulation["N_collision_tally"]): + tally = simulation["collision_tallies"][i] + + # Skip cell tallies + if tally["spatial_filter_type"] == SPATIAL_FILTER_CELL: + continue + + tally_module.score.collision_tally( + particle_container, + collision_data_container, + tally, + simulation, + data, + ) + + # Weight roulette if particle["alive"]: technique.weight_roulette(particle_container, simulation) @@ -403,6 +446,9 @@ def move_to_event(particle_container, simulation, data): # Distance to next collision d_collision = physics.collision_distance(particle_container, simulation, data) + # Distance to max energy loss as dictated by CSDA + d_csda = physics.csda_distance(particle_container, simulation, data) + # ================================================================================== # Determine event(s) # ================================================================================== @@ -432,6 +478,15 @@ def move_to_event(particle_container, simulation, data): particle["event"] = EVENT_TIME_BOUNDARY particle["surface_ID"] = -1 + # Check distance to max energy loss from CSDA + if d_csda < distance - COINCIDENCE_TOLERANCE: + distance = d_csda + particle["event"] = EVENT_CSDA_EDEP + particle["surface_ID"] = -1 + elif geometry.check_coincidence(d_csda, distance): + particle["event"] += EVENT_CSDA_EDEP + + # ================================================================================== # Move particle # ================================================================================== @@ -470,5 +525,47 @@ def move_to_event(particle_container, simulation, data): particle_container, distance, simulation, data ) + # # CSDA for protons + # if particle["particle_type"] == PARTICLE_PROTON: + # total_rho_gcm3 = 0.0 + # total_stopping_power = 0.0 + # material = simulation["native_materials"][particle["material_ID"]] + # E = particle["E"] + + # for i in range(material["N_nuclide"]): + # nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + # nuclide = simulation["nuclides"][nuclide_ID] + # dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) + # dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) + # dedx = np.interp(E/1e6, dedx_energies, dedx_values) + # total_stopping_power += dedx + + # # print(f'dedx_energies = {dedx_energies}') + # # print(f'dedx_values = {dedx_values}') + + # # Convert atoms/barn-cm to g/cm³: + # atomic_mass = nuclide["atomic_weight_ratio"] # mass in amu + # nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + # density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) + # total_rho_gcm3 += density_gcm3 + + + # print(f'distance = {distance}, dE/dx = {total_stopping_power}, E = {particle["E"]}') + # energy_loss = total_stopping_power * distance * total_rho_gcm3 * 1e6 + # collision_data = collision_data_container[0] + + # particle["E"] -= energy_loss + # collision_data["energy_deposition"] += energy_loss * particle["w"] + # print(f'\ndeposited {energy_loss * particle["w"]} eV at x={particle["x"]} from CSDA') + + # if E <= PROTON_CUTOFF_ENERGY: + # collision_data["energy_deposition"] += particle["E"] + # particle["alive"] = False + # particle["E"] = 0.0 + # return + + # # # Move particle through CSDA energy deposition + # # particle_module.csda_move(particle_container, distance, simulation, data) + # Move particle particle_module.move(particle_container, distance, simulation, data) From eee719ad5c29b65f53cf6da286f9cdbf303d9d70 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 27 May 2026 14:14:08 -0700 Subject: [PATCH 16/64] refactored the CSDA functions to deposit energy every time the particle moves --- mcdc/transport/physics/interface.py | 6 +- mcdc/transport/physics/proton/interface.py | 4 +- mcdc/transport/physics/proton/native.py | 31 ++++-- mcdc/transport/simulation.py | 116 ++++++++------------- 4 files changed, 69 insertions(+), 88 deletions(-) diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index 03c159edd..9a8da821f 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -79,7 +79,7 @@ def csda_distance(particle_container, simulation, data): density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) total_rho += density_gcm3 - print(f'dedx at energy {E} is {total_dedx}, max energy deposited is {E * CSDA_MAX_FRACTIONAL_E_LOSS}') + # print(f'dedx at energy {E} is {total_dedx}, max energy deposited is {E * CSDA_MAX_FRACTIONAL_E_LOSS}') return CSDA_MAX_FRACTIONAL_E_LOSS * E / total_dedx / total_rho @@ -125,11 +125,11 @@ def collision(particle_container, collision_data_container, program, data): @njit -def csda_edep(particle_container, collision_data_container, program, data): +def csda_edep(particle_container, collision_data_container, distance, simulation, data): particle = particle_container[0] if particle["particle_type"] == PARTICLE_NEUTRON: raise ValueError("CSDA not supported for neutrons") if particle["particle_type"] == PARTICLE_ELECTRON: raise ValueError("CSDA not supported for electrons") if particle["particle_type"] == PARTICLE_PROTON: - proton.csda_edep(particle_container, collision_data_container, program, data) \ No newline at end of file + proton.csda_edep(particle_container, collision_data_container, distance, simulation, data) \ No newline at end of file diff --git a/mcdc/transport/physics/proton/interface.py b/mcdc/transport/physics/proton/interface.py index e1f689095..f98da1913 100644 --- a/mcdc/transport/physics/proton/interface.py +++ b/mcdc/transport/physics/proton/interface.py @@ -36,5 +36,5 @@ def collision(particle_container, collision_data_container, program, data): @njit -def csda_edep(particle_container, collision_data_container, program, data): - native.csda_edep(particle_container, collision_data_container, program, data) \ No newline at end of file +def csda_edep(particle_container, collision_data_container, distance, simulation, data): + native.csda_edep(particle_container, collision_data_container, distance, simulation, data) \ No newline at end of file diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index c52e7d25f..85e84573e 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -1,5 +1,5 @@ import math - +import numpy as np from numba import njit #### @@ -243,13 +243,10 @@ def collision(particle_container, collision_data_container, program, data): @njit -def csda_edep(particle_container, collision_data_container, program, data): - simulation = util.access_simulation(program) +def csda_edep(particle_container, collision_data_container, distance, simulation, data): particle = particle_container[0] collision_data = collision_data_container[0] material = simulation["native_materials"][particle["material_ID"]] - - # Particle properties E = particle["E"] # Check for cutoff energy @@ -259,10 +256,28 @@ def csda_edep(particle_container, collision_data_container, program, data): particle["E"] = 0.0 return - # if particle makes it to this function, it will be losing CSDA_MAX_FRACTIONAL_E_LOSS of its energy - collision_data["energy_deposition"] += E * CSDA_MAX_FRACTIONAL_E_LOSS - particle["E"] -= E * CSDA_MAX_FRACTIONAL_E_LOSS + total_stopping_power = 0.0 + total_rho_gcm3 = 0.0 + # Find the total stopping power by summing over every nuclide in the material + for i in range(material["N_nuclide"]): + nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + nuclide = simulation["nuclides"][nuclide_ID] + dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) + dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) + + # TODO: replace np.interp with a non-numpy function?? + dedx = np.interp(E/1e6, dedx_energies, dedx_values) + total_stopping_power += dedx + + # Convert atoms/barn-cm to g/cm³: + atomic_mass = nuclide["atomic_weight_ratio"] # mass in amu + nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) + total_rho_gcm3 += density_gcm3 + energy_loss = total_stopping_power * distance * total_rho_gcm3 * 1e6 + particle["E"] -= energy_loss * particle["w"] + collision_data["energy_deposition"] += energy_loss * particle["w"] return diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index e329e52b8..56dc1f6d5 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -346,47 +346,8 @@ def step_particle(particle_container, program, data): if particle["event"] & EVENT_TIME_BOUNDARY: particle["alive"] = False - # CSDA energy depostiion if particle["event"] & EVENT_CSDA_EDEP: - collision_data_container = np.zeros(1, type_.collision_data) - physics.csda_edep(particle_container, collision_data_container, simulation, data) - - # Score collision tallies - if simulation["cycle_active"]: - # Cell tallies - cell = simulation["cells"][particle["cell_ID"]] - for i in range(cell["N_tally"]): - tally_base_ID = int(mcdc_get.cell.tally_IDs(i, cell, data)) - tally_base = simulation["tallies"][tally_base_ID] - - # Skip non-collision tallies - if tally_base["child_type"] != TALLY_COLLISION: - continue - - tally = simulation["collision_tallies"][tally_base["child_ID"]] - tally_module.score.collision_tally( - particle_container, - collision_data_container, - tally, - simulation, - data, - ) - - # Other collision tallies - for i in range(simulation["N_collision_tally"]): - tally = simulation["collision_tallies"][i] - - # Skip cell tallies - if tally["spatial_filter_type"] == SPATIAL_FILTER_CELL: - continue - - tally_module.score.collision_tally( - particle_container, - collision_data_container, - tally, - simulation, - data, - ) + pass # Weight roulette @@ -525,47 +486,52 @@ def move_to_event(particle_container, simulation, data): particle_container, distance, simulation, data ) - # # CSDA for protons - # if particle["particle_type"] == PARTICLE_PROTON: - # total_rho_gcm3 = 0.0 - # total_stopping_power = 0.0 - # material = simulation["native_materials"][particle["material_ID"]] - # E = particle["E"] - # for i in range(material["N_nuclide"]): - # nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) - # nuclide = simulation["nuclides"][nuclide_ID] - # dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) - # dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) - # dedx = np.interp(E/1e6, dedx_energies, dedx_values) - # total_stopping_power += dedx + # Move particle + particle_module.move(particle_container, distance, simulation, data) - # # print(f'dedx_energies = {dedx_energies}') - # # print(f'dedx_values = {dedx_values}') - # # Convert atoms/barn-cm to g/cm³: - # atomic_mass = nuclide["atomic_weight_ratio"] # mass in amu - # nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) - # density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) - # total_rho_gcm3 += density_gcm3 + # CSDA calculates energy loss after particle has moved + if True: + # TODO: implement the CSDA setting + # if settings["CSDA"]: + collision_data_container = np.zeros(1, type_.collision_data) + physics.csda_edep(particle_container, collision_data_container, distance, simulation, data) + # Score collision tallies (edep is a collision tally) + # TODO: maybe make edep a potential tracklength tally for CSDA? + if simulation["cycle_active"]: + # Cell tallies + cell = simulation["cells"][particle["cell_ID"]] + for i in range(cell["N_tally"]): + tally_base_ID = int(mcdc_get.cell.tally_IDs(i, cell, data)) + tally_base = simulation["tallies"][tally_base_ID] - # print(f'distance = {distance}, dE/dx = {total_stopping_power}, E = {particle["E"]}') - # energy_loss = total_stopping_power * distance * total_rho_gcm3 * 1e6 - # collision_data = collision_data_container[0] + # Skip non-collision tallies + if tally_base["child_type"] != TALLY_COLLISION: + continue - # particle["E"] -= energy_loss - # collision_data["energy_deposition"] += energy_loss * particle["w"] - # print(f'\ndeposited {energy_loss * particle["w"]} eV at x={particle["x"]} from CSDA') + tally = simulation["collision_tallies"][tally_base["child_ID"]] + tally_module.score.collision_tally( + particle_container, + collision_data_container, + tally, + simulation, + data, + ) - # if E <= PROTON_CUTOFF_ENERGY: - # collision_data["energy_deposition"] += particle["E"] - # particle["alive"] = False - # particle["E"] = 0.0 - # return + # Other collision tallies + for i in range(simulation["N_collision_tally"]): + tally = simulation["collision_tallies"][i] - # # # Move particle through CSDA energy deposition - # # particle_module.csda_move(particle_container, distance, simulation, data) + # Skip cell tallies + if tally["spatial_filter_type"] == SPATIAL_FILTER_CELL: + continue - # Move particle - particle_module.move(particle_container, distance, simulation, data) + tally_module.score.collision_tally( + particle_container, + collision_data_container, + tally, + simulation, + data, + ) \ No newline at end of file From 2f5e487335af4868088af02bd908e74e4cac8fd0 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 27 May 2026 14:54:39 -0700 Subject: [PATCH 17/64] added CSDA setting to input deck --- mcdc/numba_types.py | 1 + mcdc/object_/settings.py | 1 + mcdc/transport/simulation.py | 6 +----- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/mcdc/numba_types.py b/mcdc/numba_types.py index 90b373798..b2f4c270a 100644 --- a/mcdc/numba_types.py +++ b/mcdc/numba_types.py @@ -591,6 +591,7 @@ ('time_boundary', float64), ('output_name', 'U32'), ('use_progress_bar', bool), + ('csda', bool), ('N_census', int64), ('census_time_offset', int64), ('census_time_length', int64), diff --git a/mcdc/object_/settings.py b/mcdc/object_/settings.py index 60a28d896..953c271b1 100644 --- a/mcdc/object_/settings.py +++ b/mcdc/object_/settings.py @@ -44,6 +44,7 @@ class Settings(ObjectSingleton): time_boundary: float = np.inf output_name: str = "output" use_progress_bar: bool = True + csda: bool = True # Time census N_census: int = 1 diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index 56dc1f6d5..1f73d016f 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -486,15 +486,11 @@ def move_to_event(particle_container, simulation, data): particle_container, distance, simulation, data ) - # Move particle particle_module.move(particle_container, distance, simulation, data) - # CSDA calculates energy loss after particle has moved - if True: - # TODO: implement the CSDA setting - # if settings["CSDA"]: + if settings["csda"]: collision_data_container = np.zeros(1, type_.collision_data) physics.csda_edep(particle_container, collision_data_container, distance, simulation, data) From 970288957c34b596d7f7452fe0e66faa3d8eb9f9 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 28 May 2026 15:37:34 -0700 Subject: [PATCH 18/64] added a setting to change csda max fractional energy loss in the input deck --- mcdc/constant.py | 2 +- mcdc/numba_types.py | 1 + mcdc/object_/settings.py | 1 + mcdc/transport/physics/interface.py | 5 ++--- mcdc/transport/physics/proton/native.py | 1 - mcdc/transport/simulation.py | 1 - 6 files changed, 5 insertions(+), 6 deletions(-) diff --git a/mcdc/constant.py b/mcdc/constant.py index 2191e1b32..3e9d44e85 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -177,7 +177,7 @@ PI_SQRT = math.sqrt(PI) PI_HALF = PI / 2.0 BANKMAX = 100 # Default maximum active bank -CSDA_MAX_FRACTIONAL_E_LOSS = 0.01 +# CSDA_MAX_FRACTIONAL_E_LOSS = 0.01 # Axes AXIS_X = 0 diff --git a/mcdc/numba_types.py b/mcdc/numba_types.py index b2f4c270a..92efbc393 100644 --- a/mcdc/numba_types.py +++ b/mcdc/numba_types.py @@ -592,6 +592,7 @@ ('output_name', 'U32'), ('use_progress_bar', bool), ('csda', bool), + ('csda_max_fractional_e_loss', float64), ('N_census', int64), ('census_time_offset', int64), ('census_time_length', int64), diff --git a/mcdc/object_/settings.py b/mcdc/object_/settings.py index 953c271b1..60e13a348 100644 --- a/mcdc/object_/settings.py +++ b/mcdc/object_/settings.py @@ -45,6 +45,7 @@ class Settings(ObjectSingleton): output_name: str = "output" use_progress_bar: bool = True csda: bool = True + csda_max_fractional_e_loss: float = 0.01 # Time census N_census: int = 1 diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index 9a8da821f..e71ac8cd6 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -79,9 +79,8 @@ def csda_distance(particle_container, simulation, data): density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) total_rho += density_gcm3 - # print(f'dedx at energy {E} is {total_dedx}, max energy deposited is {E * CSDA_MAX_FRACTIONAL_E_LOSS}') - - return CSDA_MAX_FRACTIONAL_E_LOSS * E / total_dedx / total_rho + max_fractional_e_loss = simulation["settings"]["csda_max_fractional_e_loss"] + return max_fractional_e_loss * E / total_dedx / total_rho # ====================================================================================== diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 85e84573e..59bbd70f1 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -30,7 +30,6 @@ PARTICLE_NEUTRON, PARTICLE_PROTON, PROTON_CUTOFF_ENERGY, - CSDA_MAX_FRACTIONAL_E_LOSS, ) from mcdc.transport.data import evaluate_data from mcdc.transport.distribution import ( diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index 1f73d016f..956a6daeb 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -349,7 +349,6 @@ def step_particle(particle_container, program, data): if particle["event"] & EVENT_CSDA_EDEP: pass - # Weight roulette if particle["alive"]: technique.weight_roulette(particle_container, simulation) From 45427f720fb54195819a46df5a3204d83dc4fba7 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 3 Jun 2026 13:21:34 -0700 Subject: [PATCH 19/64] reformatting with black --- mcdc/constant.py | 3 +- mcdc/object_/nuclide.py | 19 +++----- mcdc/object_/proton_reaction.py | 30 +++++++------ mcdc/transport/physics/interface.py | 8 ++-- mcdc/transport/physics/proton/interface.py | 6 ++- mcdc/transport/physics/proton/multigroup.py | 2 +- mcdc/transport/physics/proton/native.py | 49 ++++++++------------- mcdc/transport/simulation.py | 7 +-- 8 files changed, 58 insertions(+), 66 deletions(-) diff --git a/mcdc/constant.py b/mcdc/constant.py index 3e9d44e85..8185c2995 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -177,7 +177,6 @@ PI_SQRT = math.sqrt(PI) PI_HALF = PI / 2.0 BANKMAX = 100 # Default maximum active bank -# CSDA_MAX_FRACTIONAL_E_LOSS = 0.01 # Axes AXIS_X = 0 @@ -189,7 +188,7 @@ LIGHT_SPEED = 2.99792458e10 # cm/s NEUTRON_MASS = 939.565413e6 # eV/c^2 ELECTRON_MASS = 510.99895069e3 # eV/c^2 -PROTON_MASS = 938.27208943e6 # eV/c^2 +PROTON_MASS = 938.27208943e6 # eV/c^2 BOLTZMANN_K = 8.61733326e-5 # eV/K ELECTRON_CUTOFF_ENERGY = 100 # eV PROTON_CUTOFF_ENERGY = 1000 # eV - this is dictated by the TENDL data; minimum of 1000 eV on the energy grid diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index 1f1c9e0d9..c1c268813 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -17,7 +17,7 @@ NeutronReactionInelasticScattering, set_energy_distribution, ) -from mcdc.object_.proton_reaction import( +from mcdc.object_.proton_reaction import ( ProtonReactionElasticScattering, ProtonReactionNonelasticReaction, ProtonSecondaryChannel, @@ -305,11 +305,7 @@ def set_proton_data(self): xs = file[f"proton_reactions/{rx_name}/{MT}/xs"] xs_container[xs.attrs["offset"] :] += xs[()] - self.proton_total_xs = ( - self.proton_elastic_xs - + self.proton_nonelastic_xs - ) - + self.proton_total_xs = self.proton_elastic_xs + self.proton_nonelastic_xs # ========================================================================== # The reactions @@ -334,18 +330,16 @@ def set_proton_data(self): reaction = rx_class.from_h5_group(h5_group) rx_container.append(reaction) - # ========================================================================== # Stopping power for protons # ========================================================================== self.stopping_power = file["stopping_power"]["total_stopping_power"][()] self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] - # # ========================================================================== # # Secondary particles # # ========================================================================== - + # self.proton_secondary_channels = {} # if "secondary_particles" in file: # sec_group = file["secondary_particles"] @@ -354,24 +348,23 @@ def set_proton_data(self): # continue # zap = int(zap_name.split("_")[1]) # zap_group = sec_group[zap_name] - + # # Iterate over MT numbers for this secondary particle type # for mt_name in zap_group.keys(): # if not mt_name.startswith("MT-"): # continue # MT = int(mt_name.split("-")[1]) # mt_group = zap_group[mt_name] - + # # Load secondary channel # channel = ProtonSecondaryChannel.from_h5_group(mt_group, zap) - + # if MT not in self.proton_secondary_channels: # self.proton_secondary_channels[MT] = [] # self.proton_secondary_channels[MT].append(channel) file.close() - ## TODO: UPDATE this to handle protons as well as neutrons def __repr__(self): text = "\n" diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py index db05e9344..b1f132bd5 100644 --- a/mcdc/object_/proton_reaction.py +++ b/mcdc/object_/proton_reaction.py @@ -123,6 +123,7 @@ def __repr__(self): # Proton nonelastic reaction # ====================================================================================== + class ProtonReactionNonelasticReaction(ProtonReactionBase): # Annotations for Numba mode label: str = "proton_nonelastic_reaction" @@ -249,7 +250,7 @@ def set_angular_distribution(h5_group): mu = simulation.distributions[0] elif mu_type == "tabulated": angle_type = ANGLE_DISTRIBUTED - + # Check if data is in flattened format or subgroup format if "energy" in h5_group: # Flattened format @@ -260,12 +261,12 @@ def set_angular_distribution(h5_group): else: # Subgroup format: E_in_1, E_in_2, etc. incident_energies = h5_group["incident_energies"][()] * 1e6 # MeV to eV - + # Collect all cosines and pdfs into flattened arrays cosines_list = [] pdf_list = [] offset = np.zeros(len(incident_energies), dtype=np.int32) - + for i, energy in enumerate(incident_energies): subgroup_name = f"E_in_{i + 1}" if subgroup_name in h5_group: @@ -276,19 +277,19 @@ def set_angular_distribution(h5_group): else: # Isotropic - use dummy values cosines_list.extend([0.0]) # isotropic cosine - pdf_list.extend([1.0]) # uniform pdf + pdf_list.extend([1.0]) # uniform pdf else: # Missing subgroup - assume isotropic cosines_list.extend([0.0]) pdf_list.extend([1.0]) - + if i < len(incident_energies) - 1: offset[i + 1] = len(cosines_list) - + grid = incident_energies value = np.array(cosines_list) pdf = np.array(pdf_list) - + mu = DistributionMultiTable(grid, offset, value, pdf) return angle_type, mu @@ -384,12 +385,13 @@ class ProtonSecondaryChannel(ObjectPolymorphic): Data container for a proton secondary particle channel. Plain helper object. """ + particle_type: int MT: int - multiplicity: float64 # Multiplicity of particles produced per reaction + multiplicity: float64 # Multiplicity of particles produced per reaction production_xs: NDArray[float64] production_xs_offset_: int - reference_frame: int # COM or LAB + reference_frame: int # COM or LAB energy_spectrum: DistributionBase def __init__( @@ -422,7 +424,7 @@ def from_h5_group(cls, h5_group, zap): particle_type = ZAP_TO_PARTICLE.get(zap) MT = h5_group.attrs["MT"] multiplicity = h5_group.attrs["multiplicity"] - + reference_frame_str = h5_group.attrs["reference_frame"] if reference_frame_str == "LAB": reference_frame = REFERENCE_FRAME_LAB @@ -430,7 +432,7 @@ def from_h5_group(cls, h5_group, zap): reference_frame = REFERENCE_FRAME_COM else: reference_frame = REFERENCE_FRAME_COM # default - + # Production cross section (optional) if "production_xs" in h5_group: production_xs = h5_group["production_xs"][()] @@ -441,7 +443,7 @@ def from_h5_group(cls, h5_group, zap): # Energy spectrum (currently assume Kalbach-Mann) energy_spectrum = set_energy_distribution(h5_group["kalbach_mann"]) - + return cls( particle_type, MT, @@ -453,7 +455,9 @@ def from_h5_group(cls, h5_group, zap): ) def __repr__(self): - particle_name = "Neutron" if self.particle_type == PARTICLE_NEUTRON else "Proton" + particle_name = ( + "Neutron" if self.particle_type == PARTICLE_NEUTRON else "Proton" + ) text = "\n" text += f"Proton secondary channel ({particle_name})\n" text += f" - ID: {self.ID}\n" diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index e71ac8cd6..3a1abea20 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -71,8 +71,8 @@ def csda_distance(particle_container, simulation, data): nuclide = simulation["nuclides"][nuclide_ID] dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) - dedx = np.interp(E/1e6, dedx_energies, dedx_values) - total_dedx += dedx*1e6 + dedx = np.interp(E / 1e6, dedx_energies, dedx_values) + total_dedx += dedx * 1e6 atomic_mass = nuclide["atomic_weight_ratio"] nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) @@ -131,4 +131,6 @@ def csda_edep(particle_container, collision_data_container, distance, simulation if particle["particle_type"] == PARTICLE_ELECTRON: raise ValueError("CSDA not supported for electrons") if particle["particle_type"] == PARTICLE_PROTON: - proton.csda_edep(particle_container, collision_data_container, distance, simulation, data) \ No newline at end of file + proton.csda_edep( + particle_container, collision_data_container, distance, simulation, data + ) diff --git a/mcdc/transport/physics/proton/interface.py b/mcdc/transport/physics/proton/interface.py index f98da1913..9119d4650 100644 --- a/mcdc/transport/physics/proton/interface.py +++ b/mcdc/transport/physics/proton/interface.py @@ -15,6 +15,7 @@ def particle_speed(particle_container, simulation, data): return native.particle_speed(particle_container) + # ====================================================================================== # Material properties # ====================================================================================== @@ -24,6 +25,7 @@ def particle_speed(particle_container, simulation, data): def macro_xs(reaction_type, particle_container, simulation, data): return native.macro_xs(reaction_type, particle_container, simulation, data) + # ====================================================================================== # Collision # ====================================================================================== @@ -37,4 +39,6 @@ def collision(particle_container, collision_data_container, program, data): @njit def csda_edep(particle_container, collision_data_container, distance, simulation, data): - native.csda_edep(particle_container, collision_data_container, distance, simulation, data) \ No newline at end of file + native.csda_edep( + particle_container, collision_data_container, distance, simulation, data + ) diff --git a/mcdc/transport/physics/proton/multigroup.py b/mcdc/transport/physics/proton/multigroup.py index 9b94c5d4d..7c4a4612e 100644 --- a/mcdc/transport/physics/proton/multigroup.py +++ b/mcdc/transport/physics/proton/multigroup.py @@ -17,7 +17,7 @@ PROTON_REACTION_TOTAL, PROTON_REACTION_ELASTIC_SCATTERING, PROTON_REACTION_NONELASTIC, - ) +) from mcdc.transport.physics.util import scatter_direction from mcdc.transport.distribution import sample_isotropic_direction diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 59bbd70f1..66b61b202 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -126,6 +126,7 @@ def reaction_micro_xs(E, reaction_base, nuclide, data): xs1 = mcdc_get.proton_reaction.xs(idx + 1, reaction_base, data) return linear_interpolation(E, E0, E1, xs0, xs1) + # ====================================================================================== # Collision # ====================================================================================== @@ -147,7 +148,7 @@ def collision(particle_container, collision_data_container, program, data): particle["alive"] = False particle["E"] = 0.0 return - + # ================================================================================== # Sample colliding nuclide # ================================================================================== @@ -175,12 +176,8 @@ def collision(particle_container, collision_data_container, program, data): # Sample and perform reaction # ================================================================================== - sigma_elastic = total_micro_xs( - PROTON_REACTION_ELASTIC_SCATTERING, E, nuclide, data - ) - sigma_nonelastic = total_micro_xs( - PROTON_REACTION_NONELASTIC, E, nuclide, data - ) + sigma_elastic = total_micro_xs(PROTON_REACTION_ELASTIC_SCATTERING, E, nuclide, data) + sigma_nonelastic = total_micro_xs(PROTON_REACTION_NONELASTIC, E, nuclide, data) xi = rng.lcg(particle_container) * sigmaT # Elastic scattering @@ -218,9 +215,7 @@ def collision(particle_container, collision_data_container, program, data): total -= sigma_nonelastic for i in range(nuclide["N_proton_nonelastic_reaction"]): reaction_ID = int( - mcdc_get.nuclide.proton_nonelastic_reaction_IDs( - i, nuclide, data - ) + mcdc_get.nuclide.proton_nonelastic_reaction_IDs(i, nuclide, data) ) reaction = simulation["proton_nonelastic_reactions"][reaction_ID] reaction_base_ID = reaction["parent_ID"] @@ -254,7 +249,7 @@ def csda_edep(particle_container, collision_data_container, distance, simulation particle["alive"] = False particle["E"] = 0.0 return - + total_stopping_power = 0.0 total_rho_gcm3 = 0.0 # Find the total stopping power by summing over every nuclide in the material @@ -263,15 +258,15 @@ def csda_edep(particle_container, collision_data_container, distance, simulation nuclide = simulation["nuclides"][nuclide_ID] dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) - + # TODO: replace np.interp with a non-numpy function?? - dedx = np.interp(E/1e6, dedx_energies, dedx_values) + dedx = np.interp(E / 1e6, dedx_energies, dedx_values) total_stopping_power += dedx # Convert atoms/barn-cm to g/cm³: - atomic_mass = nuclide["atomic_weight_ratio"] # mass in amu + atomic_mass = nuclide["atomic_weight_ratio"] # mass in amu nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) - density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) + density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) total_rho_gcm3 += density_gcm3 energy_loss = total_stopping_power * distance * total_rho_gcm3 * 1e6 @@ -279,7 +274,6 @@ def csda_edep(particle_container, collision_data_container, distance, simulation collision_data["energy_deposition"] += energy_loss * particle["w"] return - # ====================================================================================== # Elastic scattering @@ -301,7 +295,7 @@ def elastic_scattering( # Energy deposition collision_data["energy_deposition"] += E * particle["w"] - #print(f'\ndeposited {E * particle["w"]} eV at x={particle["x"]} from elastic scattering') + # print(f'\ndeposited {E * particle["w"]} eV at x={particle["x"]} from elastic scattering') # Note: Q-value is zero in elastic scattering @@ -385,7 +379,6 @@ def elastic_scattering( collision_data["energy_deposition"] -= particle["E"] * particle["w"] - @njit def sample_nucleus_velocity(A, particle_container): particle = particle_container[0] @@ -443,10 +436,9 @@ def sample_nucleus_velocity(A, particle_container): def nonelastic_reaction( reaction, particle_container, collision_data_container, nuclide, program, data ): - """ Proton nonelastic scattering with secondary particle production. - + Samples: 1. Outgoing proton from proton_reactions/inelastic/MT-005 2. Secondary particles from secondary_particles/ZAP_x/MT-005 @@ -475,7 +467,7 @@ def nonelastic_reaction( # =========================================================================== # 1. Sample outgoing PROTON # =========================================================================== - + # Number of outgoing protons and spectra N_proton = reaction["multiplicity"] N_spectrum = reaction["N_spectrum"] @@ -487,8 +479,7 @@ def nonelastic_reaction( # Energy deposition (will be adjusted as we create secondaries) collision_data["energy_deposition"] += total_energy * w - #print(f'\ndeposited {total_energy * particle["w"]} eV at x={particle["x"]} from nonelastic_rxn') - + # print(f'\ndeposited {total_energy * particle["w"]} eV at x={particle["x"]} from nonelastic_rxn') # Create outgoing protons for n in range(N_proton): @@ -531,10 +522,8 @@ def nonelastic_reaction( xi = rng.lcg(particle_container_new) total = 0.0 for j in range(N_spectrum): - probability = ( - mcdc_get.proton_nonelastic_reaction.spectrum_probability( - probability_idx, j, reaction, data - ) + probability = mcdc_get.proton_nonelastic_reaction.spectrum_probability( + probability_idx, j, reaction, data ) total += probability if xi < total: @@ -602,15 +591,15 @@ def nonelastic_reaction( # =========================================================================== # 2. Sample SECONDARY PARTICLES from secondary_particles groups # =========================================================================== - + # Get secondary channels for this MT (if any) # MT = int(reaction_base["MT"]) # nuclide_ID = particle["nuclide_ID"] - + # Check if nuclide has secondary particle data # (This requires access to nuclide secondary_channels dict, which needs to be added) # For now, we'll skip this part and it can be added when the data structure supports it # TODO: Add secondary particle sampling when nuclide.proton_secondary_channels is accessible -# No fission for protons \ No newline at end of file +# No fission for protons diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index 956a6daeb..3f02fe4ec 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -446,7 +446,6 @@ def move_to_event(particle_container, simulation, data): elif geometry.check_coincidence(d_csda, distance): particle["event"] += EVENT_CSDA_EDEP - # ================================================================================== # Move particle # ================================================================================== @@ -491,7 +490,9 @@ def move_to_event(particle_container, simulation, data): # CSDA calculates energy loss after particle has moved if settings["csda"]: collision_data_container = np.zeros(1, type_.collision_data) - physics.csda_edep(particle_container, collision_data_container, distance, simulation, data) + physics.csda_edep( + particle_container, collision_data_container, distance, simulation, data + ) # Score collision tallies (edep is a collision tally) # TODO: maybe make edep a potential tracklength tally for CSDA? @@ -529,4 +530,4 @@ def move_to_event(particle_container, simulation, data): tally, simulation, data, - ) \ No newline at end of file + ) From 127070f6a2bd943bcf99a21074883461d51b4cd6 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 3 Jun 2026 13:39:15 -0700 Subject: [PATCH 20/64] file to generate h5 files from ACE & PSTAR; also, proton beam example --- examples/proton_beam/input_1.py | 96 ++ examples/proton_beam/input_10.py | 97 ++ examples/proton_beam/input_10MeV.py | 97 ++ examples/proton_beam/input_1MeV.py | 96 ++ examples/proton_beam/process.py | 47 + examples/proton_beam/test.py | 23 + .../proton_ace_to_hdf5.py | 892 ++++++++++++++++++ tools/data_library_generator/util.py | 4 + 8 files changed, 1352 insertions(+) create mode 100644 examples/proton_beam/input_1.py create mode 100644 examples/proton_beam/input_10.py create mode 100644 examples/proton_beam/input_10MeV.py create mode 100644 examples/proton_beam/input_1MeV.py create mode 100644 examples/proton_beam/process.py create mode 100644 examples/proton_beam/test.py create mode 100644 tools/data_library_generator/proton_ace_to_hdf5.py diff --git a/examples/proton_beam/input_1.py b/examples/proton_beam/input_1.py new file mode 100644 index 000000000..fc30e633f --- /dev/null +++ b/examples/proton_beam/input_1.py @@ -0,0 +1,96 @@ +import numpy as np +import mcdc + +# ====================================================================================== +# Set model +# ====================================================================================== +# Proton beam, incident on a slab + +# Set materials (atom density in units of atoms/barn-cm) +silicon = mcdc.Material("silicon", {"Si28": 0.05}) + +# Set surfaces +sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") +sx2 = mcdc.Surface.PlaneX(x=0.002, boundary_condition="vacuum") +sy1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") +sy2 = mcdc.Surface.PlaneY(y=1.0, boundary_condition="vacuum") +sz1 = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") +sz2 = mcdc.Surface.PlaneZ(z=1.0, boundary_condition="vacuum") + +slab = +sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2 + +# Set cells +slab_cell = mcdc.Cell(name="silicon", region=slab, fill=silicon) + +# ====================================================================================== +# Set source +# ====================================================================================== + +mcdc.Source( + x=[0.0, 0.0], + y=[0.0, 1.0], + z=[0.0, 1.0], + direction=[1.0, 0.0, 0.0], + energy=1e6, + # energy_group=0, + particle_type="proton", + # time=[0.0, 0.0], +) + +# ====================================================================================== +# Set tallies, settings, techniques, and run MC/DC +# ====================================================================================== + +# Tallies +percent_of_range = np.array( + [ + 0.0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 85, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 107.5, + 110, + 115, + ] +) +range = 16.45 * 1e-4 # cm + +bin_edges = range * percent_of_range * 1e-2 + +mesh = mcdc.MeshStructured(x=(bin_edges)) +mcdc.Tally(name=f"edep", mesh=mesh, scores=["energy_deposition"]) + +# Settings +mcdc.settings.set_transported_particles(["proton"]) +mcdc.settings.N_particle = 1_000 +mcdc.settings.N_batch = 1 +mcdc.settings.csda = True +mcdc.settings.csda_max_fractional_e_loss = 0.001 + +# Techniques +mcdc.simulation.implicit_capture() + +# Run +mcdc.run() diff --git a/examples/proton_beam/input_10.py b/examples/proton_beam/input_10.py new file mode 100644 index 000000000..b63de3b84 --- /dev/null +++ b/examples/proton_beam/input_10.py @@ -0,0 +1,97 @@ +import numpy as np +import mcdc + +# ====================================================================================== +# Set model +# ====================================================================================== +# Proton beam, incident on a slab + +# Set materials (atom density in units of atoms/barn-cm) +silicon = mcdc.Material("silicon", {"Si28": 0.05}) + +# Set surfaces +sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") +sx2 = mcdc.Surface.PlaneX(x=0.1, boundary_condition="vacuum") +sy1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") +sy2 = mcdc.Surface.PlaneY(y=0.1, boundary_condition="vacuum") +sz1 = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") +sz2 = mcdc.Surface.PlaneZ(z=0.1, boundary_condition="vacuum") + +slab = +sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2 + +# Set cells +slab_cell = mcdc.Cell(name="silicon", region=slab, fill=silicon) + +# ====================================================================================== +# Set source +# ====================================================================================== + +mcdc.Source( + x=[0.0, 0.0], + y=[0.0, 0.0002], + z=[0.0, 0.0002], + direction=[1.0, 0.0, 0.0], + energy=1e7, + # energy_group=0, + particle_type="proton", + # time=[0.0, 0.0], +) + +# ====================================================================================== +# Set tallies, settings, techniques, and run MC/DC +# ====================================================================================== + +# Tallies +percent_of_range = np.array( + [ + 0.0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 85, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 107.5, + 110, + 115, + ] +) +range = 714.59 * 1e-4 # cm + +bin_edges = range * percent_of_range * 1e-2 + +# mesh = mcdc.MeshUniform(x=(0.0, 0.002, 50)) +mesh = mcdc.MeshStructured(x=(bin_edges)) +mcdc.Tally(name=f"edep", mesh=mesh, scores=["energy_deposition"]) + +# Settings +mcdc.settings.set_transported_particles(["proton"]) +mcdc.settings.N_particle = 10_000 +mcdc.settings.N_batch = 1 +mcdc.settings.csda = True +mcdc.settings.csda_max_fractional_e_loss = 0.001 + +# Techniques +mcdc.simulation.implicit_capture() + +# Run +mcdc.run() diff --git a/examples/proton_beam/input_10MeV.py b/examples/proton_beam/input_10MeV.py new file mode 100644 index 000000000..b63de3b84 --- /dev/null +++ b/examples/proton_beam/input_10MeV.py @@ -0,0 +1,97 @@ +import numpy as np +import mcdc + +# ====================================================================================== +# Set model +# ====================================================================================== +# Proton beam, incident on a slab + +# Set materials (atom density in units of atoms/barn-cm) +silicon = mcdc.Material("silicon", {"Si28": 0.05}) + +# Set surfaces +sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") +sx2 = mcdc.Surface.PlaneX(x=0.1, boundary_condition="vacuum") +sy1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") +sy2 = mcdc.Surface.PlaneY(y=0.1, boundary_condition="vacuum") +sz1 = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") +sz2 = mcdc.Surface.PlaneZ(z=0.1, boundary_condition="vacuum") + +slab = +sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2 + +# Set cells +slab_cell = mcdc.Cell(name="silicon", region=slab, fill=silicon) + +# ====================================================================================== +# Set source +# ====================================================================================== + +mcdc.Source( + x=[0.0, 0.0], + y=[0.0, 0.0002], + z=[0.0, 0.0002], + direction=[1.0, 0.0, 0.0], + energy=1e7, + # energy_group=0, + particle_type="proton", + # time=[0.0, 0.0], +) + +# ====================================================================================== +# Set tallies, settings, techniques, and run MC/DC +# ====================================================================================== + +# Tallies +percent_of_range = np.array( + [ + 0.0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 85, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 107.5, + 110, + 115, + ] +) +range = 714.59 * 1e-4 # cm + +bin_edges = range * percent_of_range * 1e-2 + +# mesh = mcdc.MeshUniform(x=(0.0, 0.002, 50)) +mesh = mcdc.MeshStructured(x=(bin_edges)) +mcdc.Tally(name=f"edep", mesh=mesh, scores=["energy_deposition"]) + +# Settings +mcdc.settings.set_transported_particles(["proton"]) +mcdc.settings.N_particle = 10_000 +mcdc.settings.N_batch = 1 +mcdc.settings.csda = True +mcdc.settings.csda_max_fractional_e_loss = 0.001 + +# Techniques +mcdc.simulation.implicit_capture() + +# Run +mcdc.run() diff --git a/examples/proton_beam/input_1MeV.py b/examples/proton_beam/input_1MeV.py new file mode 100644 index 000000000..fc30e633f --- /dev/null +++ b/examples/proton_beam/input_1MeV.py @@ -0,0 +1,96 @@ +import numpy as np +import mcdc + +# ====================================================================================== +# Set model +# ====================================================================================== +# Proton beam, incident on a slab + +# Set materials (atom density in units of atoms/barn-cm) +silicon = mcdc.Material("silicon", {"Si28": 0.05}) + +# Set surfaces +sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") +sx2 = mcdc.Surface.PlaneX(x=0.002, boundary_condition="vacuum") +sy1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") +sy2 = mcdc.Surface.PlaneY(y=1.0, boundary_condition="vacuum") +sz1 = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") +sz2 = mcdc.Surface.PlaneZ(z=1.0, boundary_condition="vacuum") + +slab = +sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2 + +# Set cells +slab_cell = mcdc.Cell(name="silicon", region=slab, fill=silicon) + +# ====================================================================================== +# Set source +# ====================================================================================== + +mcdc.Source( + x=[0.0, 0.0], + y=[0.0, 1.0], + z=[0.0, 1.0], + direction=[1.0, 0.0, 0.0], + energy=1e6, + # energy_group=0, + particle_type="proton", + # time=[0.0, 0.0], +) + +# ====================================================================================== +# Set tallies, settings, techniques, and run MC/DC +# ====================================================================================== + +# Tallies +percent_of_range = np.array( + [ + 0.0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 85, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 107.5, + 110, + 115, + ] +) +range = 16.45 * 1e-4 # cm + +bin_edges = range * percent_of_range * 1e-2 + +mesh = mcdc.MeshStructured(x=(bin_edges)) +mcdc.Tally(name=f"edep", mesh=mesh, scores=["energy_deposition"]) + +# Settings +mcdc.settings.set_transported_particles(["proton"]) +mcdc.settings.N_particle = 1_000 +mcdc.settings.N_batch = 1 +mcdc.settings.csda = True +mcdc.settings.csda_max_fractional_e_loss = 0.001 + +# Techniques +mcdc.simulation.implicit_capture() + +# Run +mcdc.run() diff --git a/examples/proton_beam/process.py b/examples/proton_beam/process.py new file mode 100644 index 000000000..18150c794 --- /dev/null +++ b/examples/proton_beam/process.py @@ -0,0 +1,47 @@ +import h5py +import numpy as np +import matplotlib.pyplot as plt + +energy = 1 # MeV + +# with h5py.File(f"output_{energy}mev.h5") as f: +with h5py.File(f"output.h5") as f: + isotope_edep = list(f["tallies"].keys())[0] + + edep = f["tallies"][f"{isotope_edep}"]["energy_deposition"]["mean"][()] + xgrid = f["tallies"][f"{isotope_edep}"]["grid"]["x"][()] + # print(f'xgrid = {xgrid}') + + normalized_edep = np.zeros_like(edep) + centers = np.zeros_like(edep) + for i in range(len(xgrid) - 1): + width = xgrid[i + 1] - xgrid[i] + centers[i] = xgrid[i] + width / 2 + normalized_edep[i] = edep[i] / (energy * 1e6) / width + + normalized_edep = np.array(normalized_edep) + index_of_depth_at_max = np.argmax(normalized_edep) + + print(rf"peak location: {xgrid[index_of_depth_at_max]} um") + print(f"peak magnitude = {np.max(normalized_edep)}") + + +# TODO: add automatic range calculations based on PSTAR data +range = 0.001645 + +plt.plot(centers * 1e4, normalized_edep, label="edep tally") +plt.vlines( + range * 1e4, + 0, + np.max(normalized_edep), + linestyle="--", + label="theoretical Bragg peak for 1 MeV protons", + color="red", +) +plt.title(f"Energy Deposition of {energy} MeV Protons in a Slab of Si-28") +plt.xlabel(r"x [$\mu$m]") +plt.ylabel("MeV/cm") +plt.ylim(0, 1300) +plt.legend() +plt.savefig(f"Si-28_edep_{energy}MeV.png") +# plt.show() diff --git a/examples/proton_beam/test.py b/examples/proton_beam/test.py new file mode 100644 index 000000000..ce1f8f2fa --- /dev/null +++ b/examples/proton_beam/test.py @@ -0,0 +1,23 @@ +import h5py +import matplotlib.pyplot as plt +import sys + +isotope = sys.argv[1] + +with h5py.File(f"../../proton_generated_lib/{isotope}-293.6K.h5") as f: + print(f'atomic number = {f["atomic_number"][()]}') + print(f'atomic weight ratio = {f["atomic_weight_ratio"][()]}') + print(f'fissionable = {f["fissionable"][()]}') + print(f'nuclide name = {f["nuclide_name"][()]}') + + elastic_xs = f["proton_reactions"]["elastic_scattering"]["MT-002"]["xs"][()] + inelastic_xs = f["proton_reactions"]["inelastic_scattering"]["MT-005"]["xs"][()] + + plt.plot(elastic_xs, label="elastic") + plt.plot(inelastic_xs, label="inelastic") + plt.legend() + plt.yscale("log") + plt.xscale("log") + plt.xlabel("Incident Energy (MeV)") + plt.ylabel("Cross Section") + plt.savefig(f"{isotope}_xs.png") diff --git a/tools/data_library_generator/proton_ace_to_hdf5.py b/tools/data_library_generator/proton_ace_to_hdf5.py new file mode 100644 index 000000000..549192316 --- /dev/null +++ b/tools/data_library_generator/proton_ace_to_hdf5.py @@ -0,0 +1,892 @@ +# The majority of this script was written by Anthropic's Claude + +""" +proton_ace_to_hdf5.py — Convert proton ACE files (TENDL etc.) to HDF5 for MC/DC + +Usage +----- + python proton_ace_to_hdf5.py + python proton_ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 [--pstar_dir /path/to/pstar] + python proton_ace_to_hdf5.py ... --rewrite # overwrite existing files + python proton_ace_to_hdf5.py ... --verbose # per-reaction detail + + +Environment variable fallbacks: $MCDC_ACELIB, $MCDC_LIB, $PSTAR_LIB + +HDF5 layout +----------- +-K.h5 + attrs: source_title, source_version, source_date + nuclide_name, excitation_level, temperature (K), + atomic_number, atomic_weight_ratio, fissionable + + stopping_power/ (if PSTAR data available) + energy (MeV), total_stopping_power (MeV cm2/g) + + proton_reactions/ + xs_energy_grid (MeV) + elastic_scattering/MT-002/ + xs (barns, offset=0), Q-value (MeV), reference_frame, + angular_cosine_distribution/ + capture/MT-{NNN}/ + xs (barns), Q-value (MeV), reference_frame + nonelastic_reaction/MT-{NNN}/ + xs (barns), Q-value (MeV), reference_frame, multiplicity + angular_cosine_distribution/ + energy_spectrum-{k}/ (law attr; kalbach-mann: energy, offset, + energy_out, pdf, cdf, precompound_factor, angular_slope) + fission/ (only if fissionable) + + secondary_particles/ZAP_{zap}/MT-{NNN}/ + attrs: ZAP, particle_name, MT, multiplicity, reference_frame + production_xs (barns, offset) + kalbach_mann/ (energy, offset, energy_out, pdf, cdf, + precompound_factor, angular_slope) + +ZAP identity: 1=n, 1001=p, 1002=d, 1003=t, 2003=He3, 2004=alpha, 0=gamma + +TabulatedKalbachMannDistribution properties used (from ACEtk): + outgoing_energies, pdf, cdf, + precompound_fraction_values, angular_distribution_slope_values +""" + +import argparse +import os +import sys + +import h5py +import numpy as np +from tqdm import tqdm +import ACEtk + +# -- Constants ----------------------------------------------------------------- + +ZAP_NAMES = { + 0: "photon", + 1: "neutron", + 1001: "proton", + 1002: "deuteron", + 1003: "triton", + 2003: "He3", + 2004: "alpha", +} + +Z_TO_SYMBOL = { + 1: "H", + 2: "He", + 3: "Li", + 4: "Be", + 5: "B", + 6: "C", + 7: "N", + 8: "O", + 9: "F", + 10: "Ne", + 11: "Na", + 12: "Mg", + 13: "Al", + 14: "Si", + 15: "P", + 16: "S", + 17: "Cl", + 18: "Ar", + 19: "K", + 20: "Ca", + 21: "Sc", + 22: "Ti", + 23: "V", + 24: "Cr", + 25: "Mn", + 26: "Fe", + 27: "Co", + 28: "Ni", + 29: "Cu", + 30: "Zn", + 31: "Ga", + 32: "Ge", + 33: "As", + 34: "Se", + 35: "Br", + 36: "Kr", + 37: "Rb", + 38: "Sr", + 39: "Y", + 40: "Zr", + 41: "Nb", + 42: "Mo", + 43: "Tc", + 44: "Ru", + 45: "Rh", + 46: "Pd", + 47: "Ag", + 48: "Cd", + 49: "In", + 50: "Sn", + 51: "Sb", + 52: "Te", + 53: "I", + 54: "Xe", + 55: "Cs", + 56: "Ba", + 57: "La", + 58: "Ce", + 59: "Pr", + 60: "Nd", + 61: "Pm", + 62: "Sm", + 63: "Eu", + 64: "Gd", + 65: "Tb", + 66: "Dy", + 67: "Ho", + 68: "Er", + 69: "Tm", + 70: "Yb", + 71: "Lu", + 72: "Hf", + 73: "Ta", + 74: "W", + 75: "Re", + 76: "Os", + 77: "Ir", + 78: "Pt", + 79: "Au", + 80: "Hg", + 81: "Tl", + 82: "Pb", + 83: "Bi", + 84: "Po", + 85: "At", + 86: "Rn", + 87: "Fr", + 88: "Ra", + 89: "Ac", + 90: "Th", + 91: "Pa", + 92: "U", + 93: "Np", + 94: "Pu", + 95: "Am", + 96: "Cm", + 97: "Bk", + 98: "Cf", + 99: "Es", + 100: "Fm", + 101: "Md", + 102: "No", + 103: "Lr", +} + +# Redundant sum MTs that must not be double-counted +REDUNDANT_MTS = [1, 3, 4, 10, 101, 103, 104, 105, 106, 107] +FISSION_CHANCE_MTS = [19, 20, 21, 38] + + +# -- Utility ------------------------------------------------------------------- + + +def print_error(msg): + print(f"\n[ERROR] {msg}", file=sys.stderr) + sys.exit(1) + + +def print_note(msg): + print(f" [note] {msg}") + + +def decode_ace_zaid(zaid): + """Return (Z, A, S, T=0) from an ACE ZAID string.""" + za = int(zaid.strip().split(".")[0]) + S = 0 + if za >= 600000: + S = (za % 1000) // 400 + za = za - S * 400 + return za // 1000, za % 1000, S, 0 + + +def load_pstar_file(filepath): + """ + Load a two-column PSTAR file (Energy MeV, Stopping power MeV cm2/g). + Returns (energies, stopping_powers) as float64 arrays. + """ + energies, sps = [], [] + with open(filepath) as f: + for line in f: + parts = line.strip().split() + if len(parts) != 2: + continue + try: + energies.append(float(parts[0])) + sps.append(float(parts[1])) + except ValueError: + continue + return np.array(energies), np.array(sps) + + +# -- Distribution writers ------------------------------------------------------ + + +def load_cosine_distribution(data, h5_group): + """ + Write a tabulated angular distribution into h5_group. + Returns False if the distribution is embedded in a Kalbach-Mann block + (DistributionGivenElsewhere), True otherwise. + """ + if isinstance(data, ACEtk.continuous.DistributionGivenElsewhere): + h5_group.attrs["type"] = "given_in_energy_distribution" + return False + + h5_group.attrs["type"] = "tabulated" + h5_group.attrs["unit"] = "MeV" + h5_group.create_dataset("incident_energies", data=np.array(data.incident_energies)) + + for i, subdist in enumerate(data.distributions): + eg = h5_group.create_group(f"E_in_{i + 1}") + if isinstance(subdist, ACEtk.continuous.TabulatedAngularDistribution): + eg.attrs["type"] = "tabulated" + eg.create_dataset("cosines", data=np.array(subdist.cosines)) + eg.create_dataset("pdf", data=np.array(subdist.pdf)) + eg.create_dataset("cdf", data=np.array(subdist.cdf)) + else: + eg.attrs["type"] = "isotropic" + + return True + + +def _write_kalbach_mann(km_data, h5_group): + """ + Write a KalbachMannDistributionData into h5_group as flat arrays. + offset[i] gives the starting index in the flat arrays for incident energy i. + """ + h5_group.attrs["type"] = "kalbach-mann" + + NE = km_data.number_incident_energies + h5_group.create_dataset("energy", data=np.array(km_data.incident_energies)).attrs[ + "unit" + ] = "MeV" + + offset, energy_out, pdf, cdf, r_vals, a_vals = [], [], [], [], [], [] + for i in range(1, NE + 1): + dist = km_data.distribution(i) + offset.append(len(energy_out)) + energy_out.extend(dist.outgoing_energies) + pdf.extend(dist.pdf) + cdf.extend(dist.cdf) + r_vals.extend(dist.precompound_fraction_values) + a_vals.extend(dist.angular_distribution_slope_values) + + h5_group.create_dataset("offset", data=np.array(offset, dtype=np.int32)) + h5_group.create_dataset("energy_out", data=np.array(energy_out)).attrs["unit"] = ( + "MeV" + ) + h5_group.create_dataset("pdf", data=np.array(pdf)) + h5_group.create_dataset("cdf", data=np.array(cdf)) + h5_group.create_dataset("precompound_factor", data=np.array(r_vals)) + h5_group.create_dataset("angular_slope", data=np.array(a_vals)) + + +def load_energy_distribution(data, h5_group): + """Write a primary-particle outgoing energy distribution into h5_group.""" + if isinstance(data, ACEtk.continuous.KalbachMannDistributionData): + h5_group.attrs["law"] = 44 + _write_kalbach_mann(data, h5_group) + + elif isinstance(data, ACEtk.continuous.OutgoingEnergyDistributionData): + h5_group.attrs["law"] = 4 + h5_group.create_dataset( + "incident_energies", data=np.array(data.incident_energies) + ) + for k, dist in enumerate(data.distributions): + eg = h5_group.create_group(f"E_in_{k + 1}") + eg.create_dataset( + "outgoing_energies", data=np.array(dist.outgoing_energies) + ) + eg.create_dataset("pdf", data=np.array(dist.pdf)) + eg.create_dataset("cdf", data=np.array(dist.cdf)) + + elif isinstance(data, ACEtk.continuous.LevelScatteringData): + h5_group.attrs["law"] = 3 + h5_group.create_dataset("C1", data=data.C1) + h5_group.create_dataset("C2", data=data.C2) + + elif isinstance(data, ACEtk.continuous.EquiprobableOutgoingEnergyBins): + h5_group.attrs["law"] = 1 + h5_group.create_dataset( + "incident_energies", data=np.array(data.incident_energies) + ) + for k, dist in enumerate(data.distributions): + h5_group.create_group(f"E_in_{k + 1}").create_dataset( + "energies", data=np.array(dist.energies) + ) + + else: + h5_group.attrs["law"] = -1 + h5_group.attrs["type_name"] = type(data).__name__ + try: + h5_group.create_dataset("xss_array", data=np.array(data.xss_array)) + except Exception: + pass + + +def load_fission_multiplicity(data, h5_group): + if isinstance(data, ACEtk.continuous.TabulatedFissionMultiplicity): + h5_group.attrs["type"] = "tabulated" + h5_group.create_dataset("energies", data=np.array(data.energies)) + h5_group.create_dataset("multiplicities", data=np.array(data.multiplicities)) + elif isinstance(data, ACEtk.continuous.PolynomialFissionMultiplicity): + h5_group.attrs["type"] = "polynomial" + h5_group.create_dataset("coefficients", data=np.array(data.coefficients)) + else: + h5_group.attrs["type"] = "unknown" + h5_group.attrs["type_name"] = type(data).__name__ + + +# -- Secondary particles ------------------------------------------------------- + + +def load_secondary_particles(ace_table, file, verbose=False): + n_types = ace_table.number_secondary_particle_types + if n_types == 0: + return + + type_block = ace_table.secondary_particle_type_block + info_block = ace_table.secondary_particle_information_block + rx_block = ace_table.secondary_particle_reaction_number_block + tyr_block = ace_table.secondary_particle_frame_and_multiplicity_block + xs_block = ace_table.secondary_particle_production_cross_section_block + edy_block = ace_table.secondary_particle_energy_distribution_block + + has_ang = False + try: + ang_block = ace_table.secondary_particle_angular_distribution_block + has_ang = True + except Exception: + pass + + sec_group = file.create_group("secondary_particles") + + pi_method = next( + ( + c + for c in ["particle_identifier", "ZAP", "type", "particle_type"] + if hasattr(type_block, c) + ), + None, + ) + if pi_method is None: + raise AttributeError( + f"Cannot find particle identifier on {type(type_block).__name__}. " + f"Available: {[x for x in dir(type_block) if not x.startswith('_')]}" + ) + + for i in range(1, n_types + 1): + zap = getattr(type_block, pi_method)(i) + name = ZAP_NAMES.get(zap, f"ZAP_{zap}") + n_rx = int(info_block.number_reactions[i - 1]) + + if verbose: + print(f" Secondary type {i}: ZAP={zap} ({name}), {n_rx} reactions") + + zap_group = sec_group.create_group(f"ZAP_{zap}") + zap_group.attrs["ZAP"] = zap + zap_group.attrs["particle_name"] = name + + rx_i = rx_block(i) + tyr_i = tyr_block(i) + xs_i = xs_block(i) + edy_i = edy_block(i) + ang_i = ang_block(i) if has_ang else None + + xs_method = next( + (c for c in ["cross_sections", "cross_section", "xs"] if hasattr(xs_i, c)), + None, + ) + off_method = next( + ( + c + for c in ["energy_index", "offset", "locator", "index"] + if hasattr(xs_i, c) + ), + None, + ) + edy_method = next( + ( + c + for c in [ + "energy_distribution_data", + "distribution_data", + "distribution", + ] + if hasattr(edy_i, c) + ), + None, + ) + + for j in range(1, n_rx + 1): + MT = rx_i.MT(j) + nu_raw = tyr_i.multiplicity(j) + nu = nu_raw - 100 if nu_raw >= 100 else nu_raw + rf_raw = tyr_i.reference_frame(j) + rf = ( + "LAB" + if rf_raw == ACEtk.ReferenceFrame.Laboratory + else ( + "COM" + if rf_raw == ACEtk.ReferenceFrame.CentreOfMass + else str(rf_raw) + ) + ) + + mt = zap_group.create_group(f"MT-{MT:03}") + mt.attrs["MT"] = MT + mt.attrs["multiplicity"] = nu + mt.attrs["reference_frame"] = rf + + if verbose: + print(f" MT={MT:03} nu_raw={nu_raw} nu={nu} frame={rf}") + + # Production cross section + empty_xs = np.zeros(0, dtype=float) + if xs_method and off_method: + try: + ds = mt.create_dataset( + "production_xs", data=np.array(getattr(xs_i, xs_method)(j)) + ) + ds.attrs["offset"] = int(getattr(xs_i, off_method)(j)) - 1 + ds.attrs["unit"] = "barns" + except Exception as exc: + ds = mt.create_dataset("production_xs", data=empty_xs) + ds.attrs["offset"] = 0 + ds.attrs["unit"] = "barns" + if verbose: + print(f" [warn] production xs: {exc}") + else: + ds = mt.create_dataset("production_xs", data=empty_xs) + ds.attrs["offset"] = 0 + ds.attrs["unit"] = "barns" + if verbose: + print( + f" [warn] xs methods not found: " + f"{[x for x in dir(xs_i) if not x.startswith('_')]}" + ) + + # Kalbach-Mann energy-angle distribution + if edy_method: + try: + _write_kalbach_mann( + getattr(edy_i, edy_method)(j), mt.create_group("kalbach_mann") + ) + except Exception as exc: + if verbose: + print(f" [warn] energy dist: {exc}") + elif verbose: + print( + f" [warn] edy method not found: " + f"{[x for x in dir(edy_i) if not x.startswith('_')]}" + ) + + if ang_i is not None: + try: + load_cosine_distribution( + ang_i.angular_distribution_data(j), + mt.create_group("angular_cosine_distribution"), + ) + except Exception: + pass + + +# -- Per-file processing ------------------------------------------------------- + + +def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): + with open(ace_path) as f: + header = ACEtk.Header.from_string(f.readline()) + + Z, A, S, _ = decode_ace_zaid(header.zaid) + symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") + nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" + + ace_table = ACEtk.ContinuousEnergyTable.from_file(ace_path) + T_kelvin = 293.6 # TENDL proton files report 0 K as a placeholder + + mcdc_name = f"{nuclide_name}-{T_kelvin}K.h5" + out_path = os.path.join(output_dir, mcdc_name) + + if verbose: + print(f"\n{'='*80}") + print(f" {os.path.basename(ace_path)} -> {mcdc_name}") + print(f" Z={Z} A={A} S={S} T={T_kelvin} K") + + file = h5py.File(out_path, "w") + + # Metadata + hdr = ace_table.header + file.attrs["source_title"] = hdr.title + file.attrs["source_version"] = hdr.version + file.attrs["source_date"] = hdr.date + if hasattr(hdr, "comments"): + file.attrs["source_comments"] = hdr.comments + + file.create_dataset("nuclide_name", data=nuclide_name) + file.create_dataset("excitation_level", data=S) + file.create_dataset("temperature", data=T_kelvin).attrs["unit"] = "K" + file.create_dataset("atomic_number", data=ace_table.atom_number) + file.create_dataset("atomic_weight_ratio", data=ace_table.atomic_weight_ratio) + fissionable = ace_table.fission_multiplicity_block is not None + file.create_dataset("fissionable", data=fissionable) + + # Stopping power + if pstar_dir is not None: + pstar_path = os.path.join(pstar_dir, f"{symbol}.txt") + if os.path.exists(pstar_path): + if verbose: + print(f" Loading PSTAR from {pstar_path}") + E_s, S_s = load_pstar_file(pstar_path) + sp = file.create_group("stopping_power") + sp.create_dataset("energy", data=E_s).attrs["unit"] = "MeV" + sp.create_dataset("total_stopping_power", data=S_s).attrs["unit"] = ( + "MeV cm2/g" + ) + elif verbose: + print(f" [warn] No PSTAR file for {symbol}") + + # Reaction classification + nu_block = ace_table.frame_and_multiplicity_block + rx_block = ace_table.reaction_number_block + N_reaction = nu_block.number_reactions + + proton_reactions = file.create_group("proton_reactions") + elastic_group = proton_reactions.create_group("elastic_scattering") + capture_group = proton_reactions.create_group("capture") + nonelastic_group = proton_reactions.create_group("nonelastic_reaction") + fission_group = proton_reactions.create_group("fission") + + elastic_MTs = [2] + capture_MTs = [] + nonelastic_MTs = [] + fission_MTs = ( + [18] + if rx_block.has_MT(18) + else [MT for MT in FISSION_CHANCE_MTS if rx_block.has_MT(MT)] + ) + + for i in range(N_reaction): + idx = i + 1 + MT = rx_block.MT(idx) + if MT in REDUNDANT_MTS + elastic_MTs + fission_MTs or MT > 891: + continue + nu_raw = nu_block.multiplicity(idx) + if not isinstance(nu_raw, int): + print_error(f"Non-integer multiplicity for MT-{MT:03} in {ace_path}") + nu = nu_raw - 100 if nu_raw >= 100 else nu_raw + if nu == 0: + capture_MTs.append(MT) + elif nu > 0: + nonelastic_MTs.append(MT) + else: + print_error(f"Negative multiplicity for MT-{MT:03} in {ace_path}") + + for grp, mts in [ + (elastic_group, elastic_MTs), + (capture_group, capture_MTs), + (nonelastic_group, nonelastic_MTs), + (fission_group, fission_MTs), + ]: + for MT in mts: + grp.create_group(f"MT-{MT:03}").attrs["MT"] = MT + + if verbose: + print( + f" Elastic: {elastic_MTs} Capture: {capture_MTs} " + f"Nonelastic: {nonelastic_MTs}" + + (f" Fission: {fission_MTs}" if fissionable else "") + ) + + if not fissionable: + del file["proton_reactions/fission"] + if not nonelastic_MTs: + del file["proton_reactions/nonelastic_reaction"] + + # Cross sections + xs0 = ace_table.principal_cross_section_block + xs_main = ace_table.cross_section_block + + proton_reactions.create_dataset( + "xs_energy_grid", data=np.array(xs0.energies) + ).attrs["unit"] = "MeV" + + ds = elastic_group.create_dataset("MT-002/xs", data=np.array(xs0.elastic)) + ds.attrs["offset"] = 0 + ds.attrs["unit"] = "barns" + + for mts, grp in [ + (capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + ds = grp.create_dataset( + f"MT-{MT:03}/xs", data=np.array(xs_main.cross_sections(idx)) + ) + ds.attrs["offset"] = xs_main.energy_index(idx) - 1 + ds.attrs["unit"] = "barns" + + # Q-values + q_block = ace_table.reaction_qvalue_block + elastic_group.create_dataset("MT-002/Q-value", data=0.0).attrs["unit"] = "MeV" + + for mts, grp in [ + (capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + grp.create_dataset(f"MT-{MT:03}/Q-value", data=q_block.q_value(idx)).attrs[ + "unit" + ] = "MeV" + + # Reference frames + elastic_group.create_dataset("MT-002/reference_frame", data="COM") + + for mts, grp in [ + (capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + rf = nu_block.reference_frame(idx) + rf_str = ( + "LAB" + if rf == ACEtk.ReferenceFrame.Laboratory + else "COM" if rf == ACEtk.ReferenceFrame.CentreOfMass else str(rf) + ) + grp.create_dataset(f"MT-{MT:03}/reference_frame", data=rf_str) + + # Nonelastic multiplicities + for MT in nonelastic_MTs: + idx = rx_block.index(MT) + nu_raw = nu_block.multiplicity(idx) + nonelastic_group.create_dataset( + f"MT-{MT:03}/multiplicity", data=nu_raw - 100 if nu_raw >= 100 else nu_raw + ) + + # Angular distributions + angle_block = ace_table.angular_distribution_block + + ag = elastic_group.create_group("MT-002/angular_cosine_distribution") + ag.attrs["type"] = "energy-correlated" + if ( + not load_cosine_distribution(angle_block.angular_distribution_data(0), ag) + and verbose + ): + print_note("MT-002 angular distribution is given in energy block") + + for mts, grp in [ + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + ag = grp.create_group(f"MT-{MT:03}/angular_cosine_distribution") + if ( + not load_cosine_distribution( + angle_block.angular_distribution_data(idx), ag + ) + and verbose + ): + print_note(f"MT-{MT:03} angular distribution is given in energy block") + + # Primary energy distributions + energy_block = ace_table.energy_distribution_block + + for mts, grp in [ + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + data = energy_block.energy_distribution_data(idx) + + if not isinstance(data, ACEtk.continuous.MultiDistributionData): + grp.create_dataset( + f"MT-{MT:03}/spectrum_probability_grid", data=np.array([0.0, 30.0]) + ).attrs["unit"] = "MeV" + grp.create_dataset( + f"MT-{MT:03}/spectrum_probability", data=np.array([[1.0]]) + ) + load_energy_distribution( + data, grp.create_group(f"MT-{MT:03}/energy_spectrum-1") + ) + else: + N_dist = data.number_distributions + probs = data.probabilities + + if all(p.number_interpolation_regions == 0 for p in probs): + prob_grid = np.array([0.0, 30.0]) + prob = np.zeros((1, N_dist)) + for k in range(N_dist): + prob[0, k] = max(data.probability(k + 1).probabilities) + elif all(p.number_interpolation_regions == 1 for p in probs) and all( + p.interpolants[0] == 1 for p in probs + ): + prob_grid = np.array(data.probability(1).energies) + prob = np.zeros((len(prob_grid) - 1, N_dist)) + for k in range(N_dist): + prob[:, k] = np.array( + data.probability(k + 1).probabilities[:-1] + ) + else: + print_error( + f"Unsupported multi-distribution probability for MT-{MT:03}" + ) + + grp.create_dataset( + f"MT-{MT:03}/spectrum_probability_grid", data=prob_grid + ).attrs["unit"] = "MeV" + grp.create_dataset(f"MT-{MT:03}/spectrum_probability", data=prob) + for k in range(N_dist): + load_energy_distribution( + data.distribution(k + 1), + grp.create_group(f"MT-{MT:03}/energy_spectrum-{k + 1}"), + ) + + # Secondary particles + load_secondary_particles(ace_table, file, verbose=verbose) + + # Fission data + if fissionable: + prompt_block = ace_table.fission_multiplicity_block + delayed_block = ace_table.delayed_fission_multiplicity_block + dnp_block = ace_table.delayed_neutron_precursor_block + + load_fission_multiplicity( + prompt_block.multiplicity, fission_group.create_group("prompt_multiplicity") + ) + if delayed_block is not None: + load_fission_multiplicity( + delayed_block.multiplicity, + fission_group.create_group("delayed_multiplicity"), + ) + + if dnp_block is not None: + N_DNP = dnp_block.number_delayed_precursors + fractions = np.zeros(N_DNP) + decay_rates = np.zeros(N_DNP) + for k in range(N_DNP): + d = dnp_block.precursor_group_data(k + 1) + if ( + d.number_interpolation_regions != 0 + or len(d.probabilities[:]) != 2 + or d.probabilities[0] != d.probabilities[1] + ): + print_error("Non-constant delayed neutron precursor fraction") + fractions[k] = d.probabilities[0] + decay_rates[k] = d.decay_constant + + prec = fission_group.create_group("delayed_neutron_precursors") + prec.create_dataset("fractions", data=fractions) + prec.create_dataset("decay_rates", data=decay_rates).attrs["unit"] = "/s" + + delayed_spectrum_block = ace_table.delayed_neutron_energy_distribution_block + for k in range(N_DNP): + load_energy_distribution( + delayed_spectrum_block.energy_distribution_data(k + 1), + prec.create_group(f"energy_spectrum-{k + 1}"), + ) + + file.close() + return mcdc_name + + +# -- Main ---------------------------------------------------------------------- + + +def main(): + parser = argparse.ArgumentParser( + description="Convert proton ACE files to MC/DC-compatible HDF5" + ) + parser.add_argument("--ace_dir", default=os.getenv("MCDC_ACELIB")) + parser.add_argument("--output_dir", default=os.getenv("MCDC_LIB")) + parser.add_argument("--pstar_dir", default=os.getenv("PSTAR_LIB")) + parser.add_argument("--rewrite", action="store_true", default=False) + parser.add_argument("--verbose", action="store_true", default=False) + args = parser.parse_args() + + if args.ace_dir is None: + print_error("No ACE directory. Use --ace_dir or set $MCDC_ACELIB.") + if args.output_dir is None: + print_error("No output directory. Use --output_dir or set $MCDC_LIB.") + + os.makedirs(args.output_dir, exist_ok=True) + print(f"\nACE directory : {args.ace_dir}") + print(f"Output directory: {args.output_dir}") + print(f"PSTAR directory : {args.pstar_dir}\n") + + all_files = sorted(os.listdir(args.ace_dir)) + + if args.rewrite: + target_files = all_files + else: + target_files = [] + for fname in all_files: + try: + with open(os.path.join(args.ace_dir, fname)) as f: + hdr = ACEtk.Header.from_string(f.readline()) + Z, A, S, _ = decode_ace_zaid(hdr.zaid) + symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") + nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" + if not any( + f.startswith(nuclide_name + "-") + for f in os.listdir(args.output_dir) + ): + target_files.append(fname) + except Exception: + target_files.append(fname) + + errors = [] + pbar = tqdm( + target_files, + disable=args.verbose, + bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} {postfix}", + ) + + for ace_name in pbar: + pbar.set_postfix_str(ace_name) + try: + out = process_ace_file( + os.path.join(args.ace_dir, ace_name), + args.output_dir, + pstar_dir=args.pstar_dir, + verbose=args.verbose, + ) + if args.verbose: + print(f" -> wrote {out}") + except Exception as exc: + errors.append((ace_name, str(exc))) + if args.verbose: + import traceback + + traceback.print_exc() + + print(f"\nDone. {len(target_files) - len(errors)} succeeded, {len(errors)} failed.") + if errors: + print("\nFailed files:") + for name, msg in errors: + print(f" {name}: {msg}") + + +if __name__ == "__main__": + main() diff --git a/tools/data_library_generator/util.py b/tools/data_library_generator/util.py index a2f51be27..cc552974d 100644 --- a/tools/data_library_generator/util.py +++ b/tools/data_library_generator/util.py @@ -47,6 +47,10 @@ def decode_ace_name(name: str): if extension == "70h": T = 293.6 + # Proton data: TENDL-19 (defaults at 0K, I think) + if extension == "19h": + T = 0 + else: T = ACE_TEMPERATURE_LIB81[extension] From d47e06cea490e8d39f85428f0a48bf414eefa287 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 3 Jun 2026 13:43:02 -0700 Subject: [PATCH 21/64] cleaning up examples & tools --- examples/proton_beam/input_1.py | 96 -- examples/proton_beam/input_10.py | 97 -- examples/proton_beam/test.py | 23 - ...ton_generate.py => endf70prot_generate.py} | 0 .../parse_endf70prot.py | 6 +- .../tendl_generate_v2.py | 913 ------------------ 6 files changed, 3 insertions(+), 1132 deletions(-) delete mode 100644 examples/proton_beam/input_1.py delete mode 100644 examples/proton_beam/input_10.py delete mode 100644 examples/proton_beam/test.py rename tools/data_library_generator/{proton_generate.py => endf70prot_generate.py} (100%) delete mode 100644 tools/data_library_generator/tendl_generate_v2.py diff --git a/examples/proton_beam/input_1.py b/examples/proton_beam/input_1.py deleted file mode 100644 index fc30e633f..000000000 --- a/examples/proton_beam/input_1.py +++ /dev/null @@ -1,96 +0,0 @@ -import numpy as np -import mcdc - -# ====================================================================================== -# Set model -# ====================================================================================== -# Proton beam, incident on a slab - -# Set materials (atom density in units of atoms/barn-cm) -silicon = mcdc.Material("silicon", {"Si28": 0.05}) - -# Set surfaces -sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") -sx2 = mcdc.Surface.PlaneX(x=0.002, boundary_condition="vacuum") -sy1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") -sy2 = mcdc.Surface.PlaneY(y=1.0, boundary_condition="vacuum") -sz1 = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") -sz2 = mcdc.Surface.PlaneZ(z=1.0, boundary_condition="vacuum") - -slab = +sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2 - -# Set cells -slab_cell = mcdc.Cell(name="silicon", region=slab, fill=silicon) - -# ====================================================================================== -# Set source -# ====================================================================================== - -mcdc.Source( - x=[0.0, 0.0], - y=[0.0, 1.0], - z=[0.0, 1.0], - direction=[1.0, 0.0, 0.0], - energy=1e6, - # energy_group=0, - particle_type="proton", - # time=[0.0, 0.0], -) - -# ====================================================================================== -# Set tallies, settings, techniques, and run MC/DC -# ====================================================================================== - -# Tallies -percent_of_range = np.array( - [ - 0.0, - 10, - 20, - 30, - 40, - 50, - 60, - 70, - 80, - 85, - 90, - 91, - 92, - 93, - 94, - 95, - 96, - 97, - 98, - 99, - 100, - 101, - 102, - 103, - 104, - 105, - 107.5, - 110, - 115, - ] -) -range = 16.45 * 1e-4 # cm - -bin_edges = range * percent_of_range * 1e-2 - -mesh = mcdc.MeshStructured(x=(bin_edges)) -mcdc.Tally(name=f"edep", mesh=mesh, scores=["energy_deposition"]) - -# Settings -mcdc.settings.set_transported_particles(["proton"]) -mcdc.settings.N_particle = 1_000 -mcdc.settings.N_batch = 1 -mcdc.settings.csda = True -mcdc.settings.csda_max_fractional_e_loss = 0.001 - -# Techniques -mcdc.simulation.implicit_capture() - -# Run -mcdc.run() diff --git a/examples/proton_beam/input_10.py b/examples/proton_beam/input_10.py deleted file mode 100644 index b63de3b84..000000000 --- a/examples/proton_beam/input_10.py +++ /dev/null @@ -1,97 +0,0 @@ -import numpy as np -import mcdc - -# ====================================================================================== -# Set model -# ====================================================================================== -# Proton beam, incident on a slab - -# Set materials (atom density in units of atoms/barn-cm) -silicon = mcdc.Material("silicon", {"Si28": 0.05}) - -# Set surfaces -sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") -sx2 = mcdc.Surface.PlaneX(x=0.1, boundary_condition="vacuum") -sy1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") -sy2 = mcdc.Surface.PlaneY(y=0.1, boundary_condition="vacuum") -sz1 = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") -sz2 = mcdc.Surface.PlaneZ(z=0.1, boundary_condition="vacuum") - -slab = +sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2 - -# Set cells -slab_cell = mcdc.Cell(name="silicon", region=slab, fill=silicon) - -# ====================================================================================== -# Set source -# ====================================================================================== - -mcdc.Source( - x=[0.0, 0.0], - y=[0.0, 0.0002], - z=[0.0, 0.0002], - direction=[1.0, 0.0, 0.0], - energy=1e7, - # energy_group=0, - particle_type="proton", - # time=[0.0, 0.0], -) - -# ====================================================================================== -# Set tallies, settings, techniques, and run MC/DC -# ====================================================================================== - -# Tallies -percent_of_range = np.array( - [ - 0.0, - 10, - 20, - 30, - 40, - 50, - 60, - 70, - 80, - 85, - 90, - 91, - 92, - 93, - 94, - 95, - 96, - 97, - 98, - 99, - 100, - 101, - 102, - 103, - 104, - 105, - 107.5, - 110, - 115, - ] -) -range = 714.59 * 1e-4 # cm - -bin_edges = range * percent_of_range * 1e-2 - -# mesh = mcdc.MeshUniform(x=(0.0, 0.002, 50)) -mesh = mcdc.MeshStructured(x=(bin_edges)) -mcdc.Tally(name=f"edep", mesh=mesh, scores=["energy_deposition"]) - -# Settings -mcdc.settings.set_transported_particles(["proton"]) -mcdc.settings.N_particle = 10_000 -mcdc.settings.N_batch = 1 -mcdc.settings.csda = True -mcdc.settings.csda_max_fractional_e_loss = 0.001 - -# Techniques -mcdc.simulation.implicit_capture() - -# Run -mcdc.run() diff --git a/examples/proton_beam/test.py b/examples/proton_beam/test.py deleted file mode 100644 index ce1f8f2fa..000000000 --- a/examples/proton_beam/test.py +++ /dev/null @@ -1,23 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import sys - -isotope = sys.argv[1] - -with h5py.File(f"../../proton_generated_lib/{isotope}-293.6K.h5") as f: - print(f'atomic number = {f["atomic_number"][()]}') - print(f'atomic weight ratio = {f["atomic_weight_ratio"][()]}') - print(f'fissionable = {f["fissionable"][()]}') - print(f'nuclide name = {f["nuclide_name"][()]}') - - elastic_xs = f["proton_reactions"]["elastic_scattering"]["MT-002"]["xs"][()] - inelastic_xs = f["proton_reactions"]["inelastic_scattering"]["MT-005"]["xs"][()] - - plt.plot(elastic_xs, label="elastic") - plt.plot(inelastic_xs, label="inelastic") - plt.legend() - plt.yscale("log") - plt.xscale("log") - plt.xlabel("Incident Energy (MeV)") - plt.ylabel("Cross Section") - plt.savefig(f"{isotope}_xs.png") diff --git a/tools/data_library_generator/proton_generate.py b/tools/data_library_generator/endf70prot_generate.py similarity index 100% rename from tools/data_library_generator/proton_generate.py rename to tools/data_library_generator/endf70prot_generate.py diff --git a/tools/data_library_generator/parse_endf70prot.py b/tools/data_library_generator/parse_endf70prot.py index f62b45888..da79cff2b 100644 --- a/tools/data_library_generator/parse_endf70prot.py +++ b/tools/data_library_generator/parse_endf70prot.py @@ -1,8 +1,8 @@ # This script was written by ChatGPT with Ethan Lame's instructions import os -input_file = "/home/ethan_lame/MCDC/acelib/endf70prot" # your big file -output_dir = "/home/ethan_lame/MCDC/acelib/" # where split files go +input_file = "/home/ethan_lame/MCDC/acelib/endf70prot" # your big file +output_dir = "/home/ethan_lame/MCDC/acelib/" # where split files go os.makedirs(output_dir, exist_ok=True) @@ -31,4 +31,4 @@ # Close last file if current_file is not None: - current_file.close() \ No newline at end of file + current_file.close() diff --git a/tools/data_library_generator/tendl_generate_v2.py b/tools/data_library_generator/tendl_generate_v2.py deleted file mode 100644 index fec0d4d17..000000000 --- a/tools/data_library_generator/tendl_generate_v2.py +++ /dev/null @@ -1,913 +0,0 @@ -# The majority of this script was written by Anthropic's Claude - -""" -ace_to_hdf5.py -============== -Convert a directory of proton ACE files (e.g. TENDL) into per-nuclide HDF5 files -suitable for use in MC/DC or similar Monte Carlo transport codes. - -Usage ------ - python ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 - python ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 --rewrite - python ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 --verbose - -Environment variable fallback (compatible with original MC/DC conventions): - $MCDC_ACELIB → ace_dir - $MCDC_LIB → output_dir - -HDF5 layout produced --------------------- -/-K.h5 - attrs: - source_title, source_version, source_date - nuclide_name (str) - excitation_level (int) - temperature (float, K) - atomic_number (int) - atomic_weight_ratio (float) - fissionable (bool) - - proton_reactions/ - xs_energy_grid (float array, MeV) - - elastic_scattering/ - MT-002/ - xs (float array, barns) attrs: offset, unit - Q-value (float, MeV) - reference_frame (str: "COM") - angular_cosine_distribution/ (tabulated cosine distributions) - - capture/ - MT-{NNN}/ - xs, Q-value, reference_frame - - nonelastic_reaction/ - MT-{NNN}/ - xs, Q-value, reference_frame - multiplicity (int) - angular_cosine_distribution/ - energy_spectrum-{k}/ (one per distribution in a MultiDistributionData) - - fission/ (only if fissionable) - ... - - secondary_particles/ - ZAP_{zap}/ - attrs: ZAP (int), particle_name (str) - MT-{NNN}/ - attrs: MT (int), multiplicity (int), reference_frame (str) - production_xs (float array, barns) attrs: offset, unit - kalbach_mann/ - incident_energies (float array, MeV) - interpolation_boundaries (int array) - interpolation_types (int array) - E_in_{k}/ (one group per incident energy point) - outgoing_energies (float array, MeV) - pdf (float array) - cdf (float array) - r (float array) Kalbach-Mann precompound fraction - a (float array) Kalbach-Mann slope parameter - -Notes ------ -* The Kalbach-Mann property names on TabulatedKalbachMannDistribution are - introspected at runtime the first time a distribution is encountered, so - this script will work even if ACEtk renames them between versions. -* ZAP particle identity: 1=n, 31=p, 32=d, 33=t, 34=alpha -""" - -import argparse -import os -import sys - -import h5py -import numpy as np -from tqdm import tqdm - -import ACEtk - -# ────────────────────────────────────────────────────────────────────────────── -# Helpers -# ────────────────────────────────────────────────────────────────────────────── - -# TODO: THIS IS UNCERTAIN - NEED TO VERIFY ZAP NUMBERS/PARTICLE TYPE CORRESPONDANCE - -ZAP_NAMES = { - 0: "photon", - 1: "neutron", - 31: "proton", - 32: "deuteron", - 33: "triton", - 34: "alpha", -} - -# Candidate property names for TabulatedKalbachMannDistribution fields. -# We try each list in order and use the first one that exists on the object. -_KM_CANDIDATES = { - "outgoing_energies": ["outgoing_energies", "energies", "energy"], - "pdf": ["pdf", "probabilities", "probability_density"], - "cdf": ["cdf", "cumulative_probabilities", "cumulative_distribution"], - "r": ["precompound_fraction_values", "precompound_fractions", "r", "R"], - "a": ["angular_distribution_slope_values", "slopes", "a", "A"], -} -# Cache resolved names so introspection only happens once. -_km_resolved: dict[str, str] = {} - - -def _resolve_km_attr(dist_obj, field: str) -> str: - """Return the actual attribute name on dist_obj for the given logical field.""" - if field in _km_resolved: - return _km_resolved[field] - for candidate in _KM_CANDIDATES[field]: - if hasattr(dist_obj, candidate): - _km_resolved[field] = candidate - return candidate - raise AttributeError( - f"Cannot find attribute for '{field}' on " - f"{type(dist_obj).__name__}. " - f"Tried: {_KM_CANDIDATES[field]}. " - f"Available: {[x for x in dir(dist_obj) if not x.startswith('_')]}" - ) - - -def get_km_field(dist_obj, field: str): - """Get a logical Kalbach-Mann field from a TabulatedKalbachMannDistribution.""" - attr = _resolve_km_attr(dist_obj, field) - return getattr(dist_obj, attr) - - -def print_error(msg: str): - print(f"\n[ERROR] {msg}", file=sys.stderr) - sys.exit(1) - - -def print_note(msg: str): - print(f" [note] {msg}") - - -# ────────────────────────────────────────────────────────────────────────────── -# ZAP / name decoding -# ────────────────────────────────────────────────────────────────────────────── - -# Periodic table symbol lookup (Z → symbol) -Z_TO_SYMBOL = { - 1: "H", 2: "He", 3: "Li", 4: "Be", 5: "B", 6: "C", 7: "N", 8: "O", - 9: "F", 10: "Ne",11: "Na",12: "Mg",13: "Al",14: "Si",15: "P", 16: "S", - 17:"Cl", 18:"Ar", 19:"K", 20:"Ca", 21:"Sc", 22:"Ti", 23:"V", 24:"Cr", - 25:"Mn", 26:"Fe", 27:"Co", 28:"Ni", 29:"Cu", 30:"Zn", 31:"Ga", 32:"Ge", - 33:"As", 34:"Se", 35:"Br", 36:"Kr", 37:"Rb", 38:"Sr", 39:"Y", 40:"Zr", - 41:"Nb", 42:"Mo", 43:"Tc", 44:"Ru", 45:"Rh", 46:"Pd", 47:"Ag", 48:"Cd", - 49:"In", 50:"Sn", 51:"Sb", 52:"Te", 53:"I", 54:"Xe", 55:"Cs", 56:"Ba", - 57:"La", 58:"Ce", 59:"Pr", 60:"Nd", 61:"Pm", 62:"Sm", 63:"Eu", 64:"Gd", - 65:"Tb", 66:"Dy", 67:"Ho", 68:"Er", 69:"Tm", 70:"Yb", 71:"Lu", 72:"Hf", - 73:"Ta", 74:"W", 75:"Re", 76:"Os", 77:"Ir", 78:"Pt", 79:"Au", 80:"Hg", - 81:"Tl", 82:"Pb", 83:"Bi", 84:"Po", 85:"At", 86:"Rn", 87:"Fr", 88:"Ra", - 89:"Ac", 90:"Th", 91:"Pa", 92:"U", 93:"Np", 94:"Pu", 95:"Am", 96:"Cm", - 97:"Bk", 98:"Cf", 99:"Es",100:"Fm",101:"Md",102:"No",103:"Lr", -} - - -def decode_ace_zaid(zaid: str): - """ - Decode an ACE ZAID string into (Z, A, S, T). - Handles both legacy '1001.70h' and modern '1001.710h' style ZAIDs. - Returns Z (atomic number), A (mass number), S (isomeric state), T (temperature K). - """ - # Strip trailing whitespace and split on '.' - parts = zaid.strip().split(".") - za_str = parts[0] - # ZA = Z*1000 + A, possibly with S encoded as ZA > 600000 (isomers) - za = int(za_str) - if za >= 600000: - # metastable: ZAID = Z*1000 + A + S*400 (legacy MCNP convention, approximate) - S = (za % 1000) // 400 # rough extraction - za = za - S * 400 - else: - S = 0 - Z = za // 1000 - A = za % 1000 - - # Temperature from suffix, e.g. '70h' → 293 K, '710h' → custom - # The conventional mapping is suffix_number * ~(1/100) * some factor. - # Most TENDL proton files just use a nominal 0K or room temperature. - # Use the header temperature value instead (set to 0 as default here). - T = 0 - return Z, A, S, T - - -# ────────────────────────────────────────────────────────────────────────────── -# Angular distribution loading (from original MC/DC approach) -# ────────────────────────────────────────────────────────────────────────────── - -def load_cosine_distribution(data, h5_group): - """ - Write a tabulated angular (cosine) distribution into an HDF5 group. - data is an AngularDistributionData object from ACEtk. - - Returns True if angular data was written, False if it is encoded - elsewhere (i.e. inside the Kalbach-Mann energy distribution block). - """ - # DistributionGivenElsewhere means the angular data is embedded in the - # LAW 44 Kalbach-Mann energy distribution via the r and a parameters. - # There is nothing to store here — the sampling code must use the - # Kalbach-Mann block instead. - if isinstance(data, ACEtk.continuous.DistributionGivenElsewhere): - h5_group.attrs["type"] = "given_in_energy_distribution" - return False - - energies = np.array(data.incident_energies) - h5_group.create_dataset("incident_energies", data=energies) - h5_group.attrs["unit"] = "MeV" - # Set type on root group (default to tabulated if we get here) - h5_group.attrs["type"] = "tabulated" - - for i, subdist in enumerate(data.distributions): - eg = h5_group.create_group(f"E_in_{i + 1}") - if isinstance(subdist, ACEtk.continuous.TabulatedAngularDistribution): - eg.attrs["type"] = "tabulated" - eg.create_dataset("cosines", data=np.array(subdist.cosines)) - eg.create_dataset("pdf", data=np.array(subdist.pdf)) - eg.create_dataset("cdf", data=np.array(subdist.cdf)) - else: - # Isotropic or unsupported — mark it so sampling code knows - eg.attrs["type"] = "isotropic" - - return True - - -# ────────────────────────────────────────────────────────────────────────────── -# Energy distribution loading (neutron/primary particle, existing reactions) -# ────────────────────────────────────────────────────────────────────────────── - -def load_energy_distribution(data, h5_group): - """ - Write a primary-particle outgoing energy distribution into an HDF5 group. - Handles the most common ACE law types encountered in proton libraries. - """ - if isinstance(data, ACEtk.continuous.KalbachMannDistributionData): - h5_group.attrs["law"] = 44 - _write_kalbach_mann(data, h5_group) - - elif isinstance(data, ACEtk.continuous.OutgoingEnergyDistributionData): - h5_group.attrs["law"] = 4 - h5_group.create_dataset( - "incident_energies", data=np.array(data.incident_energies) - ) - for k, dist in enumerate(data.distributions): - eg = h5_group.create_group(f"E_in_{k + 1}") - eg.create_dataset("outgoing_energies", data=np.array(dist.outgoing_energies)) - eg.create_dataset("pdf", data=np.array(dist.pdf)) - eg.create_dataset("cdf", data=np.array(dist.cdf)) - - elif isinstance(data, ACEtk.continuous.LevelScatteringData): - h5_group.attrs["law"] = 3 - h5_group.create_dataset("C1", data=data.C1) - h5_group.create_dataset("C2", data=data.C2) - - elif isinstance(data, ACEtk.continuous.EquiprobableOutgoingEnergyBins): - h5_group.attrs["law"] = 1 - h5_group.create_dataset( - "incident_energies", data=np.array(data.incident_energies) - ) - for k, dist in enumerate(data.distributions): - eg = h5_group.create_group(f"E_in_{k + 1}") - eg.create_dataset("energies", data=np.array(dist.energies)) - - else: - # Unknown law — store the raw XSS array so nothing is silently lost - h5_group.attrs["law"] = -1 - h5_group.attrs["type_name"] = type(data).__name__ - try: - h5_group.create_dataset("xss_array", data=np.array(data.xss_array)) - except Exception: - pass - - -def _write_kalbach_mann(km_data, h5_group): - """ - Write a KalbachMannDistributionData block into an open HDF5 group. - Uses MCDC-compatible format with flattened arrays and offset indices. - """ - h5_group.attrs["type"] = "kalbach-mann" - - NE = km_data.number_incident_energies - - # Incident energy grid - energy = np.array(km_data.incident_energies) - energy_ds = h5_group.create_dataset("energy", data=energy) - energy_ds.attrs["unit"] = "MeV" - - # Collect all outgoing energy points and build offset array - offset = np.zeros(NE, dtype=np.int32) - energy_out = [] - pdf = [] - precompound_factor = [] - angular_slope = [] - - for i in range(1, NE + 1): - dist = km_data.distribution(i) - offset[i - 1] = len(pdf) - energy_out.extend(get_km_field(dist, "outgoing_energies")) - pdf.extend(get_km_field(dist, "pdf")) - precompound_factor.extend(get_km_field(dist, "r")) - angular_slope.extend(get_km_field(dist, "a")) - - # Create flattened datasets - h5_group.create_dataset("offset", data=offset) - energy_out_ds = h5_group.create_dataset("energy_out", data=np.array(energy_out)) - energy_out_ds.attrs["unit"] = "MeV" - h5_group.create_dataset("pdf", data=np.array(pdf)) - h5_group.create_dataset("precompound_factor", data=np.array(precompound_factor)) - h5_group.create_dataset("angular_slope", data=np.array(angular_slope)) - - -# ────────────────────────────────────────────────────────────────────────────── -# Fission multiplicity loading -# ────────────────────────────────────────────────────────────────────────────── - -def load_fission_multiplicity(data, h5_group): - if isinstance(data, ACEtk.continuous.TabulatedFissionMultiplicity): - h5_group.attrs["type"] = "tabulated" - h5_group.create_dataset("energies", data=np.array(data.energies)) - h5_group.create_dataset("multiplicities", data=np.array(data.multiplicities)) - elif isinstance(data, ACEtk.continuous.PolynomialFissionMultiplicity): - h5_group.attrs["type"] = "polynomial" - h5_group.create_dataset("coefficients", data=np.array(data.coefficients)) - else: - h5_group.attrs["type"] = "unknown" - h5_group.attrs["type_name"] = type(data).__name__ - - -# ────────────────────────────────────────────────────────────────────────────── -# Secondary particle block extraction -# ────────────────────────────────────────────────────────────────────────────── - -def load_secondary_particles(ace_table, file, verbose=False): - """ - Extract all secondary particle production data from a proton ACE table - and write it into file['secondary_particles/ZAP_{zap}/MT-{MT:03}/...']. - """ - n_types = ace_table.number_secondary_particle_types - if n_types == 0: - return - - # ── Top-level block handles ─────────────────────────────────────────────── - # The secondary particle blocks are callable by type index — rx_block(i) - # returns the ReactionNumberBlock for type i, tyr_block(i) returns the - # FrameAndMultiplicityBlock for type i, etc. - type_block = ace_table.secondary_particle_type_block - info_block = ace_table.secondary_particle_information_block - rx_block = ace_table.secondary_particle_reaction_number_block - tyr_block = ace_table.secondary_particle_frame_and_multiplicity_block - xs_block = ace_table.secondary_particle_production_cross_section_block - edy_block = ace_table.secondary_particle_energy_distribution_block - - # angular block is optional for secondary particles in some libraries - try: - ang_block = ace_table.secondary_particle_angular_distribution_block - has_ang = True - except Exception: - has_ang = False - - sec_group = file.create_group("secondary_particles") - - # ── Introspect particle_identifier method name once ─────────────────────── - _pi_candidates = ["particle_identifier", "ZAP", "type", "particle_type"] - _pi_method = None - for cand in _pi_candidates: - if hasattr(type_block, cand): - _pi_method = cand - break - if _pi_method is None: - raise AttributeError( - f"Cannot find particle identifier method on " - f"{type(type_block).__name__}. " - f"Available: {[x for x in dir(type_block) if not x.startswith('_')]}" - ) - - # ── Loop over secondary particle types ──────────────────────────────────── - for i in range(1, n_types + 1): - - zap = getattr(type_block, _pi_method)(i) - name = ZAP_NAMES.get(zap, f"ZAP_{zap}") - - # number_reactions is a sequence property on info_block, 0-based - n_rx = int(info_block.number_reactions[i - 1]) - - if verbose: - print(f" Secondary particle type {i}: ZAP={zap} ({name}), " - f"{n_rx} reactions") - - zap_group = sec_group.create_group(f"ZAP_{zap}") - zap_group.attrs["ZAP"] = zap - zap_group.attrs["particle_name"] = name - - # Per-type sub-blocks: call the top-level block with the type index - # to get the per-type block, then call methods on that. - rx_i = rx_block(i) # ReactionNumberBlock for type i - tyr_i = tyr_block(i) # FrameAndMultiplicityBlock for type i - xs_i = xs_block(i) # production cross section block for type i - edy_i = edy_block(i) # energy distribution block for type i - ang_i = ang_block(i) if has_ang else None - - # Introspect xs sub-block method names (once, on first type) - _xs_candidates = [ - "production_xs", - "production_cross_sections", - "cross_sections", - "cross_section", - "cross_section_values", - "xs", - "xss", - ] - _off_candidates = ["energy_index", "offset", "locator", "index"] - _xs_method = next((c for c in _xs_candidates if hasattr(xs_i, c)), None) - _off_method = next((c for c in _off_candidates if hasattr(xs_i, c)), None) - - # Introspect energy distribution method name - _edy_candidates = ["energy_distribution_data", "distribution_data", "distribution"] - _edy_method = next((c for c in _edy_candidates if hasattr(edy_i, c)), None) - - for j in range(1, n_rx + 1): - - MT = rx_i.MT(j) - - # ── Multiplicity ───────────────────────────────────────────────── - nu_raw = tyr_i.multiplicity(j) - nu = nu_raw - 100 if nu_raw >= 100 else nu_raw - - # ── Reference frame ─────────────────────────────────────────────── - rf_raw = tyr_i.reference_frame(j) - if rf_raw == ACEtk.ReferenceFrame.Laboratory: - rf = "LAB" - elif rf_raw == ACEtk.ReferenceFrame.CentreOfMass: - rf = "COM" - else: - rf = str(rf_raw) - - mt_group = zap_group.create_group(f"MT-{MT:03}") - mt_group.attrs["MT"] = MT - mt_group.attrs["multiplicity"] = nu - mt_group.attrs["reference_frame"] = rf - - if verbose: - print(f" MT={MT:03} nu_raw={nu_raw} nu={nu} frame={rf}") - - # ── Production cross section ────────────────────────────────────── - if _xs_method and _off_method: - try: - xs_vals = np.array(getattr(xs_i, _xs_method)(j)) - xs_offset = int(getattr(xs_i, _off_method)(j)) - xs_ds = mt_group.create_dataset("production_xs", data=xs_vals) - xs_ds.attrs["offset"] = xs_offset - 1 # convert to 0-based - xs_ds.attrs["unit"] = "barns" - except Exception as exc: - xs_ds = mt_group.create_dataset("production_xs", data=np.zeros(0, dtype=float)) - xs_ds.attrs["offset"] = 0 - xs_ds.attrs["unit"] = "barns" - if verbose: - print(f" [warn] Could not read production xs: {exc}") - else: - xs_ds = mt_group.create_dataset("production_xs", data=np.zeros(0, dtype=float)) - xs_ds.attrs["offset"] = 0 - xs_ds.attrs["unit"] = "barns" - if verbose: - print(f" [warn] production xs block methods not resolved: " - f"{[x for x in dir(xs_i) if not x.startswith('_')]}") - - # ── Kalbach-Mann energy-angle distribution ──────────────────────── - if _edy_method: - try: - km_data = getattr(edy_i, _edy_method)(j) - km_group = mt_group.create_group("kalbach_mann") - _write_kalbach_mann(km_data, km_group) - except Exception as exc: - if verbose: - print(f" [warn] Could not read energy distribution: {exc}") - else: - if verbose: - print(f" [warn] energy distribution method not resolved: " - f"{[x for x in dir(edy_i) if not x.startswith('_')]}") - - # ── Angular distribution (if present) ──────────────────────────── - if ang_i is not None: - try: - ang_data = ang_i.angular_distribution_data(j) - ang_group = mt_group.create_group("angular_cosine_distribution") - load_cosine_distribution(ang_data, ang_group) - except Exception: - pass # not all secondary types have explicit angular data - - -# ────────────────────────────────────────────────────────────────────────────── -# Per-file processing -# ────────────────────────────────────────────────────────────────────────────── - -def process_ace_file(ace_path: str, output_dir: str, verbose: bool = False) -> str: - """ - Convert a single ACE proton file to HDF5. Returns the output filename. - """ - - # ── Header ──────────────────────────────────────────────────────────────── - with open(ace_path, "r") as f: - header = ACEtk.Header.from_string(f.readline()) - - Z, A, S, T = decode_ace_zaid(header.zaid) - symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") - nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" - - # Get temperature from the table itself (more reliable than ZAID suffix) - ace_table = ACEtk.ContinuousEnergyTable.from_file(ace_path) - T_kelvin = float(ace_table.temperature) if hasattr(ace_table, "temperature") else T - - # Forcing to be room temperature, as 0K from the file is a placeholder - T_kelvin = 293.6 - - mcdc_name = f"{nuclide_name}-{T_kelvin}K.h5" - out_path = os.path.join(output_dir, mcdc_name) - - if verbose: - print(f"\n{'='*80}") - print(f" {os.path.basename(ace_path)} → {mcdc_name}") - print(f" Z={Z} A={A} S={S} T={T_kelvin} K") - - file = h5py.File(out_path, "w") - - # ── Basic metadata ──────────────────────────────────────────────────────── - hdr = ace_table.header - file.attrs["source_title"] = hdr.title - file.attrs["source_version"] = hdr.version - file.attrs["source_date"] = hdr.date - if hasattr(hdr, "comments"): - file.attrs["source_comments"] = hdr.comments - - file.create_dataset("nuclide_name", data=nuclide_name) - file.create_dataset("excitation_level", data=S) - ds = file.create_dataset("temperature", data=T_kelvin) - ds.attrs["unit"] = "K" - file.create_dataset("atomic_number", data=ace_table.atom_number) - file.create_dataset("atomic_weight_ratio", data=ace_table.atomic_weight_ratio) - - fissionable = ace_table.fission_multiplicity_block is not None - file.create_dataset("fissionable", data=fissionable) - - # ── Reaction classification ─────────────────────────────────────────────── - proton_reactions = file.create_group("proton_reactions") - - nu_block = ace_table.frame_and_multiplicity_block - rx_block = ace_table.reaction_number_block - N_reaction = nu_block.number_reactions - - elastic_group = proton_reactions.create_group("elastic_scattering") - capture_group = proton_reactions.create_group("capture") - nonelastic_group = proton_reactions.create_group("nonelastic_reaction") - fission_group = proton_reactions.create_group("fission") - - elastic_MTs = [2] - capture_MTs = [] - nonelastic_MTs = [] - fission_MTs = [] - - fission_chance_MTs = [19, 20, 21, 38] - # Genuine redundant sum MTs — do not double-count these - redundant_MTs = [1, 3, 4, 10, 101, 103, 104, 105, 106, 107] - - total_fission_given = rx_block.has_MT(18) - if total_fission_given: - fission_MTs = [18] - else: - for MT in fission_chance_MTs: - if rx_block.has_MT(MT): - fission_MTs.append(MT) - - for i in range(N_reaction): - idx = i + 1 - MT = rx_block.MT(idx) - - if MT in redundant_MTs + elastic_MTs + fission_MTs: - continue - if MT > 891: # above the defined charged-particle range - continue - - nu_raw = nu_block.multiplicity(idx) - if not isinstance(nu_raw, int): - print_error(f"Non-integer multiplicity for MT-{MT:03} in {ace_path}") - - nu = nu_raw - 100 if nu_raw >= 100 else nu_raw - - if nu == 0: - capture_MTs.append(MT) - elif nu > 0: - nonelastic_MTs.append(MT) - else: - print_error(f"Negative decoded multiplicity for MT-{MT:03} in {ace_path}") - - # Create MT subgroups - for rx_group, rx_MTs in [ - (elastic_group, elastic_MTs), - (capture_group, capture_MTs), - (nonelastic_group, nonelastic_MTs), - (fission_group, fission_MTs), - ]: - for MT in rx_MTs: - g = rx_group.create_group(f"MT-{MT:03}") - g.attrs["MT"] = MT - - if verbose: - print(f" Elastic: {elastic_MTs}") - print(f" Capture: {capture_MTs}") - print(f" Nonelastic: {nonelastic_MTs}") - if fissionable: - print(f" Fission: {fission_MTs}") - - # Remove empty groups - if not fissionable: - del file["proton_reactions/fission"] - if len(nonelastic_MTs) == 0: - del file["proton_reactions/nonelastic_reaction"] - - # ── Cross sections ──────────────────────────────────────────────────────── - xs0_block = ace_table.principal_cross_section_block - xs_block_main = ace_table.cross_section_block - - xs_energy = np.array(xs0_block.energies) - ds = proton_reactions.create_dataset("xs_energy_grid", data=xs_energy) - ds.attrs["unit"] = "MeV" - - xs_ds = elastic_group.create_dataset("MT-002/xs", data=np.array(xs0_block.elastic)) - xs_ds.attrs["offset"] = 0 - xs_ds.attrs["unit"] = "barns" - - for MTs, group in [ - (capture_MTs, capture_group), - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: - if group is None: - continue - for MT in MTs: - idx = rx_block.index(MT) - xs_ds = group.create_dataset( - f"MT-{MT:03}/xs", - data=np.array(xs_block_main.cross_sections(idx)) - ) - xs_ds.attrs["offset"] = xs_block_main.energy_index(idx) - 1 - xs_ds.attrs["unit"] = "barns" - - # ── Q-values ────────────────────────────────────────────────────────────── - q_block = ace_table.reaction_qvalue_block - - elastic_group.create_dataset("MT-002/Q-value", data=0.0).attrs["unit"] = "MeV" - - for MTs, group in [ - (capture_MTs, capture_group), - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: - if group is None: - continue - for MT in MTs: - idx = rx_block.index(MT) - ds = group.create_dataset( - f"MT-{MT:03}/Q-value", data=q_block.q_value(idx) - ) - ds.attrs["unit"] = "MeV" - - # ── Reference frames ────────────────────────────────────────────────────── - elastic_group.create_dataset("MT-002/reference_frame", data="COM") - - for MTs, group in [ - (capture_MTs, capture_group), - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: - if group is None: - continue - for MT in MTs: - idx = rx_block.index(MT) - rf = nu_block.reference_frame(idx) - rf_str = ( - "LAB" if rf == ACEtk.ReferenceFrame.Laboratory else - "COM" if rf == ACEtk.ReferenceFrame.CentreOfMass else - str(rf) - ) - group.create_dataset(f"MT-{MT:03}/reference_frame", data=rf_str) - - # ── Nonelastic reaction multiplicities ───────────────────────────────────── - for MT in nonelastic_MTs: - idx = rx_block.index(MT) - nu_raw = nu_block.multiplicity(idx) - nu = nu_raw - 100 if nu_raw >= 100 else nu_raw - nonelastic_group.create_dataset(f"MT-{MT:03}/multiplicity", data=nu) - - # ── Angular distributions ───────────────────────────────────────────────── - angle_block = ace_table.angular_distribution_block - - ang_group = elastic_group.create_group("MT-002/angular_cosine_distribution") - ang_group.attrs["type"] = "energy-correlated" - data = angle_block.angular_distribution_data(0) - written = load_cosine_distribution(data, ang_group) - if not written and verbose: - print_note("MT-002 elastic angular distribution is given in energy block") - - for MTs, group in [ - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: - if group is None: - continue - for MT in MTs: - idx = rx_block.index(MT) - ang_group = group.create_group(f"MT-{MT:03}/angular_cosine_distribution") - data = angle_block.angular_distribution_data(idx) - written = load_cosine_distribution(data, ang_group) - if not written and verbose: - print_note(f"MT-{MT:03} angular distribution is given in energy block") - - # ── Primary energy distributions ────────────────────────────────────────── - energy_block = ace_table.energy_distribution_block - - for MTs, group in [ - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: - if group is None: - continue - for MT in MTs: - idx = rx_block.index(MT) - data = energy_block.energy_distribution_data(idx) - - if not isinstance(data, ACEtk.continuous.MultiDistributionData): - eg = group.create_group(f"MT-{MT:03}/energy_spectrum-1") - group.create_dataset( - f"MT-{MT:03}/spectrum_probability_grid", - data=np.array([0.0, 30.0]) - ).attrs["unit"] = "MeV" - group.create_dataset( - f"MT-{MT:03}/spectrum_probability", - data=np.array([[1.0]]) - ) - load_energy_distribution(data, eg) - else: - N_dist = data.number_distributions - # Probability grid - if all(np.array([x.number_interpolation_regions - for x in data.probabilities]) == 0): - prob_grid = np.array([0.0, 30.0]) - prob = np.zeros((1, N_dist)) - for k in range(N_dist): - prob[0, k] = max(data.probability(k + 1).probabilities) - elif (all(np.array([x.number_interpolation_regions - for x in data.probabilities]) == 1) - and all(np.array([x.interpolants - for x in data.probabilities]) == 1)): - prob_grid = np.array(data.probability(1).energies) - prob = np.zeros((len(prob_grid) - 1, N_dist)) - for k in range(N_dist): - prob[:, k] = np.array( - data.probability(k + 1).probabilities[:-1] - ) - else: - print_error(f"Unsupported multi-distribution probability for " - f"MT-{MT:03} in {ace_path}") - - group.create_dataset( - f"MT-{MT:03}/spectrum_probability_grid", data=prob_grid - ).attrs["unit"] = "MeV" - group.create_dataset( - f"MT-{MT:03}/spectrum_probability", data=prob - ) - for k in range(N_dist): - eg = group.create_group(f"MT-{MT:03}/energy_spectrum-{k+1}") - load_energy_distribution(data.distribution(k + 1), eg) - - # ── Secondary particles ─────────────────────────────────────────────────── - load_secondary_particles(ace_table, file, verbose=verbose) - - # ── Fission data (if applicable) ────────────────────────────────────────── - if fissionable: - prompt_block = ace_table.fission_multiplicity_block - delayed_block = ace_table.delayed_fission_multiplicity_block - dnp_block = ace_table.delayed_neutron_precursor_block - - h5g = fission_group.create_group("prompt_multiplicity") - load_fission_multiplicity(prompt_block.multiplicity, h5g) - - if delayed_block is not None: - h5g = fission_group.create_group("delayed_multiplicity") - load_fission_multiplicity(delayed_block.multiplicity, h5g) - - if dnp_block is not None: - N_DNP = dnp_block.number_delayed_precursors - fractions = np.zeros(N_DNP) - decay_rates = np.zeros(N_DNP) - for k in range(N_DNP): - d = dnp_block.precursor_group_data(k + 1) - if (d.number_interpolation_regions != 0 - or len(d.probabilities[:]) != 2 - or d.probabilities[0] != d.probabilities[1]): - print_error("Non-constant delayed neutron precursor fraction") - fractions[k] = d.probabilities[0] - decay_rates[k] = d.decay_constant - - prec = fission_group.create_group("delayed_neutron_precursors") - prec.create_dataset("fractions", data=fractions) - dr_ds = prec.create_dataset("decay_rates", data=decay_rates) - dr_ds.attrs["unit"] = "/s" - - delayed_spectrum_block = ace_table.delayed_neutron_energy_distribution_block - for k in range(N_DNP): - d = delayed_spectrum_block.energy_distribution_data(k + 1) - eg = prec.create_group(f"energy_spectrum-{k+1}") - load_energy_distribution(d, eg) - - file.close() - return mcdc_name - - -# ────────────────────────────────────────────────────────────────────────────── -# Main -# ────────────────────────────────────────────────────────────────────────────── - -def main(): - parser = argparse.ArgumentParser( - description="Convert proton ACE files to MC/DC-compatible HDF5" - ) - parser.add_argument("--ace_dir", default=os.getenv("MCDC_ACELIB"), - help="Directory containing ACE files " - "(default: $MCDC_ACELIB)") - parser.add_argument("--output_dir", default=os.getenv("MCDC_LIB"), - help="Output directory for HDF5 files " - "(default: $MCDC_LIB)") - parser.add_argument("--rewrite", action="store_true", default=False, - help="Rewrite existing HDF5 files") - parser.add_argument("--verbose", action="store_true", default=False, - help="Print detailed per-reaction info") - args = parser.parse_args() - - if args.ace_dir is None: - print_error("No ACE directory specified. Use --ace_dir or set $MCDC_ACELIB.") - if args.output_dir is None: - print_error("No output directory specified. Use --output_dir or set $MCDC_LIB.") - - os.makedirs(args.output_dir, exist_ok=True) - print(f"\nACE directory : {args.ace_dir}") - print(f"Output directory: {args.output_dir}\n") - - all_files = sorted(os.listdir(args.ace_dir)) - - # Filter to only unprocessed files unless --rewrite - if args.rewrite: - target_files = all_files - else: - target_files = [] - for fname in all_files: - ace_path = os.path.join(args.ace_dir, fname) - try: - with open(ace_path, "r") as f: - hdr = ACEtk.Header.from_string(f.readline()) - Z, A, S, _ = decode_ace_zaid(hdr.zaid) - symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") - nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" - # We don't know T yet without loading the full table, so check - # for any existing file matching the nuclide name pattern. - existing = [ - f for f in os.listdir(args.output_dir) - if f.startswith(nuclide_name + "-") - ] - if not existing: - target_files.append(fname) - except Exception: - target_files.append(fname) # include if we can't read header - - errors = [] - pbar = tqdm( - target_files, - disable=args.verbose, - bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} {postfix}", - ) - - for ace_name in pbar: - ace_path = os.path.join(args.ace_dir, ace_name) - pbar.set_postfix_str(ace_name) - try: - out = process_ace_file(ace_path, args.output_dir, verbose=args.verbose) - if args.verbose: - print(f" → wrote {out}") - except Exception as exc: - errors.append((ace_name, str(exc))) - if args.verbose: - import traceback - traceback.print_exc() - - print(f"\nDone. {len(target_files) - len(errors)} succeeded, " - f"{len(errors)} failed.") - if errors: - print("\nFailed files:") - for name, msg in errors: - print(f" {name}: {msg}") - - -if __name__ == "__main__": - main() \ No newline at end of file From c9166435825ec440a1b6e6b86d8fc9a04ee9bcd3 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 3 Jun 2026 14:16:53 -0700 Subject: [PATCH 22/64] cleaning up tools/data_library_generator --- .../endf70prot_generate.py | 484 ------------------ .../parse_endf70prot.py | 34 -- 2 files changed, 518 deletions(-) delete mode 100644 tools/data_library_generator/endf70prot_generate.py delete mode 100644 tools/data_library_generator/parse_endf70prot.py diff --git a/tools/data_library_generator/endf70prot_generate.py b/tools/data_library_generator/endf70prot_generate.py deleted file mode 100644 index 90058bf5c..000000000 --- a/tools/data_library_generator/endf70prot_generate.py +++ /dev/null @@ -1,484 +0,0 @@ -import argparse -import h5py -import numpy as np -import os -import ACEtk - -from tqdm import tqdm - -#### - -import util -from util import print_error, print_note - -parser = argparse.ArgumentParser(description="MC/DC data generator") -parser.add_argument("--rewrite", dest="rewrite", action="store_true", default=False) -parser.add_argument("--verbose", dest="verbose", action="store_true", default=False) -args, unargs = parser.parse_known_args() -rewrite = args.rewrite -verbose = args.verbose - -# Directories -output_dir = os.getenv("MCDC_LIB") -ace_dir = os.getenv("MCDC_ACELIB") - -if output_dir is None: - print_error("Environment variable $MCDC_LIB is not set") -if ace_dir is None: - print_error("Environment variable $MCDC_ACELIB is not set") - -# Create output directory if needed -os.makedirs(output_dir, exist_ok=True) -print(f"\nACE directory: {ace_dir}") -print(f"Output directory: {output_dir}\n") - -# Select the files -if rewrite: - target_files = os.listdir(ace_dir) -else: - target_files = [] - for file_name in os.listdir(ace_dir): - # File header - with open(f"{ace_dir}/{file_name}", "r") as f: - header = ACEtk.Header.from_string(f.readline()) - - # Decode ACE name to MC/DC name - Z, A, S, T = util.decode_ace_name(header.zaid) - symbol = util.Z_TO_SYMBOL[Z] - nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" - mcdc_name = f"{nuclide_name}-{T}K.h5" - - if not os.path.exists(f"{output_dir}/{mcdc_name}"): - target_files.append(file_name) - -# Loop over all files -pbar = tqdm( - target_files, - disable=verbose, - bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}{postfix}", -) -for ace_name in pbar: - # File header - with open(f"{ace_dir}/{ace_name}", "r") as f: - header = ACEtk.Header.from_string(f.readline()) - - # Decode ACE name to MC/DC name - Z, A, S, T = util.decode_ace_name(header.zaid) - symbol = util.Z_TO_SYMBOL[Z] - nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" - mcdc_name = f"{nuclide_name}-{T}K.h5" - - if not rewrite and os.path.exists(f"{output_dir}/{mcdc_name}"): - continue - - # Create MC/DC file - if verbose: - print("\n" + "=" * 80 + "\n") - print(f"Create {mcdc_name} from {ace_name}\n") - pbar.set_postfix_str(f"{mcdc_name[:-3]} from {ace_name}") - file = h5py.File(f"{output_dir}/{mcdc_name}", "w") - - # ================================================================================== - # Basic properties - # ================================================================================== - - # Load ACE tables - ace_table = ACEtk.ContinuousEnergyTable.from_file(f"{ace_dir}/{ace_name}") - - # ACE data source description - header = ace_table.header - file.attrs["source_title"] = header.title - file.attrs["source_version"] = header.version - file.attrs["source_date"] = header.date - if "comments" in dir(header): - file.attrs["source_comments"] = header.comments - - # Name and excitation level - file.create_dataset("nuclide_name", data=nuclide_name) - file.create_dataset("excitation_level", data=S) - - # Temperature - temperature = file.create_dataset("temperature", data=T) - temperature.attrs["unit"] = "K" - - # Atomic number and weight ratio - atomic_number = ace_table.atom_number - atomic_weight_ratio = ace_table.atomic_weight_ratio - file.create_dataset("atomic_number", data=atomic_number) - file.create_dataset("atomic_weight_ratio", data=atomic_weight_ratio) - - # Fissionable? - fissionable = ace_table.fission_multiplicity_block is not None - file.create_dataset("fissionable", data=fissionable) - - # ================================================================================== - # Reaction groups - # ================================================================================== - # Elastic scattering: MT=2 - # Capture: Reactions with zero multiplicity - # Fission: MT=18 or MT=(19, 20, 21, and 38) if given - # Inelastic: Non-fission reactions with non-zero multiplicity - # Ignored: MT=(1, 3, 4, 10) and MT>117 - - proton_reactions = file.create_group("proton_reactions") - - # ACE blocks - nu_block = ace_table.frame_and_multiplicity_block - rx_block = ace_table.reaction_number_block - N_reaction = nu_block.number_reactions - - if nu_block.number_reactions != rx_block.number_reactions: - print_error("Non-equal reaction number in reaction and multiplicity blocks") - - # The groups - elastic_group = proton_reactions.create_group("elastic_scattering") - capture_group = proton_reactions.create_group("capture") - inelastic_group = proton_reactions.create_group("inelastic_scattering") - fission_group = proton_reactions.create_group("fission") - - # MT groups - elastic_MTs = [2] - capture_MTs = [] - inelastic_MTs = [] - fission_MTs = [] - - # Redundant MTs - fission_chance_MTs = [19, 20, 21, 38] - redundant_MTs = [1, 3, 4, 10] - - # Set fission MTs - total_fission_given = rx_block.has_MT(18) - if total_fission_given: - fission_MTs = [18] - # The component should not be given - for MT in fission_chance_MTs: - if rx_block.has_MT(MT): - print_error("Both total fission and its components are given") - else: - for MT in fission_chance_MTs: - if rx_block.has_MT(MT): - fission_MTs.append(MT) - - # Capture and inelastic MTs - for i in range(N_reaction): - idx = i + 1 - MT = rx_block.MT(idx) - - if MT in redundant_MTs + elastic_MTs + fission_MTs or MT > 117: - continue - - nu = nu_block.multiplicity(idx) - - if type(nu) != int: - print_error(f"Non-integer multiplicity for inelastic scattering") - - if nu == 0: - capture_MTs.append(MT) - elif nu > 0: - inelastic_MTs.append(MT) - else: - print_error(f"Negative multiplicity for MT-{MT:03}") - - # Create MTs - for rx_group, rx_MTs in [ - (elastic_group, elastic_MTs), - (capture_group, capture_MTs), - (inelastic_group, inelastic_MTs), - (fission_group, fission_MTs), - ]: - for MT in rx_MTs: - MT_group = rx_group.create_group(f"MT-{MT:03}") - MT_group.attrs["MT"] = MT - - # Report MT groups - if verbose: - print(f" Reaction group MTs") - print(f" - Elastic scattering MTs: {elastic_MTs}") - print(f" - Capture MTs: {capture_MTs}") - print(f" - Inelastic scattering MTs: {inelastic_MTs}") - if fissionable: - print(f" - Fission MT: {fission_MTs}") - - # Delete empty groups - if not fissionable: - del file["proton_reactions/fission"] - if len(inelastic_MTs) == 0: - del file["proton_reactions/inelastic_scattering"] - - # ================================================================================== - # Cross-sections - # ================================================================================== - - xs0_block = ace_table.principal_cross_section_block - xs_block = ace_table.cross_section_block - - xs_energy = xs0_block.energies - xs_elastic = xs0_block.elastic - cross_sections = xs_block.cross_sections - offsets = xs_block.energy_index - - # Energy grid - xs_energy = np.array(xs_energy) - dataset = proton_reactions.create_dataset("xs_energy_grid", data=xs_energy) - dataset.attrs["unit"] = "MeV" - - # Elastic scattering - xs = elastic_group.create_dataset("MT-002/xs", data=xs_elastic) - xs.attrs["offset"] = 0 - xs.attrs["unit"] = "barns" - - # Capture, inelastic scattering, and fission - for MTs, group in [ - (capture_MTs, capture_group), - (inelastic_MTs, inelastic_group), - (fission_MTs, fission_group), - ]: - for MT in MTs: - idx = rx_block.index(MT) - xs = group.create_dataset(f"MT-{MT:03}/xs", data=cross_sections(idx)) - xs.attrs["offset"] = offsets(idx) - 1 - xs.attrs["unit"] = "barns" - - # ================================================================================== - # Q-value - # ================================================================================== - - q_value_block = ace_table.reaction_qvalue_block - - # Elastic scattering: zero Q-value - for MT in elastic_MTs: - dataset = elastic_group.create_dataset(f"MT-{MT:03}/Q-value", data=0.0) - dataset.attrs["unit"] = "MeV" - - for MTs, group in [ - (capture_MTs, capture_group), - (inelastic_MTs, inelastic_group), - (fission_MTs, fission_group), - ]: - for MT in MTs: - idx = rx_block.index(MT) - dataset = group.create_dataset( - f"MT-{MT:03}/Q-value", data=q_value_block.q_value(idx) - ) - dataset.attrs["unit"] = "MeV" - - # ================================================================================== - # Reference frames and inelastic scattering multiplicities - # ================================================================================== - # Elastic is always in COM frame (per ACE standard) - - # Elastic scattering reference frame - for MT in elastic_MTs: - elastic_group.create_dataset(f"MT-{MT:03}/reference_frame", data="COM") - - # Reference frames of the others - for MTs, group in [ - (capture_MTs, capture_group), - (inelastic_MTs, inelastic_group), - (fission_MTs, fission_group), - ]: - for MT in MTs: - idx = rx_block.index(MT) - reference_frame = nu_block.reference_frame(idx) - if reference_frame == ACEtk.ReferenceFrame.Laboratory: - reference_frame = "LAB" - elif reference_frame == ACEtk.ReferenceFrame.CentreOfMass: - reference_frame = "COM" - else: - print_error(f"Unknown reaction reference frame type for MT-{MT:03}") - group.create_dataset(f"MT-{MT:03}/reference_frame", data=reference_frame) - - # Inelastic multiplicity - for MT in inelastic_MTs: - idx = rx_block.index(MT) - nu = nu_block.multiplicity(idx) - inelastic_group.create_dataset(f"MT-{MT:03}/multiplicity", data=nu) - - # ================================================================================== - # Angular distributions - # ================================================================================== - - angle_block = ace_table.angular_distribution_block - - # Elastic scattering - angle_group = elastic_group.create_group("MT-002/angular_cosine_distribution") - data = angle_block.angular_distribution_data(0) - for subdata in data.distributions: - if not isinstance(subdata, ACEtk.continuous.TabulatedAngularDistribution): - print_error("Unsupported elastic scattering angular distribution") - util.load_cosine_distribution(data, angle_group) - - # Inelastic scattering and fission - for MTs, group in [ - (inelastic_MTs, inelastic_group), - (fission_MTs, fission_group), - ]: - for MT in MTs: - idx = rx_block.index(MT) - angle_group = group.create_group(f"MT-{MT:03}/angular_cosine_distribution") - data = angle_block.angular_distribution_data(idx) - util.load_cosine_distribution(data, angle_group) - - # ================================================================================== - # Energy distributions - # ================================================================================== - - energy_block = ace_table.energy_distribution_block - - for MTs, group in [ - (inelastic_MTs, inelastic_group), - (fission_MTs, fission_group), - ]: - for MT in MTs: - idx = rx_block.index(MT) - data = energy_block.energy_distribution_data(idx) - - if not isinstance(data, ACEtk.continuous.MultiDistributionData): - # Probabilities - dataset = group.create_dataset( - f"MT-{MT:03}/spectrum_probability_grid", data=np.array([0.0, 30.0]) - ) - dataset.attrs["unit"] = "MeV" - dataset = group.create_dataset( - f"MT-{MT:03}/spectrum_probability", data=np.array([[1.0]]) - ) - - # The distributions - energy_group = group.create_group(f"MT-{MT:03}/energy_spectrum-1") - util.load_energy_distribution(data, energy_group) - - else: - N_dist = data.number_distributions - - # ====================================================================== - # Probabilities - # ====================================================================== - - # Constant probability - if all( - np.array( - [x.number_interpolation_regions for x in data.probabilities] - ) - == 0 - ): - probability_grid = np.array([0.0, 30.0]) - probability = np.zeros((1, N_dist)) - for i in range(N_dist): - probability[0, i] = max(data.probability(i + 1).probabilities) - - # Histogram probability - elif all( - np.array( - [x.number_interpolation_regions for x in data.probabilities] - ) - == 1 - ) and all(np.array([x.interpolants for x in data.probabilities]) == 1): - probability_grid = np.array(data.probability(1).energies) - probability = np.zeros((len(probability_grid) - 1, N_dist)) - for i in range(N_dist): - if not all( - probability_grid - == np.array(data.probability(i + 1).energies) - ): - print_error("Unsupported multi-distribution energy spetrum") - probability[:, i] = np.array( - data.probability(i + 1).probabilities[:-1] - ) - - else: - print_error("Unsupported multi-distribution energy spetrum") - - dataset = group.create_dataset( - f"MT-{MT:03}/spectrum_probability_grid", data=probability_grid - ) - dataset.attrs["unit"] = "MeV" - dataset = group.create_dataset( - f"MT-{MT:03}/spectrum_probability", data=probability - ) - - # ====================================================================== - # The disributions - # ====================================================================== - - for i in range(N_dist): - energy_group = group.create_group( - f"MT-{MT:03}/energy_spectrum-{i+1}" - ) - distribution = data.distribution(i + 1) - util.load_energy_distribution(distribution, energy_group) - - # Fissionable zone below - if not fissionable: - continue - - # ================================================================================== - # Fission multiplicities and delayed neutron precursor fractions and decay rates - # ================================================================================== - - prompt_block = ace_table.fission_multiplicity_block - delayed_block = ace_table.delayed_fission_multiplicity_block - dnp_block = ace_table.delayed_neutron_precursor_block - - # Prompt multiplicity - data = prompt_block.multiplicity - h5_group = fission_group.create_group("prompt_multiplicity") - util.load_fission_multiplicity(data, h5_group) - - # Delayed multiplicity - if delayed_block is not None: - data = delayed_block.multiplicity - h5_group = fission_group.create_group("delayed_multiplicity") - util.load_fission_multiplicity(data, h5_group) - - # Delayed neutron precursor fractions and decay rates - if dnp_block is not None: - N_DNP = dnp_block.number_delayed_precursors - fractions = np.zeros(N_DNP) - decay_rates = np.zeros(N_DNP) - - for i in range(N_DNP): - idx = 1 + 1 - data = dnp_block.precursor_group_data(idx) - - if ( - not data.number_interpolation_regions == 0 - or not len(data.probabilities[:]) == 2 - or not data.probabilities[0] == data.probabilities[1] - ): - print_error("Non-constant delayed neutron precursor fraction") - - fractions[i] = data.probabilities[0] - decay_rates[i] = data.decay_constant - - precursors = fission_group.create_group("delayed_neutron_precursors") - precursors.create_dataset("fractions", data=fractions) - decay_rates = precursors.create_dataset("decay_rates", data=decay_rates) - decay_rates.attrs["unit"] = "/s" - - # ================================================================================== - # Delayed fission spectra - # ================================================================================== - - delayed_spectrum_block = ace_table.delayed_neutron_energy_distribution_block - if dnp_block is not None: - N_DNP = dnp_block.number_delayed_precursors - - for i in range(N_DNP): - idx = 1 + 1 - data = delayed_spectrum_block.energy_distribution_data(idx) - - if not isinstance(data, ACEtk.continuous.OutgoingEnergyDistributionData): - print_error(f"Unsupported delayed fission neutron spectrum: {data}") - - energy_group = fission_group.create_group( - f"delayed_neutron_precursors/energy_spectrum-{i+1}" - ) - util.load_energy_distribution(data, energy_group) - - # ================================================================================== - # Finalize - # ================================================================================== - - file.close() - -print("") diff --git a/tools/data_library_generator/parse_endf70prot.py b/tools/data_library_generator/parse_endf70prot.py deleted file mode 100644 index da79cff2b..000000000 --- a/tools/data_library_generator/parse_endf70prot.py +++ /dev/null @@ -1,34 +0,0 @@ -# This script was written by ChatGPT with Ethan Lame's instructions -import os - -input_file = "/home/ethan_lame/MCDC/acelib/endf70prot" # your big file -output_dir = "/home/ethan_lame/MCDC/acelib/" # where split files go - -os.makedirs(output_dir, exist_ok=True) - -current_file = None - -with open(input_file, "r") as f: - for line in f: - # Check for start of new isotope block - if ".70h" in line: - # Close previous file if open - if current_file is not None: - current_file.close() - - # Extract filename (first token) - filename = line.strip().split()[0] - - # Open new file - filepath = os.path.join(output_dir, filename) - current_file = open(filepath, "w") - - print(f"Creating {filename}") - - # Write line if a file is open - if current_file is not None: - current_file.write(line) - -# Close last file -if current_file is not None: - current_file.close() From 1e08474462339510ecd2b01e82e88f1791847613 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 4 Jun 2026 11:13:25 -0700 Subject: [PATCH 23/64] remove proton secondary particle channel, for now --- mcdc/object_/nuclide.py | 30 ----------- mcdc/object_/proton_reaction.py | 96 +-------------------------------- 2 files changed, 1 insertion(+), 125 deletions(-) diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index c1c268813..a263ac14c 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -20,7 +20,6 @@ from mcdc.object_.proton_reaction import ( ProtonReactionElasticScattering, ProtonReactionNonelasticReaction, - ProtonSecondaryChannel, set_energy_distribution, ) from mcdc.object_.simulation import simulation @@ -61,8 +60,6 @@ class Nuclide(ObjectNonSingleton): # proton_elastic_scattering_reactions: list[ProtonReactionElasticScattering] proton_nonelastic_reactions: list[ProtonReactionNonelasticReaction] - proton_secondary_channels: dict[int, list[ProtonSecondaryChannel]] - non_numba: list[str] = ["proton_secondary_channels"] # neutron_fission_prompt_multiplicity: DataBase neutron_fission_delayed_multiplicity: DataBase @@ -336,33 +333,6 @@ def set_proton_data(self): self.stopping_power = file["stopping_power"]["total_stopping_power"][()] self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] - # # ========================================================================== - # # Secondary particles - # # ========================================================================== - - # self.proton_secondary_channels = {} - # if "secondary_particles" in file: - # sec_group = file["secondary_particles"] - # for zap_name in sec_group.keys(): - # if not zap_name.startswith("ZAP_"): - # continue - # zap = int(zap_name.split("_")[1]) - # zap_group = sec_group[zap_name] - - # # Iterate over MT numbers for this secondary particle type - # for mt_name in zap_group.keys(): - # if not mt_name.startswith("MT-"): - # continue - # MT = int(mt_name.split("-")[1]) - # mt_group = zap_group[mt_name] - - # # Load secondary channel - # channel = ProtonSecondaryChannel.from_h5_group(mt_group, zap) - - # if MT not in self.proton_secondary_channels: - # self.proton_secondary_channels[MT] = [] - # self.proton_secondary_channels[MT].append(channel) - file.close() ## TODO: UPDATE this to handle protons as well as neutrons diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py index b1f132bd5..2cf97cf13 100644 --- a/mcdc/object_/proton_reaction.py +++ b/mcdc/object_/proton_reaction.py @@ -372,98 +372,4 @@ def set_energy_distribution(h5_group): else: print_error(f"Unsupported energy spectrum of type {spectrum_type}") - return energy_spectrum - - -# ====================================================================================== -# Proton secondary particle channel -# ====================================================================================== - - -class ProtonSecondaryChannel(ObjectPolymorphic): - """ - Data container for a proton secondary particle channel. - Plain helper object. - """ - - particle_type: int - MT: int - multiplicity: float64 # Multiplicity of particles produced per reaction - production_xs: NDArray[float64] - production_xs_offset_: int - reference_frame: int # COM or LAB - energy_spectrum: DistributionBase - - def __init__( - self, - particle_type, - MT, - multiplicity, - production_xs, - production_xs_offset, - reference_frame, - energy_spectrum, - ): - self.particle_type = particle_type - self.MT = MT - self.multiplicity = multiplicity - self.production_xs = production_xs - self.production_xs_offset_ = production_xs_offset - self.reference_frame = reference_frame - self.energy_spectrum = energy_spectrum - super().__init__(type_=0, register=False) - - @classmethod - def from_h5_group(cls, h5_group, zap): - """ - Load a secondary particle channel from HDF5 group. - zap: ZAP code (1=neutron, 31=proton, etc.) - """ - if zap not in ZAP_TO_PARTICLE: - raise ValueError(f"zap {zap} not in ZAP_TO_PARTICLE") - particle_type = ZAP_TO_PARTICLE.get(zap) - MT = h5_group.attrs["MT"] - multiplicity = h5_group.attrs["multiplicity"] - - reference_frame_str = h5_group.attrs["reference_frame"] - if reference_frame_str == "LAB": - reference_frame = REFERENCE_FRAME_LAB - elif reference_frame_str == "COM": - reference_frame = REFERENCE_FRAME_COM - else: - reference_frame = REFERENCE_FRAME_COM # default - - # Production cross section (optional) - if "production_xs" in h5_group: - production_xs = h5_group["production_xs"][()] - production_xs_offset = h5_group["production_xs"].attrs["offset"] - else: - production_xs = np.zeros(0, dtype=float) - production_xs_offset = 0 - - # Energy spectrum (currently assume Kalbach-Mann) - energy_spectrum = set_energy_distribution(h5_group["kalbach_mann"]) - - return cls( - particle_type, - MT, - multiplicity, - production_xs, - production_xs_offset, - reference_frame, - energy_spectrum, - ) - - def __repr__(self): - particle_name = ( - "Neutron" if self.particle_type == PARTICLE_NEUTRON else "Proton" - ) - text = "\n" - text += f"Proton secondary channel ({particle_name})\n" - text += f" - ID: {self.ID}\n" - text += f" - MT: {self.MT}\n" - text += f" - Multiplicity: {self.multiplicity}\n" - text += f" - Production XS: {print_1d_array(self.production_xs)} barn\n" - text += f" - Reference frame: {decode_reference_frame(self.reference_frame)}\n" - text += f" - Energy spectrum: {distribution.decode_type(self.energy_spectrum.type)} [ID: {self.energy_spectrum.ID}]\n" - return text + return energy_spectrum \ No newline at end of file From f602dacb43ba44ae26238539b8c4837e21c7d481 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 4 Jun 2026 11:23:05 -0700 Subject: [PATCH 24/64] updating documentation --- mcdc/object_/nuclide.py | 3 + mcdc/object_/proton_reaction.py | 2 +- mcdc/transport/physics/interface.py | 55 ++--- mcdc/transport/physics/proton/interface.py | 5 + mcdc/transport/physics/proton/multigroup.py | 206 ------------------ mcdc/transport/physics/proton/native.py | 5 + .../proton_ace_to_hdf5.py | 2 +- 7 files changed, 45 insertions(+), 233 deletions(-) diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index a263ac14c..9a2937d6b 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -263,6 +263,9 @@ def set_proton_data(self): file_name = f"{nuclide_name}-{temperature}K.h5" file = h5py.File(f"{dir_name}/{file_name}", "r") + # TENDL data only handles elastic scattering rxns as a unique rxn. + # Everything else is grouped together, including nonelastic rxns + # and rxns that will produce secondary particles. rx_names = [ "elastic_scattering", "nonelastic_reaction", diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py index 2cf97cf13..99c3b6c96 100644 --- a/mcdc/object_/proton_reaction.py +++ b/mcdc/object_/proton_reaction.py @@ -372,4 +372,4 @@ def set_energy_distribution(h5_group): else: print_error(f"Unsupported energy spectrum of type {spectrum_type}") - return energy_spectrum \ No newline at end of file + return energy_spectrum diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index 3a1abea20..65a0cd5d8 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -58,31 +58,6 @@ def neutron_production_xs(reaction_type, particle_container, simulation, data): return -1.0 -@njit -def csda_distance(particle_container, simulation, data): - particle = particle_container[0] - material = simulation["native_materials"][particle["material_ID"]] - E = particle["E"] - total_rho = 0.0 - total_dedx = 0.0 - - for i in range(material["N_nuclide"]): - nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) - nuclide = simulation["nuclides"][nuclide_ID] - dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) - dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) - dedx = np.interp(E / 1e6, dedx_energies, dedx_values) - total_dedx += dedx * 1e6 - - atomic_mass = nuclide["atomic_weight_ratio"] - nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) - density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) - total_rho += density_gcm3 - - max_fractional_e_loss = simulation["settings"]["csda_max_fractional_e_loss"] - return max_fractional_e_loss * E / total_dedx / total_rho - - # ====================================================================================== # Collision # ====================================================================================== @@ -123,6 +98,36 @@ def collision(particle_container, collision_data_container, program, data): proton.collision(particle_container, collision_data_container, program, data) +# ====================================================================================== +# Continuous Slowing Down Approximation +# ====================================================================================== + + +@njit +def csda_distance(particle_container, simulation, data): + particle = particle_container[0] + material = simulation["native_materials"][particle["material_ID"]] + E = particle["E"] + total_rho = 0.0 + total_dedx = 0.0 + + for i in range(material["N_nuclide"]): + nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + nuclide = simulation["nuclides"][nuclide_ID] + dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) + dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) + dedx = np.interp(E / 1e6, dedx_energies, dedx_values) + total_dedx += dedx * 1e6 + + atomic_mass = nuclide["atomic_weight_ratio"] + nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) + total_rho += density_gcm3 + + max_fractional_e_loss = simulation["settings"]["csda_max_fractional_e_loss"] + return max_fractional_e_loss * E / total_dedx / total_rho + + @njit def csda_edep(particle_container, collision_data_container, distance, simulation, data): particle = particle_container[0] diff --git a/mcdc/transport/physics/proton/interface.py b/mcdc/transport/physics/proton/interface.py index 9119d4650..34b81c01f 100644 --- a/mcdc/transport/physics/proton/interface.py +++ b/mcdc/transport/physics/proton/interface.py @@ -37,6 +37,11 @@ def collision(particle_container, collision_data_container, program, data): native.collision(particle_container, collision_data_container, program, data) +# ====================================================================================== +# Continuous Slowing Down Approximation +# ====================================================================================== + + @njit def csda_edep(particle_container, collision_data_container, distance, simulation, data): native.csda_edep( diff --git a/mcdc/transport/physics/proton/multigroup.py b/mcdc/transport/physics/proton/multigroup.py index 7c4a4612e..b48e811a3 100644 --- a/mcdc/transport/physics/proton/multigroup.py +++ b/mcdc/transport/physics/proton/multigroup.py @@ -51,59 +51,6 @@ def macro_xs(reaction_type, particle_container, simulation, data): return 0.0 -# @njit -# def proton_production_xs(reaction_type, particle_container, simulation, data): -# particle = particle_container[0] -# material = simulation["multigroup_materials"][particle["material_ID"]] -# g = particle["g"] - -# # Total production -# if reaction_type == PROTON_REACTION_TOTAL: -# total = 0.0 - -# # Scattering production -# nu = mcdc_get.multigroup_material.mgxs_nu_s(g, material, data) -# xs = mcdc_get.multigroup_material.mgxs_scatter(g, material, data) -# total += nu * xs - -# # Fission production -# nu = mcdc_get.multigroup_material.mgxs_nu_f(g, material, data) -# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) -# total += nu * xs -# return total - -# # Capture production (none) -# elif reaction_type == PROTON_REACTION_CAPTURE: -# return 0.0 - -# # Scattering production -# elif reaction_type == NEUTRON_REACTION_ELASTIC_SCATTERING: -# nu = mcdc_get.multigroup_material.mgxs_nu_s(g, material, data) -# xs = mcdc_get.multigroup_material.mgxs_scatter(g, material, data) -# return nu * xs - -# # Fission production -# elif reaction_type == NEUTRON_REACTION_FISSION: -# nu = mcdc_get.multigroup_material.mgxs_nu_f(g, material, data) -# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) -# return nu * xs - -# # Prompt fission production -# elif reaction_type == NEUTRON_REACTION_FISSION_PROMPT: -# nu = mcdc_get.multigroup_material.mgxs_nu_p(g, material, data) -# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) -# return nu * xs - -# # Delayed neutron production -# elif reaction_type == NEUTRON_REACTION_FISSION_DELAYED: -# nu = mcdc_get.multigroup_material.mgxs_nu_d_total(g, material, data) -# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) -# return nu * xs - -# # Unsupported default -# return 0.0 - - # ====================================================================================== # Collision # ====================================================================================== @@ -218,156 +165,3 @@ def scattering(particle_container, program, data): particle["w"] = particle_new["w"] else: particle_bank_module.bank_active_particle(particle_container_new, program) - - -# @njit -# def fission(particle_container, program, data): -# simulation = util.access_simulation(program) -# settings = simulation["settings"] - -# # Particle properties -# particle = particle_container[0] -# g = particle["g"] - -# # Material properties -# material = simulation["multigroup_materials"][particle["material_ID"]] -# G = material["G"] -# J = material["J"] - -# # Kill the current particle -# particle["alive"] = False - -# # Adjust production and product weights if weighted emission -# weight_production = 1.0 -# weight_product = particle["w"] -# if simulation["weighted_emission"]["active"]: -# weight_target = simulation["weighted_emission"]["weight_target"] -# weight_production = particle["w"] / weight_target -# weight_product = weight_target - -# # Fission yields -# nu = mcdc_get.multigroup_material.mgxs_nu_f(g, material, data) -# nu_p = mcdc_get.multigroup_material.mgxs_nu_p(g, material, data) -# if J > 0: -# stride = material["J"] -# start = material["mgxs_nu_d_offset"] + g * stride -# nu_d = data[start : start + stride] -# # Above is equivalent to: nu_d = mcdc_get.multigroup_material.mgxs_nu_d_vector(g, material, data) - -# # Get number of secondaries -# N = int( -# math.floor( -# weight_production * nu / simulation["k_eff"] + rng.lcg(particle_container) -# ) -# ) - -# # Set up secondary partice container -# particle_container_new = util.local_array(1, type_.particle_data) -# particle_new = particle_container_new[0] - -# # Create the secondaries -# for n in range(N): -# # Set default attributes -# particle_module.copy_as_child(particle_container_new, particle_container) - -# # Set weight -# particle_new["w"] = weight_product - -# # Sample isotropic direction -# ux_new, uy_new, uz_new = sample_isotropic_direction(particle_container_new) -# particle_new["ux"] = ux_new -# particle_new["uy"] = uy_new -# particle_new["uz"] = uz_new - -# # Prompt or delayed? -# xi = rng.lcg(particle_container_new) * nu -# total = nu_p -# if xi < total: -# prompt = True -# stride = material["G"] -# start = material["mgxs_chi_p_offset"] + g * stride -# spectrum = data[start : start + stride] -# # Above is equivalent to: spectrum = mcdc_get.multigroup_material.mgxs_chi_p_vector(g, material, data) -# else: -# prompt = False - -# # Determine delayed group, decay constant, and spectrum -# for j in range(J): -# total += nu_d[j] -# if xi < total: -# stride = material["G"] -# start = material["mgxs_chi_d_offset"] + j * stride -# spectrum = data[start : start + stride] -# # Above is equivalent to: -# # spectrum = mcdc_get.multigroup_material.mgxs_chi_d_vector( -# # j, material, data -# # ) -# decay = mcdc_get.multigroup_material.mgxs_decay_rate( -# j, material, data -# ) -# break - -# # Sample outgoing energy -# xi = rng.lcg(particle_container_new) -# tot = 0.0 -# for g_out in range(G): -# tot += spectrum[g_out] -# if tot > xi: -# break -# particle_new["g"] = g_out - -# # Sample emission time -# if not prompt: -# xi = rng.lcg(particle_container_new) -# particle_new["t"] -= math.log(xi) / decay - -# # Eigenvalue mode: bank right away -# if settings["neutron_eigenvalue_mode"]: -# particle_bank_module.bank_census_particle(particle_container_new, program) -# continue -# # Below is only relevant for fixed-source problem - -# # Skip if it's beyond time boundary -# if particle_new["t"] > settings["time_boundary"]: -# continue - -# # Check if it hits current or next census times -# hit_current_census = False -# hit_future_census = False -# idx_census = simulation["idx_census"] -# if settings["N_census"] > 1: -# if particle_new["t"] > mcdc_get.settings.census_time( -# idx_census, settings, data -# ): -# hit_current_census = True -# if particle_new["t"] > mcdc_get.settings.census_time( -# idx_census + 1, settings, data -# ): -# hit_future_census = True - -# # Not hitting census --> add to active bank -# if not hit_current_census: -# # Keep it if it is the last particle -# if n == N - 1: -# particle["alive"] = True -# particle["ux"] = particle_new["ux"] -# particle["uy"] = particle_new["uy"] -# particle["uz"] = particle_new["uz"] -# particle["t"] = particle_new["t"] -# particle["g"] = particle_new["g"] -# particle["E"] = particle_new["E"] -# particle["w"] = particle_new["w"] -# else: -# particle_bank_module.bank_active_particle( -# particle_container_new, program -# ) - -# # Hit future census --> add to future bank -# elif hit_future_census: -# # Particle will participate in the future -# particle_bank_module.bank_future_particle(particle_container_new, program) - -# # Hit current census --> add to census bank -# else: -# # Particle will participate after the current census is completed -# particle_bank_module.bank_census_particle(particle_container_new, program) diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 66b61b202..ec2644725 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -236,6 +236,11 @@ def collision(particle_container, collision_data_container, program, data): return +# ====================================================================================== +# Continous Slowing Down Approximation +# ====================================================================================== + + @njit def csda_edep(particle_container, collision_data_container, distance, simulation, data): particle = particle_container[0] diff --git a/tools/data_library_generator/proton_ace_to_hdf5.py b/tools/data_library_generator/proton_ace_to_hdf5.py index 549192316..b649017ce 100644 --- a/tools/data_library_generator/proton_ace_to_hdf5.py +++ b/tools/data_library_generator/proton_ace_to_hdf5.py @@ -9,7 +9,7 @@ python proton_ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 [--pstar_dir /path/to/pstar] python proton_ace_to_hdf5.py ... --rewrite # overwrite existing files python proton_ace_to_hdf5.py ... --verbose # per-reaction detail - + Environment variable fallbacks: $MCDC_ACELIB, $MCDC_LIB, $PSTAR_LIB From a6107695726dcabe617e0c8e99fe3e5b9f9b90fb Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Fri, 10 Apr 2026 09:24:05 -0700 Subject: [PATCH 25/64] decode_ace_name works for protons --- tools/data_library_generator/util.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/data_library_generator/util.py b/tools/data_library_generator/util.py index 44f9b5897..a2276ee5d 100644 --- a/tools/data_library_generator/util.py +++ b/tools/data_library_generator/util.py @@ -45,7 +45,12 @@ def decode_ace_name(name: str): S = offset // 100 A = offset % 100 - T = ACE_TEMPERATURE_LIB81[extension] + # Proton data: ENDF70PROT + if extension == "70h": + T = 293.6 + + else: + T = ACE_TEMPERATURE_LIB81[extension] return Z, A, S, T From 0f767723b0072802923812ebab4577a0051249c1 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Tue, 14 Apr 2026 15:27:27 -0700 Subject: [PATCH 26/64] generate hdf5 files for proton data --- tools/data_library_generator/generate.py | 2 +- .../parse_endf70prot.py | 34 ++ .../data_library_generator/proton_generate.py | 485 ++++++++++++++++++ 3 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 tools/data_library_generator/parse_endf70prot.py create mode 100644 tools/data_library_generator/proton_generate.py diff --git a/tools/data_library_generator/generate.py b/tools/data_library_generator/generate.py index 809d12108..eacc4b9da 100644 --- a/tools/data_library_generator/generate.py +++ b/tools/data_library_generator/generate.py @@ -1,8 +1,8 @@ -import ACEtk import argparse import h5py import numpy as np import os +import ACEtk from tqdm import tqdm diff --git a/tools/data_library_generator/parse_endf70prot.py b/tools/data_library_generator/parse_endf70prot.py new file mode 100644 index 000000000..f62b45888 --- /dev/null +++ b/tools/data_library_generator/parse_endf70prot.py @@ -0,0 +1,34 @@ +# This script was written by ChatGPT with Ethan Lame's instructions +import os + +input_file = "/home/ethan_lame/MCDC/acelib/endf70prot" # your big file +output_dir = "/home/ethan_lame/MCDC/acelib/" # where split files go + +os.makedirs(output_dir, exist_ok=True) + +current_file = None + +with open(input_file, "r") as f: + for line in f: + # Check for start of new isotope block + if ".70h" in line: + # Close previous file if open + if current_file is not None: + current_file.close() + + # Extract filename (first token) + filename = line.strip().split()[0] + + # Open new file + filepath = os.path.join(output_dir, filename) + current_file = open(filepath, "w") + + print(f"Creating {filename}") + + # Write line if a file is open + if current_file is not None: + current_file.write(line) + +# Close last file +if current_file is not None: + current_file.close() \ No newline at end of file diff --git a/tools/data_library_generator/proton_generate.py b/tools/data_library_generator/proton_generate.py new file mode 100644 index 000000000..af4cf6da5 --- /dev/null +++ b/tools/data_library_generator/proton_generate.py @@ -0,0 +1,485 @@ +import argparse +import h5py +import numpy as np +import os +import ACEtk + +from tqdm import tqdm + +#### + +import util +from util import print_error, print_note + +parser = argparse.ArgumentParser(description="MC/DC data generator") +parser.add_argument("--rewrite", dest="rewrite", action="store_true", default=False) +parser.add_argument("--verbose", dest="verbose", action="store_true", default=False) +args, unargs = parser.parse_known_args() +rewrite = args.rewrite +verbose = args.verbose + +# Directories +output_dir = os.getenv("MCDC_LIB") +ace_dir = os.getenv("MCDC_ACELIB") + +if output_dir is None: + print_error("Environment variable $MCDC_LIB is not set") +if ace_dir is None: + print_error("Environment variable $MCDC_ACELIB is not set") + +# Create output directory if needed +os.makedirs(output_dir, exist_ok=True) +print(f"\nACE directory: {ace_dir}") +print(f"Output directory: {output_dir}\n") + +# Select the files +if rewrite: + target_files = os.listdir(ace_dir) +else: + target_files = [] + for file_name in os.listdir(ace_dir): + # File header + with open(f"{ace_dir}/{file_name}", "r") as f: + header = ACEtk.Header.from_string(f.readline()) + + # Decode ACE name to MC/DC name + Z, A, S, T = util.decode_ace_name(header.zaid) + symbol = util.Z_TO_SYMBOL[Z] + nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" + mcdc_name = f"{nuclide_name}-{T}K.h5" + + if not os.path.exists(f"{output_dir}/{mcdc_name}"): + target_files.append(file_name) + +# Loop over all files +pbar = tqdm( + target_files, + disable=verbose, + bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}{postfix}", +) +for ace_name in pbar: + # File header + with open(f"{ace_dir}/{ace_name}", "r") as f: + header = ACEtk.Header.from_string(f.readline()) + + # Decode ACE name to MC/DC name + Z, A, S, T = util.decode_ace_name(header.zaid) + symbol = util.Z_TO_SYMBOL[Z] + nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" + mcdc_name = f"{nuclide_name}-{T}K.h5" + + if not rewrite and os.path.exists(f"{output_dir}/{mcdc_name}"): + continue + + # Create MC/DC file + if verbose: + print("\n" + "=" * 80 + "\n") + print(f"Create {mcdc_name} from {ace_name}\n") + pbar.set_postfix_str(f"{mcdc_name[:-3]} from {ace_name}") + file = h5py.File(f"{output_dir}/{mcdc_name}", "w") + + # ================================================================================== + # Basic properties + # ================================================================================== + + # Load ACE tables + ace_table = ACEtk.ContinuousEnergyTable.from_file(f"{ace_dir}/{ace_name}") + + # ACE data source description + header = ace_table.header + file.attrs["source_title"] = header.title + file.attrs["source_version"] = header.version + file.attrs["source_date"] = header.date + if "comments" in dir(header): + file.attrs["source_comments"] = header.comments + + # Name and excitation level + file.create_dataset("nuclide_name", data=nuclide_name) + file.create_dataset("excitation_level", data=S) + + # Temperature + temperature = file.create_dataset("temperature", data=T) + temperature.attrs["unit"] = "K" + + # Atomic number and weight ratio + atomic_number = ace_table.atom_number + atomic_weight_ratio = ace_table.atomic_weight_ratio + file.create_dataset("atomic_number", data=atomic_number) + file.create_dataset("atomic_weight_ratio", data=atomic_weight_ratio) + + # Fissionable? + fissionable = ace_table.fission_multiplicity_block is not None + file.create_dataset("fissionable", data=fissionable) + + # ================================================================================== + # Reaction groups + # ================================================================================== + # Elastic scattering: MT=2 + # Capture: Reactions with zero multiplicity + # Fission: MT=18 or MT=(19, 20, 21, and 38) if given + # Inelastic: Non-fission reactions with non-zero multiplicity + # Ignored: MT=(1, 3, 4, 10) and MT>117 + + proton_reactions = file.create_group("proton_reactions") + + # ACE blocks + nu_block = ace_table.frame_and_multiplicity_block + rx_block = ace_table.reaction_number_block + N_reaction = nu_block.number_reactions + + if nu_block.number_reactions != rx_block.number_reactions: + print_error("Non-equal reaction number in reaction and multiplicity blocks") + + # The groups + elastic_group = proton_reactions.create_group("elastic_scattering") + capture_group = proton_reactions.create_group("capture") + inelastic_group = proton_reactions.create_group("inelastic_scattering") + fission_group = proton_reactions.create_group("fission") + + # MT groups + elastic_MTs = [2] + capture_MTs = [] + inelastic_MTs = [] + fission_MTs = [] + + # Redundant MTs + fission_chance_MTs = [19, 20, 21, 38] + redundant_MTs = [1, 3, 4, 10] + + # Set fission MTs + total_fission_given = rx_block.has_MT(18) + if total_fission_given: + fission_MTs = [18] + # The component should not be given + for MT in fission_chance_MTs: + if rx_block.has_MT(MT): + print_error("Both total fission and its components are given") + else: + for MT in fission_chance_MTs: + if rx_block.has_MT(MT): + fission_MTs.append(MT) + + # Capture and inelastic MTs + for i in range(N_reaction): + idx = i + 1 + MT = rx_block.MT(idx) + + if MT in redundant_MTs + elastic_MTs + fission_MTs or MT > 117: + continue + + nu = nu_block.multiplicity(idx) + + if type(nu) != int: + print_error(f"Non-integer multiplicity for inelastic scattering") + + if nu == 0: + capture_MTs.append(MT) + elif nu > 0: + inelastic_MTs.append(MT) + else: + print_error(f"Negative multiplicity for MT-{MT:03}") + + # Create MTs + for rx_group, rx_MTs in [ + (elastic_group, elastic_MTs), + (capture_group, capture_MTs), + (inelastic_group, inelastic_MTs), + (fission_group, fission_MTs), + ]: + for MT in rx_MTs: + MT_group = rx_group.create_group(f"MT-{MT:03}") + MT_group.attrs["MT"] = MT + + # Report MT groups + if verbose: + print(f" Reaction group MTs") + print(f" - Elastic scattering MTs: {elastic_MTs}") + print(f" - Capture MTs: {capture_MTs}") + print(f" - Inelastic scattering MTs: {inelastic_MTs}") + if fissionable: + print(f" - Fission MT: {fission_MTs}") + + # Delete empty groups + if not fissionable: + del file["proton_reactions/fission"] + if len(inelastic_MTs) == 0: + del file["proton_reactions/inelastic_scattering"] + + # ================================================================================== + # Cross-sections + # ================================================================================== + + xs0_block = ace_table.principal_cross_section_block + xs_block = ace_table.cross_section_block + + xs_energy = xs0_block.energies + xs_elastic = xs0_block.elastic + cross_sections = xs_block.cross_sections + offsets = xs_block.energy_index + + # Energy grid + xs_energy = np.array(xs_energy) + dataset = proton_reactions.create_dataset("xs_energy_grid", data=xs_energy) + dataset.attrs["unit"] = "MeV" + + # Elastic scattering + xs = elastic_group.create_dataset("MT-002/xs", data=xs_elastic) + xs.attrs["offset"] = 0 + xs.attrs["unit"] = "barns" + + # Capture, inelastic scattering, and fission + for MTs, group in [ + (capture_MTs, capture_group), + (inelastic_MTs, inelastic_group), + (fission_MTs, fission_group), + ]: + for MT in MTs: + print(f'MT = {MT}') + idx = rx_block.index(MT) + xs = group.create_dataset(f"MT-{MT:03}/xs", data=cross_sections(idx)) + xs.attrs["offset"] = offsets(idx) - 1 + xs.attrs["unit"] = "barns" + + # ================================================================================== + # Q-value + # ================================================================================== + + q_value_block = ace_table.reaction_qvalue_block + + # Elastic scattering: zero Q-value + for MT in elastic_MTs: + dataset = elastic_group.create_dataset(f"MT-{MT:03}/Q-value", data=0.0) + dataset.attrs["unit"] = "MeV" + + for MTs, group in [ + (capture_MTs, capture_group), + (inelastic_MTs, inelastic_group), + (fission_MTs, fission_group), + ]: + for MT in MTs: + idx = rx_block.index(MT) + dataset = group.create_dataset( + f"MT-{MT:03}/Q-value", data=q_value_block.q_value(idx) + ) + dataset.attrs["unit"] = "MeV" + + # ================================================================================== + # Reference frames and inelastic scattering multiplicities + # ================================================================================== + # Elastic is always in COM frame (per ACE standard) + + # Elastic scattering reference frame + for MT in elastic_MTs: + elastic_group.create_dataset(f"MT-{MT:03}/reference_frame", data="COM") + + # Reference frames of the others + for MTs, group in [ + (capture_MTs, capture_group), + (inelastic_MTs, inelastic_group), + (fission_MTs, fission_group), + ]: + for MT in MTs: + idx = rx_block.index(MT) + reference_frame = nu_block.reference_frame(idx) + if reference_frame == ACEtk.ReferenceFrame.Laboratory: + reference_frame = "LAB" + elif reference_frame == ACEtk.ReferenceFrame.CentreOfMass: + reference_frame = "COM" + else: + print_error(f"Unknown reaction reference frame type for MT-{MT:03}") + group.create_dataset(f"MT-{MT:03}/reference_frame", data=reference_frame) + + # Inelastic multiplicity + for MT in inelastic_MTs: + idx = rx_block.index(MT) + nu = nu_block.multiplicity(idx) + inelastic_group.create_dataset(f"MT-{MT:03}/multiplicity", data=nu) + + # ================================================================================== + # Angular distributions + # ================================================================================== + + angle_block = ace_table.angular_distribution_block + + # Elastic scattering + angle_group = elastic_group.create_group("MT-002/angular_cosine_distribution") + data = angle_block.angular_distribution_data(0) + for subdata in data.distributions: + if not isinstance(subdata, ACEtk.continuous.TabulatedAngularDistribution): + print_error("Unsupported elastic scattering angular distribution") + util.load_cosine_distribution(data, angle_group) + + # Inelastic scattering and fission + for MTs, group in [ + (inelastic_MTs, inelastic_group), + (fission_MTs, fission_group), + ]: + for MT in MTs: + idx = rx_block.index(MT) + angle_group = group.create_group(f"MT-{MT:03}/angular_cosine_distribution") + data = angle_block.angular_distribution_data(idx) + util.load_cosine_distribution(data, angle_group) + + # ================================================================================== + # Energy distributions + # ================================================================================== + + energy_block = ace_table.energy_distribution_block + + for MTs, group in [ + (inelastic_MTs, inelastic_group), + (fission_MTs, fission_group), + ]: + for MT in MTs: + idx = rx_block.index(MT) + data = energy_block.energy_distribution_data(idx) + + if not isinstance(data, ACEtk.continuous.MultiDistributionData): + # Probabilities + dataset = group.create_dataset( + f"MT-{MT:03}/spectrum_probability_grid", data=np.array([0.0, 30.0]) + ) + dataset.attrs["unit"] = "MeV" + dataset = group.create_dataset( + f"MT-{MT:03}/spectrum_probability", data=np.array([[1.0]]) + ) + + # The distributions + energy_group = group.create_group(f"MT-{MT:03}/energy_spectrum-1") + util.load_energy_distribution(data, energy_group) + + else: + N_dist = data.number_distributions + + # ====================================================================== + # Probabilities + # ====================================================================== + + # Constant probability + if all( + np.array( + [x.number_interpolation_regions for x in data.probabilities] + ) + == 0 + ): + probability_grid = np.array([0.0, 30.0]) + probability = np.zeros((1, N_dist)) + for i in range(N_dist): + probability[0, i] = max(data.probability(i + 1).probabilities) + + # Histogram probability + elif all( + np.array( + [x.number_interpolation_regions for x in data.probabilities] + ) + == 1 + ) and all(np.array([x.interpolants for x in data.probabilities]) == 1): + probability_grid = np.array(data.probability(1).energies) + probability = np.zeros((len(probability_grid) - 1, N_dist)) + for i in range(N_dist): + if not all( + probability_grid + == np.array(data.probability(i + 1).energies) + ): + print_error("Unsupported multi-distribution energy spetrum") + probability[:, i] = np.array( + data.probability(i + 1).probabilities[:-1] + ) + + else: + print_error("Unsupported multi-distribution energy spetrum") + + dataset = group.create_dataset( + f"MT-{MT:03}/spectrum_probability_grid", data=probability_grid + ) + dataset.attrs["unit"] = "MeV" + dataset = group.create_dataset( + f"MT-{MT:03}/spectrum_probability", data=probability + ) + + # ====================================================================== + # The disributions + # ====================================================================== + + for i in range(N_dist): + energy_group = group.create_group( + f"MT-{MT:03}/energy_spectrum-{i+1}" + ) + distribution = data.distribution(i + 1) + util.load_energy_distribution(distribution, energy_group) + + # Fissionable zone below + if not fissionable: + continue + + # ================================================================================== + # Fission multiplicities and delayed neutron precursor fractions and decay rates + # ================================================================================== + + prompt_block = ace_table.fission_multiplicity_block + delayed_block = ace_table.delayed_fission_multiplicity_block + dnp_block = ace_table.delayed_neutron_precursor_block + + # Prompt multiplicity + data = prompt_block.multiplicity + h5_group = fission_group.create_group("prompt_multiplicity") + util.load_fission_multiplicity(data, h5_group) + + # Delayed multiplicity + if delayed_block is not None: + data = delayed_block.multiplicity + h5_group = fission_group.create_group("delayed_multiplicity") + util.load_fission_multiplicity(data, h5_group) + + # Delayed neutron precursor fractions and decay rates + if dnp_block is not None: + N_DNP = dnp_block.number_delayed_precursors + fractions = np.zeros(N_DNP) + decay_rates = np.zeros(N_DNP) + + for i in range(N_DNP): + idx = 1 + 1 + data = dnp_block.precursor_group_data(idx) + + if ( + not data.number_interpolation_regions == 0 + or not len(data.probabilities[:]) == 2 + or not data.probabilities[0] == data.probabilities[1] + ): + print_error("Non-constant delayed neutron precursor fraction") + + fractions[i] = data.probabilities[0] + decay_rates[i] = data.decay_constant + + precursors = fission_group.create_group("delayed_neutron_precursors") + precursors.create_dataset("fractions", data=fractions) + decay_rates = precursors.create_dataset("decay_rates", data=decay_rates) + decay_rates.attrs["unit"] = "/s" + + # ================================================================================== + # Delayed fission spectra + # ================================================================================== + + delayed_spectrum_block = ace_table.delayed_neutron_energy_distribution_block + if dnp_block is not None: + N_DNP = dnp_block.number_delayed_precursors + + for i in range(N_DNP): + idx = 1 + 1 + data = delayed_spectrum_block.energy_distribution_data(idx) + + if not isinstance(data, ACEtk.continuous.OutgoingEnergyDistributionData): + print_error(f"Unsupported delayed fission neutron spectrum: {data}") + + energy_group = fission_group.create_group( + f"delayed_neutron_precursors/energy_spectrum-{i+1}" + ) + util.load_energy_distribution(data, energy_group) + + # ================================================================================== + # Finalize + # ================================================================================== + + file.close() + +print("") From 5e2e7e47bd7967eeaa0bff582e7327de789aad8e Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 15 Apr 2026 10:04:29 -0700 Subject: [PATCH 27/64] proton values in constant.py --- mcdc/constant.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mcdc/constant.py b/mcdc/constant.py index a4a03218c..af367c3b2 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -113,6 +113,10 @@ ELECTRON_REACTION_IONIZATION = 104 ELECTRON_REACTION_BREMSSTRAHLUNG = 105 ELECTRON_REACTION_EXCITATION = 106 +PROTON_REACTION_TOTAL = 200 +PROTON_REACTION_ELASTIC_SCATTERING = 201 +PROTON_REACTION_CAPTURE = 202 +PROTON_REACTION_INELASTIC_SCATTERING = 203 # Particle types PARTICLE_NEUTRON = 0 @@ -190,6 +194,7 @@ LIGHT_SPEED = 2.99792458e10 # cm/s NEUTRON_MASS = 939.565413e6 # eV/c^2 ELECTRON_MASS = 510.99895069e3 # eV/c^2 +PROTON_MASS = 938.27208943e6 # eV/c^2 BOLTZMANN_K = 8.61733326e-5 # eV/K ELECTRON_CUTOFF_ENERGY = 100 # eV MU_CUTOFF = 0.999999 From ec8212f29fc1a8b89363b4a02d938b1619ae4419 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 15 Apr 2026 10:41:36 -0700 Subject: [PATCH 28/64] add proton_reaction --- mcdc/object_/proton_reaction.py | 340 ++++++++++++++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 mcdc/object_/proton_reaction.py diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py new file mode 100644 index 000000000..dd2758822 --- /dev/null +++ b/mcdc/object_/proton_reaction.py @@ -0,0 +1,340 @@ +from typing import Annotated +from numpy import float64 +from numpy.typing import NDArray + +#### + +import mcdc.object_.distribution as distribution + +from mcdc.constant import ( + ANGLE_ISOTROPIC, + ANGLE_ENERGY_CORRELATED, + ANGLE_DISTRIBUTED, + INTERPOLATION_LINEAR, + INTERPOLATION_LOG, + PROTON_REACTION_CAPTURE, + PROTON_REACTION_ELASTIC_SCATTERING, + PROTON_REACTION_INELASTIC_SCATTERING, + REFERENCE_FRAME_COM, + REFERENCE_FRAME_LAB, +) +from mcdc.object_.base import ObjectPolymorphic +from mcdc.object_.distribution import ( + DistributionBase, + DistributionMultiTable, + DistributionLevelScattering, + DistributionEvaporation, + DistributionMaxwellian, + DistributionKalbachMann, + DistributionTabulatedEnergyAngle, + DistributionNBody, +) +from mcdc.object_.simulation import simulation +from mcdc.print_ import print_1d_array, print_error + +# ====================================================================================== +# Proton reaction base class +# ====================================================================================== + + +class ProtonReactionBase(ObjectPolymorphic): + # Annotations for Numba mode + label: str = "proton_reaction" + # + MT: int + xs: NDArray[float64] + xs_offset_: int # "xs_offset" ir reserved for "xs" + reference_frame: int + q_value: float64 + + def __init__(self, type_, MT, xs, xs_offset, reference_frame, q_value): + super().__init__(type_) + self.MT = MT + self.xs = xs + self.xs_offset_ = xs_offset + self.reference_frame = reference_frame + self.q_value = q_value + + def __repr__(self): + text = "\n" + text += f"{decode_type(self.type)}\n" + text += f" - ID: {self.ID}\n" + text += f" - MT: {self.MT}\n" + text += f" - XS {print_1d_array(self.xs)} barn\n" + text += f" - Reference frame: {decode_reference_frame(self.reference_frame)}\n" + text += f" - Q-value: {self.q_value}\n" + return text + + +def decode_type(type_): + if type_ == PROTON_REACTION_ELASTIC_SCATTERING: + return "Proton elastic scattering" + elif type_ == PROTON_REACTION_CAPTURE: + return "Proton capture" + elif type_ == PROTON_REACTION_INELASTIC_SCATTERING: + return "Proton inelastic scattering" + + +def decode_reference_frame(type_): + if type_ == REFERENCE_FRAME_LAB: + return "Laboratory" + elif type_ == REFERENCE_FRAME_COM: + return "Center of mass" + + +# ====================================================================================== +# Proton elastic scattering +# ====================================================================================== + + +class ProtonReactionElasticScattering(ProtonReactionBase): + # Annotations for Numba mode + label: str = "proton_elastic_scattering_reaction" + # + mu_table: DistributionMultiTable + + def __init__(self, MT, xs, xs_offset, reference_frame, mu): + type_ = PROTON_REACTION_ELASTIC_SCATTERING + super().__init__(type_, MT, xs, xs_offset, reference_frame, 0.0) + self.mu_table = mu + + @classmethod + def from_h5_group(cls, h5_group): + MT, xs, xs_offset, reference_frame, _ = set_basic_properties(h5_group) + _, mu = set_angular_distribution(h5_group["angular_cosine_distribution"]) + return cls(MT, xs, xs_offset, reference_frame, mu) + + def __repr__(self): + text = super().__repr__() + text += f" - Scattering cosine: {distribution.decode_type(self.mu_table.type)} [ID: {self.mu_table.ID}]\n" + return text + + +# ====================================================================================== +# Proton capture +# ====================================================================================== + + +class ProtonReactionCapture(ProtonReactionBase): + # Annotations for Numba mode + label: str = "proton_capture_reaction" + + def __init__(self, MT, xs, xs_offset, reference_frame, q_value): + type_ = PROTON_REACTION_CAPTURE + super().__init__(type_, MT, xs, xs_offset, reference_frame, q_value) + + @classmethod + def from_h5_group(cls, h5_group): + MT, xs, xs_offset, reference_frame, q_value = set_basic_properties(h5_group) + return cls(MT, xs, xs_offset, reference_frame, q_value) + + +# ====================================================================================== +# Proton inelastic scattering +# ====================================================================================== + + +class ProtonReactionInelasticScattering(ProtonReactionBase): + # Annotations for Numba mode + label: str = "proton_inelastic_scattering_reaction" + # + multiplicity: int + angle_type: int + mu: DistributionBase + N_spectrum_probability_bin: int + N_spectrum: int + spectrum_probability_grid: NDArray[float64] + spectrum_probability: Annotated[ + NDArray[float64], ("N_spectrum_probability_bin", "N_spectrum") + ] + energy_spectra: list[DistributionBase] + + def __init__( + self, + MT, + xs, + xs_offset, + reference_frame, + q_value, + multiplicity, + angle_type, + mu, + spectrum_probability_grid, + spectrum_probability, + energy_spectra, + ): + type_ = PROTON_REACTION_INELASTIC_SCATTERING + super().__init__(type_, MT, xs, xs_offset, reference_frame, q_value) + + self.multiplicity = multiplicity + self.angle_type = angle_type + self.mu = mu + self.N_spectrum_probability_bin = len(spectrum_probability_grid) - 1 + self.N_spectrum = len(energy_spectra) + self.spectrum_probability_grid = spectrum_probability_grid + self.spectrum_probability = spectrum_probability + self.energy_spectra = energy_spectra + + @classmethod + def from_h5_group(cls, h5_group): + MT, xs, xs_offset, reference_frame, q_value = set_basic_properties(h5_group) + multiplicity = int(h5_group["multiplicity"][()]) + + angle_type, mu = set_angular_distribution( + h5_group["angular_cosine_distribution"] + ) + + # Energy spectra + spectrum_probability_grid = ( + h5_group[f"spectrum_probability_grid"][()] * 1e6 + ) # MeV to eV + spectrum_probability = h5_group[f"spectrum_probability"][()] + energy_spectra = [] + spectrum_names = [x for x in h5_group if x.startswith("energy_spectrum-")] + for spectrum_name in spectrum_names: + energy_spectra.append(set_energy_distribution(h5_group[f"{spectrum_name}"])) + + return cls( + MT, + xs, + xs_offset, + reference_frame, + q_value, + multiplicity, + angle_type, + mu, + spectrum_probability_grid, + spectrum_probability, + energy_spectra, + ) + + def __repr__(self): + text = super().__repr__() + if self.angle_type == ANGLE_ISOTROPIC: + text += f" - Scattering cosine: Isotropic\n" + elif self.angle_type == ANGLE_ENERGY_CORRELATED: + text += f" - Scattering cosine: Energy-correlated\n" + else: + text += f" - Scattering cosine: {distribution.decode_type(self.mu.type)} [ID: {self.mu.ID}]\n" + text += f" - Energy spectra\n" + text += f" - Probability energy grid {print_1d_array(self.spectrum_probability_grid)}\n" + for i in range(len(self.energy_spectra)): + text += f" - Spectrum {i+1}: {distribution.decode_type(self.energy_spectra[i])} [{print_1d_array(self.spectrum_probability[:,i])}] [ID: {self.energy_spectra[i].ID}]\n" + return text + + +# ====================================================================================== +# Helper functions +# ====================================================================================== + + +def set_basic_properties(h5_group): + MT = h5_group.attrs["MT"][()] + xs = h5_group["xs"][()] + xs_offset = h5_group["xs"].attrs["offset"] + reference_frame = h5_group["reference_frame"][()].decode("utf-8") + if reference_frame == "LAB": + reference_frame = REFERENCE_FRAME_LAB + elif reference_frame == "COM": + reference_frame = REFERENCE_FRAME_COM + q_value = h5_group["Q-value"][()] + return MT, xs, xs_offset, reference_frame, q_value + + +def set_angular_distribution(h5_group): + mu_type = h5_group.attrs["type"] + if mu_type == "isotropic": + angle_type = ANGLE_ISOTROPIC + mu = simulation.distributions[0] + elif mu_type == "energy-correlated": + angle_type = ANGLE_ENERGY_CORRELATED + mu = simulation.distributions[0] + else: + angle_type = ANGLE_DISTRIBUTED + grid = h5_group[f"energy"][()] * 1e6 # MeV to eV + offset = h5_group[f"offset"][()] + value = h5_group[f"value"][()] + pdf = h5_group[f"pdf"][()] + mu = DistributionMultiTable(grid, offset, value, pdf) + + return angle_type, mu + + +def set_energy_distribution(h5_group): + spectrum_type = h5_group.attrs["type"] + + if spectrum_type == "tabulated": + grid = h5_group[f"energy"][()] * 1e6 # MeV to eV + offset = h5_group[f"offset"][()] + value = h5_group[f"value"][()] * 1e6 # MeV to eV + pdf = h5_group[f"pdf"][()] / 1e6 # /MeV to /eV + energy_spectrum = DistributionMultiTable(grid, offset, value, pdf) + + elif spectrum_type == "level-scattering": + C1 = h5_group["C1"][()] * 1e6 # MeV to eV + C2 = h5_group["C2"][()] + + energy_spectrum = DistributionLevelScattering(C1, C2) + + elif spectrum_type == "evaporation": + energy = h5_group[f"temperature_energy_grid"][()] * 1e6 # MeV to eV + temperature = h5_group[f"temperature"][()] * 1e6 # MeV to eV + restriction_energy = h5_group[f"restriction_energy"][()] * 1e6 # MeV to eV + + energy_spectrum = DistributionEvaporation( + energy, temperature, restriction_energy + ) + + elif spectrum_type == "maxwellian": + energy = h5_group[f"temperature_energy_grid"][()] * 1e6 # MeV to eV + temperature = h5_group[f"temperature"][()] * 1e6 # MeV to eV + restriction_energy = h5_group[f"restriction_energy"][()] * 1e6 # MeV to eV + interpolation = h5_group[f"temperature_interpolation"][()].decode("utf-8") + if interpolation == "linear": + interpolation = INTERPOLATION_LINEAR + elif interpolation == "log": + interpolation = INTERPOLATION_LOG + + energy_spectrum = DistributionMaxwellian( + energy, temperature, restriction_energy, interpolation + ) + + elif spectrum_type == "kalbach-mann": + energy = h5_group[f"energy"][()] * 1e6 # MeV to eV + offset = h5_group[f"offset"][()] + + energy_out = h5_group[f"energy_out"][()] * 1e6 # MeV to eV + pdf = h5_group[f"pdf"][()] / 1e6 # /MeV to /eV + + precompound_factor = h5_group[f"precompound_factor"][()] + angular_slope = h5_group[f"angular_slope"][()] + + energy_spectrum = DistributionKalbachMann( + energy, offset, energy_out, pdf, precompound_factor, angular_slope + ) + + elif spectrum_type == "energy-angle-tabulated": + energy = h5_group[f"energy"][()] * 1e6 # MeV to eV + offset = h5_group[f"offset"][()] + + energy_out = h5_group[f"energy_out"][()] * 1e6 # MeV to eV + pdf = h5_group[f"pdf"][()] / 1e6 # /MeV to /eV + cosine_offset = h5_group[f"cosine_offset"][()] + + cosine = h5_group[f"cosine"][()] + cosine_pdf = h5_group[f"cosine_pdf"][()] + + energy_spectrum = DistributionTabulatedEnergyAngle( + energy, offset, energy_out, pdf, cosine_offset, cosine, cosine_pdf + ) + + elif spectrum_type == "N-body": + value = h5_group["value"][()] * 1e6 # MeV to eV + pdf = h5_group["pdf"][()] / 1e6 # /MeV to /eV + + energy_spectrum = DistributionNBody(value, pdf) + + else: + print_error(f"Unsupported energy spectrum of type {spectrum_type}") + + return energy_spectrum From 6b521a821e5f9c66aa9b972d460269b1608c3395 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 15 Apr 2026 11:07:25 -0700 Subject: [PATCH 29/64] add proton reactions to nuclide.py --- mcdc/object_/nuclide.py | 103 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index 80ca3e669..3c74e72d8 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -18,6 +18,12 @@ NeutronReactionInelasticScattering, set_energy_distribution, ) +from mcdc.object_.proton_reaction import( + ProtonReactionCapture, + ProtonReactionElasticScattering, + ProtonReactionInelasticScattering, + set_energy_distribution, +) from mcdc.object_.simulation import simulation from mcdc.print_ import print_1d_array, print_error @@ -44,11 +50,19 @@ class Nuclide(ObjectNonSingleton): neutron_capture_xs: NDArray[float64] neutron_inelastic_xs: NDArray[float64] neutron_fission_xs: NDArray[float64] + proton_xs_energy_grid: NDArray[float64] + proton_total_xs: NDArray[float64] + proton_elastic_xs: NDArray[float64] + proton_capture_xs: NDArray[float64] + proton_inelastic_xs: NDArray[float64] # neutron_elastic_scattering_reactions: list[NeutronReactionElasticScattering] neutron_capture_reactions: list[NeutronReactionCapture] neutron_inelastic_scattering_reactions: list[NeutronReactionInelasticScattering] neutron_fission_reactions: list[NeutronReactionFission] + proton_elastic_scattering_reactions: list[ProtonReactionElasticScattering] + proton_capture_reactions: list[ProtonReactionCapture] + proton_inelastic_scattering_reactions: list[ProtonReactionInelasticScattering] # neutron_fission_prompt_multiplicity: DataBase neutron_fission_delayed_multiplicity: DataBase @@ -213,6 +227,95 @@ def set_neutron_data(self): file.close() + def set_proton_data(self): + nuclide_name = self.name + # All proton data in ENDF70PROT is at 293.6K + temperature = 293.6 + + # Load data library + dir_name = os.getenv("MCDC_LIB") + file_name = f"{nuclide_name}-{temperature}K.h5" + file = h5py.File(f"{dir_name}/{file_name}", "r") + + rx_names = [ + "elastic_scattering", + "capture", + "inelastic_scattering", + ] + + # The reaction MTs + MTs = {} + for name in rx_names: + if name not in file["proton_reactions"]: + MTs[name] = [] + continue + + MTs[name] = [ + x for x in file[f"proton_reactions/{name}"] if x.startswith("MT") + ] + + # ========================================================================== + # Reaction XS + # ========================================================================== + + # Energy grid + xs_energy = file["proton_reactions/xs_energy_grid"][()] * 1e6 # MeV to eV + self.proton_xs_energy_grid = xs_energy + + # The total XS + self.proton_total_xs = np.zeros_like(self.proton_xs_energy_grid) + self.proton_elastic_xs = np.zeros_like(self.proton_xs_energy_grid) + self.proton_capture_xs = np.zeros_like(self.proton_xs_energy_grid) + self.proton_inelastic_xs = np.zeros_like(self.proton_xs_energy_grid) + + xs_containers = [ + self.proton_elastic_xs, + self.proton_capture_xs, + self.proton_inelastic_xs, + ] + + for xs_container, rx_name in list(zip(xs_containers, rx_names)): + for MT in MTs[rx_name]: + xs = file[f"proton_reactions/{rx_name}/{MT}/xs"] + xs_container[xs.attrs["offset"] :] += xs[()] + + self.proton_total_xs = ( + self.proton_elastic_xs + + self.proton_capture_xs + + self.proton_inelastic_xs + ) + + + # ========================================================================== + # The reactions + # ========================================================================== + + self.proton_elastic_scattering_reactions = [] + self.proton_capture_reactions = [] + self.proton_inelastic_scattering_reactions = [] + + rx_containers = [ + self.proton_elastic_scattering_reactions, + self.proton_capture_reactions, + self.proton_inelastic_scattering_reactions, + ] + rx_classes = [ + ProtonReactionElasticScattering, + ProtonReactionCapture, + ProtonReactionInelasticScattering, + ] + for rx_container, rx_name, rx_class in list( + zip(rx_containers, rx_names, rx_classes) + ): + for MT in MTs[rx_name]: + h5_group = file[f"proton_reactions/{rx_name}/{MT}"] + reaction = rx_class.from_h5_group(h5_group) + rx_container.append(reaction) + + file.close() + + + ## UPDATE this for protons def __repr__(self): text = "\n" text += f"Nuclide\n" From 21a2cc18266d6e4713536e9d9173eb7db1e801b8 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 15 Apr 2026 11:42:14 -0700 Subject: [PATCH 30/64] add protons to mcdc/transport/physics --- mcdc/transport/physics/__init__.py | 1 + mcdc/transport/physics/interface.py | 9 + mcdc/transport/physics/proton/__init__.py | 8 + mcdc/transport/physics/proton/interface.py | 61 ++ mcdc/transport/physics/proton/multigroup.py | 375 +++++++++++ mcdc/transport/physics/proton/native.py | 687 ++++++++++++++++++++ mcdc/transport/physics/util.py | 12 + 7 files changed, 1153 insertions(+) create mode 100644 mcdc/transport/physics/proton/__init__.py create mode 100644 mcdc/transport/physics/proton/interface.py create mode 100644 mcdc/transport/physics/proton/multigroup.py create mode 100644 mcdc/transport/physics/proton/native.py diff --git a/mcdc/transport/physics/__init__.py b/mcdc/transport/physics/__init__.py index 66133009c..72a579f30 100644 --- a/mcdc/transport/physics/__init__.py +++ b/mcdc/transport/physics/__init__.py @@ -7,3 +7,4 @@ ) import mcdc.transport.physics.electron as electron import mcdc.transport.physics.neutron as neutron +import mcdc.transport.physics.proton as proton diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index ef3cef349..9d5ed9f02 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -7,6 +7,7 @@ import mcdc.transport.rng as rng import mcdc.transport.physics.electron as electron import mcdc.transport.physics.neutron as neutron +import mcdc.transport.physics.proton as proton from mcdc.constant import * @@ -22,6 +23,8 @@ def particle_speed(particle_container, simulation, data): return neutron.particle_speed(particle_container, simulation, data) elif particle["particle_type"] == PARTICLE_ELECTRON: return electron.particle_speed(particle_container, simulation, data) + elif particle["particle_type"] == PARTICLE_PROTON: + return proton.particle_speed(particle_container, simulation, data) return -1.0 @@ -37,6 +40,8 @@ def macro_xs(reaction_type, particle_container, simulation, data): return neutron.macro_xs(reaction_type, particle_container, simulation, data) elif particle["particle_type"] == PARTICLE_ELECTRON: return electron.macro_xs(reaction_type, particle_container, simulation, data) + elif particle["particle_type"] == PARTICLE_PROTON: + return proton.macro_xs(reaction_type, particle_container, simulation, data) return -1.0 @@ -65,6 +70,8 @@ def collision_distance(particle_container, simulation, data): SigmaT = macro_xs(NEUTRON_REACTION_TOTAL, particle_container, simulation, data) elif particle["particle_type"] == PARTICLE_ELECTRON: SigmaT = macro_xs(ELECTRON_REACTION_TOTAL, particle_container, simulation, data) + elif particle["particle_type"] == PARTICLE_PROTON: + SigmaT = macro_xs(PROTON_REACTION_TOTAL, particle_container, simulation, data) # Vacuum material? if SigmaT == 0.0: @@ -84,3 +91,5 @@ def collision(particle_container, collision_data_container, program, data): neutron.collision(particle_container, collision_data_container, program, data) elif particle["particle_type"] == PARTICLE_ELECTRON: electron.collision(particle_container, collision_data_container, program, data) + elif particle["particle_type"] == PARTICLE_PROTON: + proton.collision(particle_container, collision_data_container, program, data) diff --git a/mcdc/transport/physics/proton/__init__.py b/mcdc/transport/physics/proton/__init__.py new file mode 100644 index 000000000..d95186ada --- /dev/null +++ b/mcdc/transport/physics/proton/__init__.py @@ -0,0 +1,8 @@ +from .interface import ( + particle_speed, + macro_xs, + # proton_production_xs, + collision, +) +import mcdc.transport.physics.proton.native as native +import mcdc.transport.physics.proton.multigroup as multigroup diff --git a/mcdc/transport/physics/proton/interface.py b/mcdc/transport/physics/proton/interface.py new file mode 100644 index 000000000..ebbc1b99b --- /dev/null +++ b/mcdc/transport/physics/proton/interface.py @@ -0,0 +1,61 @@ +from numba import njit + +#### + +import mcdc.transport.physics.proton.multigroup as multigroup +import mcdc.transport.physics.proton.native as native +import mcdc.transport.util as util + +# ====================================================================================== +# Particle attributes +# ====================================================================================== + + +@njit +def particle_speed(particle_container, simulation, data): + if simulation["settings"]["proton_multigroup_mode"]: + return multigroup.particle_speed(particle_container, simulation, data) + else: + return native.particle_speed(particle_container) + + +# ====================================================================================== +# Material properties +# ====================================================================================== + + +@njit +def macro_xs(reaction_type, particle_container, simulation, data): + if simulation["settings"]["proton_multigroup_mode"]: + return multigroup.macro_xs(reaction_type, particle_container, simulation, data) + else: + return native.macro_xs(reaction_type, particle_container, simulation, data) + + +# @njit +# def proton_production_xs(reaction_type, particle_container, simulation, data): +# if simulation["settings"]["proton_multigroup_mode"]: +# return multigroup.proton_production_xs( +# reaction_type, particle_container, simulation, data +# ) +# else: +# return native.proton_production_xs( +# reaction_type, particle_container, simulation, data +# ) + + +# ====================================================================================== +# Collision +# ====================================================================================== + + +@njit +def collision(particle_container, collision_data_container, program, data): + simulation = util.access_simulation(program) + + if simulation["settings"]["proton_multigroup_mode"]: + multigroup.collision( + particle_container, collision_data_container, program, data + ) + else: + native.collision(particle_container, collision_data_container, program, data) diff --git a/mcdc/transport/physics/proton/multigroup.py b/mcdc/transport/physics/proton/multigroup.py new file mode 100644 index 000000000..f91aae924 --- /dev/null +++ b/mcdc/transport/physics/proton/multigroup.py @@ -0,0 +1,375 @@ +import numpy as np +import math + +from numba import njit + +#### + +import mcdc.mcdc_get as mcdc_get +import mcdc.numba_types as type_ +import mcdc.transport.particle as particle_module +import mcdc.transport.particle_bank as particle_bank_module +import mcdc.transport.rng as rng +import mcdc.transport.util as util + +from mcdc.constant import ( + PI, + PROTON_REACTION_TOTAL, + PROTON_REACTION_CAPTURE, + PROTON_REACTION_ELASTIC_SCATTERING, + ) +from mcdc.transport.physics.util import scatter_direction +from mcdc.transport.distribution import sample_isotropic_direction + +# ====================================================================================== +# Particle attributes +# ====================================================================================== + + +@njit +def particle_speed(particle_container, simulation, data): + particle = particle_container[0] + material = simulation["multigroup_materials"][particle["material_ID"]] + return mcdc_get.multigroup_material.mgxs_speed(particle["g"], material, data) + + +# ====================================================================================== +# Material properties +# ====================================================================================== + + +@njit +def macro_xs(reaction_type, particle_container, simulation, data): + particle = particle_container[0] + material = simulation["multigroup_materials"][particle["material_ID"]] + g = particle["g"] + + if reaction_type == PROTON_REACTION_TOTAL: + return mcdc_get.multigroup_material.mgxs_total(g, material, data) + elif reaction_type == PROTON_REACTION_CAPTURE: + return mcdc_get.multigroup_material.mgxs_capture(g, material, data) + elif reaction_type == PROTON_REACTION_ELASTIC_SCATTERING: + return mcdc_get.multigroup_material.mgxs_scatter(g, material, data) + return 0.0 + + +# @njit +# def proton_production_xs(reaction_type, particle_container, simulation, data): +# particle = particle_container[0] +# material = simulation["multigroup_materials"][particle["material_ID"]] +# g = particle["g"] + +# # Total production +# if reaction_type == PROTON_REACTION_TOTAL: +# total = 0.0 + +# # Scattering production +# nu = mcdc_get.multigroup_material.mgxs_nu_s(g, material, data) +# xs = mcdc_get.multigroup_material.mgxs_scatter(g, material, data) +# total += nu * xs + +# # Fission production +# nu = mcdc_get.multigroup_material.mgxs_nu_f(g, material, data) +# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) +# total += nu * xs +# return total + +# # Capture production (none) +# elif reaction_type == PROTON_REACTION_CAPTURE: +# return 0.0 + +# # Scattering production +# elif reaction_type == NEUTRON_REACTION_ELASTIC_SCATTERING: +# nu = mcdc_get.multigroup_material.mgxs_nu_s(g, material, data) +# xs = mcdc_get.multigroup_material.mgxs_scatter(g, material, data) +# return nu * xs + +# # Fission production +# elif reaction_type == NEUTRON_REACTION_FISSION: +# nu = mcdc_get.multigroup_material.mgxs_nu_f(g, material, data) +# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) +# return nu * xs + +# # Prompt fission production +# elif reaction_type == NEUTRON_REACTION_FISSION_PROMPT: +# nu = mcdc_get.multigroup_material.mgxs_nu_p(g, material, data) +# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) +# return nu * xs + +# # Delayed neutron production +# elif reaction_type == NEUTRON_REACTION_FISSION_DELAYED: +# nu = mcdc_get.multigroup_material.mgxs_nu_d_total(g, material, data) +# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) +# return nu * xs + +# # Unsupported default +# return 0.0 + + +# ====================================================================================== +# Collision +# ====================================================================================== + + +@njit +def collision(particle_container, collision_data_container, program, data): + simulation = util.access_simulation(program) + particle = particle_container[0] + + # Get the reaction cross-sections + SigmaT = macro_xs(PROTON_REACTION_TOTAL, particle_container, simulation, data) + SigmaS = macro_xs( + PROTON_REACTION_ELASTIC_SCATTERING, particle_container, simulation, data + ) + SigmaC = macro_xs(PROTON_REACTION_CAPTURE, particle_container, simulation, data) + + # Implicit capture + if simulation["implicit_capture"]["active"]: + particle["w"] *= (SigmaT - SigmaC) / SigmaT + SigmaT -= SigmaC + + # Sample reaction type and perform the reaction + xi = rng.lcg(particle_container) * SigmaT + total = SigmaS + if total > xi: + scattering(particle_container, program, data) + else: + particle["alive"] = False + + +# ====================================================================================== +# Reactions +# ====================================================================================== + + +@njit +def scattering(particle_container, program, data): + simulation = util.access_simulation(program) + + # Particle attributes + particle = particle_container[0] + g = particle["g"] + ux = particle["ux"] + uy = particle["uy"] + uz = particle["uz"] + + # Material attributes + material = simulation["multigroup_materials"][particle["material_ID"]] + G = material["G"] + + # Kill the current particle + particle["alive"] = False + + # Adjust production and product weights if weighted emission + weight_production = 1.0 + weight_product = particle["w"] + if simulation["weighted_emission"]["active"]: + weight_target = simulation["weighted_emission"]["weight_target"] + weight_production = particle["w"] / weight_target + weight_product = weight_target + + # Get number of secondaries + nu_s = mcdc_get.multigroup_material.mgxs_nu_s(g, material, data) + N = int(math.floor(weight_production * nu_s + rng.lcg(particle_container))) + + # Set up secondary partice container + particle_container_new = util.local_array(1, type_.particle_data) + particle_new = particle_container_new[0] + + # Create the secondaries + for n in range(N): + # Set default attributes + particle_module.copy_as_child(particle_container_new, particle_container) + + # Set weight + particle_new["w"] = weight_product + + # Sample scattering angle + mu0 = 2.0 * rng.lcg(particle_container_new) - 1.0 + + # Scatter direction + azi = 2.0 * PI * rng.lcg(particle_container_new) + ux_new, uy_new, uz_new = scatter_direction(ux, uy, uz, mu0, azi) + particle_new["ux"] = ux_new + particle_new["uy"] = uy_new + particle_new["uz"] = uz_new + + # Get outgoing spectrum + stride = material["G"] + start = material["mgxs_chi_s_offset"] + g * stride + chi_s = data[start : start + stride] + # Above is equivalent to: chi_s = mcdc_get.multigroup_material.mgxs_chi_s_vector(g, material, data) + + # Sample outgoing energy + xi = rng.lcg(particle_container_new) + total = 0.0 + for g_out in range(G): + total += chi_s[g_out] + if total > xi: + break + particle_new["g"] = g_out + + # Bank, but keep it if it is the last particle + if n == N - 1: + particle["alive"] = True + particle["ux"] = particle_new["ux"] + particle["uy"] = particle_new["uy"] + particle["uz"] = particle_new["uz"] + particle["g"] = particle_new["g"] + particle["E"] = particle_new["E"] + particle["w"] = particle_new["w"] + else: + particle_bank_module.bank_active_particle(particle_container_new, program) + + +# @njit +# def fission(particle_container, program, data): +# simulation = util.access_simulation(program) +# settings = simulation["settings"] + +# # Particle properties +# particle = particle_container[0] +# g = particle["g"] + +# # Material properties +# material = simulation["multigroup_materials"][particle["material_ID"]] +# G = material["G"] +# J = material["J"] + +# # Kill the current particle +# particle["alive"] = False + +# # Adjust production and product weights if weighted emission +# weight_production = 1.0 +# weight_product = particle["w"] +# if simulation["weighted_emission"]["active"]: +# weight_target = simulation["weighted_emission"]["weight_target"] +# weight_production = particle["w"] / weight_target +# weight_product = weight_target + +# # Fission yields +# nu = mcdc_get.multigroup_material.mgxs_nu_f(g, material, data) +# nu_p = mcdc_get.multigroup_material.mgxs_nu_p(g, material, data) +# if J > 0: +# stride = material["J"] +# start = material["mgxs_nu_d_offset"] + g * stride +# nu_d = data[start : start + stride] +# # Above is equivalent to: nu_d = mcdc_get.multigroup_material.mgxs_nu_d_vector(g, material, data) + +# # Get number of secondaries +# N = int( +# math.floor( +# weight_production * nu / simulation["k_eff"] + rng.lcg(particle_container) +# ) +# ) + +# # Set up secondary partice container +# particle_container_new = util.local_array(1, type_.particle_data) +# particle_new = particle_container_new[0] + +# # Create the secondaries +# for n in range(N): +# # Set default attributes +# particle_module.copy_as_child(particle_container_new, particle_container) + +# # Set weight +# particle_new["w"] = weight_product + +# # Sample isotropic direction +# ux_new, uy_new, uz_new = sample_isotropic_direction(particle_container_new) +# particle_new["ux"] = ux_new +# particle_new["uy"] = uy_new +# particle_new["uz"] = uz_new + +# # Prompt or delayed? +# xi = rng.lcg(particle_container_new) * nu +# total = nu_p +# if xi < total: +# prompt = True +# stride = material["G"] +# start = material["mgxs_chi_p_offset"] + g * stride +# spectrum = data[start : start + stride] +# # Above is equivalent to: spectrum = mcdc_get.multigroup_material.mgxs_chi_p_vector(g, material, data) +# else: +# prompt = False + +# # Determine delayed group, decay constant, and spectrum +# for j in range(J): +# total += nu_d[j] +# if xi < total: +# stride = material["G"] +# start = material["mgxs_chi_d_offset"] + j * stride +# spectrum = data[start : start + stride] +# # Above is equivalent to: +# # spectrum = mcdc_get.multigroup_material.mgxs_chi_d_vector( +# # j, material, data +# # ) +# decay = mcdc_get.multigroup_material.mgxs_decay_rate( +# j, material, data +# ) +# break + +# # Sample outgoing energy +# xi = rng.lcg(particle_container_new) +# tot = 0.0 +# for g_out in range(G): +# tot += spectrum[g_out] +# if tot > xi: +# break +# particle_new["g"] = g_out + +# # Sample emission time +# if not prompt: +# xi = rng.lcg(particle_container_new) +# particle_new["t"] -= math.log(xi) / decay + +# # Eigenvalue mode: bank right away +# if settings["neutron_eigenvalue_mode"]: +# particle_bank_module.bank_census_particle(particle_container_new, program) +# continue +# # Below is only relevant for fixed-source problem + +# # Skip if it's beyond time boundary +# if particle_new["t"] > settings["time_boundary"]: +# continue + +# # Check if it hits current or next census times +# hit_current_census = False +# hit_future_census = False +# idx_census = simulation["idx_census"] +# if settings["N_census"] > 1: +# if particle_new["t"] > mcdc_get.settings.census_time( +# idx_census, settings, data +# ): +# hit_current_census = True +# if particle_new["t"] > mcdc_get.settings.census_time( +# idx_census + 1, settings, data +# ): +# hit_future_census = True + +# # Not hitting census --> add to active bank +# if not hit_current_census: +# # Keep it if it is the last particle +# if n == N - 1: +# particle["alive"] = True +# particle["ux"] = particle_new["ux"] +# particle["uy"] = particle_new["uy"] +# particle["uz"] = particle_new["uz"] +# particle["t"] = particle_new["t"] +# particle["g"] = particle_new["g"] +# particle["E"] = particle_new["E"] +# particle["w"] = particle_new["w"] +# else: +# particle_bank_module.bank_active_particle( +# particle_container_new, program +# ) + +# # Hit future census --> add to future bank +# elif hit_future_census: +# # Particle will participate in the future +# particle_bank_module.bank_future_particle(particle_container_new, program) + +# # Hit current census --> add to census bank +# else: +# # Particle will participate after the current census is completed +# particle_bank_module.bank_census_particle(particle_container_new, program) diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py new file mode 100644 index 000000000..4ef71f3a3 --- /dev/null +++ b/mcdc/transport/physics/proton/native.py @@ -0,0 +1,687 @@ +import math + +from numba import njit + +#### + +import mcdc.mcdc_get as mcdc_get +import mcdc.numba_types as type_ +import mcdc.transport.particle as particle_module +import mcdc.transport.particle_bank as particle_bank_module +import mcdc.transport.rng as rng +import mcdc.transport.util as util + +from mcdc.constant import ( + ANGLE_DISTRIBUTED, + ANGLE_ENERGY_CORRELATED, + ANGLE_ISOTROPIC, + BOLTZMANN_K, + THERMAL_THRESHOLD_FACTOR, + LIGHT_SPEED, + PROTON_MASS, + PI, + PI_HALF, + PI_SQRT, + PROTON_REACTION_INELASTIC_SCATTERING, + PROTON_REACTION_TOTAL, + PROTON_REACTION_CAPTURE, + PROTON_REACTION_ELASTIC_SCATTERING, + REFERENCE_FRAME_COM, +) +from mcdc.transport.data import evaluate_data +from mcdc.transport.distribution import ( + sample_correlated_distribution_with_scale, + sample_distribution_with_scale, + sample_isotropic_cosine, + sample_isotropic_direction, + sample_multi_table, +) +from mcdc.transport.physics.util import ( + evaluate_proton_xs_energy_grid, + scatter_direction, +) +from mcdc.transport.util import find_bin, linear_interpolation + +# ====================================================================================== +# Particle attributes +# ====================================================================================== + + +@njit +def particle_speed(particle_container): + particle = particle_container[0] + E = particle["E"] + mass = PROTON_MASS + return LIGHT_SPEED * math.sqrt(E * (E + 2.0 * mass)) / (E + mass) + + +@njit +def particle_energy_from_speed(speed): + beta = speed / LIGHT_SPEED + gamma = 1.0 / math.sqrt(1.0 - beta * beta) + mass = PROTON_MASS + return mass * (gamma - 1.0) + + +# ====================================================================================== +# Material properties +# ====================================================================================== + + +@njit +def macro_xs(reaction_type, particle_container, simulation, data): + particle = particle_container[0] + material = simulation["native_materials"][particle["material_ID"]] + E = particle["E"] + + total = 0.0 + + for i in range(material["N_nuclide"]): + nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + nuclide = simulation["nuclides"][nuclide_ID] + + nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + xs = total_micro_xs(reaction_type, E, nuclide, data) + + total += nuclide_density * xs + + return total + + +@njit +def total_micro_xs(reaction_type, E, nuclide, data): + idx, E0, E1 = evaluate_proton_xs_energy_grid(E, nuclide, data) + if reaction_type == PROTON_REACTION_TOTAL: + xs0 = mcdc_get.nuclide.proton_total_xs(idx, nuclide, data) + xs1 = mcdc_get.nuclide.proton_total_xs(idx + 1, nuclide, data) + elif reaction_type == PROTON_REACTION_ELASTIC_SCATTERING: + xs0 = mcdc_get.nuclide.proton_elastic_xs(idx, nuclide, data) + xs1 = mcdc_get.nuclide.proton_elastic_xs(idx + 1, nuclide, data) + elif reaction_type == PROTON_REACTION_CAPTURE: + xs0 = mcdc_get.nuclide.proton_capture_xs(idx, nuclide, data) + xs1 = mcdc_get.nuclide.proton_capture_xs(idx + 1, nuclide, data) + elif reaction_type == PROTON_REACTION_INELASTIC_SCATTERING: + xs0 = mcdc_get.nuclide.proton_inelastic_xs(idx, nuclide, data) + xs1 = mcdc_get.nuclide.proton_inelastic_xs(idx + 1, nuclide, data) + else: + # Should be unreachable + xs0 = 0.0 + xs1 = 0.0 + return linear_interpolation(E, E0, E1, xs0, xs1) + + +@njit +def reaction_micro_xs(E, reaction_base, nuclide, data): + idx, E0, E1 = evaluate_proton_xs_energy_grid(E, nuclide, data) + + # Apply offset + offset = reaction_base["xs_offset_"] + if idx < offset: + return 0.0 + else: + idx -= offset + + xs0 = mcdc_get.proton_reaction.xs(idx, reaction_base, data) + xs1 = mcdc_get.proton_reaction.xs(idx + 1, reaction_base, data) + return linear_interpolation(E, E0, E1, xs0, xs1) + + +# @njit +# def proton_production_xs(reaction_type, particle_container, simulation, data): +# # Total production +# if reaction_type == PROTON_REACTION_TOTAL: +# elastic_xs = macro_xs( +# PROTON_REACTION_ELASTIC_SCATTERING, particle_container, simulation, data +# ) +# inelastic_xs = _proton_inelastic_scattering_production_xs( +# particle_container, simulation, data +# ) +# return elastic_xs + inelastic_xs + +# # Elastic scattering production +# elif reaction_type == PROTON_REACTION_ELASTIC_SCATTERING: +# return macro_xs(reaction_type, particle_container, simulation, data) + +# # Capture production (none) +# elif reaction_type == PROTON_REACTION_CAPTURE: +# return 0.0 + +# # Inelastic scattering production +# elif reaction_type == PROTON_REACTION_INELASTIC_SCATTERING: +# return _proton_inelastic_scattering_production_xs( +# particle_container, simulation, data +# ) + +# # Unsupported default +# else: +# return 0.0 + + +# @njit +# def _proton_inelastic_scattering_production_xs(particle_container, simulation, data): +# particle = particle_container[0] +# material_base = simulation["materials"][particle["material_ID"]] +# material = simulation["native_materials"][material_base["child_ID"]] + +# total = 0.0 +# for i in range(material["N_nuclide"]): +# nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) +# nuclide = simulation["nuclides"][nuclide_ID] + +# E = particle["E"] +# nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + +# for j in range(nuclide["N_proton_inelastic_scattering_reaction"]): +# reaction_ID = int( +# mcdc_get.nuclide.proton_inelastic_scattering_reaction_IDs( +# j, nuclide, data +# ) +# ) +# reaction_base = simulation["proton_reactions"][reaction_ID] +# reaction = simulation["proton_inelastic_scattering_reactions"][ +# reaction_base["child_ID"] +# ] + +# xs = reaction_micro_xs(E, reaction_base, nuclide, data) +# nu = reaction["multiplicity"] +# total += nuclide_density * nu * xs + +# return total + + +# ====================================================================================== +# Collision +# ====================================================================================== + + +@njit +def collision(particle_container, collision_data_container, program, data): + simulation = util.access_simulation(program) + particle = particle_container[0] + collision_data = collision_data_container[0] + material = simulation["native_materials"][particle["material_ID"]] + + # Particle properties + E = particle["E"] + + # ================================================================================== + # Sample colliding nuclide + # ================================================================================== + + SigmaT = macro_xs(PROTON_REACTION_TOTAL, particle_container, simulation, data) + + # Implicit capture + if simulation["implicit_capture"]["active"]: + # Calculate capture fraction + SigmaC = macro_xs( + PROTON_REACTION_CAPTURE, particle_container, simulation, data + ) + capture_fraction = SigmaC / SigmaT + + # Deposit energy captured + collision_data["energy_deposition"] += E * particle["w"] * capture_fraction + + # Q-value: xs-weighted average over all nuclides and capture reactions + for i in range(material["N_nuclide"]): + nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + nuclide = simulation["nuclides"][nuclide_ID] + nuclide_density = mcdc_get.native_material.nuclide_densities( + i, material, data + ) + for j in range(nuclide["N_proton_capture_reaction"]): + reaction_ID = int( + mcdc_get.nuclide.proton_capture_reaction_IDs(j, nuclide, data) + ) + reaction = simulation["proton_capture_reactions"][reaction_ID] + reaction_base_ID = reaction["parent_ID"] + reaction_base = simulation["proton_reactions"][reaction_base_ID] + xs = reaction_micro_xs(E, reaction_base, nuclide, data) + Sigma_rx = nuclide_density * xs + collision_data["energy_deposition"] += ( + reaction_base["q_value"] * 1e6 * particle["w"] * Sigma_rx / SigmaT + ) + + # Capture particle weight + particle["w"] *= 1.0 - capture_fraction + + # Adjust total XS + SigmaT -= SigmaC + + xi = rng.lcg(particle_container) * SigmaT + total = 0.0 + for i in range(material["N_nuclide"]): + nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + nuclide = simulation["nuclides"][nuclide_ID] + + nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + sigmaT = total_micro_xs(PROTON_REACTION_TOTAL, E, nuclide, data) + + if simulation["implicit_capture"]["active"]: + sigmaC = total_micro_xs(PROTON_REACTION_CAPTURE, E, nuclide, data) + sigmaT -= sigmaC + + SigmaT_nuclide = nuclide_density * sigmaT + total += SigmaT_nuclide + + if total > xi: + break + + # ================================================================================== + # Sample and perform reaction + # ================================================================================== + + sigma_elastic = total_micro_xs( + PROTON_REACTION_ELASTIC_SCATTERING, E, nuclide, data + ) + sigma_inelastic = total_micro_xs( + PROTON_REACTION_INELASTIC_SCATTERING, E, nuclide, data + ) + xi = rng.lcg(particle_container) * sigmaT + + # Elastic scattering + total = sigma_elastic + if xi < total: + # Sample the actual reaction from the group + total -= sigma_elastic + for i in range(nuclide["N_proton_elastic_scattering_reaction"]): + reaction_ID = int( + mcdc_get.nuclide.proton_elastic_scattering_reaction_IDs( + i, nuclide, data + ) + ) + reaction = simulation["proton_elastic_scattering_reactions"][reaction_ID] + reaction_base_ID = reaction["parent_ID"] + reaction_base = simulation["proton_reactions"][reaction_base_ID] + total += reaction_micro_xs(E, reaction_base, nuclide, data) + + # Execute the reaction + if xi < total: + elastic_scattering( + reaction, + particle_container, + collision_data_container, + nuclide, + simulation, + data, + ) + return + + # Capture + if not simulation["implicit_capture"]["active"]: + sigma_capture = total_micro_xs(PROTON_REACTION_CAPTURE, E, nuclide, data) + total += sigma_capture + if xi < total: + # Sample the actual reaction from the group + total -= sigma_capture + for i in range(nuclide["N_proton_capture_reaction"]): + reaction_ID = int( + mcdc_get.nuclide.proton_capture_reaction_IDs(i, nuclide, data) + ) + reaction = simulation["proton_capture_reactions"][reaction_ID] + reaction_base_ID = reaction["parent_ID"] + reaction_base = simulation["proton_reactions"][reaction_base_ID] + xs = reaction_micro_xs(E, reaction_base, nuclide, data) + total += xs + + # Execute the reaction + if xi < total: + capture( + reaction, + particle_container, + collision_data_container, + nuclide, + simulation, + data, + ) + return + + # Inelastic scattering + total += sigma_inelastic + if xi < total: + # Sample the actual reaction from the group + total -= sigma_inelastic + for i in range(nuclide["N_proton_inelastic_scattering_reaction"]): + reaction_ID = int( + mcdc_get.nuclide.proton_inelastic_scattering_reaction_IDs( + i, nuclide, data + ) + ) + reaction = simulation["proton_inelastic_scattering_reactions"][reaction_ID] + reaction_base_ID = reaction["parent_ID"] + reaction_base = simulation["proton_reactions"][reaction_base_ID] + xs = reaction_micro_xs(E, reaction_base, nuclide, data) + total += xs + + # Execute the reaction + if xi < total: + inelastic_scattering( + reaction, + particle_container, + collision_data_container, + nuclide, + program, + data, + ) + return + +# ====================================================================================== +# Capture +# ====================================================================================== + + +@njit +def capture( + reaction, particle_container, collision_data_container, nuclide, simulation, data +): + particle = particle_container[0] + collision_data = collision_data_container[0] + + reaction_base_ID = reaction["parent_ID"] + reaction_base = simulation["proton_reactions"][reaction_base_ID] + + # Terminate the particle + particle["alive"] = False + + # Energy deposition + E = particle["E"] + q_value = reaction_base["q_value"] * 1e6 + collision_data["energy_deposition"] += (E + q_value) * particle["w"] + + +# ====================================================================================== +# Elastic scattering +# ====================================================================================== + + +@njit +def elastic_scattering( + reaction, particle_container, collision_data_container, nuclide, simulation, data +): + particle = particle_container[0] + collision_data = collision_data_container[0] + + # Particle attributes + E = particle["E"] + ux = particle["ux"] + uy = particle["uy"] + uz = particle["uz"] + + # Energy deposition + collision_data["energy_deposition"] += E * particle["w"] + # Note: Q-value is zero in elastic scattering + + # Sample nucleus thermal velocity + A = nuclide["atomic_weight_ratio"] + temperature = nuclide["temperature"] + if E > THERMAL_THRESHOLD_FACTOR * BOLTZMANN_K * temperature: + Vx = 0.0 + Vy = 0.0 + Vz = 0.0 + else: + Vx, Vy, Vz = sample_nucleus_velocity(A, particle_container) + + # ========================================================================= + # COM kinematics + # ========================================================================= + + # Particle speed + speed = particle_speed(particle_container) + + # Proton velocity - LAB + vx = speed * ux + vy = speed * uy + vz = speed * uz + + # COM velocity + COM_x = (vx + A * Vx) / (1.0 + A) + COM_y = (vy + A * Vy) / (1.0 + A) + COM_z = (vz + A * Vz) / (1.0 + A) + + # Proton velocity - COM + vx = vx - COM_x + vy = vy - COM_y + vz = vz - COM_z + + # Proton speed - COM + speed = math.sqrt(vx * vx + vy * vy + vz * vz) + + # Proton initial direction - COM + ux = vx / speed + uy = vy / speed + uz = vz / speed + + # Sample the scattering cosine from the multi-PDF distribution + multi_table = simulation["multi_table_distributions"][reaction["mu_table_ID"]] + mu0 = sample_multi_table(E, particle_container, multi_table, data) + + # Scatter the direction in COM + azi = 2.0 * PI * rng.lcg(particle_container) + ux_new, uy_new, uz_new = scatter_direction(ux, uy, uz, mu0, azi) + + # Proton final velocity - COM + vx = speed * ux_new + vy = speed * uy_new + vz = speed * uz_new + + # ========================================================================= + # COM to LAB + # ========================================================================= + + # Final velocity - LAB + vx = vx + COM_x + vy = vy + COM_y + vz = vz + COM_z + + # Final energy - LAB + speed = math.sqrt(vx * vx + vy * vy + vz * vz) + particle["E"] = particle_energy_from_speed(speed) + + # Final direction - LAB + particle["ux"] = vx / speed + particle["uy"] = vy / speed + particle["uz"] = vz / speed + + # Subtract outgoing energy from energy deposition + collision_data["energy_deposition"] -= particle["E"] * particle["w"] + + +@njit +def sample_nucleus_velocity(A, particle_container): + particle = particle_container[0] + + # Particle speed + speed = particle_speed(particle_container) + + # Maxwellian parameter + beta = math.sqrt(2.0659834e-11 * A) + # The constant above is + # (1.674927471e-27 kg) / (1.38064852e-19 cm^2 kg s^-2 K^-1) / (293.6 K)/2 + + # Sample nuclide speed candidate V_tilda and + # nuclide-proton polar cosine candidate mu_tilda via + # rejection sampling + y = beta * speed + while True: + if rng.lcg(particle_container) < 2.0 / (2.0 + PI_SQRT * y): + x = math.sqrt( + -math.log(rng.lcg(particle_container) * rng.lcg(particle_container)) + ) + else: + cos_val = math.cos(PI_HALF * rng.lcg(particle_container)) + x = math.sqrt( + -math.log(rng.lcg(particle_container)) + - math.log(rng.lcg(particle_container)) * cos_val * cos_val + ) + V_tilda = x / beta + mu_tilda = 2.0 * rng.lcg(particle_container) - 1.0 + + # Accept candidate V_tilda and mu_tilda? + if rng.lcg(particle_container) > math.sqrt( + speed * speed + V_tilda * V_tilda - 2.0 * speed * V_tilda * mu_tilda + ) / (speed + V_tilda): + break + + # Set nuclide velocity - LAB + azi = 2.0 * PI * rng.lcg(particle_container) + ux, uy, uz = scatter_direction( + particle["ux"], particle["uy"], particle["uz"], mu_tilda, azi + ) + Vx = ux * V_tilda + Vy = uy * V_tilda + Vz = uz * V_tilda + + return Vx, Vy, Vz + + +# ====================================================================================== +# Inelastic scattering +# ====================================================================================== + + +@njit +def inelastic_scattering( + reaction, particle_container, collision_data_container, nuclide, program, data +): + simulation = util.access_simulation(program) + particle = particle_container[0] + collision_data = collision_data_container[0] + + reaction_base_ID = reaction["parent_ID"] + reaction_base = simulation["proton_reactions"][reaction_base_ID] + + # Particle attributes + E = particle["E"] + ux = particle["ux"] + uy = particle["uy"] + uz = particle["uz"] + + # Kill the current particle + particle["alive"] = False + + # Energy deposition + q_value = reaction_base["q_value"] * 1e6 + collision_data["energy_deposition"] += (E + q_value) * particle["w"] + + # Number of secondaries and spectra + N = reaction["multiplicity"] + N_spectrum = reaction["N_spectrum"] + use_all_spectrum = N == N_spectrum + + # Set up secondary partice container + particle_container_new = util.local_array(1, type_.particle_data) + particle_new = particle_container_new[0] + + # Create the secondaries + for n in range(N): + # Set default attributes + particle_module.copy_as_child(particle_container_new, particle_container) + + # ============================================================================== + # Sample angle (if not energy-correlated) + # ============================================================================== + + angle_type = reaction["angle_type"] + if angle_type == ANGLE_ENERGY_CORRELATED: + pass + elif angle_type == ANGLE_ISOTROPIC: + mu = sample_isotropic_cosine(particle_container_new) + elif angle_type == ANGLE_DISTRIBUTED: + distribution_base = simulation["distributions"][reaction["mu_ID"]] + multi_table = simulation["multi_table_distributions"][ + distribution_base["child_ID"] + ] + mu = sample_multi_table(E, particle_container_new, multi_table, data) + + # ============================================================================== + # Sample energy (also angle if correlated) + # ============================================================================== + + # Get energy spectrum + if use_all_spectrum: + ID = int( + mcdc_get.proton_inelastic_scattering_reaction.energy_spectrum_IDs( + n, reaction, data + ) + ) + spectrum_base = simulation["distributions"][ID] + else: + offset = reaction["spectrum_probability_grid_offset"] + length = reaction["spectrum_probability_grid_length"] + probability_grid = data[offset : offset + length] + # Above is equivalent to: + # probability_grid = mcdc_get.proton_inelastic_scattering_reaction.spectrum_probability_grid_all( + # reaction, data + # ) + probability_idx = find_bin(E, probability_grid) + xi = rng.lcg(particle_container_new) + total = 0.0 + for j in range(N_spectrum): + probability = ( + mcdc_get.proton_inelastic_scattering_reaction.spectrum_probability( + probability_idx, j, reaction, data + ) + ) + total += probability + if xi < total: + ID = int( + mcdc_get.proton_inelastic_scattering_reaction.energy_spectrum_IDs( + j, reaction, data + ) + ) + spectrum_base = simulation["distributions"][ID] + break + + # Sample energy + if not angle_type == ANGLE_ENERGY_CORRELATED: + E_new = sample_distribution_with_scale( + E, spectrum_base, particle_container_new, simulation, data + ) + else: + E_new, mu = sample_correlated_distribution_with_scale( + E, spectrum_base, particle_container_new, simulation, data + ) + + # ============================================================================== + # Frame transformation + # ============================================================================== + + reaction_base = simulation["proton_reactions"][int(reaction["parent_ID"])] + reference_frame = reaction_base["reference_frame"] + if reference_frame == REFERENCE_FRAME_COM: + A = nuclide["atomic_weight_ratio"] + mu_COM = mu + E_COM = E_new + + E_new = ( + E_COM + (E + 2 * mu_COM * (A + 1) * math.sqrt(E * E_COM)) / (A + 1) ** 2 + ) + mu = mu_COM * math.sqrt(E_COM / E_new) + math.sqrt(E / E_new) / (A + 1) + + azi = 2.0 * PI * rng.lcg(particle_container_new) + ux_new, uy_new, uz_new = scatter_direction(ux, uy, uz, mu, azi) + + # Now the secondary angle and energy are finalized + particle_new["ux"] = ux_new + particle_new["uy"] = uy_new + particle_new["uz"] = uz_new + particle_new["E"] = E_new + + # Subtract outgoing energy from energy deposition + collision_data["energy_deposition"] -= particle_new["E"] * particle_new["w"] + + # ============================================================================== + # Bank the new particle + # ============================================================================== + + # Keep it if it is the last particle + if n == N - 1: + particle["alive"] = True + particle["ux"] = particle_new["ux"] + particle["uy"] = particle_new["uy"] + particle["uz"] = particle_new["uz"] + particle["E"] = particle_new["E"] + else: + particle_bank_module.bank_active_particle(particle_container_new, program) + + +# No fission for protons \ No newline at end of file diff --git a/mcdc/transport/physics/util.py b/mcdc/transport/physics/util.py index 3475a1510..8788aefce 100644 --- a/mcdc/transport/physics/util.py +++ b/mcdc/transport/physics/util.py @@ -31,6 +31,18 @@ def evaluate_electron_xs_energy_grid(e, element, data): return idx, e0, e1 +@njit +def evaluate_proton_xs_energy_grid(e, nuclide, data): + offset = nuclide["proton_xs_energy_grid_offset"] + length = nuclide["proton_xs_energy_grid_length"] + energy_grid = data[offset : offset + length] + + idx = find_bin(e, energy_grid) + e0 = energy_grid[idx] + e1 = energy_grid[idx + 1] + return idx, e0, e1 + + @njit def scatter_direction(ux, uy, uz, mu0, azi): cos_azi = math.cos(azi) From 69e431014a50ba7b37ae91a0747640d0e1edc403 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 23 Apr 2026 14:15:15 -0700 Subject: [PATCH 31/64] fixed an issue with ProtonReactionBase's MT assignment --- mcdc/object_/proton_reaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py index dd2758822..4df621073 100644 --- a/mcdc/object_/proton_reaction.py +++ b/mcdc/object_/proton_reaction.py @@ -48,12 +48,12 @@ class ProtonReactionBase(ObjectPolymorphic): q_value: float64 def __init__(self, type_, MT, xs, xs_offset, reference_frame, q_value): - super().__init__(type_) self.MT = MT self.xs = xs self.xs_offset_ = xs_offset self.reference_frame = reference_frame self.q_value = q_value + super().__init__(type_) def __repr__(self): text = "\n" From e574ce0a0151702bba0bbde989d559cc91bc8039 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 23 Apr 2026 14:16:48 -0700 Subject: [PATCH 32/64] use ACEtk to generate the hdf5 files from the proton ENDF70PROT data --- tools/data_library_generator/proton_generate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/data_library_generator/proton_generate.py b/tools/data_library_generator/proton_generate.py index af4cf6da5..90058bf5c 100644 --- a/tools/data_library_generator/proton_generate.py +++ b/tools/data_library_generator/proton_generate.py @@ -234,7 +234,6 @@ (fission_MTs, fission_group), ]: for MT in MTs: - print(f'MT = {MT}') idx = rx_block.index(MT) xs = group.create_dataset(f"MT-{MT:03}/xs", data=cross_sections(idx)) xs.attrs["offset"] = offsets(idx) - 1 From e5bff222dfaad7475310bdaac0e3607406132052 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 23 Apr 2026 14:20:16 -0700 Subject: [PATCH 33/64] more proton stuff --- mcdc/main.py | 4 + mcdc/mcdc_get/__init__.py | 8 + mcdc/mcdc_get/nuclide.py | 232 ++++++++++++++++++ mcdc/mcdc_get/proton_capture_reaction.py | 3 + .../proton_elastic_scattering_reaction.py | 3 + .../proton_inelastic_scattering_reaction.py | 84 +++++++ mcdc/mcdc_get/proton_reaction.py | 32 +++ mcdc/mcdc_set/__init__.py | 8 + mcdc/mcdc_set/nuclide.py | 232 ++++++++++++++++++ mcdc/mcdc_set/proton_capture_reaction.py | 3 + .../proton_elastic_scattering_reaction.py | 3 + .../proton_inelastic_scattering_reaction.py | 84 +++++++ mcdc/mcdc_set/proton_reaction.py | 32 +++ mcdc/object_/simulation.py | 3 + 14 files changed, 731 insertions(+) create mode 100644 mcdc/mcdc_get/proton_capture_reaction.py create mode 100644 mcdc/mcdc_get/proton_elastic_scattering_reaction.py create mode 100644 mcdc/mcdc_get/proton_inelastic_scattering_reaction.py create mode 100644 mcdc/mcdc_get/proton_reaction.py create mode 100644 mcdc/mcdc_set/proton_capture_reaction.py create mode 100644 mcdc/mcdc_set/proton_elastic_scattering_reaction.py create mode 100644 mcdc/mcdc_set/proton_inelastic_scattering_reaction.py create mode 100644 mcdc/mcdc_set/proton_reaction.py diff --git a/mcdc/main.py b/mcdc/main.py index 1e9ff34a0..f45af825a 100644 --- a/mcdc/main.py +++ b/mcdc/main.py @@ -168,6 +168,10 @@ def preparation(): if isinstance(material, Material): update_fissionable_from_nuclides(material) + if settings.proton_transport: + for nuclide in simulationPy.nuclides: + nuclide.set_proton_data() + if settings.electron_transport: for element in simulationPy.elements: element.set_electron_data() diff --git a/mcdc/mcdc_get/__init__.py b/mcdc/mcdc_get/__init__.py index 31b31afac..09e537e26 100644 --- a/mcdc/mcdc_get/__init__.py +++ b/mcdc/mcdc_get/__init__.py @@ -80,10 +80,18 @@ import mcdc.mcdc_get.neutron_inelastic_scattering_reaction as neutron_inelastic_scattering_reaction +import mcdc.mcdc_get.proton_capture_reaction as proton_capture_reaction + +import mcdc.mcdc_get.proton_elastic_scattering_reaction as proton_elastic_scattering_reaction + +import mcdc.mcdc_get.proton_inelastic_scattering_reaction as proton_inelastic_scattering_reaction + import mcdc.mcdc_get.collision_data as collision_data import mcdc.mcdc_get.particle_bank as particle_bank +import mcdc.mcdc_get.proton_reaction as proton_reaction + import mcdc.mcdc_get.settings as settings import mcdc.mcdc_get.global_weight_roulette as global_weight_roulette diff --git a/mcdc/mcdc_get/nuclide.py b/mcdc/mcdc_get/nuclide.py index af9593f10..7e0541666 100644 --- a/mcdc/mcdc_get/nuclide.py +++ b/mcdc/mcdc_get/nuclide.py @@ -177,6 +177,151 @@ def neutron_fission_xs_chunk(start, length, nuclide, data): return data[start:end] +@njit +def proton_xs_energy_grid(index, nuclide, data): + offset = nuclide["proton_xs_energy_grid_offset"] + return data[offset + index] + + +@njit +def proton_xs_energy_grid_all(nuclide, data): + start = nuclide["proton_xs_energy_grid_offset"] + size = nuclide["proton_xs_energy_grid_length"] + end = start + size + return data[start:end] + + +@njit +def proton_xs_energy_grid_last(nuclide, data): + start = nuclide["proton_xs_energy_grid_offset"] + size = nuclide["proton_xs_energy_grid_length"] + end = start + size + return data[end - 1] + + +@njit +def proton_xs_energy_grid_chunk(start, length, nuclide, data): + start += nuclide["proton_xs_energy_grid_offset"] + end = start + length + return data[start:end] + + +@njit +def proton_total_xs(index, nuclide, data): + offset = nuclide["proton_total_xs_offset"] + return data[offset + index] + + +@njit +def proton_total_xs_all(nuclide, data): + start = nuclide["proton_total_xs_offset"] + size = nuclide["proton_total_xs_length"] + end = start + size + return data[start:end] + + +@njit +def proton_total_xs_last(nuclide, data): + start = nuclide["proton_total_xs_offset"] + size = nuclide["proton_total_xs_length"] + end = start + size + return data[end - 1] + + +@njit +def proton_total_xs_chunk(start, length, nuclide, data): + start += nuclide["proton_total_xs_offset"] + end = start + length + return data[start:end] + + +@njit +def proton_elastic_xs(index, nuclide, data): + offset = nuclide["proton_elastic_xs_offset"] + return data[offset + index] + + +@njit +def proton_elastic_xs_all(nuclide, data): + start = nuclide["proton_elastic_xs_offset"] + size = nuclide["proton_elastic_xs_length"] + end = start + size + return data[start:end] + + +@njit +def proton_elastic_xs_last(nuclide, data): + start = nuclide["proton_elastic_xs_offset"] + size = nuclide["proton_elastic_xs_length"] + end = start + size + return data[end - 1] + + +@njit +def proton_elastic_xs_chunk(start, length, nuclide, data): + start += nuclide["proton_elastic_xs_offset"] + end = start + length + return data[start:end] + + +@njit +def proton_capture_xs(index, nuclide, data): + offset = nuclide["proton_capture_xs_offset"] + return data[offset + index] + + +@njit +def proton_capture_xs_all(nuclide, data): + start = nuclide["proton_capture_xs_offset"] + size = nuclide["proton_capture_xs_length"] + end = start + size + return data[start:end] + + +@njit +def proton_capture_xs_last(nuclide, data): + start = nuclide["proton_capture_xs_offset"] + size = nuclide["proton_capture_xs_length"] + end = start + size + return data[end - 1] + + +@njit +def proton_capture_xs_chunk(start, length, nuclide, data): + start += nuclide["proton_capture_xs_offset"] + end = start + length + return data[start:end] + + +@njit +def proton_inelastic_xs(index, nuclide, data): + offset = nuclide["proton_inelastic_xs_offset"] + return data[offset + index] + + +@njit +def proton_inelastic_xs_all(nuclide, data): + start = nuclide["proton_inelastic_xs_offset"] + size = nuclide["proton_inelastic_xs_length"] + end = start + size + return data[start:end] + + +@njit +def proton_inelastic_xs_last(nuclide, data): + start = nuclide["proton_inelastic_xs_offset"] + size = nuclide["proton_inelastic_xs_length"] + end = start + size + return data[end - 1] + + +@njit +def proton_inelastic_xs_chunk(start, length, nuclide, data): + start += nuclide["proton_inelastic_xs_offset"] + end = start + length + return data[start:end] + + @njit def neutron_elastic_scattering_reaction_IDs(index, nuclide, data): offset = nuclide["neutron_elastic_scattering_reaction_IDs_offset"] @@ -293,6 +438,93 @@ def neutron_fission_reaction_IDs_chunk(start, length, nuclide, data): return data[start:end] +@njit +def proton_elastic_scattering_reaction_IDs(index, nuclide, data): + offset = nuclide["proton_elastic_scattering_reaction_IDs_offset"] + return data[offset + index] + + +@njit +def proton_elastic_scattering_reaction_IDs_all(nuclide, data): + start = nuclide["proton_elastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_elastic_scattering_reaction"] + end = start + size + return data[start:end] + + +@njit +def proton_elastic_scattering_reaction_IDs_last(nuclide, data): + start = nuclide["proton_elastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_elastic_scattering_reaction"] + end = start + size + return data[end - 1] + + +@njit +def proton_elastic_scattering_reaction_IDs_chunk(start, length, nuclide, data): + start += nuclide["proton_elastic_scattering_reaction_IDs_offset"] + end = start + length + return data[start:end] + + +@njit +def proton_capture_reaction_IDs(index, nuclide, data): + offset = nuclide["proton_capture_reaction_IDs_offset"] + return data[offset + index] + + +@njit +def proton_capture_reaction_IDs_all(nuclide, data): + start = nuclide["proton_capture_reaction_IDs_offset"] + size = nuclide["N_proton_capture_reaction"] + end = start + size + return data[start:end] + + +@njit +def proton_capture_reaction_IDs_last(nuclide, data): + start = nuclide["proton_capture_reaction_IDs_offset"] + size = nuclide["N_proton_capture_reaction"] + end = start + size + return data[end - 1] + + +@njit +def proton_capture_reaction_IDs_chunk(start, length, nuclide, data): + start += nuclide["proton_capture_reaction_IDs_offset"] + end = start + length + return data[start:end] + + +@njit +def proton_inelastic_scattering_reaction_IDs(index, nuclide, data): + offset = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + return data[offset + index] + + +@njit +def proton_inelastic_scattering_reaction_IDs_all(nuclide, data): + start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_inelastic_scattering_reaction"] + end = start + size + return data[start:end] + + +@njit +def proton_inelastic_scattering_reaction_IDs_last(nuclide, data): + start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_inelastic_scattering_reaction"] + end = start + size + return data[end - 1] + + +@njit +def proton_inelastic_scattering_reaction_IDs_chunk(start, length, nuclide, data): + start += nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + end = start + length + return data[start:end] + + @njit def neutron_fission_delayed_fractions(index, nuclide, data): offset = nuclide["neutron_fission_delayed_fractions_offset"] diff --git a/mcdc/mcdc_get/proton_capture_reaction.py b/mcdc/mcdc_get/proton_capture_reaction.py new file mode 100644 index 000000000..fdbf8e750 --- /dev/null +++ b/mcdc/mcdc_get/proton_capture_reaction.py @@ -0,0 +1,3 @@ +# The following is automatically generated by code_factory.py + +from numba import njit diff --git a/mcdc/mcdc_get/proton_elastic_scattering_reaction.py b/mcdc/mcdc_get/proton_elastic_scattering_reaction.py new file mode 100644 index 000000000..fdbf8e750 --- /dev/null +++ b/mcdc/mcdc_get/proton_elastic_scattering_reaction.py @@ -0,0 +1,3 @@ +# The following is automatically generated by code_factory.py + +from numba import njit diff --git a/mcdc/mcdc_get/proton_inelastic_scattering_reaction.py b/mcdc/mcdc_get/proton_inelastic_scattering_reaction.py new file mode 100644 index 000000000..abb8e860a --- /dev/null +++ b/mcdc/mcdc_get/proton_inelastic_scattering_reaction.py @@ -0,0 +1,84 @@ +# The following is automatically generated by code_factory.py + +from numba import njit + + +@njit +def spectrum_probability_grid(index, proton_inelastic_scattering_reaction, data): + offset = proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + return data[offset + index] + + +@njit +def spectrum_probability_grid_all(proton_inelastic_scattering_reaction, data): + start = proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + size = proton_inelastic_scattering_reaction["spectrum_probability_grid_length"] + end = start + size + return data[start:end] + + +@njit +def spectrum_probability_grid_last(proton_inelastic_scattering_reaction, data): + start = proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + size = proton_inelastic_scattering_reaction["spectrum_probability_grid_length"] + end = start + size + return data[end - 1] + + +@njit +def spectrum_probability_grid_chunk(start, length, proton_inelastic_scattering_reaction, data): + start += proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + end = start + length + return data[start:end] + + +@njit +def spectrum_probability_vector(index_1, proton_inelastic_scattering_reaction, data): + offset = proton_inelastic_scattering_reaction["spectrum_probability_offset"] + stride = proton_inelastic_scattering_reaction["N_spectrum"] + start = offset + index_1 * stride + end = start + stride + return data[start:end] + + +@njit +def spectrum_probability(index_1, index_2, proton_inelastic_scattering_reaction, data): + offset = proton_inelastic_scattering_reaction["spectrum_probability_offset"] + stride = proton_inelastic_scattering_reaction["N_spectrum"] + return data[offset + index_1 * stride + index_2] + + +@njit +def spectrum_probability_chunk(start, length, proton_inelastic_scattering_reaction, data): + start += proton_inelastic_scattering_reaction["spectrum_probability_offset"] + end = start + length + return data[start:end] + + +@njit +def energy_spectrum_IDs(index, proton_inelastic_scattering_reaction, data): + offset = proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + return data[offset + index] + + +@njit +def energy_spectrum_IDs_all(proton_inelastic_scattering_reaction, data): + start = proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + size = proton_inelastic_scattering_reaction["N_energy_spectrum"] + end = start + size + return data[start:end] + + +@njit +def energy_spectrum_IDs_last(proton_inelastic_scattering_reaction, data): + start = proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + size = proton_inelastic_scattering_reaction["N_energy_spectrum"] + end = start + size + return data[end - 1] + + +@njit +def energy_spectrum_IDs_chunk(start, length, proton_inelastic_scattering_reaction, data): + start += proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + end = start + length + return data[start:end] diff --git a/mcdc/mcdc_get/proton_reaction.py b/mcdc/mcdc_get/proton_reaction.py new file mode 100644 index 000000000..94fd03787 --- /dev/null +++ b/mcdc/mcdc_get/proton_reaction.py @@ -0,0 +1,32 @@ +# The following is automatically generated by code_factory.py + +from numba import njit + + +@njit +def xs(index, proton_reaction, data): + offset = proton_reaction["xs_offset"] + return data[offset + index] + + +@njit +def xs_all(proton_reaction, data): + start = proton_reaction["xs_offset"] + size = proton_reaction["xs_length"] + end = start + size + return data[start:end] + + +@njit +def xs_last(proton_reaction, data): + start = proton_reaction["xs_offset"] + size = proton_reaction["xs_length"] + end = start + size + return data[end - 1] + + +@njit +def xs_chunk(start, length, proton_reaction, data): + start += proton_reaction["xs_offset"] + end = start + length + return data[start:end] diff --git a/mcdc/mcdc_set/__init__.py b/mcdc/mcdc_set/__init__.py index e91e83a32..bc3d3cb2f 100644 --- a/mcdc/mcdc_set/__init__.py +++ b/mcdc/mcdc_set/__init__.py @@ -80,10 +80,18 @@ import mcdc.mcdc_set.neutron_inelastic_scattering_reaction as neutron_inelastic_scattering_reaction +import mcdc.mcdc_set.proton_capture_reaction as proton_capture_reaction + +import mcdc.mcdc_set.proton_elastic_scattering_reaction as proton_elastic_scattering_reaction + +import mcdc.mcdc_set.proton_inelastic_scattering_reaction as proton_inelastic_scattering_reaction + import mcdc.mcdc_set.collision_data as collision_data import mcdc.mcdc_set.particle_bank as particle_bank +import mcdc.mcdc_set.proton_reaction as proton_reaction + import mcdc.mcdc_set.settings as settings import mcdc.mcdc_set.global_weight_roulette as global_weight_roulette diff --git a/mcdc/mcdc_set/nuclide.py b/mcdc/mcdc_set/nuclide.py index 257d62580..994d6eb4b 100644 --- a/mcdc/mcdc_set/nuclide.py +++ b/mcdc/mcdc_set/nuclide.py @@ -177,6 +177,151 @@ def neutron_fission_xs_chunk(start, length, nuclide, data, value): data[start:end] = value +@njit +def proton_xs_energy_grid(index, nuclide, data, value): + offset = nuclide["proton_xs_energy_grid_offset"] + data[offset + index] = value + + +@njit +def proton_xs_energy_grid_all(nuclide, data, value): + start = nuclide["proton_xs_energy_grid_offset"] + size = nuclide["proton_xs_energy_grid_length"] + end = start + size + data[start:end] = value + + +@njit +def proton_xs_energy_grid_last(nuclide, data, value): + start = nuclide["proton_xs_energy_grid_offset"] + size = nuclide["proton_xs_energy_grid_length"] + end = start + size + data[end - 1] = value + + +@njit +def proton_xs_energy_grid_chunk(start, length, nuclide, data, value): + start += nuclide["proton_xs_energy_grid_offset"] + end = start + length + data[start:end] = value + + +@njit +def proton_total_xs(index, nuclide, data, value): + offset = nuclide["proton_total_xs_offset"] + data[offset + index] = value + + +@njit +def proton_total_xs_all(nuclide, data, value): + start = nuclide["proton_total_xs_offset"] + size = nuclide["proton_total_xs_length"] + end = start + size + data[start:end] = value + + +@njit +def proton_total_xs_last(nuclide, data, value): + start = nuclide["proton_total_xs_offset"] + size = nuclide["proton_total_xs_length"] + end = start + size + data[end - 1] = value + + +@njit +def proton_total_xs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_total_xs_offset"] + end = start + length + data[start:end] = value + + +@njit +def proton_elastic_xs(index, nuclide, data, value): + offset = nuclide["proton_elastic_xs_offset"] + data[offset + index] = value + + +@njit +def proton_elastic_xs_all(nuclide, data, value): + start = nuclide["proton_elastic_xs_offset"] + size = nuclide["proton_elastic_xs_length"] + end = start + size + data[start:end] = value + + +@njit +def proton_elastic_xs_last(nuclide, data, value): + start = nuclide["proton_elastic_xs_offset"] + size = nuclide["proton_elastic_xs_length"] + end = start + size + data[end - 1] = value + + +@njit +def proton_elastic_xs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_elastic_xs_offset"] + end = start + length + data[start:end] = value + + +@njit +def proton_capture_xs(index, nuclide, data, value): + offset = nuclide["proton_capture_xs_offset"] + data[offset + index] = value + + +@njit +def proton_capture_xs_all(nuclide, data, value): + start = nuclide["proton_capture_xs_offset"] + size = nuclide["proton_capture_xs_length"] + end = start + size + data[start:end] = value + + +@njit +def proton_capture_xs_last(nuclide, data, value): + start = nuclide["proton_capture_xs_offset"] + size = nuclide["proton_capture_xs_length"] + end = start + size + data[end - 1] = value + + +@njit +def proton_capture_xs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_capture_xs_offset"] + end = start + length + data[start:end] = value + + +@njit +def proton_inelastic_xs(index, nuclide, data, value): + offset = nuclide["proton_inelastic_xs_offset"] + data[offset + index] = value + + +@njit +def proton_inelastic_xs_all(nuclide, data, value): + start = nuclide["proton_inelastic_xs_offset"] + size = nuclide["proton_inelastic_xs_length"] + end = start + size + data[start:end] = value + + +@njit +def proton_inelastic_xs_last(nuclide, data, value): + start = nuclide["proton_inelastic_xs_offset"] + size = nuclide["proton_inelastic_xs_length"] + end = start + size + data[end - 1] = value + + +@njit +def proton_inelastic_xs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_inelastic_xs_offset"] + end = start + length + data[start:end] = value + + @njit def neutron_elastic_scattering_reaction_IDs(index, nuclide, data, value): offset = nuclide["neutron_elastic_scattering_reaction_IDs_offset"] @@ -293,6 +438,93 @@ def neutron_fission_reaction_IDs_chunk(start, length, nuclide, data, value): data[start:end] = value +@njit +def proton_elastic_scattering_reaction_IDs(index, nuclide, data, value): + offset = nuclide["proton_elastic_scattering_reaction_IDs_offset"] + data[offset + index] = value + + +@njit +def proton_elastic_scattering_reaction_IDs_all(nuclide, data, value): + start = nuclide["proton_elastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_elastic_scattering_reaction"] + end = start + size + data[start:end] = value + + +@njit +def proton_elastic_scattering_reaction_IDs_last(nuclide, data, value): + start = nuclide["proton_elastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_elastic_scattering_reaction"] + end = start + size + data[end - 1] = value + + +@njit +def proton_elastic_scattering_reaction_IDs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_elastic_scattering_reaction_IDs_offset"] + end = start + length + data[start:end] = value + + +@njit +def proton_capture_reaction_IDs(index, nuclide, data, value): + offset = nuclide["proton_capture_reaction_IDs_offset"] + data[offset + index] = value + + +@njit +def proton_capture_reaction_IDs_all(nuclide, data, value): + start = nuclide["proton_capture_reaction_IDs_offset"] + size = nuclide["N_proton_capture_reaction"] + end = start + size + data[start:end] = value + + +@njit +def proton_capture_reaction_IDs_last(nuclide, data, value): + start = nuclide["proton_capture_reaction_IDs_offset"] + size = nuclide["N_proton_capture_reaction"] + end = start + size + data[end - 1] = value + + +@njit +def proton_capture_reaction_IDs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_capture_reaction_IDs_offset"] + end = start + length + data[start:end] = value + + +@njit +def proton_inelastic_scattering_reaction_IDs(index, nuclide, data, value): + offset = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + data[offset + index] = value + + +@njit +def proton_inelastic_scattering_reaction_IDs_all(nuclide, data, value): + start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_inelastic_scattering_reaction"] + end = start + size + data[start:end] = value + + +@njit +def proton_inelastic_scattering_reaction_IDs_last(nuclide, data, value): + start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_inelastic_scattering_reaction"] + end = start + size + data[end - 1] = value + + +@njit +def proton_inelastic_scattering_reaction_IDs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + end = start + length + data[start:end] = value + + @njit def neutron_fission_delayed_fractions(index, nuclide, data, value): offset = nuclide["neutron_fission_delayed_fractions_offset"] diff --git a/mcdc/mcdc_set/proton_capture_reaction.py b/mcdc/mcdc_set/proton_capture_reaction.py new file mode 100644 index 000000000..fdbf8e750 --- /dev/null +++ b/mcdc/mcdc_set/proton_capture_reaction.py @@ -0,0 +1,3 @@ +# The following is automatically generated by code_factory.py + +from numba import njit diff --git a/mcdc/mcdc_set/proton_elastic_scattering_reaction.py b/mcdc/mcdc_set/proton_elastic_scattering_reaction.py new file mode 100644 index 000000000..fdbf8e750 --- /dev/null +++ b/mcdc/mcdc_set/proton_elastic_scattering_reaction.py @@ -0,0 +1,3 @@ +# The following is automatically generated by code_factory.py + +from numba import njit diff --git a/mcdc/mcdc_set/proton_inelastic_scattering_reaction.py b/mcdc/mcdc_set/proton_inelastic_scattering_reaction.py new file mode 100644 index 000000000..86a9da0dc --- /dev/null +++ b/mcdc/mcdc_set/proton_inelastic_scattering_reaction.py @@ -0,0 +1,84 @@ +# The following is automatically generated by code_factory.py + +from numba import njit + + +@njit +def spectrum_probability_grid(index, proton_inelastic_scattering_reaction, data, value): + offset = proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + data[offset + index] = value + + +@njit +def spectrum_probability_grid_all(proton_inelastic_scattering_reaction, data, value): + start = proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + size = proton_inelastic_scattering_reaction["spectrum_probability_grid_length"] + end = start + size + data[start:end] = value + + +@njit +def spectrum_probability_grid_last(proton_inelastic_scattering_reaction, data, value): + start = proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + size = proton_inelastic_scattering_reaction["spectrum_probability_grid_length"] + end = start + size + data[end - 1] = value + + +@njit +def spectrum_probability_grid_chunk(start, length, proton_inelastic_scattering_reaction, data, value): + start += proton_inelastic_scattering_reaction["spectrum_probability_grid_offset"] + end = start + length + data[start:end] = value + + +@njit +def spectrum_probability_vector(index_1, proton_inelastic_scattering_reaction, data, value): + offset = proton_inelastic_scattering_reaction["spectrum_probability_offset"] + stride = proton_inelastic_scattering_reaction["N_spectrum"] + start = offset + index_1 * stride + end = start + stride + data[start:end] - value + + +@njit +def spectrum_probability(index_1, index_2, proton_inelastic_scattering_reaction, data, value): + offset = proton_inelastic_scattering_reaction["spectrum_probability_offset"] + stride = proton_inelastic_scattering_reaction["N_spectrum"] + data[offset + index_1 * stride + index_2] = value + + +@njit +def spectrum_probability_chunk(start, length, proton_inelastic_scattering_reaction, data, value): + start += proton_inelastic_scattering_reaction["spectrum_probability_offset"] + end = start + length + data[start:end] = value + + +@njit +def energy_spectrum_IDs(index, proton_inelastic_scattering_reaction, data, value): + offset = proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + data[offset + index] = value + + +@njit +def energy_spectrum_IDs_all(proton_inelastic_scattering_reaction, data, value): + start = proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + size = proton_inelastic_scattering_reaction["N_energy_spectrum"] + end = start + size + data[start:end] = value + + +@njit +def energy_spectrum_IDs_last(proton_inelastic_scattering_reaction, data, value): + start = proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + size = proton_inelastic_scattering_reaction["N_energy_spectrum"] + end = start + size + data[end - 1] = value + + +@njit +def energy_spectrum_IDs_chunk(start, length, proton_inelastic_scattering_reaction, data, value): + start += proton_inelastic_scattering_reaction["energy_spectrum_IDs_offset"] + end = start + length + data[start:end] = value diff --git a/mcdc/mcdc_set/proton_reaction.py b/mcdc/mcdc_set/proton_reaction.py new file mode 100644 index 000000000..5221e64d5 --- /dev/null +++ b/mcdc/mcdc_set/proton_reaction.py @@ -0,0 +1,32 @@ +# The following is automatically generated by code_factory.py + +from numba import njit + + +@njit +def xs(index, proton_reaction, data, value): + offset = proton_reaction["xs_offset"] + data[offset + index] = value + + +@njit +def xs_all(proton_reaction, data, value): + start = proton_reaction["xs_offset"] + size = proton_reaction["xs_length"] + end = start + size + data[start:end] = value + + +@njit +def xs_last(proton_reaction, data, value): + start = proton_reaction["xs_offset"] + size = proton_reaction["xs_length"] + end = start + size + data[end - 1] = value + + +@njit +def xs_chunk(start, length, proton_reaction, data, value): + start += proton_reaction["xs_offset"] + end = start + length + data[start:end] = value diff --git a/mcdc/object_/simulation.py b/mcdc/object_/simulation.py index 48e7e3433..6787de492 100644 --- a/mcdc/object_/simulation.py +++ b/mcdc/object_/simulation.py @@ -16,6 +16,7 @@ from mcdc.object_.material import MaterialBase from mcdc.object_.nuclide import Nuclide from mcdc.object_.neutron_reaction import NeutronReactionBase + from mcdc.object_.proton_reaction import ProtonReactionBase from mcdc.object_.source import Source from mcdc.object_.surface import Surface from mcdc.object_.tally import Tally @@ -64,6 +65,7 @@ class Simulation(ObjectSingleton): nuclides: list[Nuclide] neutron_reactions: list[NeutronReactionBase] sources: list[Source] + proton_reactions: list[ProtonReactionBase] # Geometry cells: list[Cell] @@ -150,6 +152,7 @@ def __init__(self): self.nuclides = [] self.neutron_reactions = [] self.sources = [] + self.proton_reactions = [] # Geometry self.cells = [] From 8e2d060e1f77bfb8c3b2eecbdebef88bc843bf5f Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 23 Apr 2026 14:20:32 -0700 Subject: [PATCH 34/64] numba_types for proton stuff --- mcdc/numba_types.py | 63 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/mcdc/numba_types.py b/mcdc/numba_types.py index 1caa25648..99397245c 100644 --- a/mcdc/numba_types.py +++ b/mcdc/numba_types.py @@ -418,6 +418,16 @@ ('neutron_inelastic_xs_length', int64), ('neutron_fission_xs_offset', int64), ('neutron_fission_xs_length', int64), + ('proton_xs_energy_grid_offset', int64), + ('proton_xs_energy_grid_length', int64), + ('proton_total_xs_offset', int64), + ('proton_total_xs_length', int64), + ('proton_elastic_xs_offset', int64), + ('proton_elastic_xs_length', int64), + ('proton_capture_xs_offset', int64), + ('proton_capture_xs_length', int64), + ('proton_inelastic_xs_offset', int64), + ('proton_inelastic_xs_length', int64), ('N_neutron_elastic_scattering_reaction', int64), ('neutron_elastic_scattering_reaction_IDs_offset', int64), ('N_neutron_capture_reaction', int64), @@ -426,6 +436,12 @@ ('neutron_inelastic_scattering_reaction_IDs_offset', int64), ('N_neutron_fission_reaction', int64), ('neutron_fission_reaction_IDs_offset', int64), + ('N_proton_elastic_scattering_reaction', int64), + ('proton_elastic_scattering_reaction_IDs_offset', int64), + ('N_proton_capture_reaction', int64), + ('proton_capture_reaction_IDs_offset', int64), + ('N_proton_inelastic_scattering_reaction', int64), + ('proton_inelastic_scattering_reaction_IDs_offset', int64), ('neutron_fission_prompt_multiplicity_ID', int64), ('neutron_fission_delayed_multiplicity_ID', int64), ('N_neutron_fission_delayed_precursor', int64), @@ -521,6 +537,33 @@ ('parent_ID', int64), ]) +proton_capture_reaction = into_dtype([ + ('ID', int64), + ('parent_ID', int64), +]) + +proton_elastic_scattering_reaction = into_dtype([ + ('mu_table_ID', int64), + ('ID', int64), + ('parent_ID', int64), +]) + +proton_inelastic_scattering_reaction = into_dtype([ + ('multiplicity', int64), + ('angle_type', int64), + ('mu_ID', int64), + ('N_spectrum_probability_bin', int64), + ('N_spectrum', int64), + ('spectrum_probability_grid_offset', int64), + ('spectrum_probability_grid_length', int64), + ('spectrum_probability_offset', int64), + ('spectrum_probability_length', int64), + ('N_energy_spectrum', int64), + ('energy_spectrum_IDs_offset', int64), + ('ID', int64), + ('parent_ID', int64), +]) + collision_data = into_dtype([ ('energy_deposition', float64), ]) @@ -530,6 +573,18 @@ ('tag', 'U32'), ]) +proton_reaction = into_dtype([ + ('MT', int64), + ('xs_offset', int64), + ('xs_length', int64), + ('xs_offset_', int64), + ('reference_frame', int64), + ('q_value', float64), + ('ID', int64), + ('child_type', int64), + ('child_ID', int64), +]) + settings = into_dtype([ ('N_particle', int64), ('N_batch', int64), @@ -813,6 +868,14 @@ def set_simulation(N: dict): ('N_neutron_inelastic_scattering_reaction', int64), ('sources', source, (N['source'])), ('N_source', int64), + ('proton_capture_reactions', proton_capture_reaction, (N['proton_capture_reaction'])), + ('N_proton_capture_reaction', int64), + ('proton_elastic_scattering_reactions', proton_elastic_scattering_reaction, (N['proton_elastic_scattering_reaction'])), + ('N_proton_elastic_scattering_reaction', int64), + ('proton_inelastic_scattering_reactions', proton_inelastic_scattering_reaction, (N['proton_inelastic_scattering_reaction'])), + ('N_proton_inelastic_scattering_reaction', int64), + ('proton_reactions', proton_reaction, (N['proton_reaction'])), + ('N_proton_reaction', int64), ('cells', cell, (N['cell'])), ('N_cell', int64), ('lattices', lattice, (N['lattice'])), From f68b60761394fdc0d267aaeb1efadb0d7f6ef5f2 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 23 Apr 2026 14:24:55 -0700 Subject: [PATCH 35/64] get proton particle speed --- mcdc/transport/physics/interface.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index 9d5ed9f02..3d895c5e4 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -24,6 +24,7 @@ def particle_speed(particle_container, simulation, data): elif particle["particle_type"] == PARTICLE_ELECTRON: return electron.particle_speed(particle_container, simulation, data) elif particle["particle_type"] == PARTICLE_PROTON: + print(f'proton E = {particle["E"]}') return proton.particle_speed(particle_container, simulation, data) return -1.0 From 70b80b0566ee57d8e523efbe6ef684bf10426209 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 23 Apr 2026 17:50:08 -0700 Subject: [PATCH 36/64] initialize Nuclide attributes to reduce errors when running with only protons or neutrons not in MG --- mcdc/object_/base.py | 3 +++ mcdc/object_/nuclide.py | 30 ++++++++++++++++++++++++++++++ mcdc/object_/proton_reaction.py | 2 +- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/mcdc/object_/base.py b/mcdc/object_/base.py index 11d87e8ad..bc52e3a05 100644 --- a/mcdc/object_/base.py +++ b/mcdc/object_/base.py @@ -70,6 +70,7 @@ def register_object(object_): from mcdc.object_.mesh import MeshBase from mcdc.object_.nuclide import Nuclide from mcdc.object_.neutron_reaction import NeutronReactionBase + from mcdc.object_.proton_reaction import ProtonReactionBase from mcdc.object_.source import Source from mcdc.object_.surface import Surface from mcdc.object_.tally import Tally @@ -95,6 +96,8 @@ def register_object(object_): object_list = simulation.nuclides elif isinstance(object_, NeutronReactionBase): object_list = simulation.neutron_reactions + elif isinstance(object_, ProtonReactionBase): + object_list = simulation.proton_reactions elif isinstance(object_, Region): object_list = simulation.regions elif isinstance(object_, Source): diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index 3c74e72d8..0b5a51146 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -88,6 +88,36 @@ def __init__(self, nuclide_name, temperature): self.excitation_level = int(file["excitation_level"][()]) file.close() + # Initialize all attributes to defaults + # Neutron XS + self.neutron_xs_energy_grid = np.zeros(0) + self.neutron_total_xs = np.zeros(0) + self.neutron_elastic_xs = np.zeros(0) + self.neutron_capture_xs = np.zeros(0) + self.neutron_inelastic_xs = np.zeros(0) + self.neutron_fission_xs = np.zeros(0) + # Proton XS + self.proton_xs_energy_grid = np.zeros(0) + self.proton_total_xs = np.zeros(0) + self.proton_elastic_xs = np.zeros(0) + self.proton_capture_xs = np.zeros(0) + self.proton_inelastic_xs = np.zeros(0) + # Reactions + self.neutron_elastic_scattering_reactions = [] + self.neutron_capture_reactions = [] + self.neutron_inelastic_scattering_reactions = [] + self.neutron_fission_reactions = [] + self.proton_elastic_scattering_reactions = [] + self.proton_capture_reactions = [] + self.proton_inelastic_scattering_reactions = [] + # Fission + self.neutron_fission_prompt_multiplicity = 0 + self.neutron_fission_delayed_multiplicity = 0 + self.N_neutron_fission_delayed_precursor = 0 + self.neutron_fission_delayed_fractions = np.zeros(0) + self.neutron_fission_delayed_decay_rates = np.zeros(0) + self.neutron_fission_delayed_spectra = [] + def set_neutron_data(self): nuclide_name = self.name temperature = self.temperature diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py index 4df621073..f9f1093c6 100644 --- a/mcdc/object_/proton_reaction.py +++ b/mcdc/object_/proton_reaction.py @@ -95,8 +95,8 @@ class ProtonReactionElasticScattering(ProtonReactionBase): def __init__(self, MT, xs, xs_offset, reference_frame, mu): type_ = PROTON_REACTION_ELASTIC_SCATTERING - super().__init__(type_, MT, xs, xs_offset, reference_frame, 0.0) self.mu_table = mu + super().__init__(type_, MT, xs, xs_offset, reference_frame, 0.0) @classmethod def from_h5_group(cls, h5_group): From 8660d6afa4edb0484a8b6c76f8c4674d3510224a Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Fri, 8 May 2026 11:15:57 -0700 Subject: [PATCH 37/64] more proton capabilities; elastic & nonelastic rxns --- mcdc/constant.py | 3 +- mcdc/mcdc_get/__init__.py | 4 +- mcdc/mcdc_get/nuclide.py | 98 +- mcdc/mcdc_get/proton_nonelastic_reaction.py | 84 ++ mcdc/mcdc_set/__init__.py | 4 +- mcdc/mcdc_set/nuclide.py | 98 +- mcdc/mcdc_set/proton_nonelastic_reaction.py | 84 ++ mcdc/numba_types.py | 25 +- mcdc/object_/material.py | 4 +- mcdc/object_/nuclide.py | 75 +- mcdc/object_/proton_reaction.py | 201 +++- mcdc/transport/physics/interface.py | 1 - mcdc/transport/physics/proton/interface.py | 32 +- mcdc/transport/physics/proton/multigroup.py | 4 +- mcdc/transport/physics/proton/native.py | 266 ++--- .../tendl_generate_v2.py | 913 ++++++++++++++++++ 16 files changed, 1417 insertions(+), 479 deletions(-) create mode 100644 mcdc/mcdc_get/proton_nonelastic_reaction.py create mode 100644 mcdc/mcdc_set/proton_nonelastic_reaction.py create mode 100644 tools/data_library_generator/tendl_generate_v2.py diff --git a/mcdc/constant.py b/mcdc/constant.py index af367c3b2..514d634ca 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -115,8 +115,7 @@ ELECTRON_REACTION_EXCITATION = 106 PROTON_REACTION_TOTAL = 200 PROTON_REACTION_ELASTIC_SCATTERING = 201 -PROTON_REACTION_CAPTURE = 202 -PROTON_REACTION_INELASTIC_SCATTERING = 203 +PROTON_REACTION_NONELASTIC = 202 # Particle types PARTICLE_NEUTRON = 0 diff --git a/mcdc/mcdc_get/__init__.py b/mcdc/mcdc_get/__init__.py index 09e537e26..88d1c322b 100644 --- a/mcdc/mcdc_get/__init__.py +++ b/mcdc/mcdc_get/__init__.py @@ -80,11 +80,9 @@ import mcdc.mcdc_get.neutron_inelastic_scattering_reaction as neutron_inelastic_scattering_reaction -import mcdc.mcdc_get.proton_capture_reaction as proton_capture_reaction - import mcdc.mcdc_get.proton_elastic_scattering_reaction as proton_elastic_scattering_reaction -import mcdc.mcdc_get.proton_inelastic_scattering_reaction as proton_inelastic_scattering_reaction +import mcdc.mcdc_get.proton_nonelastic_reaction as proton_nonelastic_reaction import mcdc.mcdc_get.collision_data as collision_data diff --git a/mcdc/mcdc_get/nuclide.py b/mcdc/mcdc_get/nuclide.py index 7e0541666..c5237c645 100644 --- a/mcdc/mcdc_get/nuclide.py +++ b/mcdc/mcdc_get/nuclide.py @@ -265,59 +265,30 @@ def proton_elastic_xs_chunk(start, length, nuclide, data): @njit -def proton_capture_xs(index, nuclide, data): - offset = nuclide["proton_capture_xs_offset"] +def proton_nonelastic_xs(index, nuclide, data): + offset = nuclide["proton_nonelastic_xs_offset"] return data[offset + index] @njit -def proton_capture_xs_all(nuclide, data): - start = nuclide["proton_capture_xs_offset"] - size = nuclide["proton_capture_xs_length"] +def proton_nonelastic_xs_all(nuclide, data): + start = nuclide["proton_nonelastic_xs_offset"] + size = nuclide["proton_nonelastic_xs_length"] end = start + size return data[start:end] @njit -def proton_capture_xs_last(nuclide, data): - start = nuclide["proton_capture_xs_offset"] - size = nuclide["proton_capture_xs_length"] +def proton_nonelastic_xs_last(nuclide, data): + start = nuclide["proton_nonelastic_xs_offset"] + size = nuclide["proton_nonelastic_xs_length"] end = start + size return data[end - 1] @njit -def proton_capture_xs_chunk(start, length, nuclide, data): - start += nuclide["proton_capture_xs_offset"] - end = start + length - return data[start:end] - - -@njit -def proton_inelastic_xs(index, nuclide, data): - offset = nuclide["proton_inelastic_xs_offset"] - return data[offset + index] - - -@njit -def proton_inelastic_xs_all(nuclide, data): - start = nuclide["proton_inelastic_xs_offset"] - size = nuclide["proton_inelastic_xs_length"] - end = start + size - return data[start:end] - - -@njit -def proton_inelastic_xs_last(nuclide, data): - start = nuclide["proton_inelastic_xs_offset"] - size = nuclide["proton_inelastic_xs_length"] - end = start + size - return data[end - 1] - - -@njit -def proton_inelastic_xs_chunk(start, length, nuclide, data): - start += nuclide["proton_inelastic_xs_offset"] +def proton_nonelastic_xs_chunk(start, length, nuclide, data): + start += nuclide["proton_nonelastic_xs_offset"] end = start + length return data[start:end] @@ -468,59 +439,30 @@ def proton_elastic_scattering_reaction_IDs_chunk(start, length, nuclide, data): @njit -def proton_capture_reaction_IDs(index, nuclide, data): - offset = nuclide["proton_capture_reaction_IDs_offset"] - return data[offset + index] - - -@njit -def proton_capture_reaction_IDs_all(nuclide, data): - start = nuclide["proton_capture_reaction_IDs_offset"] - size = nuclide["N_proton_capture_reaction"] - end = start + size - return data[start:end] - - -@njit -def proton_capture_reaction_IDs_last(nuclide, data): - start = nuclide["proton_capture_reaction_IDs_offset"] - size = nuclide["N_proton_capture_reaction"] - end = start + size - return data[end - 1] - - -@njit -def proton_capture_reaction_IDs_chunk(start, length, nuclide, data): - start += nuclide["proton_capture_reaction_IDs_offset"] - end = start + length - return data[start:end] - - -@njit -def proton_inelastic_scattering_reaction_IDs(index, nuclide, data): - offset = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] +def proton_nonelastic_reaction_IDs(index, nuclide, data): + offset = nuclide["proton_nonelastic_reaction_IDs_offset"] return data[offset + index] @njit -def proton_inelastic_scattering_reaction_IDs_all(nuclide, data): - start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] - size = nuclide["N_proton_inelastic_scattering_reaction"] +def proton_nonelastic_reaction_IDs_all(nuclide, data): + start = nuclide["proton_nonelastic_reaction_IDs_offset"] + size = nuclide["N_proton_nonelastic_reaction"] end = start + size return data[start:end] @njit -def proton_inelastic_scattering_reaction_IDs_last(nuclide, data): - start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] - size = nuclide["N_proton_inelastic_scattering_reaction"] +def proton_nonelastic_reaction_IDs_last(nuclide, data): + start = nuclide["proton_nonelastic_reaction_IDs_offset"] + size = nuclide["N_proton_nonelastic_reaction"] end = start + size return data[end - 1] @njit -def proton_inelastic_scattering_reaction_IDs_chunk(start, length, nuclide, data): - start += nuclide["proton_inelastic_scattering_reaction_IDs_offset"] +def proton_nonelastic_reaction_IDs_chunk(start, length, nuclide, data): + start += nuclide["proton_nonelastic_reaction_IDs_offset"] end = start + length return data[start:end] diff --git a/mcdc/mcdc_get/proton_nonelastic_reaction.py b/mcdc/mcdc_get/proton_nonelastic_reaction.py new file mode 100644 index 000000000..619a68430 --- /dev/null +++ b/mcdc/mcdc_get/proton_nonelastic_reaction.py @@ -0,0 +1,84 @@ +# The following is automatically generated by code_factory.py + +from numba import njit + + +@njit +def spectrum_probability_grid(index, proton_nonelastic_reaction, data): + offset = proton_nonelastic_reaction["spectrum_probability_grid_offset"] + return data[offset + index] + + +@njit +def spectrum_probability_grid_all(proton_nonelastic_reaction, data): + start = proton_nonelastic_reaction["spectrum_probability_grid_offset"] + size = proton_nonelastic_reaction["spectrum_probability_grid_length"] + end = start + size + return data[start:end] + + +@njit +def spectrum_probability_grid_last(proton_nonelastic_reaction, data): + start = proton_nonelastic_reaction["spectrum_probability_grid_offset"] + size = proton_nonelastic_reaction["spectrum_probability_grid_length"] + end = start + size + return data[end - 1] + + +@njit +def spectrum_probability_grid_chunk(start, length, proton_nonelastic_reaction, data): + start += proton_nonelastic_reaction["spectrum_probability_grid_offset"] + end = start + length + return data[start:end] + + +@njit +def spectrum_probability_vector(index_1, proton_nonelastic_reaction, data): + offset = proton_nonelastic_reaction["spectrum_probability_offset"] + stride = proton_nonelastic_reaction["N_spectrum"] + start = offset + index_1 * stride + end = start + stride + return data[start:end] + + +@njit +def spectrum_probability(index_1, index_2, proton_nonelastic_reaction, data): + offset = proton_nonelastic_reaction["spectrum_probability_offset"] + stride = proton_nonelastic_reaction["N_spectrum"] + return data[offset + index_1 * stride + index_2] + + +@njit +def spectrum_probability_chunk(start, length, proton_nonelastic_reaction, data): + start += proton_nonelastic_reaction["spectrum_probability_offset"] + end = start + length + return data[start:end] + + +@njit +def energy_spectrum_IDs(index, proton_nonelastic_reaction, data): + offset = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + return data[offset + index] + + +@njit +def energy_spectrum_IDs_all(proton_nonelastic_reaction, data): + start = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + size = proton_nonelastic_reaction["N_energy_spectrum"] + end = start + size + return data[start:end] + + +@njit +def energy_spectrum_IDs_last(proton_nonelastic_reaction, data): + start = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + size = proton_nonelastic_reaction["N_energy_spectrum"] + end = start + size + return data[end - 1] + + +@njit +def energy_spectrum_IDs_chunk(start, length, proton_nonelastic_reaction, data): + start += proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + end = start + length + return data[start:end] diff --git a/mcdc/mcdc_set/__init__.py b/mcdc/mcdc_set/__init__.py index bc3d3cb2f..ed05ef728 100644 --- a/mcdc/mcdc_set/__init__.py +++ b/mcdc/mcdc_set/__init__.py @@ -80,11 +80,9 @@ import mcdc.mcdc_set.neutron_inelastic_scattering_reaction as neutron_inelastic_scattering_reaction -import mcdc.mcdc_set.proton_capture_reaction as proton_capture_reaction - import mcdc.mcdc_set.proton_elastic_scattering_reaction as proton_elastic_scattering_reaction -import mcdc.mcdc_set.proton_inelastic_scattering_reaction as proton_inelastic_scattering_reaction +import mcdc.mcdc_set.proton_nonelastic_reaction as proton_nonelastic_reaction import mcdc.mcdc_set.collision_data as collision_data diff --git a/mcdc/mcdc_set/nuclide.py b/mcdc/mcdc_set/nuclide.py index 994d6eb4b..18536bcf2 100644 --- a/mcdc/mcdc_set/nuclide.py +++ b/mcdc/mcdc_set/nuclide.py @@ -265,59 +265,30 @@ def proton_elastic_xs_chunk(start, length, nuclide, data, value): @njit -def proton_capture_xs(index, nuclide, data, value): - offset = nuclide["proton_capture_xs_offset"] +def proton_nonelastic_xs(index, nuclide, data, value): + offset = nuclide["proton_nonelastic_xs_offset"] data[offset + index] = value @njit -def proton_capture_xs_all(nuclide, data, value): - start = nuclide["proton_capture_xs_offset"] - size = nuclide["proton_capture_xs_length"] +def proton_nonelastic_xs_all(nuclide, data, value): + start = nuclide["proton_nonelastic_xs_offset"] + size = nuclide["proton_nonelastic_xs_length"] end = start + size data[start:end] = value @njit -def proton_capture_xs_last(nuclide, data, value): - start = nuclide["proton_capture_xs_offset"] - size = nuclide["proton_capture_xs_length"] +def proton_nonelastic_xs_last(nuclide, data, value): + start = nuclide["proton_nonelastic_xs_offset"] + size = nuclide["proton_nonelastic_xs_length"] end = start + size data[end - 1] = value @njit -def proton_capture_xs_chunk(start, length, nuclide, data, value): - start += nuclide["proton_capture_xs_offset"] - end = start + length - data[start:end] = value - - -@njit -def proton_inelastic_xs(index, nuclide, data, value): - offset = nuclide["proton_inelastic_xs_offset"] - data[offset + index] = value - - -@njit -def proton_inelastic_xs_all(nuclide, data, value): - start = nuclide["proton_inelastic_xs_offset"] - size = nuclide["proton_inelastic_xs_length"] - end = start + size - data[start:end] = value - - -@njit -def proton_inelastic_xs_last(nuclide, data, value): - start = nuclide["proton_inelastic_xs_offset"] - size = nuclide["proton_inelastic_xs_length"] - end = start + size - data[end - 1] = value - - -@njit -def proton_inelastic_xs_chunk(start, length, nuclide, data, value): - start += nuclide["proton_inelastic_xs_offset"] +def proton_nonelastic_xs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_nonelastic_xs_offset"] end = start + length data[start:end] = value @@ -468,59 +439,30 @@ def proton_elastic_scattering_reaction_IDs_chunk(start, length, nuclide, data, v @njit -def proton_capture_reaction_IDs(index, nuclide, data, value): - offset = nuclide["proton_capture_reaction_IDs_offset"] - data[offset + index] = value - - -@njit -def proton_capture_reaction_IDs_all(nuclide, data, value): - start = nuclide["proton_capture_reaction_IDs_offset"] - size = nuclide["N_proton_capture_reaction"] - end = start + size - data[start:end] = value - - -@njit -def proton_capture_reaction_IDs_last(nuclide, data, value): - start = nuclide["proton_capture_reaction_IDs_offset"] - size = nuclide["N_proton_capture_reaction"] - end = start + size - data[end - 1] = value - - -@njit -def proton_capture_reaction_IDs_chunk(start, length, nuclide, data, value): - start += nuclide["proton_capture_reaction_IDs_offset"] - end = start + length - data[start:end] = value - - -@njit -def proton_inelastic_scattering_reaction_IDs(index, nuclide, data, value): - offset = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] +def proton_nonelastic_reaction_IDs(index, nuclide, data, value): + offset = nuclide["proton_nonelastic_reaction_IDs_offset"] data[offset + index] = value @njit -def proton_inelastic_scattering_reaction_IDs_all(nuclide, data, value): - start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] - size = nuclide["N_proton_inelastic_scattering_reaction"] +def proton_nonelastic_reaction_IDs_all(nuclide, data, value): + start = nuclide["proton_nonelastic_reaction_IDs_offset"] + size = nuclide["N_proton_nonelastic_reaction"] end = start + size data[start:end] = value @njit -def proton_inelastic_scattering_reaction_IDs_last(nuclide, data, value): - start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] - size = nuclide["N_proton_inelastic_scattering_reaction"] +def proton_nonelastic_reaction_IDs_last(nuclide, data, value): + start = nuclide["proton_nonelastic_reaction_IDs_offset"] + size = nuclide["N_proton_nonelastic_reaction"] end = start + size data[end - 1] = value @njit -def proton_inelastic_scattering_reaction_IDs_chunk(start, length, nuclide, data, value): - start += nuclide["proton_inelastic_scattering_reaction_IDs_offset"] +def proton_nonelastic_reaction_IDs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_nonelastic_reaction_IDs_offset"] end = start + length data[start:end] = value diff --git a/mcdc/mcdc_set/proton_nonelastic_reaction.py b/mcdc/mcdc_set/proton_nonelastic_reaction.py new file mode 100644 index 000000000..7105064c9 --- /dev/null +++ b/mcdc/mcdc_set/proton_nonelastic_reaction.py @@ -0,0 +1,84 @@ +# The following is automatically generated by code_factory.py + +from numba import njit + + +@njit +def spectrum_probability_grid(index, proton_nonelastic_reaction, data, value): + offset = proton_nonelastic_reaction["spectrum_probability_grid_offset"] + data[offset + index] = value + + +@njit +def spectrum_probability_grid_all(proton_nonelastic_reaction, data, value): + start = proton_nonelastic_reaction["spectrum_probability_grid_offset"] + size = proton_nonelastic_reaction["spectrum_probability_grid_length"] + end = start + size + data[start:end] = value + + +@njit +def spectrum_probability_grid_last(proton_nonelastic_reaction, data, value): + start = proton_nonelastic_reaction["spectrum_probability_grid_offset"] + size = proton_nonelastic_reaction["spectrum_probability_grid_length"] + end = start + size + data[end - 1] = value + + +@njit +def spectrum_probability_grid_chunk(start, length, proton_nonelastic_reaction, data, value): + start += proton_nonelastic_reaction["spectrum_probability_grid_offset"] + end = start + length + data[start:end] = value + + +@njit +def spectrum_probability_vector(index_1, proton_nonelastic_reaction, data, value): + offset = proton_nonelastic_reaction["spectrum_probability_offset"] + stride = proton_nonelastic_reaction["N_spectrum"] + start = offset + index_1 * stride + end = start + stride + data[start:end] - value + + +@njit +def spectrum_probability(index_1, index_2, proton_nonelastic_reaction, data, value): + offset = proton_nonelastic_reaction["spectrum_probability_offset"] + stride = proton_nonelastic_reaction["N_spectrum"] + data[offset + index_1 * stride + index_2] = value + + +@njit +def spectrum_probability_chunk(start, length, proton_nonelastic_reaction, data, value): + start += proton_nonelastic_reaction["spectrum_probability_offset"] + end = start + length + data[start:end] = value + + +@njit +def energy_spectrum_IDs(index, proton_nonelastic_reaction, data, value): + offset = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + data[offset + index] = value + + +@njit +def energy_spectrum_IDs_all(proton_nonelastic_reaction, data, value): + start = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + size = proton_nonelastic_reaction["N_energy_spectrum"] + end = start + size + data[start:end] = value + + +@njit +def energy_spectrum_IDs_last(proton_nonelastic_reaction, data, value): + start = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + size = proton_nonelastic_reaction["N_energy_spectrum"] + end = start + size + data[end - 1] = value + + +@njit +def energy_spectrum_IDs_chunk(start, length, proton_nonelastic_reaction, data, value): + start += proton_nonelastic_reaction["energy_spectrum_IDs_offset"] + end = start + length + data[start:end] = value diff --git a/mcdc/numba_types.py b/mcdc/numba_types.py index 99397245c..f379a93e7 100644 --- a/mcdc/numba_types.py +++ b/mcdc/numba_types.py @@ -424,10 +424,8 @@ ('proton_total_xs_length', int64), ('proton_elastic_xs_offset', int64), ('proton_elastic_xs_length', int64), - ('proton_capture_xs_offset', int64), - ('proton_capture_xs_length', int64), - ('proton_inelastic_xs_offset', int64), - ('proton_inelastic_xs_length', int64), + ('proton_nonelastic_xs_offset', int64), + ('proton_nonelastic_xs_length', int64), ('N_neutron_elastic_scattering_reaction', int64), ('neutron_elastic_scattering_reaction_IDs_offset', int64), ('N_neutron_capture_reaction', int64), @@ -438,10 +436,8 @@ ('neutron_fission_reaction_IDs_offset', int64), ('N_proton_elastic_scattering_reaction', int64), ('proton_elastic_scattering_reaction_IDs_offset', int64), - ('N_proton_capture_reaction', int64), - ('proton_capture_reaction_IDs_offset', int64), - ('N_proton_inelastic_scattering_reaction', int64), - ('proton_inelastic_scattering_reaction_IDs_offset', int64), + ('N_proton_nonelastic_reaction', int64), + ('proton_nonelastic_reaction_IDs_offset', int64), ('neutron_fission_prompt_multiplicity_ID', int64), ('neutron_fission_delayed_multiplicity_ID', int64), ('N_neutron_fission_delayed_precursor', int64), @@ -537,18 +533,13 @@ ('parent_ID', int64), ]) -proton_capture_reaction = into_dtype([ - ('ID', int64), - ('parent_ID', int64), -]) - proton_elastic_scattering_reaction = into_dtype([ ('mu_table_ID', int64), ('ID', int64), ('parent_ID', int64), ]) -proton_inelastic_scattering_reaction = into_dtype([ +proton_nonelastic_reaction = into_dtype([ ('multiplicity', int64), ('angle_type', int64), ('mu_ID', int64), @@ -868,12 +859,10 @@ def set_simulation(N: dict): ('N_neutron_inelastic_scattering_reaction', int64), ('sources', source, (N['source'])), ('N_source', int64), - ('proton_capture_reactions', proton_capture_reaction, (N['proton_capture_reaction'])), - ('N_proton_capture_reaction', int64), ('proton_elastic_scattering_reactions', proton_elastic_scattering_reaction, (N['proton_elastic_scattering_reaction'])), ('N_proton_elastic_scattering_reaction', int64), - ('proton_inelastic_scattering_reactions', proton_inelastic_scattering_reaction, (N['proton_inelastic_scattering_reaction'])), - ('N_proton_inelastic_scattering_reaction', int64), + ('proton_nonelastic_reactions', proton_nonelastic_reaction, (N['proton_nonelastic_reaction'])), + ('N_proton_nonelastic_reaction', int64), ('proton_reactions', proton_reaction, (N['proton_reaction'])), ('N_proton_reaction', int64), ('cells', cell, (N['cell'])), diff --git a/mcdc/object_/material.py b/mcdc/object_/material.py index c2b2fbfbe..a71800e79 100644 --- a/mcdc/object_/material.py +++ b/mcdc/object_/material.py @@ -69,9 +69,9 @@ class Material(MaterialBase): name : str, optional User label. nuclide_composition : dict - Dictionary mapping nuclide names (str) to atom densities (float). + Dictionary mapping nuclide names (str) to atom densities in units of atoms/barn-cm (float). element_composition : dict - Dictionary mapping element names (str) to atom densities (float). + Dictionary mapping element names (str) to atom densities in units of atoms/barn-cm (float). temperature : float, optional Temperature in Kelvin (default 293.6 K). diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index 0b5a51146..849469128 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -19,9 +19,9 @@ set_energy_distribution, ) from mcdc.object_.proton_reaction import( - ProtonReactionCapture, ProtonReactionElasticScattering, - ProtonReactionInelasticScattering, + ProtonReactionNonelasticReaction, + ProtonSecondaryChannel, set_energy_distribution, ) from mcdc.object_.simulation import simulation @@ -53,16 +53,16 @@ class Nuclide(ObjectNonSingleton): proton_xs_energy_grid: NDArray[float64] proton_total_xs: NDArray[float64] proton_elastic_xs: NDArray[float64] - proton_capture_xs: NDArray[float64] - proton_inelastic_xs: NDArray[float64] + proton_nonelastic_xs: NDArray[float64] # neutron_elastic_scattering_reactions: list[NeutronReactionElasticScattering] neutron_capture_reactions: list[NeutronReactionCapture] neutron_inelastic_scattering_reactions: list[NeutronReactionInelasticScattering] neutron_fission_reactions: list[NeutronReactionFission] proton_elastic_scattering_reactions: list[ProtonReactionElasticScattering] - proton_capture_reactions: list[ProtonReactionCapture] - proton_inelastic_scattering_reactions: list[ProtonReactionInelasticScattering] + proton_nonelastic_reactions: list[ProtonReactionNonelasticReaction] + proton_secondary_channels: dict[int, list[ProtonSecondaryChannel]] + non_numba: list[str] = ["proton_secondary_channels"] # neutron_fission_prompt_multiplicity: DataBase neutron_fission_delayed_multiplicity: DataBase @@ -100,19 +100,17 @@ def __init__(self, nuclide_name, temperature): self.proton_xs_energy_grid = np.zeros(0) self.proton_total_xs = np.zeros(0) self.proton_elastic_xs = np.zeros(0) - self.proton_capture_xs = np.zeros(0) - self.proton_inelastic_xs = np.zeros(0) + self.proton_nonelastic_xs = np.zeros(0) # Reactions self.neutron_elastic_scattering_reactions = [] self.neutron_capture_reactions = [] self.neutron_inelastic_scattering_reactions = [] self.neutron_fission_reactions = [] self.proton_elastic_scattering_reactions = [] - self.proton_capture_reactions = [] - self.proton_inelastic_scattering_reactions = [] + self.proton_nonelastic_reactions = [] # Fission - self.neutron_fission_prompt_multiplicity = 0 - self.neutron_fission_delayed_multiplicity = 0 + self.neutron_fission_prompt_multiplicity = DataPolynomial(np.array([0.0])) + self.neutron_fission_delayed_multiplicity = DataPolynomial(np.array([0.0])) self.N_neutron_fission_delayed_precursor = 0 self.neutron_fission_delayed_fractions = np.zeros(0) self.neutron_fission_delayed_decay_rates = np.zeros(0) @@ -259,8 +257,7 @@ def set_neutron_data(self): def set_proton_data(self): nuclide_name = self.name - # All proton data in ENDF70PROT is at 293.6K - temperature = 293.6 + temperature = self.temperature # Load data library dir_name = os.getenv("MCDC_LIB") @@ -269,8 +266,7 @@ def set_proton_data(self): rx_names = [ "elastic_scattering", - "capture", - "inelastic_scattering", + "nonelastic_reaction", ] # The reaction MTs @@ -295,13 +291,11 @@ def set_proton_data(self): # The total XS self.proton_total_xs = np.zeros_like(self.proton_xs_energy_grid) self.proton_elastic_xs = np.zeros_like(self.proton_xs_energy_grid) - self.proton_capture_xs = np.zeros_like(self.proton_xs_energy_grid) - self.proton_inelastic_xs = np.zeros_like(self.proton_xs_energy_grid) + self.proton_nonelastic_xs = np.zeros_like(self.proton_xs_energy_grid) xs_containers = [ self.proton_elastic_xs, - self.proton_capture_xs, - self.proton_inelastic_xs, + self.proton_nonelastic_xs, ] for xs_container, rx_name in list(zip(xs_containers, rx_names)): @@ -311,8 +305,7 @@ def set_proton_data(self): self.proton_total_xs = ( self.proton_elastic_xs - + self.proton_capture_xs - + self.proton_inelastic_xs + + self.proton_nonelastic_xs ) @@ -321,18 +314,15 @@ def set_proton_data(self): # ========================================================================== self.proton_elastic_scattering_reactions = [] - self.proton_capture_reactions = [] - self.proton_inelastic_scattering_reactions = [] + self.proton_nonelastic_reactions = [] rx_containers = [ self.proton_elastic_scattering_reactions, - self.proton_capture_reactions, - self.proton_inelastic_scattering_reactions, + self.proton_nonelastic_reactions, ] rx_classes = [ ProtonReactionElasticScattering, - ProtonReactionCapture, - ProtonReactionInelasticScattering, + ProtonReactionNonelasticReaction, ] for rx_container, rx_name, rx_class in list( zip(rx_containers, rx_names, rx_classes) @@ -342,10 +332,37 @@ def set_proton_data(self): reaction = rx_class.from_h5_group(h5_group) rx_container.append(reaction) + # # ========================================================================== + # # Secondary particles + # # ========================================================================== + + # self.proton_secondary_channels = {} + # if "secondary_particles" in file: + # sec_group = file["secondary_particles"] + # for zap_name in sec_group.keys(): + # if not zap_name.startswith("ZAP_"): + # continue + # zap = int(zap_name.split("_")[1]) + # zap_group = sec_group[zap_name] + + # # Iterate over MT numbers for this secondary particle type + # for mt_name in zap_group.keys(): + # if not mt_name.startswith("MT-"): + # continue + # MT = int(mt_name.split("-")[1]) + # mt_group = zap_group[mt_name] + + # # Load secondary channel + # channel = ProtonSecondaryChannel.from_h5_group(mt_group, zap) + + # if MT not in self.proton_secondary_channels: + # self.proton_secondary_channels[MT] = [] + # self.proton_secondary_channels[MT].append(channel) + file.close() - ## UPDATE this for protons + ## TODO: UPDATE this to handle protons as well as neutrons def __repr__(self): text = "\n" text += f"Nuclide\n" diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py index f9f1093c6..db05e9344 100644 --- a/mcdc/object_/proton_reaction.py +++ b/mcdc/object_/proton_reaction.py @@ -1,4 +1,5 @@ from typing import Annotated +import numpy as np from numpy import float64 from numpy.typing import NDArray @@ -12,11 +13,12 @@ ANGLE_DISTRIBUTED, INTERPOLATION_LINEAR, INTERPOLATION_LOG, - PROTON_REACTION_CAPTURE, PROTON_REACTION_ELASTIC_SCATTERING, - PROTON_REACTION_INELASTIC_SCATTERING, + PROTON_REACTION_NONELASTIC, REFERENCE_FRAME_COM, REFERENCE_FRAME_LAB, + PARTICLE_NEUTRON, + PARTICLE_PROTON, ) from mcdc.object_.base import ObjectPolymorphic from mcdc.object_.distribution import ( @@ -32,6 +34,15 @@ from mcdc.object_.simulation import simulation from mcdc.print_ import print_1d_array, print_error +# ====================================================================================== +# ZAP to particle type mapping +# ====================================================================================== + +ZAP_TO_PARTICLE = { + 1: PARTICLE_NEUTRON, + 31: PARTICLE_PROTON, +} + # ====================================================================================== # Proton reaction base class # ====================================================================================== @@ -69,10 +80,8 @@ def __repr__(self): def decode_type(type_): if type_ == PROTON_REACTION_ELASTIC_SCATTERING: return "Proton elastic scattering" - elif type_ == PROTON_REACTION_CAPTURE: - return "Proton capture" - elif type_ == PROTON_REACTION_INELASTIC_SCATTERING: - return "Proton inelastic scattering" + elif type_ == PROTON_REACTION_NONELASTIC: + return "Proton nonelastic reaction" def decode_reference_frame(type_): @@ -91,7 +100,7 @@ class ProtonReactionElasticScattering(ProtonReactionBase): # Annotations for Numba mode label: str = "proton_elastic_scattering_reaction" # - mu_table: DistributionMultiTable + mu_table: DistributionBase def __init__(self, MT, xs, xs_offset, reference_frame, mu): type_ = PROTON_REACTION_ELASTIC_SCATTERING @@ -111,32 +120,12 @@ def __repr__(self): # ====================================================================================== -# Proton capture -# ====================================================================================== - - -class ProtonReactionCapture(ProtonReactionBase): - # Annotations for Numba mode - label: str = "proton_capture_reaction" - - def __init__(self, MT, xs, xs_offset, reference_frame, q_value): - type_ = PROTON_REACTION_CAPTURE - super().__init__(type_, MT, xs, xs_offset, reference_frame, q_value) - - @classmethod - def from_h5_group(cls, h5_group): - MT, xs, xs_offset, reference_frame, q_value = set_basic_properties(h5_group) - return cls(MT, xs, xs_offset, reference_frame, q_value) - - -# ====================================================================================== -# Proton inelastic scattering +# Proton nonelastic reaction # ====================================================================================== - -class ProtonReactionInelasticScattering(ProtonReactionBase): +class ProtonReactionNonelasticReaction(ProtonReactionBase): # Annotations for Numba mode - label: str = "proton_inelastic_scattering_reaction" + label: str = "proton_nonelastic_reaction" # multiplicity: int angle_type: int @@ -163,7 +152,7 @@ def __init__( spectrum_probability, energy_spectra, ): - type_ = PROTON_REACTION_INELASTIC_SCATTERING + type_ = PROTON_REACTION_NONELASTIC super().__init__(type_, MT, xs, xs_offset, reference_frame, q_value) self.multiplicity = multiplicity @@ -229,7 +218,7 @@ def __repr__(self): def set_basic_properties(h5_group): - MT = h5_group.attrs["MT"][()] + MT = int(h5_group.attrs["MT"][()]) xs = h5_group["xs"][()] xs_offset = h5_group["xs"].attrs["offset"] reference_frame = h5_group["reference_frame"][()].decode("utf-8") @@ -242,19 +231,64 @@ def set_basic_properties(h5_group): def set_angular_distribution(h5_group): - mu_type = h5_group.attrs["type"] + # Handle missing type attribute + if "type" not in h5_group.attrs: + mu_type = "isotropic" + else: + mu_type = h5_group.attrs["type"] + if mu_type == "isotropic": angle_type = ANGLE_ISOTROPIC mu = simulation.distributions[0] elif mu_type == "energy-correlated": angle_type = ANGLE_ENERGY_CORRELATED mu = simulation.distributions[0] - else: + elif mu_type == "given_in_energy_distribution": + # Angular information comes from the Kalbach-Mann energy distribution. + angle_type = ANGLE_ENERGY_CORRELATED + mu = simulation.distributions[0] + elif mu_type == "tabulated": angle_type = ANGLE_DISTRIBUTED - grid = h5_group[f"energy"][()] * 1e6 # MeV to eV - offset = h5_group[f"offset"][()] - value = h5_group[f"value"][()] - pdf = h5_group[f"pdf"][()] + + # Check if data is in flattened format or subgroup format + if "energy" in h5_group: + # Flattened format + grid = h5_group[f"energy"][()] * 1e6 # MeV to eV + offset = h5_group[f"offset"][()] + value = h5_group[f"value"][()] + pdf = h5_group[f"pdf"][()] + else: + # Subgroup format: E_in_1, E_in_2, etc. + incident_energies = h5_group["incident_energies"][()] * 1e6 # MeV to eV + + # Collect all cosines and pdfs into flattened arrays + cosines_list = [] + pdf_list = [] + offset = np.zeros(len(incident_energies), dtype=np.int32) + + for i, energy in enumerate(incident_energies): + subgroup_name = f"E_in_{i + 1}" + if subgroup_name in h5_group: + subgroup = h5_group[subgroup_name] + if subgroup.attrs.get("type", "tabulated") == "tabulated": + cosines_list.extend(subgroup["cosines"][()]) + pdf_list.extend(subgroup["pdf"][()]) + else: + # Isotropic - use dummy values + cosines_list.extend([0.0]) # isotropic cosine + pdf_list.extend([1.0]) # uniform pdf + else: + # Missing subgroup - assume isotropic + cosines_list.extend([0.0]) + pdf_list.extend([1.0]) + + if i < len(incident_energies) - 1: + offset[i + 1] = len(cosines_list) + + grid = incident_energies + value = np.array(cosines_list) + pdf = np.array(pdf_list) + mu = DistributionMultiTable(grid, offset, value, pdf) return angle_type, mu @@ -338,3 +372,94 @@ def set_energy_distribution(h5_group): print_error(f"Unsupported energy spectrum of type {spectrum_type}") return energy_spectrum + + +# ====================================================================================== +# Proton secondary particle channel +# ====================================================================================== + + +class ProtonSecondaryChannel(ObjectPolymorphic): + """ + Data container for a proton secondary particle channel. + Plain helper object. + """ + particle_type: int + MT: int + multiplicity: float64 # Multiplicity of particles produced per reaction + production_xs: NDArray[float64] + production_xs_offset_: int + reference_frame: int # COM or LAB + energy_spectrum: DistributionBase + + def __init__( + self, + particle_type, + MT, + multiplicity, + production_xs, + production_xs_offset, + reference_frame, + energy_spectrum, + ): + self.particle_type = particle_type + self.MT = MT + self.multiplicity = multiplicity + self.production_xs = production_xs + self.production_xs_offset_ = production_xs_offset + self.reference_frame = reference_frame + self.energy_spectrum = energy_spectrum + super().__init__(type_=0, register=False) + + @classmethod + def from_h5_group(cls, h5_group, zap): + """ + Load a secondary particle channel from HDF5 group. + zap: ZAP code (1=neutron, 31=proton, etc.) + """ + if zap not in ZAP_TO_PARTICLE: + raise ValueError(f"zap {zap} not in ZAP_TO_PARTICLE") + particle_type = ZAP_TO_PARTICLE.get(zap) + MT = h5_group.attrs["MT"] + multiplicity = h5_group.attrs["multiplicity"] + + reference_frame_str = h5_group.attrs["reference_frame"] + if reference_frame_str == "LAB": + reference_frame = REFERENCE_FRAME_LAB + elif reference_frame_str == "COM": + reference_frame = REFERENCE_FRAME_COM + else: + reference_frame = REFERENCE_FRAME_COM # default + + # Production cross section (optional) + if "production_xs" in h5_group: + production_xs = h5_group["production_xs"][()] + production_xs_offset = h5_group["production_xs"].attrs["offset"] + else: + production_xs = np.zeros(0, dtype=float) + production_xs_offset = 0 + + # Energy spectrum (currently assume Kalbach-Mann) + energy_spectrum = set_energy_distribution(h5_group["kalbach_mann"]) + + return cls( + particle_type, + MT, + multiplicity, + production_xs, + production_xs_offset, + reference_frame, + energy_spectrum, + ) + + def __repr__(self): + particle_name = "Neutron" if self.particle_type == PARTICLE_NEUTRON else "Proton" + text = "\n" + text += f"Proton secondary channel ({particle_name})\n" + text += f" - ID: {self.ID}\n" + text += f" - MT: {self.MT}\n" + text += f" - Multiplicity: {self.multiplicity}\n" + text += f" - Production XS: {print_1d_array(self.production_xs)} barn\n" + text += f" - Reference frame: {decode_reference_frame(self.reference_frame)}\n" + text += f" - Energy spectrum: {distribution.decode_type(self.energy_spectrum.type)} [ID: {self.energy_spectrum.ID}]\n" + return text diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index 3d895c5e4..9d5ed9f02 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -24,7 +24,6 @@ def particle_speed(particle_container, simulation, data): elif particle["particle_type"] == PARTICLE_ELECTRON: return electron.particle_speed(particle_container, simulation, data) elif particle["particle_type"] == PARTICLE_PROTON: - print(f'proton E = {particle["E"]}') return proton.particle_speed(particle_container, simulation, data) return -1.0 diff --git a/mcdc/transport/physics/proton/interface.py b/mcdc/transport/physics/proton/interface.py index ebbc1b99b..eda6c9013 100644 --- a/mcdc/transport/physics/proton/interface.py +++ b/mcdc/transport/physics/proton/interface.py @@ -13,11 +13,7 @@ @njit def particle_speed(particle_container, simulation, data): - if simulation["settings"]["proton_multigroup_mode"]: - return multigroup.particle_speed(particle_container, simulation, data) - else: - return native.particle_speed(particle_container) - + return native.particle_speed(particle_container) # ====================================================================================== # Material properties @@ -26,23 +22,7 @@ def particle_speed(particle_container, simulation, data): @njit def macro_xs(reaction_type, particle_container, simulation, data): - if simulation["settings"]["proton_multigroup_mode"]: - return multigroup.macro_xs(reaction_type, particle_container, simulation, data) - else: - return native.macro_xs(reaction_type, particle_container, simulation, data) - - -# @njit -# def proton_production_xs(reaction_type, particle_container, simulation, data): -# if simulation["settings"]["proton_multigroup_mode"]: -# return multigroup.proton_production_xs( -# reaction_type, particle_container, simulation, data -# ) -# else: -# return native.proton_production_xs( -# reaction_type, particle_container, simulation, data -# ) - + return native.macro_xs(reaction_type, particle_container, simulation, data) # ====================================================================================== # Collision @@ -52,10 +32,4 @@ def macro_xs(reaction_type, particle_container, simulation, data): @njit def collision(particle_container, collision_data_container, program, data): simulation = util.access_simulation(program) - - if simulation["settings"]["proton_multigroup_mode"]: - multigroup.collision( - particle_container, collision_data_container, program, data - ) - else: - native.collision(particle_container, collision_data_container, program, data) + native.collision(particle_container, collision_data_container, program, data) \ No newline at end of file diff --git a/mcdc/transport/physics/proton/multigroup.py b/mcdc/transport/physics/proton/multigroup.py index f91aae924..9b94c5d4d 100644 --- a/mcdc/transport/physics/proton/multigroup.py +++ b/mcdc/transport/physics/proton/multigroup.py @@ -15,8 +15,8 @@ from mcdc.constant import ( PI, PROTON_REACTION_TOTAL, - PROTON_REACTION_CAPTURE, PROTON_REACTION_ELASTIC_SCATTERING, + PROTON_REACTION_NONELASTIC, ) from mcdc.transport.physics.util import scatter_direction from mcdc.transport.distribution import sample_isotropic_direction @@ -46,8 +46,6 @@ def macro_xs(reaction_type, particle_container, simulation, data): if reaction_type == PROTON_REACTION_TOTAL: return mcdc_get.multigroup_material.mgxs_total(g, material, data) - elif reaction_type == PROTON_REACTION_CAPTURE: - return mcdc_get.multigroup_material.mgxs_capture(g, material, data) elif reaction_type == PROTON_REACTION_ELASTIC_SCATTERING: return mcdc_get.multigroup_material.mgxs_scatter(g, material, data) return 0.0 diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 4ef71f3a3..a0272fac8 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -22,11 +22,13 @@ PI, PI_HALF, PI_SQRT, - PROTON_REACTION_INELASTIC_SCATTERING, PROTON_REACTION_TOTAL, - PROTON_REACTION_CAPTURE, PROTON_REACTION_ELASTIC_SCATTERING, + PROTON_REACTION_NONELASTIC, REFERENCE_FRAME_COM, + PARTICLE_ELECTRON, + PARTICLE_NEUTRON, + PARTICLE_PROTON, ) from mcdc.transport.data import evaluate_data from mcdc.transport.distribution import ( @@ -35,6 +37,7 @@ sample_isotropic_cosine, sample_isotropic_direction, sample_multi_table, + sample_kalbach_mann, ) from mcdc.transport.physics.util import ( evaluate_proton_xs_energy_grid, @@ -97,12 +100,9 @@ def total_micro_xs(reaction_type, E, nuclide, data): elif reaction_type == PROTON_REACTION_ELASTIC_SCATTERING: xs0 = mcdc_get.nuclide.proton_elastic_xs(idx, nuclide, data) xs1 = mcdc_get.nuclide.proton_elastic_xs(idx + 1, nuclide, data) - elif reaction_type == PROTON_REACTION_CAPTURE: - xs0 = mcdc_get.nuclide.proton_capture_xs(idx, nuclide, data) - xs1 = mcdc_get.nuclide.proton_capture_xs(idx + 1, nuclide, data) - elif reaction_type == PROTON_REACTION_INELASTIC_SCATTERING: - xs0 = mcdc_get.nuclide.proton_inelastic_xs(idx, nuclide, data) - xs1 = mcdc_get.nuclide.proton_inelastic_xs(idx + 1, nuclide, data) + elif reaction_type == PROTON_REACTION_NONELASTIC: + xs0 = mcdc_get.nuclide.proton_nonelastic_xs(idx, nuclide, data) + xs1 = mcdc_get.nuclide.proton_nonelastic_xs(idx + 1, nuclide, data) else: # Should be unreachable xs0 = 0.0 @@ -125,70 +125,6 @@ def reaction_micro_xs(E, reaction_base, nuclide, data): xs1 = mcdc_get.proton_reaction.xs(idx + 1, reaction_base, data) return linear_interpolation(E, E0, E1, xs0, xs1) - -# @njit -# def proton_production_xs(reaction_type, particle_container, simulation, data): -# # Total production -# if reaction_type == PROTON_REACTION_TOTAL: -# elastic_xs = macro_xs( -# PROTON_REACTION_ELASTIC_SCATTERING, particle_container, simulation, data -# ) -# inelastic_xs = _proton_inelastic_scattering_production_xs( -# particle_container, simulation, data -# ) -# return elastic_xs + inelastic_xs - -# # Elastic scattering production -# elif reaction_type == PROTON_REACTION_ELASTIC_SCATTERING: -# return macro_xs(reaction_type, particle_container, simulation, data) - -# # Capture production (none) -# elif reaction_type == PROTON_REACTION_CAPTURE: -# return 0.0 - -# # Inelastic scattering production -# elif reaction_type == PROTON_REACTION_INELASTIC_SCATTERING: -# return _proton_inelastic_scattering_production_xs( -# particle_container, simulation, data -# ) - -# # Unsupported default -# else: -# return 0.0 - - -# @njit -# def _proton_inelastic_scattering_production_xs(particle_container, simulation, data): -# particle = particle_container[0] -# material_base = simulation["materials"][particle["material_ID"]] -# material = simulation["native_materials"][material_base["child_ID"]] - -# total = 0.0 -# for i in range(material["N_nuclide"]): -# nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) -# nuclide = simulation["nuclides"][nuclide_ID] - -# E = particle["E"] -# nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) - -# for j in range(nuclide["N_proton_inelastic_scattering_reaction"]): -# reaction_ID = int( -# mcdc_get.nuclide.proton_inelastic_scattering_reaction_IDs( -# j, nuclide, data -# ) -# ) -# reaction_base = simulation["proton_reactions"][reaction_ID] -# reaction = simulation["proton_inelastic_scattering_reactions"][ -# reaction_base["child_ID"] -# ] - -# xs = reaction_micro_xs(E, reaction_base, nuclide, data) -# nu = reaction["multiplicity"] -# total += nuclide_density * nu * xs - -# return total - - # ====================================================================================== # Collision # ====================================================================================== @@ -210,42 +146,7 @@ def collision(particle_container, collision_data_container, program, data): SigmaT = macro_xs(PROTON_REACTION_TOTAL, particle_container, simulation, data) - # Implicit capture - if simulation["implicit_capture"]["active"]: - # Calculate capture fraction - SigmaC = macro_xs( - PROTON_REACTION_CAPTURE, particle_container, simulation, data - ) - capture_fraction = SigmaC / SigmaT - - # Deposit energy captured - collision_data["energy_deposition"] += E * particle["w"] * capture_fraction - - # Q-value: xs-weighted average over all nuclides and capture reactions - for i in range(material["N_nuclide"]): - nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) - nuclide = simulation["nuclides"][nuclide_ID] - nuclide_density = mcdc_get.native_material.nuclide_densities( - i, material, data - ) - for j in range(nuclide["N_proton_capture_reaction"]): - reaction_ID = int( - mcdc_get.nuclide.proton_capture_reaction_IDs(j, nuclide, data) - ) - reaction = simulation["proton_capture_reactions"][reaction_ID] - reaction_base_ID = reaction["parent_ID"] - reaction_base = simulation["proton_reactions"][reaction_base_ID] - xs = reaction_micro_xs(E, reaction_base, nuclide, data) - Sigma_rx = nuclide_density * xs - collision_data["energy_deposition"] += ( - reaction_base["q_value"] * 1e6 * particle["w"] * Sigma_rx / SigmaT - ) - - # Capture particle weight - particle["w"] *= 1.0 - capture_fraction - - # Adjust total XS - SigmaT -= SigmaC + # No implicit capture for protons (there's no capture xs) xi = rng.lcg(particle_container) * SigmaT total = 0.0 @@ -256,10 +157,6 @@ def collision(particle_container, collision_data_container, program, data): nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) sigmaT = total_micro_xs(PROTON_REACTION_TOTAL, E, nuclide, data) - if simulation["implicit_capture"]["active"]: - sigmaC = total_micro_xs(PROTON_REACTION_CAPTURE, E, nuclide, data) - sigmaT -= sigmaC - SigmaT_nuclide = nuclide_density * sigmaT total += SigmaT_nuclide @@ -273,8 +170,8 @@ def collision(particle_container, collision_data_container, program, data): sigma_elastic = total_micro_xs( PROTON_REACTION_ELASTIC_SCATTERING, E, nuclide, data ) - sigma_inelastic = total_micro_xs( - PROTON_REACTION_INELASTIC_SCATTERING, E, nuclide, data + sigma_nonelastic = total_micro_xs( + PROTON_REACTION_NONELASTIC, E, nuclide, data ) xi = rng.lcg(particle_container) * sigmaT @@ -306,47 +203,18 @@ def collision(particle_container, collision_data_container, program, data): ) return - # Capture - if not simulation["implicit_capture"]["active"]: - sigma_capture = total_micro_xs(PROTON_REACTION_CAPTURE, E, nuclide, data) - total += sigma_capture - if xi < total: - # Sample the actual reaction from the group - total -= sigma_capture - for i in range(nuclide["N_proton_capture_reaction"]): - reaction_ID = int( - mcdc_get.nuclide.proton_capture_reaction_IDs(i, nuclide, data) - ) - reaction = simulation["proton_capture_reactions"][reaction_ID] - reaction_base_ID = reaction["parent_ID"] - reaction_base = simulation["proton_reactions"][reaction_base_ID] - xs = reaction_micro_xs(E, reaction_base, nuclide, data) - total += xs - - # Execute the reaction - if xi < total: - capture( - reaction, - particle_container, - collision_data_container, - nuclide, - simulation, - data, - ) - return - - # Inelastic scattering - total += sigma_inelastic + # Noelastic reaction + total += sigma_nonelastic if xi < total: # Sample the actual reaction from the group - total -= sigma_inelastic - for i in range(nuclide["N_proton_inelastic_scattering_reaction"]): + total -= sigma_nonelastic + for i in range(nuclide["N_proton_nonelastic_reaction"]): reaction_ID = int( - mcdc_get.nuclide.proton_inelastic_scattering_reaction_IDs( + mcdc_get.nuclide.proton_nonelastic_reaction_IDs( i, nuclide, data ) ) - reaction = simulation["proton_inelastic_scattering_reactions"][reaction_ID] + reaction = simulation["proton_nonelastic_reactions"][reaction_ID] reaction_base_ID = reaction["parent_ID"] reaction_base = simulation["proton_reactions"][reaction_base_ID] xs = reaction_micro_xs(E, reaction_base, nuclide, data) @@ -354,7 +222,7 @@ def collision(particle_container, collision_data_container, program, data): # Execute the reaction if xi < total: - inelastic_scattering( + nonelastic_reaction( reaction, particle_container, collision_data_container, @@ -364,29 +232,6 @@ def collision(particle_container, collision_data_container, program, data): ) return -# ====================================================================================== -# Capture -# ====================================================================================== - - -@njit -def capture( - reaction, particle_container, collision_data_container, nuclide, simulation, data -): - particle = particle_container[0] - collision_data = collision_data_container[0] - - reaction_base_ID = reaction["parent_ID"] - reaction_base = simulation["proton_reactions"][reaction_base_ID] - - # Terminate the particle - particle["alive"] = False - - # Energy deposition - E = particle["E"] - q_value = reaction_base["q_value"] * 1e6 - collision_data["energy_deposition"] += (E + q_value) * particle["w"] - # ====================================================================================== # Elastic scattering @@ -451,7 +296,12 @@ def elastic_scattering( uz = vz / speed # Sample the scattering cosine from the multi-PDF distribution - multi_table = simulation["multi_table_distributions"][reaction["mu_table_ID"]] + mu_table_ID = reaction["mu_table_ID"] + if mu_table_ID >= len(simulation["multi_table_distributions"]): + mu_table_ID = 0 # Fallback to first distribution + multi_table = simulation["multi_table_distributions"][mu_table_ID] + + # multi_table = simulation["multi_table_distributions"][reaction["mu_table_ID"]] mu0 = sample_multi_table(E, particle_container, multi_table, data) # Scatter the direction in COM @@ -534,14 +384,22 @@ def sample_nucleus_velocity(A, particle_container): # ====================================================================================== -# Inelastic scattering +# Nonelastic scattering # ====================================================================================== @njit -def inelastic_scattering( +def nonelastic_reaction( reaction, particle_container, collision_data_container, nuclide, program, data ): + + """ + Proton nonelastic scattering with secondary particle production. + + Samples: + 1. Outgoing proton from proton_reactions/inelastic/MT-005 + 2. Secondary particles from secondary_particles/ZAP_x/MT-005 + """ simulation = util.access_simulation(program) particle = particle_container[0] collision_data = collision_data_container[0] @@ -554,26 +412,34 @@ def inelastic_scattering( ux = particle["ux"] uy = particle["uy"] uz = particle["uz"] + w = particle["w"] - # Kill the current particle + # Kill the incident proton particle["alive"] = False - # Energy deposition + # Q-value energy available q_value = reaction_base["q_value"] * 1e6 - collision_data["energy_deposition"] += (E + q_value) * particle["w"] + total_energy = E + q_value - # Number of secondaries and spectra - N = reaction["multiplicity"] + # =========================================================================== + # 1. Sample outgoing PROTON + # =========================================================================== + + # Number of outgoing protons and spectra + N_proton = reaction["multiplicity"] N_spectrum = reaction["N_spectrum"] - use_all_spectrum = N == N_spectrum + use_all_spectrum = N_proton == N_spectrum - # Set up secondary partice container + # Set up secondary particle container particle_container_new = util.local_array(1, type_.particle_data) particle_new = particle_container_new[0] - # Create the secondaries - for n in range(N): - # Set default attributes + # Energy deposition (will be adjusted as we create secondaries) + collision_data["energy_deposition"] += total_energy * w + + # Create outgoing protons + for n in range(N_proton): + # Set default attributes (copy incident proton) particle_module.copy_as_child(particle_container_new, particle_container) # ============================================================================== @@ -599,7 +465,7 @@ def inelastic_scattering( # Get energy spectrum if use_all_spectrum: ID = int( - mcdc_get.proton_inelastic_scattering_reaction.energy_spectrum_IDs( + mcdc_get.proton_nonelastic_reaction.energy_spectrum_IDs( n, reaction, data ) ) @@ -608,23 +474,19 @@ def inelastic_scattering( offset = reaction["spectrum_probability_grid_offset"] length = reaction["spectrum_probability_grid_length"] probability_grid = data[offset : offset + length] - # Above is equivalent to: - # probability_grid = mcdc_get.proton_inelastic_scattering_reaction.spectrum_probability_grid_all( - # reaction, data - # ) probability_idx = find_bin(E, probability_grid) xi = rng.lcg(particle_container_new) total = 0.0 for j in range(N_spectrum): probability = ( - mcdc_get.proton_inelastic_scattering_reaction.spectrum_probability( + mcdc_get.proton_nonelastic_reaction.spectrum_probability( probability_idx, j, reaction, data ) ) total += probability if xi < total: ID = int( - mcdc_get.proton_inelastic_scattering_reaction.energy_spectrum_IDs( + mcdc_get.proton_nonelastic_reaction.energy_spectrum_IDs( j, reaction, data ) ) @@ -645,7 +507,6 @@ def inelastic_scattering( # Frame transformation # ============================================================================== - reaction_base = simulation["proton_reactions"][int(reaction["parent_ID"])] reference_frame = reaction_base["reference_frame"] if reference_frame == REFERENCE_FRAME_COM: A = nuclide["atomic_weight_ratio"] @@ -665,6 +526,7 @@ def inelastic_scattering( particle_new["uy"] = uy_new particle_new["uz"] = uz_new particle_new["E"] = E_new + particle_new["particle_type"] = PARTICLE_PROTON # Subtract outgoing energy from energy deposition collision_data["energy_deposition"] -= particle_new["E"] * particle_new["w"] @@ -674,14 +536,28 @@ def inelastic_scattering( # ============================================================================== # Keep it if it is the last particle - if n == N - 1: + if n == N_proton - 1: particle["alive"] = True particle["ux"] = particle_new["ux"] particle["uy"] = particle_new["uy"] particle["uz"] = particle_new["uz"] particle["E"] = particle_new["E"] + particle["particle_type"] = PARTICLE_PROTON else: particle_bank_module.bank_active_particle(particle_container_new, program) + # =========================================================================== + # 2. Sample SECONDARY PARTICLES from secondary_particles groups + # =========================================================================== + + # Get secondary channels for this MT (if any) + # MT = int(reaction_base["MT"]) + # nuclide_ID = particle["nuclide_ID"] + + # Check if nuclide has secondary particle data + # (This requires access to nuclide secondary_channels dict, which needs to be added) + # For now, we'll skip this part and it can be added when the data structure supports it + # TODO: Add secondary particle sampling when nuclide.proton_secondary_channels is accessible + # No fission for protons \ No newline at end of file diff --git a/tools/data_library_generator/tendl_generate_v2.py b/tools/data_library_generator/tendl_generate_v2.py new file mode 100644 index 000000000..fec0d4d17 --- /dev/null +++ b/tools/data_library_generator/tendl_generate_v2.py @@ -0,0 +1,913 @@ +# The majority of this script was written by Anthropic's Claude + +""" +ace_to_hdf5.py +============== +Convert a directory of proton ACE files (e.g. TENDL) into per-nuclide HDF5 files +suitable for use in MC/DC or similar Monte Carlo transport codes. + +Usage +----- + python ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 + python ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 --rewrite + python ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 --verbose + +Environment variable fallback (compatible with original MC/DC conventions): + $MCDC_ACELIB → ace_dir + $MCDC_LIB → output_dir + +HDF5 layout produced +-------------------- +/-K.h5 + attrs: + source_title, source_version, source_date + nuclide_name (str) + excitation_level (int) + temperature (float, K) + atomic_number (int) + atomic_weight_ratio (float) + fissionable (bool) + + proton_reactions/ + xs_energy_grid (float array, MeV) + + elastic_scattering/ + MT-002/ + xs (float array, barns) attrs: offset, unit + Q-value (float, MeV) + reference_frame (str: "COM") + angular_cosine_distribution/ (tabulated cosine distributions) + + capture/ + MT-{NNN}/ + xs, Q-value, reference_frame + + nonelastic_reaction/ + MT-{NNN}/ + xs, Q-value, reference_frame + multiplicity (int) + angular_cosine_distribution/ + energy_spectrum-{k}/ (one per distribution in a MultiDistributionData) + + fission/ (only if fissionable) + ... + + secondary_particles/ + ZAP_{zap}/ + attrs: ZAP (int), particle_name (str) + MT-{NNN}/ + attrs: MT (int), multiplicity (int), reference_frame (str) + production_xs (float array, barns) attrs: offset, unit + kalbach_mann/ + incident_energies (float array, MeV) + interpolation_boundaries (int array) + interpolation_types (int array) + E_in_{k}/ (one group per incident energy point) + outgoing_energies (float array, MeV) + pdf (float array) + cdf (float array) + r (float array) Kalbach-Mann precompound fraction + a (float array) Kalbach-Mann slope parameter + +Notes +----- +* The Kalbach-Mann property names on TabulatedKalbachMannDistribution are + introspected at runtime the first time a distribution is encountered, so + this script will work even if ACEtk renames them between versions. +* ZAP particle identity: 1=n, 31=p, 32=d, 33=t, 34=alpha +""" + +import argparse +import os +import sys + +import h5py +import numpy as np +from tqdm import tqdm + +import ACEtk + +# ────────────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────────────── + +# TODO: THIS IS UNCERTAIN - NEED TO VERIFY ZAP NUMBERS/PARTICLE TYPE CORRESPONDANCE + +ZAP_NAMES = { + 0: "photon", + 1: "neutron", + 31: "proton", + 32: "deuteron", + 33: "triton", + 34: "alpha", +} + +# Candidate property names for TabulatedKalbachMannDistribution fields. +# We try each list in order and use the first one that exists on the object. +_KM_CANDIDATES = { + "outgoing_energies": ["outgoing_energies", "energies", "energy"], + "pdf": ["pdf", "probabilities", "probability_density"], + "cdf": ["cdf", "cumulative_probabilities", "cumulative_distribution"], + "r": ["precompound_fraction_values", "precompound_fractions", "r", "R"], + "a": ["angular_distribution_slope_values", "slopes", "a", "A"], +} +# Cache resolved names so introspection only happens once. +_km_resolved: dict[str, str] = {} + + +def _resolve_km_attr(dist_obj, field: str) -> str: + """Return the actual attribute name on dist_obj for the given logical field.""" + if field in _km_resolved: + return _km_resolved[field] + for candidate in _KM_CANDIDATES[field]: + if hasattr(dist_obj, candidate): + _km_resolved[field] = candidate + return candidate + raise AttributeError( + f"Cannot find attribute for '{field}' on " + f"{type(dist_obj).__name__}. " + f"Tried: {_KM_CANDIDATES[field]}. " + f"Available: {[x for x in dir(dist_obj) if not x.startswith('_')]}" + ) + + +def get_km_field(dist_obj, field: str): + """Get a logical Kalbach-Mann field from a TabulatedKalbachMannDistribution.""" + attr = _resolve_km_attr(dist_obj, field) + return getattr(dist_obj, attr) + + +def print_error(msg: str): + print(f"\n[ERROR] {msg}", file=sys.stderr) + sys.exit(1) + + +def print_note(msg: str): + print(f" [note] {msg}") + + +# ────────────────────────────────────────────────────────────────────────────── +# ZAP / name decoding +# ────────────────────────────────────────────────────────────────────────────── + +# Periodic table symbol lookup (Z → symbol) +Z_TO_SYMBOL = { + 1: "H", 2: "He", 3: "Li", 4: "Be", 5: "B", 6: "C", 7: "N", 8: "O", + 9: "F", 10: "Ne",11: "Na",12: "Mg",13: "Al",14: "Si",15: "P", 16: "S", + 17:"Cl", 18:"Ar", 19:"K", 20:"Ca", 21:"Sc", 22:"Ti", 23:"V", 24:"Cr", + 25:"Mn", 26:"Fe", 27:"Co", 28:"Ni", 29:"Cu", 30:"Zn", 31:"Ga", 32:"Ge", + 33:"As", 34:"Se", 35:"Br", 36:"Kr", 37:"Rb", 38:"Sr", 39:"Y", 40:"Zr", + 41:"Nb", 42:"Mo", 43:"Tc", 44:"Ru", 45:"Rh", 46:"Pd", 47:"Ag", 48:"Cd", + 49:"In", 50:"Sn", 51:"Sb", 52:"Te", 53:"I", 54:"Xe", 55:"Cs", 56:"Ba", + 57:"La", 58:"Ce", 59:"Pr", 60:"Nd", 61:"Pm", 62:"Sm", 63:"Eu", 64:"Gd", + 65:"Tb", 66:"Dy", 67:"Ho", 68:"Er", 69:"Tm", 70:"Yb", 71:"Lu", 72:"Hf", + 73:"Ta", 74:"W", 75:"Re", 76:"Os", 77:"Ir", 78:"Pt", 79:"Au", 80:"Hg", + 81:"Tl", 82:"Pb", 83:"Bi", 84:"Po", 85:"At", 86:"Rn", 87:"Fr", 88:"Ra", + 89:"Ac", 90:"Th", 91:"Pa", 92:"U", 93:"Np", 94:"Pu", 95:"Am", 96:"Cm", + 97:"Bk", 98:"Cf", 99:"Es",100:"Fm",101:"Md",102:"No",103:"Lr", +} + + +def decode_ace_zaid(zaid: str): + """ + Decode an ACE ZAID string into (Z, A, S, T). + Handles both legacy '1001.70h' and modern '1001.710h' style ZAIDs. + Returns Z (atomic number), A (mass number), S (isomeric state), T (temperature K). + """ + # Strip trailing whitespace and split on '.' + parts = zaid.strip().split(".") + za_str = parts[0] + # ZA = Z*1000 + A, possibly with S encoded as ZA > 600000 (isomers) + za = int(za_str) + if za >= 600000: + # metastable: ZAID = Z*1000 + A + S*400 (legacy MCNP convention, approximate) + S = (za % 1000) // 400 # rough extraction + za = za - S * 400 + else: + S = 0 + Z = za // 1000 + A = za % 1000 + + # Temperature from suffix, e.g. '70h' → 293 K, '710h' → custom + # The conventional mapping is suffix_number * ~(1/100) * some factor. + # Most TENDL proton files just use a nominal 0K or room temperature. + # Use the header temperature value instead (set to 0 as default here). + T = 0 + return Z, A, S, T + + +# ────────────────────────────────────────────────────────────────────────────── +# Angular distribution loading (from original MC/DC approach) +# ────────────────────────────────────────────────────────────────────────────── + +def load_cosine_distribution(data, h5_group): + """ + Write a tabulated angular (cosine) distribution into an HDF5 group. + data is an AngularDistributionData object from ACEtk. + + Returns True if angular data was written, False if it is encoded + elsewhere (i.e. inside the Kalbach-Mann energy distribution block). + """ + # DistributionGivenElsewhere means the angular data is embedded in the + # LAW 44 Kalbach-Mann energy distribution via the r and a parameters. + # There is nothing to store here — the sampling code must use the + # Kalbach-Mann block instead. + if isinstance(data, ACEtk.continuous.DistributionGivenElsewhere): + h5_group.attrs["type"] = "given_in_energy_distribution" + return False + + energies = np.array(data.incident_energies) + h5_group.create_dataset("incident_energies", data=energies) + h5_group.attrs["unit"] = "MeV" + # Set type on root group (default to tabulated if we get here) + h5_group.attrs["type"] = "tabulated" + + for i, subdist in enumerate(data.distributions): + eg = h5_group.create_group(f"E_in_{i + 1}") + if isinstance(subdist, ACEtk.continuous.TabulatedAngularDistribution): + eg.attrs["type"] = "tabulated" + eg.create_dataset("cosines", data=np.array(subdist.cosines)) + eg.create_dataset("pdf", data=np.array(subdist.pdf)) + eg.create_dataset("cdf", data=np.array(subdist.cdf)) + else: + # Isotropic or unsupported — mark it so sampling code knows + eg.attrs["type"] = "isotropic" + + return True + + +# ────────────────────────────────────────────────────────────────────────────── +# Energy distribution loading (neutron/primary particle, existing reactions) +# ────────────────────────────────────────────────────────────────────────────── + +def load_energy_distribution(data, h5_group): + """ + Write a primary-particle outgoing energy distribution into an HDF5 group. + Handles the most common ACE law types encountered in proton libraries. + """ + if isinstance(data, ACEtk.continuous.KalbachMannDistributionData): + h5_group.attrs["law"] = 44 + _write_kalbach_mann(data, h5_group) + + elif isinstance(data, ACEtk.continuous.OutgoingEnergyDistributionData): + h5_group.attrs["law"] = 4 + h5_group.create_dataset( + "incident_energies", data=np.array(data.incident_energies) + ) + for k, dist in enumerate(data.distributions): + eg = h5_group.create_group(f"E_in_{k + 1}") + eg.create_dataset("outgoing_energies", data=np.array(dist.outgoing_energies)) + eg.create_dataset("pdf", data=np.array(dist.pdf)) + eg.create_dataset("cdf", data=np.array(dist.cdf)) + + elif isinstance(data, ACEtk.continuous.LevelScatteringData): + h5_group.attrs["law"] = 3 + h5_group.create_dataset("C1", data=data.C1) + h5_group.create_dataset("C2", data=data.C2) + + elif isinstance(data, ACEtk.continuous.EquiprobableOutgoingEnergyBins): + h5_group.attrs["law"] = 1 + h5_group.create_dataset( + "incident_energies", data=np.array(data.incident_energies) + ) + for k, dist in enumerate(data.distributions): + eg = h5_group.create_group(f"E_in_{k + 1}") + eg.create_dataset("energies", data=np.array(dist.energies)) + + else: + # Unknown law — store the raw XSS array so nothing is silently lost + h5_group.attrs["law"] = -1 + h5_group.attrs["type_name"] = type(data).__name__ + try: + h5_group.create_dataset("xss_array", data=np.array(data.xss_array)) + except Exception: + pass + + +def _write_kalbach_mann(km_data, h5_group): + """ + Write a KalbachMannDistributionData block into an open HDF5 group. + Uses MCDC-compatible format with flattened arrays and offset indices. + """ + h5_group.attrs["type"] = "kalbach-mann" + + NE = km_data.number_incident_energies + + # Incident energy grid + energy = np.array(km_data.incident_energies) + energy_ds = h5_group.create_dataset("energy", data=energy) + energy_ds.attrs["unit"] = "MeV" + + # Collect all outgoing energy points and build offset array + offset = np.zeros(NE, dtype=np.int32) + energy_out = [] + pdf = [] + precompound_factor = [] + angular_slope = [] + + for i in range(1, NE + 1): + dist = km_data.distribution(i) + offset[i - 1] = len(pdf) + energy_out.extend(get_km_field(dist, "outgoing_energies")) + pdf.extend(get_km_field(dist, "pdf")) + precompound_factor.extend(get_km_field(dist, "r")) + angular_slope.extend(get_km_field(dist, "a")) + + # Create flattened datasets + h5_group.create_dataset("offset", data=offset) + energy_out_ds = h5_group.create_dataset("energy_out", data=np.array(energy_out)) + energy_out_ds.attrs["unit"] = "MeV" + h5_group.create_dataset("pdf", data=np.array(pdf)) + h5_group.create_dataset("precompound_factor", data=np.array(precompound_factor)) + h5_group.create_dataset("angular_slope", data=np.array(angular_slope)) + + +# ────────────────────────────────────────────────────────────────────────────── +# Fission multiplicity loading +# ────────────────────────────────────────────────────────────────────────────── + +def load_fission_multiplicity(data, h5_group): + if isinstance(data, ACEtk.continuous.TabulatedFissionMultiplicity): + h5_group.attrs["type"] = "tabulated" + h5_group.create_dataset("energies", data=np.array(data.energies)) + h5_group.create_dataset("multiplicities", data=np.array(data.multiplicities)) + elif isinstance(data, ACEtk.continuous.PolynomialFissionMultiplicity): + h5_group.attrs["type"] = "polynomial" + h5_group.create_dataset("coefficients", data=np.array(data.coefficients)) + else: + h5_group.attrs["type"] = "unknown" + h5_group.attrs["type_name"] = type(data).__name__ + + +# ────────────────────────────────────────────────────────────────────────────── +# Secondary particle block extraction +# ────────────────────────────────────────────────────────────────────────────── + +def load_secondary_particles(ace_table, file, verbose=False): + """ + Extract all secondary particle production data from a proton ACE table + and write it into file['secondary_particles/ZAP_{zap}/MT-{MT:03}/...']. + """ + n_types = ace_table.number_secondary_particle_types + if n_types == 0: + return + + # ── Top-level block handles ─────────────────────────────────────────────── + # The secondary particle blocks are callable by type index — rx_block(i) + # returns the ReactionNumberBlock for type i, tyr_block(i) returns the + # FrameAndMultiplicityBlock for type i, etc. + type_block = ace_table.secondary_particle_type_block + info_block = ace_table.secondary_particle_information_block + rx_block = ace_table.secondary_particle_reaction_number_block + tyr_block = ace_table.secondary_particle_frame_and_multiplicity_block + xs_block = ace_table.secondary_particle_production_cross_section_block + edy_block = ace_table.secondary_particle_energy_distribution_block + + # angular block is optional for secondary particles in some libraries + try: + ang_block = ace_table.secondary_particle_angular_distribution_block + has_ang = True + except Exception: + has_ang = False + + sec_group = file.create_group("secondary_particles") + + # ── Introspect particle_identifier method name once ─────────────────────── + _pi_candidates = ["particle_identifier", "ZAP", "type", "particle_type"] + _pi_method = None + for cand in _pi_candidates: + if hasattr(type_block, cand): + _pi_method = cand + break + if _pi_method is None: + raise AttributeError( + f"Cannot find particle identifier method on " + f"{type(type_block).__name__}. " + f"Available: {[x for x in dir(type_block) if not x.startswith('_')]}" + ) + + # ── Loop over secondary particle types ──────────────────────────────────── + for i in range(1, n_types + 1): + + zap = getattr(type_block, _pi_method)(i) + name = ZAP_NAMES.get(zap, f"ZAP_{zap}") + + # number_reactions is a sequence property on info_block, 0-based + n_rx = int(info_block.number_reactions[i - 1]) + + if verbose: + print(f" Secondary particle type {i}: ZAP={zap} ({name}), " + f"{n_rx} reactions") + + zap_group = sec_group.create_group(f"ZAP_{zap}") + zap_group.attrs["ZAP"] = zap + zap_group.attrs["particle_name"] = name + + # Per-type sub-blocks: call the top-level block with the type index + # to get the per-type block, then call methods on that. + rx_i = rx_block(i) # ReactionNumberBlock for type i + tyr_i = tyr_block(i) # FrameAndMultiplicityBlock for type i + xs_i = xs_block(i) # production cross section block for type i + edy_i = edy_block(i) # energy distribution block for type i + ang_i = ang_block(i) if has_ang else None + + # Introspect xs sub-block method names (once, on first type) + _xs_candidates = [ + "production_xs", + "production_cross_sections", + "cross_sections", + "cross_section", + "cross_section_values", + "xs", + "xss", + ] + _off_candidates = ["energy_index", "offset", "locator", "index"] + _xs_method = next((c for c in _xs_candidates if hasattr(xs_i, c)), None) + _off_method = next((c for c in _off_candidates if hasattr(xs_i, c)), None) + + # Introspect energy distribution method name + _edy_candidates = ["energy_distribution_data", "distribution_data", "distribution"] + _edy_method = next((c for c in _edy_candidates if hasattr(edy_i, c)), None) + + for j in range(1, n_rx + 1): + + MT = rx_i.MT(j) + + # ── Multiplicity ───────────────────────────────────────────────── + nu_raw = tyr_i.multiplicity(j) + nu = nu_raw - 100 if nu_raw >= 100 else nu_raw + + # ── Reference frame ─────────────────────────────────────────────── + rf_raw = tyr_i.reference_frame(j) + if rf_raw == ACEtk.ReferenceFrame.Laboratory: + rf = "LAB" + elif rf_raw == ACEtk.ReferenceFrame.CentreOfMass: + rf = "COM" + else: + rf = str(rf_raw) + + mt_group = zap_group.create_group(f"MT-{MT:03}") + mt_group.attrs["MT"] = MT + mt_group.attrs["multiplicity"] = nu + mt_group.attrs["reference_frame"] = rf + + if verbose: + print(f" MT={MT:03} nu_raw={nu_raw} nu={nu} frame={rf}") + + # ── Production cross section ────────────────────────────────────── + if _xs_method and _off_method: + try: + xs_vals = np.array(getattr(xs_i, _xs_method)(j)) + xs_offset = int(getattr(xs_i, _off_method)(j)) + xs_ds = mt_group.create_dataset("production_xs", data=xs_vals) + xs_ds.attrs["offset"] = xs_offset - 1 # convert to 0-based + xs_ds.attrs["unit"] = "barns" + except Exception as exc: + xs_ds = mt_group.create_dataset("production_xs", data=np.zeros(0, dtype=float)) + xs_ds.attrs["offset"] = 0 + xs_ds.attrs["unit"] = "barns" + if verbose: + print(f" [warn] Could not read production xs: {exc}") + else: + xs_ds = mt_group.create_dataset("production_xs", data=np.zeros(0, dtype=float)) + xs_ds.attrs["offset"] = 0 + xs_ds.attrs["unit"] = "barns" + if verbose: + print(f" [warn] production xs block methods not resolved: " + f"{[x for x in dir(xs_i) if not x.startswith('_')]}") + + # ── Kalbach-Mann energy-angle distribution ──────────────────────── + if _edy_method: + try: + km_data = getattr(edy_i, _edy_method)(j) + km_group = mt_group.create_group("kalbach_mann") + _write_kalbach_mann(km_data, km_group) + except Exception as exc: + if verbose: + print(f" [warn] Could not read energy distribution: {exc}") + else: + if verbose: + print(f" [warn] energy distribution method not resolved: " + f"{[x for x in dir(edy_i) if not x.startswith('_')]}") + + # ── Angular distribution (if present) ──────────────────────────── + if ang_i is not None: + try: + ang_data = ang_i.angular_distribution_data(j) + ang_group = mt_group.create_group("angular_cosine_distribution") + load_cosine_distribution(ang_data, ang_group) + except Exception: + pass # not all secondary types have explicit angular data + + +# ────────────────────────────────────────────────────────────────────────────── +# Per-file processing +# ────────────────────────────────────────────────────────────────────────────── + +def process_ace_file(ace_path: str, output_dir: str, verbose: bool = False) -> str: + """ + Convert a single ACE proton file to HDF5. Returns the output filename. + """ + + # ── Header ──────────────────────────────────────────────────────────────── + with open(ace_path, "r") as f: + header = ACEtk.Header.from_string(f.readline()) + + Z, A, S, T = decode_ace_zaid(header.zaid) + symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") + nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" + + # Get temperature from the table itself (more reliable than ZAID suffix) + ace_table = ACEtk.ContinuousEnergyTable.from_file(ace_path) + T_kelvin = float(ace_table.temperature) if hasattr(ace_table, "temperature") else T + + # Forcing to be room temperature, as 0K from the file is a placeholder + T_kelvin = 293.6 + + mcdc_name = f"{nuclide_name}-{T_kelvin}K.h5" + out_path = os.path.join(output_dir, mcdc_name) + + if verbose: + print(f"\n{'='*80}") + print(f" {os.path.basename(ace_path)} → {mcdc_name}") + print(f" Z={Z} A={A} S={S} T={T_kelvin} K") + + file = h5py.File(out_path, "w") + + # ── Basic metadata ──────────────────────────────────────────────────────── + hdr = ace_table.header + file.attrs["source_title"] = hdr.title + file.attrs["source_version"] = hdr.version + file.attrs["source_date"] = hdr.date + if hasattr(hdr, "comments"): + file.attrs["source_comments"] = hdr.comments + + file.create_dataset("nuclide_name", data=nuclide_name) + file.create_dataset("excitation_level", data=S) + ds = file.create_dataset("temperature", data=T_kelvin) + ds.attrs["unit"] = "K" + file.create_dataset("atomic_number", data=ace_table.atom_number) + file.create_dataset("atomic_weight_ratio", data=ace_table.atomic_weight_ratio) + + fissionable = ace_table.fission_multiplicity_block is not None + file.create_dataset("fissionable", data=fissionable) + + # ── Reaction classification ─────────────────────────────────────────────── + proton_reactions = file.create_group("proton_reactions") + + nu_block = ace_table.frame_and_multiplicity_block + rx_block = ace_table.reaction_number_block + N_reaction = nu_block.number_reactions + + elastic_group = proton_reactions.create_group("elastic_scattering") + capture_group = proton_reactions.create_group("capture") + nonelastic_group = proton_reactions.create_group("nonelastic_reaction") + fission_group = proton_reactions.create_group("fission") + + elastic_MTs = [2] + capture_MTs = [] + nonelastic_MTs = [] + fission_MTs = [] + + fission_chance_MTs = [19, 20, 21, 38] + # Genuine redundant sum MTs — do not double-count these + redundant_MTs = [1, 3, 4, 10, 101, 103, 104, 105, 106, 107] + + total_fission_given = rx_block.has_MT(18) + if total_fission_given: + fission_MTs = [18] + else: + for MT in fission_chance_MTs: + if rx_block.has_MT(MT): + fission_MTs.append(MT) + + for i in range(N_reaction): + idx = i + 1 + MT = rx_block.MT(idx) + + if MT in redundant_MTs + elastic_MTs + fission_MTs: + continue + if MT > 891: # above the defined charged-particle range + continue + + nu_raw = nu_block.multiplicity(idx) + if not isinstance(nu_raw, int): + print_error(f"Non-integer multiplicity for MT-{MT:03} in {ace_path}") + + nu = nu_raw - 100 if nu_raw >= 100 else nu_raw + + if nu == 0: + capture_MTs.append(MT) + elif nu > 0: + nonelastic_MTs.append(MT) + else: + print_error(f"Negative decoded multiplicity for MT-{MT:03} in {ace_path}") + + # Create MT subgroups + for rx_group, rx_MTs in [ + (elastic_group, elastic_MTs), + (capture_group, capture_MTs), + (nonelastic_group, nonelastic_MTs), + (fission_group, fission_MTs), + ]: + for MT in rx_MTs: + g = rx_group.create_group(f"MT-{MT:03}") + g.attrs["MT"] = MT + + if verbose: + print(f" Elastic: {elastic_MTs}") + print(f" Capture: {capture_MTs}") + print(f" Nonelastic: {nonelastic_MTs}") + if fissionable: + print(f" Fission: {fission_MTs}") + + # Remove empty groups + if not fissionable: + del file["proton_reactions/fission"] + if len(nonelastic_MTs) == 0: + del file["proton_reactions/nonelastic_reaction"] + + # ── Cross sections ──────────────────────────────────────────────────────── + xs0_block = ace_table.principal_cross_section_block + xs_block_main = ace_table.cross_section_block + + xs_energy = np.array(xs0_block.energies) + ds = proton_reactions.create_dataset("xs_energy_grid", data=xs_energy) + ds.attrs["unit"] = "MeV" + + xs_ds = elastic_group.create_dataset("MT-002/xs", data=np.array(xs0_block.elastic)) + xs_ds.attrs["offset"] = 0 + xs_ds.attrs["unit"] = "barns" + + for MTs, group in [ + (capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if group is None: + continue + for MT in MTs: + idx = rx_block.index(MT) + xs_ds = group.create_dataset( + f"MT-{MT:03}/xs", + data=np.array(xs_block_main.cross_sections(idx)) + ) + xs_ds.attrs["offset"] = xs_block_main.energy_index(idx) - 1 + xs_ds.attrs["unit"] = "barns" + + # ── Q-values ────────────────────────────────────────────────────────────── + q_block = ace_table.reaction_qvalue_block + + elastic_group.create_dataset("MT-002/Q-value", data=0.0).attrs["unit"] = "MeV" + + for MTs, group in [ + (capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if group is None: + continue + for MT in MTs: + idx = rx_block.index(MT) + ds = group.create_dataset( + f"MT-{MT:03}/Q-value", data=q_block.q_value(idx) + ) + ds.attrs["unit"] = "MeV" + + # ── Reference frames ────────────────────────────────────────────────────── + elastic_group.create_dataset("MT-002/reference_frame", data="COM") + + for MTs, group in [ + (capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if group is None: + continue + for MT in MTs: + idx = rx_block.index(MT) + rf = nu_block.reference_frame(idx) + rf_str = ( + "LAB" if rf == ACEtk.ReferenceFrame.Laboratory else + "COM" if rf == ACEtk.ReferenceFrame.CentreOfMass else + str(rf) + ) + group.create_dataset(f"MT-{MT:03}/reference_frame", data=rf_str) + + # ── Nonelastic reaction multiplicities ───────────────────────────────────── + for MT in nonelastic_MTs: + idx = rx_block.index(MT) + nu_raw = nu_block.multiplicity(idx) + nu = nu_raw - 100 if nu_raw >= 100 else nu_raw + nonelastic_group.create_dataset(f"MT-{MT:03}/multiplicity", data=nu) + + # ── Angular distributions ───────────────────────────────────────────────── + angle_block = ace_table.angular_distribution_block + + ang_group = elastic_group.create_group("MT-002/angular_cosine_distribution") + ang_group.attrs["type"] = "energy-correlated" + data = angle_block.angular_distribution_data(0) + written = load_cosine_distribution(data, ang_group) + if not written and verbose: + print_note("MT-002 elastic angular distribution is given in energy block") + + for MTs, group in [ + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if group is None: + continue + for MT in MTs: + idx = rx_block.index(MT) + ang_group = group.create_group(f"MT-{MT:03}/angular_cosine_distribution") + data = angle_block.angular_distribution_data(idx) + written = load_cosine_distribution(data, ang_group) + if not written and verbose: + print_note(f"MT-{MT:03} angular distribution is given in energy block") + + # ── Primary energy distributions ────────────────────────────────────────── + energy_block = ace_table.energy_distribution_block + + for MTs, group in [ + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if group is None: + continue + for MT in MTs: + idx = rx_block.index(MT) + data = energy_block.energy_distribution_data(idx) + + if not isinstance(data, ACEtk.continuous.MultiDistributionData): + eg = group.create_group(f"MT-{MT:03}/energy_spectrum-1") + group.create_dataset( + f"MT-{MT:03}/spectrum_probability_grid", + data=np.array([0.0, 30.0]) + ).attrs["unit"] = "MeV" + group.create_dataset( + f"MT-{MT:03}/spectrum_probability", + data=np.array([[1.0]]) + ) + load_energy_distribution(data, eg) + else: + N_dist = data.number_distributions + # Probability grid + if all(np.array([x.number_interpolation_regions + for x in data.probabilities]) == 0): + prob_grid = np.array([0.0, 30.0]) + prob = np.zeros((1, N_dist)) + for k in range(N_dist): + prob[0, k] = max(data.probability(k + 1).probabilities) + elif (all(np.array([x.number_interpolation_regions + for x in data.probabilities]) == 1) + and all(np.array([x.interpolants + for x in data.probabilities]) == 1)): + prob_grid = np.array(data.probability(1).energies) + prob = np.zeros((len(prob_grid) - 1, N_dist)) + for k in range(N_dist): + prob[:, k] = np.array( + data.probability(k + 1).probabilities[:-1] + ) + else: + print_error(f"Unsupported multi-distribution probability for " + f"MT-{MT:03} in {ace_path}") + + group.create_dataset( + f"MT-{MT:03}/spectrum_probability_grid", data=prob_grid + ).attrs["unit"] = "MeV" + group.create_dataset( + f"MT-{MT:03}/spectrum_probability", data=prob + ) + for k in range(N_dist): + eg = group.create_group(f"MT-{MT:03}/energy_spectrum-{k+1}") + load_energy_distribution(data.distribution(k + 1), eg) + + # ── Secondary particles ─────────────────────────────────────────────────── + load_secondary_particles(ace_table, file, verbose=verbose) + + # ── Fission data (if applicable) ────────────────────────────────────────── + if fissionable: + prompt_block = ace_table.fission_multiplicity_block + delayed_block = ace_table.delayed_fission_multiplicity_block + dnp_block = ace_table.delayed_neutron_precursor_block + + h5g = fission_group.create_group("prompt_multiplicity") + load_fission_multiplicity(prompt_block.multiplicity, h5g) + + if delayed_block is not None: + h5g = fission_group.create_group("delayed_multiplicity") + load_fission_multiplicity(delayed_block.multiplicity, h5g) + + if dnp_block is not None: + N_DNP = dnp_block.number_delayed_precursors + fractions = np.zeros(N_DNP) + decay_rates = np.zeros(N_DNP) + for k in range(N_DNP): + d = dnp_block.precursor_group_data(k + 1) + if (d.number_interpolation_regions != 0 + or len(d.probabilities[:]) != 2 + or d.probabilities[0] != d.probabilities[1]): + print_error("Non-constant delayed neutron precursor fraction") + fractions[k] = d.probabilities[0] + decay_rates[k] = d.decay_constant + + prec = fission_group.create_group("delayed_neutron_precursors") + prec.create_dataset("fractions", data=fractions) + dr_ds = prec.create_dataset("decay_rates", data=decay_rates) + dr_ds.attrs["unit"] = "/s" + + delayed_spectrum_block = ace_table.delayed_neutron_energy_distribution_block + for k in range(N_DNP): + d = delayed_spectrum_block.energy_distribution_data(k + 1) + eg = prec.create_group(f"energy_spectrum-{k+1}") + load_energy_distribution(d, eg) + + file.close() + return mcdc_name + + +# ────────────────────────────────────────────────────────────────────────────── +# Main +# ────────────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Convert proton ACE files to MC/DC-compatible HDF5" + ) + parser.add_argument("--ace_dir", default=os.getenv("MCDC_ACELIB"), + help="Directory containing ACE files " + "(default: $MCDC_ACELIB)") + parser.add_argument("--output_dir", default=os.getenv("MCDC_LIB"), + help="Output directory for HDF5 files " + "(default: $MCDC_LIB)") + parser.add_argument("--rewrite", action="store_true", default=False, + help="Rewrite existing HDF5 files") + parser.add_argument("--verbose", action="store_true", default=False, + help="Print detailed per-reaction info") + args = parser.parse_args() + + if args.ace_dir is None: + print_error("No ACE directory specified. Use --ace_dir or set $MCDC_ACELIB.") + if args.output_dir is None: + print_error("No output directory specified. Use --output_dir or set $MCDC_LIB.") + + os.makedirs(args.output_dir, exist_ok=True) + print(f"\nACE directory : {args.ace_dir}") + print(f"Output directory: {args.output_dir}\n") + + all_files = sorted(os.listdir(args.ace_dir)) + + # Filter to only unprocessed files unless --rewrite + if args.rewrite: + target_files = all_files + else: + target_files = [] + for fname in all_files: + ace_path = os.path.join(args.ace_dir, fname) + try: + with open(ace_path, "r") as f: + hdr = ACEtk.Header.from_string(f.readline()) + Z, A, S, _ = decode_ace_zaid(hdr.zaid) + symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") + nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" + # We don't know T yet without loading the full table, so check + # for any existing file matching the nuclide name pattern. + existing = [ + f for f in os.listdir(args.output_dir) + if f.startswith(nuclide_name + "-") + ] + if not existing: + target_files.append(fname) + except Exception: + target_files.append(fname) # include if we can't read header + + errors = [] + pbar = tqdm( + target_files, + disable=args.verbose, + bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} {postfix}", + ) + + for ace_name in pbar: + ace_path = os.path.join(args.ace_dir, ace_name) + pbar.set_postfix_str(ace_name) + try: + out = process_ace_file(ace_path, args.output_dir, verbose=args.verbose) + if args.verbose: + print(f" → wrote {out}") + except Exception as exc: + errors.append((ace_name, str(exc))) + if args.verbose: + import traceback + traceback.print_exc() + + print(f"\nDone. {len(target_files) - len(errors)} succeeded, " + f"{len(errors)} failed.") + if errors: + print("\nFailed files:") + for name, msg in errors: + print(f" {name}: {msg}") + + +if __name__ == "__main__": + main() \ No newline at end of file From 0a3b14cd1575f327be0c5f2c30cb89ce5598e7d0 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Mon, 11 May 2026 15:12:31 -0700 Subject: [PATCH 38/64] proton energy cutoff --- mcdc/constant.py | 5 +++-- mcdc/transport/physics/proton/native.py | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/mcdc/constant.py b/mcdc/constant.py index 514d634ca..f934ce301 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -139,12 +139,12 @@ DISTRIBUTION_TABULATED_ENERGY_ANGLE = 8 DISTRIBUTION_N_BODY = 9 -# Anguler distribution type +# Angular distribution type ANGLE_ISOTROPIC = 0 ANGLE_DISTRIBUTED = 1 ANGLE_ENERGY_CORRELATED = 2 -# Referance frame +# Reference frame REFERENCE_FRAME_LAB = 0 REFERENCE_FRAME_COM = 1 @@ -196,6 +196,7 @@ PROTON_MASS = 938.27208943e6 # eV/c^2 BOLTZMANN_K = 8.61733326e-5 # eV/K ELECTRON_CUTOFF_ENERGY = 100 # eV +PROTON_CUTOFF_ENERGY = 1000 # eV - this is dictated by the TENDL data; minimum of 1000 eV on the energy grid MU_CUTOFF = 0.999999 THERMAL_THRESHOLD_FACTOR = 400 diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index a0272fac8..4abf3feb1 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -29,6 +29,7 @@ PARTICLE_ELECTRON, PARTICLE_NEUTRON, PARTICLE_PROTON, + PROTON_CUTOFF_ENERGY, ) from mcdc.transport.data import evaluate_data from mcdc.transport.distribution import ( @@ -140,6 +141,13 @@ def collision(particle_container, collision_data_container, program, data): # Particle properties E = particle["E"] + # Check for cutoff energy + if E <= PROTON_CUTOFF_ENERGY: + collision_data["energy_deposition"] += E * particle["w"] + particle["alive"] = False + particle["E"] = 0.0 + return + # ================================================================================== # Sample colliding nuclide # ================================================================================== From 1e382ebb0150e99e8ca38094fd90b664521129ad Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Mon, 25 May 2026 17:30:18 -0700 Subject: [PATCH 39/64] CSDA support for proton transport --- mcdc/constant.py | 2 + mcdc/mcdc_get/nuclide.py | 58 ++++++++++++++++++++++ mcdc/mcdc_set/nuclide.py | 58 ++++++++++++++++++++++ mcdc/numba_types.py | 4 ++ mcdc/object_/nuclide.py | 13 +++++ mcdc/transport/physics/__init__.py | 2 + mcdc/transport/physics/interface.py | 40 +++++++++++++++ mcdc/transport/physics/proton/__init__.py | 2 +- mcdc/transport/physics/proton/interface.py | 7 ++- mcdc/transport/physics/proton/native.py | 31 ++++++++++++ mcdc/transport/simulation.py | 58 ++++++++++++++++++++++ 11 files changed, 273 insertions(+), 2 deletions(-) diff --git a/mcdc/constant.py b/mcdc/constant.py index f934ce301..11f57a786 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -92,6 +92,7 @@ # Miscellanies EVENT_TIME_CENSUS = 1 << 5 EVENT_TIME_BOUNDARY = 1 << 6 +EVENT_CSDA_EDEP = 1 << 7 # Materials MATERIAL = 0 @@ -182,6 +183,7 @@ PI_SQRT = math.sqrt(PI) PI_HALF = PI / 2.0 BANKMAX = 100 # Default maximum active bank +CSDA_MAX_FRACTIONAL_E_LOSS = 0.01 # Axes AXIS_X = 0 diff --git a/mcdc/mcdc_get/nuclide.py b/mcdc/mcdc_get/nuclide.py index c5237c645..d86a2a3bb 100644 --- a/mcdc/mcdc_get/nuclide.py +++ b/mcdc/mcdc_get/nuclide.py @@ -552,3 +552,61 @@ def neutron_fission_delayed_spectrum_IDs_chunk(start, length, nuclide, data): start += nuclide["neutron_fission_delayed_spectrum_IDs_offset"] end = start + length return data[start:end] + + +@njit +def stopping_power(index, nuclide, data): + offset = nuclide["stopping_power_offset"] + return data[offset + index] + + +@njit +def stopping_power_all(nuclide, data): + start = nuclide["stopping_power_offset"] + size = nuclide["stopping_power_length"] + end = start + size + return data[start:end] + + +@njit +def stopping_power_last(nuclide, data): + start = nuclide["stopping_power_offset"] + size = nuclide["stopping_power_length"] + end = start + size + return data[end - 1] + + +@njit +def stopping_power_chunk(start, length, nuclide, data): + start += nuclide["stopping_power_offset"] + end = start + length + return data[start:end] + + +@njit +def stopping_power_energy_grid(index, nuclide, data): + offset = nuclide["stopping_power_energy_grid_offset"] + return data[offset + index] + + +@njit +def stopping_power_energy_grid_all(nuclide, data): + start = nuclide["stopping_power_energy_grid_offset"] + size = nuclide["stopping_power_energy_grid_length"] + end = start + size + return data[start:end] + + +@njit +def stopping_power_energy_grid_last(nuclide, data): + start = nuclide["stopping_power_energy_grid_offset"] + size = nuclide["stopping_power_energy_grid_length"] + end = start + size + return data[end - 1] + + +@njit +def stopping_power_energy_grid_chunk(start, length, nuclide, data): + start += nuclide["stopping_power_energy_grid_offset"] + end = start + length + return data[start:end] diff --git a/mcdc/mcdc_set/nuclide.py b/mcdc/mcdc_set/nuclide.py index 18536bcf2..f8d6e7b62 100644 --- a/mcdc/mcdc_set/nuclide.py +++ b/mcdc/mcdc_set/nuclide.py @@ -552,3 +552,61 @@ def neutron_fission_delayed_spectrum_IDs_chunk(start, length, nuclide, data, val start += nuclide["neutron_fission_delayed_spectrum_IDs_offset"] end = start + length data[start:end] = value + + +@njit +def stopping_power(index, nuclide, data, value): + offset = nuclide["stopping_power_offset"] + data[offset + index] = value + + +@njit +def stopping_power_all(nuclide, data, value): + start = nuclide["stopping_power_offset"] + size = nuclide["stopping_power_length"] + end = start + size + data[start:end] = value + + +@njit +def stopping_power_last(nuclide, data, value): + start = nuclide["stopping_power_offset"] + size = nuclide["stopping_power_length"] + end = start + size + data[end - 1] = value + + +@njit +def stopping_power_chunk(start, length, nuclide, data, value): + start += nuclide["stopping_power_offset"] + end = start + length + data[start:end] = value + + +@njit +def stopping_power_energy_grid(index, nuclide, data, value): + offset = nuclide["stopping_power_energy_grid_offset"] + data[offset + index] = value + + +@njit +def stopping_power_energy_grid_all(nuclide, data, value): + start = nuclide["stopping_power_energy_grid_offset"] + size = nuclide["stopping_power_energy_grid_length"] + end = start + size + data[start:end] = value + + +@njit +def stopping_power_energy_grid_last(nuclide, data, value): + start = nuclide["stopping_power_energy_grid_offset"] + size = nuclide["stopping_power_energy_grid_length"] + end = start + size + data[end - 1] = value + + +@njit +def stopping_power_energy_grid_chunk(start, length, nuclide, data, value): + start += nuclide["stopping_power_energy_grid_offset"] + end = start + length + data[start:end] = value diff --git a/mcdc/numba_types.py b/mcdc/numba_types.py index f379a93e7..df53243f5 100644 --- a/mcdc/numba_types.py +++ b/mcdc/numba_types.py @@ -447,6 +447,10 @@ ('neutron_fission_delayed_decay_rates_length', int64), ('N_neutron_fission_delayed_spectrum', int64), ('neutron_fission_delayed_spectrum_IDs_offset', int64), + ('stopping_power_offset', int64), + ('stopping_power_length', int64), + ('stopping_power_energy_grid_offset', int64), + ('stopping_power_energy_grid_length', int64), ('ID', int64), ]) diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index 849469128..fcdb9c07f 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -50,6 +50,7 @@ class Nuclide(ObjectNonSingleton): neutron_capture_xs: NDArray[float64] neutron_inelastic_xs: NDArray[float64] neutron_fission_xs: NDArray[float64] + # proton_xs_energy_grid: NDArray[float64] proton_total_xs: NDArray[float64] proton_elastic_xs: NDArray[float64] @@ -59,6 +60,7 @@ class Nuclide(ObjectNonSingleton): neutron_capture_reactions: list[NeutronReactionCapture] neutron_inelastic_scattering_reactions: list[NeutronReactionInelasticScattering] neutron_fission_reactions: list[NeutronReactionFission] + # proton_elastic_scattering_reactions: list[ProtonReactionElasticScattering] proton_nonelastic_reactions: list[ProtonReactionNonelasticReaction] proton_secondary_channels: dict[int, list[ProtonSecondaryChannel]] @@ -70,6 +72,9 @@ class Nuclide(ObjectNonSingleton): neutron_fission_delayed_fractions: NDArray[float64] neutron_fission_delayed_decay_rates: NDArray[float64] neutron_fission_delayed_spectra: list[DistributionBase] + # + stopping_power: NDArray[float64] + stopping_power_energy_grid: NDArray[float64] def __init__(self, nuclide_name, temperature): super().__init__() @@ -332,6 +337,14 @@ def set_proton_data(self): reaction = rx_class.from_h5_group(h5_group) rx_container.append(reaction) + + # ========================================================================== + # Stopping power for protons + # ========================================================================== + self.stopping_power = file["stopping_power"]["total_stopping_power"][()] + self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] + + # # ========================================================================== # # Secondary particles # # ========================================================================== diff --git a/mcdc/transport/physics/__init__.py b/mcdc/transport/physics/__init__.py index 72a579f30..c0e782318 100644 --- a/mcdc/transport/physics/__init__.py +++ b/mcdc/transport/physics/__init__.py @@ -4,6 +4,8 @@ neutron_production_xs, collision_distance, collision, + csda_distance, + csda_edep, ) import mcdc.transport.physics.electron as electron import mcdc.transport.physics.neutron as neutron diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index 9d5ed9f02..03c159edd 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -1,4 +1,5 @@ import math +import numpy as np from numba import njit @@ -9,6 +10,8 @@ import mcdc.transport.physics.neutron as neutron import mcdc.transport.physics.proton as proton +import mcdc.mcdc_get as mcdc_get + from mcdc.constant import * # ====================================================================================== @@ -55,6 +58,32 @@ def neutron_production_xs(reaction_type, particle_container, simulation, data): return -1.0 +@njit +def csda_distance(particle_container, simulation, data): + particle = particle_container[0] + material = simulation["native_materials"][particle["material_ID"]] + E = particle["E"] + total_rho = 0.0 + total_dedx = 0.0 + + for i in range(material["N_nuclide"]): + nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + nuclide = simulation["nuclides"][nuclide_ID] + dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) + dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) + dedx = np.interp(E/1e6, dedx_energies, dedx_values) + total_dedx += dedx*1e6 + + atomic_mass = nuclide["atomic_weight_ratio"] + nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) + total_rho += density_gcm3 + + print(f'dedx at energy {E} is {total_dedx}, max energy deposited is {E * CSDA_MAX_FRACTIONAL_E_LOSS}') + + return CSDA_MAX_FRACTIONAL_E_LOSS * E / total_dedx / total_rho + + # ====================================================================================== # Collision # ====================================================================================== @@ -93,3 +122,14 @@ def collision(particle_container, collision_data_container, program, data): electron.collision(particle_container, collision_data_container, program, data) elif particle["particle_type"] == PARTICLE_PROTON: proton.collision(particle_container, collision_data_container, program, data) + + +@njit +def csda_edep(particle_container, collision_data_container, program, data): + particle = particle_container[0] + if particle["particle_type"] == PARTICLE_NEUTRON: + raise ValueError("CSDA not supported for neutrons") + if particle["particle_type"] == PARTICLE_ELECTRON: + raise ValueError("CSDA not supported for electrons") + if particle["particle_type"] == PARTICLE_PROTON: + proton.csda_edep(particle_container, collision_data_container, program, data) \ No newline at end of file diff --git a/mcdc/transport/physics/proton/__init__.py b/mcdc/transport/physics/proton/__init__.py index d95186ada..6fed2a1e0 100644 --- a/mcdc/transport/physics/proton/__init__.py +++ b/mcdc/transport/physics/proton/__init__.py @@ -1,8 +1,8 @@ from .interface import ( particle_speed, macro_xs, - # proton_production_xs, collision, + csda_edep, ) import mcdc.transport.physics.proton.native as native import mcdc.transport.physics.proton.multigroup as multigroup diff --git a/mcdc/transport/physics/proton/interface.py b/mcdc/transport/physics/proton/interface.py index eda6c9013..e1f689095 100644 --- a/mcdc/transport/physics/proton/interface.py +++ b/mcdc/transport/physics/proton/interface.py @@ -32,4 +32,9 @@ def macro_xs(reaction_type, particle_container, simulation, data): @njit def collision(particle_container, collision_data_container, program, data): simulation = util.access_simulation(program) - native.collision(particle_container, collision_data_container, program, data) \ No newline at end of file + native.collision(particle_container, collision_data_container, program, data) + + +@njit +def csda_edep(particle_container, collision_data_container, program, data): + native.csda_edep(particle_container, collision_data_container, program, data) \ No newline at end of file diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 4abf3feb1..c52e7d25f 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -30,6 +30,7 @@ PARTICLE_NEUTRON, PARTICLE_PROTON, PROTON_CUTOFF_ENERGY, + CSDA_MAX_FRACTIONAL_E_LOSS, ) from mcdc.transport.data import evaluate_data from mcdc.transport.distribution import ( @@ -241,6 +242,31 @@ def collision(particle_container, collision_data_container, program, data): return +@njit +def csda_edep(particle_container, collision_data_container, program, data): + simulation = util.access_simulation(program) + particle = particle_container[0] + collision_data = collision_data_container[0] + material = simulation["native_materials"][particle["material_ID"]] + + # Particle properties + E = particle["E"] + + # Check for cutoff energy + if E <= PROTON_CUTOFF_ENERGY: + collision_data["energy_deposition"] += E * particle["w"] + particle["alive"] = False + particle["E"] = 0.0 + return + + # if particle makes it to this function, it will be losing CSDA_MAX_FRACTIONAL_E_LOSS of its energy + collision_data["energy_deposition"] += E * CSDA_MAX_FRACTIONAL_E_LOSS + particle["E"] -= E * CSDA_MAX_FRACTIONAL_E_LOSS + + return + + + # ====================================================================================== # Elastic scattering # ====================================================================================== @@ -261,6 +287,8 @@ def elastic_scattering( # Energy deposition collision_data["energy_deposition"] += E * particle["w"] + #print(f'\ndeposited {E * particle["w"]} eV at x={particle["x"]} from elastic scattering') + # Note: Q-value is zero in elastic scattering # Sample nucleus thermal velocity @@ -343,6 +371,7 @@ def elastic_scattering( collision_data["energy_deposition"] -= particle["E"] * particle["w"] + @njit def sample_nucleus_velocity(A, particle_container): particle = particle_container[0] @@ -444,6 +473,8 @@ def nonelastic_reaction( # Energy deposition (will be adjusted as we create secondaries) collision_data["energy_deposition"] += total_energy * w + #print(f'\ndeposited {total_energy * particle["w"]} eV at x={particle["x"]} from nonelastic_rxn') + # Create outgoing protons for n in range(N_proton): diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index bd31952a7..1413a3f27 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -361,6 +361,52 @@ def step_particle(particle_container, program, data): # Global weight roulette if simulation["global_weight_roulette"]["active"]: technique.global_weight_roulette(particle_container, simulation) + # CSDA energy depostiion + if particle["event"] & EVENT_CSDA_EDEP: + collision_data_container = np.zeros(1, type_.collision_data) + physics.csda_edep(particle_container, collision_data_container, simulation, data) + + # Score collision tallies + if simulation["cycle_active"]: + # Cell tallies + cell = simulation["cells"][particle["cell_ID"]] + for i in range(cell["N_tally"]): + tally_base_ID = int(mcdc_get.cell.tally_IDs(i, cell, data)) + tally_base = simulation["tallies"][tally_base_ID] + + # Skip non-collision tallies + if tally_base["child_type"] != TALLY_COLLISION: + continue + + tally = simulation["collision_tallies"][tally_base["child_ID"]] + tally_module.score.collision_tally( + particle_container, + collision_data_container, + tally, + simulation, + data, + ) + + # Other collision tallies + for i in range(simulation["N_collision_tally"]): + tally = simulation["collision_tallies"][i] + + # Skip cell tallies + if tally["spatial_filter_type"] == SPATIAL_FILTER_CELL: + continue + + tally_module.score.collision_tally( + particle_container, + collision_data_container, + tally, + simulation, + data, + ) + + + # Weight roulette + if particle["alive"]: + technique.weight_roulette(particle_container, simulation) @njit @@ -415,6 +461,9 @@ def move_to_event(particle_container, simulation, data): # Distance to next collision d_collision = physics.collision_distance(particle_container, simulation, data) + # Distance to max energy loss as dictated by CSDA + d_csda = physics.csda_distance(particle_container, simulation, data) + # ================================================================================== # Determine event(s) # ================================================================================== @@ -444,6 +493,15 @@ def move_to_event(particle_container, simulation, data): particle["event"] = EVENT_TIME_BOUNDARY particle["surface_ID"] = -1 + # Check distance to max energy loss from CSDA + if d_csda < distance - COINCIDENCE_TOLERANCE: + distance = d_csda + particle["event"] = EVENT_CSDA_EDEP + particle["surface_ID"] = -1 + elif geometry.check_coincidence(d_csda, distance): + particle["event"] += EVENT_CSDA_EDEP + + # ================================================================================== # Move particle # ================================================================================== From 531949767ae935d3312fdba02e6a3fdb9a927d37 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 27 May 2026 14:14:08 -0700 Subject: [PATCH 40/64] refactored the CSDA functions to deposit energy every time the particle moves --- mcdc/transport/physics/interface.py | 6 +- mcdc/transport/physics/proton/interface.py | 4 +- mcdc/transport/physics/proton/native.py | 31 ++++++-- mcdc/transport/simulation.py | 89 ++++++++++++---------- 4 files changed, 75 insertions(+), 55 deletions(-) diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index 03c159edd..9a8da821f 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -79,7 +79,7 @@ def csda_distance(particle_container, simulation, data): density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) total_rho += density_gcm3 - print(f'dedx at energy {E} is {total_dedx}, max energy deposited is {E * CSDA_MAX_FRACTIONAL_E_LOSS}') + # print(f'dedx at energy {E} is {total_dedx}, max energy deposited is {E * CSDA_MAX_FRACTIONAL_E_LOSS}') return CSDA_MAX_FRACTIONAL_E_LOSS * E / total_dedx / total_rho @@ -125,11 +125,11 @@ def collision(particle_container, collision_data_container, program, data): @njit -def csda_edep(particle_container, collision_data_container, program, data): +def csda_edep(particle_container, collision_data_container, distance, simulation, data): particle = particle_container[0] if particle["particle_type"] == PARTICLE_NEUTRON: raise ValueError("CSDA not supported for neutrons") if particle["particle_type"] == PARTICLE_ELECTRON: raise ValueError("CSDA not supported for electrons") if particle["particle_type"] == PARTICLE_PROTON: - proton.csda_edep(particle_container, collision_data_container, program, data) \ No newline at end of file + proton.csda_edep(particle_container, collision_data_container, distance, simulation, data) \ No newline at end of file diff --git a/mcdc/transport/physics/proton/interface.py b/mcdc/transport/physics/proton/interface.py index e1f689095..f98da1913 100644 --- a/mcdc/transport/physics/proton/interface.py +++ b/mcdc/transport/physics/proton/interface.py @@ -36,5 +36,5 @@ def collision(particle_container, collision_data_container, program, data): @njit -def csda_edep(particle_container, collision_data_container, program, data): - native.csda_edep(particle_container, collision_data_container, program, data) \ No newline at end of file +def csda_edep(particle_container, collision_data_container, distance, simulation, data): + native.csda_edep(particle_container, collision_data_container, distance, simulation, data) \ No newline at end of file diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index c52e7d25f..85e84573e 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -1,5 +1,5 @@ import math - +import numpy as np from numba import njit #### @@ -243,13 +243,10 @@ def collision(particle_container, collision_data_container, program, data): @njit -def csda_edep(particle_container, collision_data_container, program, data): - simulation = util.access_simulation(program) +def csda_edep(particle_container, collision_data_container, distance, simulation, data): particle = particle_container[0] collision_data = collision_data_container[0] material = simulation["native_materials"][particle["material_ID"]] - - # Particle properties E = particle["E"] # Check for cutoff energy @@ -259,10 +256,28 @@ def csda_edep(particle_container, collision_data_container, program, data): particle["E"] = 0.0 return - # if particle makes it to this function, it will be losing CSDA_MAX_FRACTIONAL_E_LOSS of its energy - collision_data["energy_deposition"] += E * CSDA_MAX_FRACTIONAL_E_LOSS - particle["E"] -= E * CSDA_MAX_FRACTIONAL_E_LOSS + total_stopping_power = 0.0 + total_rho_gcm3 = 0.0 + # Find the total stopping power by summing over every nuclide in the material + for i in range(material["N_nuclide"]): + nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + nuclide = simulation["nuclides"][nuclide_ID] + dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) + dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) + + # TODO: replace np.interp with a non-numpy function?? + dedx = np.interp(E/1e6, dedx_energies, dedx_values) + total_stopping_power += dedx + + # Convert atoms/barn-cm to g/cm³: + atomic_mass = nuclide["atomic_weight_ratio"] # mass in amu + nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) + total_rho_gcm3 += density_gcm3 + energy_loss = total_stopping_power * distance * total_rho_gcm3 * 1e6 + particle["E"] -= energy_loss * particle["w"] + collision_data["energy_deposition"] += energy_loss * particle["w"] return diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index 1413a3f27..2dc9dfd7a 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -346,6 +346,10 @@ def step_particle(particle_container, program, data): if particle["event"] & EVENT_TIME_BOUNDARY: particle["alive"] = False + # CSDA energy depostiion + if particle["event"] & EVENT_CSDA_EDEP: + pass + # ================================================================================== # Apply techniques # ================================================================================== @@ -361,48 +365,6 @@ def step_particle(particle_container, program, data): # Global weight roulette if simulation["global_weight_roulette"]["active"]: technique.global_weight_roulette(particle_container, simulation) - # CSDA energy depostiion - if particle["event"] & EVENT_CSDA_EDEP: - collision_data_container = np.zeros(1, type_.collision_data) - physics.csda_edep(particle_container, collision_data_container, simulation, data) - - # Score collision tallies - if simulation["cycle_active"]: - # Cell tallies - cell = simulation["cells"][particle["cell_ID"]] - for i in range(cell["N_tally"]): - tally_base_ID = int(mcdc_get.cell.tally_IDs(i, cell, data)) - tally_base = simulation["tallies"][tally_base_ID] - - # Skip non-collision tallies - if tally_base["child_type"] != TALLY_COLLISION: - continue - - tally = simulation["collision_tallies"][tally_base["child_ID"]] - tally_module.score.collision_tally( - particle_container, - collision_data_container, - tally, - simulation, - data, - ) - - # Other collision tallies - for i in range(simulation["N_collision_tally"]): - tally = simulation["collision_tallies"][i] - - # Skip cell tallies - if tally["spatial_filter_type"] == SPATIAL_FILTER_CELL: - continue - - tally_module.score.collision_tally( - particle_container, - collision_data_container, - tally, - simulation, - data, - ) - # Weight roulette if particle["alive"]: @@ -542,3 +504,46 @@ def move_to_event(particle_container, simulation, data): # Move particle particle_module.move(particle_container, distance, simulation, data) + + # CSDA calculates energy loss after particle has moved + if settings["csda"]: + collision_data_container = np.zeros(1, type_.collision_data) + physics.csda_edep(particle_container, collision_data_container, distance, simulation, data) + + # Score collision tallies (edep is a collision tally) + # TODO: maybe make edep a potential tracklength tally for CSDA? + if simulation["cycle_active"]: + # Cell tallies + cell = simulation["cells"][particle["cell_ID"]] + for i in range(cell["N_tally"]): + tally_base_ID = int(mcdc_get.cell.tally_IDs(i, cell, data)) + tally_base = simulation["tallies"][tally_base_ID] + + # Skip non-collision tallies + if tally_base["child_type"] != TALLY_COLLISION: + continue + + tally = simulation["collision_tallies"][tally_base["child_ID"]] + tally_module.score.collision_tally( + particle_container, + collision_data_container, + tally, + simulation, + data, + ) + + # Other collision tallies + for i in range(simulation["N_collision_tally"]): + tally = simulation["collision_tallies"][i] + + # Skip cell tallies + if tally["spatial_filter_type"] == SPATIAL_FILTER_CELL: + continue + + tally_module.score.collision_tally( + particle_container, + collision_data_container, + tally, + simulation, + data, + ) \ No newline at end of file From 035e1a4ea4aa064a34fdf2e0a94fb35dfc09b043 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 27 May 2026 14:54:39 -0700 Subject: [PATCH 41/64] added CSDA setting to input deck --- mcdc/numba_types.py | 1 + mcdc/object_/settings.py | 1 + 2 files changed, 2 insertions(+) diff --git a/mcdc/numba_types.py b/mcdc/numba_types.py index df53243f5..0080f82b8 100644 --- a/mcdc/numba_types.py +++ b/mcdc/numba_types.py @@ -595,6 +595,7 @@ ('time_boundary', float64), ('output_name', 'U32'), ('use_progress_bar', bool), + ('csda', bool), ('N_census', int64), ('census_time_offset', int64), ('census_time_length', int64), diff --git a/mcdc/object_/settings.py b/mcdc/object_/settings.py index 60a28d896..953c271b1 100644 --- a/mcdc/object_/settings.py +++ b/mcdc/object_/settings.py @@ -44,6 +44,7 @@ class Settings(ObjectSingleton): time_boundary: float = np.inf output_name: str = "output" use_progress_bar: bool = True + csda: bool = True # Time census N_census: int = 1 From 74dfef8e3b8dc72bdafb6639a47d2572977a3fce Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 4 Jun 2026 11:44:59 -0700 Subject: [PATCH 42/64] rebasing --- mcdc/constant.py | 2 +- mcdc/numba_types.py | 1 + mcdc/object_/settings.py | 1 + mcdc/transport/physics/interface.py | 5 ++--- mcdc/transport/physics/proton/native.py | 1 - mcdc/transport/simulation.py | 3 +++ 6 files changed, 8 insertions(+), 5 deletions(-) diff --git a/mcdc/constant.py b/mcdc/constant.py index 11f57a786..1c06ee903 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -183,7 +183,7 @@ PI_SQRT = math.sqrt(PI) PI_HALF = PI / 2.0 BANKMAX = 100 # Default maximum active bank -CSDA_MAX_FRACTIONAL_E_LOSS = 0.01 +# CSDA_MAX_FRACTIONAL_E_LOSS = 0.01 # Axes AXIS_X = 0 diff --git a/mcdc/numba_types.py b/mcdc/numba_types.py index 0080f82b8..22c643d76 100644 --- a/mcdc/numba_types.py +++ b/mcdc/numba_types.py @@ -596,6 +596,7 @@ ('output_name', 'U32'), ('use_progress_bar', bool), ('csda', bool), + ('csda_max_fractional_e_loss', float64), ('N_census', int64), ('census_time_offset', int64), ('census_time_length', int64), diff --git a/mcdc/object_/settings.py b/mcdc/object_/settings.py index 953c271b1..60e13a348 100644 --- a/mcdc/object_/settings.py +++ b/mcdc/object_/settings.py @@ -45,6 +45,7 @@ class Settings(ObjectSingleton): output_name: str = "output" use_progress_bar: bool = True csda: bool = True + csda_max_fractional_e_loss: float = 0.01 # Time census N_census: int = 1 diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index 9a8da821f..e71ac8cd6 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -79,9 +79,8 @@ def csda_distance(particle_container, simulation, data): density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) total_rho += density_gcm3 - # print(f'dedx at energy {E} is {total_dedx}, max energy deposited is {E * CSDA_MAX_FRACTIONAL_E_LOSS}') - - return CSDA_MAX_FRACTIONAL_E_LOSS * E / total_dedx / total_rho + max_fractional_e_loss = simulation["settings"]["csda_max_fractional_e_loss"] + return max_fractional_e_loss * E / total_dedx / total_rho # ====================================================================================== diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 85e84573e..59bbd70f1 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -30,7 +30,6 @@ PARTICLE_NEUTRON, PARTICLE_PROTON, PROTON_CUTOFF_ENERGY, - CSDA_MAX_FRACTIONAL_E_LOSS, ) from mcdc.transport.data import evaluate_data from mcdc.transport.distribution import ( diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index 2dc9dfd7a..467712a8f 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -350,6 +350,7 @@ def step_particle(particle_container, program, data): if particle["event"] & EVENT_CSDA_EDEP: pass +<<<<<<< HEAD # ================================================================================== # Apply techniques # ================================================================================== @@ -366,6 +367,8 @@ def step_particle(particle_container, program, data): if simulation["global_weight_roulette"]["active"]: technique.global_weight_roulette(particle_container, simulation) +======= +>>>>>>> 97028895 (added a setting to change csda max fractional energy loss in the input deck) # Weight roulette if particle["alive"]: technique.weight_roulette(particle_container, simulation) From 69ad406a44a1c5586a782bb02c87b825a8ee30e8 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 3 Jun 2026 13:21:34 -0700 Subject: [PATCH 43/64] reformatting with black --- mcdc/constant.py | 3 +- mcdc/object_/nuclide.py | 19 +++----- mcdc/object_/proton_reaction.py | 30 +++++++------ mcdc/transport/physics/interface.py | 8 ++-- mcdc/transport/physics/proton/interface.py | 6 ++- mcdc/transport/physics/proton/multigroup.py | 2 +- mcdc/transport/physics/proton/native.py | 49 ++++++++------------- mcdc/transport/simulation.py | 7 +-- 8 files changed, 58 insertions(+), 66 deletions(-) diff --git a/mcdc/constant.py b/mcdc/constant.py index 1c06ee903..b3bf20fdf 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -183,7 +183,6 @@ PI_SQRT = math.sqrt(PI) PI_HALF = PI / 2.0 BANKMAX = 100 # Default maximum active bank -# CSDA_MAX_FRACTIONAL_E_LOSS = 0.01 # Axes AXIS_X = 0 @@ -195,7 +194,7 @@ LIGHT_SPEED = 2.99792458e10 # cm/s NEUTRON_MASS = 939.565413e6 # eV/c^2 ELECTRON_MASS = 510.99895069e3 # eV/c^2 -PROTON_MASS = 938.27208943e6 # eV/c^2 +PROTON_MASS = 938.27208943e6 # eV/c^2 BOLTZMANN_K = 8.61733326e-5 # eV/K ELECTRON_CUTOFF_ENERGY = 100 # eV PROTON_CUTOFF_ENERGY = 1000 # eV - this is dictated by the TENDL data; minimum of 1000 eV on the energy grid diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index fcdb9c07f..efca72aec 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -18,7 +18,7 @@ NeutronReactionInelasticScattering, set_energy_distribution, ) -from mcdc.object_.proton_reaction import( +from mcdc.object_.proton_reaction import ( ProtonReactionElasticScattering, ProtonReactionNonelasticReaction, ProtonSecondaryChannel, @@ -308,11 +308,7 @@ def set_proton_data(self): xs = file[f"proton_reactions/{rx_name}/{MT}/xs"] xs_container[xs.attrs["offset"] :] += xs[()] - self.proton_total_xs = ( - self.proton_elastic_xs - + self.proton_nonelastic_xs - ) - + self.proton_total_xs = self.proton_elastic_xs + self.proton_nonelastic_xs # ========================================================================== # The reactions @@ -337,18 +333,16 @@ def set_proton_data(self): reaction = rx_class.from_h5_group(h5_group) rx_container.append(reaction) - # ========================================================================== # Stopping power for protons # ========================================================================== self.stopping_power = file["stopping_power"]["total_stopping_power"][()] self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] - # # ========================================================================== # # Secondary particles # # ========================================================================== - + # self.proton_secondary_channels = {} # if "secondary_particles" in file: # sec_group = file["secondary_particles"] @@ -357,24 +351,23 @@ def set_proton_data(self): # continue # zap = int(zap_name.split("_")[1]) # zap_group = sec_group[zap_name] - + # # Iterate over MT numbers for this secondary particle type # for mt_name in zap_group.keys(): # if not mt_name.startswith("MT-"): # continue # MT = int(mt_name.split("-")[1]) # mt_group = zap_group[mt_name] - + # # Load secondary channel # channel = ProtonSecondaryChannel.from_h5_group(mt_group, zap) - + # if MT not in self.proton_secondary_channels: # self.proton_secondary_channels[MT] = [] # self.proton_secondary_channels[MT].append(channel) file.close() - ## TODO: UPDATE this to handle protons as well as neutrons def __repr__(self): text = "\n" diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py index db05e9344..b1f132bd5 100644 --- a/mcdc/object_/proton_reaction.py +++ b/mcdc/object_/proton_reaction.py @@ -123,6 +123,7 @@ def __repr__(self): # Proton nonelastic reaction # ====================================================================================== + class ProtonReactionNonelasticReaction(ProtonReactionBase): # Annotations for Numba mode label: str = "proton_nonelastic_reaction" @@ -249,7 +250,7 @@ def set_angular_distribution(h5_group): mu = simulation.distributions[0] elif mu_type == "tabulated": angle_type = ANGLE_DISTRIBUTED - + # Check if data is in flattened format or subgroup format if "energy" in h5_group: # Flattened format @@ -260,12 +261,12 @@ def set_angular_distribution(h5_group): else: # Subgroup format: E_in_1, E_in_2, etc. incident_energies = h5_group["incident_energies"][()] * 1e6 # MeV to eV - + # Collect all cosines and pdfs into flattened arrays cosines_list = [] pdf_list = [] offset = np.zeros(len(incident_energies), dtype=np.int32) - + for i, energy in enumerate(incident_energies): subgroup_name = f"E_in_{i + 1}" if subgroup_name in h5_group: @@ -276,19 +277,19 @@ def set_angular_distribution(h5_group): else: # Isotropic - use dummy values cosines_list.extend([0.0]) # isotropic cosine - pdf_list.extend([1.0]) # uniform pdf + pdf_list.extend([1.0]) # uniform pdf else: # Missing subgroup - assume isotropic cosines_list.extend([0.0]) pdf_list.extend([1.0]) - + if i < len(incident_energies) - 1: offset[i + 1] = len(cosines_list) - + grid = incident_energies value = np.array(cosines_list) pdf = np.array(pdf_list) - + mu = DistributionMultiTable(grid, offset, value, pdf) return angle_type, mu @@ -384,12 +385,13 @@ class ProtonSecondaryChannel(ObjectPolymorphic): Data container for a proton secondary particle channel. Plain helper object. """ + particle_type: int MT: int - multiplicity: float64 # Multiplicity of particles produced per reaction + multiplicity: float64 # Multiplicity of particles produced per reaction production_xs: NDArray[float64] production_xs_offset_: int - reference_frame: int # COM or LAB + reference_frame: int # COM or LAB energy_spectrum: DistributionBase def __init__( @@ -422,7 +424,7 @@ def from_h5_group(cls, h5_group, zap): particle_type = ZAP_TO_PARTICLE.get(zap) MT = h5_group.attrs["MT"] multiplicity = h5_group.attrs["multiplicity"] - + reference_frame_str = h5_group.attrs["reference_frame"] if reference_frame_str == "LAB": reference_frame = REFERENCE_FRAME_LAB @@ -430,7 +432,7 @@ def from_h5_group(cls, h5_group, zap): reference_frame = REFERENCE_FRAME_COM else: reference_frame = REFERENCE_FRAME_COM # default - + # Production cross section (optional) if "production_xs" in h5_group: production_xs = h5_group["production_xs"][()] @@ -441,7 +443,7 @@ def from_h5_group(cls, h5_group, zap): # Energy spectrum (currently assume Kalbach-Mann) energy_spectrum = set_energy_distribution(h5_group["kalbach_mann"]) - + return cls( particle_type, MT, @@ -453,7 +455,9 @@ def from_h5_group(cls, h5_group, zap): ) def __repr__(self): - particle_name = "Neutron" if self.particle_type == PARTICLE_NEUTRON else "Proton" + particle_name = ( + "Neutron" if self.particle_type == PARTICLE_NEUTRON else "Proton" + ) text = "\n" text += f"Proton secondary channel ({particle_name})\n" text += f" - ID: {self.ID}\n" diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index e71ac8cd6..3a1abea20 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -71,8 +71,8 @@ def csda_distance(particle_container, simulation, data): nuclide = simulation["nuclides"][nuclide_ID] dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) - dedx = np.interp(E/1e6, dedx_energies, dedx_values) - total_dedx += dedx*1e6 + dedx = np.interp(E / 1e6, dedx_energies, dedx_values) + total_dedx += dedx * 1e6 atomic_mass = nuclide["atomic_weight_ratio"] nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) @@ -131,4 +131,6 @@ def csda_edep(particle_container, collision_data_container, distance, simulation if particle["particle_type"] == PARTICLE_ELECTRON: raise ValueError("CSDA not supported for electrons") if particle["particle_type"] == PARTICLE_PROTON: - proton.csda_edep(particle_container, collision_data_container, distance, simulation, data) \ No newline at end of file + proton.csda_edep( + particle_container, collision_data_container, distance, simulation, data + ) diff --git a/mcdc/transport/physics/proton/interface.py b/mcdc/transport/physics/proton/interface.py index f98da1913..9119d4650 100644 --- a/mcdc/transport/physics/proton/interface.py +++ b/mcdc/transport/physics/proton/interface.py @@ -15,6 +15,7 @@ def particle_speed(particle_container, simulation, data): return native.particle_speed(particle_container) + # ====================================================================================== # Material properties # ====================================================================================== @@ -24,6 +25,7 @@ def particle_speed(particle_container, simulation, data): def macro_xs(reaction_type, particle_container, simulation, data): return native.macro_xs(reaction_type, particle_container, simulation, data) + # ====================================================================================== # Collision # ====================================================================================== @@ -37,4 +39,6 @@ def collision(particle_container, collision_data_container, program, data): @njit def csda_edep(particle_container, collision_data_container, distance, simulation, data): - native.csda_edep(particle_container, collision_data_container, distance, simulation, data) \ No newline at end of file + native.csda_edep( + particle_container, collision_data_container, distance, simulation, data + ) diff --git a/mcdc/transport/physics/proton/multigroup.py b/mcdc/transport/physics/proton/multigroup.py index 9b94c5d4d..7c4a4612e 100644 --- a/mcdc/transport/physics/proton/multigroup.py +++ b/mcdc/transport/physics/proton/multigroup.py @@ -17,7 +17,7 @@ PROTON_REACTION_TOTAL, PROTON_REACTION_ELASTIC_SCATTERING, PROTON_REACTION_NONELASTIC, - ) +) from mcdc.transport.physics.util import scatter_direction from mcdc.transport.distribution import sample_isotropic_direction diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 59bbd70f1..66b61b202 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -126,6 +126,7 @@ def reaction_micro_xs(E, reaction_base, nuclide, data): xs1 = mcdc_get.proton_reaction.xs(idx + 1, reaction_base, data) return linear_interpolation(E, E0, E1, xs0, xs1) + # ====================================================================================== # Collision # ====================================================================================== @@ -147,7 +148,7 @@ def collision(particle_container, collision_data_container, program, data): particle["alive"] = False particle["E"] = 0.0 return - + # ================================================================================== # Sample colliding nuclide # ================================================================================== @@ -175,12 +176,8 @@ def collision(particle_container, collision_data_container, program, data): # Sample and perform reaction # ================================================================================== - sigma_elastic = total_micro_xs( - PROTON_REACTION_ELASTIC_SCATTERING, E, nuclide, data - ) - sigma_nonelastic = total_micro_xs( - PROTON_REACTION_NONELASTIC, E, nuclide, data - ) + sigma_elastic = total_micro_xs(PROTON_REACTION_ELASTIC_SCATTERING, E, nuclide, data) + sigma_nonelastic = total_micro_xs(PROTON_REACTION_NONELASTIC, E, nuclide, data) xi = rng.lcg(particle_container) * sigmaT # Elastic scattering @@ -218,9 +215,7 @@ def collision(particle_container, collision_data_container, program, data): total -= sigma_nonelastic for i in range(nuclide["N_proton_nonelastic_reaction"]): reaction_ID = int( - mcdc_get.nuclide.proton_nonelastic_reaction_IDs( - i, nuclide, data - ) + mcdc_get.nuclide.proton_nonelastic_reaction_IDs(i, nuclide, data) ) reaction = simulation["proton_nonelastic_reactions"][reaction_ID] reaction_base_ID = reaction["parent_ID"] @@ -254,7 +249,7 @@ def csda_edep(particle_container, collision_data_container, distance, simulation particle["alive"] = False particle["E"] = 0.0 return - + total_stopping_power = 0.0 total_rho_gcm3 = 0.0 # Find the total stopping power by summing over every nuclide in the material @@ -263,15 +258,15 @@ def csda_edep(particle_container, collision_data_container, distance, simulation nuclide = simulation["nuclides"][nuclide_ID] dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) - + # TODO: replace np.interp with a non-numpy function?? - dedx = np.interp(E/1e6, dedx_energies, dedx_values) + dedx = np.interp(E / 1e6, dedx_energies, dedx_values) total_stopping_power += dedx # Convert atoms/barn-cm to g/cm³: - atomic_mass = nuclide["atomic_weight_ratio"] # mass in amu + atomic_mass = nuclide["atomic_weight_ratio"] # mass in amu nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) - density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) + density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) total_rho_gcm3 += density_gcm3 energy_loss = total_stopping_power * distance * total_rho_gcm3 * 1e6 @@ -279,7 +274,6 @@ def csda_edep(particle_container, collision_data_container, distance, simulation collision_data["energy_deposition"] += energy_loss * particle["w"] return - # ====================================================================================== # Elastic scattering @@ -301,7 +295,7 @@ def elastic_scattering( # Energy deposition collision_data["energy_deposition"] += E * particle["w"] - #print(f'\ndeposited {E * particle["w"]} eV at x={particle["x"]} from elastic scattering') + # print(f'\ndeposited {E * particle["w"]} eV at x={particle["x"]} from elastic scattering') # Note: Q-value is zero in elastic scattering @@ -385,7 +379,6 @@ def elastic_scattering( collision_data["energy_deposition"] -= particle["E"] * particle["w"] - @njit def sample_nucleus_velocity(A, particle_container): particle = particle_container[0] @@ -443,10 +436,9 @@ def sample_nucleus_velocity(A, particle_container): def nonelastic_reaction( reaction, particle_container, collision_data_container, nuclide, program, data ): - """ Proton nonelastic scattering with secondary particle production. - + Samples: 1. Outgoing proton from proton_reactions/inelastic/MT-005 2. Secondary particles from secondary_particles/ZAP_x/MT-005 @@ -475,7 +467,7 @@ def nonelastic_reaction( # =========================================================================== # 1. Sample outgoing PROTON # =========================================================================== - + # Number of outgoing protons and spectra N_proton = reaction["multiplicity"] N_spectrum = reaction["N_spectrum"] @@ -487,8 +479,7 @@ def nonelastic_reaction( # Energy deposition (will be adjusted as we create secondaries) collision_data["energy_deposition"] += total_energy * w - #print(f'\ndeposited {total_energy * particle["w"]} eV at x={particle["x"]} from nonelastic_rxn') - + # print(f'\ndeposited {total_energy * particle["w"]} eV at x={particle["x"]} from nonelastic_rxn') # Create outgoing protons for n in range(N_proton): @@ -531,10 +522,8 @@ def nonelastic_reaction( xi = rng.lcg(particle_container_new) total = 0.0 for j in range(N_spectrum): - probability = ( - mcdc_get.proton_nonelastic_reaction.spectrum_probability( - probability_idx, j, reaction, data - ) + probability = mcdc_get.proton_nonelastic_reaction.spectrum_probability( + probability_idx, j, reaction, data ) total += probability if xi < total: @@ -602,15 +591,15 @@ def nonelastic_reaction( # =========================================================================== # 2. Sample SECONDARY PARTICLES from secondary_particles groups # =========================================================================== - + # Get secondary channels for this MT (if any) # MT = int(reaction_base["MT"]) # nuclide_ID = particle["nuclide_ID"] - + # Check if nuclide has secondary particle data # (This requires access to nuclide secondary_channels dict, which needs to be added) # For now, we'll skip this part and it can be added when the data structure supports it # TODO: Add secondary particle sampling when nuclide.proton_secondary_channels is accessible -# No fission for protons \ No newline at end of file +# No fission for protons diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index 467712a8f..e4844d5f7 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -466,7 +466,6 @@ def move_to_event(particle_container, simulation, data): elif geometry.check_coincidence(d_csda, distance): particle["event"] += EVENT_CSDA_EDEP - # ================================================================================== # Move particle # ================================================================================== @@ -511,7 +510,9 @@ def move_to_event(particle_container, simulation, data): # CSDA calculates energy loss after particle has moved if settings["csda"]: collision_data_container = np.zeros(1, type_.collision_data) - physics.csda_edep(particle_container, collision_data_container, distance, simulation, data) + physics.csda_edep( + particle_container, collision_data_container, distance, simulation, data + ) # Score collision tallies (edep is a collision tally) # TODO: maybe make edep a potential tracklength tally for CSDA? @@ -549,4 +550,4 @@ def move_to_event(particle_container, simulation, data): tally, simulation, data, - ) \ No newline at end of file + ) From 521b71d4d90ef6da2431064a670d816bc2708354 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 3 Jun 2026 13:39:15 -0700 Subject: [PATCH 44/64] file to generate h5 files from ACE & PSTAR; also, proton beam example --- examples/proton_beam/input_1.py | 96 ++ examples/proton_beam/input_10.py | 97 ++ examples/proton_beam/input_10MeV.py | 97 ++ examples/proton_beam/input_1MeV.py | 96 ++ examples/proton_beam/process.py | 47 + examples/proton_beam/test.py | 23 + .../proton_ace_to_hdf5.py | 892 ++++++++++++++++++ tools/data_library_generator/util.py | 4 + 8 files changed, 1352 insertions(+) create mode 100644 examples/proton_beam/input_1.py create mode 100644 examples/proton_beam/input_10.py create mode 100644 examples/proton_beam/input_10MeV.py create mode 100644 examples/proton_beam/input_1MeV.py create mode 100644 examples/proton_beam/process.py create mode 100644 examples/proton_beam/test.py create mode 100644 tools/data_library_generator/proton_ace_to_hdf5.py diff --git a/examples/proton_beam/input_1.py b/examples/proton_beam/input_1.py new file mode 100644 index 000000000..fc30e633f --- /dev/null +++ b/examples/proton_beam/input_1.py @@ -0,0 +1,96 @@ +import numpy as np +import mcdc + +# ====================================================================================== +# Set model +# ====================================================================================== +# Proton beam, incident on a slab + +# Set materials (atom density in units of atoms/barn-cm) +silicon = mcdc.Material("silicon", {"Si28": 0.05}) + +# Set surfaces +sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") +sx2 = mcdc.Surface.PlaneX(x=0.002, boundary_condition="vacuum") +sy1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") +sy2 = mcdc.Surface.PlaneY(y=1.0, boundary_condition="vacuum") +sz1 = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") +sz2 = mcdc.Surface.PlaneZ(z=1.0, boundary_condition="vacuum") + +slab = +sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2 + +# Set cells +slab_cell = mcdc.Cell(name="silicon", region=slab, fill=silicon) + +# ====================================================================================== +# Set source +# ====================================================================================== + +mcdc.Source( + x=[0.0, 0.0], + y=[0.0, 1.0], + z=[0.0, 1.0], + direction=[1.0, 0.0, 0.0], + energy=1e6, + # energy_group=0, + particle_type="proton", + # time=[0.0, 0.0], +) + +# ====================================================================================== +# Set tallies, settings, techniques, and run MC/DC +# ====================================================================================== + +# Tallies +percent_of_range = np.array( + [ + 0.0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 85, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 107.5, + 110, + 115, + ] +) +range = 16.45 * 1e-4 # cm + +bin_edges = range * percent_of_range * 1e-2 + +mesh = mcdc.MeshStructured(x=(bin_edges)) +mcdc.Tally(name=f"edep", mesh=mesh, scores=["energy_deposition"]) + +# Settings +mcdc.settings.set_transported_particles(["proton"]) +mcdc.settings.N_particle = 1_000 +mcdc.settings.N_batch = 1 +mcdc.settings.csda = True +mcdc.settings.csda_max_fractional_e_loss = 0.001 + +# Techniques +mcdc.simulation.implicit_capture() + +# Run +mcdc.run() diff --git a/examples/proton_beam/input_10.py b/examples/proton_beam/input_10.py new file mode 100644 index 000000000..b63de3b84 --- /dev/null +++ b/examples/proton_beam/input_10.py @@ -0,0 +1,97 @@ +import numpy as np +import mcdc + +# ====================================================================================== +# Set model +# ====================================================================================== +# Proton beam, incident on a slab + +# Set materials (atom density in units of atoms/barn-cm) +silicon = mcdc.Material("silicon", {"Si28": 0.05}) + +# Set surfaces +sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") +sx2 = mcdc.Surface.PlaneX(x=0.1, boundary_condition="vacuum") +sy1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") +sy2 = mcdc.Surface.PlaneY(y=0.1, boundary_condition="vacuum") +sz1 = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") +sz2 = mcdc.Surface.PlaneZ(z=0.1, boundary_condition="vacuum") + +slab = +sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2 + +# Set cells +slab_cell = mcdc.Cell(name="silicon", region=slab, fill=silicon) + +# ====================================================================================== +# Set source +# ====================================================================================== + +mcdc.Source( + x=[0.0, 0.0], + y=[0.0, 0.0002], + z=[0.0, 0.0002], + direction=[1.0, 0.0, 0.0], + energy=1e7, + # energy_group=0, + particle_type="proton", + # time=[0.0, 0.0], +) + +# ====================================================================================== +# Set tallies, settings, techniques, and run MC/DC +# ====================================================================================== + +# Tallies +percent_of_range = np.array( + [ + 0.0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 85, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 107.5, + 110, + 115, + ] +) +range = 714.59 * 1e-4 # cm + +bin_edges = range * percent_of_range * 1e-2 + +# mesh = mcdc.MeshUniform(x=(0.0, 0.002, 50)) +mesh = mcdc.MeshStructured(x=(bin_edges)) +mcdc.Tally(name=f"edep", mesh=mesh, scores=["energy_deposition"]) + +# Settings +mcdc.settings.set_transported_particles(["proton"]) +mcdc.settings.N_particle = 10_000 +mcdc.settings.N_batch = 1 +mcdc.settings.csda = True +mcdc.settings.csda_max_fractional_e_loss = 0.001 + +# Techniques +mcdc.simulation.implicit_capture() + +# Run +mcdc.run() diff --git a/examples/proton_beam/input_10MeV.py b/examples/proton_beam/input_10MeV.py new file mode 100644 index 000000000..b63de3b84 --- /dev/null +++ b/examples/proton_beam/input_10MeV.py @@ -0,0 +1,97 @@ +import numpy as np +import mcdc + +# ====================================================================================== +# Set model +# ====================================================================================== +# Proton beam, incident on a slab + +# Set materials (atom density in units of atoms/barn-cm) +silicon = mcdc.Material("silicon", {"Si28": 0.05}) + +# Set surfaces +sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") +sx2 = mcdc.Surface.PlaneX(x=0.1, boundary_condition="vacuum") +sy1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") +sy2 = mcdc.Surface.PlaneY(y=0.1, boundary_condition="vacuum") +sz1 = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") +sz2 = mcdc.Surface.PlaneZ(z=0.1, boundary_condition="vacuum") + +slab = +sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2 + +# Set cells +slab_cell = mcdc.Cell(name="silicon", region=slab, fill=silicon) + +# ====================================================================================== +# Set source +# ====================================================================================== + +mcdc.Source( + x=[0.0, 0.0], + y=[0.0, 0.0002], + z=[0.0, 0.0002], + direction=[1.0, 0.0, 0.0], + energy=1e7, + # energy_group=0, + particle_type="proton", + # time=[0.0, 0.0], +) + +# ====================================================================================== +# Set tallies, settings, techniques, and run MC/DC +# ====================================================================================== + +# Tallies +percent_of_range = np.array( + [ + 0.0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 85, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 107.5, + 110, + 115, + ] +) +range = 714.59 * 1e-4 # cm + +bin_edges = range * percent_of_range * 1e-2 + +# mesh = mcdc.MeshUniform(x=(0.0, 0.002, 50)) +mesh = mcdc.MeshStructured(x=(bin_edges)) +mcdc.Tally(name=f"edep", mesh=mesh, scores=["energy_deposition"]) + +# Settings +mcdc.settings.set_transported_particles(["proton"]) +mcdc.settings.N_particle = 10_000 +mcdc.settings.N_batch = 1 +mcdc.settings.csda = True +mcdc.settings.csda_max_fractional_e_loss = 0.001 + +# Techniques +mcdc.simulation.implicit_capture() + +# Run +mcdc.run() diff --git a/examples/proton_beam/input_1MeV.py b/examples/proton_beam/input_1MeV.py new file mode 100644 index 000000000..fc30e633f --- /dev/null +++ b/examples/proton_beam/input_1MeV.py @@ -0,0 +1,96 @@ +import numpy as np +import mcdc + +# ====================================================================================== +# Set model +# ====================================================================================== +# Proton beam, incident on a slab + +# Set materials (atom density in units of atoms/barn-cm) +silicon = mcdc.Material("silicon", {"Si28": 0.05}) + +# Set surfaces +sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") +sx2 = mcdc.Surface.PlaneX(x=0.002, boundary_condition="vacuum") +sy1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") +sy2 = mcdc.Surface.PlaneY(y=1.0, boundary_condition="vacuum") +sz1 = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") +sz2 = mcdc.Surface.PlaneZ(z=1.0, boundary_condition="vacuum") + +slab = +sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2 + +# Set cells +slab_cell = mcdc.Cell(name="silicon", region=slab, fill=silicon) + +# ====================================================================================== +# Set source +# ====================================================================================== + +mcdc.Source( + x=[0.0, 0.0], + y=[0.0, 1.0], + z=[0.0, 1.0], + direction=[1.0, 0.0, 0.0], + energy=1e6, + # energy_group=0, + particle_type="proton", + # time=[0.0, 0.0], +) + +# ====================================================================================== +# Set tallies, settings, techniques, and run MC/DC +# ====================================================================================== + +# Tallies +percent_of_range = np.array( + [ + 0.0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 85, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 107.5, + 110, + 115, + ] +) +range = 16.45 * 1e-4 # cm + +bin_edges = range * percent_of_range * 1e-2 + +mesh = mcdc.MeshStructured(x=(bin_edges)) +mcdc.Tally(name=f"edep", mesh=mesh, scores=["energy_deposition"]) + +# Settings +mcdc.settings.set_transported_particles(["proton"]) +mcdc.settings.N_particle = 1_000 +mcdc.settings.N_batch = 1 +mcdc.settings.csda = True +mcdc.settings.csda_max_fractional_e_loss = 0.001 + +# Techniques +mcdc.simulation.implicit_capture() + +# Run +mcdc.run() diff --git a/examples/proton_beam/process.py b/examples/proton_beam/process.py new file mode 100644 index 000000000..18150c794 --- /dev/null +++ b/examples/proton_beam/process.py @@ -0,0 +1,47 @@ +import h5py +import numpy as np +import matplotlib.pyplot as plt + +energy = 1 # MeV + +# with h5py.File(f"output_{energy}mev.h5") as f: +with h5py.File(f"output.h5") as f: + isotope_edep = list(f["tallies"].keys())[0] + + edep = f["tallies"][f"{isotope_edep}"]["energy_deposition"]["mean"][()] + xgrid = f["tallies"][f"{isotope_edep}"]["grid"]["x"][()] + # print(f'xgrid = {xgrid}') + + normalized_edep = np.zeros_like(edep) + centers = np.zeros_like(edep) + for i in range(len(xgrid) - 1): + width = xgrid[i + 1] - xgrid[i] + centers[i] = xgrid[i] + width / 2 + normalized_edep[i] = edep[i] / (energy * 1e6) / width + + normalized_edep = np.array(normalized_edep) + index_of_depth_at_max = np.argmax(normalized_edep) + + print(rf"peak location: {xgrid[index_of_depth_at_max]} um") + print(f"peak magnitude = {np.max(normalized_edep)}") + + +# TODO: add automatic range calculations based on PSTAR data +range = 0.001645 + +plt.plot(centers * 1e4, normalized_edep, label="edep tally") +plt.vlines( + range * 1e4, + 0, + np.max(normalized_edep), + linestyle="--", + label="theoretical Bragg peak for 1 MeV protons", + color="red", +) +plt.title(f"Energy Deposition of {energy} MeV Protons in a Slab of Si-28") +plt.xlabel(r"x [$\mu$m]") +plt.ylabel("MeV/cm") +plt.ylim(0, 1300) +plt.legend() +plt.savefig(f"Si-28_edep_{energy}MeV.png") +# plt.show() diff --git a/examples/proton_beam/test.py b/examples/proton_beam/test.py new file mode 100644 index 000000000..ce1f8f2fa --- /dev/null +++ b/examples/proton_beam/test.py @@ -0,0 +1,23 @@ +import h5py +import matplotlib.pyplot as plt +import sys + +isotope = sys.argv[1] + +with h5py.File(f"../../proton_generated_lib/{isotope}-293.6K.h5") as f: + print(f'atomic number = {f["atomic_number"][()]}') + print(f'atomic weight ratio = {f["atomic_weight_ratio"][()]}') + print(f'fissionable = {f["fissionable"][()]}') + print(f'nuclide name = {f["nuclide_name"][()]}') + + elastic_xs = f["proton_reactions"]["elastic_scattering"]["MT-002"]["xs"][()] + inelastic_xs = f["proton_reactions"]["inelastic_scattering"]["MT-005"]["xs"][()] + + plt.plot(elastic_xs, label="elastic") + plt.plot(inelastic_xs, label="inelastic") + plt.legend() + plt.yscale("log") + plt.xscale("log") + plt.xlabel("Incident Energy (MeV)") + plt.ylabel("Cross Section") + plt.savefig(f"{isotope}_xs.png") diff --git a/tools/data_library_generator/proton_ace_to_hdf5.py b/tools/data_library_generator/proton_ace_to_hdf5.py new file mode 100644 index 000000000..549192316 --- /dev/null +++ b/tools/data_library_generator/proton_ace_to_hdf5.py @@ -0,0 +1,892 @@ +# The majority of this script was written by Anthropic's Claude + +""" +proton_ace_to_hdf5.py — Convert proton ACE files (TENDL etc.) to HDF5 for MC/DC + +Usage +----- + python proton_ace_to_hdf5.py + python proton_ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 [--pstar_dir /path/to/pstar] + python proton_ace_to_hdf5.py ... --rewrite # overwrite existing files + python proton_ace_to_hdf5.py ... --verbose # per-reaction detail + + +Environment variable fallbacks: $MCDC_ACELIB, $MCDC_LIB, $PSTAR_LIB + +HDF5 layout +----------- +-K.h5 + attrs: source_title, source_version, source_date + nuclide_name, excitation_level, temperature (K), + atomic_number, atomic_weight_ratio, fissionable + + stopping_power/ (if PSTAR data available) + energy (MeV), total_stopping_power (MeV cm2/g) + + proton_reactions/ + xs_energy_grid (MeV) + elastic_scattering/MT-002/ + xs (barns, offset=0), Q-value (MeV), reference_frame, + angular_cosine_distribution/ + capture/MT-{NNN}/ + xs (barns), Q-value (MeV), reference_frame + nonelastic_reaction/MT-{NNN}/ + xs (barns), Q-value (MeV), reference_frame, multiplicity + angular_cosine_distribution/ + energy_spectrum-{k}/ (law attr; kalbach-mann: energy, offset, + energy_out, pdf, cdf, precompound_factor, angular_slope) + fission/ (only if fissionable) + + secondary_particles/ZAP_{zap}/MT-{NNN}/ + attrs: ZAP, particle_name, MT, multiplicity, reference_frame + production_xs (barns, offset) + kalbach_mann/ (energy, offset, energy_out, pdf, cdf, + precompound_factor, angular_slope) + +ZAP identity: 1=n, 1001=p, 1002=d, 1003=t, 2003=He3, 2004=alpha, 0=gamma + +TabulatedKalbachMannDistribution properties used (from ACEtk): + outgoing_energies, pdf, cdf, + precompound_fraction_values, angular_distribution_slope_values +""" + +import argparse +import os +import sys + +import h5py +import numpy as np +from tqdm import tqdm +import ACEtk + +# -- Constants ----------------------------------------------------------------- + +ZAP_NAMES = { + 0: "photon", + 1: "neutron", + 1001: "proton", + 1002: "deuteron", + 1003: "triton", + 2003: "He3", + 2004: "alpha", +} + +Z_TO_SYMBOL = { + 1: "H", + 2: "He", + 3: "Li", + 4: "Be", + 5: "B", + 6: "C", + 7: "N", + 8: "O", + 9: "F", + 10: "Ne", + 11: "Na", + 12: "Mg", + 13: "Al", + 14: "Si", + 15: "P", + 16: "S", + 17: "Cl", + 18: "Ar", + 19: "K", + 20: "Ca", + 21: "Sc", + 22: "Ti", + 23: "V", + 24: "Cr", + 25: "Mn", + 26: "Fe", + 27: "Co", + 28: "Ni", + 29: "Cu", + 30: "Zn", + 31: "Ga", + 32: "Ge", + 33: "As", + 34: "Se", + 35: "Br", + 36: "Kr", + 37: "Rb", + 38: "Sr", + 39: "Y", + 40: "Zr", + 41: "Nb", + 42: "Mo", + 43: "Tc", + 44: "Ru", + 45: "Rh", + 46: "Pd", + 47: "Ag", + 48: "Cd", + 49: "In", + 50: "Sn", + 51: "Sb", + 52: "Te", + 53: "I", + 54: "Xe", + 55: "Cs", + 56: "Ba", + 57: "La", + 58: "Ce", + 59: "Pr", + 60: "Nd", + 61: "Pm", + 62: "Sm", + 63: "Eu", + 64: "Gd", + 65: "Tb", + 66: "Dy", + 67: "Ho", + 68: "Er", + 69: "Tm", + 70: "Yb", + 71: "Lu", + 72: "Hf", + 73: "Ta", + 74: "W", + 75: "Re", + 76: "Os", + 77: "Ir", + 78: "Pt", + 79: "Au", + 80: "Hg", + 81: "Tl", + 82: "Pb", + 83: "Bi", + 84: "Po", + 85: "At", + 86: "Rn", + 87: "Fr", + 88: "Ra", + 89: "Ac", + 90: "Th", + 91: "Pa", + 92: "U", + 93: "Np", + 94: "Pu", + 95: "Am", + 96: "Cm", + 97: "Bk", + 98: "Cf", + 99: "Es", + 100: "Fm", + 101: "Md", + 102: "No", + 103: "Lr", +} + +# Redundant sum MTs that must not be double-counted +REDUNDANT_MTS = [1, 3, 4, 10, 101, 103, 104, 105, 106, 107] +FISSION_CHANCE_MTS = [19, 20, 21, 38] + + +# -- Utility ------------------------------------------------------------------- + + +def print_error(msg): + print(f"\n[ERROR] {msg}", file=sys.stderr) + sys.exit(1) + + +def print_note(msg): + print(f" [note] {msg}") + + +def decode_ace_zaid(zaid): + """Return (Z, A, S, T=0) from an ACE ZAID string.""" + za = int(zaid.strip().split(".")[0]) + S = 0 + if za >= 600000: + S = (za % 1000) // 400 + za = za - S * 400 + return za // 1000, za % 1000, S, 0 + + +def load_pstar_file(filepath): + """ + Load a two-column PSTAR file (Energy MeV, Stopping power MeV cm2/g). + Returns (energies, stopping_powers) as float64 arrays. + """ + energies, sps = [], [] + with open(filepath) as f: + for line in f: + parts = line.strip().split() + if len(parts) != 2: + continue + try: + energies.append(float(parts[0])) + sps.append(float(parts[1])) + except ValueError: + continue + return np.array(energies), np.array(sps) + + +# -- Distribution writers ------------------------------------------------------ + + +def load_cosine_distribution(data, h5_group): + """ + Write a tabulated angular distribution into h5_group. + Returns False if the distribution is embedded in a Kalbach-Mann block + (DistributionGivenElsewhere), True otherwise. + """ + if isinstance(data, ACEtk.continuous.DistributionGivenElsewhere): + h5_group.attrs["type"] = "given_in_energy_distribution" + return False + + h5_group.attrs["type"] = "tabulated" + h5_group.attrs["unit"] = "MeV" + h5_group.create_dataset("incident_energies", data=np.array(data.incident_energies)) + + for i, subdist in enumerate(data.distributions): + eg = h5_group.create_group(f"E_in_{i + 1}") + if isinstance(subdist, ACEtk.continuous.TabulatedAngularDistribution): + eg.attrs["type"] = "tabulated" + eg.create_dataset("cosines", data=np.array(subdist.cosines)) + eg.create_dataset("pdf", data=np.array(subdist.pdf)) + eg.create_dataset("cdf", data=np.array(subdist.cdf)) + else: + eg.attrs["type"] = "isotropic" + + return True + + +def _write_kalbach_mann(km_data, h5_group): + """ + Write a KalbachMannDistributionData into h5_group as flat arrays. + offset[i] gives the starting index in the flat arrays for incident energy i. + """ + h5_group.attrs["type"] = "kalbach-mann" + + NE = km_data.number_incident_energies + h5_group.create_dataset("energy", data=np.array(km_data.incident_energies)).attrs[ + "unit" + ] = "MeV" + + offset, energy_out, pdf, cdf, r_vals, a_vals = [], [], [], [], [], [] + for i in range(1, NE + 1): + dist = km_data.distribution(i) + offset.append(len(energy_out)) + energy_out.extend(dist.outgoing_energies) + pdf.extend(dist.pdf) + cdf.extend(dist.cdf) + r_vals.extend(dist.precompound_fraction_values) + a_vals.extend(dist.angular_distribution_slope_values) + + h5_group.create_dataset("offset", data=np.array(offset, dtype=np.int32)) + h5_group.create_dataset("energy_out", data=np.array(energy_out)).attrs["unit"] = ( + "MeV" + ) + h5_group.create_dataset("pdf", data=np.array(pdf)) + h5_group.create_dataset("cdf", data=np.array(cdf)) + h5_group.create_dataset("precompound_factor", data=np.array(r_vals)) + h5_group.create_dataset("angular_slope", data=np.array(a_vals)) + + +def load_energy_distribution(data, h5_group): + """Write a primary-particle outgoing energy distribution into h5_group.""" + if isinstance(data, ACEtk.continuous.KalbachMannDistributionData): + h5_group.attrs["law"] = 44 + _write_kalbach_mann(data, h5_group) + + elif isinstance(data, ACEtk.continuous.OutgoingEnergyDistributionData): + h5_group.attrs["law"] = 4 + h5_group.create_dataset( + "incident_energies", data=np.array(data.incident_energies) + ) + for k, dist in enumerate(data.distributions): + eg = h5_group.create_group(f"E_in_{k + 1}") + eg.create_dataset( + "outgoing_energies", data=np.array(dist.outgoing_energies) + ) + eg.create_dataset("pdf", data=np.array(dist.pdf)) + eg.create_dataset("cdf", data=np.array(dist.cdf)) + + elif isinstance(data, ACEtk.continuous.LevelScatteringData): + h5_group.attrs["law"] = 3 + h5_group.create_dataset("C1", data=data.C1) + h5_group.create_dataset("C2", data=data.C2) + + elif isinstance(data, ACEtk.continuous.EquiprobableOutgoingEnergyBins): + h5_group.attrs["law"] = 1 + h5_group.create_dataset( + "incident_energies", data=np.array(data.incident_energies) + ) + for k, dist in enumerate(data.distributions): + h5_group.create_group(f"E_in_{k + 1}").create_dataset( + "energies", data=np.array(dist.energies) + ) + + else: + h5_group.attrs["law"] = -1 + h5_group.attrs["type_name"] = type(data).__name__ + try: + h5_group.create_dataset("xss_array", data=np.array(data.xss_array)) + except Exception: + pass + + +def load_fission_multiplicity(data, h5_group): + if isinstance(data, ACEtk.continuous.TabulatedFissionMultiplicity): + h5_group.attrs["type"] = "tabulated" + h5_group.create_dataset("energies", data=np.array(data.energies)) + h5_group.create_dataset("multiplicities", data=np.array(data.multiplicities)) + elif isinstance(data, ACEtk.continuous.PolynomialFissionMultiplicity): + h5_group.attrs["type"] = "polynomial" + h5_group.create_dataset("coefficients", data=np.array(data.coefficients)) + else: + h5_group.attrs["type"] = "unknown" + h5_group.attrs["type_name"] = type(data).__name__ + + +# -- Secondary particles ------------------------------------------------------- + + +def load_secondary_particles(ace_table, file, verbose=False): + n_types = ace_table.number_secondary_particle_types + if n_types == 0: + return + + type_block = ace_table.secondary_particle_type_block + info_block = ace_table.secondary_particle_information_block + rx_block = ace_table.secondary_particle_reaction_number_block + tyr_block = ace_table.secondary_particle_frame_and_multiplicity_block + xs_block = ace_table.secondary_particle_production_cross_section_block + edy_block = ace_table.secondary_particle_energy_distribution_block + + has_ang = False + try: + ang_block = ace_table.secondary_particle_angular_distribution_block + has_ang = True + except Exception: + pass + + sec_group = file.create_group("secondary_particles") + + pi_method = next( + ( + c + for c in ["particle_identifier", "ZAP", "type", "particle_type"] + if hasattr(type_block, c) + ), + None, + ) + if pi_method is None: + raise AttributeError( + f"Cannot find particle identifier on {type(type_block).__name__}. " + f"Available: {[x for x in dir(type_block) if not x.startswith('_')]}" + ) + + for i in range(1, n_types + 1): + zap = getattr(type_block, pi_method)(i) + name = ZAP_NAMES.get(zap, f"ZAP_{zap}") + n_rx = int(info_block.number_reactions[i - 1]) + + if verbose: + print(f" Secondary type {i}: ZAP={zap} ({name}), {n_rx} reactions") + + zap_group = sec_group.create_group(f"ZAP_{zap}") + zap_group.attrs["ZAP"] = zap + zap_group.attrs["particle_name"] = name + + rx_i = rx_block(i) + tyr_i = tyr_block(i) + xs_i = xs_block(i) + edy_i = edy_block(i) + ang_i = ang_block(i) if has_ang else None + + xs_method = next( + (c for c in ["cross_sections", "cross_section", "xs"] if hasattr(xs_i, c)), + None, + ) + off_method = next( + ( + c + for c in ["energy_index", "offset", "locator", "index"] + if hasattr(xs_i, c) + ), + None, + ) + edy_method = next( + ( + c + for c in [ + "energy_distribution_data", + "distribution_data", + "distribution", + ] + if hasattr(edy_i, c) + ), + None, + ) + + for j in range(1, n_rx + 1): + MT = rx_i.MT(j) + nu_raw = tyr_i.multiplicity(j) + nu = nu_raw - 100 if nu_raw >= 100 else nu_raw + rf_raw = tyr_i.reference_frame(j) + rf = ( + "LAB" + if rf_raw == ACEtk.ReferenceFrame.Laboratory + else ( + "COM" + if rf_raw == ACEtk.ReferenceFrame.CentreOfMass + else str(rf_raw) + ) + ) + + mt = zap_group.create_group(f"MT-{MT:03}") + mt.attrs["MT"] = MT + mt.attrs["multiplicity"] = nu + mt.attrs["reference_frame"] = rf + + if verbose: + print(f" MT={MT:03} nu_raw={nu_raw} nu={nu} frame={rf}") + + # Production cross section + empty_xs = np.zeros(0, dtype=float) + if xs_method and off_method: + try: + ds = mt.create_dataset( + "production_xs", data=np.array(getattr(xs_i, xs_method)(j)) + ) + ds.attrs["offset"] = int(getattr(xs_i, off_method)(j)) - 1 + ds.attrs["unit"] = "barns" + except Exception as exc: + ds = mt.create_dataset("production_xs", data=empty_xs) + ds.attrs["offset"] = 0 + ds.attrs["unit"] = "barns" + if verbose: + print(f" [warn] production xs: {exc}") + else: + ds = mt.create_dataset("production_xs", data=empty_xs) + ds.attrs["offset"] = 0 + ds.attrs["unit"] = "barns" + if verbose: + print( + f" [warn] xs methods not found: " + f"{[x for x in dir(xs_i) if not x.startswith('_')]}" + ) + + # Kalbach-Mann energy-angle distribution + if edy_method: + try: + _write_kalbach_mann( + getattr(edy_i, edy_method)(j), mt.create_group("kalbach_mann") + ) + except Exception as exc: + if verbose: + print(f" [warn] energy dist: {exc}") + elif verbose: + print( + f" [warn] edy method not found: " + f"{[x for x in dir(edy_i) if not x.startswith('_')]}" + ) + + if ang_i is not None: + try: + load_cosine_distribution( + ang_i.angular_distribution_data(j), + mt.create_group("angular_cosine_distribution"), + ) + except Exception: + pass + + +# -- Per-file processing ------------------------------------------------------- + + +def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): + with open(ace_path) as f: + header = ACEtk.Header.from_string(f.readline()) + + Z, A, S, _ = decode_ace_zaid(header.zaid) + symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") + nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" + + ace_table = ACEtk.ContinuousEnergyTable.from_file(ace_path) + T_kelvin = 293.6 # TENDL proton files report 0 K as a placeholder + + mcdc_name = f"{nuclide_name}-{T_kelvin}K.h5" + out_path = os.path.join(output_dir, mcdc_name) + + if verbose: + print(f"\n{'='*80}") + print(f" {os.path.basename(ace_path)} -> {mcdc_name}") + print(f" Z={Z} A={A} S={S} T={T_kelvin} K") + + file = h5py.File(out_path, "w") + + # Metadata + hdr = ace_table.header + file.attrs["source_title"] = hdr.title + file.attrs["source_version"] = hdr.version + file.attrs["source_date"] = hdr.date + if hasattr(hdr, "comments"): + file.attrs["source_comments"] = hdr.comments + + file.create_dataset("nuclide_name", data=nuclide_name) + file.create_dataset("excitation_level", data=S) + file.create_dataset("temperature", data=T_kelvin).attrs["unit"] = "K" + file.create_dataset("atomic_number", data=ace_table.atom_number) + file.create_dataset("atomic_weight_ratio", data=ace_table.atomic_weight_ratio) + fissionable = ace_table.fission_multiplicity_block is not None + file.create_dataset("fissionable", data=fissionable) + + # Stopping power + if pstar_dir is not None: + pstar_path = os.path.join(pstar_dir, f"{symbol}.txt") + if os.path.exists(pstar_path): + if verbose: + print(f" Loading PSTAR from {pstar_path}") + E_s, S_s = load_pstar_file(pstar_path) + sp = file.create_group("stopping_power") + sp.create_dataset("energy", data=E_s).attrs["unit"] = "MeV" + sp.create_dataset("total_stopping_power", data=S_s).attrs["unit"] = ( + "MeV cm2/g" + ) + elif verbose: + print(f" [warn] No PSTAR file for {symbol}") + + # Reaction classification + nu_block = ace_table.frame_and_multiplicity_block + rx_block = ace_table.reaction_number_block + N_reaction = nu_block.number_reactions + + proton_reactions = file.create_group("proton_reactions") + elastic_group = proton_reactions.create_group("elastic_scattering") + capture_group = proton_reactions.create_group("capture") + nonelastic_group = proton_reactions.create_group("nonelastic_reaction") + fission_group = proton_reactions.create_group("fission") + + elastic_MTs = [2] + capture_MTs = [] + nonelastic_MTs = [] + fission_MTs = ( + [18] + if rx_block.has_MT(18) + else [MT for MT in FISSION_CHANCE_MTS if rx_block.has_MT(MT)] + ) + + for i in range(N_reaction): + idx = i + 1 + MT = rx_block.MT(idx) + if MT in REDUNDANT_MTS + elastic_MTs + fission_MTs or MT > 891: + continue + nu_raw = nu_block.multiplicity(idx) + if not isinstance(nu_raw, int): + print_error(f"Non-integer multiplicity for MT-{MT:03} in {ace_path}") + nu = nu_raw - 100 if nu_raw >= 100 else nu_raw + if nu == 0: + capture_MTs.append(MT) + elif nu > 0: + nonelastic_MTs.append(MT) + else: + print_error(f"Negative multiplicity for MT-{MT:03} in {ace_path}") + + for grp, mts in [ + (elastic_group, elastic_MTs), + (capture_group, capture_MTs), + (nonelastic_group, nonelastic_MTs), + (fission_group, fission_MTs), + ]: + for MT in mts: + grp.create_group(f"MT-{MT:03}").attrs["MT"] = MT + + if verbose: + print( + f" Elastic: {elastic_MTs} Capture: {capture_MTs} " + f"Nonelastic: {nonelastic_MTs}" + + (f" Fission: {fission_MTs}" if fissionable else "") + ) + + if not fissionable: + del file["proton_reactions/fission"] + if not nonelastic_MTs: + del file["proton_reactions/nonelastic_reaction"] + + # Cross sections + xs0 = ace_table.principal_cross_section_block + xs_main = ace_table.cross_section_block + + proton_reactions.create_dataset( + "xs_energy_grid", data=np.array(xs0.energies) + ).attrs["unit"] = "MeV" + + ds = elastic_group.create_dataset("MT-002/xs", data=np.array(xs0.elastic)) + ds.attrs["offset"] = 0 + ds.attrs["unit"] = "barns" + + for mts, grp in [ + (capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + ds = grp.create_dataset( + f"MT-{MT:03}/xs", data=np.array(xs_main.cross_sections(idx)) + ) + ds.attrs["offset"] = xs_main.energy_index(idx) - 1 + ds.attrs["unit"] = "barns" + + # Q-values + q_block = ace_table.reaction_qvalue_block + elastic_group.create_dataset("MT-002/Q-value", data=0.0).attrs["unit"] = "MeV" + + for mts, grp in [ + (capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + grp.create_dataset(f"MT-{MT:03}/Q-value", data=q_block.q_value(idx)).attrs[ + "unit" + ] = "MeV" + + # Reference frames + elastic_group.create_dataset("MT-002/reference_frame", data="COM") + + for mts, grp in [ + (capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + rf = nu_block.reference_frame(idx) + rf_str = ( + "LAB" + if rf == ACEtk.ReferenceFrame.Laboratory + else "COM" if rf == ACEtk.ReferenceFrame.CentreOfMass else str(rf) + ) + grp.create_dataset(f"MT-{MT:03}/reference_frame", data=rf_str) + + # Nonelastic multiplicities + for MT in nonelastic_MTs: + idx = rx_block.index(MT) + nu_raw = nu_block.multiplicity(idx) + nonelastic_group.create_dataset( + f"MT-{MT:03}/multiplicity", data=nu_raw - 100 if nu_raw >= 100 else nu_raw + ) + + # Angular distributions + angle_block = ace_table.angular_distribution_block + + ag = elastic_group.create_group("MT-002/angular_cosine_distribution") + ag.attrs["type"] = "energy-correlated" + if ( + not load_cosine_distribution(angle_block.angular_distribution_data(0), ag) + and verbose + ): + print_note("MT-002 angular distribution is given in energy block") + + for mts, grp in [ + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + ag = grp.create_group(f"MT-{MT:03}/angular_cosine_distribution") + if ( + not load_cosine_distribution( + angle_block.angular_distribution_data(idx), ag + ) + and verbose + ): + print_note(f"MT-{MT:03} angular distribution is given in energy block") + + # Primary energy distributions + energy_block = ace_table.energy_distribution_block + + for mts, grp in [ + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None), + ]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + data = energy_block.energy_distribution_data(idx) + + if not isinstance(data, ACEtk.continuous.MultiDistributionData): + grp.create_dataset( + f"MT-{MT:03}/spectrum_probability_grid", data=np.array([0.0, 30.0]) + ).attrs["unit"] = "MeV" + grp.create_dataset( + f"MT-{MT:03}/spectrum_probability", data=np.array([[1.0]]) + ) + load_energy_distribution( + data, grp.create_group(f"MT-{MT:03}/energy_spectrum-1") + ) + else: + N_dist = data.number_distributions + probs = data.probabilities + + if all(p.number_interpolation_regions == 0 for p in probs): + prob_grid = np.array([0.0, 30.0]) + prob = np.zeros((1, N_dist)) + for k in range(N_dist): + prob[0, k] = max(data.probability(k + 1).probabilities) + elif all(p.number_interpolation_regions == 1 for p in probs) and all( + p.interpolants[0] == 1 for p in probs + ): + prob_grid = np.array(data.probability(1).energies) + prob = np.zeros((len(prob_grid) - 1, N_dist)) + for k in range(N_dist): + prob[:, k] = np.array( + data.probability(k + 1).probabilities[:-1] + ) + else: + print_error( + f"Unsupported multi-distribution probability for MT-{MT:03}" + ) + + grp.create_dataset( + f"MT-{MT:03}/spectrum_probability_grid", data=prob_grid + ).attrs["unit"] = "MeV" + grp.create_dataset(f"MT-{MT:03}/spectrum_probability", data=prob) + for k in range(N_dist): + load_energy_distribution( + data.distribution(k + 1), + grp.create_group(f"MT-{MT:03}/energy_spectrum-{k + 1}"), + ) + + # Secondary particles + load_secondary_particles(ace_table, file, verbose=verbose) + + # Fission data + if fissionable: + prompt_block = ace_table.fission_multiplicity_block + delayed_block = ace_table.delayed_fission_multiplicity_block + dnp_block = ace_table.delayed_neutron_precursor_block + + load_fission_multiplicity( + prompt_block.multiplicity, fission_group.create_group("prompt_multiplicity") + ) + if delayed_block is not None: + load_fission_multiplicity( + delayed_block.multiplicity, + fission_group.create_group("delayed_multiplicity"), + ) + + if dnp_block is not None: + N_DNP = dnp_block.number_delayed_precursors + fractions = np.zeros(N_DNP) + decay_rates = np.zeros(N_DNP) + for k in range(N_DNP): + d = dnp_block.precursor_group_data(k + 1) + if ( + d.number_interpolation_regions != 0 + or len(d.probabilities[:]) != 2 + or d.probabilities[0] != d.probabilities[1] + ): + print_error("Non-constant delayed neutron precursor fraction") + fractions[k] = d.probabilities[0] + decay_rates[k] = d.decay_constant + + prec = fission_group.create_group("delayed_neutron_precursors") + prec.create_dataset("fractions", data=fractions) + prec.create_dataset("decay_rates", data=decay_rates).attrs["unit"] = "/s" + + delayed_spectrum_block = ace_table.delayed_neutron_energy_distribution_block + for k in range(N_DNP): + load_energy_distribution( + delayed_spectrum_block.energy_distribution_data(k + 1), + prec.create_group(f"energy_spectrum-{k + 1}"), + ) + + file.close() + return mcdc_name + + +# -- Main ---------------------------------------------------------------------- + + +def main(): + parser = argparse.ArgumentParser( + description="Convert proton ACE files to MC/DC-compatible HDF5" + ) + parser.add_argument("--ace_dir", default=os.getenv("MCDC_ACELIB")) + parser.add_argument("--output_dir", default=os.getenv("MCDC_LIB")) + parser.add_argument("--pstar_dir", default=os.getenv("PSTAR_LIB")) + parser.add_argument("--rewrite", action="store_true", default=False) + parser.add_argument("--verbose", action="store_true", default=False) + args = parser.parse_args() + + if args.ace_dir is None: + print_error("No ACE directory. Use --ace_dir or set $MCDC_ACELIB.") + if args.output_dir is None: + print_error("No output directory. Use --output_dir or set $MCDC_LIB.") + + os.makedirs(args.output_dir, exist_ok=True) + print(f"\nACE directory : {args.ace_dir}") + print(f"Output directory: {args.output_dir}") + print(f"PSTAR directory : {args.pstar_dir}\n") + + all_files = sorted(os.listdir(args.ace_dir)) + + if args.rewrite: + target_files = all_files + else: + target_files = [] + for fname in all_files: + try: + with open(os.path.join(args.ace_dir, fname)) as f: + hdr = ACEtk.Header.from_string(f.readline()) + Z, A, S, _ = decode_ace_zaid(hdr.zaid) + symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") + nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" + if not any( + f.startswith(nuclide_name + "-") + for f in os.listdir(args.output_dir) + ): + target_files.append(fname) + except Exception: + target_files.append(fname) + + errors = [] + pbar = tqdm( + target_files, + disable=args.verbose, + bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} {postfix}", + ) + + for ace_name in pbar: + pbar.set_postfix_str(ace_name) + try: + out = process_ace_file( + os.path.join(args.ace_dir, ace_name), + args.output_dir, + pstar_dir=args.pstar_dir, + verbose=args.verbose, + ) + if args.verbose: + print(f" -> wrote {out}") + except Exception as exc: + errors.append((ace_name, str(exc))) + if args.verbose: + import traceback + + traceback.print_exc() + + print(f"\nDone. {len(target_files) - len(errors)} succeeded, {len(errors)} failed.") + if errors: + print("\nFailed files:") + for name, msg in errors: + print(f" {name}: {msg}") + + +if __name__ == "__main__": + main() diff --git a/tools/data_library_generator/util.py b/tools/data_library_generator/util.py index a2276ee5d..9a83892e3 100644 --- a/tools/data_library_generator/util.py +++ b/tools/data_library_generator/util.py @@ -49,6 +49,10 @@ def decode_ace_name(name: str): if extension == "70h": T = 293.6 + # Proton data: TENDL-19 (defaults at 0K, I think) + if extension == "19h": + T = 0 + else: T = ACE_TEMPERATURE_LIB81[extension] From 966122a1e2ebfab9b55c39cf2dbf9ff61a5850b3 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 3 Jun 2026 13:43:02 -0700 Subject: [PATCH 45/64] cleaning up examples & tools --- examples/proton_beam/input_1.py | 96 -- examples/proton_beam/input_10.py | 97 -- examples/proton_beam/test.py | 23 - ...ton_generate.py => endf70prot_generate.py} | 0 .../parse_endf70prot.py | 6 +- .../tendl_generate_v2.py | 913 ------------------ 6 files changed, 3 insertions(+), 1132 deletions(-) delete mode 100644 examples/proton_beam/input_1.py delete mode 100644 examples/proton_beam/input_10.py delete mode 100644 examples/proton_beam/test.py rename tools/data_library_generator/{proton_generate.py => endf70prot_generate.py} (100%) delete mode 100644 tools/data_library_generator/tendl_generate_v2.py diff --git a/examples/proton_beam/input_1.py b/examples/proton_beam/input_1.py deleted file mode 100644 index fc30e633f..000000000 --- a/examples/proton_beam/input_1.py +++ /dev/null @@ -1,96 +0,0 @@ -import numpy as np -import mcdc - -# ====================================================================================== -# Set model -# ====================================================================================== -# Proton beam, incident on a slab - -# Set materials (atom density in units of atoms/barn-cm) -silicon = mcdc.Material("silicon", {"Si28": 0.05}) - -# Set surfaces -sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") -sx2 = mcdc.Surface.PlaneX(x=0.002, boundary_condition="vacuum") -sy1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") -sy2 = mcdc.Surface.PlaneY(y=1.0, boundary_condition="vacuum") -sz1 = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") -sz2 = mcdc.Surface.PlaneZ(z=1.0, boundary_condition="vacuum") - -slab = +sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2 - -# Set cells -slab_cell = mcdc.Cell(name="silicon", region=slab, fill=silicon) - -# ====================================================================================== -# Set source -# ====================================================================================== - -mcdc.Source( - x=[0.0, 0.0], - y=[0.0, 1.0], - z=[0.0, 1.0], - direction=[1.0, 0.0, 0.0], - energy=1e6, - # energy_group=0, - particle_type="proton", - # time=[0.0, 0.0], -) - -# ====================================================================================== -# Set tallies, settings, techniques, and run MC/DC -# ====================================================================================== - -# Tallies -percent_of_range = np.array( - [ - 0.0, - 10, - 20, - 30, - 40, - 50, - 60, - 70, - 80, - 85, - 90, - 91, - 92, - 93, - 94, - 95, - 96, - 97, - 98, - 99, - 100, - 101, - 102, - 103, - 104, - 105, - 107.5, - 110, - 115, - ] -) -range = 16.45 * 1e-4 # cm - -bin_edges = range * percent_of_range * 1e-2 - -mesh = mcdc.MeshStructured(x=(bin_edges)) -mcdc.Tally(name=f"edep", mesh=mesh, scores=["energy_deposition"]) - -# Settings -mcdc.settings.set_transported_particles(["proton"]) -mcdc.settings.N_particle = 1_000 -mcdc.settings.N_batch = 1 -mcdc.settings.csda = True -mcdc.settings.csda_max_fractional_e_loss = 0.001 - -# Techniques -mcdc.simulation.implicit_capture() - -# Run -mcdc.run() diff --git a/examples/proton_beam/input_10.py b/examples/proton_beam/input_10.py deleted file mode 100644 index b63de3b84..000000000 --- a/examples/proton_beam/input_10.py +++ /dev/null @@ -1,97 +0,0 @@ -import numpy as np -import mcdc - -# ====================================================================================== -# Set model -# ====================================================================================== -# Proton beam, incident on a slab - -# Set materials (atom density in units of atoms/barn-cm) -silicon = mcdc.Material("silicon", {"Si28": 0.05}) - -# Set surfaces -sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") -sx2 = mcdc.Surface.PlaneX(x=0.1, boundary_condition="vacuum") -sy1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") -sy2 = mcdc.Surface.PlaneY(y=0.1, boundary_condition="vacuum") -sz1 = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") -sz2 = mcdc.Surface.PlaneZ(z=0.1, boundary_condition="vacuum") - -slab = +sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2 - -# Set cells -slab_cell = mcdc.Cell(name="silicon", region=slab, fill=silicon) - -# ====================================================================================== -# Set source -# ====================================================================================== - -mcdc.Source( - x=[0.0, 0.0], - y=[0.0, 0.0002], - z=[0.0, 0.0002], - direction=[1.0, 0.0, 0.0], - energy=1e7, - # energy_group=0, - particle_type="proton", - # time=[0.0, 0.0], -) - -# ====================================================================================== -# Set tallies, settings, techniques, and run MC/DC -# ====================================================================================== - -# Tallies -percent_of_range = np.array( - [ - 0.0, - 10, - 20, - 30, - 40, - 50, - 60, - 70, - 80, - 85, - 90, - 91, - 92, - 93, - 94, - 95, - 96, - 97, - 98, - 99, - 100, - 101, - 102, - 103, - 104, - 105, - 107.5, - 110, - 115, - ] -) -range = 714.59 * 1e-4 # cm - -bin_edges = range * percent_of_range * 1e-2 - -# mesh = mcdc.MeshUniform(x=(0.0, 0.002, 50)) -mesh = mcdc.MeshStructured(x=(bin_edges)) -mcdc.Tally(name=f"edep", mesh=mesh, scores=["energy_deposition"]) - -# Settings -mcdc.settings.set_transported_particles(["proton"]) -mcdc.settings.N_particle = 10_000 -mcdc.settings.N_batch = 1 -mcdc.settings.csda = True -mcdc.settings.csda_max_fractional_e_loss = 0.001 - -# Techniques -mcdc.simulation.implicit_capture() - -# Run -mcdc.run() diff --git a/examples/proton_beam/test.py b/examples/proton_beam/test.py deleted file mode 100644 index ce1f8f2fa..000000000 --- a/examples/proton_beam/test.py +++ /dev/null @@ -1,23 +0,0 @@ -import h5py -import matplotlib.pyplot as plt -import sys - -isotope = sys.argv[1] - -with h5py.File(f"../../proton_generated_lib/{isotope}-293.6K.h5") as f: - print(f'atomic number = {f["atomic_number"][()]}') - print(f'atomic weight ratio = {f["atomic_weight_ratio"][()]}') - print(f'fissionable = {f["fissionable"][()]}') - print(f'nuclide name = {f["nuclide_name"][()]}') - - elastic_xs = f["proton_reactions"]["elastic_scattering"]["MT-002"]["xs"][()] - inelastic_xs = f["proton_reactions"]["inelastic_scattering"]["MT-005"]["xs"][()] - - plt.plot(elastic_xs, label="elastic") - plt.plot(inelastic_xs, label="inelastic") - plt.legend() - plt.yscale("log") - plt.xscale("log") - plt.xlabel("Incident Energy (MeV)") - plt.ylabel("Cross Section") - plt.savefig(f"{isotope}_xs.png") diff --git a/tools/data_library_generator/proton_generate.py b/tools/data_library_generator/endf70prot_generate.py similarity index 100% rename from tools/data_library_generator/proton_generate.py rename to tools/data_library_generator/endf70prot_generate.py diff --git a/tools/data_library_generator/parse_endf70prot.py b/tools/data_library_generator/parse_endf70prot.py index f62b45888..da79cff2b 100644 --- a/tools/data_library_generator/parse_endf70prot.py +++ b/tools/data_library_generator/parse_endf70prot.py @@ -1,8 +1,8 @@ # This script was written by ChatGPT with Ethan Lame's instructions import os -input_file = "/home/ethan_lame/MCDC/acelib/endf70prot" # your big file -output_dir = "/home/ethan_lame/MCDC/acelib/" # where split files go +input_file = "/home/ethan_lame/MCDC/acelib/endf70prot" # your big file +output_dir = "/home/ethan_lame/MCDC/acelib/" # where split files go os.makedirs(output_dir, exist_ok=True) @@ -31,4 +31,4 @@ # Close last file if current_file is not None: - current_file.close() \ No newline at end of file + current_file.close() diff --git a/tools/data_library_generator/tendl_generate_v2.py b/tools/data_library_generator/tendl_generate_v2.py deleted file mode 100644 index fec0d4d17..000000000 --- a/tools/data_library_generator/tendl_generate_v2.py +++ /dev/null @@ -1,913 +0,0 @@ -# The majority of this script was written by Anthropic's Claude - -""" -ace_to_hdf5.py -============== -Convert a directory of proton ACE files (e.g. TENDL) into per-nuclide HDF5 files -suitable for use in MC/DC or similar Monte Carlo transport codes. - -Usage ------ - python ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 - python ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 --rewrite - python ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 --verbose - -Environment variable fallback (compatible with original MC/DC conventions): - $MCDC_ACELIB → ace_dir - $MCDC_LIB → output_dir - -HDF5 layout produced --------------------- -/-K.h5 - attrs: - source_title, source_version, source_date - nuclide_name (str) - excitation_level (int) - temperature (float, K) - atomic_number (int) - atomic_weight_ratio (float) - fissionable (bool) - - proton_reactions/ - xs_energy_grid (float array, MeV) - - elastic_scattering/ - MT-002/ - xs (float array, barns) attrs: offset, unit - Q-value (float, MeV) - reference_frame (str: "COM") - angular_cosine_distribution/ (tabulated cosine distributions) - - capture/ - MT-{NNN}/ - xs, Q-value, reference_frame - - nonelastic_reaction/ - MT-{NNN}/ - xs, Q-value, reference_frame - multiplicity (int) - angular_cosine_distribution/ - energy_spectrum-{k}/ (one per distribution in a MultiDistributionData) - - fission/ (only if fissionable) - ... - - secondary_particles/ - ZAP_{zap}/ - attrs: ZAP (int), particle_name (str) - MT-{NNN}/ - attrs: MT (int), multiplicity (int), reference_frame (str) - production_xs (float array, barns) attrs: offset, unit - kalbach_mann/ - incident_energies (float array, MeV) - interpolation_boundaries (int array) - interpolation_types (int array) - E_in_{k}/ (one group per incident energy point) - outgoing_energies (float array, MeV) - pdf (float array) - cdf (float array) - r (float array) Kalbach-Mann precompound fraction - a (float array) Kalbach-Mann slope parameter - -Notes ------ -* The Kalbach-Mann property names on TabulatedKalbachMannDistribution are - introspected at runtime the first time a distribution is encountered, so - this script will work even if ACEtk renames them between versions. -* ZAP particle identity: 1=n, 31=p, 32=d, 33=t, 34=alpha -""" - -import argparse -import os -import sys - -import h5py -import numpy as np -from tqdm import tqdm - -import ACEtk - -# ────────────────────────────────────────────────────────────────────────────── -# Helpers -# ────────────────────────────────────────────────────────────────────────────── - -# TODO: THIS IS UNCERTAIN - NEED TO VERIFY ZAP NUMBERS/PARTICLE TYPE CORRESPONDANCE - -ZAP_NAMES = { - 0: "photon", - 1: "neutron", - 31: "proton", - 32: "deuteron", - 33: "triton", - 34: "alpha", -} - -# Candidate property names for TabulatedKalbachMannDistribution fields. -# We try each list in order and use the first one that exists on the object. -_KM_CANDIDATES = { - "outgoing_energies": ["outgoing_energies", "energies", "energy"], - "pdf": ["pdf", "probabilities", "probability_density"], - "cdf": ["cdf", "cumulative_probabilities", "cumulative_distribution"], - "r": ["precompound_fraction_values", "precompound_fractions", "r", "R"], - "a": ["angular_distribution_slope_values", "slopes", "a", "A"], -} -# Cache resolved names so introspection only happens once. -_km_resolved: dict[str, str] = {} - - -def _resolve_km_attr(dist_obj, field: str) -> str: - """Return the actual attribute name on dist_obj for the given logical field.""" - if field in _km_resolved: - return _km_resolved[field] - for candidate in _KM_CANDIDATES[field]: - if hasattr(dist_obj, candidate): - _km_resolved[field] = candidate - return candidate - raise AttributeError( - f"Cannot find attribute for '{field}' on " - f"{type(dist_obj).__name__}. " - f"Tried: {_KM_CANDIDATES[field]}. " - f"Available: {[x for x in dir(dist_obj) if not x.startswith('_')]}" - ) - - -def get_km_field(dist_obj, field: str): - """Get a logical Kalbach-Mann field from a TabulatedKalbachMannDistribution.""" - attr = _resolve_km_attr(dist_obj, field) - return getattr(dist_obj, attr) - - -def print_error(msg: str): - print(f"\n[ERROR] {msg}", file=sys.stderr) - sys.exit(1) - - -def print_note(msg: str): - print(f" [note] {msg}") - - -# ────────────────────────────────────────────────────────────────────────────── -# ZAP / name decoding -# ────────────────────────────────────────────────────────────────────────────── - -# Periodic table symbol lookup (Z → symbol) -Z_TO_SYMBOL = { - 1: "H", 2: "He", 3: "Li", 4: "Be", 5: "B", 6: "C", 7: "N", 8: "O", - 9: "F", 10: "Ne",11: "Na",12: "Mg",13: "Al",14: "Si",15: "P", 16: "S", - 17:"Cl", 18:"Ar", 19:"K", 20:"Ca", 21:"Sc", 22:"Ti", 23:"V", 24:"Cr", - 25:"Mn", 26:"Fe", 27:"Co", 28:"Ni", 29:"Cu", 30:"Zn", 31:"Ga", 32:"Ge", - 33:"As", 34:"Se", 35:"Br", 36:"Kr", 37:"Rb", 38:"Sr", 39:"Y", 40:"Zr", - 41:"Nb", 42:"Mo", 43:"Tc", 44:"Ru", 45:"Rh", 46:"Pd", 47:"Ag", 48:"Cd", - 49:"In", 50:"Sn", 51:"Sb", 52:"Te", 53:"I", 54:"Xe", 55:"Cs", 56:"Ba", - 57:"La", 58:"Ce", 59:"Pr", 60:"Nd", 61:"Pm", 62:"Sm", 63:"Eu", 64:"Gd", - 65:"Tb", 66:"Dy", 67:"Ho", 68:"Er", 69:"Tm", 70:"Yb", 71:"Lu", 72:"Hf", - 73:"Ta", 74:"W", 75:"Re", 76:"Os", 77:"Ir", 78:"Pt", 79:"Au", 80:"Hg", - 81:"Tl", 82:"Pb", 83:"Bi", 84:"Po", 85:"At", 86:"Rn", 87:"Fr", 88:"Ra", - 89:"Ac", 90:"Th", 91:"Pa", 92:"U", 93:"Np", 94:"Pu", 95:"Am", 96:"Cm", - 97:"Bk", 98:"Cf", 99:"Es",100:"Fm",101:"Md",102:"No",103:"Lr", -} - - -def decode_ace_zaid(zaid: str): - """ - Decode an ACE ZAID string into (Z, A, S, T). - Handles both legacy '1001.70h' and modern '1001.710h' style ZAIDs. - Returns Z (atomic number), A (mass number), S (isomeric state), T (temperature K). - """ - # Strip trailing whitespace and split on '.' - parts = zaid.strip().split(".") - za_str = parts[0] - # ZA = Z*1000 + A, possibly with S encoded as ZA > 600000 (isomers) - za = int(za_str) - if za >= 600000: - # metastable: ZAID = Z*1000 + A + S*400 (legacy MCNP convention, approximate) - S = (za % 1000) // 400 # rough extraction - za = za - S * 400 - else: - S = 0 - Z = za // 1000 - A = za % 1000 - - # Temperature from suffix, e.g. '70h' → 293 K, '710h' → custom - # The conventional mapping is suffix_number * ~(1/100) * some factor. - # Most TENDL proton files just use a nominal 0K or room temperature. - # Use the header temperature value instead (set to 0 as default here). - T = 0 - return Z, A, S, T - - -# ────────────────────────────────────────────────────────────────────────────── -# Angular distribution loading (from original MC/DC approach) -# ────────────────────────────────────────────────────────────────────────────── - -def load_cosine_distribution(data, h5_group): - """ - Write a tabulated angular (cosine) distribution into an HDF5 group. - data is an AngularDistributionData object from ACEtk. - - Returns True if angular data was written, False if it is encoded - elsewhere (i.e. inside the Kalbach-Mann energy distribution block). - """ - # DistributionGivenElsewhere means the angular data is embedded in the - # LAW 44 Kalbach-Mann energy distribution via the r and a parameters. - # There is nothing to store here — the sampling code must use the - # Kalbach-Mann block instead. - if isinstance(data, ACEtk.continuous.DistributionGivenElsewhere): - h5_group.attrs["type"] = "given_in_energy_distribution" - return False - - energies = np.array(data.incident_energies) - h5_group.create_dataset("incident_energies", data=energies) - h5_group.attrs["unit"] = "MeV" - # Set type on root group (default to tabulated if we get here) - h5_group.attrs["type"] = "tabulated" - - for i, subdist in enumerate(data.distributions): - eg = h5_group.create_group(f"E_in_{i + 1}") - if isinstance(subdist, ACEtk.continuous.TabulatedAngularDistribution): - eg.attrs["type"] = "tabulated" - eg.create_dataset("cosines", data=np.array(subdist.cosines)) - eg.create_dataset("pdf", data=np.array(subdist.pdf)) - eg.create_dataset("cdf", data=np.array(subdist.cdf)) - else: - # Isotropic or unsupported — mark it so sampling code knows - eg.attrs["type"] = "isotropic" - - return True - - -# ────────────────────────────────────────────────────────────────────────────── -# Energy distribution loading (neutron/primary particle, existing reactions) -# ────────────────────────────────────────────────────────────────────────────── - -def load_energy_distribution(data, h5_group): - """ - Write a primary-particle outgoing energy distribution into an HDF5 group. - Handles the most common ACE law types encountered in proton libraries. - """ - if isinstance(data, ACEtk.continuous.KalbachMannDistributionData): - h5_group.attrs["law"] = 44 - _write_kalbach_mann(data, h5_group) - - elif isinstance(data, ACEtk.continuous.OutgoingEnergyDistributionData): - h5_group.attrs["law"] = 4 - h5_group.create_dataset( - "incident_energies", data=np.array(data.incident_energies) - ) - for k, dist in enumerate(data.distributions): - eg = h5_group.create_group(f"E_in_{k + 1}") - eg.create_dataset("outgoing_energies", data=np.array(dist.outgoing_energies)) - eg.create_dataset("pdf", data=np.array(dist.pdf)) - eg.create_dataset("cdf", data=np.array(dist.cdf)) - - elif isinstance(data, ACEtk.continuous.LevelScatteringData): - h5_group.attrs["law"] = 3 - h5_group.create_dataset("C1", data=data.C1) - h5_group.create_dataset("C2", data=data.C2) - - elif isinstance(data, ACEtk.continuous.EquiprobableOutgoingEnergyBins): - h5_group.attrs["law"] = 1 - h5_group.create_dataset( - "incident_energies", data=np.array(data.incident_energies) - ) - for k, dist in enumerate(data.distributions): - eg = h5_group.create_group(f"E_in_{k + 1}") - eg.create_dataset("energies", data=np.array(dist.energies)) - - else: - # Unknown law — store the raw XSS array so nothing is silently lost - h5_group.attrs["law"] = -1 - h5_group.attrs["type_name"] = type(data).__name__ - try: - h5_group.create_dataset("xss_array", data=np.array(data.xss_array)) - except Exception: - pass - - -def _write_kalbach_mann(km_data, h5_group): - """ - Write a KalbachMannDistributionData block into an open HDF5 group. - Uses MCDC-compatible format with flattened arrays and offset indices. - """ - h5_group.attrs["type"] = "kalbach-mann" - - NE = km_data.number_incident_energies - - # Incident energy grid - energy = np.array(km_data.incident_energies) - energy_ds = h5_group.create_dataset("energy", data=energy) - energy_ds.attrs["unit"] = "MeV" - - # Collect all outgoing energy points and build offset array - offset = np.zeros(NE, dtype=np.int32) - energy_out = [] - pdf = [] - precompound_factor = [] - angular_slope = [] - - for i in range(1, NE + 1): - dist = km_data.distribution(i) - offset[i - 1] = len(pdf) - energy_out.extend(get_km_field(dist, "outgoing_energies")) - pdf.extend(get_km_field(dist, "pdf")) - precompound_factor.extend(get_km_field(dist, "r")) - angular_slope.extend(get_km_field(dist, "a")) - - # Create flattened datasets - h5_group.create_dataset("offset", data=offset) - energy_out_ds = h5_group.create_dataset("energy_out", data=np.array(energy_out)) - energy_out_ds.attrs["unit"] = "MeV" - h5_group.create_dataset("pdf", data=np.array(pdf)) - h5_group.create_dataset("precompound_factor", data=np.array(precompound_factor)) - h5_group.create_dataset("angular_slope", data=np.array(angular_slope)) - - -# ────────────────────────────────────────────────────────────────────────────── -# Fission multiplicity loading -# ────────────────────────────────────────────────────────────────────────────── - -def load_fission_multiplicity(data, h5_group): - if isinstance(data, ACEtk.continuous.TabulatedFissionMultiplicity): - h5_group.attrs["type"] = "tabulated" - h5_group.create_dataset("energies", data=np.array(data.energies)) - h5_group.create_dataset("multiplicities", data=np.array(data.multiplicities)) - elif isinstance(data, ACEtk.continuous.PolynomialFissionMultiplicity): - h5_group.attrs["type"] = "polynomial" - h5_group.create_dataset("coefficients", data=np.array(data.coefficients)) - else: - h5_group.attrs["type"] = "unknown" - h5_group.attrs["type_name"] = type(data).__name__ - - -# ────────────────────────────────────────────────────────────────────────────── -# Secondary particle block extraction -# ────────────────────────────────────────────────────────────────────────────── - -def load_secondary_particles(ace_table, file, verbose=False): - """ - Extract all secondary particle production data from a proton ACE table - and write it into file['secondary_particles/ZAP_{zap}/MT-{MT:03}/...']. - """ - n_types = ace_table.number_secondary_particle_types - if n_types == 0: - return - - # ── Top-level block handles ─────────────────────────────────────────────── - # The secondary particle blocks are callable by type index — rx_block(i) - # returns the ReactionNumberBlock for type i, tyr_block(i) returns the - # FrameAndMultiplicityBlock for type i, etc. - type_block = ace_table.secondary_particle_type_block - info_block = ace_table.secondary_particle_information_block - rx_block = ace_table.secondary_particle_reaction_number_block - tyr_block = ace_table.secondary_particle_frame_and_multiplicity_block - xs_block = ace_table.secondary_particle_production_cross_section_block - edy_block = ace_table.secondary_particle_energy_distribution_block - - # angular block is optional for secondary particles in some libraries - try: - ang_block = ace_table.secondary_particle_angular_distribution_block - has_ang = True - except Exception: - has_ang = False - - sec_group = file.create_group("secondary_particles") - - # ── Introspect particle_identifier method name once ─────────────────────── - _pi_candidates = ["particle_identifier", "ZAP", "type", "particle_type"] - _pi_method = None - for cand in _pi_candidates: - if hasattr(type_block, cand): - _pi_method = cand - break - if _pi_method is None: - raise AttributeError( - f"Cannot find particle identifier method on " - f"{type(type_block).__name__}. " - f"Available: {[x for x in dir(type_block) if not x.startswith('_')]}" - ) - - # ── Loop over secondary particle types ──────────────────────────────────── - for i in range(1, n_types + 1): - - zap = getattr(type_block, _pi_method)(i) - name = ZAP_NAMES.get(zap, f"ZAP_{zap}") - - # number_reactions is a sequence property on info_block, 0-based - n_rx = int(info_block.number_reactions[i - 1]) - - if verbose: - print(f" Secondary particle type {i}: ZAP={zap} ({name}), " - f"{n_rx} reactions") - - zap_group = sec_group.create_group(f"ZAP_{zap}") - zap_group.attrs["ZAP"] = zap - zap_group.attrs["particle_name"] = name - - # Per-type sub-blocks: call the top-level block with the type index - # to get the per-type block, then call methods on that. - rx_i = rx_block(i) # ReactionNumberBlock for type i - tyr_i = tyr_block(i) # FrameAndMultiplicityBlock for type i - xs_i = xs_block(i) # production cross section block for type i - edy_i = edy_block(i) # energy distribution block for type i - ang_i = ang_block(i) if has_ang else None - - # Introspect xs sub-block method names (once, on first type) - _xs_candidates = [ - "production_xs", - "production_cross_sections", - "cross_sections", - "cross_section", - "cross_section_values", - "xs", - "xss", - ] - _off_candidates = ["energy_index", "offset", "locator", "index"] - _xs_method = next((c for c in _xs_candidates if hasattr(xs_i, c)), None) - _off_method = next((c for c in _off_candidates if hasattr(xs_i, c)), None) - - # Introspect energy distribution method name - _edy_candidates = ["energy_distribution_data", "distribution_data", "distribution"] - _edy_method = next((c for c in _edy_candidates if hasattr(edy_i, c)), None) - - for j in range(1, n_rx + 1): - - MT = rx_i.MT(j) - - # ── Multiplicity ───────────────────────────────────────────────── - nu_raw = tyr_i.multiplicity(j) - nu = nu_raw - 100 if nu_raw >= 100 else nu_raw - - # ── Reference frame ─────────────────────────────────────────────── - rf_raw = tyr_i.reference_frame(j) - if rf_raw == ACEtk.ReferenceFrame.Laboratory: - rf = "LAB" - elif rf_raw == ACEtk.ReferenceFrame.CentreOfMass: - rf = "COM" - else: - rf = str(rf_raw) - - mt_group = zap_group.create_group(f"MT-{MT:03}") - mt_group.attrs["MT"] = MT - mt_group.attrs["multiplicity"] = nu - mt_group.attrs["reference_frame"] = rf - - if verbose: - print(f" MT={MT:03} nu_raw={nu_raw} nu={nu} frame={rf}") - - # ── Production cross section ────────────────────────────────────── - if _xs_method and _off_method: - try: - xs_vals = np.array(getattr(xs_i, _xs_method)(j)) - xs_offset = int(getattr(xs_i, _off_method)(j)) - xs_ds = mt_group.create_dataset("production_xs", data=xs_vals) - xs_ds.attrs["offset"] = xs_offset - 1 # convert to 0-based - xs_ds.attrs["unit"] = "barns" - except Exception as exc: - xs_ds = mt_group.create_dataset("production_xs", data=np.zeros(0, dtype=float)) - xs_ds.attrs["offset"] = 0 - xs_ds.attrs["unit"] = "barns" - if verbose: - print(f" [warn] Could not read production xs: {exc}") - else: - xs_ds = mt_group.create_dataset("production_xs", data=np.zeros(0, dtype=float)) - xs_ds.attrs["offset"] = 0 - xs_ds.attrs["unit"] = "barns" - if verbose: - print(f" [warn] production xs block methods not resolved: " - f"{[x for x in dir(xs_i) if not x.startswith('_')]}") - - # ── Kalbach-Mann energy-angle distribution ──────────────────────── - if _edy_method: - try: - km_data = getattr(edy_i, _edy_method)(j) - km_group = mt_group.create_group("kalbach_mann") - _write_kalbach_mann(km_data, km_group) - except Exception as exc: - if verbose: - print(f" [warn] Could not read energy distribution: {exc}") - else: - if verbose: - print(f" [warn] energy distribution method not resolved: " - f"{[x for x in dir(edy_i) if not x.startswith('_')]}") - - # ── Angular distribution (if present) ──────────────────────────── - if ang_i is not None: - try: - ang_data = ang_i.angular_distribution_data(j) - ang_group = mt_group.create_group("angular_cosine_distribution") - load_cosine_distribution(ang_data, ang_group) - except Exception: - pass # not all secondary types have explicit angular data - - -# ────────────────────────────────────────────────────────────────────────────── -# Per-file processing -# ────────────────────────────────────────────────────────────────────────────── - -def process_ace_file(ace_path: str, output_dir: str, verbose: bool = False) -> str: - """ - Convert a single ACE proton file to HDF5. Returns the output filename. - """ - - # ── Header ──────────────────────────────────────────────────────────────── - with open(ace_path, "r") as f: - header = ACEtk.Header.from_string(f.readline()) - - Z, A, S, T = decode_ace_zaid(header.zaid) - symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") - nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" - - # Get temperature from the table itself (more reliable than ZAID suffix) - ace_table = ACEtk.ContinuousEnergyTable.from_file(ace_path) - T_kelvin = float(ace_table.temperature) if hasattr(ace_table, "temperature") else T - - # Forcing to be room temperature, as 0K from the file is a placeholder - T_kelvin = 293.6 - - mcdc_name = f"{nuclide_name}-{T_kelvin}K.h5" - out_path = os.path.join(output_dir, mcdc_name) - - if verbose: - print(f"\n{'='*80}") - print(f" {os.path.basename(ace_path)} → {mcdc_name}") - print(f" Z={Z} A={A} S={S} T={T_kelvin} K") - - file = h5py.File(out_path, "w") - - # ── Basic metadata ──────────────────────────────────────────────────────── - hdr = ace_table.header - file.attrs["source_title"] = hdr.title - file.attrs["source_version"] = hdr.version - file.attrs["source_date"] = hdr.date - if hasattr(hdr, "comments"): - file.attrs["source_comments"] = hdr.comments - - file.create_dataset("nuclide_name", data=nuclide_name) - file.create_dataset("excitation_level", data=S) - ds = file.create_dataset("temperature", data=T_kelvin) - ds.attrs["unit"] = "K" - file.create_dataset("atomic_number", data=ace_table.atom_number) - file.create_dataset("atomic_weight_ratio", data=ace_table.atomic_weight_ratio) - - fissionable = ace_table.fission_multiplicity_block is not None - file.create_dataset("fissionable", data=fissionable) - - # ── Reaction classification ─────────────────────────────────────────────── - proton_reactions = file.create_group("proton_reactions") - - nu_block = ace_table.frame_and_multiplicity_block - rx_block = ace_table.reaction_number_block - N_reaction = nu_block.number_reactions - - elastic_group = proton_reactions.create_group("elastic_scattering") - capture_group = proton_reactions.create_group("capture") - nonelastic_group = proton_reactions.create_group("nonelastic_reaction") - fission_group = proton_reactions.create_group("fission") - - elastic_MTs = [2] - capture_MTs = [] - nonelastic_MTs = [] - fission_MTs = [] - - fission_chance_MTs = [19, 20, 21, 38] - # Genuine redundant sum MTs — do not double-count these - redundant_MTs = [1, 3, 4, 10, 101, 103, 104, 105, 106, 107] - - total_fission_given = rx_block.has_MT(18) - if total_fission_given: - fission_MTs = [18] - else: - for MT in fission_chance_MTs: - if rx_block.has_MT(MT): - fission_MTs.append(MT) - - for i in range(N_reaction): - idx = i + 1 - MT = rx_block.MT(idx) - - if MT in redundant_MTs + elastic_MTs + fission_MTs: - continue - if MT > 891: # above the defined charged-particle range - continue - - nu_raw = nu_block.multiplicity(idx) - if not isinstance(nu_raw, int): - print_error(f"Non-integer multiplicity for MT-{MT:03} in {ace_path}") - - nu = nu_raw - 100 if nu_raw >= 100 else nu_raw - - if nu == 0: - capture_MTs.append(MT) - elif nu > 0: - nonelastic_MTs.append(MT) - else: - print_error(f"Negative decoded multiplicity for MT-{MT:03} in {ace_path}") - - # Create MT subgroups - for rx_group, rx_MTs in [ - (elastic_group, elastic_MTs), - (capture_group, capture_MTs), - (nonelastic_group, nonelastic_MTs), - (fission_group, fission_MTs), - ]: - for MT in rx_MTs: - g = rx_group.create_group(f"MT-{MT:03}") - g.attrs["MT"] = MT - - if verbose: - print(f" Elastic: {elastic_MTs}") - print(f" Capture: {capture_MTs}") - print(f" Nonelastic: {nonelastic_MTs}") - if fissionable: - print(f" Fission: {fission_MTs}") - - # Remove empty groups - if not fissionable: - del file["proton_reactions/fission"] - if len(nonelastic_MTs) == 0: - del file["proton_reactions/nonelastic_reaction"] - - # ── Cross sections ──────────────────────────────────────────────────────── - xs0_block = ace_table.principal_cross_section_block - xs_block_main = ace_table.cross_section_block - - xs_energy = np.array(xs0_block.energies) - ds = proton_reactions.create_dataset("xs_energy_grid", data=xs_energy) - ds.attrs["unit"] = "MeV" - - xs_ds = elastic_group.create_dataset("MT-002/xs", data=np.array(xs0_block.elastic)) - xs_ds.attrs["offset"] = 0 - xs_ds.attrs["unit"] = "barns" - - for MTs, group in [ - (capture_MTs, capture_group), - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: - if group is None: - continue - for MT in MTs: - idx = rx_block.index(MT) - xs_ds = group.create_dataset( - f"MT-{MT:03}/xs", - data=np.array(xs_block_main.cross_sections(idx)) - ) - xs_ds.attrs["offset"] = xs_block_main.energy_index(idx) - 1 - xs_ds.attrs["unit"] = "barns" - - # ── Q-values ────────────────────────────────────────────────────────────── - q_block = ace_table.reaction_qvalue_block - - elastic_group.create_dataset("MT-002/Q-value", data=0.0).attrs["unit"] = "MeV" - - for MTs, group in [ - (capture_MTs, capture_group), - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: - if group is None: - continue - for MT in MTs: - idx = rx_block.index(MT) - ds = group.create_dataset( - f"MT-{MT:03}/Q-value", data=q_block.q_value(idx) - ) - ds.attrs["unit"] = "MeV" - - # ── Reference frames ────────────────────────────────────────────────────── - elastic_group.create_dataset("MT-002/reference_frame", data="COM") - - for MTs, group in [ - (capture_MTs, capture_group), - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: - if group is None: - continue - for MT in MTs: - idx = rx_block.index(MT) - rf = nu_block.reference_frame(idx) - rf_str = ( - "LAB" if rf == ACEtk.ReferenceFrame.Laboratory else - "COM" if rf == ACEtk.ReferenceFrame.CentreOfMass else - str(rf) - ) - group.create_dataset(f"MT-{MT:03}/reference_frame", data=rf_str) - - # ── Nonelastic reaction multiplicities ───────────────────────────────────── - for MT in nonelastic_MTs: - idx = rx_block.index(MT) - nu_raw = nu_block.multiplicity(idx) - nu = nu_raw - 100 if nu_raw >= 100 else nu_raw - nonelastic_group.create_dataset(f"MT-{MT:03}/multiplicity", data=nu) - - # ── Angular distributions ───────────────────────────────────────────────── - angle_block = ace_table.angular_distribution_block - - ang_group = elastic_group.create_group("MT-002/angular_cosine_distribution") - ang_group.attrs["type"] = "energy-correlated" - data = angle_block.angular_distribution_data(0) - written = load_cosine_distribution(data, ang_group) - if not written and verbose: - print_note("MT-002 elastic angular distribution is given in energy block") - - for MTs, group in [ - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: - if group is None: - continue - for MT in MTs: - idx = rx_block.index(MT) - ang_group = group.create_group(f"MT-{MT:03}/angular_cosine_distribution") - data = angle_block.angular_distribution_data(idx) - written = load_cosine_distribution(data, ang_group) - if not written and verbose: - print_note(f"MT-{MT:03} angular distribution is given in energy block") - - # ── Primary energy distributions ────────────────────────────────────────── - energy_block = ace_table.energy_distribution_block - - for MTs, group in [ - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: - if group is None: - continue - for MT in MTs: - idx = rx_block.index(MT) - data = energy_block.energy_distribution_data(idx) - - if not isinstance(data, ACEtk.continuous.MultiDistributionData): - eg = group.create_group(f"MT-{MT:03}/energy_spectrum-1") - group.create_dataset( - f"MT-{MT:03}/spectrum_probability_grid", - data=np.array([0.0, 30.0]) - ).attrs["unit"] = "MeV" - group.create_dataset( - f"MT-{MT:03}/spectrum_probability", - data=np.array([[1.0]]) - ) - load_energy_distribution(data, eg) - else: - N_dist = data.number_distributions - # Probability grid - if all(np.array([x.number_interpolation_regions - for x in data.probabilities]) == 0): - prob_grid = np.array([0.0, 30.0]) - prob = np.zeros((1, N_dist)) - for k in range(N_dist): - prob[0, k] = max(data.probability(k + 1).probabilities) - elif (all(np.array([x.number_interpolation_regions - for x in data.probabilities]) == 1) - and all(np.array([x.interpolants - for x in data.probabilities]) == 1)): - prob_grid = np.array(data.probability(1).energies) - prob = np.zeros((len(prob_grid) - 1, N_dist)) - for k in range(N_dist): - prob[:, k] = np.array( - data.probability(k + 1).probabilities[:-1] - ) - else: - print_error(f"Unsupported multi-distribution probability for " - f"MT-{MT:03} in {ace_path}") - - group.create_dataset( - f"MT-{MT:03}/spectrum_probability_grid", data=prob_grid - ).attrs["unit"] = "MeV" - group.create_dataset( - f"MT-{MT:03}/spectrum_probability", data=prob - ) - for k in range(N_dist): - eg = group.create_group(f"MT-{MT:03}/energy_spectrum-{k+1}") - load_energy_distribution(data.distribution(k + 1), eg) - - # ── Secondary particles ─────────────────────────────────────────────────── - load_secondary_particles(ace_table, file, verbose=verbose) - - # ── Fission data (if applicable) ────────────────────────────────────────── - if fissionable: - prompt_block = ace_table.fission_multiplicity_block - delayed_block = ace_table.delayed_fission_multiplicity_block - dnp_block = ace_table.delayed_neutron_precursor_block - - h5g = fission_group.create_group("prompt_multiplicity") - load_fission_multiplicity(prompt_block.multiplicity, h5g) - - if delayed_block is not None: - h5g = fission_group.create_group("delayed_multiplicity") - load_fission_multiplicity(delayed_block.multiplicity, h5g) - - if dnp_block is not None: - N_DNP = dnp_block.number_delayed_precursors - fractions = np.zeros(N_DNP) - decay_rates = np.zeros(N_DNP) - for k in range(N_DNP): - d = dnp_block.precursor_group_data(k + 1) - if (d.number_interpolation_regions != 0 - or len(d.probabilities[:]) != 2 - or d.probabilities[0] != d.probabilities[1]): - print_error("Non-constant delayed neutron precursor fraction") - fractions[k] = d.probabilities[0] - decay_rates[k] = d.decay_constant - - prec = fission_group.create_group("delayed_neutron_precursors") - prec.create_dataset("fractions", data=fractions) - dr_ds = prec.create_dataset("decay_rates", data=decay_rates) - dr_ds.attrs["unit"] = "/s" - - delayed_spectrum_block = ace_table.delayed_neutron_energy_distribution_block - for k in range(N_DNP): - d = delayed_spectrum_block.energy_distribution_data(k + 1) - eg = prec.create_group(f"energy_spectrum-{k+1}") - load_energy_distribution(d, eg) - - file.close() - return mcdc_name - - -# ────────────────────────────────────────────────────────────────────────────── -# Main -# ────────────────────────────────────────────────────────────────────────────── - -def main(): - parser = argparse.ArgumentParser( - description="Convert proton ACE files to MC/DC-compatible HDF5" - ) - parser.add_argument("--ace_dir", default=os.getenv("MCDC_ACELIB"), - help="Directory containing ACE files " - "(default: $MCDC_ACELIB)") - parser.add_argument("--output_dir", default=os.getenv("MCDC_LIB"), - help="Output directory for HDF5 files " - "(default: $MCDC_LIB)") - parser.add_argument("--rewrite", action="store_true", default=False, - help="Rewrite existing HDF5 files") - parser.add_argument("--verbose", action="store_true", default=False, - help="Print detailed per-reaction info") - args = parser.parse_args() - - if args.ace_dir is None: - print_error("No ACE directory specified. Use --ace_dir or set $MCDC_ACELIB.") - if args.output_dir is None: - print_error("No output directory specified. Use --output_dir or set $MCDC_LIB.") - - os.makedirs(args.output_dir, exist_ok=True) - print(f"\nACE directory : {args.ace_dir}") - print(f"Output directory: {args.output_dir}\n") - - all_files = sorted(os.listdir(args.ace_dir)) - - # Filter to only unprocessed files unless --rewrite - if args.rewrite: - target_files = all_files - else: - target_files = [] - for fname in all_files: - ace_path = os.path.join(args.ace_dir, fname) - try: - with open(ace_path, "r") as f: - hdr = ACEtk.Header.from_string(f.readline()) - Z, A, S, _ = decode_ace_zaid(hdr.zaid) - symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") - nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" - # We don't know T yet without loading the full table, so check - # for any existing file matching the nuclide name pattern. - existing = [ - f for f in os.listdir(args.output_dir) - if f.startswith(nuclide_name + "-") - ] - if not existing: - target_files.append(fname) - except Exception: - target_files.append(fname) # include if we can't read header - - errors = [] - pbar = tqdm( - target_files, - disable=args.verbose, - bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} {postfix}", - ) - - for ace_name in pbar: - ace_path = os.path.join(args.ace_dir, ace_name) - pbar.set_postfix_str(ace_name) - try: - out = process_ace_file(ace_path, args.output_dir, verbose=args.verbose) - if args.verbose: - print(f" → wrote {out}") - except Exception as exc: - errors.append((ace_name, str(exc))) - if args.verbose: - import traceback - traceback.print_exc() - - print(f"\nDone. {len(target_files) - len(errors)} succeeded, " - f"{len(errors)} failed.") - if errors: - print("\nFailed files:") - for name, msg in errors: - print(f" {name}: {msg}") - - -if __name__ == "__main__": - main() \ No newline at end of file From 519a6b317685ee9ca562e31674288eb103e3383f Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 3 Jun 2026 14:16:53 -0700 Subject: [PATCH 46/64] cleaning up tools/data_library_generator --- .../endf70prot_generate.py | 484 ------------------ .../parse_endf70prot.py | 34 -- 2 files changed, 518 deletions(-) delete mode 100644 tools/data_library_generator/endf70prot_generate.py delete mode 100644 tools/data_library_generator/parse_endf70prot.py diff --git a/tools/data_library_generator/endf70prot_generate.py b/tools/data_library_generator/endf70prot_generate.py deleted file mode 100644 index 90058bf5c..000000000 --- a/tools/data_library_generator/endf70prot_generate.py +++ /dev/null @@ -1,484 +0,0 @@ -import argparse -import h5py -import numpy as np -import os -import ACEtk - -from tqdm import tqdm - -#### - -import util -from util import print_error, print_note - -parser = argparse.ArgumentParser(description="MC/DC data generator") -parser.add_argument("--rewrite", dest="rewrite", action="store_true", default=False) -parser.add_argument("--verbose", dest="verbose", action="store_true", default=False) -args, unargs = parser.parse_known_args() -rewrite = args.rewrite -verbose = args.verbose - -# Directories -output_dir = os.getenv("MCDC_LIB") -ace_dir = os.getenv("MCDC_ACELIB") - -if output_dir is None: - print_error("Environment variable $MCDC_LIB is not set") -if ace_dir is None: - print_error("Environment variable $MCDC_ACELIB is not set") - -# Create output directory if needed -os.makedirs(output_dir, exist_ok=True) -print(f"\nACE directory: {ace_dir}") -print(f"Output directory: {output_dir}\n") - -# Select the files -if rewrite: - target_files = os.listdir(ace_dir) -else: - target_files = [] - for file_name in os.listdir(ace_dir): - # File header - with open(f"{ace_dir}/{file_name}", "r") as f: - header = ACEtk.Header.from_string(f.readline()) - - # Decode ACE name to MC/DC name - Z, A, S, T = util.decode_ace_name(header.zaid) - symbol = util.Z_TO_SYMBOL[Z] - nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" - mcdc_name = f"{nuclide_name}-{T}K.h5" - - if not os.path.exists(f"{output_dir}/{mcdc_name}"): - target_files.append(file_name) - -# Loop over all files -pbar = tqdm( - target_files, - disable=verbose, - bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}{postfix}", -) -for ace_name in pbar: - # File header - with open(f"{ace_dir}/{ace_name}", "r") as f: - header = ACEtk.Header.from_string(f.readline()) - - # Decode ACE name to MC/DC name - Z, A, S, T = util.decode_ace_name(header.zaid) - symbol = util.Z_TO_SYMBOL[Z] - nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" - mcdc_name = f"{nuclide_name}-{T}K.h5" - - if not rewrite and os.path.exists(f"{output_dir}/{mcdc_name}"): - continue - - # Create MC/DC file - if verbose: - print("\n" + "=" * 80 + "\n") - print(f"Create {mcdc_name} from {ace_name}\n") - pbar.set_postfix_str(f"{mcdc_name[:-3]} from {ace_name}") - file = h5py.File(f"{output_dir}/{mcdc_name}", "w") - - # ================================================================================== - # Basic properties - # ================================================================================== - - # Load ACE tables - ace_table = ACEtk.ContinuousEnergyTable.from_file(f"{ace_dir}/{ace_name}") - - # ACE data source description - header = ace_table.header - file.attrs["source_title"] = header.title - file.attrs["source_version"] = header.version - file.attrs["source_date"] = header.date - if "comments" in dir(header): - file.attrs["source_comments"] = header.comments - - # Name and excitation level - file.create_dataset("nuclide_name", data=nuclide_name) - file.create_dataset("excitation_level", data=S) - - # Temperature - temperature = file.create_dataset("temperature", data=T) - temperature.attrs["unit"] = "K" - - # Atomic number and weight ratio - atomic_number = ace_table.atom_number - atomic_weight_ratio = ace_table.atomic_weight_ratio - file.create_dataset("atomic_number", data=atomic_number) - file.create_dataset("atomic_weight_ratio", data=atomic_weight_ratio) - - # Fissionable? - fissionable = ace_table.fission_multiplicity_block is not None - file.create_dataset("fissionable", data=fissionable) - - # ================================================================================== - # Reaction groups - # ================================================================================== - # Elastic scattering: MT=2 - # Capture: Reactions with zero multiplicity - # Fission: MT=18 or MT=(19, 20, 21, and 38) if given - # Inelastic: Non-fission reactions with non-zero multiplicity - # Ignored: MT=(1, 3, 4, 10) and MT>117 - - proton_reactions = file.create_group("proton_reactions") - - # ACE blocks - nu_block = ace_table.frame_and_multiplicity_block - rx_block = ace_table.reaction_number_block - N_reaction = nu_block.number_reactions - - if nu_block.number_reactions != rx_block.number_reactions: - print_error("Non-equal reaction number in reaction and multiplicity blocks") - - # The groups - elastic_group = proton_reactions.create_group("elastic_scattering") - capture_group = proton_reactions.create_group("capture") - inelastic_group = proton_reactions.create_group("inelastic_scattering") - fission_group = proton_reactions.create_group("fission") - - # MT groups - elastic_MTs = [2] - capture_MTs = [] - inelastic_MTs = [] - fission_MTs = [] - - # Redundant MTs - fission_chance_MTs = [19, 20, 21, 38] - redundant_MTs = [1, 3, 4, 10] - - # Set fission MTs - total_fission_given = rx_block.has_MT(18) - if total_fission_given: - fission_MTs = [18] - # The component should not be given - for MT in fission_chance_MTs: - if rx_block.has_MT(MT): - print_error("Both total fission and its components are given") - else: - for MT in fission_chance_MTs: - if rx_block.has_MT(MT): - fission_MTs.append(MT) - - # Capture and inelastic MTs - for i in range(N_reaction): - idx = i + 1 - MT = rx_block.MT(idx) - - if MT in redundant_MTs + elastic_MTs + fission_MTs or MT > 117: - continue - - nu = nu_block.multiplicity(idx) - - if type(nu) != int: - print_error(f"Non-integer multiplicity for inelastic scattering") - - if nu == 0: - capture_MTs.append(MT) - elif nu > 0: - inelastic_MTs.append(MT) - else: - print_error(f"Negative multiplicity for MT-{MT:03}") - - # Create MTs - for rx_group, rx_MTs in [ - (elastic_group, elastic_MTs), - (capture_group, capture_MTs), - (inelastic_group, inelastic_MTs), - (fission_group, fission_MTs), - ]: - for MT in rx_MTs: - MT_group = rx_group.create_group(f"MT-{MT:03}") - MT_group.attrs["MT"] = MT - - # Report MT groups - if verbose: - print(f" Reaction group MTs") - print(f" - Elastic scattering MTs: {elastic_MTs}") - print(f" - Capture MTs: {capture_MTs}") - print(f" - Inelastic scattering MTs: {inelastic_MTs}") - if fissionable: - print(f" - Fission MT: {fission_MTs}") - - # Delete empty groups - if not fissionable: - del file["proton_reactions/fission"] - if len(inelastic_MTs) == 0: - del file["proton_reactions/inelastic_scattering"] - - # ================================================================================== - # Cross-sections - # ================================================================================== - - xs0_block = ace_table.principal_cross_section_block - xs_block = ace_table.cross_section_block - - xs_energy = xs0_block.energies - xs_elastic = xs0_block.elastic - cross_sections = xs_block.cross_sections - offsets = xs_block.energy_index - - # Energy grid - xs_energy = np.array(xs_energy) - dataset = proton_reactions.create_dataset("xs_energy_grid", data=xs_energy) - dataset.attrs["unit"] = "MeV" - - # Elastic scattering - xs = elastic_group.create_dataset("MT-002/xs", data=xs_elastic) - xs.attrs["offset"] = 0 - xs.attrs["unit"] = "barns" - - # Capture, inelastic scattering, and fission - for MTs, group in [ - (capture_MTs, capture_group), - (inelastic_MTs, inelastic_group), - (fission_MTs, fission_group), - ]: - for MT in MTs: - idx = rx_block.index(MT) - xs = group.create_dataset(f"MT-{MT:03}/xs", data=cross_sections(idx)) - xs.attrs["offset"] = offsets(idx) - 1 - xs.attrs["unit"] = "barns" - - # ================================================================================== - # Q-value - # ================================================================================== - - q_value_block = ace_table.reaction_qvalue_block - - # Elastic scattering: zero Q-value - for MT in elastic_MTs: - dataset = elastic_group.create_dataset(f"MT-{MT:03}/Q-value", data=0.0) - dataset.attrs["unit"] = "MeV" - - for MTs, group in [ - (capture_MTs, capture_group), - (inelastic_MTs, inelastic_group), - (fission_MTs, fission_group), - ]: - for MT in MTs: - idx = rx_block.index(MT) - dataset = group.create_dataset( - f"MT-{MT:03}/Q-value", data=q_value_block.q_value(idx) - ) - dataset.attrs["unit"] = "MeV" - - # ================================================================================== - # Reference frames and inelastic scattering multiplicities - # ================================================================================== - # Elastic is always in COM frame (per ACE standard) - - # Elastic scattering reference frame - for MT in elastic_MTs: - elastic_group.create_dataset(f"MT-{MT:03}/reference_frame", data="COM") - - # Reference frames of the others - for MTs, group in [ - (capture_MTs, capture_group), - (inelastic_MTs, inelastic_group), - (fission_MTs, fission_group), - ]: - for MT in MTs: - idx = rx_block.index(MT) - reference_frame = nu_block.reference_frame(idx) - if reference_frame == ACEtk.ReferenceFrame.Laboratory: - reference_frame = "LAB" - elif reference_frame == ACEtk.ReferenceFrame.CentreOfMass: - reference_frame = "COM" - else: - print_error(f"Unknown reaction reference frame type for MT-{MT:03}") - group.create_dataset(f"MT-{MT:03}/reference_frame", data=reference_frame) - - # Inelastic multiplicity - for MT in inelastic_MTs: - idx = rx_block.index(MT) - nu = nu_block.multiplicity(idx) - inelastic_group.create_dataset(f"MT-{MT:03}/multiplicity", data=nu) - - # ================================================================================== - # Angular distributions - # ================================================================================== - - angle_block = ace_table.angular_distribution_block - - # Elastic scattering - angle_group = elastic_group.create_group("MT-002/angular_cosine_distribution") - data = angle_block.angular_distribution_data(0) - for subdata in data.distributions: - if not isinstance(subdata, ACEtk.continuous.TabulatedAngularDistribution): - print_error("Unsupported elastic scattering angular distribution") - util.load_cosine_distribution(data, angle_group) - - # Inelastic scattering and fission - for MTs, group in [ - (inelastic_MTs, inelastic_group), - (fission_MTs, fission_group), - ]: - for MT in MTs: - idx = rx_block.index(MT) - angle_group = group.create_group(f"MT-{MT:03}/angular_cosine_distribution") - data = angle_block.angular_distribution_data(idx) - util.load_cosine_distribution(data, angle_group) - - # ================================================================================== - # Energy distributions - # ================================================================================== - - energy_block = ace_table.energy_distribution_block - - for MTs, group in [ - (inelastic_MTs, inelastic_group), - (fission_MTs, fission_group), - ]: - for MT in MTs: - idx = rx_block.index(MT) - data = energy_block.energy_distribution_data(idx) - - if not isinstance(data, ACEtk.continuous.MultiDistributionData): - # Probabilities - dataset = group.create_dataset( - f"MT-{MT:03}/spectrum_probability_grid", data=np.array([0.0, 30.0]) - ) - dataset.attrs["unit"] = "MeV" - dataset = group.create_dataset( - f"MT-{MT:03}/spectrum_probability", data=np.array([[1.0]]) - ) - - # The distributions - energy_group = group.create_group(f"MT-{MT:03}/energy_spectrum-1") - util.load_energy_distribution(data, energy_group) - - else: - N_dist = data.number_distributions - - # ====================================================================== - # Probabilities - # ====================================================================== - - # Constant probability - if all( - np.array( - [x.number_interpolation_regions for x in data.probabilities] - ) - == 0 - ): - probability_grid = np.array([0.0, 30.0]) - probability = np.zeros((1, N_dist)) - for i in range(N_dist): - probability[0, i] = max(data.probability(i + 1).probabilities) - - # Histogram probability - elif all( - np.array( - [x.number_interpolation_regions for x in data.probabilities] - ) - == 1 - ) and all(np.array([x.interpolants for x in data.probabilities]) == 1): - probability_grid = np.array(data.probability(1).energies) - probability = np.zeros((len(probability_grid) - 1, N_dist)) - for i in range(N_dist): - if not all( - probability_grid - == np.array(data.probability(i + 1).energies) - ): - print_error("Unsupported multi-distribution energy spetrum") - probability[:, i] = np.array( - data.probability(i + 1).probabilities[:-1] - ) - - else: - print_error("Unsupported multi-distribution energy spetrum") - - dataset = group.create_dataset( - f"MT-{MT:03}/spectrum_probability_grid", data=probability_grid - ) - dataset.attrs["unit"] = "MeV" - dataset = group.create_dataset( - f"MT-{MT:03}/spectrum_probability", data=probability - ) - - # ====================================================================== - # The disributions - # ====================================================================== - - for i in range(N_dist): - energy_group = group.create_group( - f"MT-{MT:03}/energy_spectrum-{i+1}" - ) - distribution = data.distribution(i + 1) - util.load_energy_distribution(distribution, energy_group) - - # Fissionable zone below - if not fissionable: - continue - - # ================================================================================== - # Fission multiplicities and delayed neutron precursor fractions and decay rates - # ================================================================================== - - prompt_block = ace_table.fission_multiplicity_block - delayed_block = ace_table.delayed_fission_multiplicity_block - dnp_block = ace_table.delayed_neutron_precursor_block - - # Prompt multiplicity - data = prompt_block.multiplicity - h5_group = fission_group.create_group("prompt_multiplicity") - util.load_fission_multiplicity(data, h5_group) - - # Delayed multiplicity - if delayed_block is not None: - data = delayed_block.multiplicity - h5_group = fission_group.create_group("delayed_multiplicity") - util.load_fission_multiplicity(data, h5_group) - - # Delayed neutron precursor fractions and decay rates - if dnp_block is not None: - N_DNP = dnp_block.number_delayed_precursors - fractions = np.zeros(N_DNP) - decay_rates = np.zeros(N_DNP) - - for i in range(N_DNP): - idx = 1 + 1 - data = dnp_block.precursor_group_data(idx) - - if ( - not data.number_interpolation_regions == 0 - or not len(data.probabilities[:]) == 2 - or not data.probabilities[0] == data.probabilities[1] - ): - print_error("Non-constant delayed neutron precursor fraction") - - fractions[i] = data.probabilities[0] - decay_rates[i] = data.decay_constant - - precursors = fission_group.create_group("delayed_neutron_precursors") - precursors.create_dataset("fractions", data=fractions) - decay_rates = precursors.create_dataset("decay_rates", data=decay_rates) - decay_rates.attrs["unit"] = "/s" - - # ================================================================================== - # Delayed fission spectra - # ================================================================================== - - delayed_spectrum_block = ace_table.delayed_neutron_energy_distribution_block - if dnp_block is not None: - N_DNP = dnp_block.number_delayed_precursors - - for i in range(N_DNP): - idx = 1 + 1 - data = delayed_spectrum_block.energy_distribution_data(idx) - - if not isinstance(data, ACEtk.continuous.OutgoingEnergyDistributionData): - print_error(f"Unsupported delayed fission neutron spectrum: {data}") - - energy_group = fission_group.create_group( - f"delayed_neutron_precursors/energy_spectrum-{i+1}" - ) - util.load_energy_distribution(data, energy_group) - - # ================================================================================== - # Finalize - # ================================================================================== - - file.close() - -print("") diff --git a/tools/data_library_generator/parse_endf70prot.py b/tools/data_library_generator/parse_endf70prot.py deleted file mode 100644 index da79cff2b..000000000 --- a/tools/data_library_generator/parse_endf70prot.py +++ /dev/null @@ -1,34 +0,0 @@ -# This script was written by ChatGPT with Ethan Lame's instructions -import os - -input_file = "/home/ethan_lame/MCDC/acelib/endf70prot" # your big file -output_dir = "/home/ethan_lame/MCDC/acelib/" # where split files go - -os.makedirs(output_dir, exist_ok=True) - -current_file = None - -with open(input_file, "r") as f: - for line in f: - # Check for start of new isotope block - if ".70h" in line: - # Close previous file if open - if current_file is not None: - current_file.close() - - # Extract filename (first token) - filename = line.strip().split()[0] - - # Open new file - filepath = os.path.join(output_dir, filename) - current_file = open(filepath, "w") - - print(f"Creating {filename}") - - # Write line if a file is open - if current_file is not None: - current_file.write(line) - -# Close last file -if current_file is not None: - current_file.close() From 25e9dc83a839bddea0f731a83eb45ce0a3b3bc71 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 4 Jun 2026 11:13:25 -0700 Subject: [PATCH 47/64] remove proton secondary particle channel, for now --- mcdc/object_/nuclide.py | 30 ----------- mcdc/object_/proton_reaction.py | 96 +-------------------------------- 2 files changed, 1 insertion(+), 125 deletions(-) diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index efca72aec..f33cd6e0f 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -21,7 +21,6 @@ from mcdc.object_.proton_reaction import ( ProtonReactionElasticScattering, ProtonReactionNonelasticReaction, - ProtonSecondaryChannel, set_energy_distribution, ) from mcdc.object_.simulation import simulation @@ -63,8 +62,6 @@ class Nuclide(ObjectNonSingleton): # proton_elastic_scattering_reactions: list[ProtonReactionElasticScattering] proton_nonelastic_reactions: list[ProtonReactionNonelasticReaction] - proton_secondary_channels: dict[int, list[ProtonSecondaryChannel]] - non_numba: list[str] = ["proton_secondary_channels"] # neutron_fission_prompt_multiplicity: DataBase neutron_fission_delayed_multiplicity: DataBase @@ -339,33 +336,6 @@ def set_proton_data(self): self.stopping_power = file["stopping_power"]["total_stopping_power"][()] self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] - # # ========================================================================== - # # Secondary particles - # # ========================================================================== - - # self.proton_secondary_channels = {} - # if "secondary_particles" in file: - # sec_group = file["secondary_particles"] - # for zap_name in sec_group.keys(): - # if not zap_name.startswith("ZAP_"): - # continue - # zap = int(zap_name.split("_")[1]) - # zap_group = sec_group[zap_name] - - # # Iterate over MT numbers for this secondary particle type - # for mt_name in zap_group.keys(): - # if not mt_name.startswith("MT-"): - # continue - # MT = int(mt_name.split("-")[1]) - # mt_group = zap_group[mt_name] - - # # Load secondary channel - # channel = ProtonSecondaryChannel.from_h5_group(mt_group, zap) - - # if MT not in self.proton_secondary_channels: - # self.proton_secondary_channels[MT] = [] - # self.proton_secondary_channels[MT].append(channel) - file.close() ## TODO: UPDATE this to handle protons as well as neutrons diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py index b1f132bd5..2cf97cf13 100644 --- a/mcdc/object_/proton_reaction.py +++ b/mcdc/object_/proton_reaction.py @@ -372,98 +372,4 @@ def set_energy_distribution(h5_group): else: print_error(f"Unsupported energy spectrum of type {spectrum_type}") - return energy_spectrum - - -# ====================================================================================== -# Proton secondary particle channel -# ====================================================================================== - - -class ProtonSecondaryChannel(ObjectPolymorphic): - """ - Data container for a proton secondary particle channel. - Plain helper object. - """ - - particle_type: int - MT: int - multiplicity: float64 # Multiplicity of particles produced per reaction - production_xs: NDArray[float64] - production_xs_offset_: int - reference_frame: int # COM or LAB - energy_spectrum: DistributionBase - - def __init__( - self, - particle_type, - MT, - multiplicity, - production_xs, - production_xs_offset, - reference_frame, - energy_spectrum, - ): - self.particle_type = particle_type - self.MT = MT - self.multiplicity = multiplicity - self.production_xs = production_xs - self.production_xs_offset_ = production_xs_offset - self.reference_frame = reference_frame - self.energy_spectrum = energy_spectrum - super().__init__(type_=0, register=False) - - @classmethod - def from_h5_group(cls, h5_group, zap): - """ - Load a secondary particle channel from HDF5 group. - zap: ZAP code (1=neutron, 31=proton, etc.) - """ - if zap not in ZAP_TO_PARTICLE: - raise ValueError(f"zap {zap} not in ZAP_TO_PARTICLE") - particle_type = ZAP_TO_PARTICLE.get(zap) - MT = h5_group.attrs["MT"] - multiplicity = h5_group.attrs["multiplicity"] - - reference_frame_str = h5_group.attrs["reference_frame"] - if reference_frame_str == "LAB": - reference_frame = REFERENCE_FRAME_LAB - elif reference_frame_str == "COM": - reference_frame = REFERENCE_FRAME_COM - else: - reference_frame = REFERENCE_FRAME_COM # default - - # Production cross section (optional) - if "production_xs" in h5_group: - production_xs = h5_group["production_xs"][()] - production_xs_offset = h5_group["production_xs"].attrs["offset"] - else: - production_xs = np.zeros(0, dtype=float) - production_xs_offset = 0 - - # Energy spectrum (currently assume Kalbach-Mann) - energy_spectrum = set_energy_distribution(h5_group["kalbach_mann"]) - - return cls( - particle_type, - MT, - multiplicity, - production_xs, - production_xs_offset, - reference_frame, - energy_spectrum, - ) - - def __repr__(self): - particle_name = ( - "Neutron" if self.particle_type == PARTICLE_NEUTRON else "Proton" - ) - text = "\n" - text += f"Proton secondary channel ({particle_name})\n" - text += f" - ID: {self.ID}\n" - text += f" - MT: {self.MT}\n" - text += f" - Multiplicity: {self.multiplicity}\n" - text += f" - Production XS: {print_1d_array(self.production_xs)} barn\n" - text += f" - Reference frame: {decode_reference_frame(self.reference_frame)}\n" - text += f" - Energy spectrum: {distribution.decode_type(self.energy_spectrum.type)} [ID: {self.energy_spectrum.ID}]\n" - return text + return energy_spectrum \ No newline at end of file From 40ac721e2aac30e8d5d1984e94ede441068fa540 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 4 Jun 2026 11:23:05 -0700 Subject: [PATCH 48/64] updating documentation --- mcdc/object_/nuclide.py | 3 + mcdc/object_/proton_reaction.py | 2 +- mcdc/transport/physics/interface.py | 55 ++--- mcdc/transport/physics/proton/interface.py | 5 + mcdc/transport/physics/proton/multigroup.py | 206 ------------------ mcdc/transport/physics/proton/native.py | 5 + .../proton_ace_to_hdf5.py | 2 +- 7 files changed, 45 insertions(+), 233 deletions(-) diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index f33cd6e0f..4203ffbfd 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -266,6 +266,9 @@ def set_proton_data(self): file_name = f"{nuclide_name}-{temperature}K.h5" file = h5py.File(f"{dir_name}/{file_name}", "r") + # TENDL data only handles elastic scattering rxns as a unique rxn. + # Everything else is grouped together, including nonelastic rxns + # and rxns that will produce secondary particles. rx_names = [ "elastic_scattering", "nonelastic_reaction", diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py index 2cf97cf13..99c3b6c96 100644 --- a/mcdc/object_/proton_reaction.py +++ b/mcdc/object_/proton_reaction.py @@ -372,4 +372,4 @@ def set_energy_distribution(h5_group): else: print_error(f"Unsupported energy spectrum of type {spectrum_type}") - return energy_spectrum \ No newline at end of file + return energy_spectrum diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index 3a1abea20..65a0cd5d8 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -58,31 +58,6 @@ def neutron_production_xs(reaction_type, particle_container, simulation, data): return -1.0 -@njit -def csda_distance(particle_container, simulation, data): - particle = particle_container[0] - material = simulation["native_materials"][particle["material_ID"]] - E = particle["E"] - total_rho = 0.0 - total_dedx = 0.0 - - for i in range(material["N_nuclide"]): - nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) - nuclide = simulation["nuclides"][nuclide_ID] - dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) - dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) - dedx = np.interp(E / 1e6, dedx_energies, dedx_values) - total_dedx += dedx * 1e6 - - atomic_mass = nuclide["atomic_weight_ratio"] - nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) - density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) - total_rho += density_gcm3 - - max_fractional_e_loss = simulation["settings"]["csda_max_fractional_e_loss"] - return max_fractional_e_loss * E / total_dedx / total_rho - - # ====================================================================================== # Collision # ====================================================================================== @@ -123,6 +98,36 @@ def collision(particle_container, collision_data_container, program, data): proton.collision(particle_container, collision_data_container, program, data) +# ====================================================================================== +# Continuous Slowing Down Approximation +# ====================================================================================== + + +@njit +def csda_distance(particle_container, simulation, data): + particle = particle_container[0] + material = simulation["native_materials"][particle["material_ID"]] + E = particle["E"] + total_rho = 0.0 + total_dedx = 0.0 + + for i in range(material["N_nuclide"]): + nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + nuclide = simulation["nuclides"][nuclide_ID] + dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) + dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) + dedx = np.interp(E / 1e6, dedx_energies, dedx_values) + total_dedx += dedx * 1e6 + + atomic_mass = nuclide["atomic_weight_ratio"] + nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) + total_rho += density_gcm3 + + max_fractional_e_loss = simulation["settings"]["csda_max_fractional_e_loss"] + return max_fractional_e_loss * E / total_dedx / total_rho + + @njit def csda_edep(particle_container, collision_data_container, distance, simulation, data): particle = particle_container[0] diff --git a/mcdc/transport/physics/proton/interface.py b/mcdc/transport/physics/proton/interface.py index 9119d4650..34b81c01f 100644 --- a/mcdc/transport/physics/proton/interface.py +++ b/mcdc/transport/physics/proton/interface.py @@ -37,6 +37,11 @@ def collision(particle_container, collision_data_container, program, data): native.collision(particle_container, collision_data_container, program, data) +# ====================================================================================== +# Continuous Slowing Down Approximation +# ====================================================================================== + + @njit def csda_edep(particle_container, collision_data_container, distance, simulation, data): native.csda_edep( diff --git a/mcdc/transport/physics/proton/multigroup.py b/mcdc/transport/physics/proton/multigroup.py index 7c4a4612e..b48e811a3 100644 --- a/mcdc/transport/physics/proton/multigroup.py +++ b/mcdc/transport/physics/proton/multigroup.py @@ -51,59 +51,6 @@ def macro_xs(reaction_type, particle_container, simulation, data): return 0.0 -# @njit -# def proton_production_xs(reaction_type, particle_container, simulation, data): -# particle = particle_container[0] -# material = simulation["multigroup_materials"][particle["material_ID"]] -# g = particle["g"] - -# # Total production -# if reaction_type == PROTON_REACTION_TOTAL: -# total = 0.0 - -# # Scattering production -# nu = mcdc_get.multigroup_material.mgxs_nu_s(g, material, data) -# xs = mcdc_get.multigroup_material.mgxs_scatter(g, material, data) -# total += nu * xs - -# # Fission production -# nu = mcdc_get.multigroup_material.mgxs_nu_f(g, material, data) -# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) -# total += nu * xs -# return total - -# # Capture production (none) -# elif reaction_type == PROTON_REACTION_CAPTURE: -# return 0.0 - -# # Scattering production -# elif reaction_type == NEUTRON_REACTION_ELASTIC_SCATTERING: -# nu = mcdc_get.multigroup_material.mgxs_nu_s(g, material, data) -# xs = mcdc_get.multigroup_material.mgxs_scatter(g, material, data) -# return nu * xs - -# # Fission production -# elif reaction_type == NEUTRON_REACTION_FISSION: -# nu = mcdc_get.multigroup_material.mgxs_nu_f(g, material, data) -# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) -# return nu * xs - -# # Prompt fission production -# elif reaction_type == NEUTRON_REACTION_FISSION_PROMPT: -# nu = mcdc_get.multigroup_material.mgxs_nu_p(g, material, data) -# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) -# return nu * xs - -# # Delayed neutron production -# elif reaction_type == NEUTRON_REACTION_FISSION_DELAYED: -# nu = mcdc_get.multigroup_material.mgxs_nu_d_total(g, material, data) -# xs = mcdc_get.multigroup_material.mgxs_fission(g, material, data) -# return nu * xs - -# # Unsupported default -# return 0.0 - - # ====================================================================================== # Collision # ====================================================================================== @@ -218,156 +165,3 @@ def scattering(particle_container, program, data): particle["w"] = particle_new["w"] else: particle_bank_module.bank_active_particle(particle_container_new, program) - - -# @njit -# def fission(particle_container, program, data): -# simulation = util.access_simulation(program) -# settings = simulation["settings"] - -# # Particle properties -# particle = particle_container[0] -# g = particle["g"] - -# # Material properties -# material = simulation["multigroup_materials"][particle["material_ID"]] -# G = material["G"] -# J = material["J"] - -# # Kill the current particle -# particle["alive"] = False - -# # Adjust production and product weights if weighted emission -# weight_production = 1.0 -# weight_product = particle["w"] -# if simulation["weighted_emission"]["active"]: -# weight_target = simulation["weighted_emission"]["weight_target"] -# weight_production = particle["w"] / weight_target -# weight_product = weight_target - -# # Fission yields -# nu = mcdc_get.multigroup_material.mgxs_nu_f(g, material, data) -# nu_p = mcdc_get.multigroup_material.mgxs_nu_p(g, material, data) -# if J > 0: -# stride = material["J"] -# start = material["mgxs_nu_d_offset"] + g * stride -# nu_d = data[start : start + stride] -# # Above is equivalent to: nu_d = mcdc_get.multigroup_material.mgxs_nu_d_vector(g, material, data) - -# # Get number of secondaries -# N = int( -# math.floor( -# weight_production * nu / simulation["k_eff"] + rng.lcg(particle_container) -# ) -# ) - -# # Set up secondary partice container -# particle_container_new = util.local_array(1, type_.particle_data) -# particle_new = particle_container_new[0] - -# # Create the secondaries -# for n in range(N): -# # Set default attributes -# particle_module.copy_as_child(particle_container_new, particle_container) - -# # Set weight -# particle_new["w"] = weight_product - -# # Sample isotropic direction -# ux_new, uy_new, uz_new = sample_isotropic_direction(particle_container_new) -# particle_new["ux"] = ux_new -# particle_new["uy"] = uy_new -# particle_new["uz"] = uz_new - -# # Prompt or delayed? -# xi = rng.lcg(particle_container_new) * nu -# total = nu_p -# if xi < total: -# prompt = True -# stride = material["G"] -# start = material["mgxs_chi_p_offset"] + g * stride -# spectrum = data[start : start + stride] -# # Above is equivalent to: spectrum = mcdc_get.multigroup_material.mgxs_chi_p_vector(g, material, data) -# else: -# prompt = False - -# # Determine delayed group, decay constant, and spectrum -# for j in range(J): -# total += nu_d[j] -# if xi < total: -# stride = material["G"] -# start = material["mgxs_chi_d_offset"] + j * stride -# spectrum = data[start : start + stride] -# # Above is equivalent to: -# # spectrum = mcdc_get.multigroup_material.mgxs_chi_d_vector( -# # j, material, data -# # ) -# decay = mcdc_get.multigroup_material.mgxs_decay_rate( -# j, material, data -# ) -# break - -# # Sample outgoing energy -# xi = rng.lcg(particle_container_new) -# tot = 0.0 -# for g_out in range(G): -# tot += spectrum[g_out] -# if tot > xi: -# break -# particle_new["g"] = g_out - -# # Sample emission time -# if not prompt: -# xi = rng.lcg(particle_container_new) -# particle_new["t"] -= math.log(xi) / decay - -# # Eigenvalue mode: bank right away -# if settings["neutron_eigenvalue_mode"]: -# particle_bank_module.bank_census_particle(particle_container_new, program) -# continue -# # Below is only relevant for fixed-source problem - -# # Skip if it's beyond time boundary -# if particle_new["t"] > settings["time_boundary"]: -# continue - -# # Check if it hits current or next census times -# hit_current_census = False -# hit_future_census = False -# idx_census = simulation["idx_census"] -# if settings["N_census"] > 1: -# if particle_new["t"] > mcdc_get.settings.census_time( -# idx_census, settings, data -# ): -# hit_current_census = True -# if particle_new["t"] > mcdc_get.settings.census_time( -# idx_census + 1, settings, data -# ): -# hit_future_census = True - -# # Not hitting census --> add to active bank -# if not hit_current_census: -# # Keep it if it is the last particle -# if n == N - 1: -# particle["alive"] = True -# particle["ux"] = particle_new["ux"] -# particle["uy"] = particle_new["uy"] -# particle["uz"] = particle_new["uz"] -# particle["t"] = particle_new["t"] -# particle["g"] = particle_new["g"] -# particle["E"] = particle_new["E"] -# particle["w"] = particle_new["w"] -# else: -# particle_bank_module.bank_active_particle( -# particle_container_new, program -# ) - -# # Hit future census --> add to future bank -# elif hit_future_census: -# # Particle will participate in the future -# particle_bank_module.bank_future_particle(particle_container_new, program) - -# # Hit current census --> add to census bank -# else: -# # Particle will participate after the current census is completed -# particle_bank_module.bank_census_particle(particle_container_new, program) diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 66b61b202..ec2644725 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -236,6 +236,11 @@ def collision(particle_container, collision_data_container, program, data): return +# ====================================================================================== +# Continous Slowing Down Approximation +# ====================================================================================== + + @njit def csda_edep(particle_container, collision_data_container, distance, simulation, data): particle = particle_container[0] diff --git a/tools/data_library_generator/proton_ace_to_hdf5.py b/tools/data_library_generator/proton_ace_to_hdf5.py index 549192316..b649017ce 100644 --- a/tools/data_library_generator/proton_ace_to_hdf5.py +++ b/tools/data_library_generator/proton_ace_to_hdf5.py @@ -9,7 +9,7 @@ python proton_ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 [--pstar_dir /path/to/pstar] python proton_ace_to_hdf5.py ... --rewrite # overwrite existing files python proton_ace_to_hdf5.py ... --verbose # per-reaction detail - + Environment variable fallbacks: $MCDC_ACELIB, $MCDC_LIB, $PSTAR_LIB From 1415321687906887b3c420415a7af8ba00c30893 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Mon, 8 Jun 2026 12:15:32 -0700 Subject: [PATCH 49/64] csda functions now inside a conditional --- mcdc/object_/settings.py | 2 +- mcdc/transport/simulation.py | 16 +++++++++------- mcdc/transport/technique.py | 4 +++- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/mcdc/object_/settings.py b/mcdc/object_/settings.py index 60e13a348..2caac29c9 100644 --- a/mcdc/object_/settings.py +++ b/mcdc/object_/settings.py @@ -44,7 +44,7 @@ class Settings(ObjectSingleton): time_boundary: float = np.inf output_name: str = "output" use_progress_bar: bool = True - csda: bool = True + csda: bool = False csda_max_fractional_e_loss: float = 0.01 # Time census diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index e0115ac01..5c8cec841 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -427,7 +427,8 @@ def move_to_event(particle_container, simulation, data): d_collision = physics.collision_distance(particle_container, simulation, data) # Distance to max energy loss as dictated by CSDA - d_csda = physics.csda_distance(particle_container, simulation, data) + if settings["csda"]: + d_csda = physics.csda_distance(particle_container, simulation, data) # ================================================================================== # Determine event(s) @@ -459,12 +460,13 @@ def move_to_event(particle_container, simulation, data): particle["surface_ID"] = -1 # Check distance to max energy loss from CSDA - if d_csda < distance - COINCIDENCE_TOLERANCE: - distance = d_csda - particle["event"] = EVENT_CSDA_EDEP - particle["surface_ID"] = -1 - elif geometry.check_coincidence(d_csda, distance): - particle["event"] += EVENT_CSDA_EDEP + if settings["csda"]: + if d_csda < distance - COINCIDENCE_TOLERANCE: + distance = d_csda + particle["event"] = EVENT_CSDA_EDEP + particle["surface_ID"] = -1 + elif geometry.check_coincidence(d_csda, distance): + particle["event"] += EVENT_CSDA_EDEP # ================================================================================== # Move particle diff --git a/mcdc/transport/technique.py b/mcdc/transport/technique.py index 5de637998..c7c3904fd 100644 --- a/mcdc/transport/technique.py +++ b/mcdc/transport/technique.py @@ -20,7 +20,7 @@ @njit -def weight_roulette(particle_container, w_threshold, w_target): +def weight_roulette(particle_container, simulation): """ Russian roulette particle if weight is below threshold. @@ -34,6 +34,8 @@ def weight_roulette(particle_container, w_threshold, w_target): Target weight assigned upon survival. """ particle = particle_container[0] + w_threshold = simulation["global_weight_roulette"]["weight_threshold"] + w_target = simulation["global_weight_roulette"]["weight_target"] if particle["w"] < w_threshold: survival_probability = particle["w"] / w_target # sample random number to determine survival From 4f2fa945cd6d5640cbb4756abc897621de1df314 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Mon, 8 Jun 2026 14:23:13 -0700 Subject: [PATCH 50/64] add mass number to proton data --- mcdc/object_/nuclide.py | 8 ++++++-- tools/data_library_generator/proton_ace_to_hdf5.py | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index 4203ffbfd..54fc12c84 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -336,8 +336,12 @@ def set_proton_data(self): # ========================================================================== # Stopping power for protons # ========================================================================== - self.stopping_power = file["stopping_power"]["total_stopping_power"][()] - self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] + if file["stopping_power"]: + self.stopping_power = file["stopping_power"]["total_stopping_power"][()] + self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] + else: + self.stopping_power = np.array([], dtype=float) + self.stopping_power_energy_grid = np.array([], dtype=float) file.close() diff --git a/tools/data_library_generator/proton_ace_to_hdf5.py b/tools/data_library_generator/proton_ace_to_hdf5.py index b649017ce..184ae6797 100644 --- a/tools/data_library_generator/proton_ace_to_hdf5.py +++ b/tools/data_library_generator/proton_ace_to_hdf5.py @@ -18,7 +18,7 @@ -K.h5 attrs: source_title, source_version, source_date nuclide_name, excitation_level, temperature (K), - atomic_number, atomic_weight_ratio, fissionable + atomic_number, mass_number, atomic_weight_ratio, fissionable stopping_power/ (if PSTAR data available) energy (MeV), total_stopping_power (MeV cm2/g) @@ -531,6 +531,7 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): file.create_dataset("excitation_level", data=S) file.create_dataset("temperature", data=T_kelvin).attrs["unit"] = "K" file.create_dataset("atomic_number", data=ace_table.atom_number) + file.create_dataset("mass_number", data=ace_table.mass_number) file.create_dataset("atomic_weight_ratio", data=ace_table.atomic_weight_ratio) fissionable = ace_table.fission_multiplicity_block is not None file.create_dataset("fissionable", data=fissionable) From 479cc4d9b4f59d9196c4e4771372a73003739f51 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Mon, 8 Jun 2026 14:32:13 -0700 Subject: [PATCH 51/64] minor fix to ww --- mcdc/transport/simulation.py | 5 ----- mcdc/transport/technique.py | 4 +--- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index 5c8cec841..4c86e387f 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -369,11 +369,6 @@ def step_particle(particle_container, program, data): if simulation["global_weight_roulette"]["active"]: technique.global_weight_roulette(particle_container, simulation) - # Weight roulette - if particle["alive"]: - technique.weight_roulette(particle_container, simulation) - - @njit def move_to_event(particle_container, simulation, data): settings = simulation["settings"] diff --git a/mcdc/transport/technique.py b/mcdc/transport/technique.py index c7c3904fd..5de637998 100644 --- a/mcdc/transport/technique.py +++ b/mcdc/transport/technique.py @@ -20,7 +20,7 @@ @njit -def weight_roulette(particle_container, simulation): +def weight_roulette(particle_container, w_threshold, w_target): """ Russian roulette particle if weight is below threshold. @@ -34,8 +34,6 @@ def weight_roulette(particle_container, simulation): Target weight assigned upon survival. """ particle = particle_container[0] - w_threshold = simulation["global_weight_roulette"]["weight_threshold"] - w_target = simulation["global_weight_roulette"]["weight_target"] if particle["w"] < w_threshold: survival_probability = particle["w"] / w_target # sample random number to determine survival From 76c29422637cba7561614386fe6ac69b94013be0 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Mon, 8 Jun 2026 14:57:33 -0700 Subject: [PATCH 52/64] fixing stopping power initialization --- examples/proton_beam/input_1MeV.py | 4 ++-- mcdc/object_/nuclide.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/proton_beam/input_1MeV.py b/examples/proton_beam/input_1MeV.py index fc30e633f..21df0fcdb 100644 --- a/examples/proton_beam/input_1MeV.py +++ b/examples/proton_beam/input_1MeV.py @@ -7,7 +7,7 @@ # Proton beam, incident on a slab # Set materials (atom density in units of atoms/barn-cm) -silicon = mcdc.Material("silicon", {"Si28": 0.05}) +silicon = mcdc.Material("silicon", {"C12": 0.05}) # Set surfaces sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") @@ -87,7 +87,7 @@ mcdc.settings.N_particle = 1_000 mcdc.settings.N_batch = 1 mcdc.settings.csda = True -mcdc.settings.csda_max_fractional_e_loss = 0.001 +mcdc.settings.csda_max_fractional_e_loss = 0.01 # Techniques mcdc.simulation.implicit_capture() diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index 54fc12c84..893655f08 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -336,12 +336,14 @@ def set_proton_data(self): # ========================================================================== # Stopping power for protons # ========================================================================== - if file["stopping_power"]: + if "stopping_power" in file: self.stopping_power = file["stopping_power"]["total_stopping_power"][()] self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] else: self.stopping_power = np.array([], dtype=float) self.stopping_power_energy_grid = np.array([], dtype=float) + if simulation.settings.csda: + raise ValueError(f"CSDA cannot be used if no stopping power is provided for nuclide {self.name}") file.close() From 04e190644aa87bf89096d15706decb45731b2172 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Mon, 8 Jun 2026 15:10:51 -0700 Subject: [PATCH 53/64] fixed initialization of stopping power --- mcdc/object_/nuclide.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index 893655f08..df2a2a684 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -117,6 +117,9 @@ def __init__(self, nuclide_name, temperature): self.neutron_fission_delayed_fractions = np.zeros(0) self.neutron_fission_delayed_decay_rates = np.zeros(0) self.neutron_fission_delayed_spectra = [] + # Stopping Power + self.stopping_power = np.zeros(0) + self.stopping_power_energy_grid = np.zeros(0) def set_neutron_data(self): nuclide_name = self.name @@ -339,11 +342,8 @@ def set_proton_data(self): if "stopping_power" in file: self.stopping_power = file["stopping_power"]["total_stopping_power"][()] self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] - else: - self.stopping_power = np.array([], dtype=float) - self.stopping_power_energy_grid = np.array([], dtype=float) - if simulation.settings.csda: - raise ValueError(f"CSDA cannot be used if no stopping power is provided for nuclide {self.name}") + elif simulation.settings.csda: + raise ValueError(f"CSDA cannot be used if no stopping power is provided for nuclide {self.name}") file.close() From 4b1caf896dedc8cd69cda43f4e792347dbcf050c Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 10 Jun 2026 16:19:02 -0700 Subject: [PATCH 54/64] add support for isotopes without cross section data --- mcdc/object_/material.py | 2 +- mcdc/object_/nuclide.py | 34 +- ...proton_ace_to_hdf5.py => tendl_to_hdf5.py} | 640 ++++++++---------- 3 files changed, 313 insertions(+), 363 deletions(-) rename tools/data_library_generator/{proton_ace_to_hdf5.py => tendl_to_hdf5.py} (56%) diff --git a/mcdc/object_/material.py b/mcdc/object_/material.py index a71800e79..dc2e40071 100644 --- a/mcdc/object_/material.py +++ b/mcdc/object_/material.py @@ -223,7 +223,7 @@ def __repr__(self): # Currently supported temperatures -TEMPERATURES = [0.1, 233.15, 273.15, 293.6, 600.0, 900.0, 1200.0, 2500.0] +TEMPERATURES = [0.0, 0.1, 233.15, 273.15, 293.6, 600.0, 900.0, 1200.0, 2500.0] # ====================================================================================== diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index df2a2a684..2b3eb78e9 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -269,9 +269,28 @@ def set_proton_data(self): file_name = f"{nuclide_name}-{temperature}K.h5" file = h5py.File(f"{dir_name}/{file_name}", "r") - # TENDL data only handles elastic scattering rxns as a unique rxn. - # Everything else is grouped together, including nonelastic rxns - # and rxns that will produce secondary particles. + # ========================================================================== + # Stopping power for protons + # ========================================================================== + if "stopping_power" in file: + self.stopping_power = file["stopping_power"]["total_stopping_power"][()] + self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] + elif simulation.settings.csda: + raise ValueError(f"CSDA cannot be used if no stopping power is provided for nuclide {self.name}") + + # Only CSDA data available - no nuclear rxn xs + if "proton_reactions" not in file: + # Zero out all xs arrays + xs_energy = np.array([0, 1.0e10]) + self.proton_xs_energy_grid = xs_energy + + self.proton_total_xs = np.zeros_like(self.proton_xs_energy_grid) + self.proton_elastic_xs = np.zeros_like(self.proton_xs_energy_grid) + self.proton_nonelastic_xs = np.zeros_like(self.proton_xs_energy_grid) + + file.close() + return + rx_names = [ "elastic_scattering", "nonelastic_reaction", @@ -336,15 +355,6 @@ def set_proton_data(self): reaction = rx_class.from_h5_group(h5_group) rx_container.append(reaction) - # ========================================================================== - # Stopping power for protons - # ========================================================================== - if "stopping_power" in file: - self.stopping_power = file["stopping_power"]["total_stopping_power"][()] - self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] - elif simulation.settings.csda: - raise ValueError(f"CSDA cannot be used if no stopping power is provided for nuclide {self.name}") - file.close() ## TODO: UPDATE this to handle protons as well as neutrons diff --git a/tools/data_library_generator/proton_ace_to_hdf5.py b/tools/data_library_generator/tendl_to_hdf5.py similarity index 56% rename from tools/data_library_generator/proton_ace_to_hdf5.py rename to tools/data_library_generator/tendl_to_hdf5.py index 184ae6797..dd627fd32 100644 --- a/tools/data_library_generator/proton_ace_to_hdf5.py +++ b/tools/data_library_generator/tendl_to_hdf5.py @@ -1,15 +1,19 @@ # The majority of this script was written by Anthropic's Claude """ -proton_ace_to_hdf5.py — Convert proton ACE files (TENDL etc.) to HDF5 for MC/DC +tendl_to_hdf5.py — Convert TENDL proton ACE files to HDF5 for MC/DC + +For isotopes that have no ACE file (e.g. H, He) but do have a PSTAR stopping +power file, a minimal HDF5 file is created containing only the stopping power +data. This ensures every element that can appear in a material has at least +a stopping power entry. Usage ----- - python proton_ace_to_hdf5.py - python proton_ace_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 [--pstar_dir /path/to/pstar] - python proton_ace_to_hdf5.py ... --rewrite # overwrite existing files - python proton_ace_to_hdf5.py ... --verbose # per-reaction detail - + python tendl_to_hdf5.py + python tendl_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 [--pstar_dir /path/to/pstar] + python tendl_to_hdf5.py ... --rewrite # overwrite existing files + python tendl_to_hdf5.py ... --verbose # per-reaction detail Environment variable fallbacks: $MCDC_ACELIB, $MCDC_LIB, $PSTAR_LIB @@ -23,7 +27,7 @@ stopping_power/ (if PSTAR data available) energy (MeV), total_stopping_power (MeV cm2/g) - proton_reactions/ + proton_reactions/ (absent for stopping-power-only files) xs_energy_grid (MeV) elastic_scattering/MT-002/ xs (barns, offset=0), Q-value (MeV), reference_frame, @@ -59,11 +63,12 @@ from tqdm import tqdm import ACEtk + # -- Constants ----------------------------------------------------------------- ZAP_NAMES = { - 0: "photon", - 1: "neutron", + 0: "photon", + 1: "neutron", 1001: "proton", 1002: "deuteron", 1003: "triton", @@ -72,124 +77,48 @@ } Z_TO_SYMBOL = { - 1: "H", - 2: "He", - 3: "Li", - 4: "Be", - 5: "B", - 6: "C", - 7: "N", - 8: "O", - 9: "F", - 10: "Ne", - 11: "Na", - 12: "Mg", - 13: "Al", - 14: "Si", - 15: "P", - 16: "S", - 17: "Cl", - 18: "Ar", - 19: "K", - 20: "Ca", - 21: "Sc", - 22: "Ti", - 23: "V", - 24: "Cr", - 25: "Mn", - 26: "Fe", - 27: "Co", - 28: "Ni", - 29: "Cu", - 30: "Zn", - 31: "Ga", - 32: "Ge", - 33: "As", - 34: "Se", - 35: "Br", - 36: "Kr", - 37: "Rb", - 38: "Sr", - 39: "Y", - 40: "Zr", - 41: "Nb", - 42: "Mo", - 43: "Tc", - 44: "Ru", - 45: "Rh", - 46: "Pd", - 47: "Ag", - 48: "Cd", - 49: "In", - 50: "Sn", - 51: "Sb", - 52: "Te", - 53: "I", - 54: "Xe", - 55: "Cs", - 56: "Ba", - 57: "La", - 58: "Ce", - 59: "Pr", - 60: "Nd", - 61: "Pm", - 62: "Sm", - 63: "Eu", - 64: "Gd", - 65: "Tb", - 66: "Dy", - 67: "Ho", - 68: "Er", - 69: "Tm", - 70: "Yb", - 71: "Lu", - 72: "Hf", - 73: "Ta", - 74: "W", - 75: "Re", - 76: "Os", - 77: "Ir", - 78: "Pt", - 79: "Au", - 80: "Hg", - 81: "Tl", - 82: "Pb", - 83: "Bi", - 84: "Po", - 85: "At", - 86: "Rn", - 87: "Fr", - 88: "Ra", - 89: "Ac", - 90: "Th", - 91: "Pa", - 92: "U", - 93: "Np", - 94: "Pu", - 95: "Am", - 96: "Cm", - 97: "Bk", - 98: "Cf", - 99: "Es", - 100: "Fm", - 101: "Md", - 102: "No", - 103: "Lr", + 1:"H", 2:"He", 3:"Li", 4:"Be", 5:"B", 6:"C", 7:"N", 8:"O", + 9:"F", 10:"Ne", 11:"Na", 12:"Mg", 13:"Al", 14:"Si", 15:"P", 16:"S", + 17:"Cl", 18:"Ar", 19:"K", 20:"Ca", 21:"Sc", 22:"Ti", 23:"V", 24:"Cr", + 25:"Mn", 26:"Fe", 27:"Co", 28:"Ni", 29:"Cu", 30:"Zn", 31:"Ga", 32:"Ge", + 33:"As", 34:"Se", 35:"Br", 36:"Kr", 37:"Rb", 38:"Sr", 39:"Y", 40:"Zr", + 41:"Nb", 42:"Mo", 43:"Tc", 44:"Ru", 45:"Rh", 46:"Pd", 47:"Ag", 48:"Cd", + 49:"In", 50:"Sn", 51:"Sb", 52:"Te", 53:"I", 54:"Xe", 55:"Cs", 56:"Ba", + 57:"La", 58:"Ce", 59:"Pr", 60:"Nd", 61:"Pm", 62:"Sm", 63:"Eu", 64:"Gd", + 65:"Tb", 66:"Dy", 67:"Ho", 68:"Er", 69:"Tm", 70:"Yb", 71:"Lu", 72:"Hf", + 73:"Ta", 74:"W", 75:"Re", 76:"Os", 77:"Ir", 78:"Pt", 79:"Au", 80:"Hg", + 81:"Tl", 82:"Pb", 83:"Bi", 84:"Po", 85:"At", 86:"Rn", 87:"Fr", 88:"Ra", + 89:"Ac", 90:"Th", 91:"Pa", 92:"U", 93:"Np", 94:"Pu", 95:"Am", 96:"Cm", + 97:"Bk", 98:"Cf", 99:"Es",100:"Fm",101:"Md",102:"No",103:"Lr", } +SYMBOL_TO_Z = {v: k for k, v in Z_TO_SYMBOL.items()} + +# Isotopes to generate stopping-power-only HDF5 files for when no ACE file +# exists. Covers H and He which TENDL excludes because TALYS doesn't apply. +# Format: (symbol, A, atomic_weight_ratio) +# AWR = atomic mass / neutron mass; neutron mass = 1.008664916 u +PSTAR_ONLY_ISOTOPES = [ + ("H", 1, 1.00794 / 1.008664916), # natural H ≈ H-1 + ("H", 2, 2.01410 / 1.008664916), # deuterium + ("He", 3, 3.01603 / 1.008664916), # He-3 + ("He", 4, 4.00260 / 1.008664916), # He-4 +] + # Redundant sum MTs that must not be double-counted -REDUNDANT_MTS = [1, 3, 4, 10, 101, 103, 104, 105, 106, 107] +REDUNDANT_MTS = [1, 3, 4, 10, 101, 103, 104, 105, 106, 107] FISSION_CHANCE_MTS = [19, 20, 21, 38] +# Temperature written to all files (TENDL proton ACE files report 0 K) +T_KELVIN = 0.0 -# -- Utility ------------------------------------------------------------------- +# -- Utility ------------------------------------------------------------------- def print_error(msg): print(f"\n[ERROR] {msg}", file=sys.stderr) sys.exit(1) - def print_note(msg): print(f" [note] {msg}") @@ -197,9 +126,9 @@ def print_note(msg): def decode_ace_zaid(zaid): """Return (Z, A, S, T=0) from an ACE ZAID string.""" za = int(zaid.strip().split(".")[0]) - S = 0 + S = 0 if za >= 600000: - S = (za % 1000) // 400 + S = (za % 1000) // 400 za = za - S * 400 return za // 1000, za % 1000, S, 0 @@ -223,9 +152,29 @@ def load_pstar_file(filepath): return np.array(energies), np.array(sps) -# -- Distribution writers ------------------------------------------------------ +def write_stopping_power(file, pstar_dir, symbol, verbose=False): + """ + Write stopping_power group into an open HDF5 file if a PSTAR file exists. + Returns True if data was written. + """ + if pstar_dir is None: + return False + pstar_path = os.path.join(pstar_dir, f"{symbol}.txt") + if not os.path.exists(pstar_path): + if verbose: + print(f" [warn] No PSTAR file for {symbol}") + return False + if verbose: + print(f" Loading PSTAR from {pstar_path}") + E_s, S_s = load_pstar_file(pstar_path) + sp = file.create_group("stopping_power") + sp.create_dataset("energy", data=E_s).attrs["unit"] = "MeV" + sp.create_dataset("total_stopping_power", data=S_s).attrs["unit"] = "MeV cm2/g" + return True +# -- Distribution writers ------------------------------------------------------ + def load_cosine_distribution(data, h5_group): """ Write a tabulated angular distribution into h5_group. @@ -245,8 +194,8 @@ def load_cosine_distribution(data, h5_group): if isinstance(subdist, ACEtk.continuous.TabulatedAngularDistribution): eg.attrs["type"] = "tabulated" eg.create_dataset("cosines", data=np.array(subdist.cosines)) - eg.create_dataset("pdf", data=np.array(subdist.pdf)) - eg.create_dataset("cdf", data=np.array(subdist.cdf)) + eg.create_dataset("pdf", data=np.array(subdist.pdf)) + eg.create_dataset("cdf", data=np.array(subdist.cdf)) else: eg.attrs["type"] = "isotropic" @@ -261,9 +210,9 @@ def _write_kalbach_mann(km_data, h5_group): h5_group.attrs["type"] = "kalbach-mann" NE = km_data.number_incident_energies - h5_group.create_dataset("energy", data=np.array(km_data.incident_energies)).attrs[ - "unit" - ] = "MeV" + h5_group.create_dataset( + "energy", data=np.array(km_data.incident_energies) + ).attrs["unit"] = "MeV" offset, energy_out, pdf, cdf, r_vals, a_vals = [], [], [], [], [], [] for i in range(1, NE + 1): @@ -276,13 +225,13 @@ def _write_kalbach_mann(km_data, h5_group): a_vals.extend(dist.angular_distribution_slope_values) h5_group.create_dataset("offset", data=np.array(offset, dtype=np.int32)) - h5_group.create_dataset("energy_out", data=np.array(energy_out)).attrs["unit"] = ( - "MeV" - ) - h5_group.create_dataset("pdf", data=np.array(pdf)) - h5_group.create_dataset("cdf", data=np.array(cdf)) + h5_group.create_dataset( + "energy_out", data=np.array(energy_out) + ).attrs["unit"] = "MeV" + h5_group.create_dataset("pdf", data=np.array(pdf)) + h5_group.create_dataset("cdf", data=np.array(cdf)) h5_group.create_dataset("precompound_factor", data=np.array(r_vals)) - h5_group.create_dataset("angular_slope", data=np.array(a_vals)) + h5_group.create_dataset("angular_slope", data=np.array(a_vals)) def load_energy_distribution(data, h5_group): @@ -298,9 +247,7 @@ def load_energy_distribution(data, h5_group): ) for k, dist in enumerate(data.distributions): eg = h5_group.create_group(f"E_in_{k + 1}") - eg.create_dataset( - "outgoing_energies", data=np.array(dist.outgoing_energies) - ) + eg.create_dataset("outgoing_energies", data=np.array(dist.outgoing_energies)) eg.create_dataset("pdf", data=np.array(dist.pdf)) eg.create_dataset("cdf", data=np.array(dist.cdf)) @@ -320,7 +267,7 @@ def load_energy_distribution(data, h5_group): ) else: - h5_group.attrs["law"] = -1 + h5_group.attrs["law"] = -1 h5_group.attrs["type_name"] = type(data).__name__ try: h5_group.create_dataset("xss_array", data=np.array(data.xss_array)) @@ -331,19 +278,18 @@ def load_energy_distribution(data, h5_group): def load_fission_multiplicity(data, h5_group): if isinstance(data, ACEtk.continuous.TabulatedFissionMultiplicity): h5_group.attrs["type"] = "tabulated" - h5_group.create_dataset("energies", data=np.array(data.energies)) + h5_group.create_dataset("energies", data=np.array(data.energies)) h5_group.create_dataset("multiplicities", data=np.array(data.multiplicities)) elif isinstance(data, ACEtk.continuous.PolynomialFissionMultiplicity): h5_group.attrs["type"] = "polynomial" h5_group.create_dataset("coefficients", data=np.array(data.coefficients)) else: - h5_group.attrs["type"] = "unknown" + h5_group.attrs["type"] = "unknown" h5_group.attrs["type_name"] = type(data).__name__ # -- Secondary particles ------------------------------------------------------- - def load_secondary_particles(ace_table, file, verbose=False): n_types = ace_table.number_secondary_particle_types if n_types == 0: @@ -351,27 +297,24 @@ def load_secondary_particles(ace_table, file, verbose=False): type_block = ace_table.secondary_particle_type_block info_block = ace_table.secondary_particle_information_block - rx_block = ace_table.secondary_particle_reaction_number_block - tyr_block = ace_table.secondary_particle_frame_and_multiplicity_block - xs_block = ace_table.secondary_particle_production_cross_section_block - edy_block = ace_table.secondary_particle_energy_distribution_block + rx_block = ace_table.secondary_particle_reaction_number_block + tyr_block = ace_table.secondary_particle_frame_and_multiplicity_block + xs_block = ace_table.secondary_particle_production_cross_section_block + edy_block = ace_table.secondary_particle_energy_distribution_block has_ang = False try: ang_block = ace_table.secondary_particle_angular_distribution_block - has_ang = True + has_ang = True except Exception: pass sec_group = file.create_group("secondary_particles") pi_method = next( - ( - c - for c in ["particle_identifier", "ZAP", "type", "particle_type"] - if hasattr(type_block, c) - ), - None, + (c for c in ["particle_identifier", "ZAP", "type", "particle_type"] + if hasattr(type_block, c)), + None ) if pi_method is None: raise AttributeError( @@ -380,7 +323,7 @@ def load_secondary_particles(ace_table, file, verbose=False): ) for i in range(1, n_types + 1): - zap = getattr(type_block, pi_method)(i) + zap = getattr(type_block, pi_method)(i) name = ZAP_NAMES.get(zap, f"ZAP_{zap}") n_rx = int(info_block.number_reactions[i - 1]) @@ -388,64 +331,45 @@ def load_secondary_particles(ace_table, file, verbose=False): print(f" Secondary type {i}: ZAP={zap} ({name}), {n_rx} reactions") zap_group = sec_group.create_group(f"ZAP_{zap}") - zap_group.attrs["ZAP"] = zap + zap_group.attrs["ZAP"] = zap zap_group.attrs["particle_name"] = name - rx_i = rx_block(i) + rx_i = rx_block(i) tyr_i = tyr_block(i) - xs_i = xs_block(i) + xs_i = xs_block(i) edy_i = edy_block(i) ang_i = ang_block(i) if has_ang else None - xs_method = next( + xs_method = next( (c for c in ["cross_sections", "cross_section", "xs"] if hasattr(xs_i, c)), - None, + None ) off_method = next( - ( - c - for c in ["energy_index", "offset", "locator", "index"] - if hasattr(xs_i, c) - ), - None, + (c for c in ["energy_index", "offset", "locator", "index"] if hasattr(xs_i, c)), + None ) edy_method = next( - ( - c - for c in [ - "energy_distribution_data", - "distribution_data", - "distribution", - ] - if hasattr(edy_i, c) - ), - None, + (c for c in ["energy_distribution_data", "distribution_data", "distribution"] + if hasattr(edy_i, c)), + None ) for j in range(1, n_rx + 1): - MT = rx_i.MT(j) + MT = rx_i.MT(j) nu_raw = tyr_i.multiplicity(j) - nu = nu_raw - 100 if nu_raw >= 100 else nu_raw + nu = nu_raw - 100 if nu_raw >= 100 else nu_raw rf_raw = tyr_i.reference_frame(j) - rf = ( - "LAB" - if rf_raw == ACEtk.ReferenceFrame.Laboratory - else ( - "COM" - if rf_raw == ACEtk.ReferenceFrame.CentreOfMass - else str(rf_raw) - ) - ) + rf = ("LAB" if rf_raw == ACEtk.ReferenceFrame.Laboratory else + "COM" if rf_raw == ACEtk.ReferenceFrame.CentreOfMass else str(rf_raw)) mt = zap_group.create_group(f"MT-{MT:03}") - mt.attrs["MT"] = MT - mt.attrs["multiplicity"] = nu + mt.attrs["MT"] = MT + mt.attrs["multiplicity"] = nu mt.attrs["reference_frame"] = rf if verbose: print(f" MT={MT:03} nu_raw={nu_raw} nu={nu} frame={rf}") - # Production cross section empty_xs = np.zeros(0, dtype=float) if xs_method and off_method: try: @@ -453,43 +377,39 @@ def load_secondary_particles(ace_table, file, verbose=False): "production_xs", data=np.array(getattr(xs_i, xs_method)(j)) ) ds.attrs["offset"] = int(getattr(xs_i, off_method)(j)) - 1 - ds.attrs["unit"] = "barns" + ds.attrs["unit"] = "barns" except Exception as exc: ds = mt.create_dataset("production_xs", data=empty_xs) ds.attrs["offset"] = 0 - ds.attrs["unit"] = "barns" + ds.attrs["unit"] = "barns" if verbose: print(f" [warn] production xs: {exc}") else: ds = mt.create_dataset("production_xs", data=empty_xs) ds.attrs["offset"] = 0 - ds.attrs["unit"] = "barns" + ds.attrs["unit"] = "barns" if verbose: - print( - f" [warn] xs methods not found: " - f"{[x for x in dir(xs_i) if not x.startswith('_')]}" - ) + print(f" [warn] xs methods not found: " + f"{[x for x in dir(xs_i) if not x.startswith('_')]}") - # Kalbach-Mann energy-angle distribution if edy_method: try: _write_kalbach_mann( - getattr(edy_i, edy_method)(j), mt.create_group("kalbach_mann") + getattr(edy_i, edy_method)(j), + mt.create_group("kalbach_mann") ) except Exception as exc: if verbose: print(f" [warn] energy dist: {exc}") elif verbose: - print( - f" [warn] edy method not found: " - f"{[x for x in dir(edy_i) if not x.startswith('_')]}" - ) + print(f" [warn] edy method not found: " + f"{[x for x in dir(edy_i) if not x.startswith('_')]}") if ang_i is not None: try: load_cosine_distribution( ang_i.angular_distribution_data(j), - mt.create_group("angular_cosine_distribution"), + mt.create_group("angular_cosine_distribution") ) except Exception: pass @@ -497,111 +417,86 @@ def load_secondary_particles(ace_table, file, verbose=False): # -- Per-file processing ------------------------------------------------------- - def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): + """Convert a single ACE proton file to HDF5. Returns the output filename.""" with open(ace_path) as f: header = ACEtk.Header.from_string(f.readline()) Z, A, S, _ = decode_ace_zaid(header.zaid) - symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") + symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" ace_table = ACEtk.ContinuousEnergyTable.from_file(ace_path) - T_kelvin = 293.6 # TENDL proton files report 0 K as a placeholder - - mcdc_name = f"{nuclide_name}-{T_kelvin}K.h5" - out_path = os.path.join(output_dir, mcdc_name) + mcdc_name = f"{nuclide_name}-{T_KELVIN}K.h5" + out_path = os.path.join(output_dir, mcdc_name) if verbose: print(f"\n{'='*80}") print(f" {os.path.basename(ace_path)} -> {mcdc_name}") - print(f" Z={Z} A={A} S={S} T={T_kelvin} K") + print(f" Z={Z} A={A} S={S} T={T_KELVIN} K") file = h5py.File(out_path, "w") # Metadata hdr = ace_table.header - file.attrs["source_title"] = hdr.title + file.attrs["source_title"] = hdr.title file.attrs["source_version"] = hdr.version - file.attrs["source_date"] = hdr.date + file.attrs["source_date"] = hdr.date if hasattr(hdr, "comments"): file.attrs["source_comments"] = hdr.comments - file.create_dataset("nuclide_name", data=nuclide_name) - file.create_dataset("excitation_level", data=S) - file.create_dataset("temperature", data=T_kelvin).attrs["unit"] = "K" - file.create_dataset("atomic_number", data=ace_table.atom_number) - file.create_dataset("mass_number", data=ace_table.mass_number) + file.create_dataset("nuclide_name", data=nuclide_name) + file.create_dataset("excitation_level", data=S) + file.create_dataset("temperature", data=T_KELVIN).attrs["unit"] = "K" + file.create_dataset("atomic_number", data=ace_table.atom_number) + file.create_dataset("mass_number", data=ace_table.mass_number) file.create_dataset("atomic_weight_ratio", data=ace_table.atomic_weight_ratio) fissionable = ace_table.fission_multiplicity_block is not None file.create_dataset("fissionable", data=fissionable) - # Stopping power - if pstar_dir is not None: - pstar_path = os.path.join(pstar_dir, f"{symbol}.txt") - if os.path.exists(pstar_path): - if verbose: - print(f" Loading PSTAR from {pstar_path}") - E_s, S_s = load_pstar_file(pstar_path) - sp = file.create_group("stopping_power") - sp.create_dataset("energy", data=E_s).attrs["unit"] = "MeV" - sp.create_dataset("total_stopping_power", data=S_s).attrs["unit"] = ( - "MeV cm2/g" - ) - elif verbose: - print(f" [warn] No PSTAR file for {symbol}") + write_stopping_power(file, pstar_dir, symbol, verbose=verbose) # Reaction classification - nu_block = ace_table.frame_and_multiplicity_block - rx_block = ace_table.reaction_number_block + nu_block = ace_table.frame_and_multiplicity_block + rx_block = ace_table.reaction_number_block N_reaction = nu_block.number_reactions proton_reactions = file.create_group("proton_reactions") - elastic_group = proton_reactions.create_group("elastic_scattering") - capture_group = proton_reactions.create_group("capture") + elastic_group = proton_reactions.create_group("elastic_scattering") + capture_group = proton_reactions.create_group("capture") nonelastic_group = proton_reactions.create_group("nonelastic_reaction") - fission_group = proton_reactions.create_group("fission") + fission_group = proton_reactions.create_group("fission") - elastic_MTs = [2] - capture_MTs = [] + elastic_MTs = [2] + capture_MTs = [] nonelastic_MTs = [] - fission_MTs = ( - [18] - if rx_block.has_MT(18) - else [MT for MT in FISSION_CHANCE_MTS if rx_block.has_MT(MT)] - ) + fission_MTs = ([18] if rx_block.has_MT(18) else + [MT for MT in FISSION_CHANCE_MTS if rx_block.has_MT(MT)]) for i in range(N_reaction): idx = i + 1 - MT = rx_block.MT(idx) + MT = rx_block.MT(idx) if MT in REDUNDANT_MTS + elastic_MTs + fission_MTs or MT > 891: continue nu_raw = nu_block.multiplicity(idx) if not isinstance(nu_raw, int): print_error(f"Non-integer multiplicity for MT-{MT:03} in {ace_path}") nu = nu_raw - 100 if nu_raw >= 100 else nu_raw - if nu == 0: - capture_MTs.append(MT) - elif nu > 0: - nonelastic_MTs.append(MT) - else: - print_error(f"Negative multiplicity for MT-{MT:03} in {ace_path}") - - for grp, mts in [ - (elastic_group, elastic_MTs), - (capture_group, capture_MTs), - (nonelastic_group, nonelastic_MTs), - (fission_group, fission_MTs), - ]: + if nu == 0: capture_MTs.append(MT) + elif nu > 0: nonelastic_MTs.append(MT) + else: print_error(f"Negative multiplicity for MT-{MT:03} in {ace_path}") + + for grp, mts in [(elastic_group, elastic_MTs), + (capture_group, capture_MTs), + (nonelastic_group, nonelastic_MTs), + (fission_group, fission_MTs)]: for MT in mts: grp.create_group(f"MT-{MT:03}").attrs["MT"] = MT if verbose: - print( - f" Elastic: {elastic_MTs} Capture: {capture_MTs} " - f"Nonelastic: {nonelastic_MTs}" - + (f" Fission: {fission_MTs}" if fissionable else "") - ) + print(f" Elastic: {elastic_MTs} Capture: {capture_MTs} " + f"Nonelastic: {nonelastic_MTs}" + + (f" Fission: {fission_MTs}" if fissionable else "")) if not fissionable: del file["proton_reactions/fission"] @@ -609,7 +504,7 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): del file["proton_reactions/nonelastic_reaction"] # Cross sections - xs0 = ace_table.principal_cross_section_block + xs0 = ace_table.principal_cross_section_block xs_main = ace_table.cross_section_block proton_reactions.create_dataset( @@ -618,66 +513,58 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): ds = elastic_group.create_dataset("MT-002/xs", data=np.array(xs0.elastic)) ds.attrs["offset"] = 0 - ds.attrs["unit"] = "barns" + ds.attrs["unit"] = "barns" - for mts, grp in [ - (capture_MTs, capture_group), - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: + for mts, grp in [(capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None)]: if grp is None: continue for MT in mts: idx = rx_block.index(MT) - ds = grp.create_dataset( + ds = grp.create_dataset( f"MT-{MT:03}/xs", data=np.array(xs_main.cross_sections(idx)) ) ds.attrs["offset"] = xs_main.energy_index(idx) - 1 - ds.attrs["unit"] = "barns" + ds.attrs["unit"] = "barns" # Q-values q_block = ace_table.reaction_qvalue_block elastic_group.create_dataset("MT-002/Q-value", data=0.0).attrs["unit"] = "MeV" - for mts, grp in [ - (capture_MTs, capture_group), - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: + for mts, grp in [(capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None)]: if grp is None: continue for MT in mts: idx = rx_block.index(MT) - grp.create_dataset(f"MT-{MT:03}/Q-value", data=q_block.q_value(idx)).attrs[ - "unit" - ] = "MeV" + grp.create_dataset( + f"MT-{MT:03}/Q-value", data=q_block.q_value(idx) + ).attrs["unit"] = "MeV" # Reference frames elastic_group.create_dataset("MT-002/reference_frame", data="COM") - for mts, grp in [ - (capture_MTs, capture_group), - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: + for mts, grp in [(capture_MTs, capture_group), + (nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None)]: if grp is None: continue for MT in mts: idx = rx_block.index(MT) - rf = nu_block.reference_frame(idx) - rf_str = ( - "LAB" - if rf == ACEtk.ReferenceFrame.Laboratory - else "COM" if rf == ACEtk.ReferenceFrame.CentreOfMass else str(rf) - ) + rf = nu_block.reference_frame(idx) + rf_str = ("LAB" if rf == ACEtk.ReferenceFrame.Laboratory else + "COM" if rf == ACEtk.ReferenceFrame.CentreOfMass else str(rf)) grp.create_dataset(f"MT-{MT:03}/reference_frame", data=rf_str) # Nonelastic multiplicities for MT in nonelastic_MTs: - idx = rx_block.index(MT) + idx = rx_block.index(MT) nu_raw = nu_block.multiplicity(idx) nonelastic_group.create_dataset( - f"MT-{MT:03}/multiplicity", data=nu_raw - 100 if nu_raw >= 100 else nu_raw + f"MT-{MT:03}/multiplicity", + data=nu_raw - 100 if nu_raw >= 100 else nu_raw ) # Angular distributions @@ -685,45 +572,36 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): ag = elastic_group.create_group("MT-002/angular_cosine_distribution") ag.attrs["type"] = "energy-correlated" - if ( - not load_cosine_distribution(angle_block.angular_distribution_data(0), ag) - and verbose - ): + if not load_cosine_distribution(angle_block.angular_distribution_data(0), ag) \ + and verbose: print_note("MT-002 angular distribution is given in energy block") - for mts, grp in [ - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: + for mts, grp in [(nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None)]: if grp is None: continue for MT in mts: idx = rx_block.index(MT) - ag = grp.create_group(f"MT-{MT:03}/angular_cosine_distribution") - if ( - not load_cosine_distribution( - angle_block.angular_distribution_data(idx), ag - ) - and verbose - ): + ag = grp.create_group(f"MT-{MT:03}/angular_cosine_distribution") + if not load_cosine_distribution( + angle_block.angular_distribution_data(idx), ag) and verbose: print_note(f"MT-{MT:03} angular distribution is given in energy block") # Primary energy distributions energy_block = ace_table.energy_distribution_block - for mts, grp in [ - (nonelastic_MTs, nonelastic_group), - (fission_MTs, fission_group if fissionable else None), - ]: + for mts, grp in [(nonelastic_MTs, nonelastic_group), + (fission_MTs, fission_group if fissionable else None)]: if grp is None: continue for MT in mts: - idx = rx_block.index(MT) + idx = rx_block.index(MT) data = energy_block.energy_distribution_data(idx) if not isinstance(data, ACEtk.continuous.MultiDistributionData): grp.create_dataset( - f"MT-{MT:03}/spectrum_probability_grid", data=np.array([0.0, 30.0]) + f"MT-{MT:03}/spectrum_probability_grid", + data=np.array([0.0, 30.0]) ).attrs["unit"] = "MeV" grp.create_dataset( f"MT-{MT:03}/spectrum_probability", data=np.array([[1.0]]) @@ -733,22 +611,19 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): ) else: N_dist = data.number_distributions - probs = data.probabilities + probs = data.probabilities if all(p.number_interpolation_regions == 0 for p in probs): prob_grid = np.array([0.0, 30.0]) - prob = np.zeros((1, N_dist)) + prob = np.zeros((1, N_dist)) for k in range(N_dist): prob[0, k] = max(data.probability(k + 1).probabilities) - elif all(p.number_interpolation_regions == 1 for p in probs) and all( - p.interpolants[0] == 1 for p in probs - ): + elif (all(p.number_interpolation_regions == 1 for p in probs) + and all(p.interpolants[0] == 1 for p in probs)): prob_grid = np.array(data.probability(1).energies) - prob = np.zeros((len(prob_grid) - 1, N_dist)) + prob = np.zeros((len(prob_grid) - 1, N_dist)) for k in range(N_dist): - prob[:, k] = np.array( - data.probability(k + 1).probabilities[:-1] - ) + prob[:, k] = np.array(data.probability(k + 1).probabilities[:-1]) else: print_error( f"Unsupported multi-distribution probability for MT-{MT:03}" @@ -761,69 +636,106 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): for k in range(N_dist): load_energy_distribution( data.distribution(k + 1), - grp.create_group(f"MT-{MT:03}/energy_spectrum-{k + 1}"), + grp.create_group(f"MT-{MT:03}/energy_spectrum-{k + 1}") ) - # Secondary particles load_secondary_particles(ace_table, file, verbose=verbose) # Fission data if fissionable: - prompt_block = ace_table.fission_multiplicity_block + prompt_block = ace_table.fission_multiplicity_block delayed_block = ace_table.delayed_fission_multiplicity_block - dnp_block = ace_table.delayed_neutron_precursor_block + dnp_block = ace_table.delayed_neutron_precursor_block load_fission_multiplicity( - prompt_block.multiplicity, fission_group.create_group("prompt_multiplicity") + prompt_block.multiplicity, + fission_group.create_group("prompt_multiplicity") ) if delayed_block is not None: load_fission_multiplicity( delayed_block.multiplicity, - fission_group.create_group("delayed_multiplicity"), + fission_group.create_group("delayed_multiplicity") ) if dnp_block is not None: - N_DNP = dnp_block.number_delayed_precursors - fractions = np.zeros(N_DNP) + N_DNP = dnp_block.number_delayed_precursors + fractions = np.zeros(N_DNP) decay_rates = np.zeros(N_DNP) for k in range(N_DNP): d = dnp_block.precursor_group_data(k + 1) - if ( - d.number_interpolation_regions != 0 - or len(d.probabilities[:]) != 2 - or d.probabilities[0] != d.probabilities[1] - ): + if (d.number_interpolation_regions != 0 + or len(d.probabilities[:]) != 2 + or d.probabilities[0] != d.probabilities[1]): print_error("Non-constant delayed neutron precursor fraction") - fractions[k] = d.probabilities[0] + fractions[k] = d.probabilities[0] decay_rates[k] = d.decay_constant prec = fission_group.create_group("delayed_neutron_precursors") - prec.create_dataset("fractions", data=fractions) + prec.create_dataset("fractions", data=fractions) prec.create_dataset("decay_rates", data=decay_rates).attrs["unit"] = "/s" delayed_spectrum_block = ace_table.delayed_neutron_energy_distribution_block for k in range(N_DNP): load_energy_distribution( delayed_spectrum_block.energy_distribution_data(k + 1), - prec.create_group(f"energy_spectrum-{k + 1}"), + prec.create_group(f"energy_spectrum-{k + 1}") ) file.close() return mcdc_name -# -- Main ---------------------------------------------------------------------- +def process_pstar_only_file(symbol, A, awr, output_dir, pstar_dir, verbose=False): + """ + Create a minimal HDF5 file for an isotope that has no ACE data but does + have a PSTAR stopping power file. Returns the output filename, or None if + no PSTAR file was found. + """ + Z = SYMBOL_TO_Z[symbol] + nuclide_name = f"{symbol}{A}" + mcdc_name = f"{nuclide_name}-{T_KELVIN}K.h5" + out_path = os.path.join(output_dir, mcdc_name) + + if verbose: + print(f"\n{'='*80}") + print(f" (no ACE) -> {mcdc_name} [stopping power only]") + + file = h5py.File(out_path, "w") + file.attrs["source_title"] = "PSTAR (NIST) stopping power only — no ACE data" + file.attrs["source_version"] = "N/A" + file.attrs["source_date"] = "N/A" + + file.create_dataset("nuclide_name", data=nuclide_name) + file.create_dataset("excitation_level", data=0) + file.create_dataset("temperature", data=T_KELVIN).attrs["unit"] = "K" + file.create_dataset("atomic_number", data=Z) + file.create_dataset("mass_number", data=A) + file.create_dataset("atomic_weight_ratio", data=awr) + file.create_dataset("fissionable", data=False) + + written = write_stopping_power(file, pstar_dir, symbol, verbose=verbose) + file.close() + + if not written: + # No PSTAR data either — remove the empty file and signal failure + os.remove(out_path) + return None + + return mcdc_name + + +# -- Main ---------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser( - description="Convert proton ACE files to MC/DC-compatible HDF5" + description="Convert TENDL proton ACE files to MC/DC-compatible HDF5" ) - parser.add_argument("--ace_dir", default=os.getenv("MCDC_ACELIB")) + parser.add_argument("--ace_dir", default=os.getenv("MCDC_ACELIB")) parser.add_argument("--output_dir", default=os.getenv("MCDC_LIB")) - parser.add_argument("--pstar_dir", default=os.getenv("PSTAR_LIB")) - parser.add_argument("--rewrite", action="store_true", default=False) - parser.add_argument("--verbose", action="store_true", default=False) + parser.add_argument("--pstar_dir", default=os.getenv("PSTAR_LIB")) + parser.add_argument("--rewrite", action="store_true", default=False) + parser.add_argument("--verbose", action="store_true", default=False) args = parser.parse_args() if args.ace_dir is None: @@ -836,18 +748,20 @@ def main(): print(f"Output directory: {args.output_dir}") print(f"PSTAR directory : {args.pstar_dir}\n") - all_files = sorted(os.listdir(args.ace_dir)) + ace_files = sorted(f for f in os.listdir(args.ace_dir) if f.endswith(".ace")) + + # ── Pass 1: ACE files ───────────────────────────────────────────────────── if args.rewrite: - target_files = all_files + target_files = ace_files else: target_files = [] - for fname in all_files: + for fname in ace_files: try: with open(os.path.join(args.ace_dir, fname)) as f: hdr = ACEtk.Header.from_string(f.readline()) Z, A, S, _ = decode_ace_zaid(hdr.zaid) - symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") + symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" if not any( f.startswith(nuclide_name + "-") @@ -858,11 +772,8 @@ def main(): target_files.append(fname) errors = [] - pbar = tqdm( - target_files, - disable=args.verbose, - bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} {postfix}", - ) + pbar = tqdm(target_files, disable=args.verbose, + bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} {postfix}") for ace_name in pbar: pbar.set_postfix_str(ace_name) @@ -879,10 +790,39 @@ def main(): errors.append((ace_name, str(exc))) if args.verbose: import traceback - traceback.print_exc() - print(f"\nDone. {len(target_files) - len(errors)} succeeded, {len(errors)} failed.") + # ── Pass 2: PSTAR-only isotopes (e.g. H, He) ───────────────────────────── + # For each entry in PSTAR_ONLY_ISOTOPES, create a stopping-power-only HDF5 + # file if one doesn't already exist (or if --rewrite is set). + + if args.pstar_dir is not None: + existing = set(os.listdir(args.output_dir)) + for symbol, A, awr in PSTAR_ONLY_ISOTOPES: + nuclide_name = f"{symbol}{A}" + mcdc_name = f"{nuclide_name}-{T_KELVIN}K.h5" + if not args.rewrite and mcdc_name in existing: + continue + try: + out = process_pstar_only_file( + symbol, A, awr, args.output_dir, args.pstar_dir, + verbose=args.verbose + ) + if out is None: + if args.verbose: + print(f" [skip] No PSTAR data for {nuclide_name}") + elif args.verbose: + print(f" -> wrote {out} [stopping power only]") + except Exception as exc: + errors.append((nuclide_name, str(exc))) + if args.verbose: + import traceback + traceback.print_exc() + + # ── Summary ─────────────────────────────────────────────────────────────── + + n_total = len(target_files) + len(PSTAR_ONLY_ISOTOPES) + print(f"\nDone. {n_total - len(errors)} succeeded, {len(errors)} failed.") if errors: print("\nFailed files:") for name, msg in errors: @@ -890,4 +830,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file From 2bdff29a88c518b69be50b8dc7c8f46936b17283 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Tue, 16 Jun 2026 12:57:03 -0700 Subject: [PATCH 55/64] range straggling for protons --- mcdc/transport/physics/proton/native.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index ec2644725..98b2dc227 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -275,8 +275,22 @@ def csda_edep(particle_container, collision_data_container, distance, simulation total_rho_gcm3 += density_gcm3 energy_loss = total_stopping_power * distance * total_rho_gcm3 * 1e6 + Z = nuclide["atomic_number"] + A = nuclide["mass_number"] + + # Range straggling - modify energy loss to have some slight variations + # TODO: Insert different thickness regimes to sample from (e.g. Bohr, Landau, Vavilov) + gaussian_variance = 0.1569 * total_rho_gcm3 * Z / A * distance + + # TODO: Account for relativistic particles + # gaussian_variance *= (1-0.5*beta**2)/(1-beta**2) + + gaussian = np.random.normal(loc=0.0, scale=np.sqrt(gaussian_variance)) + energy_loss += gaussian particle["E"] -= energy_loss * particle["w"] collision_data["energy_deposition"] += energy_loss * particle["w"] + if energy_loss * particle["w"] <= 0.0: + print(f'NEGATIVE: energy_loss = {energy_loss * particle["w"]}') return From 1e4a1e1ae798bd112c3414c6c172bf569bad6284 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Tue, 16 Jun 2026 12:58:04 -0700 Subject: [PATCH 56/64] fix small-number errors with csda-only proton transport --- mcdc/transport/tally/closeout.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mcdc/transport/tally/closeout.py b/mcdc/transport/tally/closeout.py index 96f2cd66c..a6a29cbac 100644 --- a/mcdc/transport/tally/closeout.py +++ b/mcdc/transport/tally/closeout.py @@ -137,7 +137,9 @@ def _finalize(tally, simulation, data): # Check for round-off error (TODO: Check why this is needed.) if abs(radicand) < 1e-16: data[offset_sum_square + i] = 0.0 - else: + if radicand < 0.0 and abs(radicand) < 1e-6: + data[offset_sum_square + i] = 0.0 + else: data[offset_sum_square + i] = math.sqrt(radicand) From 39ec210aafb48ae24f90061c90dbedce18b68867 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 1 Jul 2026 11:21:55 -0700 Subject: [PATCH 57/64] better energy deposition for protons --- mcdc/constant.py | 2 +- mcdc/mcdc_get/native_material.py | 58 +++++++++++++++++++ mcdc/mcdc_set/native_material.py | 58 +++++++++++++++++++ mcdc/numba_types.py | 5 ++ mcdc/object_/material.py | 25 ++++++++ mcdc/transport/physics/interface.py | 19 ++++-- mcdc/transport/physics/proton/native.py | 47 ++++++++++----- mcdc/transport/simulation.py | 3 + tools/data_library_generator/tendl_to_hdf5.py | 15 +++-- 9 files changed, 205 insertions(+), 27 deletions(-) diff --git a/mcdc/constant.py b/mcdc/constant.py index b3bf20fdf..b858d4f17 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -197,7 +197,7 @@ PROTON_MASS = 938.27208943e6 # eV/c^2 BOLTZMANN_K = 8.61733326e-5 # eV/K ELECTRON_CUTOFF_ENERGY = 100 # eV -PROTON_CUTOFF_ENERGY = 1000 # eV - this is dictated by the TENDL data; minimum of 1000 eV on the energy grid +PROTON_CUTOFF_ENERGY = 1000 # eV MU_CUTOFF = 0.999999 THERMAL_THRESHOLD_FACTOR = 400 diff --git a/mcdc/mcdc_get/native_material.py b/mcdc/mcdc_get/native_material.py index 361294585..5aa48cbcb 100644 --- a/mcdc/mcdc_get/native_material.py +++ b/mcdc/mcdc_get/native_material.py @@ -117,3 +117,61 @@ def element_densities_chunk(start, length, native_material, data): start += native_material["element_densities_offset"] end = start + length return data[start:end] + + +@njit +def stopping_power(index, native_material, data): + offset = native_material["stopping_power_offset"] + return data[offset + index] + + +@njit +def stopping_power_all(native_material, data): + start = native_material["stopping_power_offset"] + size = native_material["stopping_power_length"] + end = start + size + return data[start:end] + + +@njit +def stopping_power_last(native_material, data): + start = native_material["stopping_power_offset"] + size = native_material["stopping_power_length"] + end = start + size + return data[end - 1] + + +@njit +def stopping_power_chunk(start, length, native_material, data): + start += native_material["stopping_power_offset"] + end = start + length + return data[start:end] + + +@njit +def stopping_power_energy_grid(index, native_material, data): + offset = native_material["stopping_power_energy_grid_offset"] + return data[offset + index] + + +@njit +def stopping_power_energy_grid_all(native_material, data): + start = native_material["stopping_power_energy_grid_offset"] + size = native_material["stopping_power_energy_grid_length"] + end = start + size + return data[start:end] + + +@njit +def stopping_power_energy_grid_last(native_material, data): + start = native_material["stopping_power_energy_grid_offset"] + size = native_material["stopping_power_energy_grid_length"] + end = start + size + return data[end - 1] + + +@njit +def stopping_power_energy_grid_chunk(start, length, native_material, data): + start += native_material["stopping_power_energy_grid_offset"] + end = start + length + return data[start:end] diff --git a/mcdc/mcdc_set/native_material.py b/mcdc/mcdc_set/native_material.py index 303ffb474..ea7845d84 100644 --- a/mcdc/mcdc_set/native_material.py +++ b/mcdc/mcdc_set/native_material.py @@ -117,3 +117,61 @@ def element_densities_chunk(start, length, native_material, data, value): start += native_material["element_densities_offset"] end = start + length data[start:end] = value + + +@njit +def stopping_power(index, native_material, data, value): + offset = native_material["stopping_power_offset"] + data[offset + index] = value + + +@njit +def stopping_power_all(native_material, data, value): + start = native_material["stopping_power_offset"] + size = native_material["stopping_power_length"] + end = start + size + data[start:end] = value + + +@njit +def stopping_power_last(native_material, data, value): + start = native_material["stopping_power_offset"] + size = native_material["stopping_power_length"] + end = start + size + data[end - 1] = value + + +@njit +def stopping_power_chunk(start, length, native_material, data, value): + start += native_material["stopping_power_offset"] + end = start + length + data[start:end] = value + + +@njit +def stopping_power_energy_grid(index, native_material, data, value): + offset = native_material["stopping_power_energy_grid_offset"] + data[offset + index] = value + + +@njit +def stopping_power_energy_grid_all(native_material, data, value): + start = native_material["stopping_power_energy_grid_offset"] + size = native_material["stopping_power_energy_grid_length"] + end = start + size + data[start:end] = value + + +@njit +def stopping_power_energy_grid_last(native_material, data, value): + start = native_material["stopping_power_energy_grid_offset"] + size = native_material["stopping_power_energy_grid_length"] + end = start + size + data[end - 1] = value + + +@njit +def stopping_power_energy_grid_chunk(start, length, native_material, data, value): + start += native_material["stopping_power_energy_grid_offset"] + end = start + length + data[start:end] = value diff --git a/mcdc/numba_types.py b/mcdc/numba_types.py index 22c643d76..3f65aecee 100644 --- a/mcdc/numba_types.py +++ b/mcdc/numba_types.py @@ -359,6 +359,11 @@ ('nuclide_densities_length', int64), ('element_densities_offset', int64), ('element_densities_length', int64), + ('stopping_power_provided', bool), + ('stopping_power_offset', int64), + ('stopping_power_length', int64), + ('stopping_power_energy_grid_offset', int64), + ('stopping_power_energy_grid_length', int64), ('ID', int64), ('parent_ID', int64), ]) diff --git a/mcdc/object_/material.py b/mcdc/object_/material.py index dc2e40071..8ea1c448d 100644 --- a/mcdc/object_/material.py +++ b/mcdc/object_/material.py @@ -1,5 +1,6 @@ import numpy as np import os +import h5py from numpy import float64 from numpy.typing import NDArray @@ -101,6 +102,10 @@ class Material(MaterialBase): elements: list[Element] nuclide_densities: NDArray[float64] element_densities: NDArray[float64] + # + stopping_power_provided: bool = False + stopping_power: NDArray[float64] + stopping_power_energy_grid: NDArray[float64] def __init__( self, @@ -129,6 +134,10 @@ def __init__( self.elements = [] self.element_densities = np.zeros(len(element_composition)) + # Stopping power + self.stopping_power = np.array([]) + self.stopping_power_energy_grid = np.array([]) + # Check if library directory is set lib_dir = os.getenv("MCDC_LIB") if lib_dir is None: @@ -220,6 +229,21 @@ def __repr__(self): f" - {element.name:<5} | {self.element_composition[element]}\n" ) return text + + def add_stopping_power( + self, + stopping_power_filename: str = "", + ): + + self.stopping_power_provided = True + + dir_name = os.getenv("MCDC_LIB") + file_name = stopping_power_filename + file = h5py.File(f"{dir_name}/{file_name}.h5", "r") + + self.stopping_power = file["stopping_power"]["total_stopping_power"][()] + self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] + file.close() # Currently supported temperatures @@ -447,6 +471,7 @@ def __repr__(self): return text + def set_nuclides_from_elements(material): material.nuclides = [] material.nuclide_composition = {} diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index 65a0cd5d8..2375a0761 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -76,6 +76,8 @@ def collision_distance(particle_container, simulation, data): elif particle["particle_type"] == PARTICLE_PROTON: SigmaT = macro_xs(PROTON_REACTION_TOTAL, particle_container, simulation, data) + # print(f'SigmaT = {SigmaT}, E = {particle["E"]}') + # Vacuum material? if SigmaT == 0.0: return INF @@ -114,16 +116,25 @@ def csda_distance(particle_container, simulation, data): for i in range(material["N_nuclide"]): nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) nuclide = simulation["nuclides"][nuclide_ID] - dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) - dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) - dedx = np.interp(E / 1e6, dedx_energies, dedx_values) - total_dedx += dedx * 1e6 + + if not material["stopping_power_provided"]: + dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) + dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) + dedx = np.interp(E / 1e6, dedx_energies, dedx_values) + total_dedx += dedx * 1e6 atomic_mass = nuclide["atomic_weight_ratio"] nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) total_rho += density_gcm3 + + if material["stopping_power_provided"]: + dedx_values = mcdc_get.native_material.stopping_power_all(material, data) + dedx_energies = mcdc_get.native_material.stopping_power_energy_grid_all(material, data) + dedx = np.interp(E / 1e6, dedx_energies, dedx_values) + total_dedx = dedx * 1e6 + max_fractional_e_loss = simulation["settings"]["csda_max_fractional_e_loss"] return max_fractional_e_loss * E / total_dedx / total_rho diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 98b2dc227..3a531479e 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -246,6 +246,7 @@ def csda_edep(particle_container, collision_data_container, distance, simulation particle = particle_container[0] collision_data = collision_data_container[0] material = simulation["native_materials"][particle["material_ID"]] + # print(f'material = {material}, type = {type(material)}, {material.dtype.names}') E = particle["E"] # Check for cutoff energy @@ -254,43 +255,61 @@ def csda_edep(particle_container, collision_data_container, distance, simulation particle["alive"] = False particle["E"] = 0.0 return - + total_stopping_power = 0.0 total_rho_gcm3 = 0.0 + total_Z = 0.0 + total_A = 0.0 # Find the total stopping power by summing over every nuclide in the material for i in range(material["N_nuclide"]): nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) nuclide = simulation["nuclides"][nuclide_ID] - dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) - dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) - # TODO: replace np.interp with a non-numpy function?? - dedx = np.interp(E / 1e6, dedx_energies, dedx_values) - total_stopping_power += dedx + # If no stopping power provided, we calculate it ourselves here + if not material["stopping_power_provided"]: + dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) + dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) + + # TODO: replace np.interp with a non-numpy function?? + dedx = np.interp(E / 1e6, dedx_energies, dedx_values) + total_stopping_power += dedx * 1e6 - # Convert atoms/barn-cm to g/cm³: + # Convert atoms/barn-cm to g/cm3: atomic_mass = nuclide["atomic_weight_ratio"] # mass in amu nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) total_rho_gcm3 += density_gcm3 - energy_loss = total_stopping_power * distance * total_rho_gcm3 * 1e6 - Z = nuclide["atomic_number"] - A = nuclide["mass_number"] + total_Z += nuclide["atomic_number"] + total_A += nuclide["mass_number"] + + Z = total_Z / material["N_nuclide"] + A = total_A / material["N_nuclide"] + + if material["stopping_power_provided"]: + dedx_values = mcdc_get.native_material.stopping_power_all(material, data) + dedx_energies = mcdc_get.native_material.stopping_power_energy_grid_all(material, data) + + dedx = np.interp(E / 1e6, dedx_energies, dedx_values) + total_stopping_power = dedx * 1e6 + + energy_loss = total_stopping_power * total_rho_gcm3 * distance # Range straggling - modify energy loss to have some slight variations # TODO: Insert different thickness regimes to sample from (e.g. Bohr, Landau, Vavilov) gaussian_variance = 0.1569 * total_rho_gcm3 * Z / A * distance - # TODO: Account for relativistic particles - # gaussian_variance *= (1-0.5*beta**2)/(1-beta**2) - gaussian = np.random.normal(loc=0.0, scale=np.sqrt(gaussian_variance)) energy_loss += gaussian - particle["E"] -= energy_loss * particle["w"] + particle["E"] -= energy_loss collision_data["energy_deposition"] += energy_loss * particle["w"] + if energy_loss * particle["w"] <= 0.0: + print(f'total density = {total_rho_gcm3}') + print(f'stopping_power = {total_stopping_power}') + print(f'distance = {distance}') print(f'NEGATIVE: energy_loss = {energy_loss * particle["w"]}') + raise ValueError('negative energy loss') return diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index 4c86e387f..845be2d74 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -463,6 +463,9 @@ def move_to_event(particle_container, simulation, data): elif geometry.check_coincidence(d_csda, distance): particle["event"] += EVENT_CSDA_EDEP + if distance < 0.0: + raise ValueError(f"Negative distance") + # ================================================================================== # Move particle # ================================================================================== diff --git a/tools/data_library_generator/tendl_to_hdf5.py b/tools/data_library_generator/tendl_to_hdf5.py index dd627fd32..1701c7141 100644 --- a/tools/data_library_generator/tendl_to_hdf5.py +++ b/tools/data_library_generator/tendl_to_hdf5.py @@ -67,13 +67,11 @@ # -- Constants ----------------------------------------------------------------- ZAP_NAMES = { - 0: "photon", - 1: "neutron", - 1001: "proton", - 1002: "deuteron", - 1003: "triton", - 2003: "He3", - 2004: "alpha", + 1: "neutron", + 31: "deuteron", + 32: "triton", + 33: "He3", + 34: "alpha", } Z_TO_SYMBOL = { @@ -119,6 +117,7 @@ def print_error(msg): print(f"\n[ERROR] {msg}", file=sys.stderr) sys.exit(1) + def print_note(msg): print(f" [note] {msg}") @@ -135,7 +134,7 @@ def decode_ace_zaid(zaid): def load_pstar_file(filepath): """ - Load a two-column PSTAR file (Energy MeV, Stopping power MeV cm2/g). + Load a two-column PSTAR file (Energy MeV, Stopping power MeV cm^2/g). Returns (energies, stopping_powers) as float64 arrays. """ energies, sps = [], [] From ae110aca42b816f0e405b0ed8e940d1064674ff3 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 1 Jul 2026 15:48:11 -0700 Subject: [PATCH 58/64] updated proton reactions to include capture & inelastic scatter (instead of just nonelastic rxns) --- examples/proton_beam/input_10MeV.py | 97 -------------------- examples/proton_beam/input_1MeV.py | 96 -------------------- examples/proton_beam/process.py | 47 ---------- mcdc/constant.py | 3 +- mcdc/mcdc_get/__init__.py | 4 +- mcdc/mcdc_get/nuclide.py | 98 ++++++++++++++++----- mcdc/mcdc_get/proton_nonelastic_reaction.py | 84 ------------------ mcdc/mcdc_set/__init__.py | 4 +- mcdc/mcdc_set/nuclide.py | 98 ++++++++++++++++----- mcdc/mcdc_set/proton_nonelastic_reaction.py | 84 ------------------ mcdc/numba_types.py | 25 ++++-- mcdc/object_/nuclide.py | 37 +++++--- mcdc/object_/proton_reaction.py | 42 +++++---- mcdc/transport/physics/proton/multigroup.py | 5 +- mcdc/transport/physics/proton/native.py | 75 +++++++++++----- 15 files changed, 289 insertions(+), 510 deletions(-) delete mode 100644 examples/proton_beam/input_10MeV.py delete mode 100644 examples/proton_beam/input_1MeV.py delete mode 100644 examples/proton_beam/process.py delete mode 100644 mcdc/mcdc_get/proton_nonelastic_reaction.py delete mode 100644 mcdc/mcdc_set/proton_nonelastic_reaction.py diff --git a/examples/proton_beam/input_10MeV.py b/examples/proton_beam/input_10MeV.py deleted file mode 100644 index b63de3b84..000000000 --- a/examples/proton_beam/input_10MeV.py +++ /dev/null @@ -1,97 +0,0 @@ -import numpy as np -import mcdc - -# ====================================================================================== -# Set model -# ====================================================================================== -# Proton beam, incident on a slab - -# Set materials (atom density in units of atoms/barn-cm) -silicon = mcdc.Material("silicon", {"Si28": 0.05}) - -# Set surfaces -sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") -sx2 = mcdc.Surface.PlaneX(x=0.1, boundary_condition="vacuum") -sy1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") -sy2 = mcdc.Surface.PlaneY(y=0.1, boundary_condition="vacuum") -sz1 = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") -sz2 = mcdc.Surface.PlaneZ(z=0.1, boundary_condition="vacuum") - -slab = +sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2 - -# Set cells -slab_cell = mcdc.Cell(name="silicon", region=slab, fill=silicon) - -# ====================================================================================== -# Set source -# ====================================================================================== - -mcdc.Source( - x=[0.0, 0.0], - y=[0.0, 0.0002], - z=[0.0, 0.0002], - direction=[1.0, 0.0, 0.0], - energy=1e7, - # energy_group=0, - particle_type="proton", - # time=[0.0, 0.0], -) - -# ====================================================================================== -# Set tallies, settings, techniques, and run MC/DC -# ====================================================================================== - -# Tallies -percent_of_range = np.array( - [ - 0.0, - 10, - 20, - 30, - 40, - 50, - 60, - 70, - 80, - 85, - 90, - 91, - 92, - 93, - 94, - 95, - 96, - 97, - 98, - 99, - 100, - 101, - 102, - 103, - 104, - 105, - 107.5, - 110, - 115, - ] -) -range = 714.59 * 1e-4 # cm - -bin_edges = range * percent_of_range * 1e-2 - -# mesh = mcdc.MeshUniform(x=(0.0, 0.002, 50)) -mesh = mcdc.MeshStructured(x=(bin_edges)) -mcdc.Tally(name=f"edep", mesh=mesh, scores=["energy_deposition"]) - -# Settings -mcdc.settings.set_transported_particles(["proton"]) -mcdc.settings.N_particle = 10_000 -mcdc.settings.N_batch = 1 -mcdc.settings.csda = True -mcdc.settings.csda_max_fractional_e_loss = 0.001 - -# Techniques -mcdc.simulation.implicit_capture() - -# Run -mcdc.run() diff --git a/examples/proton_beam/input_1MeV.py b/examples/proton_beam/input_1MeV.py deleted file mode 100644 index 21df0fcdb..000000000 --- a/examples/proton_beam/input_1MeV.py +++ /dev/null @@ -1,96 +0,0 @@ -import numpy as np -import mcdc - -# ====================================================================================== -# Set model -# ====================================================================================== -# Proton beam, incident on a slab - -# Set materials (atom density in units of atoms/barn-cm) -silicon = mcdc.Material("silicon", {"C12": 0.05}) - -# Set surfaces -sx1 = mcdc.Surface.PlaneX(x=0.0, boundary_condition="vacuum") -sx2 = mcdc.Surface.PlaneX(x=0.002, boundary_condition="vacuum") -sy1 = mcdc.Surface.PlaneY(y=0.0, boundary_condition="vacuum") -sy2 = mcdc.Surface.PlaneY(y=1.0, boundary_condition="vacuum") -sz1 = mcdc.Surface.PlaneZ(z=0.0, boundary_condition="vacuum") -sz2 = mcdc.Surface.PlaneZ(z=1.0, boundary_condition="vacuum") - -slab = +sx1 & -sx2 & +sy1 & -sy2 & +sz1 & -sz2 - -# Set cells -slab_cell = mcdc.Cell(name="silicon", region=slab, fill=silicon) - -# ====================================================================================== -# Set source -# ====================================================================================== - -mcdc.Source( - x=[0.0, 0.0], - y=[0.0, 1.0], - z=[0.0, 1.0], - direction=[1.0, 0.0, 0.0], - energy=1e6, - # energy_group=0, - particle_type="proton", - # time=[0.0, 0.0], -) - -# ====================================================================================== -# Set tallies, settings, techniques, and run MC/DC -# ====================================================================================== - -# Tallies -percent_of_range = np.array( - [ - 0.0, - 10, - 20, - 30, - 40, - 50, - 60, - 70, - 80, - 85, - 90, - 91, - 92, - 93, - 94, - 95, - 96, - 97, - 98, - 99, - 100, - 101, - 102, - 103, - 104, - 105, - 107.5, - 110, - 115, - ] -) -range = 16.45 * 1e-4 # cm - -bin_edges = range * percent_of_range * 1e-2 - -mesh = mcdc.MeshStructured(x=(bin_edges)) -mcdc.Tally(name=f"edep", mesh=mesh, scores=["energy_deposition"]) - -# Settings -mcdc.settings.set_transported_particles(["proton"]) -mcdc.settings.N_particle = 1_000 -mcdc.settings.N_batch = 1 -mcdc.settings.csda = True -mcdc.settings.csda_max_fractional_e_loss = 0.01 - -# Techniques -mcdc.simulation.implicit_capture() - -# Run -mcdc.run() diff --git a/examples/proton_beam/process.py b/examples/proton_beam/process.py deleted file mode 100644 index 18150c794..000000000 --- a/examples/proton_beam/process.py +++ /dev/null @@ -1,47 +0,0 @@ -import h5py -import numpy as np -import matplotlib.pyplot as plt - -energy = 1 # MeV - -# with h5py.File(f"output_{energy}mev.h5") as f: -with h5py.File(f"output.h5") as f: - isotope_edep = list(f["tallies"].keys())[0] - - edep = f["tallies"][f"{isotope_edep}"]["energy_deposition"]["mean"][()] - xgrid = f["tallies"][f"{isotope_edep}"]["grid"]["x"][()] - # print(f'xgrid = {xgrid}') - - normalized_edep = np.zeros_like(edep) - centers = np.zeros_like(edep) - for i in range(len(xgrid) - 1): - width = xgrid[i + 1] - xgrid[i] - centers[i] = xgrid[i] + width / 2 - normalized_edep[i] = edep[i] / (energy * 1e6) / width - - normalized_edep = np.array(normalized_edep) - index_of_depth_at_max = np.argmax(normalized_edep) - - print(rf"peak location: {xgrid[index_of_depth_at_max]} um") - print(f"peak magnitude = {np.max(normalized_edep)}") - - -# TODO: add automatic range calculations based on PSTAR data -range = 0.001645 - -plt.plot(centers * 1e4, normalized_edep, label="edep tally") -plt.vlines( - range * 1e4, - 0, - np.max(normalized_edep), - linestyle="--", - label="theoretical Bragg peak for 1 MeV protons", - color="red", -) -plt.title(f"Energy Deposition of {energy} MeV Protons in a Slab of Si-28") -plt.xlabel(r"x [$\mu$m]") -plt.ylabel("MeV/cm") -plt.ylim(0, 1300) -plt.legend() -plt.savefig(f"Si-28_edep_{energy}MeV.png") -# plt.show() diff --git a/mcdc/constant.py b/mcdc/constant.py index b858d4f17..832dc2715 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -116,7 +116,8 @@ ELECTRON_REACTION_EXCITATION = 106 PROTON_REACTION_TOTAL = 200 PROTON_REACTION_ELASTIC_SCATTERING = 201 -PROTON_REACTION_NONELASTIC = 202 +PROTON_REACTION_CAPTURE = 202 +PROTON_REACTION_INELASTIC_SCATTERING = 203 # Particle types PARTICLE_NEUTRON = 0 diff --git a/mcdc/mcdc_get/__init__.py b/mcdc/mcdc_get/__init__.py index 88d1c322b..09e537e26 100644 --- a/mcdc/mcdc_get/__init__.py +++ b/mcdc/mcdc_get/__init__.py @@ -80,9 +80,11 @@ import mcdc.mcdc_get.neutron_inelastic_scattering_reaction as neutron_inelastic_scattering_reaction +import mcdc.mcdc_get.proton_capture_reaction as proton_capture_reaction + import mcdc.mcdc_get.proton_elastic_scattering_reaction as proton_elastic_scattering_reaction -import mcdc.mcdc_get.proton_nonelastic_reaction as proton_nonelastic_reaction +import mcdc.mcdc_get.proton_inelastic_scattering_reaction as proton_inelastic_scattering_reaction import mcdc.mcdc_get.collision_data as collision_data diff --git a/mcdc/mcdc_get/nuclide.py b/mcdc/mcdc_get/nuclide.py index d86a2a3bb..e30d775dd 100644 --- a/mcdc/mcdc_get/nuclide.py +++ b/mcdc/mcdc_get/nuclide.py @@ -265,30 +265,59 @@ def proton_elastic_xs_chunk(start, length, nuclide, data): @njit -def proton_nonelastic_xs(index, nuclide, data): - offset = nuclide["proton_nonelastic_xs_offset"] +def proton_capture_xs(index, nuclide, data): + offset = nuclide["proton_capture_xs_offset"] return data[offset + index] @njit -def proton_nonelastic_xs_all(nuclide, data): - start = nuclide["proton_nonelastic_xs_offset"] - size = nuclide["proton_nonelastic_xs_length"] +def proton_capture_xs_all(nuclide, data): + start = nuclide["proton_capture_xs_offset"] + size = nuclide["proton_capture_xs_length"] end = start + size return data[start:end] @njit -def proton_nonelastic_xs_last(nuclide, data): - start = nuclide["proton_nonelastic_xs_offset"] - size = nuclide["proton_nonelastic_xs_length"] +def proton_capture_xs_last(nuclide, data): + start = nuclide["proton_capture_xs_offset"] + size = nuclide["proton_capture_xs_length"] end = start + size return data[end - 1] @njit -def proton_nonelastic_xs_chunk(start, length, nuclide, data): - start += nuclide["proton_nonelastic_xs_offset"] +def proton_capture_xs_chunk(start, length, nuclide, data): + start += nuclide["proton_capture_xs_offset"] + end = start + length + return data[start:end] + + +@njit +def proton_inelastic_xs(index, nuclide, data): + offset = nuclide["proton_inelastic_xs_offset"] + return data[offset + index] + + +@njit +def proton_inelastic_xs_all(nuclide, data): + start = nuclide["proton_inelastic_xs_offset"] + size = nuclide["proton_inelastic_xs_length"] + end = start + size + return data[start:end] + + +@njit +def proton_inelastic_xs_last(nuclide, data): + start = nuclide["proton_inelastic_xs_offset"] + size = nuclide["proton_inelastic_xs_length"] + end = start + size + return data[end - 1] + + +@njit +def proton_inelastic_xs_chunk(start, length, nuclide, data): + start += nuclide["proton_inelastic_xs_offset"] end = start + length return data[start:end] @@ -439,30 +468,59 @@ def proton_elastic_scattering_reaction_IDs_chunk(start, length, nuclide, data): @njit -def proton_nonelastic_reaction_IDs(index, nuclide, data): - offset = nuclide["proton_nonelastic_reaction_IDs_offset"] +def proton_capture_reaction_IDs(index, nuclide, data): + offset = nuclide["proton_capture_reaction_IDs_offset"] + return data[offset + index] + + +@njit +def proton_capture_reaction_IDs_all(nuclide, data): + start = nuclide["proton_capture_reaction_IDs_offset"] + size = nuclide["N_proton_capture_reaction"] + end = start + size + return data[start:end] + + +@njit +def proton_capture_reaction_IDs_last(nuclide, data): + start = nuclide["proton_capture_reaction_IDs_offset"] + size = nuclide["N_proton_capture_reaction"] + end = start + size + return data[end - 1] + + +@njit +def proton_capture_reaction_IDs_chunk(start, length, nuclide, data): + start += nuclide["proton_capture_reaction_IDs_offset"] + end = start + length + return data[start:end] + + +@njit +def proton_inelastic_scattering_reaction_IDs(index, nuclide, data): + offset = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] return data[offset + index] @njit -def proton_nonelastic_reaction_IDs_all(nuclide, data): - start = nuclide["proton_nonelastic_reaction_IDs_offset"] - size = nuclide["N_proton_nonelastic_reaction"] +def proton_inelastic_scattering_reaction_IDs_all(nuclide, data): + start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_inelastic_scattering_reaction"] end = start + size return data[start:end] @njit -def proton_nonelastic_reaction_IDs_last(nuclide, data): - start = nuclide["proton_nonelastic_reaction_IDs_offset"] - size = nuclide["N_proton_nonelastic_reaction"] +def proton_inelastic_scattering_reaction_IDs_last(nuclide, data): + start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_inelastic_scattering_reaction"] end = start + size return data[end - 1] @njit -def proton_nonelastic_reaction_IDs_chunk(start, length, nuclide, data): - start += nuclide["proton_nonelastic_reaction_IDs_offset"] +def proton_inelastic_scattering_reaction_IDs_chunk(start, length, nuclide, data): + start += nuclide["proton_inelastic_scattering_reaction_IDs_offset"] end = start + length return data[start:end] diff --git a/mcdc/mcdc_get/proton_nonelastic_reaction.py b/mcdc/mcdc_get/proton_nonelastic_reaction.py deleted file mode 100644 index 619a68430..000000000 --- a/mcdc/mcdc_get/proton_nonelastic_reaction.py +++ /dev/null @@ -1,84 +0,0 @@ -# The following is automatically generated by code_factory.py - -from numba import njit - - -@njit -def spectrum_probability_grid(index, proton_nonelastic_reaction, data): - offset = proton_nonelastic_reaction["spectrum_probability_grid_offset"] - return data[offset + index] - - -@njit -def spectrum_probability_grid_all(proton_nonelastic_reaction, data): - start = proton_nonelastic_reaction["spectrum_probability_grid_offset"] - size = proton_nonelastic_reaction["spectrum_probability_grid_length"] - end = start + size - return data[start:end] - - -@njit -def spectrum_probability_grid_last(proton_nonelastic_reaction, data): - start = proton_nonelastic_reaction["spectrum_probability_grid_offset"] - size = proton_nonelastic_reaction["spectrum_probability_grid_length"] - end = start + size - return data[end - 1] - - -@njit -def spectrum_probability_grid_chunk(start, length, proton_nonelastic_reaction, data): - start += proton_nonelastic_reaction["spectrum_probability_grid_offset"] - end = start + length - return data[start:end] - - -@njit -def spectrum_probability_vector(index_1, proton_nonelastic_reaction, data): - offset = proton_nonelastic_reaction["spectrum_probability_offset"] - stride = proton_nonelastic_reaction["N_spectrum"] - start = offset + index_1 * stride - end = start + stride - return data[start:end] - - -@njit -def spectrum_probability(index_1, index_2, proton_nonelastic_reaction, data): - offset = proton_nonelastic_reaction["spectrum_probability_offset"] - stride = proton_nonelastic_reaction["N_spectrum"] - return data[offset + index_1 * stride + index_2] - - -@njit -def spectrum_probability_chunk(start, length, proton_nonelastic_reaction, data): - start += proton_nonelastic_reaction["spectrum_probability_offset"] - end = start + length - return data[start:end] - - -@njit -def energy_spectrum_IDs(index, proton_nonelastic_reaction, data): - offset = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] - return data[offset + index] - - -@njit -def energy_spectrum_IDs_all(proton_nonelastic_reaction, data): - start = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] - size = proton_nonelastic_reaction["N_energy_spectrum"] - end = start + size - return data[start:end] - - -@njit -def energy_spectrum_IDs_last(proton_nonelastic_reaction, data): - start = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] - size = proton_nonelastic_reaction["N_energy_spectrum"] - end = start + size - return data[end - 1] - - -@njit -def energy_spectrum_IDs_chunk(start, length, proton_nonelastic_reaction, data): - start += proton_nonelastic_reaction["energy_spectrum_IDs_offset"] - end = start + length - return data[start:end] diff --git a/mcdc/mcdc_set/__init__.py b/mcdc/mcdc_set/__init__.py index ed05ef728..bc3d3cb2f 100644 --- a/mcdc/mcdc_set/__init__.py +++ b/mcdc/mcdc_set/__init__.py @@ -80,9 +80,11 @@ import mcdc.mcdc_set.neutron_inelastic_scattering_reaction as neutron_inelastic_scattering_reaction +import mcdc.mcdc_set.proton_capture_reaction as proton_capture_reaction + import mcdc.mcdc_set.proton_elastic_scattering_reaction as proton_elastic_scattering_reaction -import mcdc.mcdc_set.proton_nonelastic_reaction as proton_nonelastic_reaction +import mcdc.mcdc_set.proton_inelastic_scattering_reaction as proton_inelastic_scattering_reaction import mcdc.mcdc_set.collision_data as collision_data diff --git a/mcdc/mcdc_set/nuclide.py b/mcdc/mcdc_set/nuclide.py index f8d6e7b62..c9d60e0d2 100644 --- a/mcdc/mcdc_set/nuclide.py +++ b/mcdc/mcdc_set/nuclide.py @@ -265,30 +265,59 @@ def proton_elastic_xs_chunk(start, length, nuclide, data, value): @njit -def proton_nonelastic_xs(index, nuclide, data, value): - offset = nuclide["proton_nonelastic_xs_offset"] +def proton_capture_xs(index, nuclide, data, value): + offset = nuclide["proton_capture_xs_offset"] data[offset + index] = value @njit -def proton_nonelastic_xs_all(nuclide, data, value): - start = nuclide["proton_nonelastic_xs_offset"] - size = nuclide["proton_nonelastic_xs_length"] +def proton_capture_xs_all(nuclide, data, value): + start = nuclide["proton_capture_xs_offset"] + size = nuclide["proton_capture_xs_length"] end = start + size data[start:end] = value @njit -def proton_nonelastic_xs_last(nuclide, data, value): - start = nuclide["proton_nonelastic_xs_offset"] - size = nuclide["proton_nonelastic_xs_length"] +def proton_capture_xs_last(nuclide, data, value): + start = nuclide["proton_capture_xs_offset"] + size = nuclide["proton_capture_xs_length"] end = start + size data[end - 1] = value @njit -def proton_nonelastic_xs_chunk(start, length, nuclide, data, value): - start += nuclide["proton_nonelastic_xs_offset"] +def proton_capture_xs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_capture_xs_offset"] + end = start + length + data[start:end] = value + + +@njit +def proton_inelastic_xs(index, nuclide, data, value): + offset = nuclide["proton_inelastic_xs_offset"] + data[offset + index] = value + + +@njit +def proton_inelastic_xs_all(nuclide, data, value): + start = nuclide["proton_inelastic_xs_offset"] + size = nuclide["proton_inelastic_xs_length"] + end = start + size + data[start:end] = value + + +@njit +def proton_inelastic_xs_last(nuclide, data, value): + start = nuclide["proton_inelastic_xs_offset"] + size = nuclide["proton_inelastic_xs_length"] + end = start + size + data[end - 1] = value + + +@njit +def proton_inelastic_xs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_inelastic_xs_offset"] end = start + length data[start:end] = value @@ -439,30 +468,59 @@ def proton_elastic_scattering_reaction_IDs_chunk(start, length, nuclide, data, v @njit -def proton_nonelastic_reaction_IDs(index, nuclide, data, value): - offset = nuclide["proton_nonelastic_reaction_IDs_offset"] +def proton_capture_reaction_IDs(index, nuclide, data, value): + offset = nuclide["proton_capture_reaction_IDs_offset"] + data[offset + index] = value + + +@njit +def proton_capture_reaction_IDs_all(nuclide, data, value): + start = nuclide["proton_capture_reaction_IDs_offset"] + size = nuclide["N_proton_capture_reaction"] + end = start + size + data[start:end] = value + + +@njit +def proton_capture_reaction_IDs_last(nuclide, data, value): + start = nuclide["proton_capture_reaction_IDs_offset"] + size = nuclide["N_proton_capture_reaction"] + end = start + size + data[end - 1] = value + + +@njit +def proton_capture_reaction_IDs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_capture_reaction_IDs_offset"] + end = start + length + data[start:end] = value + + +@njit +def proton_inelastic_scattering_reaction_IDs(index, nuclide, data, value): + offset = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] data[offset + index] = value @njit -def proton_nonelastic_reaction_IDs_all(nuclide, data, value): - start = nuclide["proton_nonelastic_reaction_IDs_offset"] - size = nuclide["N_proton_nonelastic_reaction"] +def proton_inelastic_scattering_reaction_IDs_all(nuclide, data, value): + start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_inelastic_scattering_reaction"] end = start + size data[start:end] = value @njit -def proton_nonelastic_reaction_IDs_last(nuclide, data, value): - start = nuclide["proton_nonelastic_reaction_IDs_offset"] - size = nuclide["N_proton_nonelastic_reaction"] +def proton_inelastic_scattering_reaction_IDs_last(nuclide, data, value): + start = nuclide["proton_inelastic_scattering_reaction_IDs_offset"] + size = nuclide["N_proton_inelastic_scattering_reaction"] end = start + size data[end - 1] = value @njit -def proton_nonelastic_reaction_IDs_chunk(start, length, nuclide, data, value): - start += nuclide["proton_nonelastic_reaction_IDs_offset"] +def proton_inelastic_scattering_reaction_IDs_chunk(start, length, nuclide, data, value): + start += nuclide["proton_inelastic_scattering_reaction_IDs_offset"] end = start + length data[start:end] = value diff --git a/mcdc/mcdc_set/proton_nonelastic_reaction.py b/mcdc/mcdc_set/proton_nonelastic_reaction.py deleted file mode 100644 index 7105064c9..000000000 --- a/mcdc/mcdc_set/proton_nonelastic_reaction.py +++ /dev/null @@ -1,84 +0,0 @@ -# The following is automatically generated by code_factory.py - -from numba import njit - - -@njit -def spectrum_probability_grid(index, proton_nonelastic_reaction, data, value): - offset = proton_nonelastic_reaction["spectrum_probability_grid_offset"] - data[offset + index] = value - - -@njit -def spectrum_probability_grid_all(proton_nonelastic_reaction, data, value): - start = proton_nonelastic_reaction["spectrum_probability_grid_offset"] - size = proton_nonelastic_reaction["spectrum_probability_grid_length"] - end = start + size - data[start:end] = value - - -@njit -def spectrum_probability_grid_last(proton_nonelastic_reaction, data, value): - start = proton_nonelastic_reaction["spectrum_probability_grid_offset"] - size = proton_nonelastic_reaction["spectrum_probability_grid_length"] - end = start + size - data[end - 1] = value - - -@njit -def spectrum_probability_grid_chunk(start, length, proton_nonelastic_reaction, data, value): - start += proton_nonelastic_reaction["spectrum_probability_grid_offset"] - end = start + length - data[start:end] = value - - -@njit -def spectrum_probability_vector(index_1, proton_nonelastic_reaction, data, value): - offset = proton_nonelastic_reaction["spectrum_probability_offset"] - stride = proton_nonelastic_reaction["N_spectrum"] - start = offset + index_1 * stride - end = start + stride - data[start:end] - value - - -@njit -def spectrum_probability(index_1, index_2, proton_nonelastic_reaction, data, value): - offset = proton_nonelastic_reaction["spectrum_probability_offset"] - stride = proton_nonelastic_reaction["N_spectrum"] - data[offset + index_1 * stride + index_2] = value - - -@njit -def spectrum_probability_chunk(start, length, proton_nonelastic_reaction, data, value): - start += proton_nonelastic_reaction["spectrum_probability_offset"] - end = start + length - data[start:end] = value - - -@njit -def energy_spectrum_IDs(index, proton_nonelastic_reaction, data, value): - offset = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] - data[offset + index] = value - - -@njit -def energy_spectrum_IDs_all(proton_nonelastic_reaction, data, value): - start = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] - size = proton_nonelastic_reaction["N_energy_spectrum"] - end = start + size - data[start:end] = value - - -@njit -def energy_spectrum_IDs_last(proton_nonelastic_reaction, data, value): - start = proton_nonelastic_reaction["energy_spectrum_IDs_offset"] - size = proton_nonelastic_reaction["N_energy_spectrum"] - end = start + size - data[end - 1] = value - - -@njit -def energy_spectrum_IDs_chunk(start, length, proton_nonelastic_reaction, data, value): - start += proton_nonelastic_reaction["energy_spectrum_IDs_offset"] - end = start + length - data[start:end] = value diff --git a/mcdc/numba_types.py b/mcdc/numba_types.py index 3f65aecee..6510d852b 100644 --- a/mcdc/numba_types.py +++ b/mcdc/numba_types.py @@ -429,8 +429,10 @@ ('proton_total_xs_length', int64), ('proton_elastic_xs_offset', int64), ('proton_elastic_xs_length', int64), - ('proton_nonelastic_xs_offset', int64), - ('proton_nonelastic_xs_length', int64), + ('proton_capture_xs_offset', int64), + ('proton_capture_xs_length', int64), + ('proton_inelastic_xs_offset', int64), + ('proton_inelastic_xs_length', int64), ('N_neutron_elastic_scattering_reaction', int64), ('neutron_elastic_scattering_reaction_IDs_offset', int64), ('N_neutron_capture_reaction', int64), @@ -441,8 +443,10 @@ ('neutron_fission_reaction_IDs_offset', int64), ('N_proton_elastic_scattering_reaction', int64), ('proton_elastic_scattering_reaction_IDs_offset', int64), - ('N_proton_nonelastic_reaction', int64), - ('proton_nonelastic_reaction_IDs_offset', int64), + ('N_proton_capture_reaction', int64), + ('proton_capture_reaction_IDs_offset', int64), + ('N_proton_inelastic_scattering_reaction', int64), + ('proton_inelastic_scattering_reaction_IDs_offset', int64), ('neutron_fission_prompt_multiplicity_ID', int64), ('neutron_fission_delayed_multiplicity_ID', int64), ('N_neutron_fission_delayed_precursor', int64), @@ -542,13 +546,18 @@ ('parent_ID', int64), ]) +proton_capture_reaction = into_dtype([ + ('ID', int64), + ('parent_ID', int64), +]) + proton_elastic_scattering_reaction = into_dtype([ ('mu_table_ID', int64), ('ID', int64), ('parent_ID', int64), ]) -proton_nonelastic_reaction = into_dtype([ +proton_inelastic_scattering_reaction = into_dtype([ ('multiplicity', int64), ('angle_type', int64), ('mu_ID', int64), @@ -870,10 +879,12 @@ def set_simulation(N: dict): ('N_neutron_inelastic_scattering_reaction', int64), ('sources', source, (N['source'])), ('N_source', int64), + ('proton_capture_reactions', proton_capture_reaction, (N['proton_capture_reaction'])), + ('N_proton_capture_reaction', int64), ('proton_elastic_scattering_reactions', proton_elastic_scattering_reaction, (N['proton_elastic_scattering_reaction'])), ('N_proton_elastic_scattering_reaction', int64), - ('proton_nonelastic_reactions', proton_nonelastic_reaction, (N['proton_nonelastic_reaction'])), - ('N_proton_nonelastic_reaction', int64), + ('proton_inelastic_scattering_reactions', proton_inelastic_scattering_reaction, (N['proton_inelastic_scattering_reaction'])), + ('N_proton_inelastic_scattering_reaction', int64), ('proton_reactions', proton_reaction, (N['proton_reaction'])), ('N_proton_reaction', int64), ('cells', cell, (N['cell'])), diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index 2b3eb78e9..71dfabb35 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -20,7 +20,8 @@ ) from mcdc.object_.proton_reaction import ( ProtonReactionElasticScattering, - ProtonReactionNonelasticReaction, + ProtonReactionInelasticScattering, + ProtonReactionCapture, set_energy_distribution, ) from mcdc.object_.simulation import simulation @@ -53,7 +54,8 @@ class Nuclide(ObjectNonSingleton): proton_xs_energy_grid: NDArray[float64] proton_total_xs: NDArray[float64] proton_elastic_xs: NDArray[float64] - proton_nonelastic_xs: NDArray[float64] + proton_capture_xs: NDArray[float64] + proton_inelastic_xs: NDArray[float64] # neutron_elastic_scattering_reactions: list[NeutronReactionElasticScattering] neutron_capture_reactions: list[NeutronReactionCapture] @@ -61,7 +63,8 @@ class Nuclide(ObjectNonSingleton): neutron_fission_reactions: list[NeutronReactionFission] # proton_elastic_scattering_reactions: list[ProtonReactionElasticScattering] - proton_nonelastic_reactions: list[ProtonReactionNonelasticReaction] + proton_capture_reactions: list[ProtonReactionCapture] + proton_inelastic_scattering_reactions: list[ProtonReactionInelasticScattering] # neutron_fission_prompt_multiplicity: DataBase neutron_fission_delayed_multiplicity: DataBase @@ -102,14 +105,16 @@ def __init__(self, nuclide_name, temperature): self.proton_xs_energy_grid = np.zeros(0) self.proton_total_xs = np.zeros(0) self.proton_elastic_xs = np.zeros(0) - self.proton_nonelastic_xs = np.zeros(0) + self.proton_inelastic_xs = np.zeros(0) + self.proton_capture_xs = np.zeros(0) # Reactions self.neutron_elastic_scattering_reactions = [] self.neutron_capture_reactions = [] self.neutron_inelastic_scattering_reactions = [] self.neutron_fission_reactions = [] self.proton_elastic_scattering_reactions = [] - self.proton_nonelastic_reactions = [] + self.proton_inelastic_scattering_reactions = [] + self.proton_capture_reactions = [] # Fission self.neutron_fission_prompt_multiplicity = DataPolynomial(np.array([0.0])) self.neutron_fission_delayed_multiplicity = DataPolynomial(np.array([0.0])) @@ -286,14 +291,15 @@ def set_proton_data(self): self.proton_total_xs = np.zeros_like(self.proton_xs_energy_grid) self.proton_elastic_xs = np.zeros_like(self.proton_xs_energy_grid) - self.proton_nonelastic_xs = np.zeros_like(self.proton_xs_energy_grid) + self.proton_inelastic_xs = np.zeros_like(self.proton_xs_energy_grid) file.close() return rx_names = [ "elastic_scattering", - "nonelastic_reaction", + "inelastic_scattering", + "capture", ] # The reaction MTs @@ -318,11 +324,13 @@ def set_proton_data(self): # The total XS self.proton_total_xs = np.zeros_like(self.proton_xs_energy_grid) self.proton_elastic_xs = np.zeros_like(self.proton_xs_energy_grid) - self.proton_nonelastic_xs = np.zeros_like(self.proton_xs_energy_grid) + self.proton_inelastic_xs = np.zeros_like(self.proton_xs_energy_grid) + self.proton_capture_xs = np.zeros_like(self.proton_xs_energy_grid) xs_containers = [ self.proton_elastic_xs, - self.proton_nonelastic_xs, + self.proton_inelastic_xs, + self.proton_capture_xs, ] for xs_container, rx_name in list(zip(xs_containers, rx_names)): @@ -330,22 +338,25 @@ def set_proton_data(self): xs = file[f"proton_reactions/{rx_name}/{MT}/xs"] xs_container[xs.attrs["offset"] :] += xs[()] - self.proton_total_xs = self.proton_elastic_xs + self.proton_nonelastic_xs + self.proton_total_xs = self.proton_elastic_xs + self.proton_inelastic_xs + self.proton_capture_xs # ========================================================================== # The reactions # ========================================================================== self.proton_elastic_scattering_reactions = [] - self.proton_nonelastic_reactions = [] + self.proton_inelastic_scattering_reactions = [] + self.proton_capture_reactions = [] rx_containers = [ self.proton_elastic_scattering_reactions, - self.proton_nonelastic_reactions, + self.proton_inelastic_scattering_reactions, + self.proton_capture_reactions, ] rx_classes = [ ProtonReactionElasticScattering, - ProtonReactionNonelasticReaction, + ProtonReactionInelasticScattering, + ProtonReactionCapture, ] for rx_container, rx_name, rx_class in list( zip(rx_containers, rx_names, rx_classes) diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py index 99c3b6c96..7f5f27764 100644 --- a/mcdc/object_/proton_reaction.py +++ b/mcdc/object_/proton_reaction.py @@ -14,7 +14,8 @@ INTERPOLATION_LINEAR, INTERPOLATION_LOG, PROTON_REACTION_ELASTIC_SCATTERING, - PROTON_REACTION_NONELASTIC, + PROTON_REACTION_CAPTURE, + PROTON_REACTION_INELASTIC_SCATTERING, REFERENCE_FRAME_COM, REFERENCE_FRAME_LAB, PARTICLE_NEUTRON, @@ -34,15 +35,6 @@ from mcdc.object_.simulation import simulation from mcdc.print_ import print_1d_array, print_error -# ====================================================================================== -# ZAP to particle type mapping -# ====================================================================================== - -ZAP_TO_PARTICLE = { - 1: PARTICLE_NEUTRON, - 31: PARTICLE_PROTON, -} - # ====================================================================================== # Proton reaction base class # ====================================================================================== @@ -80,8 +72,10 @@ def __repr__(self): def decode_type(type_): if type_ == PROTON_REACTION_ELASTIC_SCATTERING: return "Proton elastic scattering" - elif type_ == PROTON_REACTION_NONELASTIC: - return "Proton nonelastic reaction" + elif type_ == PROTON_REACTION_INELASTIC_SCATTERING: + return "Proton inelastic scattering" + elif type_ == PROTON_REACTION_CAPTURE: + return "Proton capture" def decode_reference_frame(type_): @@ -120,13 +114,13 @@ def __repr__(self): # ====================================================================================== -# Proton nonelastic reaction +# Proton inelastic scattering # ====================================================================================== -class ProtonReactionNonelasticReaction(ProtonReactionBase): +class ProtonReactionInelasticScattering(ProtonReactionBase): # Annotations for Numba mode - label: str = "proton_nonelastic_reaction" + label: str = "proton_inelastic_scattering_reaction" # multiplicity: int angle_type: int @@ -153,7 +147,7 @@ def __init__( spectrum_probability, energy_spectra, ): - type_ = PROTON_REACTION_NONELASTIC + type_ = PROTON_REACTION_INELASTIC_SCATTERING super().__init__(type_, MT, xs, xs_offset, reference_frame, q_value) self.multiplicity = multiplicity @@ -213,6 +207,22 @@ def __repr__(self): return text + +class ProtonReactionCapture(ProtonReactionBase): + # Annotations for Numba mode + label: str = "proton_capture_reaction" + + def __init__(self, MT, xs, xs_offset, reference_frame, q_value): + type_ = PROTON_REACTION_CAPTURE + super().__init__(type_, MT, xs, xs_offset, reference_frame, q_value) + + @classmethod + def from_h5_group(cls, h5_group): + MT, xs, xs_offset, reference_frame, q_value = set_basic_properties(h5_group) + return cls(MT, xs, xs_offset, reference_frame, q_value) + + + # ====================================================================================== # Helper functions # ====================================================================================== diff --git a/mcdc/transport/physics/proton/multigroup.py b/mcdc/transport/physics/proton/multigroup.py index b48e811a3..b57c5b3b6 100644 --- a/mcdc/transport/physics/proton/multigroup.py +++ b/mcdc/transport/physics/proton/multigroup.py @@ -16,7 +16,8 @@ PI, PROTON_REACTION_TOTAL, PROTON_REACTION_ELASTIC_SCATTERING, - PROTON_REACTION_NONELASTIC, + PROTON_REACTION_INELASTIC_SCATTERING, + PROTON_REACTION_CAPTURE, ) from mcdc.transport.physics.util import scatter_direction from mcdc.transport.distribution import sample_isotropic_direction @@ -113,6 +114,8 @@ def scattering(particle_container, program, data): weight_production = particle["w"] / weight_target weight_product = weight_target + + # TODO: make this better for protons, add secondary particle generation to non-MG materials # Get number of secondaries nu_s = mcdc_get.multigroup_material.mgxs_nu_s(g, material, data) N = int(math.floor(weight_production * nu_s + rng.lcg(particle_container))) diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 3a531479e..9ee088c54 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -24,7 +24,8 @@ PI_SQRT, PROTON_REACTION_TOTAL, PROTON_REACTION_ELASTIC_SCATTERING, - PROTON_REACTION_NONELASTIC, + PROTON_REACTION_CAPTURE, + PROTON_REACTION_INELASTIC_SCATTERING, REFERENCE_FRAME_COM, PARTICLE_ELECTRON, PARTICLE_NEUTRON, @@ -101,9 +102,12 @@ def total_micro_xs(reaction_type, E, nuclide, data): elif reaction_type == PROTON_REACTION_ELASTIC_SCATTERING: xs0 = mcdc_get.nuclide.proton_elastic_xs(idx, nuclide, data) xs1 = mcdc_get.nuclide.proton_elastic_xs(idx + 1, nuclide, data) - elif reaction_type == PROTON_REACTION_NONELASTIC: - xs0 = mcdc_get.nuclide.proton_nonelastic_xs(idx, nuclide, data) - xs1 = mcdc_get.nuclide.proton_nonelastic_xs(idx + 1, nuclide, data) + elif reaction_type == PROTON_REACTION_INELASTIC_SCATTERING: + xs0 = mcdc_get.nuclide.proton_inelastic_xs(idx, nuclide, data) + xs1 = mcdc_get.nuclide.proton_inelastic_xs(idx + 1, nuclide, data) + elif reaction_type == PROTON_REACTION_CAPTURE: + xs0 = mcdc_get.nuclide.proton_capture_xs(idx, nuclide, data) + xs1 = mcdc_get.nuclide.proton_capture_xs(idx + 1, nuclide, data) else: # Should be unreachable xs0 = 0.0 @@ -155,8 +159,7 @@ def collision(particle_container, collision_data_container, program, data): SigmaT = macro_xs(PROTON_REACTION_TOTAL, particle_container, simulation, data) - # No implicit capture for protons (there's no capture xs) - + # TODO: add implicit capture for protons xi = rng.lcg(particle_container) * SigmaT total = 0.0 for i in range(material["N_nuclide"]): @@ -172,12 +175,14 @@ def collision(particle_container, collision_data_container, program, data): if total > xi: break + + # ================================================================================== # Sample and perform reaction # ================================================================================== sigma_elastic = total_micro_xs(PROTON_REACTION_ELASTIC_SCATTERING, E, nuclide, data) - sigma_nonelastic = total_micro_xs(PROTON_REACTION_NONELASTIC, E, nuclide, data) + sigma_inelastic = total_micro_xs(PROTON_REACTION_INELASTIC_SCATTERING, E, nuclide, data) xi = rng.lcg(particle_container) * sigmaT # Elastic scattering @@ -208,16 +213,16 @@ def collision(particle_container, collision_data_container, program, data): ) return - # Noelastic reaction - total += sigma_nonelastic + # Inelastic scattering + total += sigma_inelastic if xi < total: # Sample the actual reaction from the group - total -= sigma_nonelastic - for i in range(nuclide["N_proton_nonelastic_reaction"]): + total -= sigma_inelastic + for i in range(nuclide["N_proton_inelastic_scattering_reaction"]): reaction_ID = int( - mcdc_get.nuclide.proton_nonelastic_reaction_IDs(i, nuclide, data) + mcdc_get.nuclide.proton_inelastic_scattering_reaction_IDs(i, nuclide, data) ) - reaction = simulation["proton_nonelastic_reactions"][reaction_ID] + reaction = simulation["proton_inelastic_scattering_reactions"][reaction_ID] reaction_base_ID = reaction["parent_ID"] reaction_base = simulation["proton_reactions"][reaction_base_ID] xs = reaction_micro_xs(E, reaction_base, nuclide, data) @@ -225,7 +230,7 @@ def collision(particle_container, collision_data_container, program, data): # Execute the reaction if xi < total: - nonelastic_reaction( + inelastic_scattering( reaction, particle_container, collision_data_container, @@ -313,6 +318,32 @@ def csda_edep(particle_container, collision_data_container, distance, simulation return +# ====================================================================================== +# Capture +# ====================================================================================== + + +# TODO: add secondaries from capture rxns +@njit +def capture( + reaction, particle_container, collision_data_container, nuclide, simulation, data +): + particle = particle_container[0] + collision_data = collision_data_container[0] + + reaction_base_ID = reaction["parent_ID"] + reaction_base = simulation["proton_reactions"][reaction_base_ID] + + # Terminate the particle + particle["alive"] = False + + # Energy deposition + E = particle["E"] + q_value = reaction_base["q_value"] * 1e6 + collision_data["energy_deposition"] += (E + q_value) * particle["w"] + + + # ====================================================================================== # Elastic scattering # ====================================================================================== @@ -466,19 +497,20 @@ def sample_nucleus_velocity(A, particle_container): # ====================================================================================== -# Nonelastic scattering +# Inelastic scattering # ====================================================================================== +# TODO: make inelastic scattering actually produce the right things @njit -def nonelastic_reaction( +def inelastic_scattering( reaction, particle_container, collision_data_container, nuclide, program, data ): """ - Proton nonelastic scattering with secondary particle production. + Proton intelastic scattering with secondary particle production. Samples: - 1. Outgoing proton from proton_reactions/inelastic/MT-005 + 1. Outgoing proton from proton_reactions/inelastic_scattering/MT-005 2. Secondary particles from secondary_particles/ZAP_x/MT-005 """ simulation = util.access_simulation(program) @@ -517,7 +549,6 @@ def nonelastic_reaction( # Energy deposition (will be adjusted as we create secondaries) collision_data["energy_deposition"] += total_energy * w - # print(f'\ndeposited {total_energy * particle["w"]} eV at x={particle["x"]} from nonelastic_rxn') # Create outgoing protons for n in range(N_proton): @@ -547,7 +578,7 @@ def nonelastic_reaction( # Get energy spectrum if use_all_spectrum: ID = int( - mcdc_get.proton_nonelastic_reaction.energy_spectrum_IDs( + mcdc_get.proton_inelastic_scattering_reaction.energy_spectrum_IDs( n, reaction, data ) ) @@ -560,13 +591,13 @@ def nonelastic_reaction( xi = rng.lcg(particle_container_new) total = 0.0 for j in range(N_spectrum): - probability = mcdc_get.proton_nonelastic_reaction.spectrum_probability( + probability = mcdc_get.proton_inelastic_scattering_reaction.spectrum_probability( probability_idx, j, reaction, data ) total += probability if xi < total: ID = int( - mcdc_get.proton_nonelastic_reaction.energy_spectrum_IDs( + mcdc_get.proton_inelastic_scattering_reaction.energy_spectrum_IDs( j, reaction, data ) ) From 357fea9fc1c929d2e09542600adcc614dec580b9 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 15 Jul 2026 10:14:30 -0700 Subject: [PATCH 59/64] updates to proton transport --- mcdc/constant.py | 2 +- mcdc/transport/physics/interface.py | 2 - mcdc/transport/physics/proton/native.py | 84 ++++++++++++++++++- mcdc/transport/simulation.py | 2 + tools/data_library_generator/tendl_to_hdf5.py | 38 ++++----- 5 files changed, 102 insertions(+), 26 deletions(-) diff --git a/mcdc/constant.py b/mcdc/constant.py index 832dc2715..1186ca744 100644 --- a/mcdc/constant.py +++ b/mcdc/constant.py @@ -198,7 +198,7 @@ PROTON_MASS = 938.27208943e6 # eV/c^2 BOLTZMANN_K = 8.61733326e-5 # eV/K ELECTRON_CUTOFF_ENERGY = 100 # eV -PROTON_CUTOFF_ENERGY = 1000 # eV +PROTON_CUTOFF_ENERGY = 250000 # eV MU_CUTOFF = 0.999999 THERMAL_THRESHOLD_FACTOR = 400 diff --git a/mcdc/transport/physics/interface.py b/mcdc/transport/physics/interface.py index 2375a0761..29ace02a6 100644 --- a/mcdc/transport/physics/interface.py +++ b/mcdc/transport/physics/interface.py @@ -76,8 +76,6 @@ def collision_distance(particle_container, simulation, data): elif particle["particle_type"] == PARTICLE_PROTON: SigmaT = macro_xs(PROTON_REACTION_TOTAL, particle_container, simulation, data) - # print(f'SigmaT = {SigmaT}, E = {particle["E"]}') - # Vacuum material? if SigmaT == 0.0: return INF diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 9ee088c54..07774ad47 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -253,7 +253,7 @@ def csda_edep(particle_container, collision_data_container, distance, simulation material = simulation["native_materials"][particle["material_ID"]] # print(f'material = {material}, type = {type(material)}, {material.dtype.names}') E = particle["E"] - + # Check for cutoff energy if E <= PROTON_CUTOFF_ENERGY: collision_data["energy_deposition"] += E * particle["w"] @@ -302,10 +302,10 @@ def csda_edep(particle_container, collision_data_container, distance, simulation # Range straggling - modify energy loss to have some slight variations # TODO: Insert different thickness regimes to sample from (e.g. Bohr, Landau, Vavilov) - gaussian_variance = 0.1569 * total_rho_gcm3 * Z / A * distance + energy_straggling_variance = 0.1569 * total_rho_gcm3 * Z / A * distance - gaussian = np.random.normal(loc=0.0, scale=np.sqrt(gaussian_variance)) - energy_loss += gaussian + energy_straggling_modifier = np.random.normal(loc=0.0, scale=np.sqrt(energy_straggling_variance)) + energy_loss += energy_straggling_modifier particle["E"] -= energy_loss collision_data["energy_deposition"] += energy_loss * particle["w"] @@ -315,8 +315,32 @@ def csda_edep(particle_container, collision_data_container, distance, simulation print(f'distance = {distance}') print(f'NEGATIVE: energy_loss = {energy_loss * particle["w"]}') raise ValueError('negative energy loss') + + X0 = 24.01 # Radiation length for Al, in g/cm^2 + # X0 = 36.33 # Radiation length for H2O, in g/cm^2 + + # Angular scattering according to MCS theory + phi, theta = sample_mcs_angle(particle["E"], distance, total_rho_gcm3, X0) + + rotate_direction(particle, phi, theta) + return +@njit +def sample_mcs_angle(E, distance, density, X0): + sigma = highland_lynch_dahl_sigma(E, distance, density, X0) + + if sigma < 0.0: + raise ValueError(f'negative sigma = {sigma}') + + # Sample theta from the Highland distribution; phi uniformly from (0, 2pi) + theta = np.abs(np.random.normal(0, sigma)) + phi = np.random.uniform(0, 2*np.pi) + + return phi, theta + + + # ====================================================================================== # Capture @@ -672,3 +696,55 @@ def inelastic_scattering( # No fission for protons + + +@njit +def highland_lynch_dahl_sigma(E, distance, density, X0): + p = np.sqrt(E * (E + 2.0 * PROTON_MASS)) + beta = p / (E + PROTON_MASS) + z = 1 # Incident particle is a proton, Z=1 + + # X0 is measured in g/cm^2 + # Highland formula, modified by Lynch & Dahl + radiation_distance_fraction = density * distance / X0 + sigma = (13.6e6 / p*beta) * z * np.sqrt(radiation_distance_fraction) * (1 + 0.088 * np.log10(radiation_distance_fraction)) + + if sigma < 0.0: + print(f'radiation_distance_fraction = {radiation_distance_fraction}') + print(f'p = {p}, beta = {beta}, z = {z}') + print(f'density = {density}, distance = {distance}') + raise ValueError(f"negative sigma = {sigma}") + + return sigma + +@njit +def rotate_direction(particle, phi, theta): + """ + Rotate direction vector (ux, uy, uz) by polar angle theta + and azimuthal angle phi in the local frame. + Returns new (ux, uy, uz). + """ + + ux = particle["ux"] + uy = particle["uy"] + uz = particle["uz"] + + sin_theta = np.sin(theta) + cos_theta = np.cos(theta) + cos_phi = np.cos(phi) + sin_phi = np.sin(phi) + + # Build local perpendicular axes + d = np.array([ux, uy, uz]) + perp = np.array([1.0, 0.0, 0.0]) if abs(ux) < 0.9 else np.array([0.0, 1.0, 0.0]) + u = np.cross(d, perp); u /= np.linalg.norm(u) + v = np.cross(d, u) + + d_new = (cos_theta * d + + sin_theta * cos_phi * u + + sin_theta * sin_phi * v) + d_new /= np.linalg.norm(d_new) + + particle["ux"] = d_new[0] + particle["uy"] = d_new[1] + particle["uz"] = d_new[2] \ No newline at end of file diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index 845be2d74..f9a379f60 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -464,6 +464,8 @@ def move_to_event(particle_container, simulation, data): particle["event"] += EVENT_CSDA_EDEP if distance < 0.0: + print(f'distance = {distance}') + print(f'd_coll = {d_collision}, d_csda = {d_csda}, d_bnd = {d_boundary}') raise ValueError(f"Negative distance") # ================================================================================== diff --git a/tools/data_library_generator/tendl_to_hdf5.py b/tools/data_library_generator/tendl_to_hdf5.py index 1701c7141..be8df7a4a 100644 --- a/tools/data_library_generator/tendl_to_hdf5.py +++ b/tools/data_library_generator/tendl_to_hdf5.py @@ -34,7 +34,7 @@ angular_cosine_distribution/ capture/MT-{NNN}/ xs (barns), Q-value (MeV), reference_frame - nonelastic_reaction/MT-{NNN}/ + ielastic_scattering/MT-{NNN}/ xs (barns), Q-value (MeV), reference_frame, multiplicity angular_cosine_distribution/ energy_spectrum-{k}/ (law attr; kalbach-mann: energy, offset, @@ -463,12 +463,12 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): proton_reactions = file.create_group("proton_reactions") elastic_group = proton_reactions.create_group("elastic_scattering") capture_group = proton_reactions.create_group("capture") - nonelastic_group = proton_reactions.create_group("nonelastic_reaction") + inelastic_group = proton_reactions.create_group("inelastic_reaction") fission_group = proton_reactions.create_group("fission") elastic_MTs = [2] capture_MTs = [] - nonelastic_MTs = [] + inelastic_MTs = [] fission_MTs = ([18] if rx_block.has_MT(18) else [MT for MT in FISSION_CHANCE_MTS if rx_block.has_MT(MT)]) @@ -482,25 +482,25 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): print_error(f"Non-integer multiplicity for MT-{MT:03} in {ace_path}") nu = nu_raw - 100 if nu_raw >= 100 else nu_raw if nu == 0: capture_MTs.append(MT) - elif nu > 0: nonelastic_MTs.append(MT) + elif nu > 0: inelastic_MTs.append(MT) else: print_error(f"Negative multiplicity for MT-{MT:03} in {ace_path}") - for grp, mts in [(elastic_group, elastic_MTs), - (capture_group, capture_MTs), - (nonelastic_group, nonelastic_MTs), - (fission_group, fission_MTs)]: + for grp, mts in [(elastic_group, elastic_MTs), + (capture_group, capture_MTs), + (inelastic_group, inelastic_MTs), + (fission_group, fission_MTs)]: for MT in mts: grp.create_group(f"MT-{MT:03}").attrs["MT"] = MT if verbose: print(f" Elastic: {elastic_MTs} Capture: {capture_MTs} " - f"Nonelastic: {nonelastic_MTs}" + f"Inelastic: {inelastic_MTs}" + (f" Fission: {fission_MTs}" if fissionable else "")) if not fissionable: del file["proton_reactions/fission"] - if not nonelastic_MTs: - del file["proton_reactions/nonelastic_reaction"] + if not inelastic_MTs: + del file["proton_reactions/inelastic_reaction"] # Cross sections xs0 = ace_table.principal_cross_section_block @@ -515,7 +515,7 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): ds.attrs["unit"] = "barns" for mts, grp in [(capture_MTs, capture_group), - (nonelastic_MTs, nonelastic_group), + (inelastic_MTs, inelastic_group), (fission_MTs, fission_group if fissionable else None)]: if grp is None: continue @@ -532,7 +532,7 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): elastic_group.create_dataset("MT-002/Q-value", data=0.0).attrs["unit"] = "MeV" for mts, grp in [(capture_MTs, capture_group), - (nonelastic_MTs, nonelastic_group), + (inelastic_MTs, inelastic_group), (fission_MTs, fission_group if fissionable else None)]: if grp is None: continue @@ -546,7 +546,7 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): elastic_group.create_dataset("MT-002/reference_frame", data="COM") for mts, grp in [(capture_MTs, capture_group), - (nonelastic_MTs, nonelastic_group), + (inelastic_MTs, inelastic_group), (fission_MTs, fission_group if fissionable else None)]: if grp is None: continue @@ -557,11 +557,11 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): "COM" if rf == ACEtk.ReferenceFrame.CentreOfMass else str(rf)) grp.create_dataset(f"MT-{MT:03}/reference_frame", data=rf_str) - # Nonelastic multiplicities - for MT in nonelastic_MTs: + # Inelastic multiplicities + for MT in inelastic_MTs: idx = rx_block.index(MT) nu_raw = nu_block.multiplicity(idx) - nonelastic_group.create_dataset( + inelastic_group.create_dataset( f"MT-{MT:03}/multiplicity", data=nu_raw - 100 if nu_raw >= 100 else nu_raw ) @@ -575,7 +575,7 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): and verbose: print_note("MT-002 angular distribution is given in energy block") - for mts, grp in [(nonelastic_MTs, nonelastic_group), + for mts, grp in [(inelastic_MTs, inelastic_group), (fission_MTs, fission_group if fissionable else None)]: if grp is None: continue @@ -589,7 +589,7 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): # Primary energy distributions energy_block = ace_table.energy_distribution_block - for mts, grp in [(nonelastic_MTs, nonelastic_group), + for mts, grp in [(inelastic_MTs, inelastic_group), (fission_MTs, fission_group if fissionable else None)]: if grp is None: continue From 945431c78ada95cc872c1b41d5a5d0b2ca2c3c69 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Wed, 22 Jul 2026 15:33:13 -0700 Subject: [PATCH 60/64] refactor proton/native.py; update proton data generator --- .../proton_inelastic_scattering_reaction.py | 2 +- mcdc/transport/physics/proton/native.py | 175 ++-- .../data_library_generator/proton/generate.py | 868 ++++++++++++++++++ tools/data_library_generator/proton/water.py | 25 + 4 files changed, 1008 insertions(+), 62 deletions(-) create mode 100644 tools/data_library_generator/proton/generate.py create mode 100644 tools/data_library_generator/proton/water.py diff --git a/mcdc/mcdc_set/proton_inelastic_scattering_reaction.py b/mcdc/mcdc_set/proton_inelastic_scattering_reaction.py index 86a9da0dc..485e20529 100644 --- a/mcdc/mcdc_set/proton_inelastic_scattering_reaction.py +++ b/mcdc/mcdc_set/proton_inelastic_scattering_reaction.py @@ -38,7 +38,7 @@ def spectrum_probability_vector(index_1, proton_inelastic_scattering_reaction, d stride = proton_inelastic_scattering_reaction["N_spectrum"] start = offset + index_1 * stride end = start + stride - data[start:end] - value + data[start:end] = value @njit diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 07774ad47..425f88820 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -251,7 +251,6 @@ def csda_edep(particle_container, collision_data_container, distance, simulation particle = particle_container[0] collision_data = collision_data_container[0] material = simulation["native_materials"][particle["material_ID"]] - # print(f'material = {material}, type = {type(material)}, {material.dtype.names}') E = particle["E"] # Check for cutoff energy @@ -260,50 +259,14 @@ def csda_edep(particle_container, collision_data_container, distance, simulation particle["alive"] = False particle["E"] = 0.0 return - - total_stopping_power = 0.0 - total_rho_gcm3 = 0.0 - total_Z = 0.0 - total_A = 0.0 - # Find the total stopping power by summing over every nuclide in the material - for i in range(material["N_nuclide"]): - nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) - nuclide = simulation["nuclides"][nuclide_ID] - - # If no stopping power provided, we calculate it ourselves here - if not material["stopping_power_provided"]: - dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) - dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) - - # TODO: replace np.interp with a non-numpy function?? - dedx = np.interp(E / 1e6, dedx_energies, dedx_values) - total_stopping_power += dedx * 1e6 - - # Convert atoms/barn-cm to g/cm3: - atomic_mass = nuclide["atomic_weight_ratio"] # mass in amu - nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) - density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) - total_rho_gcm3 += density_gcm3 - - total_Z += nuclide["atomic_number"] - total_A += nuclide["mass_number"] - - Z = total_Z / material["N_nuclide"] - A = total_A / material["N_nuclide"] - - if material["stopping_power_provided"]: - dedx_values = mcdc_get.native_material.stopping_power_all(material, data) - dedx_energies = mcdc_get.native_material.stopping_power_energy_grid_all(material, data) - - dedx = np.interp(E / 1e6, dedx_energies, dedx_values) - total_stopping_power = dedx * 1e6 + average_A, average_Z, total_stopping_power, total_rho_gcm3 = calculate_total_stopping_power(particle_container, simulation, data) energy_loss = total_stopping_power * total_rho_gcm3 * distance # Range straggling - modify energy loss to have some slight variations # TODO: Insert different thickness regimes to sample from (e.g. Bohr, Landau, Vavilov) - energy_straggling_variance = 0.1569 * total_rho_gcm3 * Z / A * distance - + # TODO: Make this part use rng state instead of np.random.normal? + energy_straggling_variance = 0.1569 * total_rho_gcm3 * average_Z / average_A * distance energy_straggling_modifier = np.random.normal(loc=0.0, scale=np.sqrt(energy_straggling_variance)) energy_loss += energy_straggling_modifier particle["E"] -= energy_loss @@ -313,9 +276,14 @@ def csda_edep(particle_container, collision_data_container, distance, simulation print(f'total density = {total_rho_gcm3}') print(f'stopping_power = {total_stopping_power}') print(f'distance = {distance}') - print(f'NEGATIVE: energy_loss = {energy_loss * particle["w"]}') + print(f'energy_loss = {energy_loss * particle["w"]}') raise ValueError('negative energy loss') + radiation_length = get_radiation_length(particle_container, simulation, data) + + + + X0 = 24.01 # Radiation length for Al, in g/cm^2 # X0 = 36.33 # Radiation length for H2O, in g/cm^2 @@ -326,21 +294,6 @@ def csda_edep(particle_container, collision_data_container, distance, simulation return -@njit -def sample_mcs_angle(E, distance, density, X0): - sigma = highland_lynch_dahl_sigma(E, distance, density, X0) - - if sigma < 0.0: - raise ValueError(f'negative sigma = {sigma}') - - # Sample theta from the Highland distribution; phi uniformly from (0, 2pi) - theta = np.abs(np.random.normal(0, sigma)) - phi = np.random.uniform(0, 2*np.pi) - - return phi, theta - - - # ====================================================================================== # Capture @@ -388,7 +341,6 @@ def elastic_scattering( # Energy deposition collision_data["energy_deposition"] += E * particle["w"] - # print(f'\ndeposited {E * particle["w"]} eV at x={particle["x"]} from elastic scattering') # Note: Q-value is zero in elastic scattering @@ -439,7 +391,7 @@ def elastic_scattering( multi_table = simulation["multi_table_distributions"][mu_table_ID] # multi_table = simulation["multi_table_distributions"][reaction["mu_table_ID"]] - mu0 = sample_multi_table(E, particle_container, multi_table, data) + mu0 = sample_multi_table(E, particle_container, multi_table, simulation, data) # Scatter the direction in COM azi = 2.0 * PI * rng.lcg(particle_container) @@ -525,7 +477,7 @@ def sample_nucleus_velocity(A, particle_container): # ====================================================================================== -# TODO: make inelastic scattering actually produce the right things +# TODO: make inelastic scattering actually produce secondaries @njit def inelastic_scattering( reaction, particle_container, collision_data_container, nuclide, program, data @@ -593,7 +545,7 @@ def inelastic_scattering( multi_table = simulation["multi_table_distributions"][ distribution_base["child_ID"] ] - mu = sample_multi_table(E, particle_container_new, multi_table, data) + mu = sample_multi_table(E, particle_container_new, multi_table, simulation, data) # ============================================================================== # Sample energy (also angle if correlated) @@ -698,6 +650,24 @@ def inelastic_scattering( # No fission for protons +# ====================================================================================== +# Misc +# ====================================================================================== + +@njit +def sample_mcs_angle(E, distance, density, X0): + sigma = highland_lynch_dahl_sigma(E, distance, density, X0) + + if sigma < 0.0: + raise ValueError(f'negative sigma = {sigma}') + + # Sample theta from the Highland distribution; phi uniformly from (0, 2pi) + theta = np.abs(np.random.normal(0, sigma)) + phi = np.random.uniform(0, 2*np.pi) + + return phi, theta + + @njit def highland_lynch_dahl_sigma(E, distance, density, X0): p = np.sqrt(E * (E + 2.0 * PROTON_MASS)) @@ -747,4 +717,87 @@ def rotate_direction(particle, phi, theta): particle["ux"] = d_new[0] particle["uy"] = d_new[1] - particle["uz"] = d_new[2] \ No newline at end of file + particle["uz"] = d_new[2] + + +def calculate_total_stopping_power(particle_container, simulation, data): + particle = particle_container[0] + material = simulation["native_materials"][particle["material_ID"]] + E = particle["E"] + + total_stopping_power = 0.0 + total_rho_gcm3 = 0.0 + total_Z = 0.0 + total_A = 0.0 + # Find the total stopping power by summing over every nuclide in the material + for i in range(material["N_nuclide"]): + nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + nuclide = simulation["nuclides"][nuclide_ID] + + # If no stopping power provided, we calculate it ourselves here + if not material["stopping_power_provided"]: + dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) + dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) + + # TODO: replace np.interp with a non-numpy function?? + dedx = np.interp(E / 1e6, dedx_energies, dedx_values) + total_stopping_power += dedx * 1e6 + + # Convert atoms/barn-cm to g/cm3: + atomic_mass = nuclide["atomic_weight_ratio"] # mass in amu + nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) + total_rho_gcm3 += density_gcm3 + + total_Z += nuclide["atomic_number"] + total_A += nuclide["mass_number"] + + average_Z = total_Z / material["N_nuclide"] + average_A = total_A / material["N_nuclide"] + + if material["stopping_power_provided"]: + dedx_values = mcdc_get.native_material.stopping_power_all(material, data) + dedx_energies = mcdc_get.native_material.stopping_power_energy_grid_all(material, data) + + dedx = np.interp(E / 1e6, dedx_energies, dedx_values) + total_stopping_power = dedx * 1e6 + + return average_A, average_Z, total_stopping_power, total_rho_gcm3 + + + +def get_radiation_length(particle_container, simulation, data): + particle = particle_container[0] + material = simulation["native_materials"][particle["material_ID"]] + + if material["stopping_power_provided"]: + if mcdc_get.native_material.radiation_length is None: + print("ValueError: need radiation length for material. May be found at https://pdg.lbl.gov/2026/AtomicNuclearProperties") + + radiation_length = mcdc_get.native_material.radiation_length(material, data) + + elif not material["stopping_power_provided"]: + for i in range(material["N_nuclide"]): + nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) + nuclide = simulation["nuclides"][nuclide_ID] + + + + # If no stopping power provided, we calculate it ourselves here + # if not material["stopping_power_provided"]: + # dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) + # dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) + + # # TODO: replace np.interp with a non-numpy function?? + # dedx = np.interp(E / 1e6, dedx_energies, dedx_values) + # total_stopping_power += dedx * 1e6 + + # Convert atoms/barn-cm to g/cm3: + atomic_mass = nuclide["atomic_weight_ratio"] # mass in amu + nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) + density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) + total_rho_gcm3 += density_gcm3 + + + return radiation_length + diff --git a/tools/data_library_generator/proton/generate.py b/tools/data_library_generator/proton/generate.py new file mode 100644 index 000000000..ced76223a --- /dev/null +++ b/tools/data_library_generator/proton/generate.py @@ -0,0 +1,868 @@ +# The majority of this script was written by Anthropic's Claude + +""" +generate.py — Convert TENDL proton ACE files to HDF5 for MC/DC + +For isotopes that have no ACE file (e.g. H, He) but do have a PSTAR stopping +power file, a minimal HDF5 file is created containing only the stopping power +data. This ensures every element that can appear in a material has at least +a stopping power entry. + +Usage +----- + python generate.py + python generate.py --rewrite # overwrite existing files + python generate.py --verbose # per-reaction detail + +HDF5 layout +----------- +-K.h5 + attrs: source_title, source_version, source_date + nuclide_name, excitation_level, temperature (K), + atomic_number, mass_number, atomic_weight_ratio, fissionable + + stopping_power/ (if PSTAR data available) + energy (MeV), total_stopping_power (MeV cm2/g) + + proton_reactions/ (absent for stopping-power-only files) + xs_energy_grid (MeV) + elastic_scattering/MT-002/ + xs (barns, offset=0), Q-value (MeV), reference_frame, + angular_cosine_distribution/ + capture/MT-{NNN}/ + xs (barns), Q-value (MeV), reference_frame + ielastic_scattering/MT-{NNN}/ + xs (barns), Q-value (MeV), reference_frame, multiplicity + angular_cosine_distribution/ + energy_spectrum-{k}/ (law attr; kalbach-mann: energy, offset, + energy_out, pdf, cdf, precompound_factor, angular_slope) + fission/ (only if fissionable) + + secondary_particles/ZAP_{zap}/MT-{NNN}/ + attrs: ZAP, particle_name, MT, multiplicity, reference_frame + production_xs (barns, offset) + kalbach_mann/ (energy, offset, energy_out, pdf, cdf, + precompound_factor, angular_slope) + +ZAP identity: 1=n, 1001=p, 1002=d, 1003=t, 2003=He3, 2004=alpha, 0=gamma + +TabulatedKalbachMannDistribution properties used (from ACEtk): + outgoing_energies, pdf, cdf, + precompound_fraction_values, angular_distribution_slope_values +""" + +import argparse +import os +import sys + +import h5py +import numpy as np +from tqdm import tqdm +import ACEtk + + +# -- Constants ----------------------------------------------------------------- + +ZAP_NAMES = { + 1: "neutron", + 31: "deuteron", + 32: "triton", + 33: "He3", + 34: "alpha", +} + +Z_TO_SYMBOL = { + 1:"H", 2:"He", 3:"Li", 4:"Be", 5:"B", 6:"C", 7:"N", 8:"O", + 9:"F", 10:"Ne", 11:"Na", 12:"Mg", 13:"Al", 14:"Si", 15:"P", 16:"S", + 17:"Cl", 18:"Ar", 19:"K", 20:"Ca", 21:"Sc", 22:"Ti", 23:"V", 24:"Cr", + 25:"Mn", 26:"Fe", 27:"Co", 28:"Ni", 29:"Cu", 30:"Zn", 31:"Ga", 32:"Ge", + 33:"As", 34:"Se", 35:"Br", 36:"Kr", 37:"Rb", 38:"Sr", 39:"Y", 40:"Zr", + 41:"Nb", 42:"Mo", 43:"Tc", 44:"Ru", 45:"Rh", 46:"Pd", 47:"Ag", 48:"Cd", + 49:"In", 50:"Sn", 51:"Sb", 52:"Te", 53:"I", 54:"Xe", 55:"Cs", 56:"Ba", + 57:"La", 58:"Ce", 59:"Pr", 60:"Nd", 61:"Pm", 62:"Sm", 63:"Eu", 64:"Gd", + 65:"Tb", 66:"Dy", 67:"Ho", 68:"Er", 69:"Tm", 70:"Yb", 71:"Lu", 72:"Hf", + 73:"Ta", 74:"W", 75:"Re", 76:"Os", 77:"Ir", 78:"Pt", 79:"Au", 80:"Hg", + 81:"Tl", 82:"Pb", 83:"Bi", 84:"Po", 85:"At", 86:"Rn", 87:"Fr", 88:"Ra", + 89:"Ac", 90:"Th", 91:"Pa", 92:"U", 93:"Np", 94:"Pu", 95:"Am", 96:"Cm", + 97:"Bk", 98:"Cf", 99:"Es",100:"Fm",101:"Md",102:"No",103:"Lr", +} + +RADIATION_LENGTH_FROM_Z = { + "H": 63.04, "He": 94.32, "Li": 82.77, "Be": 65.19, "Bo": 52.68, "C": 42.70, + "N": 37.99, "O": 34.24, "F": 32.93, "Ne": 28.93, "Na": 27.74, "Mg": 25.03, + "Al": 24.01, "Si": 21.82, "P": 21.21, "S": 19.50, "Cl": 19.28, "Ar": 19.55, + "K": 17.32, "Ca": 16.14, "Sc": 16.55, "Ti": 16.16, "V": 15.84, "Cr": 14.94, + "Mn": 14.64, "Fe": 13.84, "Co": 13.62, "Ni": 12.68, "Cu": 12.86, "Zn": 12.43, + "Ga": 12.47, "Ge": 12.25, "As": 11.94, "Se": 11.91, "Br": 11.42, "Kr": 11.37, + "Rb": 11.03, "Sr": 10.76, "Y": 10.41, "Zr": 10.20, "Nb": 9.92, "Mo": 9.80, + "Tc": 9.58, "Ru": 9.48, "Rh": 9.27, "Pd": 9.20, "Ag": 8.97, "Cd": 9.00, + "In": 8.85, "Sn": 8.82, "Sb": 8.73, "Te": 8.83, "I": 8.48, "Xe": 8.48, + "Cs": 8.31, "Ba": 8.31, "La": 8.14, "Ce": 7.96, "Pr": 7.76, "Nd": 7.71, + "Pm": 7.51, "Sm": 7.57, "Eu": 7.44, "Gd": 7.48, "Tb": 7.36, "Dy": 7.32, + "Ho": 7.23, "Er": 7.14, "Tm": 7.03, "Yb": 7.02, "Lu": 6.92, "Hf": 6.89, + "Ta": 6.82, "W": 6.76, "Re": 6.69, "Os": 6.68, "Ir": 6.59, "Pt": 6.54, + "Au": 6.46, "Hg": 6.44, "Tl": 6.42, "Pb": 6.37, "Bi": 6.29, "Po": 6.16, + "At": 6.07, "Rn": 6.28, "Fr": 6.19, "Ra": 6.15, "Ac": 6.06, "Th": 6.07, + "Pa": 5.93, "U": 6.00, "Np": 5.87, "Pu": 5.93, "Am": 5.80, "Cm": 5.79, + "Bk": 5.69, "Cf": 5.68, "Es": 5.61, "Fm": 5.62, "Md": 5.55, "No": 5.48, + "Lr": 5.45, +} + +SYMBOL_TO_Z = {v: k for k, v in Z_TO_SYMBOL.items()} + +# Isotopes to generate stopping-power-only HDF5 files for when no ACE file +# exists. Covers H and He which TENDL excludes because TALYS doesn't apply. +# Format: (symbol, A, atomic_weight_ratio) +# AWR = atomic mass / neutron mass; neutron mass = 1.008664916 u +PSTAR_ONLY_ISOTOPES = [ + ("H", 1, 1.00794 / 1.008664916), # natural H ≈ H-1 + ("H", 2, 2.01410 / 1.008664916), # deuterium + ("He", 3, 3.01603 / 1.008664916), # He-3 + ("He", 4, 4.00260 / 1.008664916), # He-4 +] + +# Redundant sum MTs that must not be double-counted +REDUNDANT_MTS = [1, 3, 4, 10, 101, 103, 104, 105, 106, 107] +FISSION_CHANCE_MTS = [19, 20, 21, 38] + +# Temperature written to all files (TENDL proton ACE files report 0 K) +T_KELVIN = 0.0 + + +# -- Utility ------------------------------------------------------------------- + +def print_error(msg): + print(f"\n[ERROR] {msg}", file=sys.stderr) + sys.exit(1) + + +def print_note(msg): + print(f" [note] {msg}") + + +def decode_ace_zaid(zaid): + """Return (Z, A, S, T=0) from an ACE ZAID string.""" + za = int(zaid.strip().split(".")[0]) + S = 0 + if za >= 600000: + S = (za % 1000) // 400 + za = za - S * 400 + return za // 1000, za % 1000, S, 0 + + +def load_pstar_file(filepath): + """ + Load a two-column PSTAR file (Energy MeV, Stopping power MeV cm^2/g). + Returns (energies, stopping_powers) as float64 arrays. + """ + energies, sps = [], [] + with open(filepath) as f: + for line in f: + parts = line.strip().split() + if len(parts) != 2: + continue + try: + energies.append(float(parts[0])) + sps.append(float(parts[1])) + except ValueError: + continue + return np.array(energies), np.array(sps) + + +def write_stopping_power(file, pstar_dir, symbol, verbose=False): + """ + Write stopping_power group into an open HDF5 file if a PSTAR file exists. + Returns True if data was written. + """ + if pstar_dir is None: + return False + pstar_path = os.path.join(pstar_dir, f"{symbol}.txt") + if not os.path.exists(pstar_path): + if verbose: + print(f" [warn] No PSTAR file for {symbol}") + return False + if verbose: + print(f" Loading PSTAR from {pstar_path}") + E_s, S_s = load_pstar_file(pstar_path) + sp = file.create_group("stopping_power") + sp.create_dataset("energy", data=E_s).attrs["unit"] = "MeV" + sp.create_dataset("total_stopping_power", data=S_s).attrs["unit"] = "MeV cm2/g" + return True + + +# -- Distribution writers ------------------------------------------------------ + +def load_cosine_distribution(data, h5_group): + """ + Write a tabulated angular distribution into h5_group. + Returns False if the distribution is embedded in a Kalbach-Mann block + (DistributionGivenElsewhere), True otherwise. + """ + if isinstance(data, ACEtk.continuous.DistributionGivenElsewhere): + h5_group.attrs["type"] = "given_in_energy_distribution" + return False + + h5_group.attrs["type"] = "tabulated" + h5_group.attrs["unit"] = "MeV" + h5_group.create_dataset("incident_energies", data=np.array(data.incident_energies)) + + for i, subdist in enumerate(data.distributions): + eg = h5_group.create_group(f"E_in_{i + 1}") + if isinstance(subdist, ACEtk.continuous.TabulatedAngularDistribution): + eg.attrs["type"] = "tabulated" + eg.create_dataset("cosines", data=np.array(subdist.cosines)) + eg.create_dataset("pdf", data=np.array(subdist.pdf)) + eg.create_dataset("cdf", data=np.array(subdist.cdf)) + else: + eg.attrs["type"] = "isotropic" + + return True + + +def _write_kalbach_mann(km_data, h5_group): + """ + Write a KalbachMannDistributionData into h5_group as flat arrays. + offset[i] gives the starting index in the flat arrays for incident energy i. + """ + h5_group.attrs["type"] = "kalbach-mann" + + NE = km_data.number_incident_energies + h5_group.create_dataset( + "energy", data=np.array(km_data.incident_energies) + ).attrs["unit"] = "MeV" + + offset, energy_out, pdf, cdf, r_vals, a_vals = [], [], [], [], [], [] + for i in range(1, NE + 1): + dist = km_data.distribution(i) + offset.append(len(energy_out)) + energy_out.extend(dist.outgoing_energies) + pdf.extend(dist.pdf) + cdf.extend(dist.cdf) + r_vals.extend(dist.precompound_fraction_values) + a_vals.extend(dist.angular_distribution_slope_values) + + h5_group.create_dataset("offset", data=np.array(offset, dtype=np.int32)) + h5_group.create_dataset( + "energy_out", data=np.array(energy_out) + ).attrs["unit"] = "MeV" + h5_group.create_dataset("pdf", data=np.array(pdf)) + h5_group.create_dataset("cdf", data=np.array(cdf)) + h5_group.create_dataset("precompound_factor", data=np.array(r_vals)) + h5_group.create_dataset("angular_slope", data=np.array(a_vals)) + + +def load_energy_distribution(data, h5_group): + """Write a primary-particle outgoing energy distribution into h5_group.""" + if isinstance(data, ACEtk.continuous.KalbachMannDistributionData): + h5_group.attrs["law"] = 44 + _write_kalbach_mann(data, h5_group) + + elif isinstance(data, ACEtk.continuous.OutgoingEnergyDistributionData): + h5_group.attrs["law"] = 4 + h5_group.create_dataset( + "incident_energies", data=np.array(data.incident_energies) + ) + for k, dist in enumerate(data.distributions): + eg = h5_group.create_group(f"E_in_{k + 1}") + eg.create_dataset("outgoing_energies", data=np.array(dist.outgoing_energies)) + eg.create_dataset("pdf", data=np.array(dist.pdf)) + eg.create_dataset("cdf", data=np.array(dist.cdf)) + + elif isinstance(data, ACEtk.continuous.LevelScatteringData): + h5_group.attrs["law"] = 3 + h5_group.create_dataset("C1", data=data.C1) + h5_group.create_dataset("C2", data=data.C2) + + elif isinstance(data, ACEtk.continuous.EquiprobableOutgoingEnergyBins): + h5_group.attrs["law"] = 1 + h5_group.create_dataset( + "incident_energies", data=np.array(data.incident_energies) + ) + for k, dist in enumerate(data.distributions): + h5_group.create_group(f"E_in_{k + 1}").create_dataset( + "energies", data=np.array(dist.energies) + ) + + else: + h5_group.attrs["law"] = -1 + h5_group.attrs["type_name"] = type(data).__name__ + try: + h5_group.create_dataset("xss_array", data=np.array(data.xss_array)) + except Exception: + pass + + +def load_fission_multiplicity(data, h5_group): + if isinstance(data, ACEtk.continuous.TabulatedFissionMultiplicity): + h5_group.attrs["type"] = "tabulated" + h5_group.create_dataset("energies", data=np.array(data.energies)) + h5_group.create_dataset("multiplicities", data=np.array(data.multiplicities)) + elif isinstance(data, ACEtk.continuous.PolynomialFissionMultiplicity): + h5_group.attrs["type"] = "polynomial" + h5_group.create_dataset("coefficients", data=np.array(data.coefficients)) + else: + h5_group.attrs["type"] = "unknown" + h5_group.attrs["type_name"] = type(data).__name__ + + +# -- Secondary particles ------------------------------------------------------- + +def load_secondary_particles(ace_table, file, verbose=False): + n_types = ace_table.number_secondary_particle_types + if n_types == 0: + return + + type_block = ace_table.secondary_particle_type_block + info_block = ace_table.secondary_particle_information_block + rx_block = ace_table.secondary_particle_reaction_number_block + tyr_block = ace_table.secondary_particle_frame_and_multiplicity_block + xs_block = ace_table.secondary_particle_production_cross_section_block + edy_block = ace_table.secondary_particle_energy_distribution_block + + has_ang = False + try: + ang_block = ace_table.secondary_particle_angular_distribution_block + has_ang = True + except Exception: + pass + + sec_group = file.create_group("secondary_particles") + + pi_method = next( + (c for c in ["particle_identifier", "ZAP", "type", "particle_type"] + if hasattr(type_block, c)), + None + ) + if pi_method is None: + raise AttributeError( + f"Cannot find particle identifier on {type(type_block).__name__}. " + f"Available: {[x for x in dir(type_block) if not x.startswith('_')]}" + ) + + for i in range(1, n_types + 1): + zap = getattr(type_block, pi_method)(i) + name = ZAP_NAMES.get(zap, f"ZAP_{zap}") + n_rx = int(info_block.number_reactions[i - 1]) + + if verbose: + print(f" Secondary type {i}: ZAP={zap} ({name}), {n_rx} reactions") + + zap_group = sec_group.create_group(f"ZAP_{zap}") + zap_group.attrs["ZAP"] = zap + zap_group.attrs["particle_name"] = name + + rx_i = rx_block(i) + tyr_i = tyr_block(i) + xs_i = xs_block(i) + edy_i = edy_block(i) + ang_i = ang_block(i) if has_ang else None + + xs_method = next( + (c for c in ["cross_sections", "cross_section", "xs"] if hasattr(xs_i, c)), + None + ) + off_method = next( + (c for c in ["energy_index", "offset", "locator", "index"] if hasattr(xs_i, c)), + None + ) + edy_method = next( + (c for c in ["energy_distribution_data", "distribution_data", "distribution"] + if hasattr(edy_i, c)), + None + ) + + for j in range(1, n_rx + 1): + MT = rx_i.MT(j) + nu_raw = tyr_i.multiplicity(j) + nu = nu_raw - 100 if nu_raw >= 100 else nu_raw + rf_raw = tyr_i.reference_frame(j) + rf = ("LAB" if rf_raw == ACEtk.ReferenceFrame.Laboratory else + "COM" if rf_raw == ACEtk.ReferenceFrame.CentreOfMass else str(rf_raw)) + + mt = zap_group.create_group(f"MT-{MT:03}") + mt.attrs["MT"] = MT + mt.attrs["multiplicity"] = nu + mt.attrs["reference_frame"] = rf + + if verbose: + print(f" MT={MT:03} nu_raw={nu_raw} nu={nu} frame={rf}") + + empty_xs = np.zeros(0, dtype=float) + if xs_method and off_method: + try: + ds = mt.create_dataset( + "production_xs", data=np.array(getattr(xs_i, xs_method)(j)) + ) + ds.attrs["offset"] = int(getattr(xs_i, off_method)(j)) - 1 + ds.attrs["unit"] = "barns" + except Exception as exc: + ds = mt.create_dataset("production_xs", data=empty_xs) + ds.attrs["offset"] = 0 + ds.attrs["unit"] = "barns" + if verbose: + print(f" [warn] production xs: {exc}") + else: + ds = mt.create_dataset("production_xs", data=empty_xs) + ds.attrs["offset"] = 0 + ds.attrs["unit"] = "barns" + if verbose: + print(f" [warn] xs methods not found: " + f"{[x for x in dir(xs_i) if not x.startswith('_')]}") + + if edy_method: + try: + _write_kalbach_mann( + getattr(edy_i, edy_method)(j), + mt.create_group("kalbach_mann") + ) + except Exception as exc: + if verbose: + print(f" [warn] energy dist: {exc}") + elif verbose: + print(f" [warn] edy method not found: " + f"{[x for x in dir(edy_i) if not x.startswith('_')]}") + + if ang_i is not None: + try: + load_cosine_distribution( + ang_i.angular_distribution_data(j), + mt.create_group("angular_cosine_distribution") + ) + except Exception: + pass + + +# -- Per-file processing ------------------------------------------------------- + +def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): + """Convert a single ACE proton file to HDF5. Returns the output filename.""" + with open(ace_path) as f: + header = ACEtk.Header.from_string(f.readline()) + + Z, A, S, _ = decode_ace_zaid(header.zaid) + symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") + nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" + + # Special case for deuterium + if symbol == "H2": + radiation_length = 125.98 + else: + radiation_length = RADIATION_LENGTH_FROM_Z.get(symbol) + + ace_table = ACEtk.ContinuousEnergyTable.from_file(ace_path) + mcdc_name = f"{nuclide_name}-{T_KELVIN}K.h5" + out_path = os.path.join(output_dir, mcdc_name) + + if verbose: + print(f"\n{'='*80}") + print(f" {os.path.basename(ace_path)} -> {mcdc_name}") + print(f" Z={Z} A={A} S={S} T={T_KELVIN} K") + + file = h5py.File(out_path, "w") + + # Metadata + hdr = ace_table.header + file.attrs["source_title"] = hdr.title + file.attrs["source_version"] = hdr.version + file.attrs["source_date"] = hdr.date + if hasattr(hdr, "comments"): + file.attrs["source_comments"] = hdr.comments + + file.create_dataset("nuclide_name", data=nuclide_name) + file.create_dataset("excitation_level", data=S) + file.create_dataset("temperature", data=T_KELVIN).attrs["unit"] = "K" + file.create_dataset("atomic_number", data=ace_table.atom_number) + file.create_dataset("mass_number", data=ace_table.mass_number) + file.create_dataset("atomic_weight_ratio", data=ace_table.atomic_weight_ratio) + file.create_dataset("radiation_length", data=radiation_length).attr["unit"] = "g/cm2" + fissionable = ace_table.fission_multiplicity_block is not None + file.create_dataset("fissionable", data=fissionable) + + write_stopping_power(file, pstar_dir, symbol, verbose=verbose) + + # Reaction classification + nu_block = ace_table.frame_and_multiplicity_block + rx_block = ace_table.reaction_number_block + N_reaction = nu_block.number_reactions + + proton_reactions = file.create_group("proton_reactions") + elastic_group = proton_reactions.create_group("elastic_scattering") + capture_group = proton_reactions.create_group("capture") + inelastic_group = proton_reactions.create_group("inelastic_reaction") + fission_group = proton_reactions.create_group("fission") + + elastic_MTs = [2] + capture_MTs = [] + inelastic_MTs = [] + fission_MTs = ([18] if rx_block.has_MT(18) else + [MT for MT in FISSION_CHANCE_MTS if rx_block.has_MT(MT)]) + + for i in range(N_reaction): + idx = i + 1 + MT = rx_block.MT(idx) + if MT in REDUNDANT_MTS + elastic_MTs + fission_MTs or MT > 891: + continue + nu_raw = nu_block.multiplicity(idx) + if not isinstance(nu_raw, int): + print_error(f"Non-integer multiplicity for MT-{MT:03} in {ace_path}") + nu = nu_raw - 100 if nu_raw >= 100 else nu_raw + if nu == 0: capture_MTs.append(MT) + elif nu > 0: inelastic_MTs.append(MT) + else: print_error(f"Negative multiplicity for MT-{MT:03} in {ace_path}") + + for grp, mts in [(elastic_group, elastic_MTs), + (capture_group, capture_MTs), + (inelastic_group, inelastic_MTs), + (fission_group, fission_MTs)]: + for MT in mts: + grp.create_group(f"MT-{MT:03}").attrs["MT"] = MT + + if verbose: + print(f" Elastic: {elastic_MTs} Capture: {capture_MTs} " + f"Inelastic: {inelastic_MTs}" + + (f" Fission: {fission_MTs}" if fissionable else "")) + + if not fissionable: + del file["proton_reactions/fission"] + if not inelastic_MTs: + del file["proton_reactions/inelastic_reaction"] + + # Cross sections + xs0 = ace_table.principal_cross_section_block + xs_main = ace_table.cross_section_block + + proton_reactions.create_dataset( + "xs_energy_grid", data=np.array(xs0.energies) + ).attrs["unit"] = "MeV" + + ds = elastic_group.create_dataset("MT-002/xs", data=np.array(xs0.elastic)) + ds.attrs["offset"] = 0 + ds.attrs["unit"] = "barns" + + for mts, grp in [(capture_MTs, capture_group), + (inelastic_MTs, inelastic_group), + (fission_MTs, fission_group if fissionable else None)]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + ds = grp.create_dataset( + f"MT-{MT:03}/xs", data=np.array(xs_main.cross_sections(idx)) + ) + ds.attrs["offset"] = xs_main.energy_index(idx) - 1 + ds.attrs["unit"] = "barns" + + # Q-values + q_block = ace_table.reaction_qvalue_block + elastic_group.create_dataset("MT-002/Q-value", data=0.0).attrs["unit"] = "MeV" + + for mts, grp in [(capture_MTs, capture_group), + (inelastic_MTs, inelastic_group), + (fission_MTs, fission_group if fissionable else None)]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + grp.create_dataset( + f"MT-{MT:03}/Q-value", data=q_block.q_value(idx) + ).attrs["unit"] = "MeV" + + # Reference frames + elastic_group.create_dataset("MT-002/reference_frame", data="COM") + + for mts, grp in [(capture_MTs, capture_group), + (inelastic_MTs, inelastic_group), + (fission_MTs, fission_group if fissionable else None)]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + rf = nu_block.reference_frame(idx) + rf_str = ("LAB" if rf == ACEtk.ReferenceFrame.Laboratory else + "COM" if rf == ACEtk.ReferenceFrame.CentreOfMass else str(rf)) + grp.create_dataset(f"MT-{MT:03}/reference_frame", data=rf_str) + + # Inelastic multiplicities + for MT in inelastic_MTs: + idx = rx_block.index(MT) + nu_raw = nu_block.multiplicity(idx) + inelastic_group.create_dataset( + f"MT-{MT:03}/multiplicity", + data=nu_raw - 100 if nu_raw >= 100 else nu_raw + ) + + # Angular distributions + angle_block = ace_table.angular_distribution_block + + ag = elastic_group.create_group("MT-002/angular_cosine_distribution") + ag.attrs["type"] = "energy-correlated" + if not load_cosine_distribution(angle_block.angular_distribution_data(0), ag) \ + and verbose: + print_note("MT-002 angular distribution is given in energy block") + + for mts, grp in [(inelastic_MTs, inelastic_group), + (fission_MTs, fission_group if fissionable else None)]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + ag = grp.create_group(f"MT-{MT:03}/angular_cosine_distribution") + if not load_cosine_distribution( + angle_block.angular_distribution_data(idx), ag) and verbose: + print_note(f"MT-{MT:03} angular distribution is given in energy block") + + # Primary energy distributions + energy_block = ace_table.energy_distribution_block + + for mts, grp in [(inelastic_MTs, inelastic_group), + (fission_MTs, fission_group if fissionable else None)]: + if grp is None: + continue + for MT in mts: + idx = rx_block.index(MT) + data = energy_block.energy_distribution_data(idx) + + if not isinstance(data, ACEtk.continuous.MultiDistributionData): + grp.create_dataset( + f"MT-{MT:03}/spectrum_probability_grid", + data=np.array([0.0, 30.0]) + ).attrs["unit"] = "MeV" + grp.create_dataset( + f"MT-{MT:03}/spectrum_probability", data=np.array([[1.0]]) + ) + load_energy_distribution( + data, grp.create_group(f"MT-{MT:03}/energy_spectrum-1") + ) + else: + N_dist = data.number_distributions + probs = data.probabilities + + if all(p.number_interpolation_regions == 0 for p in probs): + prob_grid = np.array([0.0, 30.0]) + prob = np.zeros((1, N_dist)) + for k in range(N_dist): + prob[0, k] = max(data.probability(k + 1).probabilities) + elif (all(p.number_interpolation_regions == 1 for p in probs) + and all(p.interpolants[0] == 1 for p in probs)): + prob_grid = np.array(data.probability(1).energies) + prob = np.zeros((len(prob_grid) - 1, N_dist)) + for k in range(N_dist): + prob[:, k] = np.array(data.probability(k + 1).probabilities[:-1]) + else: + print_error( + f"Unsupported multi-distribution probability for MT-{MT:03}" + ) + + grp.create_dataset( + f"MT-{MT:03}/spectrum_probability_grid", data=prob_grid + ).attrs["unit"] = "MeV" + grp.create_dataset(f"MT-{MT:03}/spectrum_probability", data=prob) + for k in range(N_dist): + load_energy_distribution( + data.distribution(k + 1), + grp.create_group(f"MT-{MT:03}/energy_spectrum-{k + 1}") + ) + + load_secondary_particles(ace_table, file, verbose=verbose) + + # Fission data + if fissionable: + prompt_block = ace_table.fission_multiplicity_block + delayed_block = ace_table.delayed_fission_multiplicity_block + dnp_block = ace_table.delayed_neutron_precursor_block + + load_fission_multiplicity( + prompt_block.multiplicity, + fission_group.create_group("prompt_multiplicity") + ) + if delayed_block is not None: + load_fission_multiplicity( + delayed_block.multiplicity, + fission_group.create_group("delayed_multiplicity") + ) + + if dnp_block is not None: + N_DNP = dnp_block.number_delayed_precursors + fractions = np.zeros(N_DNP) + decay_rates = np.zeros(N_DNP) + for k in range(N_DNP): + d = dnp_block.precursor_group_data(k + 1) + if (d.number_interpolation_regions != 0 + or len(d.probabilities[:]) != 2 + or d.probabilities[0] != d.probabilities[1]): + print_error("Non-constant delayed neutron precursor fraction") + fractions[k] = d.probabilities[0] + decay_rates[k] = d.decay_constant + + prec = fission_group.create_group("delayed_neutron_precursors") + prec.create_dataset("fractions", data=fractions) + prec.create_dataset("decay_rates", data=decay_rates).attrs["unit"] = "/s" + + delayed_spectrum_block = ace_table.delayed_neutron_energy_distribution_block + for k in range(N_DNP): + load_energy_distribution( + delayed_spectrum_block.energy_distribution_data(k + 1), + prec.create_group(f"energy_spectrum-{k + 1}") + ) + + file.close() + return mcdc_name + + +def process_pstar_only_file(symbol, A, awr, output_dir, pstar_dir, verbose=False): + """ + Create a minimal HDF5 file for an isotope that has no ACE data but does + have a PSTAR stopping power file. Returns the output filename, or None if + no PSTAR file was found. + """ + Z = SYMBOL_TO_Z[symbol] + nuclide_name = f"{symbol}{A}" + mcdc_name = f"{nuclide_name}-{T_KELVIN}K.h5" + out_path = os.path.join(output_dir, mcdc_name) + + # Special case for deuterium + if symbol == "H2": + radiation_length = 125.98 + else: + radiation_length = RADIATION_LENGTH_FROM_Z.get(symbol) + + if verbose: + print(f"\n{'='*80}") + print(f" (no ACE) -> {mcdc_name} [stopping power only]") + + file = h5py.File(out_path, "w") + + file.attrs["source_title"] = "PSTAR (NIST) stopping power only — no ACE data" + file.attrs["source_version"] = "N/A" + file.attrs["source_date"] = "N/A" + + file.create_dataset("nuclide_name", data=nuclide_name) + file.create_dataset("excitation_level", data=0) + file.create_dataset("temperature", data=T_KELVIN).attrs["unit"] = "K" + file.create_dataset("atomic_number", data=Z) + file.create_dataset("mass_number", data=A) + file.create_dataset("atomic_weight_ratio", data=awr) + file.create_dataset("radiation_length", data=radiation_length).attrs["unit"] = "g/cm2" + file.create_dataset("fissionable", data=False) + + written = write_stopping_power(file, pstar_dir, symbol, verbose=verbose) + file.close() + + if not written: + # No PSTAR data either — remove the empty file and signal failure + os.remove(out_path) + return None + + return mcdc_name + + +# -- Main ---------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser( + description="MC/DC proton data generator" + ) + parser.add_argument("--rewrite", action="store_true", default=False) + parser.add_argument("--verbose", action="store_true", default=False) + args = parser.parse_args() + rewrite = args.rewrite + verbose = args.verbose + + output_dir = os.getenv("MCDC_LIB_PROTON") + ace_dir = os.getenv("MCDC_ACELIB_PROTON") + pstar_dir = os.getenv("MCDC_PSTAR_LIB") + if ace_dir is None: + print_error("Environment variable $MCDC_ACELIB_PROTON is not set.") + if pstar_dir is None: + print_error("Environment variable $MCDC_PSTAR_LIB is not set.") + if output_dir is None: + print_error("Environment variable $MCDC_LIB_PROTON is not set.") + + os.makedirs(output_dir, exist_ok=True) + print(f"\nACE directory : {ace_dir}") + print(f"PSTAR directory : {pstar_dir}\n") + print(f"Output directory: {output_dir}") + + ace_files = sorted(f for f in os.listdir(args.ace_dir) if f.endswith(".ace")) + + # ── Pass 1: ACE files ───────────────────────────────────────────────────── + + if rewrite: + target_files = ace_files + else: + target_files = [] + for fname in ace_files: + try: + with open(os.path.join(args.ace_dir, fname)) as f: + hdr = ACEtk.Header.from_string(f.readline()) + Z, A, S, _ = decode_ace_zaid(hdr.zaid) + symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") + nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" + if not any( + f.startswith(nuclide_name + "-") + for f in os.listdir(args.output_dir) + ): + target_files.append(fname) + except Exception: + target_files.append(fname) + + errors = [] + pbar = tqdm(target_files, disable=verbose, + bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} {postfix}") + + for ace_name in pbar: + pbar.set_postfix_str(ace_name) + try: + out = process_ace_file( + os.path.join(args.ace_dir, ace_name), + args.output_dir, + pstar_dir=args.pstar_dir, + verbose=verbose, + ) + if verbose: + print(f" -> wrote {out}") + except Exception as exc: + errors.append((ace_name, str(exc))) + if verbose: + import traceback + traceback.print_exc() + + # ── Pass 2: PSTAR-only isotopes (e.g. H, He) ───────────────────────────── + # For each entry in PSTAR_ONLY_ISOTOPES, create a stopping-power-only HDF5 + # file if one doesn't already exist (or if --rewrite is set). + + if args.pstar_dir is not None: + existing = set(os.listdir(args.output_dir)) + for symbol, A, awr in PSTAR_ONLY_ISOTOPES: + nuclide_name = f"{symbol}{A}" + mcdc_name = f"{nuclide_name}-{T_KELVIN}K.h5" + if not args.rewrite and mcdc_name in existing: + continue + try: + out = process_pstar_only_file( + symbol, A, awr, args.output_dir, args.pstar_dir, + verbose=verbose + ) + if out is None: + if verbose: + print(f" [skip] No PSTAR data for {nuclide_name}") + elif verbose: + print(f" -> wrote {out} [stopping power only]") + except Exception as exc: + errors.append((nuclide_name, str(exc))) + if verbose: + import traceback + traceback.print_exc() + + # ── Summary ─────────────────────────────────────────────────────────────── + + n_total = len(target_files) + len(PSTAR_ONLY_ISOTOPES) + print(f"\nDone. {n_total - len(errors)} succeeded, {len(errors)} failed.") + if errors: + print("\nFailed files:") + for name, msg in errors: + print(f" {name}: {msg}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/data_library_generator/proton/water.py b/tools/data_library_generator/proton/water.py new file mode 100644 index 000000000..5b8d6e5b4 --- /dev/null +++ b/tools/data_library_generator/proton/water.py @@ -0,0 +1,25 @@ +import h5py +import numpy as np + +def load_pstar_file(filepath): + energies, sps = [], [] + with open(filepath) as f: + for line in f: + parts = line.strip().split() + if len(parts) != 2: + continue + try: + energies.append(float(parts[0])) + sps.append(float(parts[1])) + except ValueError: + continue + return np.array(energies), np.array(sps) + +file = h5py.File("../../../proton_generated_lib/p_in_H2O.h5", "w") +pstar_path = "../../../pstar_lib/H2O.txt" +E_s, S_s = load_pstar_file(pstar_path) +X0 = 36.08 +sp = file.create_group("stopping_power") +sp.create_dataset("energy", data=E_s).attrs["unit"] = "MeV" +sp.create_dataset("total_stopping_power", data=S_s).attrs["unit"] = "MeV cm2/g" +sp.create_dataset("radiation_length", data=X0).attrs["unit"] = "g/cm2" \ No newline at end of file From d4387c2bb24ae9c5dd62fc73e4d07100e3e0ce44 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 23 Jul 2026 14:57:52 -0700 Subject: [PATCH 61/64] updated radiation length handling --- mcdc/numba_types.py | 3 + mcdc/object_/material.py | 11 ++ mcdc/object_/nuclide.py | 2 + mcdc/transport/physics/proton/native.py | 40 ++--- tools/data_library_generator/proton/README.md | 167 ++++++++++++++++++ .../data_library_generator/proton/generate.py | 88 +++------ 6 files changed, 221 insertions(+), 90 deletions(-) create mode 100644 tools/data_library_generator/proton/README.md diff --git a/mcdc/numba_types.py b/mcdc/numba_types.py index 8fbefa89c..52fe42146 100644 --- a/mcdc/numba_types.py +++ b/mcdc/numba_types.py @@ -347,6 +347,8 @@ ('stopping_power_length', int64), ('stopping_power_energy_grid_offset', int64), ('stopping_power_energy_grid_length', int64), + ('radiation_length', float64), + ('radiation_length_provided', bool), ('ID', int64), ('parent_ID', int64), ]) @@ -394,6 +396,7 @@ ('atomic_weight_ratio', float64), ('fissionable', bool), ('excitation_level', int64), + ('radiation_length', float64), ('neutron_xs_energy_grid_offset', int64), ('neutron_xs_energy_grid_length', int64), ('neutron_total_xs_offset', int64), diff --git a/mcdc/object_/material.py b/mcdc/object_/material.py index 8ea1c448d..0058d64f7 100644 --- a/mcdc/object_/material.py +++ b/mcdc/object_/material.py @@ -106,6 +106,9 @@ class Material(MaterialBase): stopping_power_provided: bool = False stopping_power: NDArray[float64] stopping_power_energy_grid: NDArray[float64] + # + radiation_length: float64 = 0.0 + radiation_length_provided: bool = False def __init__( self, @@ -245,6 +248,14 @@ def add_stopping_power( self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] file.close() + def custom_radiation_length( + self, + radiation_length: float, + ): + + self.radiation_length_provided = True + self.radiation_length = radiation_length + # Currently supported temperatures TEMPERATURES = [0.0, 0.1, 233.15, 273.15, 293.6, 600.0, 900.0, 1200.0, 2500.0] diff --git a/mcdc/object_/nuclide.py b/mcdc/object_/nuclide.py index 71dfabb35..0c82ceef2 100644 --- a/mcdc/object_/nuclide.py +++ b/mcdc/object_/nuclide.py @@ -43,6 +43,7 @@ class Nuclide(ObjectNonSingleton): atomic_weight_ratio: float fissionable: bool excitation_level: int + radiation_length: float # neutron_xs_energy_grid: NDArray[float64] neutron_total_xs: NDArray[float64] @@ -91,6 +92,7 @@ def __init__(self, nuclide_name, temperature): self.atomic_weight_ratio = file["atomic_weight_ratio"][()] self.fissionable = bool(file["fissionable"][()]) self.excitation_level = int(file["excitation_level"][()]) + self.radiation_length = float(file["radiation_length"][()]) file.close() # Initialize all attributes to defaults diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 425f88820..0a9690041 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -282,8 +282,6 @@ def csda_edep(particle_container, collision_data_container, distance, simulation radiation_length = get_radiation_length(particle_container, simulation, data) - - X0 = 24.01 # Radiation length for Al, in g/cm^2 # X0 = 36.33 # Radiation length for H2O, in g/cm^2 @@ -720,6 +718,7 @@ def rotate_direction(particle, phi, theta): particle["uz"] = d_new[2] +@njit def calculate_total_stopping_power(particle_container, simulation, data): particle = particle_container[0] material = simulation["native_materials"][particle["material_ID"]] @@ -765,39 +764,34 @@ def calculate_total_stopping_power(particle_container, simulation, data): return average_A, average_Z, total_stopping_power, total_rho_gcm3 - +@njit def get_radiation_length(particle_container, simulation, data): particle = particle_container[0] material = simulation["native_materials"][particle["material_ID"]] - if material["stopping_power_provided"]: - if mcdc_get.native_material.radiation_length is None: - print("ValueError: need radiation length for material. May be found at https://pdg.lbl.gov/2026/AtomicNuclearProperties") + if material["radiation_length_provided"]: + radiation_length = material["radiation_length"] + # radiation_length = mcdc_get.native_material.radiation_length(material, data) - radiation_length = mcdc_get.native_material.radiation_length(material, data) + # Calculate the radiation length based on the material's nuclide composition + # Using Eq. 4 from "Calculation of radiation length in materials", R.J da Silva + # Using nuclide density here as an analog to # of moles; ratios are preserved, so it should be fine - elif not material["stopping_power_provided"]: + elif not material["radiation_length_provided"]: + total_mass = 0.0 + X0_weighted_mass = 0.0 for i in range(material["N_nuclide"]): nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) nuclide = simulation["nuclides"][nuclide_ID] - - - # If no stopping power provided, we calculate it ourselves here - # if not material["stopping_power_provided"]: - # dedx_values = mcdc_get.nuclide.stopping_power_all(nuclide, data) - # dedx_energies = mcdc_get.nuclide.stopping_power_energy_grid_all(nuclide, data) - - # # TODO: replace np.interp with a non-numpy function?? - # dedx = np.interp(E / 1e6, dedx_energies, dedx_values) - # total_stopping_power += dedx * 1e6 - - # Convert atoms/barn-cm to g/cm3: - atomic_mass = nuclide["atomic_weight_ratio"] # mass in amu + nuclide_mass = nuclide["mass_number"] nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) - density_gcm3 = nuclide_density * 1e24 * atomic_mass / (6.022e23) - total_rho_gcm3 += density_gcm3 + nuclide_X0 = nuclide["radiation_length"] + + total_mass += nuclide_mass * nuclide_density + X0_weighted_mass += nuclide_mass * nuclide_density / nuclide_X0 + radiation_length = total_mass / X0_weighted_mass return radiation_length diff --git a/tools/data_library_generator/proton/README.md b/tools/data_library_generator/proton/README.md new file mode 100644 index 000000000..fd4847f8e --- /dev/null +++ b/tools/data_library_generator/proton/README.md @@ -0,0 +1,167 @@ +# MC/DC Proton Data Library Generator +Converts ACE-format proton cross section data from TENDL2021 into MC/DC's +per-nuclide HDF5 format for proton transport. Also uses the NIST PSTAR +database to add stopping power data to the HDF5 files. + +## Prerequisites + +- Installing ACEtk from source: [link](https://github.com/njoy/ACEtk) +- Dependencies: `pip install h5py numpy tqdm` +- For any nuclide that you want stopping power for, download the stopping power data from NIST'S PSTAR database: https://physics.nist.gov/PhysRefData/Star/Text/PSTAR.html + - When downloading the stopping power data, select only the total stopping power. This generator script uses the load_pstar_file function to extract the data assuming two columns of data. +- You need the TENDL2021 ACE files for protons. Avaiable at this link: https://tendl.imperial.ac.uk/tendl_2021/tar.html + - Or, download the tar file directly: https://tendl.imperial.ac.uk/tendl_2021/tar_files/TENDL-ACE-p.tgz + +## Environment Variables +| Variable | Description | +|------------------------|------------------------------------------------------------------------| +| `MCDC_ACELIB_PROTON` | Path to the directory containing the TENDL2021 ACE files. | +| `MCDC_LIB_PROTON` | Path to the output directory for MC/DC HDF5 files. | +| `MCDC_PSTAR_LIB` | Path to the directory containing the PSTAR stopping power table files. | + +## Usage +```bash +export MCDC_ACELIB_PROTON=/path/to/tendl2021/acefiles +export MCDC_LIB_PROTON=/path/to/mcdc/proton/library + +python generate.py # Convert only missing elements +python generate.py --rewrite # Regenerate all files +python generate.py --verbose # Print detailed per-element info +``` + +## What it Does +For each element (Z=1 to Z=103) in the TENDL2021 ACE file library, the generator: +1. Loads the data from the ACE table, and writes basic data (name, temperature, mass, etc.) to the HDF5 file. +2. Extracts the stopping power data (if present in $MCDC_PSTAR_LIB). +3. Extracts the MT numbers for elastic scattering reactions and their cross sections. +4. Extracts the MT numbers for capture reactions and their cross sections. +5. Extracts the MT numbers for inelastic scattering reactions and their cross sections. +6. Extracts the energy & angular distributions for scattering reactions. +7. Creates a data block in the HDF5 file to handle the secondary particle products & energies. +8. If there are isotopes present with PSTAR stopping power data, but without an ACE file from TENDL, it creates + an HDF5 file that contains the stopping power data. + +## Output HDF5 Schema +``` +File attrs: source_title, source_version, source_date, source_comments (if present) + +-K.h5 +├── nuclide_name (string) +├── excitation_level (int) +├── temperature (float; attr: unit="K") +├── atomic_number (int) +├── mass_number (int) +├── atomic_weight_ratio (float) +├── radiation_length (float; attr: unit="g/cm2") +├── fissionable (bool) +├── stopping_power/ (present only if a matching PSTAR file was found) +│ ├── energy (1-D array; attr: unit="MeV") +│ └── total_stopping_power (1-D array; attr: unit="MeV cm2/g") +├── proton_reactions/ +│ ├── xs_energy_grid (1-D array; attr: unit="MeV") +│ ├── elastic_scattering/ +│ │ └── MT-002/ (attr: MT=2) +│ │ ├── xs (1-D array, barns; attr: offset=0) +│ │ ├── Q-value (float=0.0; attr: unit="MeV") +│ │ ├── reference_frame (string: "COM") +│ │ └── angular_cosine_distribution/ (attr: type="energy-correlated"; see [A] below) +│ ├── capture/ (one group per capture MT, i.e. multiplicity=0) +│ │ └── MT-NNN/ (attr: MT) +│ │ ├── xs (1-D array, barns; attr: offset) +│ │ ├── Q-value (float; attr: unit="MeV") +│ │ └── reference_frame (string: "LAB" or "COM") +│ ├── inelastic_reaction/ (present only if any inelastic MTs exist) +│ │ └── MT-NNN/ (attr: MT; one per inelastic MT) +│ │ ├── xs (1-D array, barns; attr: offset) +│ │ ├── Q-value (float; attr: unit="MeV") +│ │ ├── reference_frame (string: "LAB" or "COM") +│ │ ├── multiplicity (int) +│ │ ├── angular_cosine_distribution/ (see [A] below) +│ │ ├── spectrum_probability_grid (1-D array; attr: unit="MeV") +│ │ ├── spectrum_probability (2-D array [grid x n_dist]) +│ │ └── energy_spectrum-N/ (one per outgoing-energy law; see [B] below) +│ └── fission/ (present only if fissionable) +│ ├── MT-NNN/ (attr: MT; MT-018 or the fission-chance MTs 19/20/21/38) +│ │ ├── xs (1-D array, barns; attr: offset) +│ │ ├── Q-value (float; attr: unit="MeV") +│ │ ├── reference_frame (string: "LAB" or "COM") +│ │ ├── angular_cosine_distribution/ (see [A] below) +│ │ ├── spectrum_probability_grid (1-D array; attr: unit="MeV") +│ │ ├── spectrum_probability (2-D array [grid x n_dist]) +│ │ └── energy_spectrum-N/ (see [B] below) +│ ├── prompt_multiplicity/ (see [C] below) +│ ├── delayed_multiplicity/ (optional; see [C] below) +│ └── delayed_neutron_precursors/ (optional) +│ ├── fractions (1-D array, one per precursor group) +│ ├── decay_rates (1-D array; attr: unit="/s") +│ └── energy_spectrum-N/ (one per precursor group; see [B] below) +└── secondary_particles/ (present only if ACE table has secondary-particle data) + └── ZAP_/ (attrs: ZAP, particle_name; one per secondary particle type) + └── MT-NNN/ (attrs: MT, multiplicity, reference_frame) + ├── production_xs (1-D array, barns; attr: offset) + ├── kalbach_mann/ (attr: type="kalbach-mann") + │ ├── energy (1-D array; attr: unit="MeV") + │ ├── offset (1-D int array, one entry per incident energy) + │ ├── energy_out (1-D array; attr: unit="MeV") + │ ├── pdf (1-D array) + │ ├── cdf (1-D array) + │ ├── precompound_factor (1-D array) + │ └── angular_slope (1-D array) + └── angular_cosine_distribution/ (see [A] below) + + +[A] angular_cosine_distribution/ schema (load_cosine_distribution): + attr: type="given_in_energy_distribution" (angular data embedded in the energy-distribution block — no other content) + — or — + attr: type="tabulated"; attr: unit="MeV" + ├── incident_energies (1-D array) + └── E_in_i/ (one group per incident energy) + attr: type="tabulated" → cosines, pdf, cdf (1-D arrays) + attr: type="isotropic" → (no datasets) + +[B] energy_spectrum-N/ schema (load_energy_distribution), attr: law = ENDF law number: + law=44 (Kalbach-Mann): + attr: type="kalbach-mann" + ├── energy (1-D array; attr: unit="MeV") + ├── offset (1-D int array) + ├── energy_out (1-D array; attr: unit="MeV") + ├── pdf, cdf (1-D arrays) + ├── precompound_factor (1-D array) + └── angular_slope (1-D array) + law=4 (tabulated outgoing energy): + ├── incident_energies (1-D array) + └── E_in_k/ → outgoing_energies, pdf, cdf (1-D arrays) + law=3 (level scattering): + ├── C1 (float) + └── C2 (float) + law=1 (equiprobable bins): + ├── incident_energies (1-D array) + └── E_in_k/ → energies (1-D array) + law=-1 (unrecognized type): + attr: type_name= + └── xss_array (1-D array; only if extraction succeeds) + +[C] prompt_multiplicity/ and delayed_multiplicity/ schema (load_fission_multiplicity): + attr: type="tabulated" → energies, multiplicities (1-D arrays) + attr: type="polynomial" → coefficients (1-D array) + attr: type="unknown" → attr: type_name= (no data) + + +── Stopping-power-only fallback (process_pstar_only_file; H-1, H-2, He-3, He-4 when no ACE file exists) ── + +-K.h5 +├── nuclide_name (string) +├── excitation_level (int = 0) +├── temperature (float; attr: unit="K") +├── atomic_number (int) +├── mass_number (int) +├── atomic_weight_ratio (float) +├── radiation_length (float; attr: unit="g/cm2") +├── fissionable (bool = False) +└── stopping_power/ + ├── energy (1-D array; attr: unit="MeV") + └── total_stopping_power (1-D array; attr: unit="MeV cm2/g") +``` + +## See Also +- [TENDL2021](https://tendl.imperial.ac.uk/tendl_2021/tendl2021.html) diff --git a/tools/data_library_generator/proton/generate.py b/tools/data_library_generator/proton/generate.py index ced76223a..8a9fbdb90 100644 --- a/tools/data_library_generator/proton/generate.py +++ b/tools/data_library_generator/proton/generate.py @@ -1,56 +1,5 @@ # The majority of this script was written by Anthropic's Claude -""" -generate.py — Convert TENDL proton ACE files to HDF5 for MC/DC - -For isotopes that have no ACE file (e.g. H, He) but do have a PSTAR stopping -power file, a minimal HDF5 file is created containing only the stopping power -data. This ensures every element that can appear in a material has at least -a stopping power entry. - -Usage ------ - python generate.py - python generate.py --rewrite # overwrite existing files - python generate.py --verbose # per-reaction detail - -HDF5 layout ------------ --K.h5 - attrs: source_title, source_version, source_date - nuclide_name, excitation_level, temperature (K), - atomic_number, mass_number, atomic_weight_ratio, fissionable - - stopping_power/ (if PSTAR data available) - energy (MeV), total_stopping_power (MeV cm2/g) - - proton_reactions/ (absent for stopping-power-only files) - xs_energy_grid (MeV) - elastic_scattering/MT-002/ - xs (barns, offset=0), Q-value (MeV), reference_frame, - angular_cosine_distribution/ - capture/MT-{NNN}/ - xs (barns), Q-value (MeV), reference_frame - ielastic_scattering/MT-{NNN}/ - xs (barns), Q-value (MeV), reference_frame, multiplicity - angular_cosine_distribution/ - energy_spectrum-{k}/ (law attr; kalbach-mann: energy, offset, - energy_out, pdf, cdf, precompound_factor, angular_slope) - fission/ (only if fissionable) - - secondary_particles/ZAP_{zap}/MT-{NNN}/ - attrs: ZAP, particle_name, MT, multiplicity, reference_frame - production_xs (barns, offset) - kalbach_mann/ (energy, offset, energy_out, pdf, cdf, - precompound_factor, angular_slope) - -ZAP identity: 1=n, 1001=p, 1002=d, 1003=t, 2003=He3, 2004=alpha, 0=gamma - -TabulatedKalbachMannDistribution properties used (from ACEtk): - outgoing_energies, pdf, cdf, - precompound_fraction_values, angular_distribution_slope_values -""" - import argparse import os import sys @@ -84,11 +33,13 @@ 73:"Ta", 74:"W", 75:"Re", 76:"Os", 77:"Ir", 78:"Pt", 79:"Au", 80:"Hg", 81:"Tl", 82:"Pb", 83:"Bi", 84:"Po", 85:"At", 86:"Rn", 87:"Fr", 88:"Ra", 89:"Ac", 90:"Th", 91:"Pa", 92:"U", 93:"Np", 94:"Pu", 95:"Am", 96:"Cm", - 97:"Bk", 98:"Cf", 99:"Es",100:"Fm",101:"Md",102:"No",103:"Lr", + 97:"Bk", 98:"Cf", 99:"Es",100:"Fm",101:"Md",102:"No",103:"Lr",104:"Rf", + 105:"Db",106:"Sg",107:"Bh",108:"Hs",109:"Mt",110:"Ds",111:"Rg",112:"Cn", + 113:"Nh",114:"Fl",115:"Mc",116:"Lv",117:"Ts",118:"Og" } RADIATION_LENGTH_FROM_Z = { - "H": 63.04, "He": 94.32, "Li": 82.77, "Be": 65.19, "Bo": 52.68, "C": 42.70, + "H": 63.04, "He": 94.32, "Li": 82.77, "Be": 65.19, "B": 52.68, "C": 42.70, "N": 37.99, "O": 34.24, "F": 32.93, "Ne": 28.93, "Na": 27.74, "Mg": 25.03, "Al": 24.01, "Si": 21.82, "P": 21.21, "S": 19.50, "Cl": 19.28, "Ar": 19.55, "K": 17.32, "Ca": 16.14, "Sc": 16.55, "Ti": 16.16, "V": 15.84, "Cr": 14.94, @@ -105,7 +56,9 @@ "At": 6.07, "Rn": 6.28, "Fr": 6.19, "Ra": 6.15, "Ac": 6.06, "Th": 6.07, "Pa": 5.93, "U": 6.00, "Np": 5.87, "Pu": 5.93, "Am": 5.80, "Cm": 5.79, "Bk": 5.69, "Cf": 5.68, "Es": 5.61, "Fm": 5.62, "Md": 5.55, "No": 5.48, - "Lr": 5.45, + "Lr": 5.45, "Rf": 5.47, "Db": 5.40, "Sg": 5.34, "Bh": 5.27, "Hs": 5.17, + "Mt": 5.26, "Ds": 5.24, "Rg": 5.18, "Cn": 5.16, "Nh": 5.10, "Fl": 5.08, + "Mc": 5.01, "Lv": 5.00, "Ts": 4.95, "Og": 4.88, } SYMBOL_TO_Z = {v: k for k, v in Z_TO_SYMBOL.items()} @@ -133,6 +86,7 @@ def print_error(msg): print(f"\n[ERROR] {msg}", file=sys.stderr) + raise ValueError(msg) sys.exit(1) @@ -474,7 +428,7 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): file.create_dataset("atomic_number", data=ace_table.atom_number) file.create_dataset("mass_number", data=ace_table.mass_number) file.create_dataset("atomic_weight_ratio", data=ace_table.atomic_weight_ratio) - file.create_dataset("radiation_length", data=radiation_length).attr["unit"] = "g/cm2" + file.create_dataset("radiation_length", data=radiation_length).attrs["unit"] = "g/cm2" fissionable = ace_table.fission_multiplicity_block is not None file.create_dataset("fissionable", data=fissionable) @@ -780,10 +734,10 @@ def main(): os.makedirs(output_dir, exist_ok=True) print(f"\nACE directory : {ace_dir}") - print(f"PSTAR directory : {pstar_dir}\n") - print(f"Output directory: {output_dir}") + print(f"PSTAR directory : {pstar_dir}") + print(f"Output directory: {output_dir}\n") - ace_files = sorted(f for f in os.listdir(args.ace_dir) if f.endswith(".ace")) + ace_files = sorted(f for f in os.listdir(ace_dir) if f.endswith(".ace")) # ── Pass 1: ACE files ───────────────────────────────────────────────────── @@ -793,14 +747,14 @@ def main(): target_files = [] for fname in ace_files: try: - with open(os.path.join(args.ace_dir, fname)) as f: + with open(os.path.join(ace_dir, fname)) as f: hdr = ACEtk.Header.from_string(f.readline()) Z, A, S, _ = decode_ace_zaid(hdr.zaid) symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" if not any( f.startswith(nuclide_name + "-") - for f in os.listdir(args.output_dir) + for f in os.listdir(output_dir) ): target_files.append(fname) except Exception: @@ -814,9 +768,9 @@ def main(): pbar.set_postfix_str(ace_name) try: out = process_ace_file( - os.path.join(args.ace_dir, ace_name), - args.output_dir, - pstar_dir=args.pstar_dir, + os.path.join(ace_dir, ace_name), + output_dir, + pstar_dir=pstar_dir, verbose=verbose, ) if verbose: @@ -831,16 +785,16 @@ def main(): # For each entry in PSTAR_ONLY_ISOTOPES, create a stopping-power-only HDF5 # file if one doesn't already exist (or if --rewrite is set). - if args.pstar_dir is not None: - existing = set(os.listdir(args.output_dir)) + if pstar_dir is not None: + existing = set(os.listdir(output_dir)) for symbol, A, awr in PSTAR_ONLY_ISOTOPES: nuclide_name = f"{symbol}{A}" mcdc_name = f"{nuclide_name}-{T_KELVIN}K.h5" - if not args.rewrite and mcdc_name in existing: + if not rewrite and mcdc_name in existing: continue try: out = process_pstar_only_file( - symbol, A, awr, args.output_dir, args.pstar_dir, + symbol, A, awr, output_dir, pstar_dir, verbose=verbose ) if out is None: From 2f3133cc15e9deecdbb945be598d5c8c6817197f Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Fri, 24 Jul 2026 13:09:14 -0700 Subject: [PATCH 62/64] remove old proton generator function --- tools/data_library_generator/tendl_to_hdf5.py | 832 ------------------ 1 file changed, 832 deletions(-) delete mode 100644 tools/data_library_generator/tendl_to_hdf5.py diff --git a/tools/data_library_generator/tendl_to_hdf5.py b/tools/data_library_generator/tendl_to_hdf5.py deleted file mode 100644 index be8df7a4a..000000000 --- a/tools/data_library_generator/tendl_to_hdf5.py +++ /dev/null @@ -1,832 +0,0 @@ -# The majority of this script was written by Anthropic's Claude - -""" -tendl_to_hdf5.py — Convert TENDL proton ACE files to HDF5 for MC/DC - -For isotopes that have no ACE file (e.g. H, He) but do have a PSTAR stopping -power file, a minimal HDF5 file is created containing only the stopping power -data. This ensures every element that can appear in a material has at least -a stopping power entry. - -Usage ------ - python tendl_to_hdf5.py - python tendl_to_hdf5.py --ace_dir /path/to/ace --output_dir /path/to/hdf5 [--pstar_dir /path/to/pstar] - python tendl_to_hdf5.py ... --rewrite # overwrite existing files - python tendl_to_hdf5.py ... --verbose # per-reaction detail - -Environment variable fallbacks: $MCDC_ACELIB, $MCDC_LIB, $PSTAR_LIB - -HDF5 layout ------------ --K.h5 - attrs: source_title, source_version, source_date - nuclide_name, excitation_level, temperature (K), - atomic_number, mass_number, atomic_weight_ratio, fissionable - - stopping_power/ (if PSTAR data available) - energy (MeV), total_stopping_power (MeV cm2/g) - - proton_reactions/ (absent for stopping-power-only files) - xs_energy_grid (MeV) - elastic_scattering/MT-002/ - xs (barns, offset=0), Q-value (MeV), reference_frame, - angular_cosine_distribution/ - capture/MT-{NNN}/ - xs (barns), Q-value (MeV), reference_frame - ielastic_scattering/MT-{NNN}/ - xs (barns), Q-value (MeV), reference_frame, multiplicity - angular_cosine_distribution/ - energy_spectrum-{k}/ (law attr; kalbach-mann: energy, offset, - energy_out, pdf, cdf, precompound_factor, angular_slope) - fission/ (only if fissionable) - - secondary_particles/ZAP_{zap}/MT-{NNN}/ - attrs: ZAP, particle_name, MT, multiplicity, reference_frame - production_xs (barns, offset) - kalbach_mann/ (energy, offset, energy_out, pdf, cdf, - precompound_factor, angular_slope) - -ZAP identity: 1=n, 1001=p, 1002=d, 1003=t, 2003=He3, 2004=alpha, 0=gamma - -TabulatedKalbachMannDistribution properties used (from ACEtk): - outgoing_energies, pdf, cdf, - precompound_fraction_values, angular_distribution_slope_values -""" - -import argparse -import os -import sys - -import h5py -import numpy as np -from tqdm import tqdm -import ACEtk - - -# -- Constants ----------------------------------------------------------------- - -ZAP_NAMES = { - 1: "neutron", - 31: "deuteron", - 32: "triton", - 33: "He3", - 34: "alpha", -} - -Z_TO_SYMBOL = { - 1:"H", 2:"He", 3:"Li", 4:"Be", 5:"B", 6:"C", 7:"N", 8:"O", - 9:"F", 10:"Ne", 11:"Na", 12:"Mg", 13:"Al", 14:"Si", 15:"P", 16:"S", - 17:"Cl", 18:"Ar", 19:"K", 20:"Ca", 21:"Sc", 22:"Ti", 23:"V", 24:"Cr", - 25:"Mn", 26:"Fe", 27:"Co", 28:"Ni", 29:"Cu", 30:"Zn", 31:"Ga", 32:"Ge", - 33:"As", 34:"Se", 35:"Br", 36:"Kr", 37:"Rb", 38:"Sr", 39:"Y", 40:"Zr", - 41:"Nb", 42:"Mo", 43:"Tc", 44:"Ru", 45:"Rh", 46:"Pd", 47:"Ag", 48:"Cd", - 49:"In", 50:"Sn", 51:"Sb", 52:"Te", 53:"I", 54:"Xe", 55:"Cs", 56:"Ba", - 57:"La", 58:"Ce", 59:"Pr", 60:"Nd", 61:"Pm", 62:"Sm", 63:"Eu", 64:"Gd", - 65:"Tb", 66:"Dy", 67:"Ho", 68:"Er", 69:"Tm", 70:"Yb", 71:"Lu", 72:"Hf", - 73:"Ta", 74:"W", 75:"Re", 76:"Os", 77:"Ir", 78:"Pt", 79:"Au", 80:"Hg", - 81:"Tl", 82:"Pb", 83:"Bi", 84:"Po", 85:"At", 86:"Rn", 87:"Fr", 88:"Ra", - 89:"Ac", 90:"Th", 91:"Pa", 92:"U", 93:"Np", 94:"Pu", 95:"Am", 96:"Cm", - 97:"Bk", 98:"Cf", 99:"Es",100:"Fm",101:"Md",102:"No",103:"Lr", -} - -SYMBOL_TO_Z = {v: k for k, v in Z_TO_SYMBOL.items()} - -# Isotopes to generate stopping-power-only HDF5 files for when no ACE file -# exists. Covers H and He which TENDL excludes because TALYS doesn't apply. -# Format: (symbol, A, atomic_weight_ratio) -# AWR = atomic mass / neutron mass; neutron mass = 1.008664916 u -PSTAR_ONLY_ISOTOPES = [ - ("H", 1, 1.00794 / 1.008664916), # natural H ≈ H-1 - ("H", 2, 2.01410 / 1.008664916), # deuterium - ("He", 3, 3.01603 / 1.008664916), # He-3 - ("He", 4, 4.00260 / 1.008664916), # He-4 -] - -# Redundant sum MTs that must not be double-counted -REDUNDANT_MTS = [1, 3, 4, 10, 101, 103, 104, 105, 106, 107] -FISSION_CHANCE_MTS = [19, 20, 21, 38] - -# Temperature written to all files (TENDL proton ACE files report 0 K) -T_KELVIN = 0.0 - - -# -- Utility ------------------------------------------------------------------- - -def print_error(msg): - print(f"\n[ERROR] {msg}", file=sys.stderr) - sys.exit(1) - - -def print_note(msg): - print(f" [note] {msg}") - - -def decode_ace_zaid(zaid): - """Return (Z, A, S, T=0) from an ACE ZAID string.""" - za = int(zaid.strip().split(".")[0]) - S = 0 - if za >= 600000: - S = (za % 1000) // 400 - za = za - S * 400 - return za // 1000, za % 1000, S, 0 - - -def load_pstar_file(filepath): - """ - Load a two-column PSTAR file (Energy MeV, Stopping power MeV cm^2/g). - Returns (energies, stopping_powers) as float64 arrays. - """ - energies, sps = [], [] - with open(filepath) as f: - for line in f: - parts = line.strip().split() - if len(parts) != 2: - continue - try: - energies.append(float(parts[0])) - sps.append(float(parts[1])) - except ValueError: - continue - return np.array(energies), np.array(sps) - - -def write_stopping_power(file, pstar_dir, symbol, verbose=False): - """ - Write stopping_power group into an open HDF5 file if a PSTAR file exists. - Returns True if data was written. - """ - if pstar_dir is None: - return False - pstar_path = os.path.join(pstar_dir, f"{symbol}.txt") - if not os.path.exists(pstar_path): - if verbose: - print(f" [warn] No PSTAR file for {symbol}") - return False - if verbose: - print(f" Loading PSTAR from {pstar_path}") - E_s, S_s = load_pstar_file(pstar_path) - sp = file.create_group("stopping_power") - sp.create_dataset("energy", data=E_s).attrs["unit"] = "MeV" - sp.create_dataset("total_stopping_power", data=S_s).attrs["unit"] = "MeV cm2/g" - return True - - -# -- Distribution writers ------------------------------------------------------ - -def load_cosine_distribution(data, h5_group): - """ - Write a tabulated angular distribution into h5_group. - Returns False if the distribution is embedded in a Kalbach-Mann block - (DistributionGivenElsewhere), True otherwise. - """ - if isinstance(data, ACEtk.continuous.DistributionGivenElsewhere): - h5_group.attrs["type"] = "given_in_energy_distribution" - return False - - h5_group.attrs["type"] = "tabulated" - h5_group.attrs["unit"] = "MeV" - h5_group.create_dataset("incident_energies", data=np.array(data.incident_energies)) - - for i, subdist in enumerate(data.distributions): - eg = h5_group.create_group(f"E_in_{i + 1}") - if isinstance(subdist, ACEtk.continuous.TabulatedAngularDistribution): - eg.attrs["type"] = "tabulated" - eg.create_dataset("cosines", data=np.array(subdist.cosines)) - eg.create_dataset("pdf", data=np.array(subdist.pdf)) - eg.create_dataset("cdf", data=np.array(subdist.cdf)) - else: - eg.attrs["type"] = "isotropic" - - return True - - -def _write_kalbach_mann(km_data, h5_group): - """ - Write a KalbachMannDistributionData into h5_group as flat arrays. - offset[i] gives the starting index in the flat arrays for incident energy i. - """ - h5_group.attrs["type"] = "kalbach-mann" - - NE = km_data.number_incident_energies - h5_group.create_dataset( - "energy", data=np.array(km_data.incident_energies) - ).attrs["unit"] = "MeV" - - offset, energy_out, pdf, cdf, r_vals, a_vals = [], [], [], [], [], [] - for i in range(1, NE + 1): - dist = km_data.distribution(i) - offset.append(len(energy_out)) - energy_out.extend(dist.outgoing_energies) - pdf.extend(dist.pdf) - cdf.extend(dist.cdf) - r_vals.extend(dist.precompound_fraction_values) - a_vals.extend(dist.angular_distribution_slope_values) - - h5_group.create_dataset("offset", data=np.array(offset, dtype=np.int32)) - h5_group.create_dataset( - "energy_out", data=np.array(energy_out) - ).attrs["unit"] = "MeV" - h5_group.create_dataset("pdf", data=np.array(pdf)) - h5_group.create_dataset("cdf", data=np.array(cdf)) - h5_group.create_dataset("precompound_factor", data=np.array(r_vals)) - h5_group.create_dataset("angular_slope", data=np.array(a_vals)) - - -def load_energy_distribution(data, h5_group): - """Write a primary-particle outgoing energy distribution into h5_group.""" - if isinstance(data, ACEtk.continuous.KalbachMannDistributionData): - h5_group.attrs["law"] = 44 - _write_kalbach_mann(data, h5_group) - - elif isinstance(data, ACEtk.continuous.OutgoingEnergyDistributionData): - h5_group.attrs["law"] = 4 - h5_group.create_dataset( - "incident_energies", data=np.array(data.incident_energies) - ) - for k, dist in enumerate(data.distributions): - eg = h5_group.create_group(f"E_in_{k + 1}") - eg.create_dataset("outgoing_energies", data=np.array(dist.outgoing_energies)) - eg.create_dataset("pdf", data=np.array(dist.pdf)) - eg.create_dataset("cdf", data=np.array(dist.cdf)) - - elif isinstance(data, ACEtk.continuous.LevelScatteringData): - h5_group.attrs["law"] = 3 - h5_group.create_dataset("C1", data=data.C1) - h5_group.create_dataset("C2", data=data.C2) - - elif isinstance(data, ACEtk.continuous.EquiprobableOutgoingEnergyBins): - h5_group.attrs["law"] = 1 - h5_group.create_dataset( - "incident_energies", data=np.array(data.incident_energies) - ) - for k, dist in enumerate(data.distributions): - h5_group.create_group(f"E_in_{k + 1}").create_dataset( - "energies", data=np.array(dist.energies) - ) - - else: - h5_group.attrs["law"] = -1 - h5_group.attrs["type_name"] = type(data).__name__ - try: - h5_group.create_dataset("xss_array", data=np.array(data.xss_array)) - except Exception: - pass - - -def load_fission_multiplicity(data, h5_group): - if isinstance(data, ACEtk.continuous.TabulatedFissionMultiplicity): - h5_group.attrs["type"] = "tabulated" - h5_group.create_dataset("energies", data=np.array(data.energies)) - h5_group.create_dataset("multiplicities", data=np.array(data.multiplicities)) - elif isinstance(data, ACEtk.continuous.PolynomialFissionMultiplicity): - h5_group.attrs["type"] = "polynomial" - h5_group.create_dataset("coefficients", data=np.array(data.coefficients)) - else: - h5_group.attrs["type"] = "unknown" - h5_group.attrs["type_name"] = type(data).__name__ - - -# -- Secondary particles ------------------------------------------------------- - -def load_secondary_particles(ace_table, file, verbose=False): - n_types = ace_table.number_secondary_particle_types - if n_types == 0: - return - - type_block = ace_table.secondary_particle_type_block - info_block = ace_table.secondary_particle_information_block - rx_block = ace_table.secondary_particle_reaction_number_block - tyr_block = ace_table.secondary_particle_frame_and_multiplicity_block - xs_block = ace_table.secondary_particle_production_cross_section_block - edy_block = ace_table.secondary_particle_energy_distribution_block - - has_ang = False - try: - ang_block = ace_table.secondary_particle_angular_distribution_block - has_ang = True - except Exception: - pass - - sec_group = file.create_group("secondary_particles") - - pi_method = next( - (c for c in ["particle_identifier", "ZAP", "type", "particle_type"] - if hasattr(type_block, c)), - None - ) - if pi_method is None: - raise AttributeError( - f"Cannot find particle identifier on {type(type_block).__name__}. " - f"Available: {[x for x in dir(type_block) if not x.startswith('_')]}" - ) - - for i in range(1, n_types + 1): - zap = getattr(type_block, pi_method)(i) - name = ZAP_NAMES.get(zap, f"ZAP_{zap}") - n_rx = int(info_block.number_reactions[i - 1]) - - if verbose: - print(f" Secondary type {i}: ZAP={zap} ({name}), {n_rx} reactions") - - zap_group = sec_group.create_group(f"ZAP_{zap}") - zap_group.attrs["ZAP"] = zap - zap_group.attrs["particle_name"] = name - - rx_i = rx_block(i) - tyr_i = tyr_block(i) - xs_i = xs_block(i) - edy_i = edy_block(i) - ang_i = ang_block(i) if has_ang else None - - xs_method = next( - (c for c in ["cross_sections", "cross_section", "xs"] if hasattr(xs_i, c)), - None - ) - off_method = next( - (c for c in ["energy_index", "offset", "locator", "index"] if hasattr(xs_i, c)), - None - ) - edy_method = next( - (c for c in ["energy_distribution_data", "distribution_data", "distribution"] - if hasattr(edy_i, c)), - None - ) - - for j in range(1, n_rx + 1): - MT = rx_i.MT(j) - nu_raw = tyr_i.multiplicity(j) - nu = nu_raw - 100 if nu_raw >= 100 else nu_raw - rf_raw = tyr_i.reference_frame(j) - rf = ("LAB" if rf_raw == ACEtk.ReferenceFrame.Laboratory else - "COM" if rf_raw == ACEtk.ReferenceFrame.CentreOfMass else str(rf_raw)) - - mt = zap_group.create_group(f"MT-{MT:03}") - mt.attrs["MT"] = MT - mt.attrs["multiplicity"] = nu - mt.attrs["reference_frame"] = rf - - if verbose: - print(f" MT={MT:03} nu_raw={nu_raw} nu={nu} frame={rf}") - - empty_xs = np.zeros(0, dtype=float) - if xs_method and off_method: - try: - ds = mt.create_dataset( - "production_xs", data=np.array(getattr(xs_i, xs_method)(j)) - ) - ds.attrs["offset"] = int(getattr(xs_i, off_method)(j)) - 1 - ds.attrs["unit"] = "barns" - except Exception as exc: - ds = mt.create_dataset("production_xs", data=empty_xs) - ds.attrs["offset"] = 0 - ds.attrs["unit"] = "barns" - if verbose: - print(f" [warn] production xs: {exc}") - else: - ds = mt.create_dataset("production_xs", data=empty_xs) - ds.attrs["offset"] = 0 - ds.attrs["unit"] = "barns" - if verbose: - print(f" [warn] xs methods not found: " - f"{[x for x in dir(xs_i) if not x.startswith('_')]}") - - if edy_method: - try: - _write_kalbach_mann( - getattr(edy_i, edy_method)(j), - mt.create_group("kalbach_mann") - ) - except Exception as exc: - if verbose: - print(f" [warn] energy dist: {exc}") - elif verbose: - print(f" [warn] edy method not found: " - f"{[x for x in dir(edy_i) if not x.startswith('_')]}") - - if ang_i is not None: - try: - load_cosine_distribution( - ang_i.angular_distribution_data(j), - mt.create_group("angular_cosine_distribution") - ) - except Exception: - pass - - -# -- Per-file processing ------------------------------------------------------- - -def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): - """Convert a single ACE proton file to HDF5. Returns the output filename.""" - with open(ace_path) as f: - header = ACEtk.Header.from_string(f.readline()) - - Z, A, S, _ = decode_ace_zaid(header.zaid) - symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") - nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" - - ace_table = ACEtk.ContinuousEnergyTable.from_file(ace_path) - mcdc_name = f"{nuclide_name}-{T_KELVIN}K.h5" - out_path = os.path.join(output_dir, mcdc_name) - - if verbose: - print(f"\n{'='*80}") - print(f" {os.path.basename(ace_path)} -> {mcdc_name}") - print(f" Z={Z} A={A} S={S} T={T_KELVIN} K") - - file = h5py.File(out_path, "w") - - # Metadata - hdr = ace_table.header - file.attrs["source_title"] = hdr.title - file.attrs["source_version"] = hdr.version - file.attrs["source_date"] = hdr.date - if hasattr(hdr, "comments"): - file.attrs["source_comments"] = hdr.comments - - file.create_dataset("nuclide_name", data=nuclide_name) - file.create_dataset("excitation_level", data=S) - file.create_dataset("temperature", data=T_KELVIN).attrs["unit"] = "K" - file.create_dataset("atomic_number", data=ace_table.atom_number) - file.create_dataset("mass_number", data=ace_table.mass_number) - file.create_dataset("atomic_weight_ratio", data=ace_table.atomic_weight_ratio) - fissionable = ace_table.fission_multiplicity_block is not None - file.create_dataset("fissionable", data=fissionable) - - write_stopping_power(file, pstar_dir, symbol, verbose=verbose) - - # Reaction classification - nu_block = ace_table.frame_and_multiplicity_block - rx_block = ace_table.reaction_number_block - N_reaction = nu_block.number_reactions - - proton_reactions = file.create_group("proton_reactions") - elastic_group = proton_reactions.create_group("elastic_scattering") - capture_group = proton_reactions.create_group("capture") - inelastic_group = proton_reactions.create_group("inelastic_reaction") - fission_group = proton_reactions.create_group("fission") - - elastic_MTs = [2] - capture_MTs = [] - inelastic_MTs = [] - fission_MTs = ([18] if rx_block.has_MT(18) else - [MT for MT in FISSION_CHANCE_MTS if rx_block.has_MT(MT)]) - - for i in range(N_reaction): - idx = i + 1 - MT = rx_block.MT(idx) - if MT in REDUNDANT_MTS + elastic_MTs + fission_MTs or MT > 891: - continue - nu_raw = nu_block.multiplicity(idx) - if not isinstance(nu_raw, int): - print_error(f"Non-integer multiplicity for MT-{MT:03} in {ace_path}") - nu = nu_raw - 100 if nu_raw >= 100 else nu_raw - if nu == 0: capture_MTs.append(MT) - elif nu > 0: inelastic_MTs.append(MT) - else: print_error(f"Negative multiplicity for MT-{MT:03} in {ace_path}") - - for grp, mts in [(elastic_group, elastic_MTs), - (capture_group, capture_MTs), - (inelastic_group, inelastic_MTs), - (fission_group, fission_MTs)]: - for MT in mts: - grp.create_group(f"MT-{MT:03}").attrs["MT"] = MT - - if verbose: - print(f" Elastic: {elastic_MTs} Capture: {capture_MTs} " - f"Inelastic: {inelastic_MTs}" - + (f" Fission: {fission_MTs}" if fissionable else "")) - - if not fissionable: - del file["proton_reactions/fission"] - if not inelastic_MTs: - del file["proton_reactions/inelastic_reaction"] - - # Cross sections - xs0 = ace_table.principal_cross_section_block - xs_main = ace_table.cross_section_block - - proton_reactions.create_dataset( - "xs_energy_grid", data=np.array(xs0.energies) - ).attrs["unit"] = "MeV" - - ds = elastic_group.create_dataset("MT-002/xs", data=np.array(xs0.elastic)) - ds.attrs["offset"] = 0 - ds.attrs["unit"] = "barns" - - for mts, grp in [(capture_MTs, capture_group), - (inelastic_MTs, inelastic_group), - (fission_MTs, fission_group if fissionable else None)]: - if grp is None: - continue - for MT in mts: - idx = rx_block.index(MT) - ds = grp.create_dataset( - f"MT-{MT:03}/xs", data=np.array(xs_main.cross_sections(idx)) - ) - ds.attrs["offset"] = xs_main.energy_index(idx) - 1 - ds.attrs["unit"] = "barns" - - # Q-values - q_block = ace_table.reaction_qvalue_block - elastic_group.create_dataset("MT-002/Q-value", data=0.0).attrs["unit"] = "MeV" - - for mts, grp in [(capture_MTs, capture_group), - (inelastic_MTs, inelastic_group), - (fission_MTs, fission_group if fissionable else None)]: - if grp is None: - continue - for MT in mts: - idx = rx_block.index(MT) - grp.create_dataset( - f"MT-{MT:03}/Q-value", data=q_block.q_value(idx) - ).attrs["unit"] = "MeV" - - # Reference frames - elastic_group.create_dataset("MT-002/reference_frame", data="COM") - - for mts, grp in [(capture_MTs, capture_group), - (inelastic_MTs, inelastic_group), - (fission_MTs, fission_group if fissionable else None)]: - if grp is None: - continue - for MT in mts: - idx = rx_block.index(MT) - rf = nu_block.reference_frame(idx) - rf_str = ("LAB" if rf == ACEtk.ReferenceFrame.Laboratory else - "COM" if rf == ACEtk.ReferenceFrame.CentreOfMass else str(rf)) - grp.create_dataset(f"MT-{MT:03}/reference_frame", data=rf_str) - - # Inelastic multiplicities - for MT in inelastic_MTs: - idx = rx_block.index(MT) - nu_raw = nu_block.multiplicity(idx) - inelastic_group.create_dataset( - f"MT-{MT:03}/multiplicity", - data=nu_raw - 100 if nu_raw >= 100 else nu_raw - ) - - # Angular distributions - angle_block = ace_table.angular_distribution_block - - ag = elastic_group.create_group("MT-002/angular_cosine_distribution") - ag.attrs["type"] = "energy-correlated" - if not load_cosine_distribution(angle_block.angular_distribution_data(0), ag) \ - and verbose: - print_note("MT-002 angular distribution is given in energy block") - - for mts, grp in [(inelastic_MTs, inelastic_group), - (fission_MTs, fission_group if fissionable else None)]: - if grp is None: - continue - for MT in mts: - idx = rx_block.index(MT) - ag = grp.create_group(f"MT-{MT:03}/angular_cosine_distribution") - if not load_cosine_distribution( - angle_block.angular_distribution_data(idx), ag) and verbose: - print_note(f"MT-{MT:03} angular distribution is given in energy block") - - # Primary energy distributions - energy_block = ace_table.energy_distribution_block - - for mts, grp in [(inelastic_MTs, inelastic_group), - (fission_MTs, fission_group if fissionable else None)]: - if grp is None: - continue - for MT in mts: - idx = rx_block.index(MT) - data = energy_block.energy_distribution_data(idx) - - if not isinstance(data, ACEtk.continuous.MultiDistributionData): - grp.create_dataset( - f"MT-{MT:03}/spectrum_probability_grid", - data=np.array([0.0, 30.0]) - ).attrs["unit"] = "MeV" - grp.create_dataset( - f"MT-{MT:03}/spectrum_probability", data=np.array([[1.0]]) - ) - load_energy_distribution( - data, grp.create_group(f"MT-{MT:03}/energy_spectrum-1") - ) - else: - N_dist = data.number_distributions - probs = data.probabilities - - if all(p.number_interpolation_regions == 0 for p in probs): - prob_grid = np.array([0.0, 30.0]) - prob = np.zeros((1, N_dist)) - for k in range(N_dist): - prob[0, k] = max(data.probability(k + 1).probabilities) - elif (all(p.number_interpolation_regions == 1 for p in probs) - and all(p.interpolants[0] == 1 for p in probs)): - prob_grid = np.array(data.probability(1).energies) - prob = np.zeros((len(prob_grid) - 1, N_dist)) - for k in range(N_dist): - prob[:, k] = np.array(data.probability(k + 1).probabilities[:-1]) - else: - print_error( - f"Unsupported multi-distribution probability for MT-{MT:03}" - ) - - grp.create_dataset( - f"MT-{MT:03}/spectrum_probability_grid", data=prob_grid - ).attrs["unit"] = "MeV" - grp.create_dataset(f"MT-{MT:03}/spectrum_probability", data=prob) - for k in range(N_dist): - load_energy_distribution( - data.distribution(k + 1), - grp.create_group(f"MT-{MT:03}/energy_spectrum-{k + 1}") - ) - - load_secondary_particles(ace_table, file, verbose=verbose) - - # Fission data - if fissionable: - prompt_block = ace_table.fission_multiplicity_block - delayed_block = ace_table.delayed_fission_multiplicity_block - dnp_block = ace_table.delayed_neutron_precursor_block - - load_fission_multiplicity( - prompt_block.multiplicity, - fission_group.create_group("prompt_multiplicity") - ) - if delayed_block is not None: - load_fission_multiplicity( - delayed_block.multiplicity, - fission_group.create_group("delayed_multiplicity") - ) - - if dnp_block is not None: - N_DNP = dnp_block.number_delayed_precursors - fractions = np.zeros(N_DNP) - decay_rates = np.zeros(N_DNP) - for k in range(N_DNP): - d = dnp_block.precursor_group_data(k + 1) - if (d.number_interpolation_regions != 0 - or len(d.probabilities[:]) != 2 - or d.probabilities[0] != d.probabilities[1]): - print_error("Non-constant delayed neutron precursor fraction") - fractions[k] = d.probabilities[0] - decay_rates[k] = d.decay_constant - - prec = fission_group.create_group("delayed_neutron_precursors") - prec.create_dataset("fractions", data=fractions) - prec.create_dataset("decay_rates", data=decay_rates).attrs["unit"] = "/s" - - delayed_spectrum_block = ace_table.delayed_neutron_energy_distribution_block - for k in range(N_DNP): - load_energy_distribution( - delayed_spectrum_block.energy_distribution_data(k + 1), - prec.create_group(f"energy_spectrum-{k + 1}") - ) - - file.close() - return mcdc_name - - -def process_pstar_only_file(symbol, A, awr, output_dir, pstar_dir, verbose=False): - """ - Create a minimal HDF5 file for an isotope that has no ACE data but does - have a PSTAR stopping power file. Returns the output filename, or None if - no PSTAR file was found. - """ - Z = SYMBOL_TO_Z[symbol] - nuclide_name = f"{symbol}{A}" - mcdc_name = f"{nuclide_name}-{T_KELVIN}K.h5" - out_path = os.path.join(output_dir, mcdc_name) - - if verbose: - print(f"\n{'='*80}") - print(f" (no ACE) -> {mcdc_name} [stopping power only]") - - file = h5py.File(out_path, "w") - - file.attrs["source_title"] = "PSTAR (NIST) stopping power only — no ACE data" - file.attrs["source_version"] = "N/A" - file.attrs["source_date"] = "N/A" - - file.create_dataset("nuclide_name", data=nuclide_name) - file.create_dataset("excitation_level", data=0) - file.create_dataset("temperature", data=T_KELVIN).attrs["unit"] = "K" - file.create_dataset("atomic_number", data=Z) - file.create_dataset("mass_number", data=A) - file.create_dataset("atomic_weight_ratio", data=awr) - file.create_dataset("fissionable", data=False) - - written = write_stopping_power(file, pstar_dir, symbol, verbose=verbose) - file.close() - - if not written: - # No PSTAR data either — remove the empty file and signal failure - os.remove(out_path) - return None - - return mcdc_name - - -# -- Main ---------------------------------------------------------------------- - -def main(): - parser = argparse.ArgumentParser( - description="Convert TENDL proton ACE files to MC/DC-compatible HDF5" - ) - parser.add_argument("--ace_dir", default=os.getenv("MCDC_ACELIB")) - parser.add_argument("--output_dir", default=os.getenv("MCDC_LIB")) - parser.add_argument("--pstar_dir", default=os.getenv("PSTAR_LIB")) - parser.add_argument("--rewrite", action="store_true", default=False) - parser.add_argument("--verbose", action="store_true", default=False) - args = parser.parse_args() - - if args.ace_dir is None: - print_error("No ACE directory. Use --ace_dir or set $MCDC_ACELIB.") - if args.output_dir is None: - print_error("No output directory. Use --output_dir or set $MCDC_LIB.") - - os.makedirs(args.output_dir, exist_ok=True) - print(f"\nACE directory : {args.ace_dir}") - print(f"Output directory: {args.output_dir}") - print(f"PSTAR directory : {args.pstar_dir}\n") - - ace_files = sorted(f for f in os.listdir(args.ace_dir) if f.endswith(".ace")) - - # ── Pass 1: ACE files ───────────────────────────────────────────────────── - - if args.rewrite: - target_files = ace_files - else: - target_files = [] - for fname in ace_files: - try: - with open(os.path.join(args.ace_dir, fname)) as f: - hdr = ACEtk.Header.from_string(f.readline()) - Z, A, S, _ = decode_ace_zaid(hdr.zaid) - symbol = Z_TO_SYMBOL.get(Z, f"Z{Z}") - nuclide_name = f"{symbol}{A}" if S == 0 else f"{symbol}{A}m{S}" - if not any( - f.startswith(nuclide_name + "-") - for f in os.listdir(args.output_dir) - ): - target_files.append(fname) - except Exception: - target_files.append(fname) - - errors = [] - pbar = tqdm(target_files, disable=args.verbose, - bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} {postfix}") - - for ace_name in pbar: - pbar.set_postfix_str(ace_name) - try: - out = process_ace_file( - os.path.join(args.ace_dir, ace_name), - args.output_dir, - pstar_dir=args.pstar_dir, - verbose=args.verbose, - ) - if args.verbose: - print(f" -> wrote {out}") - except Exception as exc: - errors.append((ace_name, str(exc))) - if args.verbose: - import traceback - traceback.print_exc() - - # ── Pass 2: PSTAR-only isotopes (e.g. H, He) ───────────────────────────── - # For each entry in PSTAR_ONLY_ISOTOPES, create a stopping-power-only HDF5 - # file if one doesn't already exist (or if --rewrite is set). - - if args.pstar_dir is not None: - existing = set(os.listdir(args.output_dir)) - for symbol, A, awr in PSTAR_ONLY_ISOTOPES: - nuclide_name = f"{symbol}{A}" - mcdc_name = f"{nuclide_name}-{T_KELVIN}K.h5" - if not args.rewrite and mcdc_name in existing: - continue - try: - out = process_pstar_only_file( - symbol, A, awr, args.output_dir, args.pstar_dir, - verbose=args.verbose - ) - if out is None: - if args.verbose: - print(f" [skip] No PSTAR data for {nuclide_name}") - elif args.verbose: - print(f" -> wrote {out} [stopping power only]") - except Exception as exc: - errors.append((nuclide_name, str(exc))) - if args.verbose: - import traceback - traceback.print_exc() - - # ── Summary ─────────────────────────────────────────────────────────────── - - n_total = len(target_files) + len(PSTAR_ONLY_ISOTOPES) - print(f"\nDone. {n_total - len(errors)} succeeded, {len(errors)} failed.") - if errors: - print("\nFailed files:") - for name, msg in errors: - print(f" {name}: {msg}") - - -if __name__ == "__main__": - main() \ No newline at end of file From 14a219e15900c5c4b6a5bdba34b6917a5fe11502 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 6 Aug 2026 16:13:34 -0700 Subject: [PATCH 63/64] cleaned up radiation_length calculation; added distribution handling to proton_reaction --- mcdc/object_/material.py | 21 ++- mcdc/object_/proton_reaction.py | 67 +++++---- mcdc/transport/physics/proton/native.py | 174 +++++++++++++++--------- mcdc/transport/simulation.py | 3 - 4 files changed, 164 insertions(+), 101 deletions(-) diff --git a/mcdc/object_/material.py b/mcdc/object_/material.py index 0058d64f7..e1510b5ce 100644 --- a/mcdc/object_/material.py +++ b/mcdc/object_/material.py @@ -1,6 +1,7 @@ import numpy as np import os import h5py +import re from numpy import float64 from numpy.typing import NDArray @@ -107,7 +108,7 @@ class Material(MaterialBase): stopping_power: NDArray[float64] stopping_power_energy_grid: NDArray[float64] # - radiation_length: float64 = 0.0 + radiation_length: float = 0.0 radiation_length_provided: bool = False def __init__( @@ -141,6 +142,10 @@ def __init__( self.stopping_power = np.array([]) self.stopping_power_energy_grid = np.array([]) + # Radiation length calculation prep + total_mass = 0.0 + X0_weighted_mass = 0.0 + # Check if library directory is set lib_dir = os.getenv("MCDC_LIB") if lib_dir is None: @@ -216,6 +221,16 @@ def __init__( if nuclide.fissionable: self.fissionable = True + # Calculate the material's radiation length (for proton transport purposes) + nuclide_mass = nuclide.mass_number + nuclide_X0 = nuclide.radiation_length + + total_mass += nuclide_mass * nuclide_density + X0_weighted_mass += nuclide_mass * nuclide_density / nuclide_X0 + + # Set the material radiation length + self.radiation_length = total_mass / X0_weighted_mass + def __repr__(self): text = super().__repr__() text += f" - Temperature: {self.temperature} K\n" @@ -246,6 +261,8 @@ def add_stopping_power( self.stopping_power = file["stopping_power"]["total_stopping_power"][()] self.stopping_power_energy_grid = file["stopping_power"]["energy"][()] + if file["radiation_length"]["radiation_length"][()]: + self.radiation_length = file["radiation_length"]["radiation_length"][()] file.close() def custom_radiation_length( @@ -254,7 +271,7 @@ def custom_radiation_length( ): self.radiation_length_provided = True - self.radiation_length = radiation_length + self.radiation_length = radiation_length # Currently supported temperatures diff --git a/mcdc/object_/proton_reaction.py b/mcdc/object_/proton_reaction.py index 7f5f27764..57cda4f36 100644 --- a/mcdc/object_/proton_reaction.py +++ b/mcdc/object_/proton_reaction.py @@ -106,7 +106,7 @@ def from_h5_group(cls, h5_group): MT, xs, xs_offset, reference_frame, _ = set_basic_properties(h5_group) _, mu = set_angular_distribution(h5_group["angular_cosine_distribution"]) return cls(MT, xs, xs_offset, reference_frame, mu) - + def __repr__(self): text = super().__repr__() text += f" - Scattering cosine: {distribution.decode_type(self.mu_table.type)} [ID: {self.mu_table.ID}]\n" @@ -164,33 +164,26 @@ def from_h5_group(cls, h5_group): MT, xs, xs_offset, reference_frame, q_value = set_basic_properties(h5_group) multiplicity = int(h5_group["multiplicity"][()]) - angle_type, mu = set_angular_distribution( - h5_group["angular_cosine_distribution"] - ) + ang_type_str = h5_group["angular_cosine_distribution"].attrs.get("type", "isotropic") + if ang_type_str == "given_in_energy_distribution": + angle_type, mu = set_angular_distribution_from_kalbach_mann( + h5_group["energy_spectrum-1"] + ) + else: + angle_type, mu = set_angular_distribution( + h5_group["angular_cosine_distribution"] + ) - # Energy spectra - spectrum_probability_grid = ( - h5_group[f"spectrum_probability_grid"][()] * 1e6 - ) # MeV to eV - spectrum_probability = h5_group[f"spectrum_probability"][()] - energy_spectra = [] - spectrum_names = [x for x in h5_group if x.startswith("energy_spectrum-")] - for spectrum_name in spectrum_names: - energy_spectra.append(set_energy_distribution(h5_group[f"{spectrum_name}"])) - - return cls( - MT, - xs, - xs_offset, - reference_frame, - q_value, - multiplicity, - angle_type, - mu, - spectrum_probability_grid, - spectrum_probability, - energy_spectra, - ) + spectrum_probability_grid = h5_group["spectrum_probability_grid"][()] * 1e6 + spectrum_probability = h5_group["spectrum_probability"][()] + energy_spectra = [ + set_energy_distribution(h5_group[name]) + for name in sorted(x for x in h5_group if x.startswith("energy_spectrum-")) + ] + + return cls(MT, xs, xs_offset, reference_frame, q_value, multiplicity, + angle_type, mu, spectrum_probability_grid, spectrum_probability, + energy_spectra) def __repr__(self): text = super().__repr__() @@ -255,9 +248,9 @@ def set_angular_distribution(h5_group): angle_type = ANGLE_ENERGY_CORRELATED mu = simulation.distributions[0] elif mu_type == "given_in_energy_distribution": - # Angular information comes from the Kalbach-Mann energy distribution. - angle_type = ANGLE_ENERGY_CORRELATED - mu = simulation.distributions[0] + raise ValueError( + "set_angular_distribution called with given_in_energy_distribution; " + "use set_angular_distribution_from_kalbach_mann instead.") elif mu_type == "tabulated": angle_type = ANGLE_DISTRIBUTED @@ -304,6 +297,20 @@ def set_angular_distribution(h5_group): return angle_type, mu +def set_angular_distribution_from_kalbach_mann(spectrum_group): + """ + Build a DistributionMultiTable for Kalbach-Mann angular sampling. + The 'value' array holds the angular slope 'a'. The transport kernel + uses these to sample cosines analytically via the Kalbach-Mann formula. + """ + grid = spectrum_group["energy"][()] * 1e6 # MeV to eV + offset = spectrum_group["offset"][()] + a = spectrum_group["angular_slope"][()] + pdf = spectrum_group["pdf"][()] + + mu = DistributionMultiTable(grid, offset, a, pdf) + return ANGLE_ENERGY_CORRELATED, mu + def set_energy_distribution(h5_group): spectrum_type = h5_group.attrs["type"] diff --git a/mcdc/transport/physics/proton/native.py b/mcdc/transport/physics/proton/native.py index 0a9690041..50e449e58 100644 --- a/mcdc/transport/physics/proton/native.py +++ b/mcdc/transport/physics/proton/native.py @@ -1,6 +1,7 @@ import math import numpy as np from numba import njit +import time #### @@ -95,19 +96,68 @@ def macro_xs(reaction_type, particle_container, simulation, data): @njit def total_micro_xs(reaction_type, E, nuclide, data): + idx, E0, E1 = evaluate_proton_xs_energy_grid(E, nuclide, data) if reaction_type == PROTON_REACTION_TOTAL: xs0 = mcdc_get.nuclide.proton_total_xs(idx, nuclide, data) xs1 = mcdc_get.nuclide.proton_total_xs(idx + 1, nuclide, data) + elif reaction_type == PROTON_REACTION_ELASTIC_SCATTERING: xs0 = mcdc_get.nuclide.proton_elastic_xs(idx, nuclide, data) xs1 = mcdc_get.nuclide.proton_elastic_xs(idx + 1, nuclide, data) + + # total_elastic_scattering_xs = 0 + # # Get total elastic scattering xs for all possible elastic scattering rxns + # for i in range(nuclide["N_proton_elastic_scattering_reaction"]): + # reaction_ID = int( + # mcdc_get.nuclide.proton_elastic_scattering_reaction_IDs(i, nuclide, data) + # ) + # reaction = simulation["proton_elastic_scattering_reactions"][reaction_ID] + # reaction_base_ID = reaction["parent_ID"] + # reaction_base = simulation["proton_reactions"][reaction_base_ID] + # xs = reaction_micro_xs(E, reaction_base, nuclide, data) + # total_elastic_scattering_xs += xs + + # return total_elastic_scattering_xs + elif reaction_type == PROTON_REACTION_INELASTIC_SCATTERING: xs0 = mcdc_get.nuclide.proton_inelastic_xs(idx, nuclide, data) xs1 = mcdc_get.nuclide.proton_inelastic_xs(idx + 1, nuclide, data) + + # print(f'all inelastic xs = {mcdc_get.nuclide.proton_inelastic_xs_all(nuclide, data)}') + # print(f'idx = {idx}, xs0 = {xs0}, xs1 = {xs1}') + # raise ValueError("stop") + + # total_inelastic_scattering_xs = 0 + # # Get total inelastic scattering xs for all possible inelastic scattering rxns + # for i in range(nuclide["N_proton_inelastic_scattering_reaction"]): + # reaction_ID = int( + # mcdc_get.nuclide.proton_inelastic_scattering_reaction_IDs(i, nuclide, data) + # ) + # reaction = simulation["proton_inelastic_scattering_reactions"][reaction_ID] + # reaction_base_ID = reaction["parent_ID"] + # reaction_base = simulation["proton_reactions"][reaction_base_ID] + # xs = reaction_micro_xs(E, reaction_base, nuclide, data) + # total_inelastic_scattering_xs += xs + + # return total_inelastic_scattering_xs elif reaction_type == PROTON_REACTION_CAPTURE: xs0 = mcdc_get.nuclide.proton_capture_xs(idx, nuclide, data) xs1 = mcdc_get.nuclide.proton_capture_xs(idx + 1, nuclide, data) + # total_capture_xs = 0 + # # Get total capture xs for all possible capture rxns + # for i in range(nuclide["N_proton_capture_reaction"]): + # reaction_ID = int( + # mcdc_get.nuclide.proton_capture_reaction_IDs(i, nuclide, data) + # ) + # reaction = simulation["proton_capture_reactions"][reaction_ID] + # reaction_base_ID = reaction["parent_ID"] + # reaction_base = simulation["proton_reactions"][reaction_base_ID] + # xs = reaction_micro_xs(E, reaction_base, nuclide, data) + # total_capture_xs += xs + + # return total_capture_xs + else: # Should be unreachable xs0 = 0.0 @@ -176,13 +226,13 @@ def collision(particle_container, collision_data_container, program, data): break - # ================================================================================== # Sample and perform reaction # ================================================================================== sigma_elastic = total_micro_xs(PROTON_REACTION_ELASTIC_SCATTERING, E, nuclide, data) sigma_inelastic = total_micro_xs(PROTON_REACTION_INELASTIC_SCATTERING, E, nuclide, data) + sigma_capture = total_micro_xs(PROTON_REACTION_CAPTURE, E, nuclide, data) xi = rng.lcg(particle_container) * sigmaT # Elastic scattering @@ -211,7 +261,35 @@ def collision(particle_container, collision_data_container, program, data): simulation, data, ) - return + return + + # Capture + if not simulation["implicit_capture"]["active"]: + # print(f'particle being captured') + sigma_capture = total_micro_xs(PROTON_REACTION_CAPTURE, E, nuclide, data) + total += sigma_capture + if xi < total: + # Sample the actual reaction from the group + total -= sigma_capture + for i in range(nuclide["N_proton_capture_reaction"]): + reaction_ID = int(mcdc_get.nuclide.proton_capture_reaction_IDs(i, nuclide, data)) + reaction = simulation["proton_capture_reactions"][reaction_ID] + reaction_base_ID = reaction["parent_ID"] + reaction_base = simulation["proton_reactions"][reaction_base_ID] + xs = reaction_micro_xs(E, reaction_base, nuclide, data) + total += xs + + # Execute the reaction + if xi < total: + capture( + reaction, + particle_container, + collision_data_container, + nuclide, + simulation, + data + ) + # Inelastic scattering total += sigma_inelastic @@ -242,7 +320,7 @@ def collision(particle_container, collision_data_container, program, data): # ====================================================================================== -# Continous Slowing Down Approximation +# Continous Slowing Down Approximation (CSDA) # ====================================================================================== @@ -279,12 +357,8 @@ def csda_edep(particle_container, collision_data_container, distance, simulation print(f'energy_loss = {energy_loss * particle["w"]}') raise ValueError('negative energy loss') - radiation_length = get_radiation_length(particle_container, simulation, data) - + X0 = material["radiation_length"] - X0 = 24.01 # Radiation length for Al, in g/cm^2 - # X0 = 36.33 # Radiation length for H2O, in g/cm^2 - # Angular scattering according to MCS theory phi, theta = sample_mcs_angle(particle["E"], distance, total_rho_gcm3, X0) @@ -328,6 +402,12 @@ def capture( def elastic_scattering( reaction, particle_container, collision_data_container, nuclide, simulation, data ): + + # print(f'reaction = {repr(reaction)}') + # print(f'{reaction.dtype.names}') + # print(f'{reaction["mu_table_ID"]}, {reaction["ID"]}, {reaction["parent_ID"]}') + + # print(f'particle undergoing elastic scattering') particle = particle_container[0] collision_data = collision_data_container[0] @@ -382,13 +462,10 @@ def elastic_scattering( uy = vy / speed uz = vz / speed - # Sample the scattering cosine from the multi-PDF distribution - mu_table_ID = reaction["mu_table_ID"] - if mu_table_ID >= len(simulation["multi_table_distributions"]): - mu_table_ID = 0 # Fallback to first distribution - multi_table = simulation["multi_table_distributions"][mu_table_ID] - - # multi_table = simulation["multi_table_distributions"][reaction["mu_table_ID"]] + # # Sample the scattering cosine from the multi-PDF distribution + # print(f'simulation = {simulation}, names = {simulation.dtype.names}') + # print(f'reaction = {reaction}, names = {reaction.dtype.names}') + multi_table = simulation["multi_table_distributions"][reaction["mu_table_ID"]] mu0 = sample_multi_table(E, particle_container, multi_table, simulation, data) # Scatter the direction in COM @@ -480,13 +557,15 @@ def sample_nucleus_velocity(A, particle_container): def inelastic_scattering( reaction, particle_container, collision_data_container, nuclide, program, data ): - """ - Proton intelastic scattering with secondary particle production. + # """ + # Proton intelastic scattering with secondary particle production. + + # Samples: + # 1. Outgoing proton from proton_reactions/inelastic_scattering/MT-005 + # 2. Secondary particles from secondary_particles/ZAP_x/MT-005 + # """ + # print(f'particle undergoing inelastic scattering') - Samples: - 1. Outgoing proton from proton_reactions/inelastic_scattering/MT-005 - 2. Secondary particles from secondary_particles/ZAP_x/MT-005 - """ simulation = util.access_simulation(program) particle = particle_container[0] collision_data = collision_data_container[0] @@ -509,7 +588,7 @@ def inelastic_scattering( total_energy = E + q_value # =========================================================================== - # 1. Sample outgoing PROTON + # Sample outgoing proton # =========================================================================== # Number of outgoing protons and spectra @@ -529,6 +608,8 @@ def inelastic_scattering( # Set default attributes (copy incident proton) particle_module.copy_as_child(particle_container_new, particle_container) + + # ============================================================================== # Sample angle (if not energy-correlated) # ============================================================================== @@ -543,7 +624,8 @@ def inelastic_scattering( multi_table = simulation["multi_table_distributions"][ distribution_base["child_ID"] ] - mu = sample_multi_table(E, particle_container_new, multi_table, simulation, data) + + mu = sample_multi_table(E, particle_container, multi_table, simulation, data) # ============================================================================== # Sample energy (also angle if correlated) @@ -634,15 +716,7 @@ def inelastic_scattering( # =========================================================================== # 2. Sample SECONDARY PARTICLES from secondary_particles groups # =========================================================================== - - # Get secondary channels for this MT (if any) - # MT = int(reaction_base["MT"]) - # nuclide_ID = particle["nuclide_ID"] - - # Check if nuclide has secondary particle data - # (This requires access to nuclide secondary_channels dict, which needs to be added) - # For now, we'll skip this part and it can be added when the data structure supports it - # TODO: Add secondary particle sampling when nuclide.proton_secondary_channels is accessible + # TODO: Add secondary particle sampling # No fission for protons @@ -676,6 +750,7 @@ def highland_lynch_dahl_sigma(E, distance, density, X0): # Highland formula, modified by Lynch & Dahl radiation_distance_fraction = density * distance / X0 sigma = (13.6e6 / p*beta) * z * np.sqrt(radiation_distance_fraction) * (1 + 0.088 * np.log10(radiation_distance_fraction)) + sigma = np.abs(sigma) if sigma < 0.0: print(f'radiation_distance_fraction = {radiation_distance_fraction}') @@ -761,37 +836,4 @@ def calculate_total_stopping_power(particle_container, simulation, data): dedx = np.interp(E / 1e6, dedx_energies, dedx_values) total_stopping_power = dedx * 1e6 - return average_A, average_Z, total_stopping_power, total_rho_gcm3 - - -@njit -def get_radiation_length(particle_container, simulation, data): - particle = particle_container[0] - material = simulation["native_materials"][particle["material_ID"]] - - if material["radiation_length_provided"]: - radiation_length = material["radiation_length"] - # radiation_length = mcdc_get.native_material.radiation_length(material, data) - - # Calculate the radiation length based on the material's nuclide composition - # Using Eq. 4 from "Calculation of radiation length in materials", R.J da Silva - # Using nuclide density here as an analog to # of moles; ratios are preserved, so it should be fine - - elif not material["radiation_length_provided"]: - total_mass = 0.0 - X0_weighted_mass = 0.0 - for i in range(material["N_nuclide"]): - nuclide_ID = int(mcdc_get.native_material.nuclide_IDs(i, material, data)) - nuclide = simulation["nuclides"][nuclide_ID] - - nuclide_mass = nuclide["mass_number"] - nuclide_density = mcdc_get.native_material.nuclide_densities(i, material, data) - nuclide_X0 = nuclide["radiation_length"] - - total_mass += nuclide_mass * nuclide_density - X0_weighted_mass += nuclide_mass * nuclide_density / nuclide_X0 - - radiation_length = total_mass / X0_weighted_mass - - return radiation_length - + return average_A, average_Z, total_stopping_power, total_rho_gcm3 \ No newline at end of file diff --git a/mcdc/transport/simulation.py b/mcdc/transport/simulation.py index 59eabeaa9..c0c998f99 100644 --- a/mcdc/transport/simulation.py +++ b/mcdc/transport/simulation.py @@ -324,9 +324,6 @@ def step_particle(particle_container, program, data): if particle["event"] & EVENT_TIME_BOUNDARY: particle["alive"] = False - if particle["event"] & EVENT_CSDA_EDEP: - pass - # CSDA energy depostiion if particle["event"] & EVENT_CSDA_EDEP: pass From 74c50b5ad21a37d56bd719f8cc54f97a13e16814 Mon Sep 17 00:00:00 2001 From: Ethan Lame Date: Thu, 6 Aug 2026 16:14:22 -0700 Subject: [PATCH 64/64] cleaned up proton/generate.py --- tools/data_library_generator/proton/generate.py | 4 ++-- tools/data_library_generator/proton/water.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/data_library_generator/proton/generate.py b/tools/data_library_generator/proton/generate.py index 8a9fbdb90..d51fa456c 100644 --- a/tools/data_library_generator/proton/generate.py +++ b/tools/data_library_generator/proton/generate.py @@ -442,7 +442,7 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): proton_reactions = file.create_group("proton_reactions") elastic_group = proton_reactions.create_group("elastic_scattering") capture_group = proton_reactions.create_group("capture") - inelastic_group = proton_reactions.create_group("inelastic_reaction") + inelastic_group = proton_reactions.create_group("inelastic_scattering") fission_group = proton_reactions.create_group("fission") elastic_MTs = [2] @@ -479,7 +479,7 @@ def process_ace_file(ace_path, output_dir, pstar_dir=None, verbose=False): if not fissionable: del file["proton_reactions/fission"] if not inelastic_MTs: - del file["proton_reactions/inelastic_reaction"] + del file["proton_reactions/inelastic_scattering"] # Cross sections xs0 = ace_table.principal_cross_section_block diff --git a/tools/data_library_generator/proton/water.py b/tools/data_library_generator/proton/water.py index 5b8d6e5b4..92799a0c4 100644 --- a/tools/data_library_generator/proton/water.py +++ b/tools/data_library_generator/proton/water.py @@ -22,4 +22,5 @@ def load_pstar_file(filepath): sp = file.create_group("stopping_power") sp.create_dataset("energy", data=E_s).attrs["unit"] = "MeV" sp.create_dataset("total_stopping_power", data=S_s).attrs["unit"] = "MeV cm2/g" -sp.create_dataset("radiation_length", data=X0).attrs["unit"] = "g/cm2" \ No newline at end of file +rad_length = file.create_group("radiation_length") +rad_length.create_dataset("radiation_length", data=X0).attrs["unit"] = "g/cm2" \ No newline at end of file