From 055ea15a2ddf40f6fff4eef52990e79b37652f64 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Fri, 17 Oct 2025 18:09:37 -0500 Subject: [PATCH 01/26] Clip mixture distributions based on mean times integral (#3603) --- openmc/stats/univariate.py | 76 ++++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 12 deletions(-) diff --git a/openmc/stats/univariate.py b/openmc/stats/univariate.py index d6cf19f2b8..c48cc00757 100644 --- a/openmc/stats/univariate.py +++ b/openmc/stats/univariate.py @@ -295,6 +295,20 @@ class Discrete(Univariate): """ return np.sum(self.p) + def mean(self) -> float: + """Return mean of the discrete distribution + + The mean is the weighted average of the discrete values. + + .. versionadded:: 0.15.3 + + Returns + ------- + float + Mean of discrete distribution + """ + return np.sum(self.x * self.p) / np.sum(self.p) + def clip(self, tolerance: float = 1e-6, inplace: bool = False) -> Discrete: r"""Remove low-importance points from discrete distribution. @@ -413,6 +427,18 @@ class Uniform(Univariate): rng = np.random.RandomState(seed) return rng.uniform(self.a, self.b, n_samples) + def mean(self) -> float: + """Return mean of the uniform distribution + + .. versionadded:: 0.15.3 + + Returns + ------- + float + Mean of uniform distribution + """ + return 0.5 * (self.a + self.b) + def to_xml_element(self, element_name: str): """Return XML representation of the uniform distribution @@ -1123,7 +1149,7 @@ class Tabular(Univariate): """ interpolation = get_text(elem, 'interpolation') - params = get_elem_list(elem, "parameters", float) + params = get_elem_list(elem, "parameters", float) m = (len(params) + 1)//2 # +1 for when len(params) is odd x = params[:m] p = params[m:] @@ -1347,6 +1373,30 @@ class Mixture(Univariate): for p, dist in zip(self.probability, self.distribution) ]) + def mean(self) -> float: + """Return mean of the mixture distribution + + The mean is the weighted average of the means of the component + distributions, weighted by probability * integral. + + .. versionadded:: 0.15.3 + + Returns + ------- + float + Mean of the mixture distribution + """ + # Weight each component by its probability and integral + weights = [p*dist.integral() for p, dist in + zip(self.probability, self.distribution)] + total_weight = sum(weights) + + if total_weight == 0: + return 0.0 + + return sum([w*dist.mean() for w, dist in + zip(weights, self.distribution)]) / total_weight + def clip(self, tolerance: float = 1e-6, inplace: bool = False) -> Mixture: r"""Remove low-importance points / distributions @@ -1369,14 +1419,14 @@ class Mixture(Univariate): Distribution with low-importance points / distributions removed """ - # Determine integral of original distribution to compare later - original_integral = self.integral() + # Calculate mean * integral for original distribution to compare later. + original_mean_integral = self.mean() * self.integral() # Determine indices for any distributions that contribute non-negligibly - # to overall intensity - intensities = [prob*dist.integral() for prob, dist in - zip(self.probability, self.distribution)] - indices = _intensity_clip(intensities, tolerance=tolerance) + # to overall mean * integral + mean_integrals = [prob*dist.mean()*dist.integral() for prob, dist in + zip(self.probability, self.distribution)] + indices = _intensity_clip(mean_integrals, tolerance=tolerance) # Clip mixture of distributions probability = self.probability[indices] @@ -1397,12 +1447,14 @@ class Mixture(Univariate): # Create new distribution new_dist = type(self)(probability, distribution) - # Show warning if integral of new distribution is not within - # tolerance of original - diff = (original_integral - new_dist.integral())/original_integral + # Show warning if mean * integral of new distribution is not within + # tolerance of original. For energy distributions, mean * integral + # represents total energy. + new_mean_integral = new_dist.mean() * new_dist.integral() + diff = (original_mean_integral - new_mean_integral)/original_mean_integral if diff > tolerance: - warn("Clipping mixture distribution resulted in an integral that is " - f"lower by a fraction of {diff} when tolerance={tolerance}.") + warn("Clipping mixture distribution resulted in a mean*integral " + f"that is lower by a fraction of {diff} when tolerance={tolerance}.") return new_dist From 3ac5d6f8f540a0bfbe53b8264ae1d691093853ec Mon Sep 17 00:00:00 2001 From: Jonathan Shimwell Date: Mon, 20 Oct 2025 18:45:14 +0200 Subject: [PATCH 02/26] Allow Path objects in MGXSLibrary.export_to_hdf5 (#3608) Co-authored-by: Paul Romano --- openmc/mgxs_library.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openmc/mgxs_library.py b/openmc/mgxs_library.py index b840563cff..ce86f97dfb 100644 --- a/openmc/mgxs_library.py +++ b/openmc/mgxs_library.py @@ -11,7 +11,7 @@ import openmc import openmc.mgxs from openmc.mgxs import SCATTER_TABULAR, SCATTER_LEGENDRE, SCATTER_HISTOGRAM from .checkvalue import check_type, check_value, check_greater_than, \ - check_iterable_type, check_less_than, check_filetype_version + check_iterable_type, check_less_than, check_filetype_version, PathLike ROOM_TEMPERATURE_KELVIN = 294.0 @@ -2506,7 +2506,7 @@ class MGXSLibrary: Parameters ---------- - filename : str + filename : str or PathLike Filename of file, default is mgxs.h5. libver : {'earliest', 'latest'} Compatibility mode for the HDF5 file. 'latest' will produce files @@ -2514,7 +2514,7 @@ class MGXSLibrary: """ - check_type('filename', filename, str) + check_type('filename', filename, PathLike) # Create and write to the HDF5 file file = h5py.File(filename, "w", libver=libver) From c31032cf25f5a8c839c20fdb1017cbbf6de88278 Mon Sep 17 00:00:00 2001 From: GuySten <62616591+GuySten@users.noreply.github.com> Date: Wed, 22 Oct 2025 02:03:27 +0300 Subject: [PATCH 03/26] load mesh objects from weight_windows.h5 file (#3598) Co-authored-by: Patrick Shriwise --- include/openmc/mesh.h | 22 ++ src/mesh.cpp | 369 ++++++++++++++++++++++------ src/weight_windows.cpp | 4 + tests/cpp_unit_tests/CMakeLists.txt | 1 + tests/cpp_unit_tests/test_mesh.cpp | 257 +++++++++++++++++++ 5 files changed, 579 insertions(+), 74 deletions(-) create mode 100644 tests/cpp_unit_tests/test_mesh.cpp diff --git a/include/openmc/mesh.h b/include/openmc/mesh.h index a56705c9ec..5c9272e93b 100644 --- a/include/openmc/mesh.h +++ b/include/openmc/mesh.h @@ -132,8 +132,14 @@ public: // Constructors and destructor Mesh() = default; Mesh(pugi::xml_node node); + Mesh(hid_t group); virtual ~Mesh() = default; + // Factory method for creating meshes from either an XML node or HDF5 group + template + static const std::unique_ptr& create( + T dataset, const std::string& mesh_type, const std::string& mesh_library); + // Methods //! Perform any preparation needed to support point location within the mesh virtual void prepare_for_point_location() {}; @@ -258,6 +264,7 @@ class StructuredMesh : public Mesh { public: StructuredMesh() = default; StructuredMesh(pugi::xml_node node) : Mesh {node} {}; + StructuredMesh(hid_t group) : Mesh {group} {}; virtual ~StructuredMesh() = default; using MeshIndex = std::array; @@ -423,6 +430,7 @@ class PeriodicStructuredMesh : public StructuredMesh { public: PeriodicStructuredMesh() = default; PeriodicStructuredMesh(pugi::xml_node node) : StructuredMesh {node} {}; + PeriodicStructuredMesh(hid_t group) : StructuredMesh {group} {}; Position local_coords(const Position& r) const override { @@ -442,6 +450,7 @@ public: // Constructors RegularMesh() = default; RegularMesh(pugi::xml_node node); + RegularMesh(hid_t group); // Overridden methods int get_index_in_direction(double r, int i) const override; @@ -481,6 +490,8 @@ public: //! Return the volume for a given mesh index double volume(const MeshIndex& ijk) const override; + int set_grid(); + // Data members double volume_frac_; //!< Volume fraction of each mesh element double element_volume_; //!< Volume of each mesh element @@ -492,6 +503,7 @@ public: // Constructors RectilinearMesh() = default; RectilinearMesh(pugi::xml_node node); + RectilinearMesh(hid_t group); // Overridden methods int get_index_in_direction(double r, int i) const override; @@ -534,6 +546,7 @@ public: // Constructors CylindricalMesh() = default; CylindricalMesh(pugi::xml_node node); + CylindricalMesh(hid_t group); // Overridden methods virtual MeshIndex get_indices(Position r, bool& in_mesh) const override; @@ -598,6 +611,7 @@ public: // Constructors SphericalMesh() = default; SphericalMesh(pugi::xml_node node); + SphericalMesh(hid_t group); // Overridden methods virtual MeshIndex get_indices(Position r, bool& in_mesh) const override; @@ -668,6 +682,7 @@ public: // Constructors UnstructuredMesh() { n_dimension_ = 3; }; UnstructuredMesh(pugi::xml_node node); + UnstructuredMesh(hid_t group); static const std::string mesh_type; virtual std::string get_mesh_type() const override; @@ -774,6 +789,7 @@ public: // Constructors MOABMesh() = default; MOABMesh(pugi::xml_node); + MOABMesh(hid_t group); MOABMesh(const std::string& filename, double length_multiplier = 1.0); MOABMesh(std::shared_ptr external_mbi); @@ -943,6 +959,7 @@ class LibMesh : public UnstructuredMesh { public: // Constructors LibMesh(pugi::xml_node node); + LibMesh(hid_t group); LibMesh(const std::string& filename, double length_multiplier = 1.0); LibMesh(libMesh::MeshBase& input_mesh, double length_multiplier = 1.0); @@ -1069,6 +1086,11 @@ private: //! \param[in] root XML node void read_meshes(pugi::xml_node root); +//! Read meshes from an HDF5 file +// +//! \param[in] group HDF5 group ("meshes" group) +void read_meshes(hid_t group); + //! Write mesh data to an HDF5 group // //! \param[in] group HDF5 group diff --git a/src/mesh.cpp b/src/mesh.cpp index 58d218b9ca..610d057cf0 100644 --- a/src/mesh.cpp +++ b/src/mesh.cpp @@ -230,6 +230,42 @@ void MaterialVolumes::add_volume_unsafe( // Mesh implementation //============================================================================== +template +const std::unique_ptr& Mesh::create( + T dataset, const std::string& mesh_type, const std::string& mesh_library) +{ + // Determine mesh type. Add to model vector and map + if (mesh_type == RegularMesh::mesh_type) { + model::meshes.push_back(make_unique(dataset)); + } else if (mesh_type == RectilinearMesh::mesh_type) { + model::meshes.push_back(make_unique(dataset)); + } else if (mesh_type == CylindricalMesh::mesh_type) { + model::meshes.push_back(make_unique(dataset)); + } else if (mesh_type == SphericalMesh::mesh_type) { + model::meshes.push_back(make_unique(dataset)); +#ifdef OPENMC_DAGMC_ENABLED + } else if (mesh_type == UnstructuredMesh::mesh_type && + mesh_library == MOABMesh::mesh_lib_type) { + model::meshes.push_back(make_unique(dataset)); +#endif +#ifdef OPENMC_LIBMESH_ENABLED + } else if (mesh_type == UnstructuredMesh::mesh_type && + mesh_library == LibMesh::mesh_lib_type) { + model::meshes.push_back(make_unique(dataset)); +#endif + } else if (mesh_type == UnstructuredMesh::mesh_type) { + fatal_error("Unstructured mesh support is not enabled or the mesh " + "library is invalid."); + } else { + fatal_error(fmt::format("Invalid mesh type: {}", mesh_type)); + } + + // Map ID to position in vector + model::mesh_map[model::meshes.back()->id_] = model::meshes.size() - 1; + + return model::meshes.back(); +} + Mesh::Mesh(pugi::xml_node node) { // Read mesh id @@ -238,6 +274,17 @@ Mesh::Mesh(pugi::xml_node node) name_ = get_node_value(node, "name"); } +Mesh::Mesh(hid_t group) +{ + // Read mesh ID + read_attribute(group, "id", id_); + + // Read mesh name + if (object_exists(group, "name")) { + read_dataset(group, "name", name_); + } +} + void Mesh::set_id(int32_t id) { assert(id >= 0 || id == C_NONE); @@ -265,7 +312,13 @@ void Mesh::set_id(int32_t id) // Update ID and entry in the mesh map id_ = id; - model::mesh_map[id] = model::meshes.size() - 1; + + // find the index of this mesh in the model::meshes vector + // (search in reverse because this mesh was likely just added to the vector) + auto it = std::find_if(model::meshes.rbegin(), model::meshes.rend(), + [this](const std::unique_ptr& mesh) { return mesh.get() == this; }); + + model::mesh_map[id] = std::distance(model::meshes.begin(), it.base()) - 1; } vector Mesh::volumes() const @@ -627,6 +680,46 @@ UnstructuredMesh::UnstructuredMesh(pugi::xml_node node) : Mesh(node) } } +UnstructuredMesh::UnstructuredMesh(hid_t group) : Mesh(group) +{ + n_dimension_ = 3; + + // check the mesh type + if (object_exists(group, "type")) { + std::string temp; + read_dataset(group, "type", temp); + if (temp != mesh_type) { + fatal_error(fmt::format("Invalid mesh type: {}", temp)); + } + } + + // check if a length unit multiplier was specified + if (object_exists(group, "length_multiplier")) { + read_dataset(group, "length_multiplier", length_multiplier_); + } + + // get the filename of the unstructured mesh to load + if (object_exists(group, "filename")) { + read_dataset(group, "filename", filename_); + if (!file_exists(filename_)) { + fatal_error("Mesh file '" + filename_ + "' does not exist!"); + } + } else { + fatal_error(fmt::format( + "No filename supplied for unstructured mesh with ID: {}", id_)); + } + + if (attribute_exists(group, "options")) { + read_attribute(group, "options", options_); + } + + // check if mesh tally data should be written with + // statepoint files + if (attribute_exists(group, "output")) { + read_attribute(group, "output", output_); + } +} + void UnstructuredMesh::determine_bounds() { double xmin = INFTY; @@ -1086,6 +1179,72 @@ void StructuredMesh::surface_bins_crossed( // RegularMesh implementation //============================================================================== +int RegularMesh::set_grid() +{ + auto shape = xt::adapt(shape_, {n_dimension_}); + + // Check that dimensions are all greater than zero + if (xt::any(shape <= 0)) { + set_errmsg("All entries for a regular mesh dimensions " + "must be positive."); + return OPENMC_E_INVALID_ARGUMENT; + } + + // Make sure lower_left and dimension match + if (lower_left_.size() != n_dimension_) { + set_errmsg("Number of entries in lower_left must be the same " + "as the regular mesh dimensions."); + return OPENMC_E_INVALID_ARGUMENT; + } + if (width_.size() > 0) { + + // Check to ensure width has same dimensions + if (width_.size() != n_dimension_) { + set_errmsg("Number of entries on width must be the same as " + "the regular mesh dimensions."); + return OPENMC_E_INVALID_ARGUMENT; + } + + // Check for negative widths + if (xt::any(width_ < 0.0)) { + set_errmsg("Cannot have a negative width on a regular mesh."); + return OPENMC_E_INVALID_ARGUMENT; + } + + // Set width and upper right coordinate + upper_right_ = xt::eval(lower_left_ + shape * width_); + + } else if (upper_right_.size() > 0) { + + // Check to ensure upper_right_ has same dimensions + if (upper_right_.size() != n_dimension_) { + set_errmsg("Number of entries on upper_right must be the " + "same as the regular mesh dimensions."); + return OPENMC_E_INVALID_ARGUMENT; + } + + // Check that upper-right is above lower-left + if (xt::any(upper_right_ < lower_left_)) { + set_errmsg( + "The upper_right coordinates of a regular mesh must be greater than " + "the lower_left coordinates."); + return OPENMC_E_INVALID_ARGUMENT; + } + + // Set width + width_ = xt::eval((upper_right_ - lower_left_) / shape); + } + + // Set material volumes + volume_frac_ = 1.0 / xt::prod(shape)(); + + element_volume_ = 1.0; + for (int i = 0; i < n_dimension_; i++) { + element_volume_ *= width_[i]; + } + return 0; +} + RegularMesh::RegularMesh(pugi::xml_node node) : StructuredMesh {node} { // Determine number of dimensions for mesh @@ -1100,12 +1259,6 @@ RegularMesh::RegularMesh(pugi::xml_node node) : StructuredMesh {node} } std::copy(shape.begin(), shape.end(), shape_.begin()); - // Check that dimensions are all greater than zero - if (xt::any(shape <= 0)) { - fatal_error("All entries on the element for a tally " - "mesh must be positive."); - } - // Check for lower-left coordinates if (check_for_node(node, "lower_left")) { // Read mesh lower-left corner location @@ -1114,12 +1267,6 @@ RegularMesh::RegularMesh(pugi::xml_node node) : StructuredMesh {node} fatal_error("Must specify on a mesh."); } - // Make sure lower_left and dimension match - if (shape.size() != lower_left_.size()) { - fatal_error("Number of entries on must be the same " - "as the number of entries on ."); - } - if (check_for_node(node, "width")) { // Make sure one of upper-right or width were specified if (check_for_node(node, "upper_right")) { @@ -1128,49 +1275,52 @@ RegularMesh::RegularMesh(pugi::xml_node node) : StructuredMesh {node} width_ = get_node_xarray(node, "width"); - // Check to ensure width has same dimensions - auto n = width_.size(); - if (n != lower_left_.size()) { - fatal_error("Number of entries on must be the same as " - "the number of entries on ."); - } - - // Check for negative widths - if (xt::any(width_ < 0.0)) { - fatal_error("Cannot have a negative on a tally mesh."); - } - - // Set width and upper right coordinate - upper_right_ = xt::eval(lower_left_ + shape * width_); - } else if (check_for_node(node, "upper_right")) { + upper_right_ = get_node_xarray(node, "upper_right"); - // Check to ensure width has same dimensions - auto n = upper_right_.size(); - if (n != lower_left_.size()) { - fatal_error("Number of entries on must be the " - "same as the number of entries on ."); - } - - // Check that upper-right is above lower-left - if (xt::any(upper_right_ < lower_left_)) { - fatal_error("The coordinates must be greater than " - "the coordinates on a tally mesh."); - } - - // Set width - width_ = xt::eval((upper_right_ - lower_left_) / shape); } else { fatal_error("Must specify either or on a mesh."); } - // Set material volumes - volume_frac_ = 1.0 / xt::prod(shape)(); + if (int err = set_grid()) { + fatal_error(openmc_err_msg); + } +} - element_volume_ = 1.0; - for (int i = 0; i < n_dimension_; i++) { - element_volume_ *= width_[i]; +RegularMesh::RegularMesh(hid_t group) : StructuredMesh {group} +{ + // Determine number of dimensions for mesh + if (!object_exists(group, "dimension")) { + fatal_error("Must specify on a regular mesh."); + } + + xt::xtensor shape; + read_dataset(group, "dimension", shape); + int n = n_dimension_ = shape.size(); + if (n != 1 && n != 2 && n != 3) { + fatal_error("Mesh must be one, two, or three dimensions."); + } + std::copy(shape.begin(), shape.end(), shape_.begin()); + + // Check for lower-left coordinates + if (object_exists(group, "lower_left")) { + // Read mesh lower-left corner location + read_dataset(group, "lower_left", lower_left_); + } else { + fatal_error("Must specify lower_left dataset on a mesh."); + } + + if (object_exists(group, "upper_right")) { + + read_dataset(group, "upper_right", upper_right_); + + } else { + fatal_error("Must specify either upper_right dataset on a mesh."); + } + + if (int err = set_grid()) { + fatal_error(openmc_err_msg); } } @@ -1343,6 +1493,19 @@ RectilinearMesh::RectilinearMesh(pugi::xml_node node) : StructuredMesh {node} } } +RectilinearMesh::RectilinearMesh(hid_t group) : StructuredMesh {group} +{ + n_dimension_ = 3; + + read_dataset(group, "x_grid", grid_[0]); + read_dataset(group, "y_grid", grid_[1]); + read_dataset(group, "z_grid", grid_[2]); + + if (int err = set_grid()) { + fatal_error(openmc_err_msg); + } +} + const std::string RectilinearMesh::mesh_type = "rectilinear"; std::string RectilinearMesh::get_mesh_type() const @@ -1478,6 +1641,19 @@ CylindricalMesh::CylindricalMesh(pugi::xml_node node) } } +CylindricalMesh::CylindricalMesh(hid_t group) : PeriodicStructuredMesh {group} +{ + n_dimension_ = 3; + read_dataset(group, "r_grid", grid_[0]); + read_dataset(group, "phi_grid", grid_[1]); + read_dataset(group, "z_grid", grid_[2]); + read_dataset(group, "origin", origin_); + + if (int err = set_grid()) { + fatal_error(openmc_err_msg); + } +} + const std::string CylindricalMesh::mesh_type = "cylindrical"; std::string CylindricalMesh::get_mesh_type() const @@ -1756,6 +1932,20 @@ SphericalMesh::SphericalMesh(pugi::xml_node node) } } +SphericalMesh::SphericalMesh(hid_t group) : PeriodicStructuredMesh {group} +{ + n_dimension_ = 3; + + read_dataset(group, "r_grid", grid_[0]); + read_dataset(group, "theta_grid", grid_[1]); + read_dataset(group, "phi_grid", grid_[2]); + read_dataset(group, "origin", origin_); + + if (int err = set_grid()) { + fatal_error(openmc_err_msg); + } +} + const std::string SphericalMesh::mesh_type = "spherical"; std::string SphericalMesh::get_mesh_type() const @@ -2520,6 +2710,11 @@ MOABMesh::MOABMesh(pugi::xml_node node) : UnstructuredMesh(node) initialize(); } +MOABMesh::MOABMesh(hid_t group) : UnstructuredMesh(group) +{ + initialize(); +} + MOABMesh::MOABMesh(const std::string& filename, double length_multiplier) : UnstructuredMesh() { @@ -3228,6 +3423,15 @@ LibMesh::LibMesh(pugi::xml_node node) : UnstructuredMesh(node) initialize(); } +LibMesh::LibMesh(hid_t group) : UnstructuredMesh(group) +{ + // filename_ and length_multiplier_ will already be set by the + // UnstructuredMesh constructor + set_mesh_pointer_from_filename(filename_); + set_length_multiplier(length_multiplier_); + initialize(); +} + // create the mesh from a pointer to a libMesh Mesh LibMesh::LibMesh(libMesh::MeshBase& input_mesh, double length_multiplier) { @@ -3618,34 +3822,51 @@ void read_meshes(pugi::xml_node root) mesh_lib = get_node_value(node, "library", true, true); } - // Read mesh and add to vector - if (mesh_type == RegularMesh::mesh_type) { - model::meshes.push_back(make_unique(node)); - } else if (mesh_type == RectilinearMesh::mesh_type) { - model::meshes.push_back(make_unique(node)); - } else if (mesh_type == CylindricalMesh::mesh_type) { - model::meshes.push_back(make_unique(node)); - } else if (mesh_type == SphericalMesh::mesh_type) { - model::meshes.push_back(make_unique(node)); -#ifdef OPENMC_DAGMC_ENABLED - } else if (mesh_type == UnstructuredMesh::mesh_type && - mesh_lib == MOABMesh::mesh_lib_type) { - model::meshes.push_back(make_unique(node)); -#endif -#ifdef OPENMC_LIBMESH_ENABLED - } else if (mesh_type == UnstructuredMesh::mesh_type && - mesh_lib == LibMesh::mesh_lib_type) { - model::meshes.push_back(make_unique(node)); -#endif - } else if (mesh_type == UnstructuredMesh::mesh_type) { - fatal_error("Unstructured mesh support is not enabled or the mesh " - "library is invalid."); - } else { - fatal_error("Invalid mesh type: " + mesh_type); + Mesh::create(node, mesh_type, mesh_lib); + } +} + +void read_meshes(hid_t group) +{ + std::unordered_set mesh_ids; + + std::vector ids; + read_attribute(group, "ids", ids); + + for (auto id : ids) { + + // Check to make sure multiple meshes in the same file don't share IDs + if (contains(mesh_ids, id)) { + fatal_error(fmt::format("Two or more meshes use the same unique ID " + "'{}' in the same HDF5 input file", + id)); + } + mesh_ids.insert(id); + + // If we've already read a mesh with the same ID in a *different* file, + // assume it is the same here + if (model::mesh_map.find(id) != model::mesh_map.end()) { + warning(fmt::format("Mesh with ID={} appears in multiple files.", id)); + continue; } - // Map ID to position in vector - model::mesh_map[model::meshes.back()->id_] = model::meshes.size() - 1; + std::string name = fmt::format("mesh {}", id); + hid_t mesh_group = open_group(group, name.c_str()); + + std::string mesh_type; + if (object_exists(mesh_group, "type")) { + read_dataset(mesh_group, "type", mesh_type); + } else { + mesh_type = "regular"; + } + + // determine the mesh library to use + std::string mesh_lib; + if (object_exists(mesh_group, "library")) { + read_dataset(mesh_group, "library", mesh_lib); + } + + Mesh::create(mesh_group, mesh_type, mesh_lib); } } diff --git a/src/weight_windows.cpp b/src/weight_windows.cpp index 26762ad18a..0d648d3335 100644 --- a/src/weight_windows.cpp +++ b/src/weight_windows.cpp @@ -1328,6 +1328,10 @@ extern "C" int openmc_weight_windows_import(const char* filename) hid_t weight_windows_group = open_group(ww_file, "weight_windows"); + hid_t mesh_group = open_group(ww_file, "meshes"); + + read_meshes(mesh_group); + std::vector names = group_names(weight_windows_group); for (const auto& name : names) { diff --git a/tests/cpp_unit_tests/CMakeLists.txt b/tests/cpp_unit_tests/CMakeLists.txt index 8fedc2daa5..5f87db9eac 100644 --- a/tests/cpp_unit_tests/CMakeLists.txt +++ b/tests/cpp_unit_tests/CMakeLists.txt @@ -5,6 +5,7 @@ set(TEST_NAMES test_interpolate test_math test_mcpl_stat_sum + test_mesh # Add additional unit test files here ) diff --git a/tests/cpp_unit_tests/test_mesh.cpp b/tests/cpp_unit_tests/test_mesh.cpp new file mode 100644 index 0000000000..24c4f77373 --- /dev/null +++ b/tests/cpp_unit_tests/test_mesh.cpp @@ -0,0 +1,257 @@ +#include +#include +#include + +#include +#include + +#include "openmc/hdf5_interface.h" +#include "openmc/mesh.h" + +using namespace openmc; + +TEST_CASE("Test mesh hdf5 roundtrip - regular") +{ + // The XML data as a string + std::string xml_string = R"( + + 3 4 5 + -2 -3 -5 + 2 3 5 + + )"; + + // Create a pugixml document object + pugi::xml_document doc; + + // Load the XML from the string + pugi::xml_parse_result result = doc.load_string(xml_string.c_str()); + + pugi::xml_node root = doc.child("mesh"); + + auto mesh = RegularMesh(root); + + hid_t file_id = file_open("mesh.h5", 'w'); + + mesh.to_hdf5(file_id); + + file_close(file_id); + + hid_t file_id2 = file_open("mesh.h5", 'r'); + + hid_t group = open_group(file_id2, "mesh 1"); + + auto mesh2 = RegularMesh(group); + + file_close(file_id2); + + remove("mesh.h5"); + + REQUIRE(mesh2.shape_ == mesh.shape_); + + REQUIRE(mesh2.lower_left() == mesh.lower_left()); + + REQUIRE(mesh2.upper_right() == mesh.upper_right()); +} + +TEST_CASE("Test mesh hdf5 roundtrip - rectilinear") +{ + // The XML data as a string + std::string xml_string = R"( + + 0.0 1.0 5.0 10.0 + -10.0 -5.0 0.0 + -100.0 0.0 100.0 + + )"; + + // Create a pugixml document object + pugi::xml_document doc; + + // Load the XML from the string + pugi::xml_parse_result result = doc.load_string(xml_string.c_str()); + + pugi::xml_node root = doc.child("mesh"); + + auto mesh = RectilinearMesh(root); + + hid_t file_id = file_open("mesh.h5", 'w'); + + mesh.to_hdf5(file_id); + + file_close(file_id); + + hid_t file_id2 = file_open("mesh.h5", 'r'); + + hid_t group = open_group(file_id2, "mesh 1"); + + auto mesh2 = RectilinearMesh(group); + + file_close(file_id2); + + remove("mesh.h5"); + + REQUIRE(mesh2.shape_ == mesh.shape_); + + REQUIRE(mesh2.grid_ == mesh.grid_); +} + +TEST_CASE("Test mesh hdf5 roundtrip - cylindrical") +{ + // The XML data as a string + std::string xml_string = R"( + + 0.1 0.2 0.5 1.0 + 0.0 6.283185307179586 + 0.1 0.2 0.4 0.6 1.0 + 0 0 0 + + )"; + + // Create a pugixml document object + pugi::xml_document doc; + + // Load the XML from the string + pugi::xml_parse_result result = doc.load_string(xml_string.c_str()); + + pugi::xml_node root = doc.child("mesh"); + + auto mesh = CylindricalMesh(root); + + hid_t file_id = file_open("mesh.h5", 'w'); + + mesh.to_hdf5(file_id); + + file_close(file_id); + + hid_t file_id2 = file_open("mesh.h5", 'r'); + + hid_t group = open_group(file_id2, "mesh 1"); + + auto mesh2 = CylindricalMesh(group); + + file_close(file_id2); + + remove("mesh.h5"); + + REQUIRE(mesh2.shape_ == mesh.shape_); + + REQUIRE(mesh2.grid_ == mesh.grid_); +} + +TEST_CASE("Test mesh hdf5 roundtrip - spherical") +{ + // The XML data as a string + std::string xml_string = R"( + + 0.1 0.2 0.5 1.0 + 0.0 3.141592653589793 + 0.0 6.283185307179586 + 0.0 0.0 0.0 + ' + )"; + + // Create a pugixml document object + pugi::xml_document doc; + + // Load the XML from the string + pugi::xml_parse_result result = doc.load_string(xml_string.c_str()); + + pugi::xml_node root = doc.child("mesh"); + + auto mesh = SphericalMesh(root); + + hid_t file_id = file_open("mesh.h5", 'w'); + + mesh.to_hdf5(file_id); + + file_close(file_id); + + hid_t file_id2 = file_open("mesh.h5", 'r'); + + hid_t group = open_group(file_id2, "mesh 1"); + + auto mesh2 = SphericalMesh(group); + + file_close(file_id2); + + remove("mesh.h5"); + + REQUIRE(mesh2.shape_ == mesh.shape_); + + REQUIRE(mesh2.grid_ == mesh.grid_); +} + +TEST_CASE("Test multiple meshes HDF5 roundtrip - spherical") +{ + // The XML data as a string + std::string xml_string = R"( + + + 0.1 0.2 0.5 1.0 + 0.0 3.141592653589793 + 0.0 6.283185307179586 + 0.0 0.0 0.0 + + + 3 4 5 + -2 -3 -5 + 2 3 5 + + + )"; + + // Create a pugixml document object + pugi::xml_document doc; + + // Load the XML from the string + pugi::xml_parse_result result = doc.load_string(xml_string.c_str()); + + pugi::xml_node root = doc.child("meshes"); + + read_meshes(root); + + const auto spherical_mesh_xml = + dynamic_cast(model::meshes[0].get()); + const auto regular_mesh_xml = + dynamic_cast(model::meshes[1].get()); + + hid_t file_id = file_open("meshes.h5", 'w'); + + hid_t root_group = create_group(file_id, "root"); + + open_group(file_id, "root"); + + meshes_to_hdf5(root_group); + + close_group(root_group); + + file_close(file_id); + + hid_t file_id2 = file_open("meshes.h5", 'r'); + + hid_t root_group_read = open_group(file_id2, "root"); + + hid_t mesh_group_read = open_group(root_group_read, "meshes"); + + read_meshes(mesh_group_read); + + // increment mesh IDs to avoid collision during read + for (auto& mesh : model::meshes) { + mesh->set_id(mesh->id() + 10); + } + + const auto spherical_mesh_hdf5 = dynamic_cast( + model::meshes[model::mesh_map[spherical_mesh_xml->id_]].get()); + const auto regular_mesh_hdf5 = dynamic_cast( + model::meshes[model::mesh_map[regular_mesh_xml->id_]].get()); + + remove("meshes.h5"); + + REQUIRE(spherical_mesh_hdf5->shape_ == spherical_mesh_xml->shape_); + REQUIRE(spherical_mesh_hdf5->grid_ == spherical_mesh_xml->grid_); + + REQUIRE(regular_mesh_hdf5->shape_ == regular_mesh_xml->shape_); + REQUIRE(regular_mesh_hdf5->lower_left() == regular_mesh_xml->lower_left()); + REQUIRE(regular_mesh_hdf5->upper_right() == regular_mesh_xml->upper_right()); +} From 70b52546624cb3a654189856d8c9e012624c7672 Mon Sep 17 00:00:00 2001 From: John Tramm Date: Mon, 27 Oct 2025 03:30:54 -0500 Subject: [PATCH 04/26] Random Ray Geometry Debug Mode Fix (#3615) --- src/random_ray/random_ray.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/random_ray/random_ray.cpp b/src/random_ray/random_ray.cpp index 89a91449df..c19d136a4a 100644 --- a/src/random_ray/random_ray.cpp +++ b/src/random_ray/random_ray.cpp @@ -278,6 +278,10 @@ uint64_t RandomRay::transport_history_based_single_ray() // Transports ray across a single source region void RandomRay::event_advance_ray() { + // If geometry debug mode is on, check for cell overlaps + if (settings::check_overlaps) + check_cell_overlap(*this); + // Find the distance to the nearest boundary boundary() = distance_to_boundary(*this); double distance = boundary().distance(); From 230db28d39ae1e3011e3ad85cb8b5090693fe862 Mon Sep 17 00:00:00 2001 From: John Tramm Date: Tue, 28 Oct 2025 03:22:37 -0500 Subject: [PATCH 05/26] FW-CADIS Disregard Max Realizations Setting (#3616) --- docs/source/usersguide/variance_reduction.rst | 3 +-- src/simulation.cpp | 7 ++----- src/weight_windows.cpp | 15 +++++++++++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/source/usersguide/variance_reduction.rst b/docs/source/usersguide/variance_reduction.rst index 369e33e2d8..5c24851585 100644 --- a/docs/source/usersguide/variance_reduction.rst +++ b/docs/source/usersguide/variance_reduction.rst @@ -133,8 +133,7 @@ random ray mode can be found in the :ref:`Random Ray User Guide `. # we used for source region decomposition wwg = openmc.WeightWindowGenerator( method='fw_cadis', - mesh=mesh, - max_realizations=settings.batches + mesh=mesh ) # Add generator to openmc.settings object diff --git a/src/simulation.cpp b/src/simulation.cpp index f55546c4ce..05d5545260 100644 --- a/src/simulation.cpp +++ b/src/simulation.cpp @@ -400,11 +400,8 @@ void finalize_batch() simulation::time_tallies.stop(); // update weight windows if needed - if (settings::solver_type != SolverType::RANDOM_RAY || - simulation::current_batch == settings::n_batches) { - for (const auto& wwg : variance_reduction::weight_windows_generators) { - wwg->update(); - } + for (const auto& wwg : variance_reduction::weight_windows_generators) { + wwg->update(); } // Reset global tally results diff --git a/src/weight_windows.cpp b/src/weight_windows.cpp index 0d648d3335..674fae49c3 100644 --- a/src/weight_windows.cpp +++ b/src/weight_windows.cpp @@ -26,6 +26,7 @@ #include "openmc/random_ray/flat_source_domain.h" #include "openmc/search.h" #include "openmc/settings.h" +#include "openmc/simulation.h" #include "openmc/tallies/filter_energy.h" #include "openmc/tallies/filter_mesh.h" #include "openmc/tallies/filter_particle.h" @@ -966,11 +967,17 @@ void WeightWindowsGenerator::update() const Tally* tally = model::tallies[tally_idx_].get(); - // if we're beyond the number of max realizations or not at the corrrect - // update interval, skip the update - if (max_realizations_ < tally->n_realizations_ || - tally->n_realizations_ % update_interval_ != 0) + // If in random ray mode, only update on the last batch + if (settings::solver_type == SolverType::RANDOM_RAY) { + if (simulation::current_batch != settings::n_batches) { + return; + } + // If in Monte Carlo mode and beyond the number of max realizations or + // not at the correct update interval, skip the update + } else if (max_realizations_ < tally->n_realizations_ || + tally->n_realizations_ % update_interval_ != 0) { return; + } wws->update_weights(tally, tally_value_, threshold_, ratio_, method_); From f10d7d9f67375acb088d01e82bab957617f18e39 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Tue, 28 Oct 2025 03:23:16 -0500 Subject: [PATCH 06/26] Speed up `apply_time_correction` by reducing file I/O and deepcopies (#3617) --- openmc/deplete/d1s.py | 27 ++++++++++++++++----------- tests/unit_tests/test_d1s.py | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/openmc/deplete/d1s.py b/openmc/deplete/d1s.py index f51dea4160..bc99fc42db 100644 --- a/openmc/deplete/d1s.py +++ b/openmc/deplete/d1s.py @@ -5,7 +5,7 @@ shutdown dose rate calculations. """ -from copy import deepcopy +from copy import copy from typing import Sequence from math import log, prod @@ -164,8 +164,12 @@ def apply_time_correction( radionuclides = [str(x) for x in tally.filters[i_filter].bins] tcf = np.array([time_correction_factors[x][index] for x in radionuclides]) - # Create copy of tally - new_tally = deepcopy(tally) + # Force tally results to be read and std_dev to be computed + tally.std_dev + + # Create shallow copy of tally + new_tally = copy(tally) + new_tally._filters = copy(tally._filters) # Determine number of bins in other filters n_bins_before = prod([f.num_bins for f in tally.filters[:i_filter]]) @@ -177,32 +181,33 @@ def apply_time_correction( shape = (n_bins_before, n_radionuclides, n_bins_after, n_nuclides, n_scores) tally_sum = new_tally.sum.reshape(shape) tally_sum_sq = new_tally.sum_sq.reshape(shape) + tally_mean = new_tally.mean.reshape(shape) + tally_std_dev = new_tally.std_dev.reshape(shape) # Apply TCF, broadcasting to the correct dimensions tcf.shape = (1, -1, 1, 1, 1) new_tally._sum = tally_sum * tcf new_tally._sum_sq = tally_sum_sq * (tcf*tcf) - new_tally._mean = None - new_tally._std_dev = None + new_tally._mean = tally_mean * tcf + new_tally._std_dev = tally_std_dev * tcf shape = (-1, n_nuclides, n_scores) if sum_nuclides: - # Query the mean and standard deviation - mean = new_tally.mean - std_dev = new_tally.std_dev - # Sum over parent nuclides (note that when combining different bins for # parent nuclide, we can't work directly on sum_sq) - new_tally._mean = mean.sum(axis=1).reshape(shape) - new_tally._std_dev = np.linalg.norm(std_dev, axis=1).reshape(shape) + new_tally._mean = new_tally.mean.sum(axis=1).reshape(shape) + new_tally._std_dev = np.linalg.norm(new_tally.std_dev, axis=1).reshape(shape) new_tally._derived = True # Remove ParentNuclideFilter new_tally.filters.pop(i_filter) else: + # Change shape back to (filter combinations, nuclides, scores) new_tally._sum.shape = shape new_tally._sum_sq.shape = shape + new_tally._mean.shape = shape + new_tally._std_dev.shape = shape return new_tally diff --git a/tests/unit_tests/test_d1s.py b/tests/unit_tests/test_d1s.py index 9410f2da2e..8f3b62f400 100644 --- a/tests/unit_tests/test_d1s.py +++ b/tests/unit_tests/test_d1s.py @@ -120,6 +120,13 @@ def test_apply_time_correction(run_in_tmpdir): tally = sp.tallies[tally.id] flux = tally.mean.flatten() + # Copy attributes from original tally + tally_filters = list(tally.filters) + tally_sum = tally.sum.copy() + tally_sum_sq = tally.sum_sq.copy() + tally_mean = tally.mean.copy() + tally_std_dev = tally.std_dev.copy() + # Apply TCF and make sure results are consistent result = d1s.apply_time_correction(tally, factors, sum_nuclides=False) tcf = np.array([factors[nuc][-1] for nuc in nuclides]) @@ -129,6 +136,13 @@ def test_apply_time_correction(run_in_tmpdir): result_summed = d1s.apply_time_correction(tally, factors) assert result_summed.mean.flatten()[0] == pytest.approx(result.mean.sum()) + # Make sure original tally is unchanged + assert tally.filters == tally_filters + assert np.all(tally.sum == tally_sum) + assert np.all(tally.sum_sq == tally_sum_sq) + assert np.all(tally.mean == tally_mean) + assert np.all(tally.std_dev == tally_std_dev) + # Make sure various tally methods work result.get_values() result_summed.get_values() From a74c1424a8a883066b2c0afe4b23f20c84cbb815 Mon Sep 17 00:00:00 2001 From: Makarand More <40858007+Jarvis2001@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:20:11 +0530 Subject: [PATCH 07/26] Update `check_type` calls to accept both `str` and `os.PathLike` objects. (#3618) Co-authored-by: Paul Romano --- openmc/checkvalue.py | 2 +- openmc/mgxs/library.py | 7 ++-- openmc/mgxs/mdgxs.py | 3 +- openmc/mgxs/mgxs.py | 3 +- openmc/mgxs_library.py | 5 ++- openmc/plots.py | 2 +- tests/unit_tests/test_pathlike_simple.py | 46 ++++++++++++++++++++++++ 7 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 tests/unit_tests/test_pathlike_simple.py diff --git a/openmc/checkvalue.py b/openmc/checkvalue.py index 4fa205b14f..5ff2cf9ac5 100644 --- a/openmc/checkvalue.py +++ b/openmc/checkvalue.py @@ -37,7 +37,7 @@ def check_type(name, value, expected_type, expected_iter_type=None, *, none_ok=F [t.__name__ for t in expected_type])) else: msg = (f'Unable to set "{name}" to "{value}" which is not of type "' - f'{expected_type.__name__}"') + f'{expected_type}"') raise TypeError(msg) if expected_iter_type: diff --git a/openmc/mgxs/library.py b/openmc/mgxs/library.py index 12a4630bdc..f782cbe9e5 100644 --- a/openmc/mgxs/library.py +++ b/openmc/mgxs/library.py @@ -10,6 +10,7 @@ import numpy as np import openmc import openmc.mgxs import openmc.checkvalue as cv +from openmc.checkvalue import PathLike from ..tallies import ESTIMATOR_TYPES @@ -851,7 +852,7 @@ class Library: 'since a statepoint has not yet been loaded' raise ValueError(msg) - cv.check_type('filename', filename, str) + cv.check_type('filename', filename, (str, PathLike)) cv.check_type('directory', directory, str) import h5py @@ -894,7 +895,7 @@ class Library: """ - cv.check_type('filename', filename, str) + cv.check_type('filename', filename, (str, PathLike)) cv.check_type('directory', directory, str) # Make directory if it does not exist @@ -930,7 +931,7 @@ class Library: """ - cv.check_type('filename', filename, str) + cv.check_type('filename', filename, (str, PathLike)) cv.check_type('directory', directory, str) # Make directory if it does not exist diff --git a/openmc/mgxs/mdgxs.py b/openmc/mgxs/mdgxs.py index b95a4fbc0e..c12c1a9abe 100644 --- a/openmc/mgxs/mdgxs.py +++ b/openmc/mgxs/mdgxs.py @@ -7,6 +7,7 @@ import numpy as np import openmc import openmc.checkvalue as cv +from openmc.checkvalue import PathLike from openmc.mgxs import MGXS from .mgxs import _DOMAIN_TO_FILTER @@ -722,7 +723,7 @@ class MDGXS(MGXS): """ - cv.check_type('filename', filename, str) + cv.check_type('filename', filename, (str, PathLike)) cv.check_type('directory', directory, str) cv.check_value('format', format, ['csv', 'excel', 'pickle', 'latex']) cv.check_value('xs_type', xs_type, ['macro', 'micro']) diff --git a/openmc/mgxs/mgxs.py b/openmc/mgxs/mgxs.py index b8f2b8d3fb..533ab0ad32 100644 --- a/openmc/mgxs/mgxs.py +++ b/openmc/mgxs/mgxs.py @@ -10,6 +10,7 @@ import numpy as np import openmc from openmc.data import REACTION_MT, REACTION_NAME, FISSION_MTS import openmc.checkvalue as cv +from openmc.checkvalue import PathLike from ..tallies import ESTIMATOR_TYPES from . import EnergyGroups @@ -1982,7 +1983,7 @@ class MGXS: """ - cv.check_type('filename', filename, str) + cv.check_type('filename', filename, (str, PathLike)) cv.check_type('directory', directory, str) cv.check_value('format', format, ['csv', 'excel', 'pickle', 'latex']) cv.check_value('xs_type', xs_type, ['macro', 'micro']) diff --git a/openmc/mgxs_library.py b/openmc/mgxs_library.py index ce86f97dfb..4bc2d4a5a7 100644 --- a/openmc/mgxs_library.py +++ b/openmc/mgxs_library.py @@ -2514,8 +2514,7 @@ class MGXSLibrary: """ - check_type('filename', filename, PathLike) - + check_type('filename', filename, (str, PathLike)) # Create and write to the HDF5 file file = h5py.File(filename, "w", libver=libver) file.attrs['filetype'] = np.bytes_(_FILETYPE_MGXS_LIBRARY) @@ -2554,7 +2553,7 @@ class MGXSLibrary: raise ValueError("Either path or openmc.config['mg_cross_sections']" "must be set") - check_type('filename', filename, str) + check_type('filename', filename, (str, PathLike)) file = h5py.File(filename, 'r') # Check filetype and version diff --git a/openmc/plots.py b/openmc/plots.py index e9130f0bea..a0bde3f007 100644 --- a/openmc/plots.py +++ b/openmc/plots.py @@ -439,7 +439,7 @@ class PlotBase(IDManagerMixin): @filename.setter def filename(self, filename): - cv.check_type('filename', filename, str) + cv.check_type('filename', filename, (str, PathLike)) self._filename = filename @property diff --git a/tests/unit_tests/test_pathlike_simple.py b/tests/unit_tests/test_pathlike_simple.py new file mode 100644 index 0000000000..e0116bf01c --- /dev/null +++ b/tests/unit_tests/test_pathlike_simple.py @@ -0,0 +1,46 @@ +"""Simple test for PathLike filename support""" + +from pathlib import Path + +import pytest +import openmc +from openmc.checkvalue import check_type, PathLike + + +def test_pathlike_type_checking(): + """Test that PathLike type checking works correctly""" + + # Test with string (should work) + check_type('filename', 'test.txt', PathLike) + + # Test with Path object (should work) + path_obj = Path('test.txt') + check_type('filename', path_obj, PathLike) + + # Test with Path object containing subdirectories (should work) + path_with_subdir = Path('subdir') / 'test.txt' + check_type('filename', path_with_subdir, PathLike) + + # Test with invalid type (should raise TypeError) + with pytest.raises(TypeError): + check_type('filename', 123, PathLike) + + +def test_plot_filename_pathlike(): + """Test that plot filename accepts Path objects""" + + plot = openmc.Plot() + + # Test with string (should still work) + plot.filename = "test_plot" + assert plot.filename == "test_plot" + + # Test with Path object + path_obj = Path("test_plot_path") + plot.filename = path_obj + assert plot.filename == path_obj + + # Test with Path object containing subdirectories + path_with_subdir = Path("subdir") / "test_plot" + plot.filename = path_with_subdir + assert plot.filename == path_with_subdir From 4c4176661124fe92f5304d20d1b6e7675c44d455 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Tue, 28 Oct 2025 11:36:03 -0500 Subject: [PATCH 08/26] Criticality search method on the Model class (#3569) --- openmc/model/model.py | 270 ++++++++++++++++++++++++++++++++- src/settings.cpp | 5 - tests/unit_tests/test_lib.py | 3 - tests/unit_tests/test_model.py | 57 +++++++ 4 files changed, 326 insertions(+), 9 deletions(-) diff --git a/openmc/model/model.py b/openmc/model/model.py index c1ffafafd3..7963751d56 100644 --- a/openmc/model/model.py +++ b/openmc/model/model.py @@ -1,6 +1,7 @@ from __future__ import annotations -from collections.abc import Iterable, Sequence +from collections.abc import Callable, Iterable, Sequence import copy +from dataclasses import dataclass, field from functools import cache from pathlib import Path import math @@ -8,11 +9,13 @@ from numbers import Integral, Real import random import re from tempfile import NamedTemporaryFile, TemporaryDirectory +from typing import Any, Protocol import warnings import h5py import lxml.etree as ET import numpy as np +from scipy.optimize import curve_fit import openmc import openmc._xml as xml @@ -24,6 +27,12 @@ from openmc.plots import add_plot_params, _BASIS_INDICES from openmc.utility_funcs import change_directory +# Protocol for a function that is passed to search_keff +class ModelModifier(Protocol): + def __call__(self, val: float, **kwargs: Any) -> None: + ... + + class Model: """Model container. @@ -2196,3 +2205,262 @@ class Model: # Take a wild guess as to how many rays are needed self.settings.particles = 2 * int(max_length) + + def keff_search( + self, + func: ModelModifier, + x0: float, + x1: float, + target: float = 1.0, + k_tol: float = 1e-4, + sigma_final: float = 3e-4, + p: float = 0.5, + q: float = 0.95, + memory: int = 4, + x_min: float | None = None, + x_max: float | None = None, + b0: int | None = None, + b_min: int = 20, + b_max: int | None = None, + maxiter: int = 50, + output: bool = False, + func_kwargs: dict[str, Any] | None = None, + run_kwargs: dict[str, Any] | None = None, + ) -> SearchResult: + r"""Perform a keff search on a model parametrized by a single variable. + + This method uses the GRsecant method described in a paper by `Price and + Roskoff `_. The GRsecant + method is a modification of the secant method that accounts for + uncertainties in the function evaluations. The method uses a weighted + linear fit of the most recent function evaluations to predict the next + point to evaluate. It also adaptively changes the number of batches to + meet the target uncertainty value at each iteration. + + The target uncertainty for iteration :math:`n+1` is determined by the + following equation (following Eq. (8) in the paper): + + .. math:: + \sigma_{i+1} = q \sigma_\text{final} \left ( \frac{ \min \left \{ + \left\lvert k_i - k_\text{target} \right\rvert : k=0,1,\dots,n + \right \} }{k_\text{tol}} \right )^p + + where :math:`q` is a multiplicative factor less than 1, given as the + ``sigma_factor`` parameter below. + + Parameters + ---------- + func : ModelModifier + Function that takes the parameter to be searched and makes a + modification to the model. + x0 : float + First guess for the parameter passed to `func` + x1 : float + Second guess for the parameter passed to `func` + target : float, optional + keff value to search for + k_tol : float, optional + Stopping criterion on the function value; the absolute value must be + within ``k_tol`` of zero to be accepted. + sigma_final : float, optional + Maximum accepted k-effective uncertainty for the stopping criterion. + p : float, optional + Exponent used in the stopping criterion. + q : float, optional + Multiplicative factor used in the stopping criterion. + memory : int, optional + Number of most-recent points used in the weighted linear fit of + ``f(x) = a + b x`` to predict the next point. + x_min : float, optional + Minimum allowed value for the parameter ``x``. + x_max : float, optional + Maximum allowed value for the parameter ``x``. + b0 : int, optional + Number of active batches to use for the initial function + evaluations. If None, uses the model's current setting. + b_min : int, optional + Minimum number of active batches to use in a function evaluation. + b_max : int, optional + Maximum number of active batches to use in a function evaluation. + maxiter : int, optional + Maximum number of iterations to perform. + output : bool, optional + Whether or not to display output showing iteration progress. + func_kwargs : dict, optional + Keyword-based arguments to pass to the `func` function. + run_kwargs : dict, optional + Keyword arguments to pass to :meth:`openmc.Model.run` or + :meth:`openmc.lib.run`. + + Returns + ------- + SearchResult + Result object containing the estimated root (parameter value) and + evaluation history (parameters, means, standard deviations, and + batches), plus convergence status and termination reason. + + """ + import openmc.lib + + check_type('model modifier', func, Callable) + check_type('target', target, Real) + if memory < 2: + raise ValueError("memory must be ≥ 2") + func_kwargs = {} if func_kwargs is None else dict(func_kwargs) + run_kwargs = {} if run_kwargs is None else dict(run_kwargs) + run_kwargs.setdefault('output', False) + + # Create lists to store the history of evaluations + xs: list[float] = [] + fs: list[float] = [] + ss: list[float] = [] + gs: list[int] = [] + count = 0 + + # Helper function to evaluate f and store results + def eval_at(x: float, batches: int) -> tuple[float, float]: + # Modify the model with the current guess + func(x, **func_kwargs) + + # Change the number of batches and run the model + batches += self.settings.inactive + if openmc.lib.is_initialized: + openmc.lib.settings.set_batches(batches) + openmc.lib.reset() + openmc.lib.run(**run_kwargs) + sp_filepath = f'statepoint.{batches}.h5' + else: + self.settings.batches = batches + sp_filepath = self.run(**run_kwargs) + + # Extract keff and its uncertainty + with openmc.StatePoint(sp_filepath) as sp: + keff = sp.keff + + if output: + nonlocal count + count += 1 + print(f'Iteration {count}: {batches=}, {x=:.6g}, {keff=:.5f}') + + xs.append(float(x)) + fs.append(float(keff.n - target)) + ss.append(float(keff.s)) + gs.append(int(batches)) + return fs[-1], ss[-1] + + # Default b0 to current model settings if not explicitly provided + if b0 is None: + b0 = self.settings.batches - self.settings.inactive + + # Perform the search (inlined GRsecant) in a temporary directory + with TemporaryDirectory() as tmpdir: + if not openmc.lib.is_initialized: + run_kwargs.setdefault('cwd', tmpdir) + + # ---- Seed with two evaluations + f0, s0 = eval_at(x0, b0) + if abs(f0) <= k_tol and s0 <= sigma_final: + return SearchResult(x0, xs, fs, ss, gs, True, "converged") + f1, s1 = eval_at(x1, b0) + if abs(f1) <= k_tol and s1 <= sigma_final: + return SearchResult(x1, xs, fs, ss, gs, True, "converged") + + for _ in range(maxiter - 2): + # ------ Step 1: propose next x via GRsecant + m = min(memory, len(xs)) + + # Perform a curve fit on f(x) = a + bx accounting for + # uncertainties. This is equivalent to minimizing the function + # in Equation (A.14) + (a, b), _ = curve_fit( + lambda x, a, b: a + b*x, + xs[-m:], fs[-m:], sigma=ss[-m:], absolute_sigma=True + ) + x_new = float(-a / b) + + # Clamp x_new to the bounds if provided + if x_min is not None: + x_new = max(x_new, x_min) + if x_max is not None: + x_new = min(x_new, x_max) + + # ------ Step 2: choose target σ for next run (Eq. 8 + clamp) + + min_abs_f = float(np.min(np.abs(fs))) + base = q * sigma_final + ratio = min_abs_f / k_tol if k_tol > 0 else 1.0 + sig = base * (ratio ** p) + sig_target = max(sig, base) + + # ------ Step 3: choose generations to hit σ_target (Appendix C) + + # Use at least two past points for regression + if len(gs) >= 2 and np.var(np.log(gs)) > 0.0: + # Perform a curve fit based on Eq. (C.3) to solve for ln(k). + # Note that unlike in the paper, we do not leave r as an + # undetermined parameter and choose r=0.5. + (ln_k,), _ = curve_fit( + lambda ln_b, ln_k: ln_k - 0.5*ln_b, + np.log(gs[-4:]), np.log(ss[-4:]), + ) + k = float(np.exp(ln_k)) + else: + k = float(ss[-1] * math.sqrt(gs[-1])) + + b_new = (k / sig_target) ** 2 + + # Clamp and round up to integer + b_new = max(b_min, math.ceil(b_new)) + if b_max is not None: + b_new = min(b_new, b_max) + + # Evaluate at proposed x with batches determined above + f_new, s_new = eval_at(x_new, b_new) + + # Termination based on both criteria (|f| and σ) + if abs(f_new) <= k_tol and s_new <= sigma_final: + return SearchResult(x_new, xs, fs, ss, gs, True, "converged") + + return SearchResult(xs[-1], xs, fs, ss, gs, False, "maxiter") + + +@dataclass +class SearchResult: + """Result of a GRsecant keff search. + + Attributes + ---------- + root : float + Estimated parameter value where f(x) = 0 at termination. + parameters : list[float] + Parameter values (x) evaluated during the search, in order. + keffs : list[float] + Estimated keff values for each evaluation. + stdevs : list[float] + One-sigma uncertainties of keff for each evaluation. + batches : list[int] + Number of active batches used for each evaluation. + converged : bool + Whether both |f| <= k_tol and sigma <= sigma_final were met. + flag : str + Reason for termination (e.g., "converged", "maxiter"). + """ + root: float + parameters: list[float] = field(repr=False) + means: list[float] = field(repr=False) + stdevs: list[float] = field(repr=False) + batches: list[int] = field(repr=False) + converged: bool + flag: str + + @property + def function_calls(self) -> int: + """Number of function evaluations performed.""" + return len(self.parameters) + + @property + def total_batches(self) -> int: + """Total number of active batches used across all evaluations.""" + return sum(self.batches) + + diff --git a/src/settings.cpp b/src/settings.cpp index 325256cdc5..13b91b0e4f 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -1220,11 +1220,6 @@ extern "C" int openmc_set_n_batches( return OPENMC_E_INVALID_ARGUMENT; } - if (simulation::current_batch >= n_batches) { - set_errmsg("Number of batches must be greater than current batch."); - return OPENMC_E_INVALID_ARGUMENT; - } - if (!settings::trigger_on) { // Set n_batches and n_max_batches to same value settings::n_batches = n_batches; diff --git a/tests/unit_tests/test_lib.py b/tests/unit_tests/test_lib.py index 43bc5a8f6f..eb4dc3dce6 100644 --- a/tests/unit_tests/test_lib.py +++ b/tests/unit_tests/test_lib.py @@ -496,9 +496,6 @@ def test_set_n_batches(lib_run): for i in range(7): openmc.lib.next_batch() - # Setting n_batches less than current_batch should raise error - with pytest.raises(exc.InvalidArgumentError): - settings.set_batches(6) # n_batches should stay the same assert settings.get_batches() == 10 diff --git a/tests/unit_tests/test_model.py b/tests/unit_tests/test_model.py index 60f8b1a25a..f4f94a47ca 100644 --- a/tests/unit_tests/test_model.py +++ b/tests/unit_tests/test_model.py @@ -901,6 +901,7 @@ def test_id_map_aligned_model(): assert tr_instance == 3, f"Expected cell instance 3 at top-right corner, got {tr_instance}" assert tr_material == 5, f"Expected material ID 5 at top-right corner, got {tr_material}" + def test_setter_from_list(): mat = openmc.Material() model = openmc.Model(materials=[mat]) @@ -913,3 +914,59 @@ def test_setter_from_list(): plot = openmc.Plot() model = openmc.Model(plots=[plot]) assert isinstance(model.plots, openmc.Plots) + + +def test_keff_search(run_in_tmpdir): + """Test the Model.keff_search method""" + + # Create model of a sphere of U235 + mat = openmc.Material() + mat.set_density('g/cm3', 18.9) + mat.add_nuclide('U235', 1.0) + sphere = openmc.Sphere(r=10.0, boundary_type='vacuum') + cell = openmc.Cell(fill=mat, region=-sphere) + geometry = openmc.Geometry([cell]) + settings = openmc.Settings(particles=1000, inactive=10, batches=30) + model = openmc.Model(geometry=geometry, settings=settings) + + # Define function to modify sphere radius + def modify_radius(radius): + sphere.r = radius + + # Perform keff search + k_tol = 4e-3 + sigma_final = 2e-3 + result = model.keff_search( + func=modify_radius, + x0=6.0, + x1=9.0, + k_tol=k_tol, + sigma_final=sigma_final, + output=True, + ) + + final_keff = result.means[-1] + 1.0 # Add back target since means are (keff - target) + final_sigma = result.stdevs[-1] + + # Check for convergence and that tolerances are met + assert result.converged, "keff_search did not converge" + assert abs(final_keff - 1.0) <= k_tol, \ + f"Final keff {final_keff:.5f} not within k_tol {k_tol}" + assert final_sigma <= sigma_final, \ + f"Final uncertainty {final_sigma:.5f} exceeds sigma_final {sigma_final}" + + # Check type of result + assert isinstance(result, openmc.model.SearchResult) + + # Check that we have function evaluation history + assert len(result.parameters) >= 2 + assert len(result.means) == len(result.parameters) + assert len(result.stdevs) == len(result.parameters) + assert len(result.batches) == len(result.parameters) + + # Check that function_calls property works + assert result.function_calls == len(result.parameters) + + # Check that total_batches property works + assert result.total_batches == sum(result.batches) + assert result.total_batches > 0 From 5fc289b99d6ace24d6bcf736a77cfef4c59f6a82 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Fri, 31 Oct 2025 19:15:25 -0500 Subject: [PATCH 09/26] Automate workflow for mesh- or cell-based R2S calculations (#3508) Co-authored-by: Ethan Peterson Co-authored-by: Jonathan Shimwell --- docs/source/pythonapi/deplete.rst | 10 + docs/source/usersguide/decay_sources.rst | 205 ++++++- openmc/deplete/__init__.py | 1 + openmc/deplete/microxs.py | 113 +++- openmc/deplete/r2s.py | 680 +++++++++++++++++++++++ openmc/mesh.py | 18 +- openmc/model/model.py | 23 + openmc/utility_funcs.py | 19 + tests/unit_tests/test_r2s.py | 152 +++++ 9 files changed, 1173 insertions(+), 48 deletions(-) create mode 100644 openmc/deplete/r2s.py create mode 100644 tests/unit_tests/test_r2s.py diff --git a/docs/source/pythonapi/deplete.rst b/docs/source/pythonapi/deplete.rst index f112cf8ccf..25fcd898f4 100644 --- a/docs/source/pythonapi/deplete.rst +++ b/docs/source/pythonapi/deplete.rst @@ -287,6 +287,16 @@ the following abstract base classes: abc.SIIntegrator abc.DepSystemSolver +R2S Automation +-------------- + +.. autosummary:: + :toctree: generated + :nosignatures: + :template: myclass.rst + + R2SManager + D1S Functions ------------- diff --git a/docs/source/usersguide/decay_sources.rst b/docs/source/usersguide/decay_sources.rst index d5a078135b..398680e746 100644 --- a/docs/source/usersguide/decay_sources.rst +++ b/docs/source/usersguide/decay_sources.rst @@ -6,42 +6,189 @@ Decay Sources Through the :ref:`depletion ` capabilities in OpenMC, it is possible to simulate radiation emitted from the decay of activated materials. -For fusion energy systems, this is commonly done using what is known as the -`rigorous 2-step `_ (R2S) method. -In this method, a neutron transport calculation is used to determine the neutron -flux and reaction rates over a cell- or mesh-based spatial discretization of the -model. Then, the neutron flux in each discrete region is used to predict the -activated material composition using a depletion solver. Finally, a photon -transport calculation with a source based on the activity and energy spectrum of -the activated materials is used to determine a desired physical response (e.g., -a dose rate) at one or more locations of interest. +For fusion energy systems, this is commonly done using either the `rigorous +2-step `_ (R2S) method or the +`direct 1-step `_ (D1S) method. +In the R2S method, a neutron transport calculation is used to determine the +neutron flux and reaction rates over a cell- or mesh-based spatial +discretization of the model. Then, the neutron flux in each discrete region is +used to predict the activated material composition using a depletion solver. +Finally, a photon transport calculation with a source based on the activity and +energy spectrum of the activated materials is used to determine a desired +physical response (e.g., a dose rate) at one or more locations of interest. +OpenMC includes automation for both the R2S and D1S methods as described in the +following sections. -Once a depletion simulation has been completed in OpenMC, the intrinsic decay -source can be determined as follows. First the activated material composition -can be determined using the :class:`openmc.deplete.Results` object. Indexing an -instance of this class with the timestep index returns a -:class:`~openmc.deplete.StepResult` object, which itself has a -:meth:`~openmc.deplete.StepResult.get_material` method. Once the activated -:class:`~openmc.Material` has been obtained, the -:meth:`~openmc.Material.get_decay_photon_energy` method will give the energy -spectrum of the decay photon source. The integral of the spectrum also indicates -the intensity of the source in units of [Bq]. Altogether, the workflow looks as -follows:: +Rigorous 2-Step (R2S) Calculations +================================== +OpenMC includes an :class:`openmc.deplete.R2SManager` class that fully automates +cell- and mesh-based R2S calculations. Before we describe this class, it is +useful to understand the basic mechanics of how an R2S calculation works. +Generally, it involves the following steps: + +1. The :meth:`openmc.deplete.get_microxs_and_flux` function is called to run a + neutron transport calculation that determines fluxes and microscopic cross + sections in each activation region. +2. The :class:`openmc.deplete.IndependentOperator` and + :class:`openmc.deplete.PredictorIntegrator` classes are used to carry out a + depletion (activation) calculation in order to determine predicted material + compositions based on a set of timesteps and source rates. +3. The activated material composition is determined using the + :class:`openmc.deplete.Results` class. Indexing an instance of this class + with the timestep index returns a :class:`~openmc.deplete.StepResult` object, + which itself has a :meth:`~openmc.deplete.StepResult.get_material` method + returning an activated material. +4. The :meth:`openmc.Material.get_decay_photon_energy` method is used to obtain + the energy spectrum of the decay photon source. The integral of the spectrum + also indicates the intensity of the source in units of [Bq]. +5. A new photon source is defined using one of OpenMC's source classes with the + energy distribution set equal to the object returned by the + :meth:`openmc.Material.get_decay_photon_energy` method. The source is then + assigned to a photon :class:`~openmc.Model`. +6. A photon transport calculation is run with ``model.run()``. + +Altogether, the workflow looks as follows:: + + # Run neutron transport calculation + fluxes, micros = openmc.deplete.get_microxs_and_flux(model, domains) + + # Run activation calculation + op = openmc.deplete.IndependentOperator(mats, fluxes, micros) + timesteps = ... + source_rates = ... + integrator = openmc.deplete.Integrator(op, timesteps, source_rates) + integrator.integrate() + + # Get decay photon source at last timestep results = openmc.deplete.Results("depletion_results.h5") - - # Get results at last timestep step = results[-1] - - # Get activated material composition for ID=1 activated_mat = step.get_material('1') - - # Determine photon source photon_energy = activated_mat.get_decay_photon_energy() + photon_source = openmc.IndependentSource( + space=..., + energy=photon_energy, + particle='photon', + strength=photon_energy.integral() + ) -By default, the :meth:`~openmc.Material.get_decay_photon_energy` method will -eliminate spectral lines with very low intensity, but this behavior can be -configured with the ``clip_tolerance`` argument. + # Run photon transport calculation + model.settings.source = photon_source + model.run() + +Note that by default, the :meth:`~openmc.Material.get_decay_photon_energy` +method will eliminate spectral lines with very low intensity, but this behavior +can be configured with the ``clip_tolerance`` argument. + +Cell-based R2S +-------------- + +In practice, users do not need to manually go through each of the steps in an R2S +calculation described above. The :class:`~openmc.deplete.R2SManager` fully +automates the execution of neutron transport, depletion, decay source +generation, and photon transport. For a cell-based R2S calculation, once you +have a :class:`~openmc.Model` that has been defined, simply create an instance +of :class:`~openmc.deplete.R2SManager` by passing the model and a list of cells +to activate:: + + r2s = openmc.deplete.R2SManager(model, [cell1, cell2, cell3]) + +Note that the ``volume`` attribute must be set for any cell that is to be +activated. The :class:`~openmc.deplete.R2SManager` class allows you to +optionally specify a separate photon model; if not given as an argument, it will +create a shallow copy of the original neutron model (available as the +``neutron_model`` attribute) and store it in the ``photon_model`` attribute. We +can use this to define tallies specific to the photon model:: + + dose_tally = openmc.Tally() + ... + r2s.photon_model.tallies = [dose_tally] + +Next, define the timesteps and source rates for the activation calculation:: + + timesteps = [(3.0, 'd'), (5.0, 'h')] + source_rates = [1e12, 0.0] + +In this case, the model is irradiated for 3 days with a source rate of +:math:`10^{12}` neutron/sec and then the source is turned off and the activated +materials are allowed to decay for 5 hours. These parameters should be passed to +the :meth:`~openmc.deplete.R2SManager.run` method to execute the full R2S +calculation. Before we can do that though, for a cell-based calculation, the one +other piece of information that is needed is bounding boxes of the activated +cells:: + + bounding_boxes = { + cell1.id: cell1.bounding_box, + cell2.id: cell2.bounding_box, + cell3.id: cell3.bounding_box + } + +Note that calling the ``bounding_box`` attribute may not work for all +constructive solid geometry regions (for example, a cell that uses a +non-axis-aligned plane). In these cases, the bounding box will need to be +specified manually. Once you have a set of bounding boxes, the R2S calculation +can be run:: + + r2s.run(timesteps, source_rates, bounding_boxes=bounding_boxes) + +If not specified otherwise, a photon transport calculation is run at each time +in the depletion schedule. That means in the case above, we would see three +photon transport calculations. To specify specific times at which photon +transport calculations should be run, pass the ``photon_time_indices`` argument. +For example, if we wanted to run a photon transport calculation only on the last +time (after the 5 hour decay), we would run:: + + r2s.run(timesteps, source_rates, bounding_boxes=bounding_boxes, + photon_time_indices=[2]) + +After an R2S calculation has been run, the :class:`~openmc.deplete.R2SManager` +instance will have a ``results`` dictionary that allows you to directly access +results from each of the steps. It will also write out all the output files into +a directory that is named "r2s_/". The ``output_dir`` argument to the +:meth:`~openmc.deplete.R2SManager.run` method enables you to override the +default output directory name if desired. + +The :meth:`~openmc.deplete.R2SManager.run` method actually runs three +lower-level methods under the hood:: + + r2s.step1_neutron_transport(...) + r2s.step2_activation(...) + r2s.step3_photon_transport(...) + +For users looking for more control over the calculation, these lower-level +methods can be used in lieu of the :meth:`openmc.deplete.R2SManager.run` method. + +Mesh-based R2S +-------------- + +Executing a mesh-based R2S calculation looks nearly identical to the cell-based +R2S workflow described above. The only difference is that instead of passing a +list of cells to the ``domains`` argument of +:class:`~openmc.deplete.R2SManager`, you need to define a mesh object and pass +that instead. This might look like the following:: + + # Define a regular Cartesian mesh + mesh = openmc.RegularMesh() + mesh.lower_left = (-50., -50., 0.) + mesh.upper_right = (50., 50., 75.) + mesh.dimension = (10, 10, 5) + + r2s = openmc.deplete.R2SManager(model, mesh) + +Executing the R2S calculation is then performed by adding photon tallies and +calling the :meth:`~openmc.deplete.R2SManager.run` method with the appropriate +timesteps and source rates. Note that in this case we do not need to define cell +volumes or bounding boxes as is required for a cell-based R2S calculation. +Instead, during the neutron transport step, OpenMC will run a raytracing +calculation to determine material volume fractions within each mesh element +using the :meth:`openmc.MeshBase.material_volumes` method. Arguments to this +method can be customized via the ``mat_vol_kwargs`` argument to the +:meth:`~openmc.deplete.R2SManager.run` method. Most often, this would involve +customizing the number of rays traced to obtain better estimates of volumes. As +an example, if we wanted to run the raytracing calculation with 10 million rays, +we would run:: + + r2s.run(timesteps, source_rates, mat_vol_kwargs={'n_samples': 10_000_000}) Direct 1-Step (D1S) Calculations ================================ diff --git a/openmc/deplete/__init__.py b/openmc/deplete/__init__.py index 8a9509e900..052e224596 100644 --- a/openmc/deplete/__init__.py +++ b/openmc/deplete/__init__.py @@ -17,6 +17,7 @@ from .stepresult import * from .results import * from .integrators import * from .transfer_rates import * +from .r2s import * from . import abc from . import cram from . import helpers diff --git a/openmc/deplete/microxs.py b/openmc/deplete/microxs.py index 4ce199f0cc..e351c923df 100644 --- a/openmc/deplete/microxs.py +++ b/openmc/deplete/microxs.py @@ -8,8 +8,9 @@ from __future__ import annotations from collections.abc import Sequence import shutil from tempfile import TemporaryDirectory -from typing import Union, TypeAlias +from typing import Union, TypeAlias, Self +import h5py import pandas as pd import numpy as np @@ -20,6 +21,7 @@ from openmc.data import REACTION_MT import openmc from .chain import Chain, REACTIONS, _get_chain from .coupled_operator import _find_cross_sections, _get_nuclides_with_data +from ..utility_funcs import h5py_file_or_group import openmc.lib from openmc.mpi import comm @@ -47,6 +49,7 @@ def get_microxs_and_flux( reaction_rate_mode: str = 'direct', chain_file: PathLike | Chain | None = None, path_statepoint: PathLike | None = None, + path_input: PathLike | None = None, run_kwargs=None ) -> tuple[list[np.ndarray], list[MicroXS]]: """Generate microscopic cross sections and fluxes for multiple domains. @@ -59,7 +62,7 @@ def get_microxs_and_flux( .. versionadded:: 0.14.0 .. versionchanged:: 0.15.3 - Added `reaction_rate_mode` and `path_statepoint` arguments. + Added `reaction_rate_mode`, `path_statepoint`, `path_input` arguments. Parameters ---------- @@ -90,6 +93,10 @@ def get_microxs_and_flux( Path to write the statepoint file from the neutron transport solve to. By default, The statepoint file is written to a temporary directory and is not kept. + path_input : path-like, optional + Path to write the model XML file from the neutron transport solve to. + By default, the model XML file is written to a temporary directory and + not kept. run_kwargs : dict, optional Keyword arguments passed to :meth:`openmc.Model.run` @@ -108,7 +115,7 @@ def get_microxs_and_flux( check_value('reaction_rate_mode', reaction_rate_mode, {'direct', 'flux'}) # Save any original tallies on the model - original_tallies = model.tallies + original_tallies = list(model.tallies) # Determine what reactions and nuclides are available in chain chain = _get_chain(chain_file) @@ -178,6 +185,10 @@ def get_microxs_and_flux( shutil.move(statepoint_path, path_statepoint) statepoint_path = path_statepoint + # Export the model to path_input if provided + if path_input is not None: + model.export_to_model_xml(path_input) + with StatePoint(statepoint_path) as sp: if reaction_rate_mode == 'direct': rr_tally = sp.tallies[rr_tally.id] @@ -383,8 +394,7 @@ class MicroXS: MicroXS """ - if 'float_precision' not in kwargs: - kwargs['float_precision'] = 'round_trip' + kwargs.setdefault('float_precision', 'round_trip') df = pd.read_csv(csv_file, **kwargs) df.set_index(['nuclides', 'reactions', 'groups'], inplace=True) @@ -419,3 +429,96 @@ class MicroXS: ) df = pd.DataFrame({'xs': self.data.flatten()}, index=multi_index) df.to_csv(*args, **kwargs) + + def to_hdf5(self, group_or_filename: h5py.Group | PathLike, **kwargs): + """Export microscopic cross section data to HDF5 format + + Parameters + ---------- + group_or_filename : h5py.Group or path-like + HDF5 group or filename to write to + kwargs : dict, optional + Keyword arguments to pass to :meth:`h5py.Group.create_dataset`. + Defaults to {'compression': 'lzf'}. + + """ + kwargs.setdefault('compression', 'lzf') + + with h5py_file_or_group(group_or_filename, 'w') as group: + # Store cross section data as 3D dataset + group.create_dataset('data', data=self.data, **kwargs) + + # Store metadata as datasets using string encoding + group.create_dataset('nuclides', data=np.array(self.nuclides, dtype='S')) + group.create_dataset('reactions', data=np.array(self.reactions, dtype='S')) + + @classmethod + def from_hdf5(cls, group_or_filename: h5py.Group | PathLike) -> Self: + """Load data from an HDF5 file + + Parameters + ---------- + group_or_filename : h5py.Group or str or PathLike + HDF5 group or path to HDF5 file. If given as an h5py.Group, the + data is read from that group. If given as a string, it is assumed + to be the filename for the HDF5 file. + + Returns + ------- + MicroXS + """ + + with h5py_file_or_group(group_or_filename, 'r') as group: + # Read data from HDF5 group + data = group['data'][:] + nuclides = [nuc.decode('utf-8') for nuc in group['nuclides'][:]] + reactions = [rxn.decode('utf-8') for rxn in group['reactions'][:]] + + return cls(data, nuclides, reactions) + + +def write_microxs_hdf5( + micros: Sequence[MicroXS], + filename: PathLike, + names: Sequence[str] | None = None, + **kwargs +): + """Write multiple MicroXS objects to an HDF5 file + + Parameters + ---------- + micros : list of MicroXS + List of MicroXS objects + filename : PathLike + Output HDF5 filename + names : list of str, optional + Names for each MicroXS object. If None, uses 'domain_0', 'domain_1', + etc. + **kwargs + Additional keyword arguments passed to :meth:`h5py.Group.create_dataset` + """ + if names is None: + names = [f'domain_{i}' for i in range(len(micros))] + + # Open file once and write all domains using group interface + with h5py.File(filename, 'w') as f: + for microxs, name in zip(micros, names): + group = f.create_group(name) + microxs.to_hdf5(group, **kwargs) + + +def read_microxs_hdf5(filename: PathLike) -> dict[str, MicroXS]: + """Read multiple MicroXS objects from an HDF5 file + + Parameters + ---------- + filename : path-like + HDF5 filename + + Returns + ------- + dict + Dictionary mapping domain names to MicroXS objects + """ + with h5py.File(filename, 'r') as f: + return {name: MicroXS.from_hdf5(group) for name, group in f.items()} diff --git a/openmc/deplete/r2s.py b/openmc/deplete/r2s.py new file mode 100644 index 0000000000..7b5deddc23 --- /dev/null +++ b/openmc/deplete/r2s.py @@ -0,0 +1,680 @@ +from __future__ import annotations +from collections.abc import Sequence +import copy +from datetime import datetime +import json +from pathlib import Path + +import numpy as np +import openmc +from . import IndependentOperator, PredictorIntegrator +from .microxs import get_microxs_and_flux, write_microxs_hdf5, read_microxs_hdf5 +from .results import Results +from ..checkvalue import PathLike + + +def get_activation_materials( + model: openmc.Model, mmv: openmc.MeshMaterialVolumes +) -> openmc.Materials: + """Get a list of activation materials for each mesh element/material. + + When performing a mesh-based R2S calculation, a unique material is needed + for each activation region, which is a combination of a mesh element and a + material within that mesh element. This function generates a list of such + materials, each with a unique name and volume corresponding to the mesh + element and material. + + Parameters + ---------- + model : openmc.Model + The full model containing the geometry and materials. + mmv : openmc.MeshMaterialVolumes + The mesh material volumes object containing the materials and their + volumes for each mesh element. + + Returns + ------- + openmc.Materials + A list of materials, each corresponding to a unique mesh element and + material combination. + + """ + # Get the material ID, volume, and element index for each element-material + # combination + mat_ids = mmv._materials[mmv._materials > -1] + volumes = mmv._volumes[mmv._materials > -1] + elems, _ = np.where(mmv._materials > -1) + + # Get all materials in the model + material_dict = model._get_all_materials() + + # Create a new activation material for each element-material combination + materials = openmc.Materials() + for elem, mat_id, vol in zip(elems, mat_ids, volumes): + mat = material_dict[mat_id] + new_mat = mat.clone() + new_mat.depletable = True + new_mat.name = f'Element {elem}, Material {mat_id}' + new_mat.volume = vol + materials.append(new_mat) + + return materials + + +class R2SManager: + """Manager for Rigorous 2-Step (R2S) method calculations. + + This class is responsible for managing the materials and sources needed for + mesh-based or cell-based R2S calculations. It provides methods to get + activation materials and decay photon sources based on the mesh/cells and + materials in the OpenMC model. + + This class supports the use of a different models for the neutron and photon + transport calculation. However, for cell-based calculations, it assumes that + the only changes in the model are material assignments. For mesh-based + calculations, it checks material assignments in the photon model and any + element--material combinations that don't appear in the photon model are + skipped. + + Parameters + ---------- + neutron_model : openmc.Model + The OpenMC model to use for neutron transport. + domains : openmc.MeshBase or Sequence[openmc.Cell] + The mesh or a sequence of cells that represent the spatial units over + which the R2S calculation will be performed. + photon_model : openmc.Model, optional + The OpenMC model to use for photon transport calculations. If None, a + shallow copy of the neutron_model will be created and used. + + Attributes + ---------- + domains : openmc.MeshBase or Sequence[openmc.Cell] + The mesh or a sequence of cells that represent the spatial units over + which the R2S calculation will be performed. + neutron_model : openmc.Model + The OpenMC model used for neutron transport. + photon_model : openmc.Model + The OpenMC model used for photon transport calculations. + method : {'mesh-based', 'cell-based'} + Indicates whether the R2S calculation uses mesh elements ('mesh-based') + as the spatial discetization or a list of a cells ('cell-based'). + results : dict + A dictionary that stores results from the R2S calculation. + + """ + def __init__( + self, + neutron_model: openmc.Model, + domains: openmc.MeshBase | Sequence[openmc.Cell], + photon_model: openmc.Model | None = None, + ): + self.neutron_model = neutron_model + if photon_model is None: + # Create a shallow copy of the neutron model for photon transport + self.photon_model = openmc.Model( + geometry=copy.copy(neutron_model.geometry), + materials=copy.copy(neutron_model.materials), + settings=copy.copy(neutron_model.settings), + tallies=copy.copy(neutron_model.tallies), + plots=copy.copy(neutron_model.plots), + ) + else: + self.photon_model = photon_model + if isinstance(domains, openmc.MeshBase): + self.method = 'mesh-based' + else: + self.method = 'cell-based' + self.domains = domains + self.results = {} + + def run( + self, + timesteps: Sequence[float] | Sequence[tuple[float, str]], + source_rates: float | Sequence[float], + timestep_units: str = 's', + photon_time_indices: Sequence[int] | None = None, + output_dir: PathLike | None = None, + bounding_boxes: dict[int, openmc.BoundingBox] | None = None, + chain_file: PathLike | None = None, + micro_kwargs: dict | None = None, + mat_vol_kwargs: dict | None = None, + run_kwargs: dict | None = None, + operator_kwargs: dict | None = None, + ): + """Run the R2S calculation. + + Parameters + ---------- + timesteps : Sequence[float] or Sequence[tuple[float, str]] + Sequence of timesteps. Note that values are not cumulative. The + units are specified by the `timestep_units` argument when + `timesteps` is an iterable of float. Alternatively, units can be + specified for each step by passing an iterable of (value, unit) + tuples. + source_rates : float or Sequence[float] + Source rate in [neutron/sec] for each interval in `timesteps`. + timestep_units : {'s', 'min', 'h', 'd', 'a'}, optional + Units for values specified in the `timesteps` argument when passing + float values. 's' means seconds, 'min' means minutes, 'h' means + hours, 'd' means days, and 'a' means years (Julian). + photon_time_indices : Sequence[int], optional + Sequence of time indices at which photon transport should be run; + represented as indices into the array of times formed by the + timesteps. For example, if two timesteps are specified, the array of + times would contain three entries, and [2] would indicate computing + photon results at the last time. A value of None indicates to run + photon transport for each time. + output_dir : PathLike, optional + Path to directory where R2S calculation outputs will be saved. If + not provided, a timestamped directory 'r2s_YYYY-MM-DDTHH-MM-SS' is + created. Subdirectories will be created for the neutron transport, + activation, and photon transport steps. + bounding_boxes : dict[int, openmc.BoundingBox], optional + Dictionary mapping cell IDs to bounding boxes used for spatial + source sampling in cell-based R2S calculations. Required if method + is 'cell-based'. + chain_file : PathLike, optional + Path to the depletion chain XML file to use during activation. If + not provided, the default configured chain file will be used. + micro_kwargs : dict, optional + Additional keyword arguments passed to + :func:`openmc.deplete.get_microxs_and_flux` during the neutron + transport step. + mat_vol_kwargs : dict, optional + Additional keyword arguments passed to + :meth:`openmc.MeshBase.material_volumes`. + run_kwargs : dict, optional + Additional keyword arguments passed to :meth:`openmc.Model.run` + during the neutron and photon transport step. By default, output is + disabled. + operator_kwargs : dict, optional + Additional keyword arguments passed to + :class:`openmc.deplete.IndependentOperator`. + + Returns + ------- + Path + Path to the output directory containing all calculation results + """ + + if output_dir is None: + stamp = datetime.now().strftime('%Y-%m-%dT%H-%M-%S') + output_dir = Path(f'r2s_{stamp}') + + # Set run_kwargs for the neutron transport step + if micro_kwargs is None: + micro_kwargs = {} + if run_kwargs is None: + run_kwargs = {} + if operator_kwargs is None: + operator_kwargs = {} + run_kwargs.setdefault('output', False) + micro_kwargs.setdefault('run_kwargs', run_kwargs) + # If a chain file is provided, prefer it for steps 1 and 2 + if chain_file is not None: + micro_kwargs.setdefault('chain_file', chain_file) + operator_kwargs.setdefault('chain_file', chain_file) + + self.step1_neutron_transport( + output_dir / 'neutron_transport', mat_vol_kwargs, micro_kwargs + ) + self.step2_activation( + timesteps, source_rates, timestep_units, output_dir / 'activation', + operator_kwargs=operator_kwargs + ) + self.step3_photon_transport( + photon_time_indices, bounding_boxes, output_dir / 'photon_transport', + mat_vol_kwargs=mat_vol_kwargs, run_kwargs=run_kwargs + ) + + return output_dir + + def step1_neutron_transport( + self, + output_dir: PathLike = "neutron_transport", + mat_vol_kwargs: dict | None = None, + micro_kwargs: dict | None = None + ): + """Run the neutron transport step. + + This step computes the material volume fractions on the mesh, creates a + mesh-material filter, and retrieves the fluxes and microscopic cross + sections for each mesh/material combination. This step will populate the + 'fluxes' and 'micros' keys in the results dictionary. For a mesh-based + calculation, it will also populate the 'mesh_material_volumes' key. + + Parameters + ---------- + output_dir : PathLike, optional + The directory where the results will be saved. + mat_vol_kwargs : dict, optional + Additional keyword arguments based to + :meth:`openmc.MeshBase.material_volumes`. + micro_kwargs : dict, optional + Additional keyword arguments passed to + :func:`openmc.deplete.get_microxs_and_flux`. + + """ + + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + if self.method == 'mesh-based': + # Compute material volume fractions on the mesh + if mat_vol_kwargs is None: + mat_vol_kwargs = {} + self.results['mesh_material_volumes'] = mmv = \ + self.domains.material_volumes(self.neutron_model, **mat_vol_kwargs) + + # Save results to file + mmv.save(output_dir / 'mesh_material_volumes.npz') + + # Create mesh-material filter based on what combos were found + domains = openmc.MeshMaterialFilter.from_volumes(self.domains, mmv) + else: + domains: Sequence[openmc.Cell] = self.domains + + # Check to make sure that each cell is filled with a material and + # that the volume has been set + + # TODO: If volumes are not set, run volume calculation for cells + for cell in domains: + if cell.fill is None: + raise ValueError( + f"Cell {cell.id} is not filled with a materials. " + "Please set the fill material for each cell before " + "running the R2S calculation." + ) + if cell.volume is None: + raise ValueError( + f"Cell {cell.id} does not have a volume set. " + "Please set the volume for each cell before running " + "the R2S calculation." + ) + + # Set default keyword arguments for microxs and flux calculation + if micro_kwargs is None: + micro_kwargs = {} + micro_kwargs.setdefault('path_statepoint', output_dir / 'statepoint.h5') + micro_kwargs.setdefault('path_input', output_dir / 'model.xml') + + # Run neutron transport and get fluxes and micros + self.results['fluxes'], self.results['micros'] = get_microxs_and_flux( + self.neutron_model, domains, **micro_kwargs) + + # Save flux and micros to file + np.save(output_dir / 'fluxes.npy', self.results['fluxes']) + write_microxs_hdf5(self.results['micros'], output_dir / 'micros.h5') + + def step2_activation( + self, + timesteps: Sequence[float] | Sequence[tuple[float, str]], + source_rates: float | Sequence[float], + timestep_units: str = 's', + output_dir: PathLike = 'activation', + operator_kwargs: dict | None = None, + ): + """Run the activation step. + + This step creates a unique copy of each activation material based on the + mesh elements or cells, then solves the depletion equations for each + material using the fluxes and microscopic cross sections obtained in the + neutron transport step. This step will populate the 'depletion_results' + and 'activation_materials' keys in the results dictionary. + + Parameters + ---------- + timesteps : Sequence[float] or Sequence[tuple[float, str]] + Sequence of timesteps. Note that values are not cumulative. The + units are specified by the `timestep_units` argument when + `timesteps` is an iterable of float. Alternatively, units can be + specified for each step by passing an iterable of (value, unit) + tuples. + source_rates : float | Sequence[float] + Source rate in [neutron/sec] for each interval in `timesteps`. + timestep_units : {'s', 'min', 'h', 'd', 'a'}, optional + Units for values specified in the `timesteps` argument when passing + float values. 's' means seconds, 'min' means minutes, 'h' means + hours, 'd' means days, and 'a' means years (Julian). + output_dir : PathLike, optional + Path to directory where activation calculation outputs will be + saved. + operator_kwargs : dict, optional + Additional keyword arguments passed to + :class:`openmc.deplete.IndependentOperator`. + """ + + if self.method == 'mesh-based': + # Get unique material for each (mesh, material) combination + mmv = self.results['mesh_material_volumes'] + self.results['activation_materials'] = get_activation_materials(self.neutron_model, mmv) + else: + # Create unique material for each cell + activation_mats = openmc.Materials() + for cell in self.domains: + mat = cell.fill.clone() + mat.name = f'Cell {cell.id}' + mat.depletable = True + mat.volume = cell.volume + activation_mats.append(mat) + self.results['activation_materials'] = activation_mats + + # Save activation materials to file + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + self.results['activation_materials'].export_to_xml( + output_dir / 'materials.xml') + + # Create depletion operator for the activation materials + if operator_kwargs is None: + operator_kwargs = {} + operator_kwargs.setdefault('normalization_mode', 'source-rate') + op = IndependentOperator( + self.results['activation_materials'], + self.results['fluxes'], + self.results['micros'], + **operator_kwargs + ) + + # Create time integrator and solve depletion equations + integrator = PredictorIntegrator( + op, timesteps, source_rates=source_rates, timestep_units=timestep_units + ) + output_path = output_dir / 'depletion_results.h5' + integrator.integrate(final_step=False, path=output_path) + + # Get depletion results + self.results['depletion_results'] = Results(output_path) + + def step3_photon_transport( + self, + time_indices: Sequence[int] | None = None, + bounding_boxes: dict[int, openmc.BoundingBox] | None = None, + output_dir: PathLike = 'photon_transport', + mat_vol_kwargs: dict | None = None, + run_kwargs: dict | None = None, + ): + """Run the photon transport step. + + This step performs photon transport calculations using decay photon + sources created from the activated materials. For each specified time, + it creates appropriate photon sources and runs a transport calculation. + In mesh-based mode, the sources are created using the mesh material + volumes, while in cell-based mode, they are created using bounding boxes + for each cell. This step will populate the 'photon_tallies' key in the + results dictionary. + + Parameters + ---------- + time_indices : Sequence[int], optional + Sequence of time indices at which photon transport should be run; + represented as indices into the array of times formed by the + timesteps. For example, if two timesteps are specified, the array of + times would contain three entries, and [2] would indicate computing + photon results at the last time. A value of None indicates to run + photon transport for each time. + bounding_boxes : dict[int, openmc.BoundingBox], optional + Dictionary mapping cell IDs to bounding boxes used for spatial + source sampling in cell-based R2S calculations. Required if method + is 'cell-based'. + output_dir : PathLike, optional + Path to directory where photon transport outputs will be saved. + mat_vol_kwargs : dict, optional + Additional keyword arguments passed to + :meth:`openmc.MeshBase.material_volumes`. + run_kwargs : dict, optional + Additional keyword arguments passed to :meth:`openmc.Model.run` + during the photon transport step. By default, output is disabled. + """ + + # TODO: Automatically determine bounding box for each cell + if bounding_boxes is None and self.method == 'cell-based': + raise ValueError("bounding_boxes must be provided for cell-based " + "R2S calculations.") + + # Set default run arguments if not provided + if run_kwargs is None: + run_kwargs = {} + run_kwargs.setdefault('output', False) + + # Write out JSON file with tally IDs that can be used for loading + # results + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + # Get default time indices if not provided + if time_indices is None: + n_steps = len(self.results['depletion_results']) + time_indices = list(range(n_steps)) + + # Check whether the photon model is different + neutron_univ = self.neutron_model.geometry.root_universe + photon_univ = self.photon_model.geometry.root_universe + different_photon_model = (neutron_univ != photon_univ) + + # For mesh-based calculations, compute material volume fractions for the + # photon model if it is different from the neutron model to account for + # potential material changes + if self.method == 'mesh-based' and different_photon_model: + self.results['mesh_material_volumes_photon'] = photon_mmv = \ + self.domains.material_volumes(self.photon_model, **mat_vol_kwargs) + + # Save photon MMV results to file + photon_mmv.save(output_dir / 'mesh_material_volumes.npz') + + tally_ids = [tally.id for tally in self.photon_model.tallies] + with open(output_dir / 'tally_ids.json', 'w') as f: + json.dump(tally_ids, f) + + self.results['photon_tallies'] = {} + + # Get dictionary of cells in the photon model + if different_photon_model: + photon_cells = self.photon_model.geometry.get_all_cells() + + for time_index in time_indices: + # Create decay photon source + if self.method == 'mesh-based': + self.photon_model.settings.source = \ + self.get_decay_photon_source_mesh(time_index) + else: + sources = [] + results = self.results['depletion_results'] + for cell, original_mat in zip(self.domains, self.results['activation_materials']): + # Skip if the cell is not in the photon model or the + # material has changed + if different_photon_model: + if cell.id not in photon_cells or \ + cell.fill.id != photon_cells[cell.id].fill.id: + continue + + # Get bounding box for the cell + bounding_box = bounding_boxes[cell.id] + + # Get activated material composition + activated_mat = results[time_index].get_material(str(original_mat.id)) + + # Create decay photon source source + space = openmc.stats.Box(*bounding_box) + energy = activated_mat.get_decay_photon_energy() + strength = energy.integral() if energy is not None else 0.0 + source = openmc.IndependentSource( + space=space, + energy=energy, + particle='photon', + strength=strength, + constraints={'domains': [cell]} + ) + sources.append(source) + self.photon_model.settings.source = sources + + # Convert time_index (which may be negative) to a normal index + if time_index < 0: + time_index = len(self.results['depletion_results']) + time_index + + # Run photon transport calculation + run_kwargs['cwd'] = Path(output_dir) / f'time_{time_index}' + statepoint_path = self.photon_model.run(**run_kwargs) + + # Store tally results + with openmc.StatePoint(statepoint_path) as sp: + self.results['photon_tallies'][time_index] = [ + sp.tallies[tally.id] for tally in self.photon_model.tallies + ] + + def get_decay_photon_source_mesh( + self, + time_index: int = -1 + ) -> list[openmc.MeshSource]: + """Create decay photon source for a mesh-based calculation. + + This function creates N :class:`MeshSource` objects where N is the + maximum number of unique materials that appears in a single mesh + element. For each mesh element-material combination, and + IndependentSource instance is created with a spatial constraint limited + the sampled decay photons to the correct region. + + When the photon transport model is different from the neutron model, the + photon MeshMaterialVolumes is used to determine whether an (element, + material) combination exists in the photon model. + + Parameters + ---------- + time_index : int, optional + Time index for the decay photon source. Default is -1 (last time). + + Returns + ------- + list of openmc.MeshSource + A list of MeshSource objects, each containing IndependentSource + instances for the decay photons in the corresponding mesh element. + + """ + mat_dict = self.neutron_model._get_all_materials() + + # Some MeshSource objects will have empty positions; create a "null source" + # that is used for this case + null_source = openmc.IndependentSource(particle='photon', strength=0.0) + + # List to hold sources for each MeshSource (length = N) + source_lists = [] + + # Index in the overall list of activated materials + index_mat = 0 + + # Get various results from previous steps + mat_vols = self.results['mesh_material_volumes'] + materials = self.results['activation_materials'] + results = self.results['depletion_results'] + photon_mat_vols = self.results.get('mesh_material_volumes_photon') + + # Total number of mesh elements + n_elements = mat_vols.num_elements + + for index_elem in range(n_elements): + # Determine which materials exist in the photon model for this element + if photon_mat_vols is not None: + photon_materials = { + mat_id + for mat_id, _ in photon_mat_vols.by_element(index_elem) + if mat_id is not None + } + + for j, (mat_id, _) in enumerate(mat_vols.by_element(index_elem)): + # Skip void volume + if mat_id is None: + continue + + # Skip if this material doesn't exist in photon model + if photon_mat_vols is not None and mat_id not in photon_materials: + index_mat += 1 + continue + + # Check whether a new MeshSource object is needed + if j >= len(source_lists): + source_lists.append([null_source]*n_elements) + + # Get activated material composition + original_mat = materials[index_mat] + activated_mat = results[time_index].get_material(str(original_mat.id)) + + # Create decay photon source source + energy = activated_mat.get_decay_photon_energy() + if energy is not None: + strength = energy.integral() + source_lists[j][index_elem] = openmc.IndependentSource( + energy=energy, + particle='photon', + strength=strength, + constraints={'domains': [mat_dict[mat_id]]} + ) + + # Increment index of activated material + index_mat += 1 + + # Return list of mesh sources + return [openmc.MeshSource(self.domains, sources) for sources in source_lists] + + def load_results(self, path: PathLike): + """Load results from a previous R2S calculation. + + Parameters + ---------- + path : PathLike + Path to the directory containing the R2S calculation results. + + """ + path = Path(path) + + # Load neutron transport results + neutron_dir = path / 'neutron_transport' + if self.method == 'mesh-based': + mmv_file = neutron_dir / 'mesh_material_volumes.npz' + if mmv_file.exists(): + self.results['mesh_material_volumes'] = \ + openmc.MeshMaterialVolumes.from_npz(mmv_file) + fluxes_file = neutron_dir / 'fluxes.npy' + if fluxes_file.exists(): + self.results['fluxes'] = list(np.load(fluxes_file, allow_pickle=True)) + micros_dict = read_microxs_hdf5(neutron_dir / 'micros.h5') + self.results['micros'] = [ + micros_dict[f'domain_{i}'] for i in range(len(micros_dict)) + ] + + # Load activation results + activation_dir = path / 'activation' + activation_results = activation_dir / 'depletion_results.h5' + if activation_results.exists(): + self.results['depletion_results'] = Results(activation_results) + activation_mats_file = activation_dir / 'materials.xml' + if activation_mats_file.exists(): + self.results['activation_materials'] = \ + openmc.Materials.from_xml(activation_mats_file) + + # Load photon transport results + photon_dir = path / 'photon_transport' + + # Load photon mesh material volumes if they exist (for mesh-based calculations) + if self.method == 'mesh-based': + photon_mmv_file = photon_dir / 'mesh_material_volumes.npz' + if photon_mmv_file.exists(): + self.results['mesh_material_volumes_photon'] = \ + openmc.MeshMaterialVolumes.from_npz(photon_mmv_file) + + # Load tally IDs from JSON file + tally_ids_path = photon_dir / 'tally_ids.json' + if tally_ids_path.exists(): + with tally_ids_path.open('r') as f: + tally_ids = json.load(f) + self.results['photon_tallies'] = {} + + # For each photon transport calc, load the statepoint and get the + # tally results based on tally_ids + for time_dir in photon_dir.glob('time_*'): + time_index = int(time_dir.name.split('_')[1]) + for sp_path in time_dir.glob('statepoint.*.h5'): + with openmc.StatePoint(sp_path) as sp: + self.results['photon_tallies'][time_index] = [ + sp.tallies[tally_id] for tally_id in tally_ids + ] diff --git a/openmc/mesh.py b/openmc/mesh.py index 9601207e91..3d93d87a1b 100644 --- a/openmc/mesh.py +++ b/openmc/mesh.py @@ -325,20 +325,10 @@ class MeshBase(IDManagerMixin, ABC): vols = material_volumes mat_volume_by_element = [vols.by_element(i) for i in range(vols.num_elements)] + # Get dictionary of all materials + materials = model._get_all_materials() + # Create homogenized material for each element - materials = model.geometry.get_all_materials() - - # Account for materials in DAGMC universes - # TODO: This should really get incorporated in lower-level calls to - # get_all_materials, but right now it requires information from the - # Model object - for cell in model.geometry.get_all_cells().values(): - if isinstance(cell.fill, openmc.DAGMCUniverse): - names = cell.fill.material_names - materials.update({ - mat.id: mat for mat in model.materials if mat.name in names - }) - homogenized_materials = [] for mat_volume_list in mat_volume_by_element: material_ids, volumes = [list(x) for x in zip(*mat_volume_list)] @@ -410,7 +400,7 @@ class MeshBase(IDManagerMixin, ABC): # In order to get mesh into model, we temporarily replace the # tallies with a single mesh tally using the current mesh - original_tallies = model.tallies + original_tallies = list(model.tallies) new_tally = openmc.Tally() new_tally.filters = [openmc.MeshFilter(self)] new_tally.scores = ['flux'] diff --git a/openmc/model/model.py b/openmc/model/model.py index 7963751d56..64294c23cb 100644 --- a/openmc/model/model.py +++ b/openmc/model/model.py @@ -212,6 +212,29 @@ class Model: result[mat.name].add(mat) return result + # TODO: This should really get incorporated in lower-level calls to + # get_all_materials, but right now it requires information from the Model object + def _get_all_materials(self) -> dict[int, openmc.Material]: + """Get all materials including those in DAGMC universes + + Returns + ------- + dict + Dictionary mapping material ID to material instances + """ + # Get all materials from the Geometry object + materials = self.geometry.get_all_materials() + + # Account for materials in DAGMC universes + for cell in self.geometry.get_all_cells().values(): + if isinstance(cell.fill, openmc.DAGMCUniverse): + names = cell.fill.material_names + materials.update({ + mat.id: mat for mat in self.materials if mat.name in names + }) + + return materials + def add_kinetics_parameters_tallies(self, num_groups: int | None = None): """Add tallies for calculating kinetics parameters using the IFP method. diff --git a/openmc/utility_funcs.py b/openmc/utility_funcs.py index da9f73b165..935a589853 100644 --- a/openmc/utility_funcs.py +++ b/openmc/utility_funcs.py @@ -3,6 +3,8 @@ import os from pathlib import Path from tempfile import TemporaryDirectory +import h5py + import openmc from .checkvalue import PathLike @@ -57,3 +59,20 @@ def input_path(filename: PathLike) -> Path: return Path(filename).resolve() else: return Path(filename) + + +@contextmanager +def h5py_file_or_group(group_or_filename: PathLike | h5py.Group, *args, **kwargs): + """Context manager for opening an HDF5 file or using an existing group + + Parameters + ---------- + group_or_filename : path-like or h5py.Group + Path to HDF5 file, or group from an existing HDF5 file + + """ + if isinstance(group_or_filename, h5py.Group): + yield group_or_filename + else: + with h5py.File(group_or_filename, *args, **kwargs) as f: + yield f diff --git a/tests/unit_tests/test_r2s.py b/tests/unit_tests/test_r2s.py new file mode 100644 index 0000000000..a94f85c8c0 --- /dev/null +++ b/tests/unit_tests/test_r2s.py @@ -0,0 +1,152 @@ +from pathlib import Path + +import pytest +import openmc +from openmc.deplete import Chain, R2SManager + + +@pytest.fixture +def simple_model_and_mesh(tmp_path): + # Define two materials: water and Ni + h2o = openmc.Material() + h2o.add_nuclide("H1", 2.0) + h2o.add_nuclide("O16", 1.0) + h2o.set_density("g/cm3", 1.0) + nickel = openmc.Material() + nickel.add_element("Ni", 1.0) + nickel.set_density("g/cm3", 4.0) + + # Geometry: two half-spaces split by x=0 plane + left = openmc.XPlane(0.0) + x_min = openmc.XPlane(-10.0, boundary_type='vacuum') + x_max = openmc.XPlane(10.0, boundary_type='vacuum') + y_min = openmc.YPlane(-10.0, boundary_type='vacuum') + y_max = openmc.YPlane(10.0, boundary_type='vacuum') + z_min = openmc.ZPlane(-10.0, boundary_type='vacuum') + z_max = openmc.ZPlane(10.0, boundary_type='vacuum') + + c1 = openmc.Cell(fill=h2o, region=+x_min & -left & +y_min & -y_max & +z_min & -z_max) + c2 = openmc.Cell(fill=nickel, region=+left & -x_max & +y_min & -y_max & +z_min & -z_max) + c1.volume = 4000.0 + c2.volume = 4000.0 + geometry = openmc.Geometry([c1, c2]) + + # Simple settings with a point source + settings = openmc.Settings() + settings.batches = 10 + settings.particles = 1000 + settings.run_mode = 'fixed source' + settings.source = openmc.IndependentSource() + model = openmc.Model(geometry, settings=settings) + + mesh = openmc.RegularMesh() + mesh.lower_left = (-10.0, -10.0, -10.0) + mesh.upper_right = (10.0, 10.0, 10.0) + mesh.dimension = (1, 1, 1) + return model, (c1, c2), mesh + + +def test_r2s_mesh_expected_output(simple_model_and_mesh, tmp_path): + model, (c1, c2), mesh = simple_model_and_mesh + + # Use mesh-based domains + r2s = R2SManager(model, mesh) + + # Use custom reduced chain file for Ni + chain = Chain.from_xml(Path(__file__).parents[1] / "chain_ni.xml") + + # Run R2S calculation + outdir = r2s.run( + timesteps=[(1.0, 'd')], + source_rates=[1.0], + photon_time_indices=[1], + output_dir=tmp_path, + chain_file=chain, + ) + + # Check directories and files exist + nt = Path(outdir) / 'neutron_transport' + assert (nt / 'fluxes.npy').exists() + assert (nt / 'micros.h5').exists() + assert (nt / 'mesh_material_volumes.npz').exists() + act = Path(outdir) / 'activation' + assert (act / 'depletion_results.h5').exists() + pt = Path(outdir) / 'photon_transport' + assert (pt / 'tally_ids.json').exists() + assert (pt / 'time_1' / 'statepoint.10.h5').exists() + + # Basic results structure checks + assert len(r2s.results['fluxes']) == 2 + assert len(r2s.results['micros']) == 2 + assert len(r2s.results['mesh_material_volumes']) == 2 + assert len(r2s.results['activation_materials']) == 2 + assert len(r2s.results['depletion_results']) == 2 + + # Check activation materials + amats = r2s.results['activation_materials'] + assert all(m.depletable for m in amats) + # Volumes preserved + assert {m.volume for m in amats} == {c1.volume, c2.volume} + + # Check loading results + r2s_loaded = R2SManager(model, mesh) + r2s_loaded.load_results(outdir) + assert len(r2s_loaded.results['fluxes']) == 2 + assert len(r2s_loaded.results['micros']) == 2 + assert len(r2s_loaded.results['mesh_material_volumes']) == 2 + assert len(r2s_loaded.results['activation_materials']) == 2 + assert len(r2s_loaded.results['depletion_results']) == 2 + + +def test_r2s_cell_expected_output(simple_model_and_mesh, tmp_path): + model, (c1, c2), _ = simple_model_and_mesh + + # Use cell-based domains + r2s = R2SManager(model, [c1, c2]) + + # Use custom reduced chain file for Ni + chain = Chain.from_xml(Path(__file__).parents[1] / "chain_ni.xml") + + # Run R2S calculation + bounding_boxes = {c1.id: c1.bounding_box, c2.id: c2.bounding_box} + outdir = r2s.run( + timesteps=[(1.0, 'd')], + source_rates=[1.0], + photon_time_indices=[1], + output_dir=tmp_path, + bounding_boxes=bounding_boxes, + chain_file=chain + ) + + # Check directories and files exist + nt = Path(outdir) / 'neutron_transport' + assert (nt / 'fluxes.npy').exists() + assert (nt / 'micros.h5').exists() + act = Path(outdir) / 'activation' + assert (act / 'depletion_results.h5').exists() + pt = Path(outdir) / 'photon_transport' + assert (pt / 'tally_ids.json').exists() + assert (pt / 'time_1' / 'statepoint.10.h5').exists() + + # Basic results structure checks + assert len(r2s.results['fluxes']) == 2 + assert len(r2s.results['micros']) == 2 + assert len(r2s.results['activation_materials']) == 2 + assert len(r2s.results['depletion_results']) == 2 + + # Check activation materials + amats = r2s.results['activation_materials'] + assert all(m.depletable for m in amats) + # Names include cell IDs + assert any(f"Cell {c1.id}" in m.name for m in amats) + assert any(f"Cell {c2.id}" in m.name for m in amats) + # Volumes preserved + assert {m.volume for m in amats} == {c1.volume, c2.volume} + + # Check loading results + r2s_loaded = R2SManager(model, [c1, c2]) + r2s_loaded.load_results(outdir) + assert len(r2s_loaded.results['fluxes']) == 2 + assert len(r2s_loaded.results['micros']) == 2 + assert len(r2s_loaded.results['activation_materials']) == 2 + assert len(r2s_loaded.results['depletion_results']) == 2 From fd964bc9b04dcf2090b212b8307551b32fea810b Mon Sep 17 00:00:00 2001 From: GuySten <62616591+GuySten@users.noreply.github.com> Date: Mon, 3 Nov 2025 09:42:59 +0200 Subject: [PATCH 10/26] Enable specifying reference direction for azimuthal angle in PolarAzimuthal distribution (#3582) Co-authored-by: shimwell --- include/openmc/distribution_multi.h | 2 ++ openmc/stats/multivariate.py | 25 +++++++++++++++- src/distribution_multi.cpp | 15 +++++++++- tests/regression_tests/source/inputs_true.dat | 2 +- .../regression_tests/source/results_true.dat | 2 +- tests/unit_tests/test_stats.py | 30 +++++++++++++++++++ 6 files changed, 72 insertions(+), 4 deletions(-) diff --git a/include/openmc/distribution_multi.h b/include/openmc/distribution_multi.h index 9e84d03d57..75126593f7 100644 --- a/include/openmc/distribution_multi.h +++ b/include/openmc/distribution_multi.h @@ -51,6 +51,8 @@ public: Distribution* phi() const { return phi_.get(); } private: + Direction v_ref_ {1.0, 0.0, 0.0}; //!< reference direction + Direction w_ref_; UPtrDist mu_; //!< Distribution of polar angle UPtrDist phi_; //!< Distribution of azimuthal angle }; diff --git a/openmc/stats/multivariate.py b/openmc/stats/multivariate.py index 222d2d18a5..1ce998758a 100644 --- a/openmc/stats/multivariate.py +++ b/openmc/stats/multivariate.py @@ -79,6 +79,9 @@ class PolarAzimuthal(UnitSphere): reference_uvw : Iterable of float Direction from which polar angle is measured. Defaults to the positive z-direction. + reference_vwu : Iterable of float + Direction from which azimuthal angle is measured. Defaults to the positive + x-direction. Attributes ---------- @@ -89,8 +92,9 @@ class PolarAzimuthal(UnitSphere): """ - def __init__(self, mu=None, phi=None, reference_uvw=(0., 0., 1.)): + def __init__(self, mu=None, phi=None, reference_uvw=(0., 0., 1.), reference_vwu=(1., 0., 0.)): super().__init__(reference_uvw) + self.reference_vwu = reference_vwu if mu is not None: self.mu = mu else: @@ -100,6 +104,20 @@ class PolarAzimuthal(UnitSphere): self.phi = phi else: self.phi = Uniform(0., 2*pi) + + @property + def reference_vwu(self): + return self._reference_vwu + + @reference_vwu.setter + def reference_vwu(self, vwu): + cv.check_type('reference v direction', vwu, Iterable, Real) + vwu = np.asarray(vwu) + uvw = self.reference_uvw + cv.check_greater_than('reference v direction must not be parallel to reference u direction', np.linalg.norm(np.cross(vwu,uvw)), 1e-6*np.linalg.norm(vwu)) + vwu -= vwu.dot(uvw)*uvw + cv.check_less_than('reference v direction must be orthogonal to reference u direction', np.abs(vwu.dot(uvw)), 1e-6) + self._reference_vwu = vwu/np.linalg.norm(vwu) @property def mu(self): @@ -132,6 +150,8 @@ class PolarAzimuthal(UnitSphere): element.set("type", "mu-phi") if self.reference_uvw is not None: element.set("reference_uvw", ' '.join(map(str, self.reference_uvw))) + if self.reference_vwu is not None: + element.set("reference_vwu", ' '.join(map(str, self.reference_vwu))) element.append(self.mu.to_xml_element('mu')) element.append(self.phi.to_xml_element('phi')) return element @@ -155,6 +175,9 @@ class PolarAzimuthal(UnitSphere): uvw = get_elem_list(elem, "reference_uvw", float) if uvw is not None: mu_phi.reference_uvw = uvw + vwu = get_elem_list(elem, "reference_vwu", float) + if vwu is not None: + mu_phi.reference_vwu = vwu mu_phi.mu = Univariate.from_xml_element(elem.find('mu')) mu_phi.phi = Univariate.from_xml_element(elem.find('phi')) return mu_phi diff --git a/src/distribution_multi.cpp b/src/distribution_multi.cpp index b7b3efe526..cdb33adc2a 100644 --- a/src/distribution_multi.cpp +++ b/src/distribution_multi.cpp @@ -58,6 +58,15 @@ PolarAzimuthal::PolarAzimuthal(Direction u, UPtrDist mu, UPtrDist phi) PolarAzimuthal::PolarAzimuthal(pugi::xml_node node) : UnitSphereDistribution {node} { + // Read reference directional unit vector + if (check_for_node(node, "reference_vwu")) { + auto v_ref = get_node_array(node, "reference_vwu"); + if (v_ref.size() != 3) + fatal_error("Angular distribution reference v direction must have " + "three parameters specified."); + v_ref_ = Direction(v_ref.data()); + } + w_ref_ = u_ref_.cross(v_ref_); if (check_for_node(node, "mu")) { pugi::xml_node node_dist = node.child("mu"); mu_ = distribution_from_xml(node_dist); @@ -79,11 +88,15 @@ Direction PolarAzimuthal::sample(uint64_t* seed) const double mu = mu_->sample(seed); if (mu == 1.0) return u_ref_; + if (mu == -1.0) + return -u_ref_; // Sample azimuthal angle double phi = phi_->sample(seed); - return rotate_angle(u_ref_, mu, &phi, seed); + double f = std::sqrt(1 - mu * mu); + + return mu * u_ref_ + f * std::cos(phi) * v_ref_ + f * std::sin(phi) * w_ref_; } //============================================================================== diff --git a/tests/regression_tests/source/inputs_true.dat b/tests/regression_tests/source/inputs_true.dat index 9f10b79d6b..0c3764ba6f 100644 --- a/tests/regression_tests/source/inputs_true.dat +++ b/tests/regression_tests/source/inputs_true.dat @@ -25,7 +25,7 @@ -2.0 0.0 2.0 0.2 0.3 0.2 - + -1.0 0.0 1.0 0.5 0.25 0.25 diff --git a/tests/regression_tests/source/results_true.dat b/tests/regression_tests/source/results_true.dat index 951075bbb9..7d03c696d3 100644 --- a/tests/regression_tests/source/results_true.dat +++ b/tests/regression_tests/source/results_true.dat @@ -1,2 +1,2 @@ k-combined: -3.034717E-01 2.799386E-03 +3.080655E-01 4.837707E-03 diff --git a/tests/unit_tests/test_stats.py b/tests/unit_tests/test_stats.py index abf143f12a..998d4b984c 100644 --- a/tests/unit_tests/test_stats.py +++ b/tests/unit_tests/test_stats.py @@ -516,3 +516,33 @@ def test_combine_distributions(): # uncertainty of the expected value samples = combined.sample(10_000) assert_sample_mean(samples, 0.25) + +def test_reference_vwu_projection(): + """When a non-orthogonal vector is provided, the setter should project out + any component along reference_uvw so the stored vector is orthogonal. + """ + pa = openmc.stats.PolarAzimuthal() # default reference_uvw == (0, 0, 1) + + # Provide a vector that is not orthogonal to (0,0,1) + pa.reference_vwu = (2.0, 0.5, 0.3) + + reference_v = np.asarray(pa.reference_vwu) + reference_u = np.asarray(pa.reference_uvw) + + # reference_v should be orthogonal to reference_u + assert abs(np.dot(reference_v, reference_u)) < 1e-6 + + +def test_reference_vwu_normalization(): + """When a non-normalized vector is provided, the setter should normalize + the projected vector to unit length. + """ + pa = openmc.stats.PolarAzimuthal() # default reference_uvw == (0, 0, 1) + + # Provide a vector that is neither orthogonal to (0,0,1) nor unit-length + pa.reference_vwu = (2.0, 0.5, 0.3) + + reference_v = np.asarray(pa.reference_vwu) + + # reference_v should be unit length + assert np.isclose(np.linalg.norm(reference_v), 1.0, atol=1e-12) From 2d8e006c3de9c8fbc0b010357ebf5a870909c8bc Mon Sep 17 00:00:00 2001 From: Perry <100789850+yrrepy@users.noreply.github.com> Date: Mon, 3 Nov 2025 09:10:30 -0800 Subject: [PATCH 11/26] Enable nuclide filters with get_decay_photon_energy (#3614) Co-authored-by: Paul Romano --- openmc/material.py | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/openmc/material.py b/openmc/material.py index c7b954b666..1609da05a3 100644 --- a/openmc/material.py +++ b/openmc/material.py @@ -290,11 +290,13 @@ class Material(IDManagerMixin): return self.get_decay_photon_energy(0.0) def get_decay_photon_energy( - self, - clip_tolerance: float = 1e-6, - units: str = 'Bq', - volume: float | None = None - ) -> Univariate | None: + self, + clip_tolerance: float = 1e-6, + units: str = 'Bq', + volume: float | None = None, + exclude_nuclides: list[str] | None = None, + include_nuclides: list[str] | None = None + ) -> Univariate | None: r"""Return energy distribution of decay photons from unstable nuclides. .. versionadded:: 0.14.0 @@ -302,22 +304,31 @@ class Material(IDManagerMixin): Parameters ---------- clip_tolerance : float - Maximum fraction of :math:`\sum_i x_i p_i` for discrete - distributions that will be discarded. + Maximum fraction of :math:`\sum_i x_i p_i` for discrete distributions + that will be discarded. units : {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3'} Specifies the units on the integral of the distribution. volume : float, optional Volume of the material. If not passed, defaults to using the :attr:`Material.volume` attribute. + exclude_nuclides : list of str, optional + Nuclides to exclude from the photon source calculation. + include_nuclides : list of str, optional + Nuclides to include in the photon source calculation. If specified, + only these nuclides are used. Returns ------- Univariate or None - Decay photon energy distribution. The integral of this distribution - is the total intensity of the photon source in the requested units. + Decay photon energy distribution. The integral of this distribution is + the total intensity of the photon source in the requested units. """ cv.check_value('units', units, {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3'}) + + if exclude_nuclides is not None and include_nuclides is not None: + raise ValueError("Cannot specify both exclude_nuclides and include_nuclides") + if units == 'Bq': multiplier = volume if volume is not None else self.volume if multiplier is None: @@ -332,6 +343,11 @@ class Material(IDManagerMixin): dists = [] probs = [] for nuc, atoms_per_bcm in self.get_nuclide_atom_densities().items(): + if exclude_nuclides is not None and nuc in exclude_nuclides: + continue + if include_nuclides is not None and nuc not in include_nuclides: + continue + source_per_atom = openmc.data.decay_photon_energy(nuc) if source_per_atom is not None and atoms_per_bcm > 0.0: dists.append(source_per_atom) From bd76fc056651e33d138d18dbee061c0a7bc83823 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Mon, 3 Nov 2025 11:11:57 -0600 Subject: [PATCH 12/26] Fix bug in normalization of tally results with no_reduce (#3619) --- src/tallies/tally.cpp | 7 +++++- tests/unit_tests/test_no_reduce.py | 40 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/test_no_reduce.py diff --git a/src/tallies/tally.cpp b/src/tallies/tally.cpp index b9c615ecb5..12ee3427eb 100644 --- a/src/tallies/tally.cpp +++ b/src/tallies/tally.cpp @@ -825,9 +825,14 @@ void Tally::accumulate() total_source = 1.0; } + // Determine number of particles contributing to tally + double contributing_particles = settings::reduce_tallies + ? settings::n_particles + : simulation::work_per_rank; + // Account for number of source particles in normalization double norm = - total_source / (settings::n_particles * settings::gen_per_batch); + total_source / (contributing_particles * settings::gen_per_batch); if (settings::solver_type == SolverType::RANDOM_RAY) { norm = 1.0; diff --git a/tests/unit_tests/test_no_reduce.py b/tests/unit_tests/test_no_reduce.py new file mode 100644 index 0000000000..00ddb5a959 --- /dev/null +++ b/tests/unit_tests/test_no_reduce.py @@ -0,0 +1,40 @@ +"""Test the settings.no_reduce feature to ensure tallies are correctly +reduced across MPI processes.""" + +import openmc +import pytest + +from tests.testing_harness import config + + +@pytest.mark.parametrize('no_reduce', [True, False]) +def test_no_reduce(no_reduce, run_in_tmpdir): + """Test that tally results are correct with and without no_reduce.""" + + # Create simple sphere model with vacuum + model = openmc.Model() + sphere = openmc.Sphere(r=1.0, boundary_type='vacuum') + cell = openmc.Cell(region=-sphere) + model.geometry = openmc.Geometry([cell]) + model.settings.run_mode = 'fixed source' + model.settings.batches = 10 + model.settings.particles = 100 + model.settings.source = openmc.IndependentSource(space=openmc.stats.Point()) + model.settings.no_reduce = no_reduce + + # Tally: surface current on vacuum boundary + surf_filter = openmc.SurfaceFilter(sphere) + tally = openmc.Tally() + tally.filters = [surf_filter] + tally.scores = ['current'] + model.tallies = [tally] + + # Run OpenMC with proper MPI arguments if needed + kwargs = {'apply_tally_results': True, 'openmc_exec': config['exe']} + if config['mpi']: + kwargs['mpi_args'] = [config['mpiexec'], '-n', config['mpi_np']] + model.run(**kwargs) + + # The tally should be ~1.0 (every particle crosses the surface once) + tally_mean = tally.mean.flatten()[0] + assert tally_mean == pytest.approx(1.0) From e5c7d0ca88983cdd37bb9870cd1fccc8e4301736 Mon Sep 17 00:00:00 2001 From: Jon Shimwell Date: Wed, 5 Nov 2025 17:03:20 +0100 Subject: [PATCH 13/26] Adding vtkhdf option to write vtk data (#3252) Co-authored-by: shimwell Co-authored-by: Jonathan Shimwell Co-authored-by: rherrero-pf <156206440+rherrero-pf@users.noreply.github.com> Co-authored-by: Patrick Shriwise Co-authored-by: Paul Romano --- openmc/mesh.py | 166 ++++++++++++++++-- .../test_mesh_dagmc_tets.vtk | 159 +++++++++++++++++ tests/unit_tests/test_mesh.py | 69 ++++++++ tests/unit_tests/test_mesh_dagmc_tets.vtk | 1 + 4 files changed, 377 insertions(+), 18 deletions(-) create mode 100644 tests/regression_tests/unstructured_mesh/test_mesh_dagmc_tets.vtk create mode 120000 tests/unit_tests/test_mesh_dagmc_tets.vtk diff --git a/openmc/mesh.py b/openmc/mesh.py index 3d93d87a1b..ce5218b5e9 100644 --- a/openmc/mesh.py +++ b/openmc/mesh.py @@ -5,6 +5,7 @@ from collections.abc import Iterable, Sequence, Mapping from functools import wraps from math import pi, sqrt, atan2 from numbers import Integral, Real +from pathlib import Path from typing import Protocol import h5py @@ -2443,6 +2444,7 @@ class UnstructuredMesh(MeshBase): _UNSUPPORTED_ELEM = -1 _LINEAR_TET = 0 _LINEAR_HEX = 1 + _VTK_TETRA = 10 def __init__(self, filename: PathLike, library: str, mesh_id: int | None = None, name: str = '', length_multiplier: float = 1.0, @@ -2652,7 +2654,8 @@ class UnstructuredMesh(MeshBase): warnings.warn( "The 'UnstructuredMesh.write_vtk_mesh' method has been renamed " "to 'write_data_to_vtk' and will be removed in a future version " - " of OpenMC.", FutureWarning + " of OpenMC.", + FutureWarning, ) self.write_data_to_vtk(**kwargs) @@ -2670,9 +2673,10 @@ class UnstructuredMesh(MeshBase): Parameters ---------- filename : str or pathlib.Path - Name of the VTK file to write. If the filename ends in '.vtu' then a - binary VTU format file will be written, if the filename ends in - '.vtk' then a legacy VTK file will be written. + Name of the VTK file to write. If the filename ends in '.vtkhdf' + then a VTKHDF format file will be written. If the filename ends in + '.vtu' then a binary VTU format file will be written. If the + filename ends in '.vtk' then a legacy VTK file will be written. datasets : dict Dictionary whose keys are the data labels and values are numpy appropriately sized arrays of the data @@ -2680,6 +2684,35 @@ class UnstructuredMesh(MeshBase): Whether or not to normalize the data by the volume of the mesh elements """ + + if Path(filename).suffix == ".vtkhdf": + + self._write_data_to_vtk_hdf5_format( + filename=filename, + datasets=datasets, + volume_normalization=volume_normalization, + ) + + elif Path(filename).suffix == ".vtk" or Path(filename).suffix == ".vtu": + + self._write_data_to_vtk_ascii_format( + filename=filename, + datasets=datasets, + volume_normalization=volume_normalization, + ) + + else: + raise ValueError( + "Unsupported file extension, The filename must end with " + "'.vtkhdf', '.vtu' or '.vtk'" + ) + + def _write_data_to_vtk_ascii_format( + self, + filename: PathLike | None = None, + datasets: dict | None = None, + volume_normalization: bool = True, + ): from vtkmodules.util import numpy_support from vtkmodules import vtkCommonCore from vtkmodules import vtkCommonDataModel @@ -2687,9 +2720,7 @@ class UnstructuredMesh(MeshBase): from vtkmodules import vtkIOXML if self.connectivity is None or self.vertices is None: - raise RuntimeError( - "This mesh has not been loaded from a statepoint file." - ) + raise RuntimeError("This mesh has not been loaded from a statepoint file.") if filename is None: filename = f"mesh_{self.id}.vtk" @@ -2771,29 +2802,128 @@ class UnstructuredMesh(MeshBase): writer.Write() + def _write_data_to_vtk_hdf5_format( + self, + filename: PathLike | None = None, + datasets: dict | None = None, + volume_normalization: bool = True, + ): + def append_dataset(dset, array): + """Convenience function to append data to an HDF5 dataset""" + origLen = dset.shape[0] + dset.resize(origLen + array.shape[0], axis=0) + dset[origLen:] = array + + if self.library != "moab": + raise NotImplementedError("VTKHDF output is only supported for MOAB meshes") + + # the self.connectivity contains arrays of length 8 to support hex + # elements as well, in the case of tetrahedra mesh elements, the + # last 4 values are -1 and are removed + trimmed_connectivity = [] + for cell in self.connectivity: + # Find the index of the first -1 value, if any + first_negative_index = np.where(cell == -1)[0] + if first_negative_index.size > 0: + # Slice the array up to the first -1 value + trimmed_connectivity.append(cell[: first_negative_index[0]]) + else: + # No -1 values, append the whole cell + trimmed_connectivity.append(cell) + trimmed_connectivity = np.array(trimmed_connectivity, dtype="int32").flatten() + + # MOAB meshes supports tet elements only so we know it has 4 points per cell + points_per_cell = 4 + + # offsets are the indices of the first point of each cell in the array of points + offsets = np.arange(0, self.n_elements * points_per_cell + 1, points_per_cell) + + for name, data in datasets.items(): + if data.shape != self.dimension: + raise ValueError( + f'Cannot apply dataset "{name}" with ' + f"shape {data.shape} to mesh {self.id} " + f"with dimensions {self.dimension}" + ) + + with h5py.File(filename, "w") as f: + + root = f.create_group("VTKHDF") + vtk_file_format_version = (2, 1) + root.attrs["Version"] = vtk_file_format_version + ascii_type = "UnstructuredGrid".encode("ascii") + root.attrs.create( + "Type", + ascii_type, + dtype=h5py.string_dtype("ascii", len(ascii_type)), + ) + + # create hdf5 file structure + root.create_dataset("NumberOfPoints", (0,), maxshape=(None,), dtype="i8") + root.create_dataset("Types", (0,), maxshape=(None,), dtype="uint8") + root.create_dataset("Points", (0, 3), maxshape=(None, 3), dtype="f") + root.create_dataset( + "NumberOfConnectivityIds", (0,), maxshape=(None,), dtype="i8" + ) + root.create_dataset("NumberOfCells", (0,), maxshape=(None,), dtype="i8") + root.create_dataset("Offsets", (0,), maxshape=(None,), dtype="i8") + root.create_dataset("Connectivity", (0,), maxshape=(None,), dtype="i8") + + append_dataset(root["NumberOfPoints"], np.array([len(self.vertices)])) + append_dataset(root["Points"], self.vertices) + append_dataset( + root["NumberOfConnectivityIds"], + np.array([len(trimmed_connectivity)]), + ) + append_dataset(root["Connectivity"], trimmed_connectivity) + append_dataset(root["NumberOfCells"], np.array([self.n_elements])) + append_dataset(root["Offsets"], offsets) + + append_dataset( + root["Types"], np.full(self.n_elements, self._VTK_TETRA, dtype="uint8") + ) + + cell_data_group = root.create_group("CellData") + + for name, data in datasets.items(): + + cell_data_group.create_dataset( + name, (0,), maxshape=(None,), dtype="float64", chunks=True + ) + + if volume_normalization: + data /= self.volumes + append_dataset(cell_data_group[name], data) + @classmethod def from_hdf5(cls, group: h5py.Group, mesh_id: int, name: str): - filename = group['filename'][()].decode() - library = group['library'][()].decode() - if 'options' in group.attrs: + filename = group["filename"][()].decode() + library = group["library"][()].decode() + if "options" in group.attrs: options = group.attrs['options'].decode() else: options = None - mesh = cls(filename=filename, library=library, mesh_id=mesh_id, name=name, options=options) + mesh = cls( + filename=filename, + library=library, + mesh_id=mesh_id, + name=name, + options=options, + ) mesh._has_statepoint_data = True - vol_data = group['volumes'][()] + vol_data = group["volumes"][()] mesh.volumes = np.reshape(vol_data, (vol_data.shape[0],)) mesh.n_elements = mesh.volumes.size - vertices = group['vertices'][()] + vertices = group["vertices"][()] mesh._vertices = vertices.reshape((-1, 3)) - connectivity = group['connectivity'][()] + connectivity = group["connectivity"][()] mesh._connectivity = connectivity.reshape((-1, 8)) - mesh._element_types = group['element_types'][()] + mesh._element_types = group["element_types"][()] - if 'length_multiplier' in group: - mesh.length_multiplier = group['length_multiplier'][()] + if "length_multiplier" in group: + mesh.length_multiplier = group["length_multiplier"][()] return mesh @@ -2812,7 +2942,7 @@ class UnstructuredMesh(MeshBase): element.set("library", self._library) if self.options is not None: - element.set('options', self.options) + element.set("options", self.options) subelement = ET.SubElement(element, "filename") subelement.text = str(self.filename) diff --git a/tests/regression_tests/unstructured_mesh/test_mesh_dagmc_tets.vtk b/tests/regression_tests/unstructured_mesh/test_mesh_dagmc_tets.vtk new file mode 100644 index 0000000000..2ddd228cf1 --- /dev/null +++ b/tests/regression_tests/unstructured_mesh/test_mesh_dagmc_tets.vtk @@ -0,0 +1,159 @@ +# vtk DataFile Version 2.0 +made_with_cad_to_dagmc_package, Created by Gmsh 4.12.1 +ASCII +DATASET UNSTRUCTURED_GRID +POINTS 14 double +-0.5 -0.5 0.5 +-0.5 -0.5 -0.5 +-0.5 0.5 0.5 +-0.5 0.5 -0.5 +0.5 -0.5 0.5 +0.5 -0.5 -0.5 +0.5 0.5 0.5 +0.5 0.5 -0.5 +-0.5 0 0 +0.5 0 0 +0 -0.5 0 +0 0.5 0 +0 0 -0.5 +0 0 0.5 + +CELLS 68 268 +1 0 +1 1 +1 2 +1 3 +1 4 +1 5 +1 6 +1 7 +2 1 0 +2 0 2 +2 3 2 +2 1 3 +2 5 4 +2 4 6 +2 7 6 +2 5 7 +2 1 5 +2 0 4 +2 3 7 +2 2 6 +3 1 0 8 +3 0 2 8 +3 3 1 8 +3 2 3 8 +3 5 9 4 +3 4 9 6 +3 7 9 5 +3 6 9 7 +3 0 1 10 +3 4 0 10 +3 1 5 10 +3 5 4 10 +3 2 11 3 +3 6 11 2 +3 3 11 7 +3 7 11 6 +3 1 3 12 +3 5 1 12 +3 3 7 12 +3 7 5 12 +3 0 13 2 +3 4 13 0 +3 2 13 6 +3 6 13 4 +4 13 8 12 10 +4 11 8 12 13 +4 12 11 13 9 +4 10 12 13 9 +4 12 3 11 7 +4 13 2 8 0 +4 8 12 1 3 +4 11 3 8 2 +4 10 8 1 0 +4 0 10 13 4 +4 1 12 10 5 +4 11 2 13 6 +4 4 9 13 6 +4 6 9 11 7 +4 10 9 4 5 +4 7 9 12 5 +4 3 8 12 11 +4 1 12 8 10 +4 8 11 2 13 +4 10 13 8 0 +4 13 10 9 4 +4 11 13 9 6 +4 12 11 9 7 +4 9 10 12 5 + +CELL_TYPES 68 +1 +1 +1 +1 +1 +1 +1 +1 +3 +3 +3 +3 +3 +3 +3 +3 +3 +3 +3 +3 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +5 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 +10 diff --git a/tests/unit_tests/test_mesh.py b/tests/unit_tests/test_mesh.py index f0f289408a..9aca8b5965 100644 --- a/tests/unit_tests/test_mesh.py +++ b/tests/unit_tests/test_mesh.py @@ -2,6 +2,7 @@ from math import pi from tempfile import TemporaryDirectory from pathlib import Path +import h5py import numpy as np from scipy.stats import chi2 import pytest @@ -486,6 +487,74 @@ def test_umesh(run_in_tmpdir, simple_umesh, export_type): with pytest.raises(ValueError, match='Cannot apply dataset "mean"'): simple_umesh.write_data_to_vtk(datasets={'mean': ref_data[:-2]}, filename=filename) + +@pytest.mark.skipif(not openmc.lib._dagmc_enabled(), reason="DAGMC not enabled.") +def test_write_vtkhdf(request, run_in_tmpdir): + """Performs a minimal UnstructuredMesh simulation, reads in the resulting + statepoint file and writes the mesh data to vtk and vtkhdf files. It is + necessary to read in the unstructured mesh from a statepoint file to ensure + it has all the required attributes + """ + model = openmc.Model() + + surf1 = openmc.Sphere(r=1000.0, boundary_type="vacuum") + cell1 = openmc.Cell(region=-surf1) + model.geometry = openmc.Geometry([cell1]) + + umesh = openmc.UnstructuredMesh( + request.path.parent / "test_mesh_dagmc_tets.vtk", + "moab", + mesh_id = 1 + ) + mesh_filter = openmc.MeshFilter(umesh) + + # Create flux mesh tally to score alpha production + mesh_tally = openmc.Tally(name="test_tally") + mesh_tally.filters = [mesh_filter] + mesh_tally.scores = ["flux"] + + model.tallies = [mesh_tally] + + model.settings.run_mode = "fixed source" + model.settings.batches = 2 + model.settings.particles = 10 + + statepoint_file = model.run() + + with openmc.StatePoint(statepoint_file) as statepoint: + my_tally = statepoint.get_tally(name="test_tally") + + umesh_from_sp = statepoint.meshes[umesh.id] + + datasets={ + "mean": my_tally.mean.flatten(), + "std_dev": my_tally.std_dev.flatten() + } + + umesh_from_sp.write_data_to_vtk(datasets=datasets, filename="test_mesh.vtkhdf") + umesh_from_sp.write_data_to_vtk(datasets=datasets, filename="test_mesh.vtk") + + with pytest.raises(ValueError, match="Unsupported file extension"): + # Supported file extensions are vtk or vtkhdf, not hdf5, so this should raise an error + umesh_from_sp.write_data_to_vtk( + datasets=datasets, + filename="test_mesh.hdf5", + ) + with pytest.raises(ValueError, match="Cannot apply dataset"): + # The shape of the data should match the shape of the mesh, so this should raise an error + umesh_from_sp.write_data_to_vtk( + datasets={'incorrectly_shaped_data': np.array(([1,2,3]))}, + filename="test_mesh_incorrect_shape.vtkhdf", + ) + + assert Path("test_mesh.vtk").exists() + assert Path("test_mesh.vtkhdf").exists() + + # just ensure we can open the file without error + with h5py.File("test_mesh.vtkhdf", "r"): + ... + + def test_mesh_get_homogenized_materials(): """Test the get_homogenized_materials method""" # Simple model with 1 cm of Fe56 next to 1 cm of H1 diff --git a/tests/unit_tests/test_mesh_dagmc_tets.vtk b/tests/unit_tests/test_mesh_dagmc_tets.vtk new file mode 120000 index 0000000000..9f7000175d --- /dev/null +++ b/tests/unit_tests/test_mesh_dagmc_tets.vtk @@ -0,0 +1 @@ +../regression_tests/unstructured_mesh/test_mesh_dagmc_tets.vtk \ No newline at end of file From c0f302db6845e2d303625317430b6ee26ff2cec7 Mon Sep 17 00:00:00 2001 From: Perry <100789850+yrrepy@users.noreply.github.com> Date: Fri, 7 Nov 2025 08:58:40 -0800 Subject: [PATCH 14/26] Add energy group structure: SCALE-999 (#3564) --- openmc/mgxs/__init__.py | 208 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 205 insertions(+), 3 deletions(-) diff --git a/openmc/mgxs/__init__.py b/openmc/mgxs/__init__.py index 5de85afb0e..682b5d5507 100644 --- a/openmc/mgxs/__init__.py +++ b/openmc/mgxs/__init__.py @@ -13,8 +13,8 @@ GROUP_STRUCTURES = {} - "XMAS-172_" designed for LWR analysis ([SAR1990]_, [SAN2004]_) - "SHEM-361_" designed for LWR analysis to eliminate self-shielding calculations of thermal resonances ([HFA2005]_, [SAN2007]_, [HEB2008]_) -- "SCALE-X" (where X is 44 which is designed for criticality analysis - and 252 is designed for thermal reactors) for the SCALE code suite +- "SCALE-X" (where X is 44 which is designed for criticality analysis, 252 is designed + for thermal reactors and 999 for multipurpose activation) for the SCALE code suite ([ZAL1999]_ and [REARDEN2013]_) - "MPACT-X" (where X is 51 (PWR), 60 (BWR), 69 (Magnox)) from the MPACT_ reactor physics code ([KIM2019]_ and [KIM2020]_) @@ -29,6 +29,7 @@ GROUP_STRUCTURES = {} .. _SCALE44: https://www-nds.iaea.org/publications/indc/indc-czr-0001.pdf .. _ECCO-33: https://serpent.vtt.fi/mediawiki/index.php/ECCO_33-group_structure .. _SCALE252: https://oecd-nea.org/science/wpncs/amct/workingarea/meeting2013/EGAMCT2013_08.pdf +.. _SCALE999: https://info.ornl.gov/sites/publications/Files/Pub67728.pdf, https://www.nrc.gov/docs/ML1218/ML12184A002.pdf .. _MPACT: https://vera.ornl.gov/mpact/ .. _XMAS-172: https://www-nds.iaea.org/wimsd/energy.htm .. _SHEM-361: http://merlin.polymtl.ca/downloads/FP214.pdf @@ -593,7 +594,208 @@ GROUP_STRUCTURES['CCFE-709'] = np.array([ 2.4000e8, 2.8000e8, 3.2000e8, 3.6000e8, 4.0000e8, 4.4000e8, 4.8000e8, 5.2000e8, 5.6000e8, 6.0000e8, 6.4000e8, 6.8000e8, 7.2000e8, 7.6000e8, 8.0000e8, - 8.4000e8, 8.8000e8, 9.2000e8, 9.6000e8, 1.0000e9,]) + 8.4000e8, 8.8000e8, 9.2000e8, 9.6000e8, 1.0000e9]) +GROUP_STRUCTURES['SCALE-999'] = np.array([ + 1.000e-5, 1.000e-4, 5.000e-4, 7.500e-4, 1.000e-3, + 1.200e-3, 1.500e-3, 2.000e-3, 2.500e-3, 3.000e-3, + 4.000e-3, 5.000e-3, 7.500e-3, 1.000e-2, 1.450e-2, + 1.850e-2, 2.100e-2, 2.530e-2, 3.000e-2, 4.000e-2, + 5.000e-2, 6.000e-2, 7.000e-2, 8.000e-2, 9.000e-2, + 1.000e-1, 1.250e-1, 1.500e-1, 1.750e-1, 1.840e-1, + 2.000e-1, 2.250e-1, 2.500e-1, 2.750e-1, 3.000e-1, + 3.250e-1, 3.500e-1, 3.668e-1, 3.750e-1, 4.000e-1, + 4.140e-1, 4.500e-1, 5.000e-1, 5.316e-1, 5.500e-1, + 6.000e-1, 6.250e-1, 6.500e-1, 6.826e-1, 7.000e-1, + 7.500e-1, 8.000e-1, 8.500e-1, 8.764e-1, 9.000e-1, + 9.250e-1, 9.500e-1, 9.750e-1, 1.000e+0, 1.010e+0, + 1.020e+0, 1.030e+0, 1.040e+0, 1.050e+0, 1.060e+0, + 1.070e+0, 1.080e+0, 1.090e+0, 1.100e+0, 1.110e+0, + 1.120e+0, 1.130e+0, 1.140e+0, 1.150e+0, 1.175e+0, + 1.200e+0, 1.225e+0, 1.250e+0, 1.300e+0, 1.350e+0, + 1.400e+0, 1.450e+0, 1.500e+0, 1.545e+0, 1.590e+0, + 1.635e+0, 1.680e+0, 1.725e+0, 1.770e+0, 1.815e+0, + 1.860e+0, 1.900e+0, 1.940e+0, 1.970e+0, 2.000e+0, + 2.060e+0, 2.120e+0, 2.165e+0, 2.210e+0, 2.255e+0, + 2.300e+0, 2.340e+0, 2.380e+0, 2.425e+0, 2.470e+0, + 2.520e+0, 2.570e+0, 2.620e+0, 2.670e+0, 2.720e+0, + 2.770e+0, 2.820e+0, 2.870e+0, 2.920e+0, 2.970e+0, + 3.000e+0, 3.100e+0, 3.200e+0, 3.300e+0, 3.500e+0, + 3.620e+0, 3.730e+0, 3.830e+0, 3.928e+0, 4.000e+0, + 4.100e+0, 4.300e+0, 4.500e+0, 4.750e+0, 4.875e+0, + 5.000e+0, 5.044e+0, 5.250e+0, 5.400e+0, 5.550e+0, + 5.700e+0, 5.850e+0, 6.000e+0, 6.250e+0, 6.375e+0, + 6.500e+0, 6.625e+0, 6.750e+0, 6.875e+0, 7.000e+0, + 7.075e+0, 7.150e+0, 7.625e+0, 8.100e+0, 8.208e+0, + 8.315e+0, 8.708e+0, 9.100e+0, 9.550e+0, 1.000e+1, + 1.034e+1, 1.068e+1, 1.109e+1, 1.150e+1, 1.170e+1, + 1.190e+1, 1.240e+1, 1.290e+1, 1.333e+1, 1.375e+1, + 1.408e+1, 1.440e+1, 1.475e+1, 1.510e+1, 1.555e+1, + 1.600e+1, 1.650e+1, 1.700e+1, 1.730e+1, 1.760e+1, + 1.805e+1, 1.850e+1, 1.875e+1, 1.900e+1, 1.940e+1, + 2.000e+1, 2.050e+1, 2.100e+1, 2.175e+1, 2.250e+1, + 2.375e+1, 2.500e+1, 2.625e+1, 2.750e+1, 2.826e+1, + 2.902e+1, 2.951e+1, 3.000e+1, 3.063e+1, 3.125e+1, + 3.150e+1, 3.175e+1, 3.250e+1, 3.325e+1, 3.350e+1, + 3.375e+1, 3.418e+1, 3.460e+1, 3.500e+1, 3.550e+1, + 3.600e+1, 3.700e+1, 3.713e+1, 3.727e+1, 3.763e+1, + 3.800e+1, 3.855e+1, 3.910e+1, 3.935e+1, 3.960e+1, + 4.030e+1, 4.100e+1, 4.170e+1, 4.240e+1, 4.320e+1, + 4.400e+1, 4.460e+1, 4.520e+1, 4.610e+1, 4.700e+1, + 4.743e+1, 4.785e+1, 4.808e+1, 4.830e+1, 4.875e+1, + 4.920e+1, 4.990e+1, 5.060e+1, 5.130e+1, 5.200e+1, + 5.270e+1, 5.340e+1, 5.620e+1, 5.800e+1, 6.000e+1, + 6.100e+1, 6.122e+1, 6.144e+1, 6.300e+1, 6.500e+1, + 6.625e+1, 6.750e+1, 6.975e+1, 7.200e+1, 7.400e+1, + 7.600e+1, 7.745e+1, 7.889e+1, 7.945e+1, 8.000e+1, + 8.170e+1, 8.200e+1, 8.400e+1, 8.600e+1, 8.800e+1, + 9.000e+1, 9.250e+1, 9.500e+1, 9.700e+1, 1.000e+2, + 1.012e+2, 1.038e+2, 1.050e+2, 1.080e+2, 1.105e+2, + 1.130e+2, 1.160e+2, 1.175e+2, 1.190e+2, 1.205e+2, + 1.220e+2, 1.240e+2, 1.260e+2, 1.280e+2, 1.301e+2, + 1.325e+2, 1.350e+2, 1.375e+2, 1.400e+2, 1.430e+2, + 1.450e+2, 1.475e+2, 1.500e+2, 1.525e+2, 1.550e+2, + 1.575e+2, 1.600e+2, 1.625e+2, 1.650e+2, 1.670e+2, + 1.700e+2, 1.716e+2, 1.739e+2, 1.763e+2, 1.786e+2, + 1.800e+2, 1.877e+2, 1.885e+2, 1.915e+2, 1.930e+2, + 1.962e+2, 1.999e+2, 2.020e+2, 2.074e+2, 2.088e+2, + 2.095e+2, 2.122e+2, 2.145e+2, 2.175e+2, 2.200e+2, + 2.237e+2, 2.269e+2, 2.301e+2, 2.333e+2, 2.367e+2, + 2.400e+2, 2.442e+2, 2.484e+2, 2.527e+2, 2.571e+2, + 2.615e+2, 2.661e+2, 2.707e+2, 2.754e+2, 2.801e+2, + 2.850e+2, 2.899e+2, 2.948e+2, 2.999e+2, 3.050e+2, + 3.107e+2, 3.165e+2, 3.224e+2, 3.284e+2, 3.345e+2, + 3.408e+2, 3.471e+2, 3.536e+2, 3.591e+2, 3.648e+2, + 3.705e+2, 3.764e+2, 3.823e+2, 3.883e+2, 3.944e+2, + 4.007e+2, 4.070e+2, 4.134e+2, 4.199e+2, 4.265e+2, + 4.332e+2, 4.400e+2, 4.470e+2, 4.540e+2, 4.595e+2, + 4.650e+2, 4.706e+2, 4.763e+2, 4.821e+2, 4.879e+2, + 4.937e+2, 4.997e+2, 5.057e+2, 5.118e+2, 5.180e+2, + 5.243e+2, 5.306e+2, 5.370e+2, 5.435e+2, 5.500e+2, + 5.581e+2, 5.662e+2, 5.745e+2, 5.830e+2, 5.932e+2, + 6.036e+2, 6.142e+2, 6.250e+2, 6.359e+2, 6.471e+2, + 6.585e+2, 6.700e+2, 6.765e+2, 6.830e+2, 6.909e+2, + 6.988e+2, 7.069e+2, 7.150e+2, 7.232e+2, 7.316e+2, + 7.400e+2, 7.485e+2, 7.598e+2, 7.712e+2, 7.827e+2, + 7.945e+2, 8.064e+2, 8.185e+2, 8.308e+2, 8.433e+2, + 8.559e+2, 8.688e+2, 8.818e+2, 8.950e+2, 9.085e+2, + 9.221e+2, 9.360e+2, 9.500e+2, 9.555e+2, 9.611e+2, + 9.720e+2, 9.829e+2, 9.940e+2, 1.005e+3, 1.017e+3, + 1.028e+3, 1.040e+3, 1.051e+3, 1.063e+3, 1.075e+3, + 1.087e+3, 1.100e+3, 1.112e+3, 1.125e+3, 1.137e+3, + 1.150e+3, 1.171e+3, 1.191e+3, 1.213e+3, 1.234e+3, + 1.249e+3, 1.265e+3, 1.280e+3, 1.296e+3, 1.312e+3, + 1.328e+3, 1.344e+3, 1.361e+3, 1.377e+3, 1.394e+3, + 1.411e+3, 1.429e+3, 1.446e+3, 1.464e+3, 1.482e+3, + 1.500e+3, 1.525e+3, 1.550e+3, 1.585e+3, 1.610e+3, + 1.636e+3, 1.662e+3, 1.689e+3, 1.716e+3, 1.744e+3, + 1.772e+3, 1.800e+3, 1.828e+3, 1.856e+3, 1.885e+3, + 1.914e+3, 1.943e+3, 1.973e+3, 2.004e+3, 2.035e+3, + 2.075e+3, 2.116e+3, 2.158e+3, 2.200e+3, 2.250e+3, + 2.290e+3, 2.337e+3, 2.386e+3, 2.435e+3, 2.500e+3, + 2.532e+3, 2.580e+3, 2.613e+3, 2.679e+3, 2.747e+3, + 2.808e+3, 2.871e+3, 2.935e+3, 3.000e+3, 3.035e+3, + 3.112e+3, 3.191e+3, 3.272e+3, 3.355e+3, 3.440e+3, + 3.527e+3, 3.616e+3, 3.707e+3, 3.740e+3, 3.819e+3, + 3.900e+3, 3.998e+3, 4.099e+3, 4.202e+3, 4.307e+3, + 4.400e+3, 4.500e+3, 4.620e+3, 4.740e+3, 4.850e+3, + 4.960e+3, 5.100e+3, 5.250e+3, 5.400e+3, 5.531e+3, + 5.645e+3, 5.700e+3, 5.879e+3, 6.000e+3, 6.128e+3, + 6.258e+3, 6.392e+3, 6.528e+3, 6.667e+3, 6.809e+3, + 6.954e+3, 7.102e+3, 7.212e+3, 7.323e+3, 7.437e+3, + 7.552e+3, 7.669e+3, 7.787e+3, 7.908e+3, 8.030e+3, + 8.159e+3, 8.289e+3, 8.422e+3, 8.557e+3, 8.694e+3, + 8.834e+3, 8.975e+3, 9.119e+3, 9.307e+3, 9.500e+3, + 9.763e+3, 1.003e+4, 1.031e+4, 1.060e+4, 1.086e+4, + 1.114e+4, 1.142e+4, 1.171e+4, 1.202e+4, 1.234e+4, + 1.266e+4, 1.300e+4, 1.324e+4, 1.348e+4, 1.373e+4, + 1.398e+4, 1.424e+4, 1.450e+4, 1.476e+4, 1.503e+4, + 1.527e+4, 1.550e+4, 1.574e+4, 1.599e+4, 1.623e+4, + 1.649e+4, 1.674e+4, 1.700e+4, 1.727e+4, 1.755e+4, + 1.783e+4, 1.812e+4, 1.841e+4, 1.870e+4, 1.900e+4, + 1.931e+4, 1.965e+4, 2.000e+4, 2.045e+4, 2.092e+4, + 2.139e+4, 2.188e+4, 2.229e+4, 2.271e+4, 2.314e+4, + 2.358e+4, 2.388e+4, 2.418e+4, 2.450e+4, 2.479e+4, + 2.500e+4, 2.520e+4, 2.552e+4, 2.580e+4, 2.606e+4, + 2.653e+4, 2.700e+4, 2.737e+4, 2.774e+4, 2.812e+4, + 2.850e+4, 2.887e+4, 2.924e+4, 2.962e+4, 3.000e+4, + 3.045e+4, 3.090e+4, 3.136e+4, 3.183e+4, 3.243e+4, + 3.304e+4, 3.367e+4, 3.431e+4, 3.468e+4, 3.507e+4, + 3.545e+4, 3.584e+4, 3.624e+4, 3.663e+4, 3.704e+4, + 3.744e+4, 3.786e+4, 3.827e+4, 3.869e+4, 3.912e+4, + 3.955e+4, 3.998e+4, 4.042e+4, 4.087e+4, 4.136e+4, + 4.186e+4, 4.237e+4, 4.288e+4, 4.340e+4, 4.393e+4, + 4.446e+4, 4.500e+4, 4.565e+4, 4.631e+4, 4.721e+4, + 4.812e+4, 4.905e+4, 5.000e+4, 5.099e+4, 5.200e+4, + 5.248e+4, 5.347e+4, 5.448e+4, 5.551e+4, 5.656e+4, + 5.740e+4, 5.826e+4, 5.912e+4, 6.000e+4, 6.088e+4, + 6.177e+4, 6.267e+4, 6.358e+4, 6.451e+4, 6.545e+4, + 6.641e+4, 6.738e+4, 6.851e+4, 6.965e+4, 7.081e+4, + 7.200e+4, 7.300e+4, 7.399e+4, 7.500e+4, 7.610e+4, + 7.722e+4, 7.835e+4, 7.950e+4, 8.074e+4, 8.200e+4, + 8.250e+4, 8.374e+4, 8.500e+4, 8.652e+4, 8.788e+4, + 8.926e+4, 9.067e+4, 9.210e+4, 9.355e+4, 9.502e+4, + 9.652e+4, 9.804e+4, 1.000e+5, 1.013e+5, 1.027e+5, + 1.040e+5, 1.054e+5, 1.068e+5, 1.082e+5, 1.096e+5, + 1.111e+5, 1.125e+5, 1.139e+5, 1.153e+5, 1.168e+5, + 1.183e+5, 1.197e+5, 1.213e+5, 1.228e+5, 1.241e+5, + 1.255e+5, 1.269e+5, 1.283e+5, 1.291e+5, 1.307e+5, + 1.323e+5, 1.340e+5, 1.357e+5, 1.374e+5, 1.391e+5, + 1.409e+5, 1.426e+5, 1.445e+5, 1.463e+5, 1.481e+5, + 1.490e+5, 1.519e+5, 1.538e+5, 1.557e+5, 1.576e+5, + 1.596e+5, 1.616e+5, 1.637e+5, 1.657e+5, 1.678e+5, + 1.699e+5, 1.721e+5, 1.742e+5, 1.764e+5, 1.786e+5, + 1.809e+5, 1.832e+5, 1.855e+5, 1.878e+5, 1.902e+5, + 1.926e+5, 1.962e+5, 2.000e+5, 2.024e+5, 2.050e+5, + 2.076e+5, 2.102e+5, 2.128e+5, 2.155e+5, 2.182e+5, + 2.209e+5, 2.237e+5, 2.265e+5, 2.294e+5, 2.323e+5, + 2.352e+5, 2.381e+5, 2.411e+5, 2.442e+5, 2.472e+5, + 2.500e+5, 2.527e+5, 2.555e+5, 2.584e+5, 2.612e+5, + 2.641e+5, 2.670e+5, 2.700e+5, 2.732e+5, 2.767e+5, + 2.802e+5, 2.837e+5, 2.873e+5, 2.909e+5, 2.945e+5, + 2.972e+5, 2.985e+5, 3.020e+5, 3.053e+5, 3.088e+5, + 3.122e+5, 3.157e+5, 3.192e+5, 3.228e+5, 3.264e+5, + 3.300e+5, 3.337e+5, 3.379e+5, 3.422e+5, 3.465e+5, + 3.508e+5, 3.553e+5, 3.597e+5, 3.643e+5, 3.688e+5, + 3.735e+5, 3.782e+5, 3.829e+5, 3.877e+5, 3.938e+5, + 4.000e+5, 4.076e+5, 4.138e+5, 4.200e+5, 4.249e+5, + 4.299e+5, 4.349e+5, 4.400e+5, 4.452e+5, 4.505e+5, + 4.553e+5, 4.601e+5, 4.650e+5, 4.700e+5, 4.772e+5, + 4.845e+5, 4.920e+5, 4.995e+5, 5.054e+5, 5.113e+5, + 5.173e+5, 5.234e+5, 5.299e+5, 5.365e+5, 5.432e+5, + 5.500e+5, 5.557e+5, 5.614e+5, 5.672e+5, 5.730e+5, + 5.784e+5, 5.891e+5, 6.000e+5, 6.081e+5, 6.158e+5, + 6.235e+5, 6.313e+5, 6.393e+5, 6.468e+5, 6.545e+5, + 6.622e+5, 6.700e+5, 6.790e+5, 6.926e+5, 7.065e+5, + 7.154e+5, 7.244e+5, 7.335e+5, 7.427e+5, 7.500e+5, + 7.576e+5, 7.653e+5, 7.730e+5, 7.808e+5, 7.904e+5, + 8.002e+5, 8.100e+5, 8.200e+5, 8.301e+5, 8.403e+5, + 8.506e+5, 8.611e+5, 8.750e+5, 8.874e+5, 9.000e+5, + 9.072e+5, 9.200e+5, 9.400e+5, 9.616e+5, 9.800e+5, + 1.003e+6, 1.010e+6, 1.040e+6, 1.070e+6, 1.100e+6, + 1.108e+6, 1.136e+6, 1.165e+6, 1.200e+6, 1.225e+6, + 1.250e+6, 1.287e+6, 1.317e+6, 1.356e+6, 1.400e+6, + 1.423e+6, 1.461e+6, 1.500e+6, 1.536e+6, 1.572e+6, + 1.612e+6, 1.653e+6, 1.695e+6, 1.738e+6, 1.782e+6, + 1.827e+6, 1.850e+6, 1.921e+6, 1.969e+6, 2.019e+6, + 2.070e+6, 2.123e+6, 2.176e+6, 2.231e+6, 2.307e+6, + 2.354e+6, 2.365e+6, 2.385e+6, 2.466e+6, 2.479e+6, + 2.535e+6, 2.592e+6, 2.658e+6, 2.725e+6, 2.794e+6, + 2.865e+6, 2.932e+6, 3.000e+6, 3.080e+6, 3.166e+6, + 3.247e+6, 3.329e+6, 3.413e+6, 3.499e+6, 3.588e+6, + 3.679e+6, 3.772e+6, 3.867e+6, 3.965e+6, 4.066e+6, + 4.183e+6, 4.304e+6, 4.398e+6, 4.493e+6, 4.607e+6, + 4.724e+6, 4.800e+6, 4.882e+6, 4.966e+6, 5.092e+6, + 5.221e+6, 5.353e+6, 5.488e+6, 5.627e+6, 5.770e+6, + 5.916e+6, 6.065e+6, 6.219e+6, 6.376e+6, 6.434e+6, + 6.592e+6, 6.703e+6, 6.873e+6, 7.047e+6, 7.225e+6, + 7.408e+6, 7.596e+6, 7.788e+6, 7.985e+6, 8.187e+6, + 8.395e+6, 8.607e+6, 8.825e+6, 9.048e+6, 9.278e+6, + 9.512e+6, 9.753e+6, 1.000e+7, 1.025e+7, 1.051e+7, + 1.078e+7, 1.105e+7, 1.133e+7, 1.162e+7, 1.191e+7, + 1.221e+7, 1.252e+7, 1.284e+7, 1.317e+7, 1.350e+7, + 1.384e+7, 1.419e+7, 1.455e+7, 1.492e+7, 1.530e+7, + 1.568e+7, 1.608e+7, 1.649e+7, 1.691e+7, 1.733e+7, + 1.790e+7, 1.845e+7, 1.900e+7, 1.964e+7, 2.000e+7]) GROUP_STRUCTURES['UKAEA-1102'] = np.array([ 1.0000e-5, 1.0471e-5, 1.0965e-5, 1.1482e-5, 1.2023e-5, 1.2589e-5, 1.3183e-5, 1.3804e-5, 1.4454e-5, 1.5136e-5, From 8cd3911cbe0fa277b889effa1c5c1566b0683f55 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Wed, 12 Nov 2025 00:01:18 -0600 Subject: [PATCH 15/26] Reset DAGMC history when reviving from source. (#3601) Co-authored-by: Paul Romano --- src/particle.cpp | 3 + .../weightwindows/dagmc/__init__.py | 0 .../dagmc/nested_shell_geometry.h5m | Bin 0 -> 58680 bytes tests/unit_tests/weightwindows/dagmc/test.py | 98 ++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 tests/unit_tests/weightwindows/dagmc/__init__.py create mode 100644 tests/unit_tests/weightwindows/dagmc/nested_shell_geometry.h5m create mode 100644 tests/unit_tests/weightwindows/dagmc/test.py diff --git a/src/particle.cpp b/src/particle.cpp index 6b4c332a8b..6ba8ebf128 100644 --- a/src/particle.cpp +++ b/src/particle.cpp @@ -121,6 +121,9 @@ void Particle::from_source(const SourceSite* src) fission() = false; zero_flux_derivs(); lifetime() = 0.0; +#ifdef OPENMC_DAGMC_ENABLED + history().reset(); +#endif // Copy attributes from source bank site type() = src->particle; diff --git a/tests/unit_tests/weightwindows/dagmc/__init__.py b/tests/unit_tests/weightwindows/dagmc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/weightwindows/dagmc/nested_shell_geometry.h5m b/tests/unit_tests/weightwindows/dagmc/nested_shell_geometry.h5m new file mode 100644 index 0000000000000000000000000000000000000000..af1d5563d9255b518d42b551b7527cdf45eeaaf3 GIT binary patch literal 58680 zcmeI5Yj9oFb;pk+KZLPCyiG`g2qcIk7#VO-42ElLVHxlvwsGXVBv}%ch%5_KuO9BGtEqH+L^|kzT8Qt<36Tw+GK`lzqpf5OTU!vefIyStGleD zt1HV!md^2<`@ie4*WP=bwb$NfpL69$>Q>*hXuA*W(&~v_t%2v|y?eT2o|m_^@9OR9*)tONjE~Lh zH>~yvQ<| zKp&7ig_-36@<5{|s6*;r;{)Lf^Gii%pqXD5Y2I12a!c)+4V$*%m6E8`{mZSd%G^lz zL-`zcR=SfTnME0q7@o7O& zql8r@yO}$#N62~<~`XDKO?fg(Eerw(rDo*3K$O|w-)NTA`)UU;7<#&+y z?Qr*3Ueesv**p5athuedqot=+tFsU>S-+ik0Ex&McN1^!LxIO5l z@f-Xw+dggl5@&}LKlo$Kts8D!d27Sk)nX8r85D}da34`Ravw2#`S=Ei&u+yx_1kzK z-^$FBCgp*l^Fm*<=Lam52kP5{9)6yAo=ST(o(Gchz+uG?WsgrDm>X58g0>r}X>24v z)vp{|4wvO)T?WqB$hxtA)!1^ljEjDaEAo#WXXu}3WsFu#!H@Pzo(rljBSDB(S6;Fy@3J?zv+)%ADLw&UYWat)tuPHRd{BczJ1Q zFk3ErWhLDo@bW0i#Gibeshw|Mrf$b+uA`yN$WJu-eh#ap{=46+gZ9_WUt4J3=Fzm@ z=UcOC!>yhlJH|o$-63(lbm|yDE^|UkL_t`z!S?hW=&PT8K%zzp^l@ ziTNebDe=D5uHL4e-gxIKl&)|a5@AmwBfY?9A0rysQ4IV-pGoiM9{fZoStkR9_R$BI z1x5Bt3e}s+XO;)-K6<6PSEcu~=q=BK-E-j1*5=;!-R-@5yo~q_(T{dvq~{DpiEuM$z5N3)eXlHIkmE9q}(uxuZhkyHFn3-xBn>9)U_fXNw1H9$2i7 zS1W$-!|{2ZmV2(g_!?>&8;Q>Yif{6OjrXxj&s;9g(`?>csC7kY$)ae1TY>2J6%GD3 z6z*XXuQ}sJ_jGFfb=Da71xFaR4XnE#c#AD z=%?|k3pdNa#xLuGjfx-KalF?DH8)Vx*hqZ7t#QS~X5)SA(leLK`oP{V6}dienm}+xFL2kv1amm>EO?U zsiE+<3uJey{Lv(Pk>0}^At(5yBpUy{r~i>1aHRi^Ke7HVelwVOOTT~%l{maUX}!IvucOy($>C|PnE#7V{(6UzQ(7`V z*5A?5wY{k$QQcklvFDYcb)g-Mn?mzS_4=Ui!YVyxp$C%Yl{&={E}80ig?!krdBWzy z^|iOHTe*Hc>F|W-!*Y!a`k&&&JU~7yza{8bDUNda&E`WqJ;8i9wt8Yd9M*bcqWLhh zIkY>?hZDQrGfgWgAJ!|5=MpOS;Y z;`iyhe-%s&^6E@Xo~--y_yP3(@DJ!pOL1`MZSr%hcm6zRxIfc6>X^R%7<)hE$QMH6 zCwl)d&=B+)|D(E|ew_s#u=6GJ#XiLmE?~Z7aMU4~Br;zfXbc99nlH`YODcB0B&qrR zW&dE7d29UnGPl26uW>QS?#i@6T>_yR)?o{vp1@@BpdU`%aRgzwh+-pu~C7ednky=Y8cq%D%5Q+4h9{0pE&n z?C;8}?kIx&psdFCQ3_(vzqh)RG-ejR(EO^$p%Lgk?qkCHJ@_;eJ0$r&v*NE~g=mN^ zjF_CR^Xp{%5%y*CBP-b-!v}*u1{MSipVQYLWADoj_J($|{3tX}R`msa#@Fb+r{8D3 z2a@K=YQ+&AI9~H)_wG=`epSSK9i!&S_!sY!osXCwt7N|{s*Kc5Ms@KQzms0+PTnIfbX~DyyJ4*c zCT>BPXLJ7xdG}YuP;lH{5t*++>FhYxEmq@f$X6*svuQx)0{FK^X55vR>*}{WY5JX`h|{x!WAN zHxxkcfatgH4}9`#wce*f&%R$(u6hSo=i9Tt^Of4P1^if{7Va-_Vd?il^Zdwmuu6;r zk6KASOGym(cZZWPd7u z1ShW>hyLy4^2eNbjv}wHZrt}|=tZOaQD}dv@~NPR9TiJqmUzIf8!OfCjN%A49KCKl zw%@fek3^4$8Y)%aBHi_}UYq=WRB8O2hqx|tcKv?TIqs|sRmPw8xlbDls8Kqjzp^fP z$M|Qoj{i=*{ZCuT%%lrFPw~$R{E@?A-(Sf7E?B^`fttS0560j3$@wAlBR5~=M^^HD zbx8hb3b2XJz+2i_KT$Q}Pw##IIkHpZDhSQoY6n{ZDaX9$@~d|7OtdR~+T`-6r>5RZ_mI z8VdDHG~c}?{WRYNzs#Y;rX8a`e69&v1(TkzVvF44@&&N*mys>xa zgt=lyRm8kuCg|YmeZRn4n^l5*n*Y#WN&)$8`_0i)`N13k(hyP^$z|u39W@5L! zdzRY2B%4(`msfs1+VqcA+3SR{pPt*6mA?A5BqM$NIfC+MR@WuicmC6L$;f_2_QQ2} zZX15d%1>GOhyDaSydtCPn2~?7@=x&JJ=^GSR{hPYzg(Blp6KkXu1j`K6b^m{Hc#~F zLhg%C6@8lUY$5w+tN&K1pns=+<=BEzRW*kN6RKJP)J2YrU@oFiN)Tp*<1 z*rQ(h$NXgZ*rEN{!B6y)cHjrvgCDU&duXR$yZZw_($Dh(dt^VGcGFJeTu1EDPWb3? z{Xnh*?WY~Imv+(*E2lryOF3xD+x}2Ld}!KZuLu2x56yJ~ZFy+y+w#;;d1%VP$A8GF z-(Cm!)JHk=s2@3e>wn@1f8&4n_?7s>?=~*cBd%;*!pA=KG47DlK4|pdQy%+C;}SmY zBtD5(_|WtZyVOtnu!kHz{U#2n2R=0QQ4jSJ*YKh7GuMIosTV#p{-HefsUJQx?bU1J z(U&y-?-xEO5dPDm z9~KS@_Zt3_Mvk2)#D7$XJ?ex1nB>@bTr_;_P%mV{?0iG|Y)eru{BKH5dFXwn9QDEHcIGANLBD9|7Yscl`g=mkLt8oJkz*e|`q+U+ zjy{Oq%c}ne!u>+{;P(yxRa5R2(eR-u2aSDb^wER1?Lkg?J;>1qv4{FcJj}ezJj=YxyqYwB-lujU zzh5-v9}qntM4x&d6#rf!`m_gov;%$G{gBE%EF2U*B79W%nDBAo6T&Br{3+3t-z%DO z=u^+rl4B2j+JimXfj;eiM&<21%=~NTQ|3wLT|2)r4^!UGujt$P9DC@~9_-N$=6~AF z{LDN$r2g1>@+I+K6O#9TB>Km~*M)BgUlzV1d{y{;;eO!{gvII?+x`;O^QLgIuw1xE zc$%<6c%JZV;e27K@KoUf;T+*Y;km+7gy#t73Co0M3C|SL-qS^&A)G6;?Y~-l+n>wC zxBdH=`1BWh9~IyB|0?mVKWfCc{`sW%_zQa%iBG+hd%yU!gL;;UPy4a=A@S)i_Ae3N zUY`rZx7X`h@$L1i5})gdz01Y7*Z1S%+v|Oe`1bmLLVV%@dzXq&y_7p&eA+=hmEzNW z>@5|a{$jscd>elki*MucLh)^UE)k!2#omX-xAFTy@ohX`A-;|8>%=GCv3I5T)JwUK zh)+AH=Y8VSe(Wt5pZ;S11L6}`#2xWUe6EzF{Uh+V5kJ)5PsMTlMM!7lB*MSAoTJ>-<9{hyNlCZW~0?fbO!>V-cMQs1!X z*MyYEZg?(|`y^f|j|3W|{mAJDa^eQLwTnJ-u0Q2zCvwI$a>gxk{EnRZkTaf<6VJ#Q zm&l1@|A>(jpU5vWa`FxGj~hAuLVksj6Yt2c zG;-n<`7$HN-^go>oV@)8pCl4cM-NZOqEBnMX z^EcywagO{$(kITz+sHpGIdM!LLS7{~aY|l5{xQjkL-G>xC6W_ow~0o+R5Wo!9zlM! z$=_V(okq^}xK#WuBPX9@jlub(Q#c8~GigmkGaOTcGIH$QBL34t@)I<1_%$Q{rs&PW zuN(PyMSog|KK5S{pE&!LDgOh}4Z`P){0-5~Lh8f*o8l8k-!tX^So9Z#FBthRMRy5l zFZO>WK5_DjXy*055`CBO`$Ee9TJ+t*{YL%|qKO0iLHXZEe!r0Q7xLeW|FH0fLfZEq zq8}6fNJ#nr6iwXxI6=;JPm<%;BsqML`v0K%;DfaPzY^q>|L+7j<+n>t{E&}Y#lKxh z{IrQ~7v3QxemX?&6m|-UAM(we;`a!NpI*^@Lh=jo(=R@GIxin~7&zA4<= z+SA+0qC~vWkjhj4%;fl3)z`C|Ts%7N`JT#C|D^H3XgC_v@o{Y9V{>26?M=iGTvV@^oxdm!O%mZ*$1XPw3SmHIrh;*4vjtd=!3{#Ry)`~hGt(Gn)`WZ z_Mu-f<)N*d^2o7|9&%{f10Q`5Is4u0XG62k4bA>HH2dQ0lT#kr$|;W=`{*Hu#vXk1 zLF8%v2?3PmpWugL^G}H@=6BbR6|r%wc9P1!SN4r>Wx>mLceS@z ZKJ@mkzRs4Wo;}NVH#PV5?Swso{}1hu%5MMw literal 0 HcmV?d00001 diff --git a/tests/unit_tests/weightwindows/dagmc/test.py b/tests/unit_tests/weightwindows/dagmc/test.py new file mode 100644 index 0000000000..ed01a93ed3 --- /dev/null +++ b/tests/unit_tests/weightwindows/dagmc/test.py @@ -0,0 +1,98 @@ +import pytest + +import openmc +import openmc.lib + +pytestmark = pytest.mark.skipif( + not openmc.lib._dagmc_enabled(), + reason="DAGMC CAD geometry is not enabled.", +) + + +def test_dagmc_weight_windows_near_boundary(run_in_tmpdir, request): + """Ensure splitting near a boundary doesn't lose particles due to + a stale DAGMC history on the particle object.""" + + # DAGMC model overview: + # * Three nested cubes; innermost cube contains a fusion neutron source. + # * Two outer cubes filled with tungsten. Weight windows defined on a mesh + # cause particles to split moving outward. + # Outer cubes are similar in size (outer slightly larger) so particles + # frequently cross the problem boundary immediately after splitting. No lost + # particles are allowed to the correct DAGMC history after splitting is used. + model = openmc.Model() + + dagmc_file = request.path.parent / 'nested_shell_geometry.h5m' + dagmc_univ = openmc.DAGMCUniverse(dagmc_file) + model.geometry = openmc.Geometry(dagmc_univ) + + tungsten = openmc.Material(name='shell') + tungsten.add_element('W', 1.0) + tungsten.set_density('g/cm3', 7.8) + materials = openmc.Materials([tungsten]) + model.materials = materials + + settings = openmc.Settings() + settings.output = {'tallies': False, 'summary': False} + + source = openmc.IndependentSource() + source.space = openmc.stats.Point((0.0, 0.0, 0.0)) + source.angle = openmc.stats.Isotropic() + source.energy = openmc.stats.Discrete([14.1e6], [1.0]) + settings.source = source + + settings.batches = 2 + settings.particles = 500 + settings.run_mode = 'fixed source' + settings.survival_biasing = False + settings.max_lost_particles = 1 + settings.max_history_splits = 10_000_000 + + settings.weight_window_checkpoints = { + 'surface': True, + 'collision': True, + } + + mesh = openmc.RegularMesh() + mesh.lower_left = (-60.0, -60.0, -60.0) + mesh.upper_right = (60.0, 60.0, 60.0) + mesh.dimension = (24, 1, 1) + + weight_windows_lower = [ + 0.030750733294361156, + 0.056110505674355333, + 0.08187875047968339, + 0.1101743496347699, + 0.13982370013053508, + 0.17443799246829372, + 0.21576286623367483, + 0.26416659508033646, + 0.318574932646899, + 0.3804031702117963, + 0.42899359749256355, + 0.4954283294279403, + 0.49999999999999994, + 0.43432341070872266, + 0.38302303850488206, + 0.32148375935490886, + 0.2637416945702018, + 0.21498369367288853, + 0.17163611765361744, + 0.13832102142074995, + 0.10717772257151495, + 0.07986176041282561, + 0.05499644859408233, + 0.03058023506703803, + ] + + weight_windows = openmc.WeightWindows( + mesh, + lower_ww_bounds=weight_windows_lower, + upper_bound_ratio=5.0, + ) + weight_windows.max_lower_bound_ratio = 1.0 + settings.weight_windows = weight_windows + settings.weight_windows_on = True + model.settings = settings + + model.run() \ No newline at end of file From 5c2bfe771eb94c759923c63be5ba3eb01a6b3ec9 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Wed, 12 Nov 2025 00:29:19 -0600 Subject: [PATCH 16/26] Write particle states as separate lines in track VTK files. (#3628) --- openmc/tracks.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/openmc/tracks.py b/openmc/tracks.py index 61e5a72442..81646e7d2e 100644 --- a/openmc/tracks.py +++ b/openmc/tracks.py @@ -296,16 +296,16 @@ class Tracks(list): for state in pt.states: points.InsertNextPoint(state['r']) - # Create VTK line and assign points to line. - n = pt.states.size - line = vtk.vtkPolyLine() - line.GetPointIds().SetNumberOfIds(n) - for i in range(n): - line.GetPointIds().SetId(i, point_offset + i) - point_offset += n + # Create VTK line and assign points to line. + n = pt.states.size + line = vtk.vtkPolyLine() + line.GetPointIds().SetNumberOfIds(n) + for i in range(n): + line.GetPointIds().SetId(i, point_offset + i) + point_offset += n - # Add line to cell array - cells.InsertNextCell(line) + # Add line to cell array + cells.InsertNextCell(line) data = vtk.vtkPolyData() data.SetPoints(points) From e5348d3f62a821f84513a026977eef0f8bef01ad Mon Sep 17 00:00:00 2001 From: Marco De Pietri Date: Wed, 12 Nov 2025 05:35:07 -0500 Subject: [PATCH 17/26] Avoid divide-by-zero in `from_multigroup_flux` when flux is zero (#3624) Co-authored-by: Paul Romano --- openmc/deplete/microxs.py | 12 ++++++++---- tests/unit_tests/test_deplete_microxs.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/openmc/deplete/microxs.py b/openmc/deplete/microxs.py index e351c923df..d7624955e8 100644 --- a/openmc/deplete/microxs.py +++ b/openmc/deplete/microxs.py @@ -356,13 +356,17 @@ class MicroXS: reactions = chain.reactions mts = [REACTION_MT[name] for name in reactions] - # Normalize multigroup flux - multigroup_flux = np.array(multigroup_flux) - multigroup_flux /= multigroup_flux.sum() - # Create 3D array for microscopic cross sections microxs_arr = np.zeros((len(nuclides), len(mts), 1)) + # If flux is zero, safely return zero cross sections + multigroup_flux = np.array(multigroup_flux) + if (flux_sum := multigroup_flux.sum()) == 0.0: + return cls(microxs_arr, nuclides, reactions) + + # Normalize multigroup flux + multigroup_flux /= flux_sum + # Compute microscopic cross sections within a temporary session with openmc.lib.TemporarySession(**init_kwargs): # For each nuclide and reaction, compute the flux-averaged xs diff --git a/tests/unit_tests/test_deplete_microxs.py b/tests/unit_tests/test_deplete_microxs.py index 073b3f162d..5762a8511b 100644 --- a/tests/unit_tests/test_deplete_microxs.py +++ b/tests/unit_tests/test_deplete_microxs.py @@ -111,3 +111,16 @@ def test_multigroup_flux_same(): energies=energies, multigroup_flux=flux, chain_file=chain_file) assert microxs_4g.data == pytest.approx(microxs_2g.data) + + +def test_microxs_zero_flux(): + chain_file = Path(__file__).parents[1] / 'chain_simple.xml' + + # Generate micro XS based on zero flux + energies = [0., 6.25e-1, 5.53e3, 8.21e5, 2.e7] + flux = [0.0, 0.0, 0.0, 0.0] + microxs = MicroXS.from_multigroup_flux( + energies=energies, multigroup_flux=flux, chain_file=chain_file) + + # All microscopic cross sections should be zero + assert np.all(microxs.data == 0.0) From 2d77544b0c045f7e693146dc466e5cc290fbdc49 Mon Sep 17 00:00:00 2001 From: Gregoire Biot Date: Wed, 12 Nov 2025 11:41:37 -0500 Subject: [PATCH 18/26] Adding variance of variance and normality tests for tally statistics (#3454) Co-authored-by: Ethan Peterson Co-authored-by: Paul Romano --- docs/source/io_formats/statepoint.rst | 2 + docs/source/methods/tallies.rst | 107 +++++- include/openmc/constants.h | 2 +- include/openmc/hdf5_interface.h | 10 +- include/openmc/tallies/tally.h | 5 + openmc/statepoint.py | 4 + openmc/tallies.py | 524 +++++++++++++++++++++++++- src/hdf5_interface.cpp | 16 +- src/state_point.cpp | 17 +- src/tallies/tally.cpp | 42 ++- src/weight_windows.cpp | 8 +- tests/unit_tests/test_tallies.py | 213 +++++++++++ 12 files changed, 915 insertions(+), 35 deletions(-) diff --git a/docs/source/io_formats/statepoint.rst b/docs/source/io_formats/statepoint.rst index 3b10317696..2309643dc8 100644 --- a/docs/source/io_formats/statepoint.rst +++ b/docs/source/io_formats/statepoint.rst @@ -149,6 +149,8 @@ The current version of the statepoint file format is 18.1. tallies will have a value of 0 unless otherwise instructed. - **multiply_density** (*int*) -- Flag indicating whether reaction rates should be multiplied by atom density (1) or not (0). + - **higher_moments** (*int*) -- Flag indicating whether + higher-order tally moments are enabled (1) or not (0). :Datasets: - **n_realizations** (*int*) -- Number of realizations. - **n_filters** (*int*) -- Number of filters used. diff --git a/docs/source/methods/tallies.rst b/docs/source/methods/tallies.rst index 79a63fbdd8..27a3f873ab 100644 --- a/docs/source/methods/tallies.rst +++ b/docs/source/methods/tallies.rst @@ -387,6 +387,101 @@ of this is that the longer you run a simulation, the better you know your results. Therefore, by running a simulation long enough, it is possible to reduce the stochastic uncertainty to arbitrarily low levels. +Skewness +++++++++ + +The `skewness`_ of a population quantifies the asymmetry of the probability +distribution around its mean. Positive and negative skewness indicate a +longer/heavier right and left tail respectively. Let :math:`x_1,\ldots,x_n` be +the per-realization values for a bin, with sample mean :math:`\bar{x}` and +sample central moments: + +.. math:: + + m_k \;=\; \frac{1}{n}\sum_{i=1}^{n}\bigl(x_i-\bar{x}\bigr)^k. + +OpenMC reports the *adjusted Fisher-Pearson skewness* (defined for :math:`n \ge +3`), which is commonly used in many statistical packages: + +.. math:: + + G_1 \;=\; \frac{\sqrt{n \cdot (n-1)}}{\,n-2\,}\cdot\frac{m_3}{m_2^{3/2}}. + +where :math:`m_2` and :math:`m_3` correspond to the biased sample second and +third central moment respectively. + +Kurtosis +++++++++ + +The `kurtosis`_ of a population quantifies tail weight (also called tailedness) +of the probability distribution relative to a normal distribution. Positive +excess kurtosis indicates *heavier tails* whereas negative excess kurtosis +indicates *lighter tails*. Kurtosis is especially useful for identifying bins +where occasional extreme scores dominate uncertainty. OpenMC reports the +*adjusted excess kurtosis* (defined for :math:`n \ge 4`): + +.. math:: + + G_2 \;=\; \frac{(n-1)}{(n-2)(n-3)} + \left[(n+1)\,\frac{m_4}{m_2^{2}} \;-\; 3(n-1)\right]. + +where :math:`m_2` and :math:`m_4` correspond to the biased sample second and +fourth central moment respectively. For a perfectly normal distribution, the +excess kurtosis is :math:`0`. + +Variance of Variance +++++++++++++++++++++ + +The variance of the variance (also known as the coefficient of variation +squared) measures *stability of the sample variance* :math:`s^2` and, by +extension, the reliability of reported relative errors. High VOV means that +error bars themselves are noisy—often due to heavy tails, skewness, or too few +realizations. + +.. math:: + + VOV = \frac{s^2(s_{\bar{X}}^2)}{s_{\bar{X}}^4 } = \frac{m_4}{m_2^2} - \frac{1}{n} + +where :math:`s_{\bar{X}}^2` is the estimated variance of the mean and +:math:`s^2(s_{\bar{X}}^2)` is the estimated variance in :math:`s_{\bar{X}}^2`. +The MCNP manual suggests a hard threshold such that :math:`VOV < 0.1` to improve +the probability of forming a reliable confidence interval. However, OpenMC does +not enforce an universal cut-off because the suitability of any single threshold +depends strongly on problem specifics (estimator choice, variance-reduction +settings, tally binning, or even effective sample size). + + +Normality Tests (D'Agostino-Pearson) +++++++++++++++++++++++++++++++++++++ + +These normality test verify the hypothesis that fluctuations are *approximately +normal*, a working assumption behind many Monte Carlo diagnostics and +`confidence-interval heuristics`_. Tests are provided for: (i) skewness-only, +(ii) kurtosis-only, and (iii) the *omnibus* combination. OpenMC uses the +finite-sample-adjusted skewness :math:`G_1` and excess kurtosis :math:`G_2` +above to construct standardized normal scores :math:`Z_1` (from :math:`G_1`) and +:math:`Z_2` (from :math:`G_2`) via the D'Agostino-Pearson transformations. The +omnibus statistic is + +.. math:: + + K^2 \;=\; Z_1^{\,2} \;+\; Z_2^{\,2} + \;\sim\; \chi^2_{(2)} \quad \text{under } H_0:\ \text{normality}. + +OpenMC reports :math:`Z_1`, :math:`Z_2`, :math:`K^2`, and their p-values when +prerequisites are met (skewness for :math:`n\ge 3`, kurtosis and omnibus for +:math:`n\ge 4`). Given a user-chosen significance level :math:`\alpha` (default +is :math:`0.05`), reject :math:`H_0` if :math:`\text{p-value}<\alpha`; otherwise +fail to reject. OpenMC leaves the interpretation to the user, who should +consider VOV together with skewness, kurtosis, and normality tests results when +judging whether reported confidence intervals are credible for their application +[#norm-tests]_. + +.. [#norm-tests] + Higher-moments accumulation must be enabled with ``higher_moments = True`` + for running these diagnostics including the skewness, kurtosis, and normality + tests. + Figure of Merit +++++++++++++++ @@ -405,14 +500,16 @@ defined as .. math:: :label: relative_error - r = \frac{s_\bar{X}}{\bar{x}}. + r = \frac{s_{\bar{X}}}{\bar{x}}. Based on this definition, one can see that a higher FOM is desirable. The FOM is useful as a comparative tool. For example, if a variance reduction technique is being applied to a simulation, the FOM with variance reduction can be compared to the FOM without variance reduction to ascertain whether the reduction in variance outweighs the potential increase in execution time (e.g., due to -particle splitting). +particle splitting). It is important to note that MCNP reports the FOM using CPU +time (wall-clock time multiplied by the number of threads/cores), whereas OpenMC +reports the FOM using only the wall-clock time :math:`t`. Confidence Intervals ++++++++++++++++++++ @@ -521,6 +618,8 @@ improve the estimate of the percentile. .. rubric:: References +.. _confidence-interval heuristics: https://doi.org/10.1080/00031305.1990.10475751 + .. _following approximation: https://doi.org/10.1080/03610918708812641 .. _Bessel's correction: https://en.wikipedia.org/wiki/Bessel's_correction @@ -541,6 +640,10 @@ improve the estimate of the percentile. .. _converges in distribution: https://en.wikipedia.org/wiki/Convergence_of_random_variables#Convergence_in_distribution +.. _skewness: https://en.wikipedia.org/wiki/Skewness + +.. _kurtosis: https://en.wikipedia.org/wiki/Kurtosis + .. _confidence intervals: https://en.wikipedia.org/wiki/Confidence_interval .. _Student's t-distribution: https://en.wikipedia.org/wiki/Student%27s_t-distribution diff --git a/include/openmc/constants.h b/include/openmc/constants.h index a0d1646131..b661934812 100644 --- a/include/openmc/constants.h +++ b/include/openmc/constants.h @@ -291,7 +291,7 @@ enum class MgxsType { // ============================================================================ // TALLY-RELATED CONSTANTS -enum class TallyResult { VALUE, SUM, SUM_SQ, SIZE }; +enum class TallyResult { VALUE, SUM, SUM_SQ, SUM_THIRD, SUM_FOURTH }; enum class TallyType { VOLUME, MESH_SURFACE, SURFACE, PULSE_HEIGHT }; diff --git a/include/openmc/hdf5_interface.h b/include/openmc/hdf5_interface.h index 0092c08f8d..28b0d2b113 100644 --- a/include/openmc/hdf5_interface.h +++ b/include/openmc/hdf5_interface.h @@ -100,8 +100,8 @@ void read_llong(hid_t obj_id, const char* name, long long* buffer, bool indep); void read_string( hid_t obj_id, const char* name, size_t slen, char* buffer, bool indep); -void read_tally_results( - hid_t group_id, hsize_t n_filter, hsize_t n_score, double* results); +void read_tally_results(hid_t group_id, hsize_t n_filter, hsize_t n_score, + hsize_t n_results, double* results); void write_attr_double(hid_t obj_id, int ndim, const hsize_t* dims, const char* name, const double* buffer); void write_attr_int(hid_t obj_id, int ndim, const hsize_t* dims, @@ -114,9 +114,9 @@ void write_int(hid_t group_id, int ndim, const hsize_t* dims, const char* name, void write_llong(hid_t group_id, int ndim, const hsize_t* dims, const char* name, const long long* buffer, bool indep); void write_string(hid_t group_id, int ndim, const hsize_t* dims, size_t slen, - const char* name, char const* buffer, bool indep); -void write_tally_results( - hid_t group_id, hsize_t n_filter, hsize_t n_score, const double* results); + const char* name, const char* buffer, bool indep); +void write_tally_results(hid_t group_id, hsize_t n_filter, hsize_t n_score, + hsize_t n_results, const double* results); } // extern "C" //============================================================================== diff --git a/include/openmc/tallies/tally.h b/include/openmc/tallies/tally.h index 3beeb9d5ac..374daff92a 100644 --- a/include/openmc/tallies/tally.h +++ b/include/openmc/tallies/tally.h @@ -106,6 +106,8 @@ public: bool writable() const { return writable_; } + bool higher_moments() const { return higher_moments_; } + //---------------------------------------------------------------------------- // Other methods. @@ -190,6 +192,9 @@ private: //! Whether to multiply by atom density for reaction rates bool multiply_density_ {true}; + //! Whether to accumulate higher moments (third and fourth) + bool higher_moments_ {false}; + int64_t index_; }; diff --git a/openmc/statepoint.py b/openmc/statepoint.py index a763db3971..11986841f0 100644 --- a/openmc/statepoint.py +++ b/openmc/statepoint.py @@ -434,6 +434,10 @@ class StatePoint: if "multiply_density" in group.attrs: tally.multiply_density = group.attrs["multiply_density"].item() > 0 + # Check if tally has higher_moments attribute + if 'higher_moments' in group.attrs: + tally.higher_moments = bool(group.attrs['higher_moments'][()]) + # Read the number of realizations n_realizations = group['n_realizations'][()] diff --git a/openmc/tallies.py b/openmc/tallies.py index 075b1e9911..25ec29a585 100644 --- a/openmc/tallies.py +++ b/openmc/tallies.py @@ -3,6 +3,7 @@ from collections.abc import Iterable, MutableSequence import copy from functools import partial, reduce, wraps from itertools import product +from math import sqrt, log from numbers import Integral, Real import operator from pathlib import Path @@ -12,6 +13,7 @@ import h5py import numpy as np import pandas as pd import scipy.sparse as sps +from scipy.stats import chi2, norm import openmc import openmc.checkvalue as cv @@ -91,10 +93,20 @@ class Tally(IDManagerMixin): sum_sq : numpy.ndarray An array containing the sum of each independent realization squared for each bin + sum_third : numpy.ndarray + An array containing the sum of each independent realization to the third power for + each bin + sum_fourth : numpy.ndarray + An array containing the sum of each independent realization to the fourth power for + each bin mean : numpy.ndarray An array containing the sample mean for each bin std_dev : numpy.ndarray An array containing the sample standard deviation for each bin + vov : numpy.ndarray + An array containing the variance of the variance for each tally bin + higher_moments : bool + Whether or not the tally accumulates the sums third and fourth to compute higher-order moments figure_of_merit : numpy.ndarray An array containing the figure of merit for each bin @@ -129,8 +141,12 @@ class Tally(IDManagerMixin): self._sum = None self._sum_sq = None + self._sum_third = None + self._sum_fourth = None self._mean = None self._std_dev = None + self._vov = None + self._higher_moments = False self._simulation_time = None self._with_batch_statistics = False self._derived = False @@ -221,6 +237,15 @@ class Tally(IDManagerMixin): cv.check_type('multiply density', value, bool) self._multiply_density = value + @property + def higher_moments(self) -> bool: + return self._higher_moments + + @higher_moments.setter + def higher_moments(self, value): + cv.check_type("higher_moments", value, bool) + self._higher_moments = value + @property def filters(self): return self._filters @@ -371,6 +396,11 @@ class Tally(IDManagerMixin): # Update nuclides nuclide_names = group['nuclides'][()] self._nuclides = [name.decode().strip() for name in nuclide_names] + # Check for higher_moments attribute + if "higher_moments" in group.attrs: + self._higher_moments = bool(group.attrs["higher_moments"][()]) + else: + self._higher_moments = False # Extract Tally data from the file data = group['results'] @@ -385,10 +415,25 @@ class Tally(IDManagerMixin): self._sum = sum_ self._sum_sq = sum_sq + if self._higher_moments: + # Extract additional Tally data when higher moments enabled + sum_third = data[:, :, 2] + sum_fourth = data[:, :, 3] + + # Reshape the results arrays + sum_third = np.reshape(sum_third, self.shape) + sum_fourth = np.reshape(sum_fourth, self.shape) + + # Set the additional data for this Tally + self._sum_third = sum_third + self._sum_fourth = sum_fourth + # Convert NumPy arrays to SciPy sparse LIL matrices if self.sparse: self._sum = sps.lil_matrix(self._sum.flatten(), self._sum.shape) self._sum_sq = sps.lil_matrix(self._sum_sq.flatten(), self._sum_sq.shape) + self._sum_third = sps.lil_matrix(self._sum_third.flatten(), self._sum_third.shape) + self._sum_fourth = sps.lil_matrix(self.sum_fourth.flatten(), self._sum_fourth.shape) # Read simulation time (needed for figure of merit) self._simulation_time = f["runtime"]["simulation"][()] @@ -428,6 +473,52 @@ class Tally(IDManagerMixin): cv.check_type('sum_sq', sum_sq, Iterable) self._sum_sq = sum_sq + @property + @ensure_results + def sum_third(self): + if not self._higher_moments: + raise ValueError( + "Higher moments have not been enabled for this tally. To make " + "higher moments available, set the higher_moments attribute to " + "True before running a simulation." + ) + + if not self._sp_filename or self.derived: + return None + + if self.sparse: + return np.reshape(self._sum_third.toarray(), self.shape) + else: + return self._sum_third + + @sum_third.setter + def sum_third(self, sum_third): + cv.check_type("sum_third", sum_third, Iterable) + self._sum_third = sum_third + + @property + @ensure_results + def sum_fourth(self): + if not self._higher_moments: + raise ValueError( + "Higher moments have not been enabled for this tally. To make " + "higher moments available, set the higher_moments attribute to " + "True before running a simulation." + ) + + if not self._sp_filename or self.derived: + return None + + if self.sparse: + return np.reshape(self._sum_fourth.toarray(), self.shape) + else: + return self._sum_fourth + + @sum_fourth.setter + def sum_fourth(self, sum_fourth): + cv.check_type("sum_fourth", sum_fourth, Iterable) + self._sum_fourth = sum_fourth + @property def mean(self): if self._mean is None: @@ -470,14 +561,370 @@ class Tally(IDManagerMixin): else: return self._std_dev + @property + def vov(self): + if self._vov is None: + n = self.num_realizations + sum1 = self.sum + sum2 = self.sum_sq + sum3 = self.sum_third + sum4 = self.sum_fourth + self._vov = np.zeros_like(sum1, dtype=float) + + # Calculate the variance of the variance (Eq. 2.232 in + # https://doi.org/10.2172/2372634) + numerator = (sum4 - (4.0*sum3*sum1)/n + + (6.0*sum2*(sum1**2))/(n**2) + - (3.0*(sum1)**4)/(n**3)) + denominator = (sum2 - (1.0/n)*(sum1**2))**2 + + mask = denominator > 0.0 + + self._vov[mask] = numerator[mask]/denominator[mask] - 1.0/n + + if self.sparse: + self._vov = sps.lil_matrix(self._vov.flatten(), self._vov.shape) + + if self.sparse: + return np.reshape(self._vov.toarray(), self.shape) + else: + return self._vov + + @property + def m2(self): + n = self.num_realizations + return self.sum_sq/n - self.mean**2 + + @property + def m3(self): + n = self.num_realizations + mean = self.mean + sum2 = self.sum_sq/n + sum3 = self.sum_third/n + + return sum3 - 3.0*mean*sum2 + 2.0*mean**3 + + @property + def m4(self): + n = self.num_realizations + mean = self.mean + sum2 = self.sum_sq/n + sum3 = self.sum_third/n + sum4 = self.sum_fourth/n + + return sum4 - 4.0*mean*sum3 + 6.0*(mean**2)*sum2 - 3.0*mean**4 + + def skew(self, bias=False) -> np.ndarray: + """Return the sample skewness of each tally bin. + + This method computes and returns the unadjusted or adjusted + Fisher-Pearson coefficient of skewness. + + Parameters + ---------- + bias : bool + If False, calculations are corrected for bias and the adjusted + Fisher-Pearson skewness (:math:`G_1`) is returned. If True, + calculations are not corrected for bias and the unadjusted skewness + (:math:`g_1`) is returned. + + Returns + ------- + float + The skewness of each tally bin + """ + n = self.num_realizations + m2 = self.m2 + m3 = self.m3 + + with np.errstate(divide="ignore", invalid="ignore"): + g1 = np.where(m2 > 0.0, m3/(m2**1.5), 0.0) + + if bias: + return g1 + else: + if n <= 2: + raise ValueError("Insufficient number of independent realizations" + f"for bias-corrected skewness: need n >= 3, got {n=}.") + else: + return sqrt(n*(n - 1))/(n - 2)*g1 + + def kurtosis(self, fisher=True, bias=False) -> np.ndarray: + r"""Return the sample kurtosis of each tally bin. + + This method computes and returns the sample kurtosis using either + Pearson's or Fisher's definition, with or without finite-sample bias + correction. The value returned depends on the `bias` and `fisher` + arguments as follows: + + - **bias=True, fisher=False**: Returns :math:`b_2` (Pearson's kurtosis) + This is the raw fourth standardized moment: :math:`m_4/m_2^2`. For a + normal distribution, :math:`b_2\approx 3`. + + - **bias=True, fisher=True**: Returns :math:`g_2` (excess kurtosis) This + is :math:`b_2 - 3`, centered at 0 for normal distributions. Positive + values indicate heavier tails, negative values lighter tails. + + - **bias=False, fisher=True** (default): Returns :math:`G_2` (adjusted + excess kurtosis). This applies finite-sample bias correction to + :math:`g_2`. This is the recommended estimator for statistical + inference. + + - **bias=False, fisher=False**: Returns bias-corrected Pearson's + kurtosis. This is :math:`G_2 + 3`. + + Parameters + ---------- + fisher : bool, optional + If True (default), Fisher's definition is used (excess kurtosis). If + False, Pearson's definition is used. + bias : bool, optional + If False (default), calculations are corrected for statistical bias + using finite-sample adjustments. If True, calculations use the + biased estimator (population formulas). + + Returns + ------- + numpy.ndarray + The kurtosis of each tally bin + + """ + n = self.num_realizations + m2 = self.m2 + m4 = self.m4 + + with np.errstate(divide="ignore", invalid="ignore"): + b2 = np.where(m2 > 0.0, m4/(m2**2), 0.0) + g2 = b2 - 3.0 + + if bias: + # Biased estimator (g2 or b2) + return g2 if fisher else b2 + else: + # Unbiased estimator with finite-sample correction + if n <= 3: + raise ValueError("Insufficient number of independent realizations" + f"for bias-corrected kurtosis: need n >= 4, got {n=}.") + else: + G2 = ((n - 1)/((n - 2)*(n - 3)))*((n + 1)*g2 + 6.0) + return G2 if fisher else G2 + 3.0 + + def skewtest(self, alternative: str = "two-sided"): + """Perform D'Agostino and Pearson's test for skewness. + + This method tests the null hypothesis that the skewness of the + population that the sample was drawn from is the same as that of a + corresponding normal distribution. + + Parameters + ---------- + alternative : {'two-sided', 'less', 'greater'}, optional + Defines the alternative hypothesis. The following options are + available: + + * 'two-sided': the skewness of the distribution is different from + that of the normal distribution (i.e., non-zero) + * 'less': the skewness of the distribution is less than that of the + normal distribution + * 'greater': the skewness of the distribution is greater than that + of the normal distribution + + Returns + ------- + statistic : np.ndarray + The computed z-score for the skewness test for each tally bin + pvalue : np.ndarray + The p-value for the hypothesis test for each tally bin + + Notes + ----- + This test is based on D'Agostino and Pearson's test [1]_. The test + requires at least 8 realizations to produce valid results. + + References + ---------- + .. [1] D'Agostino, R. B. (1971), "An omnibus test of normality for + moderate and large sample size", Biometrika, 58, 341-348 + + """ + n = self.num_realizations + if n < 8: + raise ValueError("Skewness test is not well-defined for n < 8.") + + g1 = self.skew(bias=True) + + # --- Z1 (skewness) --- + y = g1 * sqrt(((n + 1.0)*(n + 3.0))/(6.0*(n - 2.0))) + beta2 = (3.0*(n**2 + 27.0*n - 70.0)*(n + 1.0)*(n + 3.0) + )/((n - 2.0)*(n + 5.0)*(n + 7.0)*(n + 9.0)) + W2 = -1.0 + sqrt(2.0*(beta2 - 1.0)) + delta = 1.0 / sqrt(log(sqrt(W2))) + alpha = sqrt(2.0 / (W2 - 1.0)) + Zb1 = np.where( + y >= 0.0, + delta*np.log((y/alpha) + np.sqrt((y/alpha)**2 + 1.0)), + -delta*np.log((-y/alpha) + np.sqrt((y/alpha)**2 + 1.0)) + ) + + # p-value + if alternative == "two-sided": + p = 2.0 * (1.0 - norm.cdf(np.abs(Zb1))) + elif alternative == "greater": + p = 1.0 - norm.cdf(Zb1) + elif alternative == "less": + p = norm.cdf(Zb1) + else: + raise ValueError("alternative must be 'two-sided', 'greater', or 'less'") + + return Zb1, p + + def kurtosistest(self, alternative: str = "two-sided"): + """Perform D'Agostino and Pearson's test for kurtosis. + + This method tests the null hypothesis that the kurtosis of the + population that the sample was drawn from is the same as that of a + corresponding normal distribution. + + Parameters + ---------- + alternative : {'two-sided', 'less', 'greater'}, optional + Defines the alternative hypothesis. Default is 'two-sided'. + The following options are available: + + * 'two-sided': the kurtosis of the distribution is different from + that of the normal distribution + * 'less': the kurtosis of the distribution is less than that of the + normal distribution + * 'greater': the kurtosis of the distribution is greater than that + of the normal distribution + + Returns + ------- + statistic : np.ndarray + The computed z-score for the kurtosis test for each tally bin + pvalue : np.ndarray + The p-value for the hypothesis test for each tally bin + + Raises + ------ + ValueError + If the number of realizations is less than 20, or if an invalid + alternative hypothesis is specified. + + Notes + ----- + This test is based on D'Agostino and Pearson's test [1]_. The test + is typically recommended for at least 20 realizations to produce + valid results. + + References + ---------- + .. [1] D'Agostino, R. B. (1971), "An omnibus test of normality for + moderate and large sample size", Biometrika, 58, 341-348 + + """ + n = self.num_realizations + if n < 20: + raise ValueError("Kurtosis test is typically recommended for n >= 20.") + + b2 = self.kurtosis(bias=True, fisher=False) + + # --- Z2 (kurtosis) --- + mean_b2 = 3.0 * (n - 1.0) / (n + 1.0) + var_b2 = (24.0*n*(n - 2.0)*(n - 3.0)/( + (n + 1.0)**2*(n + 3.0)*(n + 5.0))) + x = (b2 - mean_b2)/np.sqrt(var_b2) + moment = ((6.0*(n**2 - 5.0*n + 2.0))/((n + 7.0)*(n + 9.0)) + )*sqrt((6.0*(n + 3.0)*(n + 5.0))/(n*(n - 2.0)*(n - 3.0))) + A = 6.0 + (8.0/moment)*((2.0/moment) + sqrt(1.0 + 4.0/(moment**2))) + Zb2 = (1.0- 2.0/(9.0*A) - ((1.0 - 2.0/A) / (1.0 + (x + )*sqrt(2.0/(A - 4.0))))**(1.0/3.0)) / sqrt(2.0/(9.0*A)) + + # p-value + if alternative == "two-sided": + p = 2.0 * (1.0 - norm.cdf(np.abs(Zb2))) + elif alternative == "greater": + p = 1.0 - norm.cdf(Zb2) + elif alternative == "less": + p = norm.cdf(Zb2) + else: + raise ValueError("alternative must be 'two-sided', 'greater', or 'less'") + + return Zb2, p + + def normaltest(self, alternative: str = "two-sided"): + """Perform D'Agostino and Pearson's omnibus test for normality. + + This method tests the null hypothesis that a sample comes from a + normal distribution. It combines skewness and kurtosis to produce an + omnibus test of normality. + + Parameters + ---------- + alternative : {'two-sided', 'less', 'greater'}, optional + Defines the alternative hypothesis used for the component skewness + and kurtosis tests. Default is 'two-sided'. The following options + are available: + + * 'two-sided': the distribution is different from normal + * 'less': used for the component tests + * 'greater': used for the component tests + + Returns + ------- + statistic : np.ndarray + The computed z-score for the normality test for each tally bin + pvalue : np.ndarray + The p-value for the hypothesis test for each tally bin + + Raises + ------ + ValueError + If the number of realizations is less than 20, or if an invalid + alternative hypothesis is specified. + + Notes + ----- + This test combines a test for skewness and a test for kurtosis to + produce an omnibus test [1]_. The test statistic is: + + .. math:: + + K^2 = Z_1^2 + Z_2^2 + + where :math:`Z_1` is the z-score from the skewness test and + :math:`Z_2` is the z-score from the kurtosis test. This statistic + follows a chi-square distribution with 2 degrees of freedom. + + The test requires at least 20 realizations to produce valid results. + + References + ---------- + .. [1] D'Agostino, R. B. and Pearson, E. S. (1973), "Tests for + departure from normality", Biometrika, 60, 613-622 + + """ + n = self.num_realizations + if n < 20: + raise ValueError("normaltest requires n >= 20 (per D'Agostino-Pearson).") + + # Use the component tests + Z1, _ = self.skewtest(alternative) + Z2, _ = self.kurtosistest(alternative) + + # Combine as chi-square with df=2 since we have skewness and kurtosis + K2 = Z1*Z1 + Z2*Z2 + p = chi2.sf(K2, df=2) + return K2, p + @property def figure_of_merit(self): mean = self.mean std_dev = self.std_dev fom = np.zeros_like(mean) nonzero = np.abs(mean) > 0 - fom[nonzero] = 1.0 / ( - (std_dev[nonzero] / mean[nonzero])**2 * self._simulation_time) + rel_err = std_dev[nonzero] / mean[nonzero] + fom[nonzero] = 1.0 / (rel_err**2 * self._simulation_time) return fom @property @@ -528,6 +975,12 @@ class Tally(IDManagerMixin): if self._sum_sq is not None: self._sum_sq = sps.lil_matrix(self._sum_sq.flatten(), self._sum_sq.shape) + if self._sum_third is not None: + self._sum_third = sps.lil_matrix(self._sum_third.flatten(), + self._sum_third.shape) + if self._sum_fourth is not None: + self._sum_fourth = sps.lil_matrix(self._sum_fourth.flatten(), + self._sum_fourth.shape) if self._mean is not None: self._mean = sps.lil_matrix(self._mean.flatten(), self._mean.shape) @@ -543,6 +996,10 @@ class Tally(IDManagerMixin): self._sum = np.reshape(self._sum.toarray(), self.shape) if self._sum_sq is not None: self._sum_sq = np.reshape(self._sum_sq.toarray(), self.shape) + if self._sum_third is not None: + self._sum_third = np.reshape(self._sum_third.toarray(), self.shape) + if self._sum_fourth is not None: + self._sum_fourth = np.reshape(self._sum_fourth.toarray(), self.shape) if self._mean is not None: self._mean = np.reshape(self._mean.toarray(), self.shape) if self._std_dev is not None: @@ -869,6 +1326,34 @@ class Tally(IDManagerMixin): merged_tally._sum_sq = np.reshape(merged_sum_sq, merged_tally.shape) + # Concatenate sum_third arrays if present in both tallies + if self._sum_third is not None and other._sum_third is not None: + self_sum_third = self.get_reshaped_data(value="sum_third") + other_sum_third = other_copy.get_reshaped_data(value="sum_third") + + if join_right: + merged_sum_third = np.concatenate((self_sum_third, other_sum_third), + axis=merge_axis) + else: + merged_sum_third = np.concatenate((other_sum_third, self_sum_third), + axis=merge_axis) + + merged_tally._sum_third = np.reshape(merged_sum_third, merged_tally.shape) + + # Concatenate sum_fourth arrays if present in both tallies + if self._sum_fourth is not None and other._sum_fourth is not None: + self_sum_fourth = self.get_reshaped_data(value="sum_fourth") + other_sum_fourth = other_copy.get_reshaped_data(value="sum_fourth") + + if join_right: + merged_sum_fourth = np.concatenate((self_sum_fourth, other_sum_fourth), + axis=merge_axis) + else: + merged_sum_fourth = np.concatenate((other_sum_fourth, self_sum_fourth), + axis=merge_axis) + + merged_tally._sum_fourth = np.reshape(merged_sum_fourth, merged_tally.shape) + # Concatenate mean arrays if present in both tallies if self.mean is not None and other.mean is not None: self_mean = self.get_reshaped_data(value='mean') @@ -958,6 +1443,11 @@ class Tally(IDManagerMixin): subelement = ET.SubElement(element, "derivative") subelement.text = str(self.derivative.id) + # Optional higher moments accumulation + if self.higher_moments: + subelement = ET.SubElement(element, "higher_moments") + subelement.text = str(self.higher_moments).lower() + return element def add_results(self, statepoint: cv.PathLike | openmc.StatePoint): @@ -984,8 +1474,12 @@ class Tally(IDManagerMixin): # point are based on the current statepoint file self._sum = None self._sum_sq = None + self._sum_third = None + self._sum_fourth = None self._mean = None self._std_dev = None + self._vov = None + self._higher_moments = False self._num_realizations = 0 self._results_read = False @@ -1355,7 +1849,9 @@ class Tally(IDManagerMixin): (value == 'std_dev' and self.std_dev is None) or \ (value == 'rel_err' and self.mean is None) or \ (value == 'sum' and self.sum is None) or \ - (value == 'sum_sq' and self.sum_sq is None): + (value == 'sum_sq' and self.sum_sq is None) or \ + (value == "sum_third" and self.sum_third is None) or \ + (value == "sum_fourth" and self.sum_fourth is None): msg = f'The Tally ID="{self.id}" has no data to return' raise ValueError(msg) @@ -1378,10 +1874,14 @@ class Tally(IDManagerMixin): data = self.sum[indices] elif value == 'sum_sq': data = self.sum_sq[indices] + elif value == "sum_third": + data = self.sum_third[indices] + elif value == "sum_fourth": + data = self.sum_fourth[indices] else: msg = f'Unable to return results from Tally ID="{value}" since ' \ f'the requested value "{self.id}" is not \'mean\', ' \ - '\'std_dev\', \'rel_err\', \'sum\', or \'sum_sq\'' + '\'std_dev\', \'rel_err\', \'sum\', \'sum_sq\', \'sum_third\' or \'sum_fourth\'' raise LookupError(msg) return data @@ -2711,6 +3211,16 @@ class Tally(IDManagerMixin): new_sum_sq = self.get_values(scores, filters, filter_bins, nuclides, 'sum_sq') new_tally.sum_sq = new_sum_sq + if not self.derived and self._sum_third is not None: + new_sum_third = self.get_values( + scores, filters, filter_bins, nuclides, "sum_third" + ) + new_tally._sum_third = new_sum_third + if not self.derived and self._sum_fourth is not None: + new_sum_fourth = self.get_values( + scores, filters, filter_bins, nuclides, "sum_fourth" + ) + new_tally._sum_fourth = new_sum_fourth if self.mean is not None: new_mean = self.get_values(scores, filters, filter_bins, nuclides, 'mean') @@ -3151,6 +3661,12 @@ class Tally(IDManagerMixin): if not self.derived and self.sum_sq is not None: new_tally._sum_sq = np.zeros(new_tally.shape, dtype=np.float64) new_tally._sum_sq[diag_indices, :, :] = self.sum_sq + if not self.derived and self._sum_third is not None: + new_tally._sum_third = np.zeros(new_tally.shape, dtype=np.float64) + new_tally._sum_third[diag_indices, :, :] = self.sum_third + if not self.derived and self._sum_fourth is not None: + new_tally._sum_fourth = np.zeros(new_tally.shape, dtype=np.float64) + new_tally._sum_fourth[diag_indices, :, :] = self.sum_fourth if self.mean is not None: new_tally._mean = np.zeros(new_tally.shape, dtype=np.float64) new_tally._mean[diag_indices, :, :] = self.mean diff --git a/src/hdf5_interface.cpp b/src/hdf5_interface.cpp index bf1f79549e..c56d485e28 100644 --- a/src/hdf5_interface.cpp +++ b/src/hdf5_interface.cpp @@ -536,14 +536,14 @@ void read_complex( H5Tclose(complex_id); } -void read_tally_results( - hid_t group_id, hsize_t n_filter, hsize_t n_score, double* results) +void read_tally_results(hid_t group_id, hsize_t n_filter, hsize_t n_score, + hsize_t n_results, double* results) { // Create dataspace for hyperslab in memory constexpr int ndim = 3; - hsize_t dims[ndim] {n_filter, n_score, 3}; + hsize_t dims[ndim] {n_filter, n_score, n_results}; hsize_t start[ndim] {0, 0, 1}; - hsize_t count[ndim] {n_filter, n_score, 2}; + hsize_t count[ndim] {n_filter, n_score, n_results - 1}; hid_t memspace = H5Screate_simple(ndim, dims, nullptr); H5Sselect_hyperslab(memspace, H5S_SELECT_SET, start, nullptr, count, nullptr); @@ -686,15 +686,15 @@ void write_string( group_id, 0, nullptr, buffer.length(), name, buffer.c_str(), indep); } -void write_tally_results( - hid_t group_id, hsize_t n_filter, hsize_t n_score, const double* results) +void write_tally_results(hid_t group_id, hsize_t n_filter, hsize_t n_score, + hsize_t n_results, const double* results) { // Set dimensions of sum/sum_sq hyperslab to store constexpr int ndim = 3; - hsize_t count[ndim] {n_filter, n_score, 2}; + hsize_t count[ndim] {n_filter, n_score, n_results - 1}; // Set dimensions of results array - hsize_t dims[ndim] {n_filter, n_score, 3}; + hsize_t dims[ndim] {n_filter, n_score, n_results}; hsize_t start[ndim] {0, 0, 1}; hid_t memspace = H5Screate_simple(ndim, dims, nullptr); H5Sselect_hyperslab(memspace, H5S_SELECT_SET, start, nullptr, count, nullptr); diff --git a/src/state_point.cpp b/src/state_point.cpp index 8195c48650..0b0fed1324 100644 --- a/src/state_point.cpp +++ b/src/state_point.cpp @@ -201,6 +201,12 @@ extern "C" int openmc_statepoint_write(const char* filename, bool* write_source) write_attribute(tally_group, "multiply_density", 0); } + if (tally->higher_moments()) { + write_attribute(tally_group, "higher_moments", 1); + } else { + write_attribute(tally_group, "higher_moments", 0); + } + if (tally->estimator_ == TallyEstimator::ANALOG) { write_dataset(tally_group, "estimator", "analog"); } else if (tally->estimator_ == TallyEstimator::TRACKLENGTH) { @@ -264,12 +270,13 @@ extern "C" int openmc_statepoint_write(const char* filename, bool* write_source) for (const auto& tally : model::tallies) { if (!tally->writable_) continue; - // Write sum and sum_sq for each bin + + // Write results for each bin std::string name = "tally " + std::to_string(tally->id_); hid_t tally_group = open_group(tallies_group, name.c_str()); auto& results = tally->results_; write_tally_results(tally_group, results.shape()[0], - results.shape()[1], results.data()); + results.shape()[1], results.shape()[2], results.data()); close_group(tally_group); } } else { @@ -509,7 +516,8 @@ extern "C" int openmc_statepoint_load(const char* filename) } else { auto& results = tally->results_; read_tally_results(tally_group, results.shape()[0], - results.shape()[1], results.data()); + results.shape()[1], results.shape()[2], results.data()); + read_dataset(tally_group, "n_realizations", tally->n_realizations_); close_group(tally_group); } @@ -1001,7 +1009,8 @@ void write_tally_results_nr(hid_t file_id) // Write reduced tally results to file auto shape = results_copy.shape(); - write_tally_results(tally_group, shape[0], shape[1], results_copy.data()); + write_tally_results( + tally_group, shape[0], shape[1], shape[2], results_copy.data()); close_group(tally_group); } else { diff --git a/src/tallies/tally.cpp b/src/tallies/tally.cpp index 12ee3427eb..9daeb1d69f 100644 --- a/src/tallies/tally.cpp +++ b/src/tallies/tally.cpp @@ -107,6 +107,9 @@ Tally::Tally(pugi::xml_node node) multiply_density_ = get_node_value_bool(node, "multiply_density"); } + if (check_for_node(node, "higher_moments")) { + higher_moments_ = get_node_value_bool(node, "higher_moments"); + } // ======================================================================= // READ DATA FOR FILTERS @@ -800,7 +803,11 @@ void Tally::init_triggers(pugi::xml_node node) void Tally::init_results() { int n_scores = scores_.size() * nuclides_.size(); - results_ = xt::empty({n_filter_bins_, n_scores, 3}); + if (higher_moments_) { + results_ = xt::empty({n_filter_bins_, n_scores, 5}); + } else { + results_ = xt::empty({n_filter_bins_, n_scores, 3}); + } } void Tally::reset() @@ -838,14 +845,33 @@ void Tally::accumulate() norm = 1.0; } -// Accumulate each result + // Accumulate each result + if (higher_moments_) { #pragma omp parallel for - for (int i = 0; i < results_.shape()[0]; ++i) { - for (int j = 0; j < results_.shape()[1]; ++j) { - double val = results_(i, j, TallyResult::VALUE) * norm; - results_(i, j, TallyResult::VALUE) = 0.0; - results_(i, j, TallyResult::SUM) += val; - results_(i, j, TallyResult::SUM_SQ) += val * val; + // filter bins (specific cell, energy bins) + for (int i = 0; i < results_.shape()[0]; ++i) { + // score bins (flux, total reaction rate, fission reaction rate, etc.) + for (int j = 0; j < results_.shape()[1]; ++j) { + double val = results_(i, j, TallyResult::VALUE) * norm; + double val2 = val * val; + results_(i, j, TallyResult::VALUE) = 0.0; + results_(i, j, TallyResult::SUM) += val; + results_(i, j, TallyResult::SUM_SQ) += val2; + results_(i, j, TallyResult::SUM_THIRD) += val2 * val; + results_(i, j, TallyResult::SUM_FOURTH) += val2 * val2; + } + } + } else { +#pragma omp parallel for + // filter bins (specific cell, energy bins) + for (int i = 0; i < results_.shape()[0]; ++i) { + // score bins (flux, total reaction rate, fission reaction rate, etc.) + for (int j = 0; j < results_.shape()[1]; ++j) { + double val = results_(i, j, TallyResult::VALUE) * norm; + results_(i, j, TallyResult::VALUE) = 0.0; + results_(i, j, TallyResult::SUM) += val; + results_(i, j, TallyResult::SUM_SQ) += val * val; + } } } } diff --git a/src/weight_windows.cpp b/src/weight_windows.cpp index 674fae49c3..9333800bc3 100644 --- a/src/weight_windows.cpp +++ b/src/weight_windows.cpp @@ -547,8 +547,10 @@ void WeightWindows::update_weights(const Tally* tally, const std::string& value, // build a shape for a view of the tally results, this will always be // dimension 5 (3 filter dimensions, 1 score dimension, 1 results dimension) - std::array shape = { - 1, 1, 1, tally->n_scores(), static_cast(TallyResult::SIZE)}; + // Look for the size of the last dimension of the results array + const auto& results_arr = tally->results(); + const int results_dim = static_cast(results_arr.shape()[2]); + std::array shape = {1, 1, 1, tally->n_scores(), results_dim}; // set the shape for the filters applied on the tally for (int i = 0; i < tally->filters().size(); i++) { @@ -586,7 +588,7 @@ void WeightWindows::update_weights(const Tally* tally, const std::string& value, // get a fully reshaped view of the tally according to tally ordering of // filters - auto tally_values = xt::reshape_view(tally->results(), shape); + auto tally_values = xt::reshape_view(results_arr, shape); // get a that is (particle, energy, mesh, scores, values) auto transposed_view = xt::transpose(tally_values, transpose); diff --git a/tests/unit_tests/test_tallies.py b/tests/unit_tests/test_tallies.py index c38f067d58..7b1bf0a2fe 100644 --- a/tests/unit_tests/test_tallies.py +++ b/tests/unit_tests/test_tallies.py @@ -1,6 +1,8 @@ +from math import sqrt import numpy as np import pytest import openmc +import scipy.stats as sps def test_xml_roundtrip(run_in_tmpdir): @@ -163,3 +165,214 @@ def test_tally_application(sphere_model, run_in_tmpdir): assert (sp_tally.std_dev == tally.std_dev).all() assert (sp_tally.mean == tally.mean).all() assert sp_tally.nuclides == tally.nuclides + +def _tally_from_data(x, *, higher_moments=True, normality=True): + t = openmc.Tally() + t.scores = ["flux"] # 1 score + t.nuclides = [openmc.Nuclide("H1")] # 1 nuclide + t._sp_filename = "dummy.h5" # mark "results available" + t._results_read = True # don't try to read from disk + t._num_realizations = int(len(x)) # n + t.higher_moments = bool(higher_moments) + + x = np.asarray(x, dtype=float) + # (num_filter_bins=1, num_nuclides=1, num_scores=1) -> (1,1,1) arrays + t._sum = np.array([[[np.sum(x)]]], dtype=float) + t._sum_sq = np.array([[[np.sum(x**2)]]], dtype=float) + if higher_moments: + t._sum_third = np.array([[[np.sum(x**3)]]], dtype=float) + t._sum_fourth = np.array([[[np.sum(x**4)]]], dtype=float) + return t + +@pytest.mark.parametrize( + "x, skew_true, kurt_true", + [ # Rademacher distribution + (np.array([1.0, -1.0] * 200), 0.0, 1.0), + # Two-point {0,3} with p(0)=3/4, p(3)=1/4 + (np.concatenate([np.zeros(600), np.full(200, 3.0)]), 2.0 / sqrt(3.0), 7.0 / 3.0), + # Bernoulli distribution + (np.concatenate([np.ones(300), np.zeros(700)]), (1 - 2 * 0.3) / sqrt(0.3 * 0.7), (1 - 3 * 0.3 + 3 * 0.3**2) / (0.3 * 0.7)), + ], +) +def test_b1_b2_analytical_against_tally(x, skew_true, kurt_true): + t = _tally_from_data(x, higher_moments=True, normality=False) + + g1 = t.skew(bias=True)[0, 0, 0] + b2 = t.kurtosis(bias=True, fisher=False)[0, 0, 0] + + assert np.isclose(g1, skew_true, rtol=0, atol=1e-12) + assert np.isclose(b2, kurt_true, rtol=0, atol=1e-12) + +@pytest.mark.parametrize( + "draw, skew_true, kurt_true", + [(lambda rng, n: rng.normal(0, 1, n), 0.0, 3.0), # Normal + (lambda rng, n: rng.random(n), 0.0, 1.8), # Uniform(0,1) + (lambda rng, n: rng.exponential(1.0, n), 2.0, 9.0), # Exp(1) + (lambda rng, n: (rng.random(n) < 0.3).astype(float), + (1 - 2 * 0.3) / sqrt(0.3 * 0.7), + (1 - 3 * 0.3 + 3 * 0.3**2) / (0.3 * 0.7),),],) + +def test_b1_b2_scipy_and_theory(draw, skew_true, kurt_true): + rng = np.random.default_rng(12345) + N = 200_000 + x = draw(rng, N) + + # Tally outputs + t = _tally_from_data(x, higher_moments=True, normality=False) + g1_t = t.skew(bias=True)[0, 0, 0] + b2_t = t.kurtosis(bias=True, fisher=False)[0, 0, 0] + + # SciPy (population, bias=True to match population-moment style) + skew_sp = sps.skew(x, bias=True) + kurt_sp = sps.kurtosis(x, fisher=False, bias=True) + + # Compare to SciPy numerically + assert np.isclose(g1_t, skew_sp, rtol=0, atol=5e-3) + assert np.isclose(b2_t, kurt_sp, rtol=0, atol=5e-3) + + # Compare to analytical targets with size-dependent tolerances + tol_skew = 0.02 if abs(skew_true) < 0.5 else 0.05 + tol_kurt = 0.03 if kurt_true < 4 else 0.1 + assert abs(g1_t - skew_true) < tol_skew + assert abs(b2_t - kurt_true) < tol_kurt + + +def test_kurtosis_bias_fisher_combinations(): + """Test that all combinations of bias and fisher match scipy.stats.kurtosis""" + rng = np.random.default_rng(42) + x = rng.normal(0, 1, 10000) + + t = _tally_from_data(x, higher_moments=True, normality=False) # Test all four combinations + # 1. bias=True, fisher=False (Pearson's kurtosis, b2) + b2_tally = t.kurtosis(bias=True, fisher=False)[0, 0, 0] + b2_scipy = sps.kurtosis(x, fisher=False, bias=True) + assert np.isclose(b2_tally, b2_scipy, rtol=0, atol=1e-10) + assert np.isclose(b2_tally, 3.0, rtol=0.05, atol=0.1) # Should be ~3 for normal + + # 2. bias=True, fisher=True (excess kurtosis, g2) + g2_tally = t.kurtosis(bias=True, fisher=True)[0, 0, 0] + g2_scipy = sps.kurtosis(x, fisher=True, bias=True) + assert np.isclose(g2_tally, g2_scipy, rtol=0, atol=1e-10) + assert np.isclose(g2_tally, 0.0, rtol=0, atol=0.1) # Should be ~0 for normal + assert np.isclose(g2_tally, b2_tally - 3.0, rtol=0, atol=1e-10) # g2 = b2 - 3 + + # 3. bias=False, fisher=True (adjusted excess kurtosis, G2) + G2_tally = t.kurtosis(bias=False, fisher=True)[0, 0, 0] + G2_tally_default = t.kurtosis()[0, 0, 0] # Should be same as default + G2_scipy = sps.kurtosis(x, fisher=True, bias=False) + assert np.isclose(G2_tally, G2_tally_default, rtol=0, atol=1e-10) + assert np.isclose(G2_tally, G2_scipy, rtol=0, atol=1e-10) + assert np.isclose(G2_tally, 0.0, rtol=0, atol=0.1) # Should be ~0 for normal + + # 4. bias=False, fisher=False (adjusted Pearson's kurtosis) + adj_b2_tally = t.kurtosis(bias=False, fisher=False)[0, 0, 0] + adj_b2_scipy = sps.kurtosis(x, fisher=False, bias=False) + assert np.isclose(adj_b2_tally, adj_b2_scipy, rtol=0, atol=1e-10) + assert np.isclose(adj_b2_tally, 3.0, rtol=0.05, atol=0.1) # Should be ~3 for normal + assert np.isclose(adj_b2_tally, G2_tally + 3.0, rtol=0, atol=1e-10) # adj_b2 = G2 + 3 + + +def test_ztests_scipy_comparison(): + rng = np.random.default_rng(987) + x_norm = rng.normal(size=50_000) + x_exp = rng.exponential(size=50_000) + + # -------- Normal dataset (should not reject) -------- + t0 = _tally_from_data(x_norm, higher_moments=True, normality=True) + Zb1_0, p_skew_0 = t0.skewtest(alternative="two-sided") + Zb2_0, p_kurt_0 = t0.kurtosistest(alternative="two-sided") + K2_0, p_omni_0 = t0.normaltest(alternative="two-sided") + + Zb1_0 = Zb1_0.ravel()[0] + p_skew_0 = p_skew_0.ravel()[0] + Zb2_0 = Zb2_0.ravel()[0] + p_kurt_0 = p_kurt_0.ravel()[0] + K2_0 = K2_0.ravel()[0] + p_omni_0 = p_omni_0.ravel()[0] + + z_skew_sp0, p_skew_sp0 = sps.skewtest(x_norm) + z_kurt_sp0, p_kurt_sp0 = sps.kurtosistest(x_norm) + k2_sp0, p_omni_sp0 = sps.normaltest(x_norm) + + assert np.isclose(Zb1_0, z_skew_sp0, atol=0.15) + assert np.isclose(Zb2_0, z_kurt_sp0, atol=0.15) + assert np.isclose(K2_0, k2_sp0, atol=0.30) + assert np.isclose(p_skew_0, p_skew_sp0, atol=5e-3) + assert np.isclose(p_kurt_0, p_kurt_sp0, atol=5e-3) + assert np.isclose(p_omni_0, p_omni_sp0, atol=5e-3) + + # -------- Exponential dataset (should strongly reject) -------- + t1 = _tally_from_data(x_exp, higher_moments=True, normality=True) + + Zb1_1, p_skew_1 = t1.skewtest(alternative="two-sided") + Zb2_1, p_kurt_1 = t1.kurtosistest(alternative="two-sided") + K2_1, p_omni_1 = t1.normaltest(alternative="two-sided") + + Zb1_1 = Zb1_1.ravel()[0] + p_skew_1 = p_skew_1.ravel()[0] + Zb2_1 = Zb2_1.ravel()[0] + p_kurt_1 = p_kurt_1.ravel()[0] + K2_1 = K2_1.ravel()[0] + p_omni_1 = p_omni_1.ravel()[0] + + z_skew_sp1, p_skew_sp1 = sps.skewtest(x_exp) + z_kurt_sp1, p_kurt_sp1 = sps.kurtosistest(x_exp) + k2_sp1, p_omni_sp1 = sps.normaltest(x_exp) + + # Both pipelines should reject very strongly + assert p_skew_1 < 1e-6 and p_skew_sp1 < 1e-6 + assert p_kurt_1 < 1e-6 and p_kurt_sp1 < 1e-6 + assert p_omni_1 < 1e-6 and p_omni_sp1 < 1e-6 + + # Right-skewed and heavy-tailed → large positive Z-statistics + assert Zb1_1 > 30 and z_skew_sp1 > 30 + assert Zb2_1 > 30 and z_kurt_sp1 > 30 + assert K2_1 > 2000 and k2_sp1 > 2000 + +def test_vov_stochastic(sphere_model, run_in_tmpdir): + tally = openmc.Tally(name="test tally") + ef = openmc.EnergyFilter([0.0, 0.1, 1.0, 10.0e6]) + mesh = openmc.RegularMesh.from_domain(sphere_model.geometry, (2, 2, 2)) + mf = openmc.MeshFilter(mesh) + tally.filters = [ef, mf] + tally.scores = ["flux", "absorption", "fission", "scatter"] + tally.higher_moments = True + sphere_model.tallies = [tally] + + sp_file = sphere_model.run(apply_tally_results=True) + + assert tally._mean is None + assert tally._std_dev is None + assert tally._sum is None + assert tally._sum_sq is None + assert tally._sum_third is None + assert tally._sum_fourth is None + assert tally._num_realizations == 0 + assert tally._sp_filename == sp_file + + with openmc.StatePoint(sp_file) as sp: + assert tally in sp.tallies.values() + sp_tally = sp.tallies[tally.id] + + assert np.all(sp_tally.std_dev == tally.std_dev) + assert np.all(sp_tally.mean == tally.mean) + assert np.all(sp_tally.vov == tally.vov) + assert sp_tally.nuclides == tally.nuclides + + n = sp_tally.num_realizations + mean = sp_tally.mean + sum_ = sp_tally._sum + sum_sq = sp_tally._sum_sq + sum_third = sp_tally._sum_third + sum_fourth = sp_tally._sum_fourth + + expected_vov = np.zeros_like(mean) + nonzero = np.abs(mean) > 0 + + num = (sum_fourth - (4.0*sum_third*sum_)/n + (6.0*sum_sq*sum_**2)/(n**2) + - (3.0*sum_**4)/(n**3)) + den = (sum_sq - (1.0/n)*sum_**2)**2 + + expected_vov[nonzero] = num[nonzero]/den[nonzero] - 1.0/n + + assert np.allclose(expected_vov, sp_tally.vov, rtol=1e-7, atol=0.0) From 4e24c2d933dcf92b4ddb51dfca362a20ef82f2f6 Mon Sep 17 00:00:00 2001 From: April Novak Date: Wed, 12 Nov 2025 15:59:45 -0600 Subject: [PATCH 19/26] Update documentation for particle tracks (#3627) Co-authored-by: Paul Romano --- docs/source/usersguide/processing.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/source/usersguide/processing.rst b/docs/source/usersguide/processing.rst index 8b5ae53fac..fe6ab0826f 100644 --- a/docs/source/usersguide/processing.rst +++ b/docs/source/usersguide/processing.rst @@ -68,17 +68,17 @@ generation, and particle number of the desired particle. For example, to create a track file for particle 4 of batch 1 and generation 2:: settings = openmc.Settings() - settings.track = (1, 2, 4) + settings.track = [(1, 2, 4)] -To specify multiple particles, the length of the iterable should be a multiple -of three, e.g., if we wanted particles 3 and 4 from batch 1 and generation 2:: +To specify multiple particles, specify a list of tuples, e.g., if we wanted +particles 3 and 4 from batch 1 and generation 2:: - settings.track = (1, 2, 3, 1, 2, 4) + settings.track = [(1, 2, 3), (1, 2, 4)] -After running OpenMC, the working directory will contain a file of the form -"track_(batch #)_(generation #)_(particle #).h5" for each particle tracked. -These track files can be converted into VTK poly data files with the -:class:`openmc.Tracks` class. +After running OpenMC (now, without the ``-t`` argument), the working directory +will contain a file named `tracks.h5`, which contains a collection of particle +tracks. These track files can be converted into VTK poly data files or +matplotlib plots with the :class:`openmc.Tracks` class. ---------------------- Source Site Processing From 50bb0df191053e6fc58b9d779022402ac159115e Mon Sep 17 00:00:00 2001 From: Lorenzo Chierici Date: Wed, 12 Nov 2025 23:45:48 +0100 Subject: [PATCH 20/26] depletion-thermochemistry: Redox control transfer rates (#2783) Co-authored-by: Gavin Ridley Co-authored-by: Paul Romano --- openmc/deplete/abc.py | 30 ++++++++++ openmc/deplete/chain.py | 54 ++++++++++++++++++ openmc/deplete/pool.py | 13 +++++ openmc/deplete/transfer_rates.py | 43 +++++++++++++- .../ref_depletion_with_ext_source.h5 | Bin 37328 -> 37328 bytes .../ref_depletion_with_feed.h5 | Bin 37328 -> 37328 bytes .../ref_depletion_with_redox.h5 | Bin 0 -> 37328 bytes .../ref_depletion_with_removal.h5 | Bin 37328 -> 37328 bytes .../ref_depletion_with_removal_and_redox.h5 | Bin 0 -> 37328 bytes .../ref_depletion_with_transfer.h5 | Bin 37328 -> 37328 bytes .../ref_depletion_with_transfer_and_redox.h5 | Bin 0 -> 37328 bytes .../ref_no_depletion_only_feed.h5 | Bin 37328 -> 37328 bytes .../ref_no_depletion_only_removal.h5 | Bin 37328 -> 37328 bytes .../ref_no_depletion_with_ext_source.h5 | Bin 37328 -> 37328 bytes .../ref_no_depletion_with_transfer.h5 | Bin 37328 -> 37328 bytes .../deplete_with_transfer_rates/test.py | 22 ++++--- .../unit_tests/test_deplete_transfer_rates.py | 32 +++++++++++ 17 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 tests/regression_tests/deplete_with_transfer_rates/ref_depletion_with_redox.h5 create mode 100644 tests/regression_tests/deplete_with_transfer_rates/ref_depletion_with_removal_and_redox.h5 create mode 100644 tests/regression_tests/deplete_with_transfer_rates/ref_depletion_with_transfer_and_redox.h5 diff --git a/openmc/deplete/abc.py b/openmc/deplete/abc.py index c5a6219c7b..fb18d86afc 100644 --- a/openmc/deplete/abc.py +++ b/openmc/deplete/abc.py @@ -1013,6 +1013,36 @@ class Integrator(ABC): material, composition, rate, rate_units, timesteps) + def add_redox(self, material, buffer, oxidation_states, timesteps=None): + """Add redox control to depletable material. + + Parameters + ---------- + material : openmc.Material or str or int + Depletable material + buffer : dict + Dictionary of buffer nuclides used to maintain redox balance. Keys + are nuclide names (strings) and values are their respective + fractions (float) that collectively sum to 1. + oxidation_states : dict + User-defined oxidation states for elements. Keys are element symbols + (e.g., 'H', 'He'), and values are their corresponding oxidation + states as integers (e.g., +1, 0). + timesteps : list of int, optional + List of timestep indices where to set external source rates. + Defaults to None, which means the external source rate is set for + all timesteps. + """ + if self.transfer_rates is None: + if hasattr(self.operator, 'model'): + materials = self.operator.model.materials + elif hasattr(self.operator, 'materials'): + materials = self.operator.materials + self.transfer_rates = TransferRates( + self.operator, materials, len(self.timesteps)) + + self.transfer_rates.set_redox(material, buffer, oxidation_states, timesteps) + @add_params class SIIntegrator(Integrator): r"""Abstract class for the Stochastic Implicit Euler integrators diff --git a/openmc/deplete/chain.py b/openmc/deplete/chain.py index ac5c02aa50..f34416d561 100644 --- a/openmc/deplete/chain.py +++ b/openmc/deplete/chain.py @@ -7,6 +7,7 @@ loaded from an .xml file and all the nuclides are linked together. from io import StringIO from itertools import chain import math +import numpy as np import re from collections import defaultdict, namedtuple from collections.abc import Mapping, Iterable @@ -714,6 +715,59 @@ class Chain: # Return CSC representation instead of DOK return sp.csc_matrix((vals, (rows, cols)), shape=(n, n)) + def add_redox_term(self, matrix, buffer, oxidation_states): + """Adds a redox term to the depletion matrix from data contained in + the matrix itself and a few user-inputs. + + The redox term to add to the buffer nuclide :math:`N_j` can be written + as: :math:`\frac{dN_j(t)}{dt} = + \cdots - \frac{1}{OS_j}\sum_i N_i a_{ij} \cdot OS_i ` + + where :math:`OS` is the oxidation states vector and `a_{ij}` the + corresponding term in the Bateman matrix. + + Parameters + ---------- + matrix : scipy.sparse.csc_matrix + Sparse matrix representing depletion + buffer : dict + Dictionary of buffer nuclides used to maintain anoins net balance. + Keys are nuclide names (strings) and values are their respective + fractions (float) that collectively sum to 1. + oxidation_states : dict + User-defined oxidation states for elements. Keys are element symbols + (e.g., 'H', 'He'), and values are their corresponding oxidation + states as integers (e.g., +1, 0). + Returns + ------- + matrix : scipy.sparse.csc_matrix + Sparse matrix with redox term added + """ + # Elements list with the same size as self.nuclides + elements = [re.split(r'\d+', nuc.name)[0] for nuc in self.nuclides] + + # Match oxidation states with all elements and add 0 if not data + os = np.array([oxidation_states[elm] if elm in oxidation_states else 0 + for elm in elements]) + + # Buffer idx with nuclide index as value + buffer_idx = {nuc: self.nuclide_dict[nuc] for nuc in buffer} + array = matrix.toarray() + redox_change = np.array([]) + + # calculate the redox array + for i in range(len(self)): + # Net redox impact of reaction: multiply the i-th column of the + # depletion matrix by the oxidation states + redox_change = np.append(redox_change, sum(array[:, i]*os)) + + # Subtract redox vector to the buffer nuclides in the matrix scaling by + # their respective oxidation states + for nuc, idx in buffer_idx.items(): + array[idx] -= redox_change * buffer[nuc] / os[idx] + + return sp.csc_matrix(array) + def form_rr_term(self, tr_rates, current_timestep, mats): """Function to form the transfer rate term matrices. diff --git a/openmc/deplete/pool.py b/openmc/deplete/pool.py index 03b050af38..aa348c02aa 100644 --- a/openmc/deplete/pool.py +++ b/openmc/deplete/pool.py @@ -109,6 +109,13 @@ def deplete(func, chain, n, rates, dt, current_timestep=None, matrix_func=None, matrices = [matrix - transfer for (matrix, transfer) in zip(matrices, transfers)] + if transfer_rates.redox: + for mat_idx, mat_id in enumerate(transfer_rates.local_mats): + if mat_id in transfer_rates.redox: + matrices[mat_idx] = chain.add_redox_term(matrices[mat_idx], + transfer_rates.redox[mat_id][0], + transfer_rates.redox[mat_id][1]) + if current_timestep in transfer_rates.index_transfer: # Gather all on comm.rank 0 matrices = comm.gather(matrices) @@ -125,6 +132,12 @@ def deplete(func, chain, n, rates, dt, current_timestep=None, matrix_func=None, transfer_matrix = chain.form_rr_term(transfer_rates, current_timestep, mat_pair) + + # check if destination material has a redox control + if mat_pair[0] in transfer_rates.redox: + transfer_matrix = chain.add_redox_term(transfer_matrix, + transfer_rates.redox[mat_pair[0]][0], + transfer_rates.redox[mat_pair[0]][1]) transfer_pair[mat_pair] = transfer_matrix # Combine all matrices together in a single matrix of matrices diff --git a/openmc/deplete/transfer_rates.py b/openmc/deplete/transfer_rates.py index 4f2b9aba5f..ea9fc9185e 100644 --- a/openmc/deplete/transfer_rates.py +++ b/openmc/deplete/transfer_rates.py @@ -49,9 +49,10 @@ class ExternalRates: self.local_mats = operator.local_mats self.number_of_timesteps = number_of_timesteps - # initialize transfer rates container dict + #initialize transfer rates container dict self.external_rates = {mat: defaultdict(list) for mat in self.burnable_mats} self.external_timesteps = [] + self.redox = {} def _get_material_id(self, val): """Helper method for getting material id from Material obj or name. @@ -300,6 +301,46 @@ class TransferRates(ExternalRates): self.external_timesteps = np.unique(np.concatenate( [self.external_timesteps, timesteps])) + def set_redox(self, material, buffer, oxidation_states, timesteps=None): + """Add redox control to depletable material. + + Parameters + ---------- + material : openmc.Material or str or int + Depletable material + buffer : dict + Dictionary of buffer nuclides used to maintain redox balance. + Keys are nuclide names (strings) and values are their respective + fractions (float) that collectively sum to 1. + oxidation_states : dict + User-defined oxidation states for elements. + Keys are element symbols (e.g., 'H', 'He'), and values are their + corresponding oxidation states as integers (e.g., +1, 0). + timesteps : list of int, optional + List of timestep indices where to set external source rates. + Defaults to None, which means the external source rate is set for + all timesteps. + + """ + material_id = self._get_material_id(material) + if timesteps is not None: + for timestep in timesteps: + check_value('timestep', timestep, range(self.number_of_timesteps)) + timesteps = np.array(timesteps) + else: + timesteps = np.arange(self.number_of_timesteps) + #Check nuclides in buffer exist + for nuc in buffer: + if nuc not in self.chain_nuclides: + raise ValueError(f'{nuc} is not a valid nuclide.') + # Checks element in oxidation states exist + for elm in oxidation_states: + if elm not in ELEMENT_SYMBOL.values(): + raise ValueError(f'{elm} is not a valid element.') + + self.redox[material_id] = (buffer, oxidation_states) + self.external_timesteps = np.unique(np.concatenate( + [self.external_timesteps, timesteps])) class ExternalSourceRates(ExternalRates): """Class for defining external source rates. diff --git a/tests/regression_tests/deplete_with_transfer_rates/ref_depletion_with_ext_source.h5 b/tests/regression_tests/deplete_with_transfer_rates/ref_depletion_with_ext_source.h5 index 3ed38f3d6ce3973fd63e7967b1246c8012ebf063..2f9951a42aaa0613c0b4a20351ae2c0df985b0bc 100644 GIT binary patch delta 569 zcmcbxnCZe|rVS2#j53oQ`{L`jC9l$uo6Bd%00mo5bLh;i`vB*6-8Fg^a%@Z|^%tzWFI=h?g8OIAU8Q+5L;d0Wiy{t}+CheB5fg=BG-o^RH!sw#G-cKV@?+ lvt52=hS{y(9<~z?PT;!2+W+$S!`t>#eokIBQHcfQX8^fN!^Qvr delta 569 zcmcbxnCZe|rVS2#jIxs*`{L_&C$G|wo6Bd%00p~FbLh;i`vB+9zH9U>A<9GwvP;sMMu~xzzyKY&yn6*$7~OC!0S~X{%IR5 zfIH~(R?pA-SLnmlFa6CcVRLUEoFD6wD}C|0DV%?T@qMWGIZr5Ghk>DNxAxX|6D;8h z!uRO(8|Rq7`3BtGg7OzW*zbHj`BcA>Pr~V`_9Ai?hL(RfIj?-IXu)%AiL?06R;z-| zJDfKbB~3rKWrnlezFmg858|97_9uO*KcDM-K)2w9eprA@^!-^6e!oB9@-EZjXwU$|5)1ozM82TJp1hWf+#f#MFA+A<9Gwl@uqMMu~xzztx@&yn6*$7~OC0RQR_|FjJj zz#SyJ&GYmA75Z@X7k=|f*xcI(=U2MqN?*Kg3g_=&d>`t4&J)VlVPL4(t-bZ#1WULA z?L9jE#yKW%zAtyTp!|go_8VVMKGm;eYMy)4p7roO@nbax&i1Xxp0O+IIp?XaT-X2G z-&si`evf9Gw)3wSZ=X!ra>~idzshk<{vD^2lNBe#*FWgkJSnRZ5!7DGvL?Kcc@MWp zd+k@Q#?^?R{&hwBn8;rdxct>wS&HXgE{F3E1^K6nY*B*qt=xn)Z+)<~W1IU>{{7^C zw(F$#N!OoJx4W+YZrzO+z@S!>jLbeY@4xMJnS~F(zr1SurDjo);Q>Xs0cWOPU+YrE z4iD;j&zaoGrw~D1U8cVxayP=ly?f^ssVzJN*YBUTQRZ1TBB)g*n)PzcgQ4=kptjt2 mSHW(N1zbbsbFtQv79%*n?DLVpyHy|US4U4?HBpHL6x0C2(a??n delta 688 zcmcbxnCZe|rVS2#jIxs*`{L{8C9l$uo6Bd%00nDKbLh;i`vB+fxNGz*A<9Gw&x6vMMu~xzzyKc&yn6*$7~OCz|~bB{%IR5 zfIH~sR?pA-SLnmlXaDAvu(`Jn&Np<)mA-i06wW`*_&(J8oF|m8!@$tGTYKxf36^jL zA$xTCjdM)kd~fb5d`FEVoPga}|Uw^G*^Q5dwL{Jwl%bM^)<~`gZ z$F*O%8doEN`oR_LVY`dXJN zc6d-1dCufcK7|PCiZcBbk-HHV?%q4ENNwRExPHH^jWW-&5kW03(X5wi9t@QS2DQt^ ny9#!DEZ`c-pNqAYv>3tp)t`?9-mUs*?+`Y5)kGy0P*4K^K)BOB diff --git a/tests/regression_tests/deplete_with_transfer_rates/ref_depletion_with_redox.h5 b/tests/regression_tests/deplete_with_transfer_rates/ref_depletion_with_redox.h5 new file mode 100644 index 0000000000000000000000000000000000000000..c7f002ba14a29133887bb7b7d06cc0a6378a71ec GIT binary patch literal 37328 zcmeHPeQ;FO6~Avcfh9yuC`v$t7p=1Lp_>peKz6fn7fjTaC^+32JGdcP$l820A53gf znQGfOjwQ5KV~c6{LyaHUv}0`f7>s4AFzql2Gg_xm8gYi9ovK@Hhg6%S@4b7@d)b?P zyX+&2TRi*6-goXj_uO-T_x#Sg@9lg0Sh;`QWm)sGnEG)zSO&|{4)JRoK7+e>4a(Ra zgaZl64k&wV{6JDHs%?2tp9A(~JFn=2`i*z5UCWq_p?xf&nRQO%N5xQ4sc5U8p!Q8) zmpTIFYs)ItIbytEoCUq9op~Ifj;0x(gGr(%d7oPPlv6hevd=|zPk z%MD*&@0VC6YlnVbe|!U4QczTK%Hr#BPBJzKJU~B1Uf?_juHg1i?<~lUS-id&hVy#j zkNZWnnw@utXL64^!7-i(m}wsPqWq_E(*pr94Xlteu3QR3cv;a*Rx~c;+3M|^D~1ldUuu?@T##QWFA0E?7!v`r zy!4*HbIcc0m6woDVqQs>Pw1CbJ{|A4>YFc)zNYXg_I_X7uOzy@_Flq8u;7vv-0M-G^F&EW^A z7XZ0^F0ao6y{>uDdIKO2&*$|r=&=RSdQOnbbp=#R&l1n8B3{OQv02(awW_tTUT$cs z-yCX|anGvG$ZX#V+fb#iSDUz`>{`|RZgZ%rn%_RFY7I@Wl*o=*zKX!QQNGe(rmqAD znB}W3h$HNd`C+Q^74l5XHwHhf@hlS@HtbmA8QL-9S>PHz5b+EngvxjbnDLB39AV#7 z@k~5l8~m`wvkY+9uw#v9Xvd6a12P|oc!m)|WjqATcvdS%_evGd#QmH^u*G2bA70){J`8a(rp1}`mJj0)p4LjC&hIY(&*13rL zO+1T^6Ov*hV8*jzh$HNkDxQtNc_9zRGx%YRXYljs1>XljJ7zo^SDT?11qulxGfb*sx>me1>++cow{g4@5k}2%$0_0%kmOKpbJ;RPpS{ z5XNeu3#v*f_~3KCPOx2@ z&kt~1@jBB5dV{bpMm=$#%%4tUtQZWVo_L=3g1sKdOQA+@igAgS$-qdDFFq*P}lHklWq7{>%1_=SLB*WS`eX+{~VDi~zp4{*+e`F)CvrV3t?91fC*~ zF|K0SN~R9^Am)W+`G9_@@9JsCP$A)au^GGq_kXC5@(geq-}5$Ww<~Y5s%>WPmt-o> z@~Q1MyG=9UV6tY}Zp-XiryXg{nu;Q-!$Jz5Dn$7p_&phqfYu~sYVJ*X|5wBVy2~e*Sqd ze|yun`MfYTVeaiIoNZ#$=Zgo%8{&cT1>n*9T>}r*T$ z*O4+_$R+7T4_r@T|76YVzzb0r7yDx=URR<_*Oy5y6sZ#ega9Ex2oM5<03kpK5CVh% zAwUQa0vCXQ+5Lo^^Rzhtzbm6YUiX`I@7WdIg8Ep}{eftFNFoFX0YZQfAOr{jLVyq; z1kwostJe>M=Xu@GXL7wj*9qwq1>_4MKnM^5ga9Ex2oM5^5ir{~CVDPx81{{*hxd)e z5eH26ji`_P#eO6c0)zk|KnM^5ga9Ex2&5MR$?hA^CfF|?G1({5{xH4PoxC9g2mwNX z5Fi9niNO55?JvH%Xvp_kQH|%8QMZ3|Yt8R_-g(qNRbviRzvNu-?ByP`xBm~0rO!ST z(dFmc9eGAd-8c*p1<$#{u3omU0!|v1E0Kcu*CD= zzNLR0`{kIs;OUlst&2E4yCXBCpS)e>32xZ4YUIsv_vRn0**x&$cim@PZ`~UxmG%Bv zjdfS|KV;Yc<>}g&BV$WkJ-R=eUa$D{jozDe{llMMmREA>kS^Ew>#rXA(+XWaBYk|& z9mgtkx%0r1{*O9W>GE9%m+o9%SES49XFi_0a4=H(;hA3#uQ|}I`=Q~L_iwLO4urIF z+tAyknTJo!d274N*Yw1RR~BF}nTq9^abv-zj^g z@#L*Xk4Y7eDC_I^)qhUzJLmTIeRlhmPfy|cjxFhUC0ufaKA&@oZ}{56@sD)*A8Wg> zf3tg+j^}+@mAN19o~5_vUwir&1%G``m&gA*^6m?MV}1HVcg`E!;`ZEs*Zy;V>lt?+ zJCyaiklX2ba_@;dzZEI-6wg0>-QS-Xb7wqw`|f`pddGdd`;q+5-#7aInSJt&g^$|| zT$eq%_=6|*=>DwEy1Qh~&+_&0J)b^*&1XM7sLMlh=RW3NwoI2jKfI&D^P5UtzJBFR r1^o{f8}iRPPxZGI>hh+QtG-w8dZe^!|1+DWAAO4Zf$NIpg!TO&nBY)i literal 0 HcmV?d00001 diff --git a/tests/regression_tests/deplete_with_transfer_rates/ref_depletion_with_removal.h5 b/tests/regression_tests/deplete_with_transfer_rates/ref_depletion_with_removal.h5 index 3d48105d7b444cc16f5b6438f97f897d2c1657fd..c3121a9535097cbd3bc2457b664ff774ca654e2c 100644 GIT binary patch delta 670 zcmcbxnCZe|rVS2#j53oQ`{L_&B(Ktto6Bd%00o;)bLh;i`vB)Jx@+_-A<9Gws#GVMMu~xzzyKa&yn6*$7~OC0Qc$-|FjJj zz#a5utLNwaEA-*&zy9Wxu(`Jn&i8Q1mA-i06wcqn_&(J8oF|m8!@y9!TYKxf36^jL zv3qp-jdM)k{BZ7WLHP?G>`y$Ke5zl`luz@NJT(XoTCHz?k32oIA3}(w0l2|Vr!&ni1-^&# z&9=`ztNUUC+-Db=>$Yl5mx9ZGh{~C^=j3uYKVa88i=i7=GEVh&%zk>SYbyTR{^~xxdBVvnwgz4i2di9_;0BcayLV^9 zOg4B>Uv^^fW6N3ycTl?2aigtQRpIItk4CJtQ920cPht(=UOL|j&gV63vJXBP0_6)c fFhrcY#JKz_BBA<9GwjT_RMMu~xzztx@&yn6*$7~OCz^7Fo{%IR5 zfIH~;R?pA-SLnmlxBupqu(`Jn&JTCVmA-i06wZIk_&(J8oF|m8!@#g;xAxX|6D;8h zTK4Gl8|Rq7`L^8Mg7OzW*!Mr0e5zl`bi>_A_H2jmi65&maE=b-yPF`V;(YJLj7yfU zteis+&*nYhrsDkO#oH%Sww!V@@UL=QlYhtQ{A9%m@%8btXQeu%1mFhioX#}66!;#_ zSKmJStnP~maG%{{uG^|LT?#ILEh=Z)o|DVre3M=8ELL)B!}+yOo-wv;x3-(OB2r_L z*I(N|ExJ2Bk80S}&0fJ`EaOyf$LFWFx~Ag4?eFf>nOD>jer#C_;SOq-I&QS}sw!N)=+TIkHcAKK`~ubh?xpjs;Cwa1Ci~!%AyB?B f1B1r7ON`5}B7!=0^2CYaO#D)lS4~u60R=1or!~mt diff --git a/tests/regression_tests/deplete_with_transfer_rates/ref_depletion_with_removal_and_redox.h5 b/tests/regression_tests/deplete_with_transfer_rates/ref_depletion_with_removal_and_redox.h5 new file mode 100644 index 0000000000000000000000000000000000000000..80ce771fceae774cd606fef7888c325128b7462f GIT binary patch literal 37328 zcmeHPeQ;D)6~Avcp$h~EErk?n`cj#8O;Xu>&?bc4Y}gPJ93>E_tL?O#k1eE=FOw_= zMa)R6adafK&NSlSq|TtBEp7VcfOPCu`jOfW6Dn3lfr(>Bqr=oy+YzgP@4b7@d)b?P zyX+%NxAg2Ed*8YD+;h+Uz4JTo-nZ}VcWSF^=FM3;hp8WjgJrO6?GV2%!e@LBuR$5x z18^WgIRNE|jUPygMYSyl>a)SVZ08lDP`_#8+O>??7~01Yn%UqiepCh(^@_Io325K+ zb+scBz##z9d+LwQ$EI zi8BM}phP(TAYZV|VxZ4vEBzhpzSg#2@Aj@BsI%)7#f6H1`YpjOML`)+?hf{Z+Io9H zQBpKi9poGOe-8MT13cn%WLs7ISG6lh`a}{o-J)9W6mM=T2L{2-DAE zJo8EH8n#I3x2>}+)UK>j7Rr?{gqM}hWu+H|JlnjZsqXlZKUSLMB^TsZ%1Z*^B*sL* zEH9lDc#ipErt%WQZ+j%K5d({3D@Apis;_YY`hEa55-ya%S%r!

%x-v&8GFh?j9+>{@M|+SuFKE_a05 zn}Xdku36O?nXOx48LIU4Y7>`~Rjaz*?G83J^V?^Qy}>D#64^1!R}naGl&>_H=_>&O zX8CFe;t0EAewe9zg*+4UjlmCVJj(=!4LjC&hIY(&=KlmAhqJ;(FKMhc%v|*M=Qy zJVQHXJaaDR8pJb1ipqEhnDK1H^I_@FFw#<-iQ`}*jUucxSF zr6PQ$rD&!4le}8qp_FyJnr$vF(SEU4N>xpjwFkQVL9U>dYCrTZpem){gU|Uo!S>cX zet_eO`%D+;jl;Sa^~7~De>#n^GBAvK;&t8&_J$!Zp`MthYC+Gjf*+us3*>s?yn^}_ zEAaOm(H|o(TFbBao`PY=T7E@43Ewv;=aAv=-d7Aj8YnFg5c8%CUX8$WMd+lnqi{=O zdwb)jpyIyP9__swYN}Mt0k*iA~&T)v>AgsHX8UryM4`qT)uy?{?)e%kI|3HhfRHBh=j1+7d)je;yMn z_#>W7ua~OT`1@4e>zvGVmDrnt_LEE=}>^=TH3K1H{4l{B08t8uOpR zgMq{+?Fwq(`v6`Tv%WI^;7(|E3hR6Ey_&6{=kmq37l3AYO?=$UCkWNuRz7PV003kpK5CVh%A&?jWvvp&l=fWmn-H3X4-&heb1G)c;IZ~qTvS_-#D17w>S4!4&8O?r@HJpeDoK8Snbs1)%S1z=Zi0__H-XE zeDlIn7u-dU^t@LSae96jnJqp1T9v1%;nDJQFI{wZeygG>{LtI(f4E+~%U>z${cq@O zTlV};yZ$dviyn_$C~yty{#^I-x_`YmvO?E??Z5MKR-gH$F7K&sUv~U=D|Pv-^!Lr5 zJyxg7Uq4dt{JVqYx?KK3;qH>QQeAGJ``v{%jYlerCZCz8IPzoN4-K!J_q|#f4r=AH zidQP_N6##Ib%)F6-!T7lAa}X%<$qh{+jR6t^TukH``#}I-!t&1M}6+~U#)tg z^VFKxk4bgAl=JI&`ah=*|Jz+X`oX&EADO}PyZO%T%Ql{!r_X2a{&i=5zxJ#yKRHo- zXz7_j9nZ0X-Z=~M7whf23O0OjVApuxSSzs7TCaKk(6pSkGX zdwVFa>T#!MZ`p(Y``M8y&zExZpKUnCJO|(TitpvWyzTx?z9*~ni#O~2Z`t0I_vzbj#Uk?pXmO0pZ!AM+Z&DX)-3JnoL}@Sz5h+`<(2Gn7VC29yDck1HJ{VvhVrb6 r6H6*|x&K7To8Nn5l`e-o3%>A<9Gwl55hMMu~xzzq<{&yn6*$7~OCfZFO0|FjJj zz#ZhZ&GYmA75Z@X8-Mdk*xcI(=TCFVmA-i06wd$6_&(J8oF|m8&A?E&TYKxf36^jL z!FzQ2jdM)kd_V4PLHP?G?4M6Q*{^JxAW&`3eE6RDu^I#C@Ib!1334jVH(t!RWckX< zIrQ*s-V<&r&VOFKeKKXsDJLucD#tbXcbra6R-7O&m>}J^Zs&pTPPN${3vX`Man_w2 zI6=SOJ>FEg%MYsX17rIU?4rhUff8Ci)C;mT2gum`8`#JKgT5$F2L_>AAg&&0T zw`t!p_?Br5=if<`KCk~K5X#qKU@%&myZ1l^BK)o8?kKz}H-u|=wB~g4EP;>qYmQD{ KHBpHL6afHS{MF3> delta 709 zcmcbxnCZe|rVS2#jIxs*`{L`DC9l$uo6Bd%00p~FbLh;i`vB)}x@+_-cf;rNC>vkUa?o^xYvGC@09cPot zffMxWo&RKSGXBL6x9x6RlTzGVMEEP$?Rw6*aRS`C^9xHBepVNStKam}H}24d6>z?w zn@N+`a&JF5Ax5ReE~DLlJJkhVxw~7KgLI!@u~v!mHixfRxP;thoYgn+rkgR z`75+<8GOq$hV%C&N}tz%6A0z&Ffc?c&E0#T0ulbMa(5J7l^en}JY93Td6vLOdxH~` LS4~u60Yv}+O6u9M diff --git a/tests/regression_tests/deplete_with_transfer_rates/ref_depletion_with_transfer_and_redox.h5 b/tests/regression_tests/deplete_with_transfer_rates/ref_depletion_with_transfer_and_redox.h5 new file mode 100644 index 0000000000000000000000000000000000000000..6847fd7dfe6960da89a0e4fdf0be9f35eb56d7f4 GIT binary patch literal 37328 zcmeHPeQ;FO6~AvcfyFEkD9BbUJ_?mZLRm8)#x@Bn7nTz}OARLSi=`%~yj} zWTv!@b_OCXL#Apc>GUJoVpFTqG%0LliV+ zn|)+4#j}6xedpeD&pr2d&+okZ-oCewNjs(w~jFzL;F}lGi#o}kFuenP|;RDLG7Eq zZgd3l*X0(fbHsSTI1BodJM(D}uQ(pYwUgy*153=ooJw>zglcNbt14N#L!pt@!X1+& z&J3V~65;%Ve8JMmK%Y%g`a9V6@^GkbXLShF*{2l6X^MdQrJ-s?K^al53Dwqz>uNz! zQZ!T@NkLIb&yB6eImuWr@BsZ3d4cncOy%}aZvbS+bY7ng!})!& z$Njik&Ca|1Y22e`aE#{xW|{{+F8^uV@qvJt2ard8I6t75$P?rv)>|!k9Kr6D!v4%@#7(|0nS z`6M=x%~1L^RE6s+lwHa+IR}RDv#d0hbydi-B@b>ZIN9}=9J9RSf&5B&NdTP05fL!U zOML>*F<*>TUP3;Jc_mRkp0sJ3iyxp~ z5y*|7=JhVnYq=#_F9LG^t-M|aJvJv=&kb_Eu7HZ;v&8eNh?j9+Y`V5jEv~DokSpse zwuNeB+_S1PGTXPpHdN{B*CsA0yH<6-TN5fS;kVC<>q0{;#j|6UuLfb=C|_wX(^nh> z%<@$W#1VGK{4iGe3V9~x8-pL#c$Nwd8+NSm4DFcltmrd*AmSND2$iuAFyk46IKsZk z;+c59HuzzUXDQ&YVaFQJ(2g0;A~GL{c!m)|Wh?~DcvdDy_evJe#Qm1 zMSO&T#k@55VeNc|ej0YH@eJ*l@hpSnc6S*N~x-;vi3k%QHU$3rP>eu3#v*<_~3KCZm?Z8 zmmlD`;&rA6^m<`mjC$fenLnMzST-0&J@Gv62YaoMmrzg4Q~99hSi%ob&jWIyu&$tf z-3t6YNA$$-qdDF9+*P}m0AUAq>{U_}k&yONri9WB3xS2iQI0*RS`cqy(#Hbtz0kgc)BJdP> zjBz!RtwidO4`N~a`mUaK3>6Z-7n{N>aQ}z;C{F^X@jY*|cDwQ>tJ-Gveo3nG zET7tLv)eQi4kl}s?Y7jG_1clvtf?rXIxM8{u|kv&g5SgODAJmwRL#9%@BfN;pd1eu z)Jq&6eqm&{P3YguomL$iijQg4}%^ABb#lvS36~9B)C1^OE#G~POa0DezH$9#)9uWtW6Q92e z=5Me5I-eKDCd|DtiL;GwdbD_8ydfSaUjrWP-!$-0&5ct$`1*_CMV;1Jb9^4JhZef2fzE@KQdY(XRdqHTH*T&Wx@2%?oSsLO0V44$j zJbf?kfc=n%@yrbFi`Kw?SYm#|eaw)6jP6JhAwUQa0)zk|KnM^5ga9Ex2oM5^f4$3%LZnXoc%Z?4PKa-FP7i<6?g-#p_Cx>H0Fkg(7uAfDj-A2mwNX5Fi8y0YZQf zAOr{jLf{$@FuR|SK1!Pr_+1(G@w(rvd+TG-EvSzr-5-d?ha^IP5Fi8y0YZQfAOr{j zLSQ^0VD=zTlsxU8^W+pC+UtJBk@4iN zL0vv_e%afbU*4zxYvPejKS+7_&>X$JYdbic%CD|?GY{5&uzt|(dwg(`^!=A|ePx^4R$lzWRqyuitlk#cf5!Wc=lGVQ99i$b zq$)i7`CWGXU!HFJ#o(1io>twT87~yPf3$sxuK&h=C%e{MIHJo9c@?uyzP? zo-e*upvzmk7Crxu=9Rjpykxczpo11d+hPaJ?rNOK3WvHuj#LCf$R+r zei+}f94lM242tgqwOAD!#`(3{tB>8_avhpg|0*?XVqjC{Cu&)4$X-Z-2Q`0UK^ z37hx*wc5C&_&RaaSvc~m+!B*)R6zg8sFK*x6a;uius)TPcP`YYoX8n!k;T#-MPN0KR@|I z%A3p=E=@bM=dCl|3tRiVoBHSK{g2*v<&I}-Q*>Pa^K|#04sL1F*LU9OS(n}mF4pxA zyu0}Ji3?xU Date: Thu, 13 Nov 2025 21:35:33 +0100 Subject: [PATCH 21/26] Addition of a collision tracking feature (#3417) Co-authored-by: Paul Romano --- CMakeLists.txt | 1 + docs/source/io_formats/collision_track.rst | 46 ++++ docs/source/io_formats/index.rst | 1 + docs/source/io_formats/settings.rst | 79 ++++++ docs/source/pythonapi/base.rst | 3 + docs/source/usersguide/settings.rst | 56 ++++ include/openmc/bank.h | 2 + include/openmc/bank_io.h | 103 +++++++ include/openmc/collision_track.h | 23 ++ include/openmc/constants.h | 1 + include/openmc/mcpl_interface.h | 15 ++ include/openmc/message_passing.h | 1 + include/openmc/particle_data.h | 19 ++ include/openmc/settings.h | 20 ++ include/openmc/simulation.h | 1 + include/openmc/urr.h | 8 +- openmc/settings.py | 246 ++++++++++++++--- openmc/source.py | 175 ++++++++++-- src/bank.cpp | 3 + src/collision_track.cpp | 238 ++++++++++++++++ src/finalize.cpp | 5 + src/initialize.cpp | 31 +++ src/mcpl_interface.cpp | 155 +++++++++++ src/message_passing.cpp | 1 + src/particle.cpp | 6 + src/physics.cpp | 5 +- src/settings.cpp | 76 +++++- src/simulation.cpp | 13 +- src/state_point.cpp | 90 +------ .../collision_track/__init__.py | 0 .../case_1_Reactions/inputs_true.dat | 58 ++++ .../case_1_Reactions/results_true.dat | 2 + .../case_2_Cell_ID/inputs_true.dat | 58 ++++ .../case_2_Cell_ID/results_true.dat | 2 + .../case_3_Material_ID/inputs_true.dat | 58 ++++ .../case_3_Material_ID/results_true.dat | 2 + .../case_4_Nuclide_ID/inputs_true.dat | 58 ++++ .../case_4_Nuclide_ID/results_true.dat | 2 + .../case_5_Universe_ID/inputs_true.dat | 59 ++++ .../case_5_Universe_ID/results_true.dat | 2 + .../inputs_true.dat | 58 ++++ .../results_true.dat | 2 + .../inputs_true.dat | 63 +++++ .../results_true.dat | 2 + .../case_8_2threads/inputs_true.dat | 57 ++++ .../case_8_2threads/results_true.dat | 2 + .../regression_tests/collision_track/test.py | 255 ++++++++++++++++++ tests/testing_harness.py | 126 ++++++++- tests/unit_tests/test_collision_track.py | 127 +++++++++ 49 files changed, 2268 insertions(+), 148 deletions(-) create mode 100644 docs/source/io_formats/collision_track.rst create mode 100644 include/openmc/bank_io.h create mode 100644 include/openmc/collision_track.h create mode 100644 src/collision_track.cpp create mode 100644 tests/regression_tests/collision_track/__init__.py create mode 100644 tests/regression_tests/collision_track/case_1_Reactions/inputs_true.dat create mode 100644 tests/regression_tests/collision_track/case_1_Reactions/results_true.dat create mode 100644 tests/regression_tests/collision_track/case_2_Cell_ID/inputs_true.dat create mode 100644 tests/regression_tests/collision_track/case_2_Cell_ID/results_true.dat create mode 100644 tests/regression_tests/collision_track/case_3_Material_ID/inputs_true.dat create mode 100644 tests/regression_tests/collision_track/case_3_Material_ID/results_true.dat create mode 100644 tests/regression_tests/collision_track/case_4_Nuclide_ID/inputs_true.dat create mode 100644 tests/regression_tests/collision_track/case_4_Nuclide_ID/results_true.dat create mode 100644 tests/regression_tests/collision_track/case_5_Universe_ID/inputs_true.dat create mode 100644 tests/regression_tests/collision_track/case_5_Universe_ID/results_true.dat create mode 100644 tests/regression_tests/collision_track/case_6_deposited_energy_threshold/inputs_true.dat create mode 100644 tests/regression_tests/collision_track/case_6_deposited_energy_threshold/results_true.dat create mode 100644 tests/regression_tests/collision_track/case_7_all_parameters_used_together/inputs_true.dat create mode 100644 tests/regression_tests/collision_track/case_7_all_parameters_used_together/results_true.dat create mode 100644 tests/regression_tests/collision_track/case_8_2threads/inputs_true.dat create mode 100644 tests/regression_tests/collision_track/case_8_2threads/results_true.dat create mode 100644 tests/regression_tests/collision_track/test.py create mode 100644 tests/unit_tests/test_collision_track.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 474451c8ae..87b8789d10 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -338,6 +338,7 @@ list(APPEND libopenmc_SOURCES src/cell.cpp src/chain.cpp src/cmfd_solver.cpp + src/collision_track.cpp src/cross_sections.cpp src/dagmc.cpp src/distribution.cpp diff --git a/docs/source/io_formats/collision_track.rst b/docs/source/io_formats/collision_track.rst new file mode 100644 index 0000000000..a364597544 --- /dev/null +++ b/docs/source/io_formats/collision_track.rst @@ -0,0 +1,46 @@ +.. _io_collision_track: + +=========================== +Collision Track File Format +=========================== + +When collision tracking is enabled with ``mcpl=false`` (the default), OpenMC +writes binary data to an HDF5 file named ``collision_track.h5``. The same data +may also be written after each batch when multiple files are requested +(``collision_track.N.h5``) or when the run is performed in parallel. The file +contains the information needed to reconstruct each recorded collision. + +The current revision of the collision track file format is 1.0. + +**/** + +:Attributes: + - **filetype** (*char[]*) -- String indicating the type of file. + For collision-track files the value is ``"collision_track"``. + +:Datasets: + + - **collision_track_bank** (Compound type) -- Collision information + for each stored event. Each entry in the dataset corresponds to one + collision and contains the following fields: + + - ``r`` (*double[3]*) -- Position of the collision in [cm]. + - ``u`` (*double[3]*) -- Direction unit vector immediately after the collision. + - ``E`` (*double*) -- Incident particle energy before the collision in [eV]. + - ``dE`` (*double*) -- Energy loss over the collision (:math:`E_\text{before} - E_\text{after}`) in [eV]. + - ``time`` (*double*) -- Time of the collision in [s]. + - ``wgt`` (*double*) -- Particle weight at the collision. + - ``event_mt`` (*int*) -- ENDF MT number identifying the reaction. + - ``delayed_group`` (*int*) -- Delayed neutron group index (non-zero for delayed events). + - ``cell_id`` (*int*) -- ID of the cell in which the collision occurred. + - ``nuclide_id`` (*int*) -- ZA identifier of the nuclide (ZZZAAAM format). + - ``material_id`` (*int*) -- ID of the material containing the collision site. + - ``universe_id`` (*int*) -- ID of the universe containing the collision site. + - ``n_collision`` (*int*) -- Collision counter for the particle history. + - ``particle`` (*int*) -- Particle type (0=neutron, 1=photon, 2=electron, 3=positron). + - ``parent_id`` (*int64*) -- Unique ID of the parent particle. + - ``progeny_id`` (*int64*) -- Progeny ID of the particle. + +In an MPI run, OpenMC writes the combined dataset by gathering collision-track +entries from all ranks before flushing them to disk, so the final file appears +as though it were produced serially. diff --git a/docs/source/io_formats/index.rst b/docs/source/io_formats/index.rst index 4bbaa961a6..5b4efea669 100644 --- a/docs/source/io_formats/index.rst +++ b/docs/source/io_formats/index.rst @@ -44,6 +44,7 @@ Output Files statepoint source + collision_track summary properties depletion_results diff --git a/docs/source/io_formats/settings.rst b/docs/source/io_formats/settings.rst index 720846c851..b7874fcf2f 100644 --- a/docs/source/io_formats/settings.rst +++ b/docs/source/io_formats/settings.rst @@ -20,6 +20,85 @@ source neutrons. *Default*: None +----------------------------- +```` Element +----------------------------- + +The ```` element indicates to track information about particle +collisions based on a set of criteria and store these events in a file named +``collision_track.h5``. This file records details such as the position of the +interaction, direction of the incoming particle, incident energy and deposited +energy, weight, time of the interaction, and the delayed neutron group (0 for +prompt neutrons). Additional information such as the cell ID, material ID, +universe ID, nuclide ZAID, particle type, and event MT number are also stored. +Users can specify one or more criterion to filter collisions. If no criteria are +specified, it defaults to tracking all collisions across the model. + +.. warning:: + Storing all collisions can be very memory intensive. For more targeted + tracking, users can employ a variety of parameters such as ``cell_ids``, + ``reactions``, ``universe_ids``, ``material_ids``, ``nuclides``, and + ``deposited_E_threshold`` to refine the selection of particle interactions + to be banked. + +This element can contain one or more of the following attributes or +sub-elements: + + :max_collisions: + An integer indicating the maximum number of collisions to be banked per file. + + *Default*: 1000 + + :max_collision_track_files: + An integer indicating the number of collision_track files to be used. + + *Default*: 1 + + :mcpl: + An optional boolean to enable MCPL_-format instead of the native HDF5-based + format. If activated, the output file name and type is changed to + ``collision_track.mcpl``. + + *Default*: false + + .. _MCPL: https://mctools.github.io/mcpl/mcpl.pdf + + :cell_ids: + A list of integers representing cell IDs to define specific cells in which + collisions are to be banked. + + *Default*: None + + :universe_ids: + A list of integers representing the universe IDs to define specific + universes in which collisions are to be banked. + + *Default*: None + + :material_ids: + A list of integers representing the material IDs to define specific + materials in which collisions are to be banked. + + *Default*: None + + :nuclides: + A list of strings representing the nuclide, to define specific + define specific target nuclide collisions to be banked. + + *Default*: None + + :reactions: + A list of integers representing the ENDF-6 format MT numbers or strings + (e.g. (n,fission)) to define specific reaction types to be banked. + + *Default*: None + + :deposited_E_threshold: + A float defining the minimum deposited energy per collision (in eV) to + trigger banking. + + *Default*: 0.0 + ---------------------------------- ```` Element ---------------------------------- diff --git a/docs/source/pythonapi/base.rst b/docs/source/pythonapi/base.rst index 2a9d0876cd..ce2f6f0f85 100644 --- a/docs/source/pythonapi/base.rst +++ b/docs/source/pythonapi/base.rst @@ -216,6 +216,9 @@ Post-processing :nosignatures: :template: myfunction.rst + openmc.read_collision_track_file + openmc.read_collision_track_hdf5 + openmc.read_collision_track_mcpl openmc.voxel_to_vtk The following classes and functions are used for functional expansion reconstruction. diff --git a/docs/source/usersguide/settings.rst b/docs/source/usersguide/settings.rst index 1b2d4bc1a5..f973f14655 100644 --- a/docs/source/usersguide/settings.rst +++ b/docs/source/usersguide/settings.rst @@ -756,6 +756,62 @@ instance, whereas the :meth:`openmc.Track.filter` method returns a new track_files = [f"tracks_p{rank}.h5" for rank in range(32)] openmc.Tracks.combine(track_files, "tracks.h5") +Collision Track File +--------------------- + +OpenMC can generate a collision track file that contains detailed collision +information (position, direction, energy, deposited energy, time, weight, cell +ID, material ID, universe ID, nuclide ZAID, particle type, particle delayed +group and particle ID) for each particle collision depending on user-defined +parameters. To invoke this feature, set the +:attr:`~openmc.Settings.collision_track` attribute as shown in this example:: + + settings.collision_track = { + "max_collisions": 300, + "reactions": ["(n,fission)", "(n,2n)"], + "material_ids": [1,2], + "nuclides": ["U238", "O16"], + "cell_ids": [5, 12] + } + +In this example, collision track information is written to the +collision_track.h5 file at the end of the simulation. The file contains +300 recorded collisions that occurred in materials with IDs 1 or 2, involving +fission or (n,2n) reactions on the nuclides U-238 or O-16, within cells +with IDs 5 and 12. +The file can be read using :func:`openmc.read_collision_track_file`. +The example below shows how to extract the data from the collision_track +feature and displays the fields stored in the file: + +>>> data = openmc.read_collision_track_file('collision_track.h5') +>>> data.dtype + dtype([('r', [('x', ' source_bank; extern SharedArray surf_source_bank; +extern SharedArray collision_track_bank; + extern SharedArray fission_bank; extern vector> ifp_source_delayed_group_bank; diff --git a/include/openmc/bank_io.h b/include/openmc/bank_io.h new file mode 100644 index 0000000000..90ffd820fa --- /dev/null +++ b/include/openmc/bank_io.h @@ -0,0 +1,103 @@ +#ifndef OPENMC_BANK_IO_H +#define OPENMC_BANK_IO_H + +#include "hdf5.h" + +#include "openmc/message_passing.h" +#include "openmc/span.h" +#include "openmc/vector.h" + +#include + +#ifdef OPENMC_MPI +#include +#endif + +namespace openmc { + +template +void write_bank_dataset(const char* dataset_name, hid_t group_id, + span bank, const vector& bank_index, hid_t banktype +#ifdef OPENMC_MPI + , + MPI_Datatype mpi_dtype +#endif +) +{ + int64_t dims_size = bank_index.back(); + int64_t count_size = bank_index[mpi::rank + 1] - bank_index[mpi::rank]; + +#ifdef PHDF5 + hsize_t dims[] {static_cast(dims_size)}; + hid_t dspace = H5Screate_simple(1, dims, nullptr); + hid_t dset = H5Dcreate(group_id, dataset_name, banktype, dspace, H5P_DEFAULT, + H5P_DEFAULT, H5P_DEFAULT); + + hsize_t count[] {static_cast(count_size)}; + hid_t memspace = H5Screate_simple(1, count, nullptr); + + hsize_t start[] {static_cast(bank_index[mpi::rank])}; + H5Sselect_hyperslab(dspace, H5S_SELECT_SET, start, nullptr, count, nullptr); + + hid_t plist = H5Pcreate(H5P_DATASET_XFER); + H5Pset_dxpl_mpio(plist, H5FD_MPIO_COLLECTIVE); + + H5Dwrite(dset, banktype, memspace, dspace, plist, bank.data()); + + H5Sclose(dspace); + H5Sclose(memspace); + H5Dclose(dset); + H5Pclose(plist); +#else + if (mpi::master) { + hsize_t dims[] {static_cast(dims_size)}; + hid_t dspace = H5Screate_simple(1, dims, nullptr); + hid_t dset = H5Dcreate(group_id, dataset_name, banktype, dspace, + H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT); + +#ifdef OPENMC_MPI + vector temp_bank {bank.begin(), bank.end()}; +#endif + + for (int i = 0; i < mpi::n_procs; ++i) { + hsize_t count[] {static_cast(bank_index[i + 1] - bank_index[i])}; + hid_t memspace = H5Screate_simple(1, count, nullptr); + +#ifdef OPENMC_MPI + if (i > 0) { + MPI_Recv(bank.data(), count[0], mpi_dtype, i, i, mpi::intracomm, + MPI_STATUS_IGNORE); + } +#endif + + hid_t dspace_rank = H5Dget_space(dset); + hsize_t start[] {static_cast(bank_index[i])}; + H5Sselect_hyperslab( + dspace_rank, H5S_SELECT_SET, start, nullptr, count, nullptr); + + H5Dwrite(dset, banktype, memspace, dspace_rank, H5P_DEFAULT, bank.data()); + + H5Sclose(memspace); + H5Sclose(dspace_rank); + } + + H5Dclose(dset); + +#ifdef OPENMC_MPI + std::copy(temp_bank.begin(), temp_bank.end(), bank.begin()); +#endif + } +#ifdef OPENMC_MPI + else { + if (!bank.empty()) { + MPI_Send( + bank.data(), bank.size(), mpi_dtype, 0, mpi::rank, mpi::intracomm); + } + } +#endif +#endif +} + +} // namespace openmc + +#endif // OPENMC_BANK_IO_H diff --git a/include/openmc/collision_track.h b/include/openmc/collision_track.h new file mode 100644 index 0000000000..208b8f6e1f --- /dev/null +++ b/include/openmc/collision_track.h @@ -0,0 +1,23 @@ +#ifndef OPENMC_COLLISION_TRACK_H +#define OPENMC_COLLISION_TRACK_H + +#include + +namespace openmc { + +class Particle; + +//! Reserve space in the collision track bank according to user settings. +void collision_track_reserve_bank(); + +//! Write collision track data to disk when the bank is full or the batch ends. +void collision_track_flush_bank(); + +//! Record the current particle as a collision-track entry when applicable. +//! +//! \param particle Particle whose collision should be recorded if eligible +void collision_track_record(Particle& particle); + +} // namespace openmc + +#endif // OPENMC_COLLISION_TRACK_H diff --git a/include/openmc/constants.h b/include/openmc/constants.h index b661934812..bba260c3a4 100644 --- a/include/openmc/constants.h +++ b/include/openmc/constants.h @@ -34,6 +34,7 @@ constexpr array VERSION_VOXEL {2, 0}; constexpr array VERSION_MGXS_LIBRARY {1, 0}; constexpr array VERSION_PROPERTIES {1, 1}; constexpr array VERSION_WEIGHT_WINDOWS {1, 0}; +constexpr array VERSION_COLLISION_TRACK {1, 0}; // ============================================================================ // ADJUSTABLE PARAMETERS diff --git a/include/openmc/mcpl_interface.h b/include/openmc/mcpl_interface.h index a76d72e649..a9cce3e69a 100644 --- a/include/openmc/mcpl_interface.h +++ b/include/openmc/mcpl_interface.h @@ -38,6 +38,21 @@ vector mcpl_source_sites(std::string path); void write_mcpl_source_point(const char* filename, span source_bank, const vector& bank_index); +//! Write an MCPL collision track file +//! +//! This function writes collision track data to an MCPL file. Additional +//! collision-specific metadata (such as energy deposition, material info, etc.) +//! is stored in the file header as blob data. +//! +//! \param[in] filename Path to MCPL file +//! \param[in] collision_track_bank Vector of CollisionTrackSites to write to +//! file for this MPI rank. +//! \param[in] bank_index Pointer to vector of site index ranges over all +//! MPI ranks. +void write_mcpl_collision_track(const char* filename, + span collision_track_bank, + const vector& bank_index); + //! Check if MCPL functionality is available bool is_mcpl_interface_available(); diff --git a/include/openmc/message_passing.h b/include/openmc/message_passing.h index a1641a9069..ce993776b7 100644 --- a/include/openmc/message_passing.h +++ b/include/openmc/message_passing.h @@ -18,6 +18,7 @@ extern bool master; #ifdef OPENMC_MPI extern MPI_Datatype source_site; +extern MPI_Datatype collision_track_site; extern MPI_Comm intracomm; #endif diff --git a/include/openmc/particle_data.h b/include/openmc/particle_data.h index 5383487d4f..fdacfa765b 100644 --- a/include/openmc/particle_data.h +++ b/include/openmc/particle_data.h @@ -56,6 +56,25 @@ struct SourceSite { int64_t progeny_id; }; +struct CollisionTrackSite { + Position r; + Direction u; + double E; + double dE; + double time {0.0}; + double wgt {1.0}; + int event_mt {0}; + int delayed_group {0}; + int cell_id {0}; + int nuclide_id; + int material_id {0}; + int universe_id {0}; + int n_collision {0}; + ParticleType particle; + int64_t parent_id; + int64_t progeny_id; +}; + //! State of a particle used for particle track files struct TrackState { Position r; //!< Position in [cm] diff --git a/include/openmc/settings.h b/include/openmc/settings.h index 78bfa088e6..b369c99fef 100644 --- a/include/openmc/settings.h +++ b/include/openmc/settings.h @@ -32,6 +32,24 @@ enum class IFPParameter { GenerationTime, }; +struct CollisionTrackConfig { + bool mcpl_write {false}; //!< Write collision tracks using MCPL? + std::unordered_set + cell_ids; //!< Cell ids where collisions will be written + std::unordered_set + mt_numbers; //!< MT Numbers where collisions will be written + std::unordered_set + universe_ids; //!< Universe IDs where collisions will be written + std::unordered_set + material_ids; //!< Material IDs where collisions will be written + std::unordered_set + nuclides; //!< Nuclides where collisions will be written + double deposited_energy_threshold {0.0}; //!< Minimum deposited energy [eV] + int64_t max_collisions { + 1000}; //!< Maximum events recorded per collision track file + int64_t max_files {1}; //!< Maximum number of collision track files +}; + //============================================================================== // Global variable declarations //============================================================================== @@ -41,6 +59,7 @@ namespace settings { // Boolean flags extern bool assume_separate; //!< assume tallies are spatially separate? extern bool check_overlaps; //!< check overlaps in geometry? +extern bool collision_track; //!< flag to use collision track feature? extern bool confidence_intervals; //!< use confidence intervals for results? extern bool create_fission_neutrons; //!< create fission neutrons (fixed source)? @@ -145,6 +164,7 @@ extern std::unordered_set statepoint_batch; //!< Batches when state should be written extern std::unordered_set source_write_surf_id; //!< Surface ids where sources will be written +extern CollisionTrackConfig collision_track_config; extern double source_rejection_fraction; //!< Minimum fraction of source sites //!< that must be accepted extern double free_gas_threshold; //!< Threshold multiplier for free gas diff --git a/include/openmc/simulation.h b/include/openmc/simulation.h index 3e4e24e1d0..9a6cf1b213 100644 --- a/include/openmc/simulation.h +++ b/include/openmc/simulation.h @@ -22,6 +22,7 @@ constexpr int STATUS_EXIT_ON_TRIGGER {2}; namespace simulation { +extern int ct_current_file; //!< current collision track file index extern "C" int current_batch; //!< current batch extern "C" int current_gen; //!< current fission generation extern "C" bool initialized; //!< has simulation been initialized? diff --git a/include/openmc/urr.h b/include/openmc/urr.h index 3978c7b86a..1e60371584 100644 --- a/include/openmc/urr.h +++ b/include/openmc/urr.h @@ -27,10 +27,10 @@ public: double heating; }; - Interpolation interp_; //!< interpolation type - int inelastic_flag_; //!< inelastic competition flag - int absorption_flag_; //!< other absorption flag - bool multiply_smooth_; //!< multiply by smooth cross section? + Interpolation interp_; //!< interpolation type + int inelastic_flag_; //!< inelastic competition flag + int absorption_flag_; //!< other absorption flag + bool multiply_smooth_; //!< multiply by smooth cross section? vector energy_; //!< incident energies auto n_energy() const { return energy_.size(); } diff --git a/openmc/settings.py b/openmc/settings.py index 2f8a2b1248..43c1fe0698 100644 --- a/openmc/settings.py +++ b/openmc/settings.py @@ -6,7 +6,7 @@ from numbers import Integral, Real from pathlib import Path import lxml.etree as ET - +import warnings import openmc import openmc.checkvalue as cv from openmc.checkvalue import PathLike @@ -47,6 +47,21 @@ class Settings: half-width of the 95% two-sided confidence interval. If False, uncertainties on tally results will be reported as the sample standard deviation. + collision_track : dict + Options for writing collision information. Acceptable keys are: + + :max_collisions: Maximum number of collisions to be banked per file. (int) + :max_collision_track_files: Maximum number of collision_track files. (int) + :mcpl: Output in the form of an MCPL-file. (bool) + :cell_ids: List of cell IDs to define cells in which collisions should be banked. (list of int) + :universe_ids: List of universe IDs to define universes in which collisions should be banked. (list of int) + :material_ids: List of material IDs to define materials in which collisions should be banked. (list of int) + :nuclides: List of nuclides to define nuclides in which collisions should be banked. + (ex: ["I135m", "U233"] ). (list of str) + :reactions: List of reaction to define specific reactions that should be banked + (ex: ["(n,fission)", 2, "(n,2n)"] ). (list of str or int) + :deposited_E_threshold: Number to define the minimum deposited energy during + per collision to trigger banking. (float) create_fission_neutrons : bool Indicate whether fission neutrons should be created or not. cutoff : dict @@ -395,6 +410,9 @@ class Settings: # Iterated Fission Probability self._ifp_n_generation = None + # Collision track feature + self._collision_track = {} + # Output options self._statepoint = {} self._sourcepoint = {} @@ -434,7 +452,8 @@ class Settings: self._max_particle_events = None self._write_initial_source = None self._weight_windows = WeightWindowsList() - self._weight_window_generators = cv.CheckedList(WeightWindowGenerator, 'weight window generators') + self._weight_window_generators = cv.CheckedList( + WeightWindowGenerator, 'weight window generators') self._weight_windows_on = None self._weight_windows_file = None self._weight_window_checkpoints = {} @@ -475,8 +494,9 @@ class Settings: @generations_per_batch.setter def generations_per_batch(self, generations_per_batch: int): - cv.check_type('generations per patch', generations_per_batch, Integral) - cv.check_greater_than('generations per batch', generations_per_batch, 0) + cv.check_type('generations per batch', generations_per_batch, Integral) + cv.check_greater_than('generations per batch', + generations_per_batch, 0) self._generations_per_batch = generations_per_batch @property @@ -506,7 +526,8 @@ class Settings: @rel_max_lost_particles.setter def rel_max_lost_particles(self, rel_max_lost_particles: float): cv.check_type('rel_max_lost_particles', rel_max_lost_particles, Real) - cv.check_greater_than('rel_max_lost_particles', rel_max_lost_particles, 0) + cv.check_greater_than('rel_max_lost_particles', + rel_max_lost_particles, 0) cv.check_less_than('rel_max_lost_particles', rel_max_lost_particles, 1) self._rel_max_lost_particles = rel_max_lost_particles @@ -516,8 +537,10 @@ class Settings: @max_write_lost_particles.setter def max_write_lost_particles(self, max_write_lost_particles: int): - cv.check_type('max_write_lost_particles', max_write_lost_particles, Integral) - cv.check_greater_than('max_write_lost_particles', max_write_lost_particles, 0) + cv.check_type('max_write_lost_particles', + max_write_lost_particles, Integral) + cv.check_greater_than('max_write_lost_particles', + max_write_lost_particles, 0) self._max_write_lost_particles = max_write_lost_particles @property @@ -538,12 +561,12 @@ class Settings: def keff_trigger(self, keff_trigger: dict): if not isinstance(keff_trigger, dict): msg = f'Unable to set a trigger on keff from "{keff_trigger}" ' \ - 'which is not a Python dictionary' + 'which is not a Python dictionary' raise ValueError(msg) elif 'type' not in keff_trigger: msg = f'Unable to set a trigger on keff from "{keff_trigger}" ' \ - 'which does not have a "type" key' + 'which does not have a "type" key' raise ValueError(msg) elif keff_trigger['type'] not in ['variance', 'std_dev', 'rel_err']: @@ -553,7 +576,7 @@ class Settings: elif 'threshold' not in keff_trigger: msg = f'Unable to set a trigger on keff from "{keff_trigger}" ' \ - 'which does not have a "threshold" key' + 'which does not have a "threshold" key' raise ValueError(msg) elif not isinstance(keff_trigger['threshold'], Real): @@ -570,7 +593,7 @@ class Settings: @energy_mode.setter def energy_mode(self, energy_mode: str): cv.check_value('energy mode', energy_mode, - ['continuous-energy', 'multi-group']) + ['continuous-energy', 'multi-group']) self._energy_mode = energy_mode @property @@ -593,7 +616,8 @@ class Settings: def source(self, source: SourceBase | Iterable[SourceBase]): if not isinstance(source, MutableSequence): source = [source] - self._source = cv.CheckedList(SourceBase, 'source distributions', source) + self._source = cv.CheckedList( + SourceBase, 'source distributions', source) @property def confidence_intervals(self) -> bool: @@ -610,7 +634,8 @@ class Settings: @electron_treatment.setter def electron_treatment(self, electron_treatment: str): - cv.check_value('electron treatment', electron_treatment, ['led', 'ttb']) + cv.check_value('electron treatment', + electron_treatment, ['led', 'ttb']) self._electron_treatment = electron_treatment @property @@ -704,7 +729,8 @@ class Settings: @trigger_max_batches.setter def trigger_max_batches(self, trigger_max_batches: int): cv.check_type('trigger maximum batches', trigger_max_batches, Integral) - cv.check_greater_than('trigger maximum batches', trigger_max_batches, 0) + cv.check_greater_than('trigger maximum batches', + trigger_max_batches, 0) self._trigger_max_batches = trigger_max_batches @property @@ -713,8 +739,10 @@ class Settings: @trigger_batch_interval.setter def trigger_batch_interval(self, trigger_batch_interval: int): - cv.check_type('trigger batch interval', trigger_batch_interval, Integral) - cv.check_greater_than('trigger batch interval', trigger_batch_interval, 0) + cv.check_type('trigger batch interval', + trigger_batch_interval, Integral) + cv.check_greater_than('trigger batch interval', + trigger_batch_interval, 0) self._trigger_batch_interval = trigger_batch_interval @property @@ -798,19 +826,22 @@ class Settings: @surf_source_write.setter def surf_source_write(self, surf_source_write: dict): - cv.check_type("surface source writing options", surf_source_write, Mapping) + cv.check_type("surface source writing options", + surf_source_write, Mapping) for key, value in surf_source_write.items(): cv.check_value( "surface source writing key", key, - ("surface_ids", "max_particles", "max_source_files", "mcpl", "cell", "cellfrom", "cellto"), + ("surface_ids", "max_particles", "max_source_files", + "mcpl", "cell", "cellfrom", "cellto"), ) if key == "surface_ids": cv.check_type( "surface ids for source banking", value, Iterable, Integral ) for surf_id in value: - cv.check_greater_than("surface id for source banking", surf_id, 0) + cv.check_greater_than( + "surface id for source banking", surf_id, 0) elif key == "mcpl": cv.check_type("write to an MCPL-format file", value, bool) @@ -827,6 +858,79 @@ class Settings: self._surf_source_write = surf_source_write + @property + def collision_track(self) -> dict: + return self._collision_track + + @collision_track.setter + def collision_track(self, collision_track: dict): + cv.check_type('Collision tracking options', collision_track, Mapping) + for key, value in collision_track.items(): + cv.check_value('collision_track key', key, + ('cell_ids', 'reactions', 'universe_ids', 'material_ids', 'nuclides', + 'deposited_E_threshold', 'max_collisions', 'max_collision_track_files', 'mcpl')) + if key == 'cell_ids': + cv.check_type('cell ids for collision tracking data banking', value, + Iterable, Integral) + for cell_id in value: + cv.check_greater_than('cell id for collision tracking data banking', + cell_id, 0) + elif key == 'reactions': + cv.check_type('MT numbers for collision tracking data banking', value, + Iterable) + for reaction in value: + if isinstance(reaction, int): + cv.check_greater_than( + 'MT number for collision tracking data banking', reaction, 0 + ) + elif isinstance(reaction, str): + # check against allowed strings? so far let C++ code handle it + pass + else: + raise TypeError( + f"MT number for collision tracking data banking must be a positive int or string, " + f"got {type(reaction).__name__}") + elif key == 'universe_ids': + cv.check_type('universe ids for collision tracking data banking', value, + Iterable, Integral) + for universe_id in value: + cv.check_greater_than('universe id for collision tracking data banking', + universe_id, 0) + elif key == 'material_ids': + cv.check_type('material ids for collision tracking data banking', value, + Iterable, Integral) + for material_id in value: + cv.check_greater_than('material id for collision tracking data banking', + material_id, 0) + elif key == 'nuclides': + cv.check_type('nuclides for collision tracking data banking', value, + Iterable, str) + for nuclide in value: + # If nuclide name doesn't look valid, give a warning + try: + openmc.data.zam(nuclide) + except ValueError: + warnings.warn(f"Nuclide {nuclide} is not valid") + elif key == 'deposited_E_threshold': + cv.check_type('Deposited Energy Threshold for collision tracking data banking', + value, Real) + cv.check_greater_than('Deposited Energy Threshold for collision tracking data banking', + value, 0) + elif key == 'max_collisions': + cv.check_type('maximum collisions banks per file', + value, Integral) + cv.check_greater_than('maximum collisions banks in collision tracking', + value, 0) + elif key == 'max_collision_track_files': + cv.check_type('maximum collisions banks', + value, Integral) + cv.check_greater_than('maximum number of collision_track files ', + value, 0) + elif key == 'mcpl': + cv.check_type('write to an MCPL-format file', value, bool) + + self._collision_track = collision_track + @property def no_reduce(self) -> bool: return self._no_reduce @@ -943,7 +1047,7 @@ class Settings: def cutoff(self, cutoff: dict): if not isinstance(cutoff, Mapping): msg = f'Unable to set cutoff from "{cutoff}" which is not a '\ - 'Python dictionary' + 'Python dictionary' raise ValueError(msg) for key in cutoff: if key == 'weight': @@ -961,7 +1065,7 @@ class Settings: cv.check_greater_than('energy cutoff', cutoff[key], 0.0) else: msg = f'Unable to set cutoff to "{key}" which is unsupported ' \ - 'by OpenMC' + 'by OpenMC' self._cutoff = cutoff @@ -1130,12 +1234,14 @@ class Settings: @weight_window_checkpoints.setter def weight_window_checkpoints(self, weight_window_checkpoints: dict): for key in weight_window_checkpoints.keys(): - cv.check_value('weight_window_checkpoints', key, ('collision', 'surface')) + cv.check_value('weight_window_checkpoints', + key, ('collision', 'surface')) self._weight_window_checkpoints = weight_window_checkpoints @property def max_splits(self): - raise AttributeError('max_splits has been deprecated. Please use max_history_splits instead') + raise AttributeError( + 'max_splits has been deprecated. Please use max_history_splits instead') @property def max_history_splits(self) -> int: @@ -1184,7 +1290,8 @@ class Settings: def weight_window_generators(self, wwgs): if not isinstance(wwgs, MutableSequence): wwgs = [wwgs] - self._weight_window_generators = cv.CheckedList(WeightWindowGenerator, 'weight window generators', wwgs) + self._weight_window_generators = cv.CheckedList( + WeightWindowGenerator, 'weight window generators', wwgs) @property def random_ray(self) -> dict: @@ -1221,7 +1328,8 @@ class Settings: for mesh, domains in value: cv.check_type('mesh', mesh, MeshBase) cv.check_type('domains', domains, Iterable) - valid_types = (openmc.Material, openmc.Cell, openmc.Universe) + valid_types = (openmc.Material, + openmc.Cell, openmc.Universe) for domain in domains: if not isinstance(domain, valid_types): raise ValueError( @@ -1255,9 +1363,12 @@ class Settings: @source_rejection_fraction.setter def source_rejection_fraction(self, source_rejection_fraction: float): - cv.check_type('source_rejection_fraction', source_rejection_fraction, Real) - cv.check_greater_than('source_rejection_fraction', source_rejection_fraction, 0) - cv.check_less_than('source_rejection_fraction', source_rejection_fraction, 1) + cv.check_type('source_rejection_fraction', + source_rejection_fraction, Real) + cv.check_greater_than('source_rejection_fraction', + source_rejection_fraction, 0) + cv.check_less_than('source_rejection_fraction', + source_rejection_fraction, 1) self._source_rejection_fraction = source_rejection_fraction @property @@ -1422,6 +1533,45 @@ class Settings: subelement = ET.SubElement(element, key) subelement.text = str(self._surf_source_write[key]) + def _create_collision_track_subelement(self, root): + if self._collision_track: + element = ET.SubElement(root, "collision_track") + if 'cell_ids' in self._collision_track: + subelement = ET.SubElement(element, "cell_ids") + subelement.text = ' '.join( + str(x) for x in self._collision_track['cell_ids']) + if 'reactions' in self._collision_track: + subelement = ET.SubElement(element, "reactions") + subelement.text = ' '.join( + str(x) for x in self._collision_track['reactions']) + if 'universe_ids' in self._collision_track: + subelement = ET.SubElement(element, "universe_ids") + subelement.text = ' '.join( + str(x) for x in self._collision_track['universe_ids']) + if 'material_ids' in self._collision_track: + subelement = ET.SubElement(element, "material_ids") + subelement.text = ' '.join( + str(x) for x in self._collision_track['material_ids']) + if 'nuclides' in self._collision_track: + subelement = ET.SubElement(element, "nuclides") + subelement.text = ' '.join( + str(x) for x in self._collision_track['nuclides']) + if 'deposited_E_threshold' in self._collision_track: + subelement = ET.SubElement(element, "deposited_E_threshold") + subelement.text = str( + self._collision_track['deposited_E_threshold']) + if 'max_collisions' in self._collision_track: + subelement = ET.SubElement(element, "max_collisions") + subelement.text = str(self._collision_track['max_collisions']) + if 'max_collision_track_files' in self._collision_track: + subelement = ET.SubElement( + element, "max_collision_track_files") + subelement.text = str( + self._collision_track['max_collision_track_files']) + if 'mcpl' in self._collision_track: + subelement = ET.SubElement(element, "mcpl") + subelement.text = str(self._collision_track['mcpl']).lower() + def _create_confidence_intervals(self, root): if self._confidence_intervals is not None: element = ET.SubElement(root, "confidence_intervals") @@ -1477,8 +1627,8 @@ class Settings: # use default heuristic for entropy mesh if not set by user if self.entropy_mesh.dimension is None: if self.particles is None: - raise RuntimeError("Number of particles must be set in order to " \ - "use entropy mesh dimension heuristic") + raise RuntimeError("Number of particles must be set in order to " + "use entropy mesh dimension heuristic") else: n = ceil((self.particles / 20.0)**(1.0 / 3.0)) d = len(self.entropy_mesh.lower_left) @@ -1568,7 +1718,8 @@ class Settings: path = f"./mesh[@id='{self.ufs_mesh.id}']" if root.find(path) is None: root.append(self.ufs_mesh.to_xml_element()) - if mesh_memo is not None: mesh_memo.add(self.ufs_mesh.id) + if mesh_memo is not None: + mesh_memo.add(self.ufs_mesh.id) def _create_use_decay_photons_subelement(self, root): if self._use_decay_photons is not None: @@ -1694,11 +1845,13 @@ class Settings: if 'collision' in self._weight_window_checkpoints: subelement = ET.SubElement(element, "collision") - subelement.text = str(self._weight_window_checkpoints['collision']).lower() + subelement.text = str( + self._weight_window_checkpoints['collision']).lower() if 'surface' in self._weight_window_checkpoints: subelement = ET.SubElement(element, "surface") - subelement.text = str(self._weight_window_checkpoints['surface']).lower() + subelement.text = str( + self._weight_window_checkpoints['surface']).lower() def _create_max_history_splits_subelement(self, root): if self._max_history_splits is not None: @@ -1730,6 +1883,9 @@ class Settings: for domain in domains: domain_elem = ET.SubElement(mesh_elem, 'domain') domain_elem.set('id', str(domain.id)) + domain_elem.set( + 'type', domain.__class__.__name__.lower()) + if mesh_memo is not None and mesh.id not in mesh_memo: domain_elem.set('type', domain.__class__.__name__.lower()) # See if a element already exists -- if not, add it path = f"./mesh[@id='{mesh.id}']" @@ -1881,6 +2037,25 @@ class Settings: value = int(value) self.surf_source_write[key] = value + def _collision_track_from_xml_element(self, root): + elem = root.find('collision_track') + if elem is not None: + for key in ('cell_ids', 'reactions', 'universe_ids', 'material_ids', 'nuclides', + 'deposited_E_threshold', 'max_collisions', "max_collision_track_files", 'mcpl'): + value = get_text(elem, key) + if value is not None: + if key in ('cell_ids', 'universe_ids', 'material_ids'): + value = [int(x) for x in value.split()] + elif key in ('reactions', 'nuclides'): + value = value.split() + elif key in ('max_collisions', 'max_collision_track_files'): + value = int(value) + elif key == 'deposited_E_threshold': + value = float(value) + elif key == 'mcpl': + value = value in ('true', '1') + self.collision_track[key] = value + def _confidence_intervals_from_xml_element(self, root): text = get_text(root, 'confidence_intervals') if text is not None: @@ -2180,7 +2355,8 @@ class Settings: elif domain_type == 'universe': domain = openmc.Universe(domain_id) domains.append(domain) - self.random_ray['source_region_meshes'].append((mesh, domains)) + self.random_ray['source_region_meshes'].append( + (mesh, domains)) def _use_decay_photons_from_xml_element(self, root): text = get_text(root, 'use_decay_photons') @@ -2223,6 +2399,7 @@ class Settings: self._create_sourcepoint_subelement(element) self._create_surf_source_read_subelement(element) self._create_surf_source_write_subelement(element) + self._create_collision_track_subelement(element) self._create_confidence_intervals(element) self._create_electron_treatment_subelement(element) self._create_energy_mode_subelement(element) @@ -2336,6 +2513,7 @@ class Settings: settings._sourcepoint_from_xml_element(elem) settings._surf_source_read_from_xml_element(elem) settings._surf_source_write_from_xml_element(elem) + settings._collision_track_from_xml_element(elem) settings._confidence_intervals_from_xml_element(elem) settings._electron_treatment_from_xml_element(elem) settings._energy_mode_from_xml_element(elem) diff --git a/openmc/source.py b/openmc/source.py index 9b730cf1de..84d8a9619d 100644 --- a/openmc/source.py +++ b/openmc/source.py @@ -6,7 +6,6 @@ from numbers import Real from pathlib import Path import warnings from typing import Any -from pathlib import Path import lxml.etree as ET import numpy as np @@ -107,10 +106,12 @@ class SourceBase(ABC): cv.check_type('fissionable', value, bool) self._constraints['fissionable'] = value elif key == 'rejection_strategy': - cv.check_value('rejection strategy', value, ('resample', 'kill')) + cv.check_value('rejection strategy', + value, ('resample', 'kill')) self._constraints['rejection_strategy'] = value else: - raise ValueError(f'Unknown key in constraints dictionary: {key}') + raise ValueError( + f'Unknown key in constraints dictionary: {key}') @abstractmethod def populate_xml_element(self, element): @@ -144,13 +145,16 @@ class SourceBase(ABC): dt_elem = ET.SubElement(constraints_elem, "domain_type") dt_elem.text = constraints["domain_type"] id_elem = ET.SubElement(constraints_elem, "domain_ids") - id_elem.text = ' '.join(str(uid) for uid in constraints["domain_ids"]) + id_elem.text = ' '.join(str(uid) + for uid in constraints["domain_ids"]) if "time_bounds" in constraints: dt_elem = ET.SubElement(constraints_elem, "time_bounds") - dt_elem.text = ' '.join(str(t) for t in constraints["time_bounds"]) + dt_elem.text = ' '.join(str(t) + for t in constraints["time_bounds"]) if "energy_bounds" in constraints: dt_elem = ET.SubElement(constraints_elem, "energy_bounds") - dt_elem.text = ' '.join(str(E) for E in constraints["energy_bounds"]) + dt_elem.text = ' '.join(str(E) + for E in constraints["energy_bounds"]) if "fissionable" in constraints: dt_elem = ET.SubElement(constraints_elem, "fissionable") dt_elem.text = str(constraints["fissionable"]).lower() @@ -199,7 +203,8 @@ class SourceBase(ABC): elif source_type == 'mesh': return MeshSource.from_xml_element(elem, meshes) else: - raise ValueError(f'Source type {source_type} is not recognized') + raise ValueError( + f'Source type {source_type} is not recognized') @staticmethod def _get_constraints(elem: ET.Element) -> dict[str, Any]: @@ -316,7 +321,8 @@ class IndependentSource(SourceBase): time: openmc.stats.Univariate | None = None, strength: float = 1.0, particle: str = 'neutron', - domains: Sequence[openmc.Cell | openmc.Material | openmc.Universe] | None = None, + domains: Sequence[openmc.Cell | openmc.Material | + openmc.Universe] | None = None, constraints: dict[str, Any] | None = None ): if domains is not None: @@ -527,11 +533,12 @@ class MeshSource(SourceBase): 'fissionable', and 'rejection_strategy'. """ + def __init__( self, mesh: MeshBase, sources: Sequence[SourceBase], - constraints: dict[str, Any] | None = None, + constraints: dict[str, Any] | None = None, ): super().__init__(strength=None, constraints=constraints) self.mesh = mesh @@ -577,7 +584,8 @@ class MeshSource(SourceBase): elif isinstance(self.mesh, UnstructuredMesh): if s.ndim > 1: - raise ValueError('Sources must be a 1-D array for unstructured mesh') + raise ValueError( + 'Sources must be a 1-D array for unstructured mesh') self._sources = s for src in self._sources: @@ -646,7 +654,8 @@ class MeshSource(SourceBase): mesh_id = int(get_text(elem, 'mesh')) mesh = meshes[mesh_id] - sources = [SourceBase.from_xml_element(e) for e in elem.iterchildren('source')] + sources = [SourceBase.from_xml_element( + e) for e in elem.iterchildren('source')] constraints = cls._get_constraints(elem) return cls(mesh, sources, constraints=constraints) @@ -656,7 +665,8 @@ def Source(*args, **kwargs): A function for backward compatibility of sources. Will be removed in the future. Please update to IndependentSource. """ - warnings.warn("This class is deprecated in favor of 'IndependentSource'", FutureWarning) + warnings.warn( + "This class is deprecated in favor of 'IndependentSource'", FutureWarning) return openmc.IndependentSource(*args, **kwargs) @@ -703,6 +713,7 @@ class CompiledSource(SourceBase): 'fissionable', and 'rejection_strategy'. """ + def __init__( self, library: PathLike, @@ -914,7 +925,8 @@ class ParticleType(IntEnum): try: return cls[value.upper()] except KeyError: - raise ValueError(f"Invalid string for creation of {cls.__name__}: {value}") + raise ValueError( + f"Invalid string for creation of {cls.__name__}: {value}") @classmethod def from_pdg_number(cls, pdg_number: int) -> ParticleType: @@ -984,6 +996,7 @@ class SourceParticle: Type of the particle """ + def __init__( self, r: Iterable[float] = (0., 0., 0.), @@ -1042,7 +1055,8 @@ def write_source_file( openmc.SourceParticle """ - cv.check_iterable_type("source particles", source_particles, SourceParticle) + cv.check_iterable_type( + "source particles", source_particles, SourceParticle) pl = ParticleList(source_particles) pl.export_to_hdf5(filename, **kwargs) @@ -1104,7 +1118,8 @@ class ParticleList(list): for particle in f.particles: # Determine particle type based on the PDG number try: - particle_type = ParticleType.from_pdg_number(particle.pdgcode) + particle_type = ParticleType.from_pdg_number( + particle.pdgcode) except ValueError: particle_type = "UNKNOWN" @@ -1241,3 +1256,133 @@ def read_source_file(filename: PathLike) -> ParticleList: return ParticleList.from_hdf5(filename) else: return ParticleList.from_mcpl(filename) + + +def read_collision_track_hdf5(filename): + """Read a collision track file in HDF5 format. + + Parameters + ---------- + filename : str or path-like + Path to the HDF5 collision track file. + + Returns + ------- + numpy.ndarray + Structured array containing collision track data. + + See Also + -------- + read_collision_track_mcpl + read_collision_track_file + """ + + with h5py.File(filename, 'r') as file: + data = file['collision_track_bank'][:] + + return data + + +def read_collision_track_mcpl(file_path): + """Read a collision track file in MCPL format. + + Parameters + ---------- + file_path : str or path-like + Path to the MCPL collision track file. + + Returns + ------- + numpy.ndarray + Structured array of particle collision track information, including + position, direction, energy, weight, reaction data, and identifiers. + + See Also + -------- + read_collision_track_hdf5 + read_collision_track_file + """ + import mcpl + myfile = mcpl.MCPLFile(file_path) + data = { + 'r': [], # for position (x, y, z) + 'u': [], # for direction (ux, uy, uz) + 'E': [], 'dE': [], 'time': [], + 'wgt': [], 'event_mt': [], 'delayed_group': [], + 'cell_id': [], 'nuclide_id': [], 'material_id': [], + 'universe_id': [], 'n_collision': [], 'particle': [], + 'parent_id': [], 'progeny_id': [] + } + + # Read and collect data from the MCPL file + for i, p in enumerate(myfile.particles): + if f'blob_{i}' in myfile.blobs: + blob_data = myfile.blobs[f'blob_{i}'] + decoded_str = blob_data.decode('utf-8') + pairs = decoded_str.split(';') + values_dict = {k.strip(): v.strip() + for k, v in (pair.split(':') for pair in pairs if pair.strip())} + + data['r'].append((p.x, p.y, p.z)) # Append as tuple + data['u'].append((p.ux, p.uy, p.uz)) # Append as tuple + data['E'].append(p.ekin * 1e6) + data['dE'].append(float(values_dict.get('dE', 0))) + data['time'].append(p.time * 1e-3) + data['wgt'].append(p.weight) + data['event_mt'].append(int(values_dict.get('event_mt', 0))) + data['delayed_group'].append( + int(values_dict.get('delayed_group', 0))) + data['cell_id'].append(int(values_dict.get('cell_id', 0))) + data['nuclide_id'].append(int(values_dict.get('nuclide_id', 0))) + data['material_id'].append(int(values_dict.get('material_id', 0))) + data['universe_id'].append(int(values_dict.get('universe_id', 0))) + data['n_collision'].append(int(values_dict.get('n_collision', 0))) + data['particle'].append(ParticleType.from_pdg_number(p.pdgcode)) + data['parent_id'].append(int(values_dict.get('parent_id', 0))) + data['progeny_id'].append(int(values_dict.get('progeny_id', 0))) + + dtypes = [ + ('r', [('x', 'f8'), ('y', 'f8'), ('z', 'f8')]), + ('u', [('x', 'f8'), ('y', 'f8'), ('z', 'f8')]), + ('E', 'f8'), ('dE', 'f8'), ('time', 'f8'), ('wgt', 'f8'), + ('event_mt', 'f8'), ('delayed_group', 'i4'), ('cell_id', 'i4'), + ('nuclide_id', 'i4'), ('material_id', 'i4'), ('universe_id', 'i4'), + ('n_collision', 'i4'), ('particle', 'i4'), + ('parent_id', 'i8'), ('progeny_id', 'i8') + ] + + structured_array = np.zeros(len(data['r']), dtype=dtypes) + for key in data: + structured_array[key] = data[key] # Assign data + + return structured_array + + +def read_collision_track_file(filename): + """Read a collision track file (HDF5 or MCPL) and return its data. + + Parameters + ---------- + filename : str or path-like + Path to the collision track file to read. Must end with + ``.h5`` or ``.mcpl``. + + Returns + ------- + numpy.ndarray + Structured array containing collision track data. + + See Also + -------- + read_collision_track_hdf5 + read_collision_track_mcpl + """ + + filename = Path(filename) + if filename.suffix not in ('.h5', '.mcpl'): + raise ValueError('Collision track file must have a .h5 or .mcpl extension.') + + if filename.suffix == '.h5': + return read_collision_track_hdf5(filename) + else: + return read_collision_track_mcpl(filename) diff --git a/src/bank.cpp b/src/bank.cpp index 3e806b3c0b..33790379b8 100644 --- a/src/bank.cpp +++ b/src/bank.cpp @@ -20,6 +20,8 @@ vector source_bank; SharedArray surf_source_bank; +SharedArray collision_track_bank; + // The fission bank is allocated as a SharedArray, rather than a vector, as it // will be shared by all threads in the simulation. It will be allocated to a // fixed maximum capacity in the init_fission_bank() function. Then, Elements @@ -50,6 +52,7 @@ void free_memory_bank() { simulation::source_bank.clear(); simulation::surf_source_bank.clear(); + simulation::collision_track_bank.clear(); simulation::fission_bank.clear(); simulation::progeny_per_particle.clear(); simulation::ifp_source_delayed_group_bank.clear(); diff --git a/src/collision_track.cpp b/src/collision_track.cpp new file mode 100644 index 0000000000..75e56574a6 --- /dev/null +++ b/src/collision_track.cpp @@ -0,0 +1,238 @@ +#include "openmc/collision_track.h" + +#include +#include + +#include + +#include "openmc/bank.h" +#include "openmc/bank_io.h" +#include "openmc/cell.h" +#include "openmc/constants.h" +#include "openmc/error.h" +#include "openmc/file_utils.h" +#include "openmc/hdf5_interface.h" +#include "openmc/material.h" +#include "openmc/mcpl_interface.h" +#include "openmc/message_passing.h" +#include "openmc/nuclide.h" +#include "openmc/output.h" +#include "openmc/particle.h" +#include "openmc/settings.h" +#include "openmc/simulation.h" +#include "openmc/universe.h" + +#ifdef OPENMC_MPI +#include +#endif + +namespace openmc { + +namespace { + +hid_t h5_collision_track_banktype() +{ + hid_t postype = H5Tcreate(H5T_COMPOUND, sizeof(Position)); + H5Tinsert(postype, "x", HOFFSET(Position, x), H5T_NATIVE_DOUBLE); + H5Tinsert(postype, "y", HOFFSET(Position, y), H5T_NATIVE_DOUBLE); + H5Tinsert(postype, "z", HOFFSET(Position, z), H5T_NATIVE_DOUBLE); + + hid_t banktype = H5Tcreate(H5T_COMPOUND, sizeof(CollisionTrackSite)); + + H5Tinsert(banktype, "r", HOFFSET(CollisionTrackSite, r), postype); + H5Tinsert(banktype, "u", HOFFSET(CollisionTrackSite, u), postype); + H5Tinsert(banktype, "E", HOFFSET(CollisionTrackSite, E), H5T_NATIVE_DOUBLE); + H5Tinsert(banktype, "dE", HOFFSET(CollisionTrackSite, dE), H5T_NATIVE_DOUBLE); + H5Tinsert( + banktype, "time", HOFFSET(CollisionTrackSite, time), H5T_NATIVE_DOUBLE); + H5Tinsert( + banktype, "wgt", HOFFSET(CollisionTrackSite, wgt), H5T_NATIVE_DOUBLE); + H5Tinsert(banktype, "event_mt", HOFFSET(CollisionTrackSite, event_mt), + H5T_NATIVE_INT); + H5Tinsert(banktype, "delayed_group", + HOFFSET(CollisionTrackSite, delayed_group), H5T_NATIVE_INT); + H5Tinsert( + banktype, "cell_id", HOFFSET(CollisionTrackSite, cell_id), H5T_NATIVE_INT); + H5Tinsert(banktype, "nuclide_id", HOFFSET(CollisionTrackSite, nuclide_id), + H5T_NATIVE_INT); + H5Tinsert(banktype, "material_id", HOFFSET(CollisionTrackSite, material_id), + H5T_NATIVE_INT); + H5Tinsert(banktype, "universe_id", HOFFSET(CollisionTrackSite, universe_id), + H5T_NATIVE_INT); + H5Tinsert(banktype, "n_collision", HOFFSET(CollisionTrackSite, n_collision), + H5T_NATIVE_INT); + H5Tinsert(banktype, "particle", HOFFSET(CollisionTrackSite, particle), + H5T_NATIVE_INT); + H5Tinsert(banktype, "parent_id", HOFFSET(CollisionTrackSite, parent_id), + H5T_NATIVE_INT64); + H5Tinsert(banktype, "progeny_id", HOFFSET(CollisionTrackSite, progeny_id), + H5T_NATIVE_INT64); + H5Tclose(postype); + return banktype; +} + +void write_collision_track_bank(hid_t group_id, + openmc::span collision_track_bank, + const openmc::vector& bank_index) +{ + hid_t banktype = h5_collision_track_banktype(); +#ifdef OPENMC_MPI + write_bank_dataset("collision_track_bank", group_id, collision_track_bank, + bank_index, banktype, mpi::collision_track_site); +#else + write_bank_dataset("collision_track_bank", group_id, collision_track_bank, + bank_index, banktype); +#endif + + H5Tclose(banktype); +} + +void write_h5_collision_track(const char* filename, + openmc::span collision_track_bank, + const openmc::vector& bank_index) +{ +#ifdef PHDF5 + bool parallel = true; +#else + bool parallel = false; +#endif + + if (!filename) + fatal_error("write_h5_collision_track filename needs a nonempty name."); + + std::string filename_(filename); + const auto extension = get_file_extension(filename_); + if (extension.empty()) { + filename_.append(".h5"); + } else if (extension != "h5") { + warning("write_h5_collision_track was passed a file extension differing " + "from .h5, but an hdf5 file will be written."); + } + + hid_t file_id; + if (mpi::master || parallel) { + file_id = file_open(filename_.c_str(), 'w', true); + + // Write filetype and version info + write_attribute(file_id, "filetype", "collision_track"); + write_attribute(file_id, "version", VERSION_COLLISION_TRACK); + } + + write_collision_track_bank(file_id, collision_track_bank, bank_index); + + if (mpi::master || parallel) + file_close(file_id); +} + +} // namespace + +bool should_record_event(int id_cell, int mt_event, const std::string& nuclide, + int id_universe, int id_material, double energy_loss) +{ + auto matches_filter = [](const auto& filter_set, const auto& value) { + return filter_set.empty() || filter_set.count(value) > 0; + }; + + const auto& cfg = settings::collision_track_config; + return simulation::current_batch > settings::n_inactive && + !simulation::collision_track_bank.full() && + matches_filter(cfg.cell_ids, id_cell) && + matches_filter(cfg.mt_numbers, mt_event) && + matches_filter(cfg.universe_ids, id_universe) && + matches_filter(cfg.material_ids, id_material) && + matches_filter(cfg.nuclides, nuclide) && + (cfg.deposited_energy_threshold == 0 || + cfg.deposited_energy_threshold < energy_loss); +} + +void collision_track_reserve_bank() +{ + simulation::collision_track_bank.reserve( + settings::collision_track_config.max_collisions); +} + +void collision_track_flush_bank() +{ + const auto& cfg = settings::collision_track_config; + if (simulation::ct_current_file > cfg.max_files) + return; + + bool last_batch = (simulation::current_batch == settings::n_batches); + if (!simulation::collision_track_bank.full() && !last_batch) + return; + + auto size = simulation::collision_track_bank.size(); + if (size == 0 && !last_batch) + return; + + auto collision_track_work_index = mpi::calculate_parallel_index_vector(size); + openmc::span collisiontrackbankspan( + simulation::collision_track_bank.begin(), size); + + std::string ext = cfg.mcpl_write ? "mcpl" : "h5"; + auto filename = fmt::format("{}collision_track.{}.{}", settings::path_output, + simulation::ct_current_file, ext); + + if (cfg.max_files == 1 || (simulation::ct_current_file == 1 && last_batch)) { + filename = settings::path_output + "collision_track." + ext; + } + write_message("Creating {}...", filename, 4); + + if (cfg.mcpl_write) { + write_mcpl_collision_track( + filename.c_str(), collisiontrackbankspan, collision_track_work_index); + } else { + write_h5_collision_track( + filename.c_str(), collisiontrackbankspan, collision_track_work_index); + } + + simulation::collision_track_bank.clear(); + if (!last_batch && cfg.max_files >= 1) { + collision_track_reserve_bank(); + } + ++simulation::ct_current_file; +} + +void collision_track_record(Particle& particle) +{ + int cell_index = particle.lowest_coord().cell(); + if (cell_index == C_NONE) + return; + + int cell_id = model::cells[cell_index]->id_; + const auto* nuclide_ptr = data::nuclides[particle.event_nuclide()].get(); + std::string nuclide = nuclide_ptr->name_; + int universe_id = model::universes[particle.lowest_coord().universe()]->id_; + double delta_E = particle.E_last() - particle.E(); + int material_index = particle.material(); + if (material_index == C_NONE) + return; + + int material_id = model::materials[material_index]->id_; + + if (!should_record_event(cell_id, particle.event_mt(), nuclide, universe_id, + material_id, delta_E)) + return; + + CollisionTrackSite site; + site.r = particle.r(); + site.u = particle.u(); + site.E = particle.E_last(); + site.dE = delta_E; + site.time = particle.time(); + site.wgt = particle.wgt(); + site.event_mt = particle.event_mt(); + site.delayed_group = particle.delayed_group(); + site.cell_id = cell_id; + site.nuclide_id = + 10000 * nuclide_ptr->Z_ + 10 * nuclide_ptr->A_ + nuclide_ptr->metastable_; + site.material_id = material_id; + site.universe_id = universe_id; + site.n_collision = particle.n_collision(); + site.particle = particle.type(); + site.parent_id = particle.id(); + site.progeny_id = particle.n_progeny(); + simulation::collision_track_bank.thread_safe_append(site); +} + +} // namespace openmc diff --git a/src/finalize.cpp b/src/finalize.cpp index 659f390b39..9ee9434099 100644 --- a/src/finalize.cpp +++ b/src/finalize.cpp @@ -3,6 +3,7 @@ #include "openmc/bank.h" #include "openmc/capi.h" #include "openmc/cmfd_solver.h" +#include "openmc/collision_track.h" #include "openmc/constants.h" #include "openmc/cross_sections.h" #include "openmc/dagmc.h" @@ -76,6 +77,7 @@ int openmc_finalize() // Reset global variables settings::assume_separate = false; settings::check_overlaps = false; + settings::collision_track_config = CollisionTrackConfig {}; settings::confidence_intervals = false; settings::create_fission_neutrons = true; settings::create_delayed_neutrons = true; @@ -177,6 +179,9 @@ int openmc_finalize() if (mpi::source_site != MPI_DATATYPE_NULL) { MPI_Type_free(&mpi::source_site); } + if (mpi::collision_track_site != MPI_DATATYPE_NULL) { + MPI_Type_free(&mpi::collision_track_site); + } #endif openmc_reset_random_ray(); diff --git a/src/initialize.cpp b/src/initialize.cpp index 36f3261169..e2a5b97433 100644 --- a/src/initialize.cpp +++ b/src/initialize.cpp @@ -178,6 +178,37 @@ void initialize_mpi(MPI_Comm intracomm) MPI_DOUBLE, MPI_INT, MPI_INT, MPI_INT, MPI_INT, MPI_LONG, MPI_LONG}; MPI_Type_create_struct(11, blocks, disp, types, &mpi::source_site); MPI_Type_commit(&mpi::source_site); + + CollisionTrackSite bc; + MPI_Aint dispc[16]; + MPI_Get_address(&bc.r, &dispc[0]); // double + MPI_Get_address(&bc.u, &dispc[1]); // double + MPI_Get_address(&bc.E, &dispc[2]); // double + MPI_Get_address(&bc.dE, &dispc[3]); // double + MPI_Get_address(&bc.time, &dispc[4]); // double + MPI_Get_address(&bc.wgt, &dispc[5]); // double + MPI_Get_address(&bc.event_mt, &dispc[6]); // int + MPI_Get_address(&bc.delayed_group, &dispc[7]); // int + MPI_Get_address(&bc.cell_id, &dispc[8]); // int + MPI_Get_address(&bc.nuclide_id, &dispc[9]); // int + MPI_Get_address(&bc.material_id, &dispc[10]); // int + MPI_Get_address(&bc.universe_id, &dispc[11]); // int + MPI_Get_address(&bc.n_collision, &dispc[12]); // int + MPI_Get_address(&bc.particle, &dispc[13]); // int + MPI_Get_address(&bc.parent_id, &dispc[14]); // int64_t + MPI_Get_address(&bc.progeny_id, &dispc[15]); // int64_t + for (int i = 15; i >= 0; --i) { + dispc[i] -= dispc[0]; + } + + int blocksc[] = {3, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}; + MPI_Datatype typesc[] = {MPI_DOUBLE, MPI_DOUBLE, MPI_DOUBLE, MPI_DOUBLE, + MPI_DOUBLE, MPI_DOUBLE, MPI_INT, MPI_INT, MPI_INT, MPI_INT, MPI_INT, + MPI_INT, MPI_INT, MPI_INT, MPI_INT64_T, MPI_INT64_T}; + + MPI_Type_create_struct( + 16, blocksc, dispc, typesc, &mpi::collision_track_site); + MPI_Type_commit(&mpi::collision_track_site); } #endif // OPENMC_MPI diff --git a/src/mcpl_interface.cpp b/src/mcpl_interface.cpp index b8e7807100..1294073019 100644 --- a/src/mcpl_interface.cpp +++ b/src/mcpl_interface.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -61,6 +62,8 @@ using mcpl_hdr_nparticles_fpt = uint64_t (*)(mcpl_file_t* file_handle); using mcpl_read_fpt = const mcpl_particle_repr_t* (*)(mcpl_file_t* file_handle); using mcpl_close_file_fpt = void (*)(mcpl_file_t* file_handle); +using mcpl_hdr_add_data_fpt = void (*)(mcpl_outfile_t* file_handle, + const char* key, int32_t ldata, const char* data); using mcpl_create_outfile_fpt = mcpl_outfile_t* (*)(const char* filename); using mcpl_hdr_set_srcname_fpt = void (*)( mcpl_outfile_t* outfile_handle, const char* srcname); @@ -110,6 +113,7 @@ struct McplApi { mcpl_close_file_fpt close_file; mcpl_create_outfile_fpt create_outfile; mcpl_hdr_set_srcname_fpt hdr_set_srcname; + mcpl_hdr_add_data_fpt hdr_add_data; mcpl_add_particle_fpt add_particle; mcpl_close_outfile_fpt close_outfile; mcpl_hdr_add_stat_sum_fpt hdr_add_stat_sum; @@ -146,6 +150,8 @@ struct McplApi { load_symbol_platform("mcpl_create_outfile")); hdr_set_srcname = reinterpret_cast( load_symbol_platform("mcpl_hdr_set_srcname")); + hdr_add_data = reinterpret_cast( + load_symbol_platform("mcpl_hdr_add_data")); add_particle = reinterpret_cast( load_symbol_platform("mcpl_add_particle")); close_outfile = reinterpret_cast( @@ -545,4 +551,153 @@ void write_mcpl_source_point(const char* filename, span source_bank, } } +// Collision track feature with MCPL +void write_mcpl_collision_track_internal(mcpl_outfile_t* file_id, + span collision_track_bank, + const vector& bank_index_all_ranks) +{ + if (mpi::master) { + if (!file_id) { + fatal_error("MCPL: Internal error - master rank called " + "write_mcpl_source_bank_internal with null file_id."); + } + vector receive_buffer; + vector all_sites; + all_sites.reserve(static_cast(bank_index_all_ranks.back())); + vector all_blobs; + all_blobs.reserve(static_cast(bank_index_all_ranks.back())); + + for (int rank_idx = 0; rank_idx < mpi::n_procs; ++rank_idx) { + size_t num_sites_on_rank = static_cast( + bank_index_all_ranks[rank_idx + 1] - bank_index_all_ranks[rank_idx]); + if (num_sites_on_rank == 0) + continue; + + span sites_to_process; +#ifdef OPENMC_MPI + if (rank_idx == mpi::rank) { + sites_to_process = openmc::span( + collision_track_bank.data(), num_sites_on_rank); + } else { + receive_buffer.resize(num_sites_on_rank); + MPI_Recv(receive_buffer.data(), num_sites_on_rank, + mpi::collision_track_site, rank_idx, rank_idx, mpi::intracomm, + MPI_STATUS_IGNORE); + sites_to_process = openmc::span( + receive_buffer.data(), num_sites_on_rank); + } +#else + sites_to_process = openmc::span( + collision_track_bank.data(), num_sites_on_rank); +#endif + + for (const auto& site : sites_to_process) { + std::ostringstream custom_data_stream; + custom_data_stream << " dE : " << site.dE + << " ; event_mt : " << site.event_mt + << " ; delayed_group : " << site.delayed_group + << " ; cell_id : " << site.cell_id + << " ; nuclide_id : " << site.nuclide_id + << " ; material_id : " << site.material_id + << " ; universe_id : " << site.universe_id + << " ; n_collision : " << site.n_collision + << " ; parent_id : " << site.parent_id + << " ; progeny_id : " << site.progeny_id; + + all_blobs.push_back(custom_data_stream.str()); + all_sites.push_back(site); + } + } + + for (size_t idx = 0; idx < all_blobs.size(); ++idx) { + const auto& blob = all_blobs[idx]; + std::string key = "blob_" + std::to_string(idx); + g_mcpl_api->hdr_add_data(file_id, key.c_str(), blob.size(), blob.c_str()); + } + + for (const auto& site : all_sites) { + mcpl_particle_repr_t p_repr {}; + p_repr.position[0] = site.r.x; + p_repr.position[1] = site.r.y; + p_repr.position[2] = site.r.z; + p_repr.direction[0] = site.u.x; + p_repr.direction[1] = site.u.y; + p_repr.direction[2] = site.u.z; + p_repr.ekin = site.E * 1e-6; + p_repr.time = site.time * 1e3; + p_repr.weight = site.wgt; + switch (site.particle) { + case ParticleType::neutron: + p_repr.pdgcode = 2112; + break; + case ParticleType::photon: + p_repr.pdgcode = 22; + break; + case ParticleType::electron: + p_repr.pdgcode = 11; + break; + case ParticleType::positron: + p_repr.pdgcode = -11; + break; + default: + continue; + } + g_mcpl_api->add_particle(file_id, &p_repr); + } + } else { +#ifdef OPENMC_MPI + if (!collision_track_bank.empty()) { + MPI_Send(collision_track_bank.data(), collision_track_bank.size(), + mpi::collision_track_site, 0, mpi::rank, mpi::intracomm); + } +#endif + } +} + +void write_mcpl_collision_track(const char* filename, + span collision_track_bank, + const vector& bank_index) +{ + ensure_mcpl_ready_or_fatal(); + + std::string filename_(filename); + const auto extension = get_file_extension(filename_); + if (extension.empty()) { + filename_.append(".mcpl"); + } else if (extension != "mcpl") { + warning(fmt::format("Specified filename '{}' has an extension '.{}', but " + "an MCPL file (.mcpl) will be written using this name.", + filename, extension)); + } + + mcpl_outfile_t* file_id = nullptr; + + if (mpi::master) { + file_id = g_mcpl_api->create_outfile(filename_.c_str()); + if (!file_id) { + fatal_error(fmt::format( + "MCPL: Failed to create output file '{}'. Check permissions and path.", + filename_)); + } + std::string src_line; + if (VERSION_DEV) { + src_line = fmt::format("OpenMC {}.{}.{}-dev{}", VERSION_MAJOR, + VERSION_MINOR, VERSION_RELEASE, VERSION_COMMIT_COUNT); + } else { + src_line = fmt::format( + "OpenMC {}.{}.{}", VERSION_MAJOR, VERSION_MINOR, VERSION_RELEASE); + } + + g_mcpl_api->hdr_set_srcname(file_id, src_line.c_str()); + } + write_mcpl_collision_track_internal( + file_id, collision_track_bank, bank_index); + + if (mpi::master) { + if (file_id) { + g_mcpl_api->close_outfile(file_id); + } + } +} + } // namespace openmc diff --git a/src/message_passing.cpp b/src/message_passing.cpp index 374c1aa725..a160f6d73c 100644 --- a/src/message_passing.cpp +++ b/src/message_passing.cpp @@ -10,6 +10,7 @@ bool master {true}; #ifdef OPENMC_MPI MPI_Comm intracomm {MPI_COMM_NULL}; MPI_Datatype source_site {MPI_DATATYPE_NULL}; +MPI_Datatype collision_track_site {MPI_DATATYPE_NULL}; #endif extern "C" bool openmc_master() diff --git a/src/particle.cpp b/src/particle.cpp index 6ba8ebf128..2d70d715e2 100644 --- a/src/particle.cpp +++ b/src/particle.cpp @@ -8,6 +8,7 @@ #include "openmc/bank.h" #include "openmc/capi.h" #include "openmc/cell.h" +#include "openmc/collision_track.h" #include "openmc/constants.h" #include "openmc/dagmc.h" #include "openmc/error.h" @@ -351,6 +352,11 @@ void Particle::event_collide() collision_mg(*this); } + // Collision track feature to recording particle interaction + if (settings::collision_track) { + collision_track_record(*this); + } + // Score collision estimator tallies -- this is done after a collision // has occurred rather than before because we need information on the // outgoing energy for any tallies with an outgoing energy filter diff --git a/src/physics.cpp b/src/physics.cpp index 3a17077b36..41509af97b 100644 --- a/src/physics.cpp +++ b/src/physics.cpp @@ -122,6 +122,7 @@ void sample_neutron_reaction(Particle& p) "with k-effective close to or greater than one."); } } + p.event_mt() = rx.mt_; } // Create secondary photons @@ -663,7 +664,9 @@ void absorption(Particle& p, int i_nuclide) p.wgt() = 0.0; p.event() = TallyEvent::ABSORB; - p.event_mt() = N_DISAPPEAR; + if (!p.fission()) { + p.event_mt() = N_DISAPPEAR; + } } } } diff --git a/src/settings.cpp b/src/settings.cpp index 13b91b0e4f..9dcf7c8dbc 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -11,6 +11,7 @@ #endif #include "openmc/capi.h" +#include "openmc/collision_track.h" #include "openmc/constants.h" #include "openmc/container_util.h" #include "openmc/distribution.h" @@ -26,6 +27,7 @@ #include "openmc/plot.h" #include "openmc/random_lcg.h" #include "openmc/random_ray/random_ray.h" +#include "openmc/reaction.h" #include "openmc/simulation.h" #include "openmc/source.h" #include "openmc/string_utils.h" @@ -45,6 +47,7 @@ namespace settings { // Default values for boolean flags bool assume_separate {false}; bool check_overlaps {false}; +bool collision_track {false}; bool cmfd_run {false}; bool confidence_intervals {false}; bool create_delayed_neutrons {true}; @@ -128,6 +131,7 @@ std::unordered_set statepoint_batch; double source_rejection_fraction {0.05}; double free_gas_threshold {400.0}; std::unordered_set source_write_surf_id; +CollisionTrackConfig collision_track_config {}; int64_t ssw_max_particles; int64_t ssw_max_files; int64_t ssw_cell_id {C_NONE}; @@ -925,8 +929,72 @@ void read_settings_xml(pugi::xml_node root) } } - // If source is not separate and is to be written out in the statepoint file, - // make sure that the sourcepoint batch numbers are contained in the + // Check if the user has specified to write specific collisions + if (check_for_node(root, "collision_track")) { + settings::collision_track = true; + // Get collision track node + xml_node node_ct = root.child("collision_track"); + collision_track_config = CollisionTrackConfig {}; + + // Determine cell ids at which crossing particles are to be banked + if (check_for_node(node_ct, "cell_ids")) { + auto temp = get_node_array(node_ct, "cell_ids"); + for (const auto& b : temp) { + collision_track_config.cell_ids.insert(b); + } + } + if (check_for_node(node_ct, "reactions")) { + auto temp = get_node_array(node_ct, "reactions"); + for (const auto& b : temp) { + int reaction_int = reaction_type(b); + if (reaction_int > 0) { + collision_track_config.mt_numbers.insert(reaction_int); + } + } + } + if (check_for_node(node_ct, "universe_ids")) { + auto temp = get_node_array(node_ct, "universe_ids"); + for (const auto& b : temp) { + collision_track_config.universe_ids.insert(b); + } + } + if (check_for_node(node_ct, "material_ids")) { + auto temp = get_node_array(node_ct, "material_ids"); + for (const auto& b : temp) { + collision_track_config.material_ids.insert(b); + } + } + if (check_for_node(node_ct, "nuclides")) { + auto temp = get_node_array(node_ct, "nuclides"); + for (const auto& b : temp) { + collision_track_config.nuclides.insert(b); + } + } + if (check_for_node(node_ct, "deposited_E_threshold")) { + collision_track_config.deposited_energy_threshold = + std::stod(get_node_value(node_ct, "deposited_E_threshold")); + } + // Get maximum number of particles to be banked per collision + if (check_for_node(node_ct, "max_collisions")) { + collision_track_config.max_collisions = + std::stoll(get_node_value(node_ct, "max_collisions")); + } else { + warning("A maximum number of collisions needs to be specified. " + "By default the code sets 'max_collisions' parameter equals to " + "1000."); + } + // Get maximum number of collision_track files to be created + if (check_for_node(node_ct, "max_collision_track_files")) { + collision_track_config.max_files = + std::stoll(get_node_value(node_ct, "max_collision_track_files")); + } + if (check_for_node(node_ct, "mcpl")) { + collision_track_config.mcpl_write = get_node_value_bool(node_ct, "mcpl"); + } + } + + // If source is not separate and is to be written out in the statepoint + // file, make sure that the sourcepoint batch numbers are contained in the // statepoint list if (!source_separate) { for (const auto& b : sourcepoint_batch) { @@ -1171,8 +1239,8 @@ void read_settings_xml(pugi::xml_node root) variance_reduction::weight_windows_generators.emplace_back( std::make_unique(node_wwg)); } - // if any of the weight windows are intended to be generated otf, make sure - // they're applied + // if any of the weight windows are intended to be generated otf, make + // sure they're applied for (const auto& wwg : variance_reduction::weight_windows_generators) { if (wwg->on_the_fly_) { settings::weight_windows_on = true; diff --git a/src/simulation.cpp b/src/simulation.cpp index 05d5545260..b536ae5881 100644 --- a/src/simulation.cpp +++ b/src/simulation.cpp @@ -2,6 +2,7 @@ #include "openmc/bank.h" #include "openmc/capi.h" +#include "openmc/collision_track.h" #include "openmc/container_util.h" #include "openmc/eigenvalue.h" #include "openmc/error.h" @@ -9,7 +10,6 @@ #include "openmc/geometry_aux.h" #include "openmc/ifp.h" #include "openmc/material.h" -#include "openmc/mcpl_interface.h" #include "openmc/message_passing.h" #include "openmc/nuclide.h" #include "openmc/output.h" @@ -118,6 +118,7 @@ int openmc_simulation_init() // Reset global variables -- this is done before loading state point (as that // will potentially populate k_generation and entropy) simulation::current_batch = 0; + simulation::ct_current_file = 1; simulation::ssw_current_file = 1; simulation::k_generation.clear(); simulation::entropy.clear(); @@ -297,6 +298,7 @@ namespace openmc { namespace simulation { +int ct_current_file; int current_batch; int current_gen; bool initialized {false}; @@ -347,6 +349,11 @@ void allocate_banks() // Allocate surface source bank simulation::surf_source_bank.reserve(settings::ssw_max_particles); } + + if (settings::collision_track) { + // Allocate collision track bank + collision_track_reserve_bank(); + } } void initialize_batch() @@ -490,6 +497,10 @@ void finalize_batch() ++simulation::ssw_current_file; } } + // Write collision track file if requested + if (settings::collision_track) { + collision_track_flush_bank(); + } } void initialize_generation() diff --git a/src/state_point.cpp b/src/state_point.cpp index 0b0fed1324..47296da3a5 100644 --- a/src/state_point.cpp +++ b/src/state_point.cpp @@ -9,6 +9,7 @@ #include #include "openmc/bank.h" +#include "openmc/bank_io.h" #include "openmc/capi.h" #include "openmc/constants.h" #include "openmc/eigenvalue.h" @@ -642,91 +643,12 @@ void write_source_bank(hid_t group_id, span source_bank, { hid_t banktype = h5banktype(); - // Set total and individual process dataspace sizes for source bank - int64_t dims_size = bank_index.back(); - int64_t count_size = bank_index[mpi::rank + 1] - bank_index[mpi::rank]; - -#ifdef PHDF5 - // Set size of total dataspace for all procs and rank - hsize_t dims[] {static_cast(dims_size)}; - hid_t dspace = H5Screate_simple(1, dims, nullptr); - hid_t dset = H5Dcreate(group_id, "source_bank", banktype, dspace, H5P_DEFAULT, - H5P_DEFAULT, H5P_DEFAULT); - - // Create another data space but for each proc individually - hsize_t count[] {static_cast(count_size)}; - hid_t memspace = H5Screate_simple(1, count, nullptr); - - // Select hyperslab for this dataspace - hsize_t start[] {static_cast(bank_index[mpi::rank])}; - H5Sselect_hyperslab(dspace, H5S_SELECT_SET, start, nullptr, count, nullptr); - - // Set up the property list for parallel writing - hid_t plist = H5Pcreate(H5P_DATASET_XFER); - H5Pset_dxpl_mpio(plist, H5FD_MPIO_COLLECTIVE); - - // Write data to file in parallel - H5Dwrite(dset, banktype, memspace, dspace, plist, source_bank.data()); - - // Free resources - H5Sclose(dspace); - H5Sclose(memspace); - H5Dclose(dset); - H5Pclose(plist); - +#ifdef OPENMC_MPI + write_bank_dataset("source_bank", group_id, source_bank, bank_index, banktype, + mpi::source_site); #else - - if (mpi::master) { - // Create dataset big enough to hold all source sites - hsize_t dims[] {static_cast(dims_size)}; - hid_t dspace = H5Screate_simple(1, dims, nullptr); - hid_t dset = H5Dcreate(group_id, "source_bank", banktype, dspace, - H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT); - - // Save source bank sites since the array is overwritten below -#ifdef OPENMC_MPI - vector temp_source {source_bank.begin(), source_bank.end()}; -#endif - - for (int i = 0; i < mpi::n_procs; ++i) { - // Create memory space - hsize_t count[] {static_cast(bank_index[i + 1] - bank_index[i])}; - hid_t memspace = H5Screate_simple(1, count, nullptr); - -#ifdef OPENMC_MPI - // Receive source sites from other processes - if (i > 0) - MPI_Recv(source_bank.data(), count[0], mpi::source_site, i, i, - mpi::intracomm, MPI_STATUS_IGNORE); -#endif - - // Select hyperslab for this dataspace - dspace = H5Dget_space(dset); - hsize_t start[] {static_cast(bank_index[i])}; - H5Sselect_hyperslab( - dspace, H5S_SELECT_SET, start, nullptr, count, nullptr); - - // Write data to hyperslab - H5Dwrite( - dset, banktype, memspace, dspace, H5P_DEFAULT, source_bank.data()); - - H5Sclose(memspace); - H5Sclose(dspace); - } - - // Close all ids - H5Dclose(dset); - -#ifdef OPENMC_MPI - // Restore state of source bank - std::copy(temp_source.begin(), temp_source.end(), source_bank.begin()); -#endif - } else { -#ifdef OPENMC_MPI - MPI_Send(source_bank.data(), count_size, mpi::source_site, 0, mpi::rank, - mpi::intracomm); -#endif - } + write_bank_dataset( + "source_bank", group_id, source_bank, bank_index, banktype); #endif H5Tclose(banktype); diff --git a/tests/regression_tests/collision_track/__init__.py b/tests/regression_tests/collision_track/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/regression_tests/collision_track/case_1_Reactions/inputs_true.dat b/tests/regression_tests/collision_track/case_1_Reactions/inputs_true.dat new file mode 100644 index 0000000000..7533616c05 --- /dev/null +++ b/tests/regression_tests/collision_track/case_1_Reactions/inputs_true.dat @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + eigenvalue + 100 + 5 + 1 + + + -2.0 -2.0 -2.0 2.0 2.0 2.0 + + + true + + + + (n,fission) 101 + 300 + + 1 + + diff --git a/tests/regression_tests/collision_track/case_1_Reactions/results_true.dat b/tests/regression_tests/collision_track/case_1_Reactions/results_true.dat new file mode 100644 index 0000000000..d4d1d1e5ad --- /dev/null +++ b/tests/regression_tests/collision_track/case_1_Reactions/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +5.642735E-02 1.494035E-02 diff --git a/tests/regression_tests/collision_track/case_2_Cell_ID/inputs_true.dat b/tests/regression_tests/collision_track/case_2_Cell_ID/inputs_true.dat new file mode 100644 index 0000000000..55fb835de0 --- /dev/null +++ b/tests/regression_tests/collision_track/case_2_Cell_ID/inputs_true.dat @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + eigenvalue + 100 + 5 + 1 + + + -2.0 -2.0 -2.0 2.0 2.0 2.0 + + + true + + + + 22 + 300 + + 1 + + diff --git a/tests/regression_tests/collision_track/case_2_Cell_ID/results_true.dat b/tests/regression_tests/collision_track/case_2_Cell_ID/results_true.dat new file mode 100644 index 0000000000..d4d1d1e5ad --- /dev/null +++ b/tests/regression_tests/collision_track/case_2_Cell_ID/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +5.642735E-02 1.494035E-02 diff --git a/tests/regression_tests/collision_track/case_3_Material_ID/inputs_true.dat b/tests/regression_tests/collision_track/case_3_Material_ID/inputs_true.dat new file mode 100644 index 0000000000..61890414ba --- /dev/null +++ b/tests/regression_tests/collision_track/case_3_Material_ID/inputs_true.dat @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + eigenvalue + 100 + 5 + 1 + + + -2.0 -2.0 -2.0 2.0 2.0 2.0 + + + true + + + + 1 + 300 + + 1 + + diff --git a/tests/regression_tests/collision_track/case_3_Material_ID/results_true.dat b/tests/regression_tests/collision_track/case_3_Material_ID/results_true.dat new file mode 100644 index 0000000000..d4d1d1e5ad --- /dev/null +++ b/tests/regression_tests/collision_track/case_3_Material_ID/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +5.642735E-02 1.494035E-02 diff --git a/tests/regression_tests/collision_track/case_4_Nuclide_ID/inputs_true.dat b/tests/regression_tests/collision_track/case_4_Nuclide_ID/inputs_true.dat new file mode 100644 index 0000000000..8960dde5cb --- /dev/null +++ b/tests/regression_tests/collision_track/case_4_Nuclide_ID/inputs_true.dat @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + eigenvalue + 100 + 5 + 1 + + + -2.0 -2.0 -2.0 2.0 2.0 2.0 + + + true + + + + O16 U235 + 300 + + 1 + + diff --git a/tests/regression_tests/collision_track/case_4_Nuclide_ID/results_true.dat b/tests/regression_tests/collision_track/case_4_Nuclide_ID/results_true.dat new file mode 100644 index 0000000000..d4d1d1e5ad --- /dev/null +++ b/tests/regression_tests/collision_track/case_4_Nuclide_ID/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +5.642735E-02 1.494035E-02 diff --git a/tests/regression_tests/collision_track/case_5_Universe_ID/inputs_true.dat b/tests/regression_tests/collision_track/case_5_Universe_ID/inputs_true.dat new file mode 100644 index 0000000000..8c0d7aa8ee --- /dev/null +++ b/tests/regression_tests/collision_track/case_5_Universe_ID/inputs_true.dat @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + eigenvalue + 100 + 5 + 1 + + + -2.0 -2.0 -2.0 2.0 2.0 2.0 + + + true + + + + 22 + 77 + 300 + + 1 + + diff --git a/tests/regression_tests/collision_track/case_5_Universe_ID/results_true.dat b/tests/regression_tests/collision_track/case_5_Universe_ID/results_true.dat new file mode 100644 index 0000000000..d4d1d1e5ad --- /dev/null +++ b/tests/regression_tests/collision_track/case_5_Universe_ID/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +5.642735E-02 1.494035E-02 diff --git a/tests/regression_tests/collision_track/case_6_deposited_energy_threshold/inputs_true.dat b/tests/regression_tests/collision_track/case_6_deposited_energy_threshold/inputs_true.dat new file mode 100644 index 0000000000..5173dc35cf --- /dev/null +++ b/tests/regression_tests/collision_track/case_6_deposited_energy_threshold/inputs_true.dat @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + eigenvalue + 100 + 5 + 1 + + + -2.0 -2.0 -2.0 2.0 2.0 2.0 + + + true + + + + 550000.0 + 300 + + 1 + + diff --git a/tests/regression_tests/collision_track/case_6_deposited_energy_threshold/results_true.dat b/tests/regression_tests/collision_track/case_6_deposited_energy_threshold/results_true.dat new file mode 100644 index 0000000000..d4d1d1e5ad --- /dev/null +++ b/tests/regression_tests/collision_track/case_6_deposited_energy_threshold/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +5.642735E-02 1.494035E-02 diff --git a/tests/regression_tests/collision_track/case_7_all_parameters_used_together/inputs_true.dat b/tests/regression_tests/collision_track/case_7_all_parameters_used_together/inputs_true.dat new file mode 100644 index 0000000000..005d9feb27 --- /dev/null +++ b/tests/regression_tests/collision_track/case_7_all_parameters_used_together/inputs_true.dat @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + eigenvalue + 100 + 5 + 1 + + + -2.0 -2.0 -2.0 2.0 2.0 2.0 + + + true + + + + 22 33 + elastic 18 (n,disappear) + 77 + 1 11 + U238 U235 H1 U234 + 100000.0 + 300 + + 1 + + diff --git a/tests/regression_tests/collision_track/case_7_all_parameters_used_together/results_true.dat b/tests/regression_tests/collision_track/case_7_all_parameters_used_together/results_true.dat new file mode 100644 index 0000000000..d4d1d1e5ad --- /dev/null +++ b/tests/regression_tests/collision_track/case_7_all_parameters_used_together/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +5.642735E-02 1.494035E-02 diff --git a/tests/regression_tests/collision_track/case_8_2threads/inputs_true.dat b/tests/regression_tests/collision_track/case_8_2threads/inputs_true.dat new file mode 100644 index 0000000000..514932c1a6 --- /dev/null +++ b/tests/regression_tests/collision_track/case_8_2threads/inputs_true.dat @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + eigenvalue + 100 + 5 + 1 + + + -2.0 -2.0 -2.0 2.0 2.0 2.0 + + + true + + + + 200 + + 1 + + diff --git a/tests/regression_tests/collision_track/case_8_2threads/results_true.dat b/tests/regression_tests/collision_track/case_8_2threads/results_true.dat new file mode 100644 index 0000000000..d4d1d1e5ad --- /dev/null +++ b/tests/regression_tests/collision_track/case_8_2threads/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +5.642735E-02 1.494035E-02 diff --git a/tests/regression_tests/collision_track/test.py b/tests/regression_tests/collision_track/test.py new file mode 100644 index 0000000000..00e1e3de41 --- /dev/null +++ b/tests/regression_tests/collision_track/test.py @@ -0,0 +1,255 @@ +"""Test the 'collision_track' setting. + +Results +------- + +All results are generated using only 1 MPI process. + +All results are generated using 1 thread except for "test_consistency_low_realization_number". +This specific test verifies that when the number of realization (i.e., point being candidate +to be stored) is lower than the capacity, results are reproducible even with multiple +threads (i.e., there is no potential thread competition that would produce different +results in that case). + +All results are generated using the history-based mode except for cases e01 to e03. + +All results are visually verified using the '_visualize.py' script in the regression test folder. + +OpenMC models +------------- + +Four OpenMC models with CSG-only geometries are used to cover the transmission, vacuum, +reflective and periodic Boundary Conditions (BC): + +- model_1: cylindrical core in 2 boxes (vacuum and transmission BC), + +# Test cases for simulation parameters using CSG-only geometries +# ============================================================ +# Each test case is defined by a combination of folder name, model name, and specific parameters. +# Below is a summary of the parameters used in the test cases: +# +# - max_collisions: Maximum number of particles to track in the simulation. +# - reactions: List of MT numbers (reaction types- 2 for scattering, 18 for fission, 101 for absorbtion). +# - cell_ids: IDs of specific cells in the model. +# - mat_ids: Material IDs for filtering particles. +# - nuclides: Nuclides for filtering particles. +# - univ_ids: Universe IDs for filtering particles. +# - E_threshold: Energy threshold for filtering particles (optional). +# +# The test cases are designed to validate the behavior of the simulation under various configurations. + +*: BC stands for Boundary Conditions, T for Transmission, R for Reflective, and V for Vacuum. + +An additional case, called 'case-a01', is used to check that the results are comparable when +the number of threads is set to 2 if the number of realization is lower than the capacity. + + +*: BC stands for Boundary Conditions, T for Transmission, and V for Vacuum. + +Notes: + +- The test cases list is non-exhaustive compared to the number of possible combinations. + Test cases have been selected based on use and internal code logic. + + + +TODO: + +- Test with a lattice. + +""" + +import os + +import openmc +import openmc.lib +import pytest + +from tests.testing_harness import CollisionTrackTestHarness +from tests.regression_tests import config + + +@pytest.fixture(scope="function") +def two_threads(monkeypatch): + """Set the number of OMP threads to 2 for the test.""" + monkeypatch.setenv("OMP_NUM_THREADS", "2") + + +@pytest.fixture(scope="function") +def single_process(monkeypatch): + """Set the number of MPI process to 1 for the test.""" + monkeypatch.setitem(config, "mpi_np", "1") + + +@pytest.fixture(scope="module") +def model_1(): + """Cylindrical core contained in a first box which is contained in a larger box. + A lower universe is used to describe the interior of the first box which + contains the core and its surrounding space. + + """ + openmc.reset_auto_ids() + model = openmc.Model() + + # ============================================================================= + # Materials + # ============================================================================= + + fuel = openmc.Material(material_id=1) + fuel.add_nuclide("U234", 0.0004524) + fuel.add_nuclide("U235", 0.0506068) + fuel.add_nuclide("U238", 0.9487090) + fuel.add_nuclide("U236", 0.0002318) + fuel.add_nuclide("O16", 2.0) + fuel.set_density("g/cm3", 11.0) + + water = openmc.Material(material_id=11) + water.add_nuclide("H1", 2.0) + water.add_nuclide("O16", 1.0) + water.set_density("g/cm3", 1.0) + + # ============================================================================= + # Geometry + # ============================================================================= + + # ----------------------------------------------------------------------------- + # Cylindrical core + # ----------------------------------------------------------------------------- + + # Parameters + core_radius = 2.0 + core_height = 4.0 + + # Surfaces + core_cylinder = openmc.ZCylinder(r=core_radius) + core_lower_plane = openmc.ZPlane(-core_height / 2.0) + core_upper_plane = openmc.ZPlane(core_height / 2.0) + + # Region + core_region = -core_cylinder & +core_lower_plane & -core_upper_plane + + # Cells + core = openmc.Cell(fill=fuel, region=core_region, cell_id=22) + outside_core_region = +core_cylinder | -core_lower_plane | +core_upper_plane + outside_core = openmc.Cell( + fill=water, region=outside_core_region, cell_id=33) + + # Universe + inside_box1_universe = openmc.Universe( + cells=[core, outside_core], universe_id=77) + + # ----------------------------------------------------------------------------- + # Box 1 + # ----------------------------------------------------------------------------- + + # Parameters + box1_size = 6.0 + + # Surfaces + box1_rpp = openmc.model.RectangularParallelepiped( + -box1_size / 2.0, box1_size / 2.0, + -box1_size / 2.0, box1_size / 2.0, + -box1_size / 2.0, box1_size / 2.0, + ) + + # Cell + box1 = openmc.Cell(fill=inside_box1_universe, region=-box1_rpp, cell_id=5) + + # ----------------------------------------------------------------------------- + # Box 2 + # ----------------------------------------------------------------------------- + + # Parameters + box2_size = 8 + + # Surfaces + box2_rpp = openmc.model.RectangularParallelepiped( + -box2_size / 2.0, box2_size / 2.0, + -box2_size / 2.0, box2_size / 2.0, + -box2_size / 2.0, box2_size / 2.0, + boundary_type="vacuum" + ) + + # Cell + box2 = openmc.Cell(fill=water, region=-box2_rpp & +box1_rpp, cell_id=8) + + # Register geometry + model.geometry = openmc.Geometry([box1, box2]) + + # ============================================================================= + # Settings + # ============================================================================= + + model.settings = openmc.Settings() + model.settings.particles = 100 + model.settings.batches = 5 + model.settings.inactive = 1 + model.settings.seed = 1 + + bounds = [ + -core_radius, + -core_radius, + -core_height / 2.0, + core_radius, + core_radius, + core_height / 2.0, + ] + distribution = openmc.stats.Box(bounds[:3], bounds[3:]) + model.settings.source = openmc.IndependentSource( + space=distribution, constraints={'fissionable': True}) + + return model + + +@pytest.mark.parametrize( + "folder, model_name, parameter", + [("case_1_Reactions", "model_1", {"max_collisions": 300, "reactions": ["(n,fission)", 101]}), + ("case_2_Cell_ID", "model_1", { + "max_collisions": 300, "cell_ids": [22]}), + ("case_3_Material_ID", "model_1", { + "max_collisions": 300, "material_ids": [1]}), + ("case_4_Nuclide_ID", "model_1", { + "max_collisions": 300, "nuclides": ["O16", "U235"]}), + ("case_5_Universe_ID", "model_1", { + "max_collisions": 300, "cell_ids": [22], "universe_ids": [77]}), + ("case_6_deposited_energy_threshold", "model_1", { + "max_collisions": 300, "deposited_E_threshold": 5.5e5}), + ("case_7_all_parameters_used_together", "model_1", { + "max_collisions": 300, + "reactions": ["elastic", 18, "(n,disappear)"], + "material_ids": [1, 11], + "universe_ids": [77], + "nuclides": ["U238", "U235", "H1", "U234"], + "cell_ids": [22, 33], + "deposited_E_threshold": 1e5}) + ], +) +def test_collision_track_several_cases( + folder, model_name, parameter, request +): + # Since for these tests the actual number of collisions recorded is < max_collisions, + # we can run them with 1 or 2 threads, and in history or event mode. + model = request.getfixturevalue(model_name) + model.settings.collision_track = parameter + harness = CollisionTrackTestHarness( + "statepoint.5.h5", model=model, workdir=folder + ) + harness.main() + + +@pytest.mark.skipif(config["event"], reason="Results from history-based mode.") +def test_collision_track_2threads(model_1, two_threads, single_process): + # This test checks that the `max_collisions` setting is honored: + # no collisions beyond the specified limit should be recorded. + # + # For the result to be reproducible, the number of threads and + # the transport mode (history vs. event) must remain fixed. + assert os.environ["OMP_NUM_THREADS"] == "2" + assert config["mpi_np"] == "1" + model_1.settings.collision_track = { + "max_collisions": 200 + } + harness = CollisionTrackTestHarness( + "statepoint.5.h5", model=model_1, workdir="case_8_2threads" + ) + harness.main() diff --git a/tests/testing_harness.py b/tests/testing_harness.py index 11ced5b38a..1ad91b7a89 100644 --- a/tests/testing_harness.py +++ b/tests/testing_harness.py @@ -68,7 +68,7 @@ class TestHarness: if config['mpi']: mpi_args = [config['mpiexec'], '-n', config['mpi_np']] openmc.run(openmc_exec=config['exe'], mpi_args=mpi_args, - event_based=config['event']) + event_based=config['event']) else: openmc.run(openmc_exec=config['exe'], event_based=config['event']) @@ -305,9 +305,12 @@ class PyAPITestHarness(TestHarness): else: self.execute_test() - def execute_test(self): + def execute_test(self, change_dir=False): """Build input XMLs, run OpenMC, and verify correct results.""" + base_dir = os.getcwd() if change_dir else None try: + if change_dir: + os.chdir(self.workdir) self._build_inputs() inputs = self._get_inputs() self._write_inputs(inputs) @@ -319,10 +322,15 @@ class PyAPITestHarness(TestHarness): self._compare_results() finally: self._cleanup() + if base_dir: + os.chdir(base_dir) - def update_results(self): + def update_results(self, change_dir=False): """Update results_true.dat and inputs_true.dat""" + base_dir = os.getcwd() if change_dir else None try: + if change_dir: + os.chdir(self.workdir) self._build_inputs() inputs = self._get_inputs() self._write_inputs(inputs) @@ -334,6 +342,8 @@ class PyAPITestHarness(TestHarness): self._overwrite_results() finally: self._cleanup() + if base_dir: + os.chdir(base_dir) def _build_inputs(self): """Write input XML files.""" @@ -371,7 +381,8 @@ class PyAPITestHarness(TestHarness): """Delete XMLs, statepoints, tally, and test files.""" super()._cleanup() output = ['materials.xml', 'geometry.xml', 'settings.xml', - 'tallies.xml', 'plots.xml', 'inputs_test.dat', 'model.xml'] + 'tallies.xml', 'plots.xml', 'inputs_test.dat', 'model.xml', + 'collision_track.h5', 'collision_track.mcpl'] for f in output: if os.path.exists(f): os.remove(f) @@ -389,6 +400,7 @@ class TolerantPyAPITestHarness(PyAPITestHarness): due to single precision usage (e.g., as in the random ray solver). """ + def _are_files_equal(self, actual_path, expected_path, tolerance): def isfloat(value): try: @@ -428,7 +440,8 @@ class TolerantPyAPITestHarness(PyAPITestHarness): def _compare_results(self): """Make sure the current results agree with the reference.""" - compare = self._are_files_equal('results_test.dat', 'results_true.dat', 1e-6) + compare = self._are_files_equal( + 'results_test.dat', 'results_true.dat', 1e-6) if not compare: expected = open('results_true.dat').readlines() actual = open('results_test.dat').readlines() @@ -476,6 +489,7 @@ class WeightWindowPyAPITestHarness(PyAPITestHarness): class PlotTestHarness(TestHarness): """Specialized TestHarness for running OpenMC plotting tests.""" + def __init__(self, plot_names, voxel_convert_checks=[]): super().__init__(None) self._plot_names = plot_names @@ -523,3 +537,105 @@ class PlotTestHarness(TestHarness): outstr = sha512.hexdigest() return outstr + + +class CollisionTrackTestHarness(PyAPITestHarness): + def __init__(self, statepoint_name, model=None, inputs_true=None, workdir=None): + super().__init__(statepoint_name, model, inputs_true) + self.workdir = workdir + + def _test_output_created(self): + """Make sure collision_track.h5 has also been created.""" + super()._test_output_created() + if self._model.settings.collision_track: + assert os.path.exists( + "collision_track.h5" + ), "collision_track file has not been created." + + def _compare_output(self): + """Compare collision_track.h5 files.""" + if self._model.settings.collision_track: + collision_track_true = self._return_collision_track_data( + "collision_track_true.h5") + collision_track_test = self._return_collision_track_data( + "collision_track.h5") + np.testing.assert_allclose( + collision_track_true, collision_track_test, rtol=1e-07) + + def main(self): + """Accept commandline arguments and either run or update tests.""" + if config["build_inputs"]: + self.build_inputs() + elif config["update"]: + self.update_results(change_dir=True) + else: + self.execute_test(change_dir=True) + + def build_inputs(self): + """Build inputs.""" + base_dir = os.getcwd() + try: + os.chdir(self.workdir) + self._build_inputs() + finally: + os.chdir(base_dir) + + def _overwrite_results(self): + """Also add the 'collision_track.h5' file during overwriting.""" + super()._overwrite_results() + if os.path.exists("collision_track.h5"): + shutil.copyfile("collision_track.h5", "collision_track_true.h5") + + @staticmethod + def _return_collision_track_data(filepath): + """ + Read a collision_track file and return a sorted array composed + of flatten arrays of collision information. + + Parameters + ---------- + filepath : str + Path to the collision_track file + + Returns + ------- + data : np.array + Sorted array composed of flatten arrays of collision_track data for + each collision information + """ + data = [] + keys = [] + + # Read source file + source = openmc.read_collision_track_file(filepath) + for src in source: + r = src['r'] + u = src['u'] + e = src['E'] + de = src['dE'] + time = src['time'] + wgt = src['wgt'] + delayed_group = src['delayed_group'] + cell_id = src['cell_id'] + nuclide_id = src['nuclide_id'] + material_id = src['material_id'] + universe_id = src['universe_id'] + n_collision = src['n_collision'] + event_mt = src['event_mt'] + key = ( + f"{r[0]:.10e} {r[1]:.10e} {r[2]:.10e} {u[0]:.10e} {u[1]:.10e} {u[2]:.10e}" + f"{e:.10e} {de:.10e} {time:.10e} {wgt:.10e} {event_mt} {delayed_group} {cell_id}" + f"{nuclide_id} {material_id} {universe_id} {n_collision} " + ) + keys.append(key) + values = [*r, *u, e, de, time, wgt, event_mt, + delayed_group, cell_id, nuclide_id, material_id, + universe_id, n_collision] + assert len(values) == 17 + data.append(values) + + data = np.array(data) + keys = np.array(keys) + sorted_idx = np.argsort(keys, kind='stable') + + return data[sorted_idx] diff --git a/tests/unit_tests/test_collision_track.py b/tests/unit_tests/test_collision_track.py new file mode 100644 index 0000000000..9bc6a8c15f --- /dev/null +++ b/tests/unit_tests/test_collision_track.py @@ -0,0 +1,127 @@ +"""Test the 'collision_track' setting used to store particle information +during specified collision conditions in a file for a given simulation.""" + +import openmc +import pytest +import h5py +import numpy as np + +from tests.testing_harness import CollisionTrackTestHarness as ctt + + +@pytest.fixture(scope="module") +def geometry(): + """Simple hydrogen sphere geometry""" + openmc.reset_auto_ids() + material = openmc.Material(name="H1") + material.add_element("H", 1.0) + sphere = openmc.Sphere(r=1.0, boundary_type="vacuum") + cell = openmc.Cell(region=-sphere, fill=material) + return openmc.Geometry([cell]) + + +@pytest.mark.parametrize( + "parameter", + [ + {"max_collisions": 200}, + {"max_collisions": 200, "reactions": ["(n,disappear)"]}, + {"max_collisions": 200, "cell_ids": [1]}, + {"max_collisions": 200, "material_ids": [1]}, + {"max_collisions": 200, "universe_ids": [1]}, + {"max_collisions": 200, "nuclides": ["H1"]}, + {"max_collisions": 200, "deposited_E_threshold": 200000.0}, + {"max_collisions": 200, "mcpl": True} + + ], +) +def test_xml_serialization(parameter, run_in_tmpdir): + """Check that the different use cases can be written and read in XML.""" + settings = openmc.Settings() + settings.collision_track = parameter + settings.export_to_xml() + + read_settings = openmc.Settings.from_xml() + assert read_settings.collision_track == parameter + + +@pytest.fixture(scope="module") +def model(): + """Simple hydrogen sphere divided in two hemispheres + by a z-plane to form 2 cells.""" + openmc.reset_auto_ids() + model = openmc.Model() + + # Material + material = openmc.Material(name="H1") + material.add_element("H", 1.0) + + # Geometry + radius = 1.0 + sphere = openmc.Sphere(r=radius, boundary_type="reflective") + plane = openmc.ZPlane(0.0) + cell_1 = openmc.Cell(region=-sphere & -plane, fill=material, cell_id=1) + cell_2 = openmc.Cell(region=-sphere & +plane, fill=material, cell_id=2) + root = openmc.Universe(cells=[cell_1, cell_2]) + model.geometry = openmc.Geometry(root) + + # Settings + model.settings = openmc.Settings() + model.settings.run_mode = "fixed source" + model.settings.particles = 1 + model.settings.batches = 1 + model.settings.seed = 2 + + bounds = [-radius, -radius, -radius, radius, radius, radius] + distribution = openmc.stats.Box(bounds[:3], bounds[3:]) + model.settings.source = openmc.IndependentSource(space=distribution) + + return model + + +def test_particle_location(run_in_tmpdir, model): + """Test the location of particles with respected to the "cell_ids" + and the location x, y, z of the particle itself. the upper sphere will + have positive z component and the bottom sphere a negative z compnent. + + """ + model.settings.collision_track = { + "max_collisions": 200, + "reactions": ["elastic"], + "cell_ids": [1, 2] + } + model.run() + + with h5py.File("collision_track.h5", "r") as f: + source = f["collision_track_bank"] + + assert len(source) == 60 + + # We want to verify that the collisions happenening are in the right cells + # and the position of the particle is either positive or negative relative + # to the z plane. In this case, we track the position of the particle + # relative to the cell_id already set. + for point in source: + if point['cell_id'] == 1: + assert point['r'][2] < 0.0 # z component negative + elif point['cell_id'] == 2: + assert point["r"][2] > 0.0 # z component positive + else: + assert False + + +def test_format_similarity(run_in_tmpdir, model): + model.settings.collision_track = {"max_collisions": 200, "reactions": ['elastic'], + "cell_ids": [1, 2], "mcpl": False} + model.run() + data_h5 = ctt._return_collision_track_data('collision_track.h5') + + model.settings.collision_track["mcpl"] = True + model.run() + data_mcpl = ctt._return_collision_track_data('collision_track.mcpl') + + assert len(data_h5) == 60 + assert len(data_mcpl) == 60 + + np.testing.assert_allclose(data_h5, data_mcpl, rtol=1e-05) + # tolerance not that low due to the strings that is saved in MCPL, + # not enough precision! From 8d618716b55d92e892314d966ac75ffe5b19b7d7 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Thu, 13 Nov 2025 17:14:39 -0600 Subject: [PATCH 22/26] Avoid multiprocessing Pool when running depletion tests with MPI (#3633) --- tests/conftest.py | 21 +++++++++++++++++-- .../deplete_no_transport/test.py | 4 ++++ .../deplete_with_transport/test.py | 2 ++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index fa6718502d..71dd5ebf5d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,12 +29,29 @@ def run_in_tmpdir(tmpdir): yield finally: orig.chdir() - + @pytest.fixture(scope="module") def endf_data(): - return os.environ['OPENMC_ENDF_DATA'] + return os.environ['OPENMC_ENDF_DATA'] @pytest.fixture(scope='session', autouse=True) def resolve_paths(): with openmc.config.patch('resolve_paths', False): yield + + +@pytest.fixture(scope='session', autouse=True) +def disable_depletion_multiprocessing_under_mpi(): + """Fork-based depletion multiprocessing may deadlock if MPI is active.""" + if not regression_config['mpi']: + yield + return + + from openmc.deplete import pool + + original_setting = pool.USE_MULTIPROCESSING + pool.USE_MULTIPROCESSING = False + try: + yield + finally: + pool.USE_MULTIPROCESSING = original_setting diff --git a/tests/regression_tests/deplete_no_transport/test.py b/tests/regression_tests/deplete_no_transport/test.py index 63ae584e11..5550ad0481 100644 --- a/tests/regression_tests/deplete_no_transport/test.py +++ b/tests/regression_tests/deplete_no_transport/test.py @@ -76,6 +76,8 @@ def test_against_self(run_in_tmpdir, dt = [360] # single step # Perform simulation using the predictor algorithm + if config['mpi'] and multiproc: + pytest.skip("Multiprocessing depletion is disabled when MPI is enabled.") openmc.deplete.pool.USE_MULTIPROCESSING = multiproc openmc.deplete.PredictorIntegrator(op, dt, @@ -135,6 +137,8 @@ def test_against_coupled(run_in_tmpdir, dt = [dt] # single step # Perform simulation using the predictor algorithm + if config['mpi'] and multiproc: + pytest.skip("Multiprocessing depletion is disabled when MPI is enabled.") openmc.deplete.pool.USE_MULTIPROCESSING = multiproc openmc.deplete.PredictorIntegrator( op, dt, power=174, timestep_units=time_units).integrate() diff --git a/tests/regression_tests/deplete_with_transport/test.py b/tests/regression_tests/deplete_with_transport/test.py index 0f7ebf00f3..a2be22c565 100644 --- a/tests/regression_tests/deplete_with_transport/test.py +++ b/tests/regression_tests/deplete_with_transport/test.py @@ -65,6 +65,8 @@ def test_full(run_in_tmpdir, problem, multiproc): power = 2.337e15*4*JOULE_PER_EV*1e6 # MeV/second cm from CASMO # Perform simulation using the predictor algorithm + if config['mpi'] and multiproc: + pytest.skip("Multiprocessing depletion is disabled when MPI is enabled.") openmc.deplete.pool.USE_MULTIPROCESSING = multiproc openmc.deplete.PredictorIntegrator(op, dt, power).integrate() From 7815d3a680de21362e7fec64c871ecbab1150421 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Thu, 13 Nov 2025 23:27:17 -0600 Subject: [PATCH 23/26] Fix typo in DAGMC lost particle test (#3634) --- tests/unit_tests/dagmc/test_lost_particles.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/dagmc/test_lost_particles.py b/tests/unit_tests/dagmc/test_lost_particles.py index 502bd795e8..3a4166009d 100644 --- a/tests/unit_tests/dagmc/test_lost_particles.py +++ b/tests/unit_tests/dagmc/test_lost_particles.py @@ -70,12 +70,12 @@ def test_lost_particles(run_in_tmpdir, broken_dagmc_model): openmc.run() # run this again, but with the dagmc universe as the root unvierse + # to ensure that lost particles are still caught in this case for univ in broken_dagmc_model.geometry.get_all_universes().values(): if isinstance(univ, openmc.DAGMCUniverse): - broken_dagmc_model.geometry.root_unvierse = univ + broken_dagmc_model.geometry.root_universe = univ break broken_dagmc_model.export_to_xml() with pytest.raises(RuntimeError, match='Maximum number of lost particles has been reached.'): openmc.run() - From 5c63e0df21a25e2c9a25f8be6cfd23a09d6007b3 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Tue, 18 Nov 2025 17:57:12 -0600 Subject: [PATCH 24/26] Fix a few warnings, rename add_to_tallies_file (#3639) --- docs/source/usersguide/random_ray.rst | 2 +- openmc/deplete/chain.py | 13 +++--- openmc/mgxs/library.py | 18 +++++--- openmc/mgxs/mgxs.py | 4 +- openmc/model/model.py | 6 +-- openmc/tallies.py | 45 +++++++------------ .../mgxs_library_ce_to_mg/test.py | 2 +- .../mgxs_library_ce_to_mg_nuclides/test.py | 2 +- .../mgxs_library_condense/test.py | 2 +- .../mgxs_library_correction/test.py | 2 +- .../mgxs_library_distribcell/test.py | 2 +- .../mgxs_library_hdf5/test.py | 2 +- .../mgxs_library_histogram/test.py | 2 +- .../mgxs_library_mesh/test.py | 2 +- .../mgxs_library_no_nuclides/test.py | 2 +- .../mgxs_library_nuclides/test.py | 2 +- .../mgxs_library_specific_nuclides/test.py | 2 +- 17 files changed, 54 insertions(+), 56 deletions(-) diff --git a/docs/source/usersguide/random_ray.rst b/docs/source/usersguide/random_ray.rst index 138ae910c9..d5d752a834 100644 --- a/docs/source/usersguide/random_ray.rst +++ b/docs/source/usersguide/random_ray.rst @@ -765,7 +765,7 @@ energy decomposition:: # Create a "tallies.xml" file for the MGXS Library tallies = openmc.Tallies() - mgxs_lib.add_to_tallies_file(tallies, merge=True) + mgxs_lib.add_to_tallies(tallies, merge=True) # Export tallies.export_to_xml() diff --git a/openmc/deplete/chain.py b/openmc/deplete/chain.py index f34416d561..873d7ca892 100644 --- a/openmc/deplete/chain.py +++ b/openmc/deplete/chain.py @@ -630,7 +630,7 @@ class Chain: n = len(self) - # we accumulate indices and value entries for everything and create the matrix + # we accumulate indices and value entries for everything and create the matrix # in one step at the end to avoid expensive index checks scipy otherwise does. rows, cols, vals = [], [], [] def setval(i, j, val): @@ -716,14 +716,17 @@ class Chain: return sp.csc_matrix((vals, (rows, cols)), shape=(n, n)) def add_redox_term(self, matrix, buffer, oxidation_states): - """Adds a redox term to the depletion matrix from data contained in + r"""Adds a redox term to the depletion matrix from data contained in the matrix itself and a few user-inputs. The redox term to add to the buffer nuclide :math:`N_j` can be written - as: :math:`\frac{dN_j(t)}{dt} = - \cdots - \frac{1}{OS_j}\sum_i N_i a_{ij} \cdot OS_i ` + as: - where :math:`OS` is the oxidation states vector and `a_{ij}` the + .. math:: + \frac{dN_j(t)}{dt} = \cdots - \frac{1}{OS_j}\sum_i N_i a_{ij} + \cdot OS_i + + where :math:`OS` is the oxidation states vector and :math:`a_{ij}` the corresponding term in the Bateman matrix. Parameters diff --git a/openmc/mgxs/library.py b/openmc/mgxs/library.py index f782cbe9e5..b476de9020 100644 --- a/openmc/mgxs/library.py +++ b/openmc/mgxs/library.py @@ -556,14 +556,14 @@ class Library: self.all_mgxs[domain.id][mgxs_type] = mgxs - def add_to_tallies_file(self, tallies_file, merge=True): - """Add all tallies from all MGXS objects to a tallies file. + def add_to_tallies(self, tallies, merge=True): + """Add tallies from all MGXS objects to a tallies object. NOTE: This assumes that :meth:`Library.build_library` has been called Parameters ---------- - tallies_file : openmc.Tallies + tallies : openmc.Tallies A Tallies collection to add each MGXS' tallies to generate a 'tallies.xml' input file for OpenMC merge : bool @@ -572,7 +572,7 @@ class Library: """ - cv.check_type('tallies_file', tallies_file, openmc.Tallies) + cv.check_type('tallies', tallies, openmc.Tallies) # Add tallies from each MGXS for each domain and mgxs type for domain in self.domains: @@ -587,7 +587,15 @@ class Library: = list(range(1, self.num_delayed_groups + 1)) for tally in mgxs.tallies.values(): - tallies_file.append(tally, merge=merge) + tallies.append(tally, merge=merge) + + def add_to_tallies_file(self, tallies_file, merge=True): + warn( + "The Library.add_to_tallies_file(...) method has been renamed to" + "add_to_tallies(...) and will be removed in a future version of " + "OpenMC.", FutureWarning + ) + self.add_to_tallies(tallies_file, merge=merge) def load_from_statepoint(self, statepoint): """Extracts tallies in an OpenMC StatePoint with the data needed to diff --git a/openmc/mgxs/mgxs.py b/openmc/mgxs/mgxs.py index 533ab0ad32..f621db092f 100644 --- a/openmc/mgxs/mgxs.py +++ b/openmc/mgxs/mgxs.py @@ -2127,8 +2127,8 @@ class MGXS: df['std. dev.'] /= np.tile(densities, tile_factor) # Replace NaNs by zeros (happens if nuclide density is zero) - df['mean'].replace(np.nan, 0.0, inplace=True) - df['std. dev.'].replace(np.nan, 0.0, inplace=True) + df['mean'] = df['mean'].replace(np.nan, 0.0) + df['std. dev.'] = df['std. dev.'].replace(np.nan, 0.0) # Sort the dataframe by domain type id (e.g., distribcell id) and # energy groups such that data is from fast to thermal diff --git a/openmc/model/model.py b/openmc/model/model.py index 64294c23cb..10e5cbfc74 100644 --- a/openmc/model/model.py +++ b/openmc/model/model.py @@ -1791,7 +1791,7 @@ class Model: mgxs_lib.build_library() # Create a "tallies.xml" file for the MGXS Library - mgxs_lib.add_to_tallies_file(model.tallies, merge=True) + mgxs_lib.add_to_tallies(model.tallies, merge=True) # Run statepoint_filename = model.run(cwd=directory) @@ -1980,7 +1980,7 @@ class Model: mgxs_lib.build_library() # Create a "tallies.xml" file for the MGXS Library - mgxs_lib.add_to_tallies_file(model.tallies, merge=True) + mgxs_lib.add_to_tallies(model.tallies, merge=True) # Run statepoint_filename = model.run(cwd=directory) @@ -2075,7 +2075,7 @@ class Model: mgxs_lib.build_library() # Create a "tallies.xml" file for the MGXS Library - mgxs_lib.add_to_tallies_file(model.tallies, merge=True) + mgxs_lib.add_to_tallies(model.tallies, merge=True) # Run statepoint_filename = model.run(cwd=directory) diff --git a/openmc/tallies.py b/openmc/tallies.py index 25ec29a585..add3565798 100644 --- a/openmc/tallies.py +++ b/openmc/tallies.py @@ -738,13 +738,9 @@ class Tally(IDManagerMixin): Notes ----- - This test is based on D'Agostino and Pearson's test [1]_. The test - requires at least 8 realizations to produce valid results. - - References - ---------- - .. [1] D'Agostino, R. B. (1971), "An omnibus test of normality for - moderate and large sample size", Biometrika, 58, 341-348 + This test is based on `D'Agostino and Pearson's test + `_. The test requires at least + 8 realizations to produce valid results. """ n = self.num_realizations @@ -788,8 +784,8 @@ class Tally(IDManagerMixin): Parameters ---------- alternative : {'two-sided', 'less', 'greater'}, optional - Defines the alternative hypothesis. Default is 'two-sided'. - The following options are available: + Defines the alternative hypothesis. Default is 'two-sided'. The + following options are available: * 'two-sided': the kurtosis of the distribution is different from that of the normal distribution @@ -813,14 +809,9 @@ class Tally(IDManagerMixin): Notes ----- - This test is based on D'Agostino and Pearson's test [1]_. The test - is typically recommended for at least 20 realizations to produce - valid results. - - References - ---------- - .. [1] D'Agostino, R. B. (1971), "An omnibus test of normality for - moderate and large sample size", Biometrika, 58, 341-348 + This test is based on `D'Agostino and Pearson's test + `_. The test is typically + recommended for at least 20 realizations to produce valid results. """ n = self.num_realizations @@ -855,9 +846,9 @@ class Tally(IDManagerMixin): def normaltest(self, alternative: str = "two-sided"): """Perform D'Agostino and Pearson's omnibus test for normality. - This method tests the null hypothesis that a sample comes from a - normal distribution. It combines skewness and kurtosis to produce an - omnibus test of normality. + This method tests the null hypothesis that a sample comes from a normal + distribution. It combines skewness and kurtosis to produce an omnibus + test of normality. Parameters ---------- @@ -886,23 +877,19 @@ class Tally(IDManagerMixin): Notes ----- This test combines a test for skewness and a test for kurtosis to - produce an omnibus test [1]_. The test statistic is: + produce an `omnibus test `_. + The test statistic is: .. math:: K^2 = Z_1^2 + Z_2^2 - where :math:`Z_1` is the z-score from the skewness test and - :math:`Z_2` is the z-score from the kurtosis test. This statistic - follows a chi-square distribution with 2 degrees of freedom. + where :math:`Z_1` is the z-score from the skewness test and :math:`Z_2` + is the z-score from the kurtosis test. This statistic follows a + chi-square distribution with 2 degrees of freedom. The test requires at least 20 realizations to produce valid results. - References - ---------- - .. [1] D'Agostino, R. B. and Pearson, E. S. (1973), "Tests for - departure from normality", Biometrika, 60, 613-622 - """ n = self.num_realizations if n < 20: diff --git a/tests/regression_tests/mgxs_library_ce_to_mg/test.py b/tests/regression_tests/mgxs_library_ce_to_mg/test.py index 48a715997a..075167f588 100644 --- a/tests/regression_tests/mgxs_library_ce_to_mg/test.py +++ b/tests/regression_tests/mgxs_library_ce_to_mg/test.py @@ -28,7 +28,7 @@ class MGXSTestHarness(PyAPITestHarness): self.mgxs_lib.build_library() # Initialize a tallies file - self.mgxs_lib.add_to_tallies_file(self._model.tallies, merge=False) + self.mgxs_lib.add_to_tallies(self._model.tallies, merge=False) def _run_openmc(self): # Initial run diff --git a/tests/regression_tests/mgxs_library_ce_to_mg_nuclides/test.py b/tests/regression_tests/mgxs_library_ce_to_mg_nuclides/test.py index a77ad24296..489105f8f5 100644 --- a/tests/regression_tests/mgxs_library_ce_to_mg_nuclides/test.py +++ b/tests/regression_tests/mgxs_library_ce_to_mg_nuclides/test.py @@ -28,7 +28,7 @@ class MGXSTestHarness(PyAPITestHarness): self.mgxs_lib.build_library() # Initialize a tallies file - self.mgxs_lib.add_to_tallies_file(self._model.tallies, merge=False) + self.mgxs_lib.add_to_tallies(self._model.tallies, merge=False) def _run_openmc(self): # Initial run diff --git a/tests/regression_tests/mgxs_library_condense/test.py b/tests/regression_tests/mgxs_library_condense/test.py index a7e60617f0..bbc4c11bfa 100644 --- a/tests/regression_tests/mgxs_library_condense/test.py +++ b/tests/regression_tests/mgxs_library_condense/test.py @@ -36,7 +36,7 @@ class MGXSTestHarness(PyAPITestHarness): self.mgxs_lib.build_library() # Add tallies - self.mgxs_lib.add_to_tallies_file(self._model.tallies, merge=False) + self.mgxs_lib.add_to_tallies(self._model.tallies, merge=False) def _get_results(self, hash_output=False): """Digest info in the statepoint and return as a string.""" diff --git a/tests/regression_tests/mgxs_library_correction/test.py b/tests/regression_tests/mgxs_library_correction/test.py index 05eedfef82..64e638e442 100644 --- a/tests/regression_tests/mgxs_library_correction/test.py +++ b/tests/regression_tests/mgxs_library_correction/test.py @@ -29,7 +29,7 @@ class MGXSTestHarness(PyAPITestHarness): self.mgxs_lib.build_library() # Add tallies - self.mgxs_lib.add_to_tallies_file(self._model.tallies, merge=False) + self.mgxs_lib.add_to_tallies(self._model.tallies, merge=False) def _get_results(self, hash_output=False): """Digest info in the statepoint and return as a string.""" diff --git a/tests/regression_tests/mgxs_library_distribcell/test.py b/tests/regression_tests/mgxs_library_distribcell/test.py index fd6c8e9386..464b309c00 100644 --- a/tests/regression_tests/mgxs_library_distribcell/test.py +++ b/tests/regression_tests/mgxs_library_distribcell/test.py @@ -36,7 +36,7 @@ class MGXSTestHarness(PyAPITestHarness): self.mgxs_lib.build_library() # Add tallies - self.mgxs_lib.add_to_tallies_file(self._model.tallies, merge=False) + self.mgxs_lib.add_to_tallies(self._model.tallies, merge=False) self._model.tallies.export_to_xml() def _get_results(self, hash_output=False): diff --git a/tests/regression_tests/mgxs_library_hdf5/test.py b/tests/regression_tests/mgxs_library_hdf5/test.py index 06625c25f9..4fb4bf0936 100644 --- a/tests/regression_tests/mgxs_library_hdf5/test.py +++ b/tests/regression_tests/mgxs_library_hdf5/test.py @@ -40,7 +40,7 @@ class MGXSTestHarness(PyAPITestHarness): self.mgxs_lib.build_library() # Add tallies - self.mgxs_lib.add_to_tallies_file(self._model.tallies, merge=False) + self.mgxs_lib.add_to_tallies(self._model.tallies, merge=False) def _get_results(self, hash_output=False): """Digest info in the statepoint and return as a string.""" diff --git a/tests/regression_tests/mgxs_library_histogram/test.py b/tests/regression_tests/mgxs_library_histogram/test.py index b9905910ab..42fc1957a6 100644 --- a/tests/regression_tests/mgxs_library_histogram/test.py +++ b/tests/regression_tests/mgxs_library_histogram/test.py @@ -30,7 +30,7 @@ class MGXSTestHarness(PyAPITestHarness): self.mgxs_lib.build_library() # Add tallies - self.mgxs_lib.add_to_tallies_file(self._model.tallies, merge=False) + self.mgxs_lib.add_to_tallies(self._model.tallies, merge=False) def _get_results(self, hash_output=False): """Digest info in the statepoint and return as a string.""" diff --git a/tests/regression_tests/mgxs_library_mesh/test.py b/tests/regression_tests/mgxs_library_mesh/test.py index 89c68a75a3..c1a5980b5d 100644 --- a/tests/regression_tests/mgxs_library_mesh/test.py +++ b/tests/regression_tests/mgxs_library_mesh/test.py @@ -57,7 +57,7 @@ def model(): model.mgxs_lib.build_library() # Add tallies - model.mgxs_lib.add_to_tallies_file(model.tallies, merge=False) + model.mgxs_lib.add_to_tallies(model.tallies, merge=False) return model diff --git a/tests/regression_tests/mgxs_library_no_nuclides/test.py b/tests/regression_tests/mgxs_library_no_nuclides/test.py index af14a5dc8f..a02086af3e 100644 --- a/tests/regression_tests/mgxs_library_no_nuclides/test.py +++ b/tests/regression_tests/mgxs_library_no_nuclides/test.py @@ -39,7 +39,7 @@ class MGXSTestHarness(PyAPITestHarness): self.mgxs_lib.build_library() # Add tallies - self.mgxs_lib.add_to_tallies_file(self._model.tallies, merge=False) + self.mgxs_lib.add_to_tallies(self._model.tallies, merge=False) def _get_results(self, hash_output=False): """Digest info in the statepoint and return as a string.""" diff --git a/tests/regression_tests/mgxs_library_nuclides/test.py b/tests/regression_tests/mgxs_library_nuclides/test.py index 8a7673565e..a10070358a 100644 --- a/tests/regression_tests/mgxs_library_nuclides/test.py +++ b/tests/regression_tests/mgxs_library_nuclides/test.py @@ -36,7 +36,7 @@ class MGXSTestHarness(PyAPITestHarness): self.mgxs_lib.build_library() # Add tallies - self.mgxs_lib.add_to_tallies_file(self._model.tallies, merge=False) + self.mgxs_lib.add_to_tallies(self._model.tallies, merge=False) def _get_results(self, hash_output=True): """Digest info in the statepoint and return as a string.""" diff --git a/tests/regression_tests/mgxs_library_specific_nuclides/test.py b/tests/regression_tests/mgxs_library_specific_nuclides/test.py index 61910e539e..0ccbb83bdb 100644 --- a/tests/regression_tests/mgxs_library_specific_nuclides/test.py +++ b/tests/regression_tests/mgxs_library_specific_nuclides/test.py @@ -37,7 +37,7 @@ class MGXSTestHarness(PyAPITestHarness): self.mgxs_lib.build_library() # Add tallies - self.mgxs_lib.add_to_tallies_file(self._model.tallies, merge=True) + self.mgxs_lib.add_to_tallies(self._model.tallies, merge=True) def _get_results(self, hash_output=True): """Digest info in the statepoint and return as a string.""" From 028f4404485407b01a31c44f30249852601a29c8 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Wed, 19 Nov 2025 11:19:27 -0600 Subject: [PATCH 25/26] Support MPI parallelism in R2SManager (#3632) --- openmc/deplete/microxs.py | 31 +++++++++++++------------ openmc/deplete/r2s.py | 48 ++++++++++++++++++++++++--------------- openmc/lib/core.py | 45 ++++++++++++++++++++++++++++-------- 3 files changed, 82 insertions(+), 42 deletions(-) diff --git a/openmc/deplete/microxs.py b/openmc/deplete/microxs.py index d7624955e8..f4bdc67953 100644 --- a/openmc/deplete/microxs.py +++ b/openmc/deplete/microxs.py @@ -170,13 +170,14 @@ def get_microxs_and_flux( # Reinitialize with tallies openmc.lib.init(intracomm=comm) - # create temporary run with TemporaryDirectory() as temp_dir: - if run_kwargs is None: - run_kwargs = {} - else: - run_kwargs = dict(run_kwargs) - run_kwargs.setdefault('cwd', temp_dir) + # Indicate to run in temporary directory unless being executed through + # openmc.lib, in which case we don't need to specify the cwd + run_kwargs = dict(run_kwargs) if run_kwargs else {} + if not openmc.lib.is_initialized: + run_kwargs.setdefault('cwd', temp_dir) + + # Run transport simulation statepoint_path = model.run(**run_kwargs) if comm.rank == 0: @@ -189,15 +190,18 @@ def get_microxs_and_flux( if path_input is not None: model.export_to_model_xml(path_input) - with StatePoint(statepoint_path) as sp: - if reaction_rate_mode == 'direct': - rr_tally = sp.tallies[rr_tally.id] - rr_tally._read_results() - flux_tally = sp.tallies[flux_tally.id] - flux_tally._read_results() + # Broadcast updated statepoint path to all ranks + statepoint_path = comm.bcast(statepoint_path) + + # Read in tally results (on all ranks) + with StatePoint(statepoint_path) as sp: + if reaction_rate_mode == 'direct': + rr_tally = sp.tallies[rr_tally.id] + rr_tally._read_results() + flux_tally = sp.tallies[flux_tally.id] + flux_tally._read_results() # Get flux values and make energy groups last dimension - flux_tally = comm.bcast(flux_tally) flux = flux_tally.get_reshaped_data() # (domains, groups, 1, 1) flux = np.moveaxis(flux, 1, -1) # (domains, 1, 1, groups) @@ -206,7 +210,6 @@ def get_microxs_and_flux( if reaction_rate_mode == 'direct': # Get reaction rates - rr_tally = comm.bcast(rr_tally) reaction_rates = rr_tally.get_reshaped_data() # (domains, groups, nuclides, reactions) # Make energy groups last dimension diff --git a/openmc/deplete/r2s.py b/openmc/deplete/r2s.py index 7b5deddc23..7f3e94edd9 100644 --- a/openmc/deplete/r2s.py +++ b/openmc/deplete/r2s.py @@ -11,6 +11,9 @@ from . import IndependentOperator, PredictorIntegrator from .microxs import get_microxs_and_flux, write_microxs_hdf5, read_microxs_hdf5 from .results import Results from ..checkvalue import PathLike +from ..mpi import comm +from openmc.lib import TemporarySession +from openmc.utility_funcs import change_directory def get_activation_materials( @@ -199,8 +202,10 @@ class R2SManager: """ if output_dir is None: + # Create timestamped output directory and broadcast to all ranks for + # consistency (different ranks may have slightly different times) stamp = datetime.now().strftime('%Y-%m-%dT%H-%M-%S') - output_dir = Path(f'r2s_{stamp}') + output_dir = Path(comm.bcast(f'r2s_{stamp}')) # Set run_kwargs for the neutron transport step if micro_kwargs is None: @@ -257,18 +262,19 @@ class R2SManager: """ - output_dir = Path(output_dir) + output_dir = Path(output_dir).resolve() output_dir.mkdir(parents=True, exist_ok=True) if self.method == 'mesh-based': # Compute material volume fractions on the mesh if mat_vol_kwargs is None: mat_vol_kwargs = {} - self.results['mesh_material_volumes'] = mmv = \ - self.domains.material_volumes(self.neutron_model, **mat_vol_kwargs) + self.results['mesh_material_volumes'] = mmv = comm.bcast( + self.domains.material_volumes(self.neutron_model, **mat_vol_kwargs)) # Save results to file - mmv.save(output_dir / 'mesh_material_volumes.npz') + if comm.rank == 0: + mmv.save(output_dir / 'mesh_material_volumes.npz') # Create mesh-material filter based on what combos were found domains = openmc.MeshMaterialFilter.from_volumes(self.domains, mmv) @@ -299,13 +305,16 @@ class R2SManager: micro_kwargs.setdefault('path_statepoint', output_dir / 'statepoint.h5') micro_kwargs.setdefault('path_input', output_dir / 'model.xml') - # Run neutron transport and get fluxes and micros - self.results['fluxes'], self.results['micros'] = get_microxs_and_flux( - self.neutron_model, domains, **micro_kwargs) + # Run neutron transport and get fluxes and micros. Run via openmc.lib to + # maintain a consistent parallelism strategy with the activation step. + with TemporarySession(): + self.results['fluxes'], self.results['micros'] = get_microxs_and_flux( + self.neutron_model, domains, **micro_kwargs) # Save flux and micros to file - np.save(output_dir / 'fluxes.npy', self.results['fluxes']) - write_microxs_hdf5(self.results['micros'], output_dir / 'micros.h5') + if comm.rank == 0: + np.save(output_dir / 'fluxes.npy', self.results['fluxes']) + write_microxs_hdf5(self.results['micros'], output_dir / 'micros.h5') def step2_activation( self, @@ -457,15 +466,17 @@ class R2SManager: # photon model if it is different from the neutron model to account for # potential material changes if self.method == 'mesh-based' and different_photon_model: - self.results['mesh_material_volumes_photon'] = photon_mmv = \ - self.domains.material_volumes(self.photon_model, **mat_vol_kwargs) + self.results['mesh_material_volumes_photon'] = photon_mmv = comm.bcast( + self.domains.material_volumes(self.photon_model, **mat_vol_kwargs)) # Save photon MMV results to file - photon_mmv.save(output_dir / 'mesh_material_volumes.npz') + if comm.rank == 0: + photon_mmv.save(output_dir / 'mesh_material_volumes.npz') - tally_ids = [tally.id for tally in self.photon_model.tallies] - with open(output_dir / 'tally_ids.json', 'w') as f: - json.dump(tally_ids, f) + if comm.rank == 0: + tally_ids = [tally.id for tally in self.photon_model.tallies] + with open(output_dir / 'tally_ids.json', 'w') as f: + json.dump(tally_ids, f) self.results['photon_tallies'] = {} @@ -514,8 +525,9 @@ class R2SManager: time_index = len(self.results['depletion_results']) + time_index # Run photon transport calculation - run_kwargs['cwd'] = Path(output_dir) / f'time_{time_index}' - statepoint_path = self.photon_model.run(**run_kwargs) + photon_dir = Path(output_dir) / f'time_{time_index}' + with TemporarySession(self.photon_model, cwd=photon_dir): + statepoint_path = self.photon_model.run(**run_kwargs) # Store tally results with openmc.StatePoint(statepoint_path) as sp: diff --git a/openmc/lib/core.py b/openmc/lib/core.py index 9f8db69d57..cfccecef2a 100644 --- a/openmc/lib/core.py +++ b/openmc/lib/core.py @@ -13,6 +13,7 @@ from numpy.ctypeslib import as_array from . import _dll from .error import _error_handler +from ..mpi import comm from openmc.checkvalue import PathLike import openmc.lib import openmc @@ -632,6 +633,9 @@ class TemporarySession: model : openmc.Model, optional OpenMC model to use for the session. If None, a minimal working model is created. + cwd : PathLike, optional + Working directory in which to run OpenMC. If None, a temporary directory + is created and deleted automatically. **init_kwargs Keyword arguments to pass to :func:`openmc.lib.init`. @@ -639,10 +643,13 @@ class TemporarySession: ---------- model : openmc.Model The OpenMC model used for the session. + comm : mpi4py.MPI.Intracomm + The MPI intracommunicator used for the session. """ - def __init__(self, model=None, **init_kwargs): - self.init_kwargs = init_kwargs + def __init__(self, model=None, cwd=None, **init_kwargs): + self.init_kwargs = dict(init_kwargs) + self.cwd = cwd if model is None: surf = openmc.Sphere(boundary_type="vacuum") cell = openmc.Cell(region=-surf) @@ -652,6 +659,10 @@ class TemporarySession: particles=1, batches=1, output={'summary': False}) self.model = model + # Determine MPI intercommunicator + self.init_kwargs.setdefault('intracomm', comm) + self.comm = self.init_kwargs['intracomm'] + def __enter__(self): """Initialize the OpenMC library in a temporary directory.""" # If already initialized, the context manager is a no-op @@ -662,14 +673,24 @@ class TemporarySession: # Store original working directory self.orig_dir = Path.cwd() - # Set up temporary directory - self.tmp_dir = TemporaryDirectory() - working_dir = Path(self.tmp_dir.name) - working_dir.mkdir(parents=True, exist_ok=True) - os.chdir(working_dir) + if self.cwd is None: + # Set up temporary directory on rank 0 + if self.comm.rank == 0: + self._tmp_dir = TemporaryDirectory() + self.cwd = self._tmp_dir.name - # Export model and initialize OpenMC - self.model.export_to_model_xml() + # Broadcast the path so that all ranks use the same directory + self.cwd = self.comm.bcast(self.cwd) + + # Create and change to specified directory + self.cwd = Path(self.cwd) + self.cwd.mkdir(parents=True, exist_ok=True) + os.chdir(self.cwd) + + # Export model on first rank and initialize OpenMC + if self.comm.rank == 0: + self.model.export_to_model_xml() + self.comm.barrier() openmc.lib.init(**self.init_kwargs) return self @@ -683,7 +704,11 @@ class TemporarySession: finalize() finally: os.chdir(self.orig_dir) - self.tmp_dir.cleanup() + + # Make sure all ranks have finalized before deleting temporary dir + self.comm.barrier() + if hasattr(self, '_tmp_dir'): + self._tmp_dir.cleanup() class _DLLGlobal: From f544d02e499248281050ef2272d042c4a7933858 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Wed, 19 Nov 2025 11:26:57 -0600 Subject: [PATCH 26/26] Don't write reaction rates in depletion results by default, remove per-stage data for multistage integrators (#3609) --- docs/source/io_formats/depletion_results.rst | 26 +-- openmc/deplete/abc.py | 153 ++++++++----- openmc/deplete/integrators.py | 85 +++---- openmc/deplete/results.py | 8 +- openmc/deplete/stepresult.py | 222 +++++++++++-------- tests/unit_tests/test_deplete_continue.py | 8 +- tests/unit_tests/test_deplete_integrator.py | 93 +++++--- tests/unit_tests/test_deplete_restart.py | 14 +- 8 files changed, 336 insertions(+), 273 deletions(-) diff --git a/docs/source/io_formats/depletion_results.rst b/docs/source/io_formats/depletion_results.rst index 7035fc9c9e..b2a726dad0 100644 --- a/docs/source/io_formats/depletion_results.rst +++ b/docs/source/io_formats/depletion_results.rst @@ -4,7 +4,7 @@ Depletion Results File Format ============================= -The current version of the depletion results file format is 1.1. +The current version of the depletion results file format is 1.2. **/** @@ -12,22 +12,20 @@ The current version of the depletion results file format is 1.1. - **version** (*int[2]*) -- Major and minor version of the statepoint file format. -:Datasets: - **eigenvalues** (*double[][][2]*) -- k-eigenvalues at each - time/stage. This array has shape (number of timesteps, number of - stages, value). The last axis contains the eigenvalue and the - associated uncertainty - - **number** (*double[][][][]*) -- Total number of atoms. This array - has shape (number of timesteps, number of stages, number of +:Datasets: - **eigenvalues** (*double[][2]*) -- k-eigenvalues at each timestep. + This array has shape (number of timesteps, 2). The second axis + contains the eigenvalue and its associated uncertainty. + - **number** (*double[][][]*) -- Total number of atoms at each + timestep. This array has shape (number of timesteps, number of materials, number of nuclides). - - **reaction rates** (*double[][][][][]*) -- Reaction rates used to - build depletion matrices. This array has shape (number of - timesteps, number of stages, number of materials, number of - nuclides, number of reactions). + - **reaction rates** (*double[][][][]*) -- Reaction rates at each + timestep. This array has shape (number of timesteps, number of + materials, number of nuclides, number of reactions). Only stored if + write_rates=True. - **time** (*double[][2]*) -- Time in [s] at beginning/end of each step. - - **source_rate** (*double[][]*) -- Power in [W] or source rate in - [neutron/sec]. This array has shape (number of timesteps, number - of stages). + - **source_rate** (*double[]*) -- Power in [W] or source rate in + [neutron/sec] for each timestep. - **depletion time** (*double[]*) -- Average process time in [s] spent depleting a material across all burnable materials and, if applicable, MPI processes. diff --git a/openmc/deplete/abc.py b/openmc/deplete/abc.py index fb18d86afc..32f468306d 100644 --- a/openmc/deplete/abc.py +++ b/openmc/deplete/abc.py @@ -631,17 +631,7 @@ class Integrator(ABC): solver: str = "cram48", continue_timesteps: bool = False, ): - # Check number of stages previously used - if operator.prev_res is not None: - res = operator.prev_res[-1] - if res.data.shape[0] != self._num_stages: - raise ValueError( - "{} incompatible with previous restart calculation. " - "Previous scheme used {} intermediate solutions, while " - "this uses {}".format( - self.__class__.__name__, res.data.shape[0], - self._num_stages)) - elif continue_timesteps: + if continue_timesteps and operator.prev_res is None: raise ValueError("Continuation run requires passing prev_results.") self.operator = operator self.chain = operator.chain @@ -775,12 +765,8 @@ class Integrator(ABC): ------- proc_time : float Time spent in CRAM routines for all materials in [s] - n_list : list of list of numpy.ndarray - Concentrations at each of the intermediate points with - the final concentration as the last element - op_results : list of openmc.deplete.OperatorResult - Eigenvalue and reaction rates from intermediate transport - simulations + n_end : list of numpy.ndarray + Concentrations at end of timestep """ @property @@ -811,9 +797,9 @@ class Integrator(ABC): """Get beginning of step concentrations, reaction rates from restart""" res = self.operator.prev_res[-1] # Depletion methods expect list of arrays - bos_conc = list(res.data[0]) - rates = res.rates[0] - k = ufloat(res.k[0, 0], res.k[0, 1]) + bos_conc = list(res.data) + rates = res.rates + k = ufloat(res.k[0], res.k[1]) if res.source_rate != 0.0: # Scale reaction rates by ratio of source rates @@ -855,7 +841,8 @@ class Integrator(ABC): self, final_step: bool = True, output: bool = True, - path: PathLike = 'depletion_results.h5' + path: PathLike = 'depletion_results.h5', + write_rates: bool = False ): """Perform the entire depletion process across all steps @@ -874,6 +861,11 @@ class Integrator(ABC): Path to file to write. Defaults to 'depletion_results.h5'. .. versionadded:: 0.15.0 + write_rates : bool, optional + Whether reaction rates should be written to the results file for + each step. Defaults to ``False`` to reduce file size. + + .. versionadded:: 0.15.3 """ with change_directory(self.operator.output_dir): n = self.operator.initial_condition() @@ -890,18 +882,22 @@ class Integrator(ABC): n, res = self._get_bos_data_from_restart(source_rate, n) # Solve Bateman equations over time interval - proc_time, n_list, res_list = self(n, res.rates, dt, source_rate, i) + proc_time, n_end = self(n, res.rates, dt, source_rate, i) - # Insert BOS concentration, transport results - n_list.insert(0, n) - res_list.insert(0, res) - - # Remove actual EOS concentration for next step - n = n_list.pop() - - StepResult.save(self.operator, n_list, res_list, [t, t + dt], - source_rate, self._i_res + i, proc_time, path) + StepResult.save( + self.operator, + n, + res, + [t, t + dt], + source_rate, + self._i_res + i, + proc_time, + write_rates=write_rates, + path=path + ) + # Update for next step + n = n_end t += dt # Final simulation -- in the case that final_step is False, a zero @@ -910,9 +906,18 @@ class Integrator(ABC): # solve) if output and final_step and comm.rank == 0: print(f"[openmc.deplete] t={t} (final operator evaluation)") - res_list = [self.operator(n, source_rate if final_step else 0.0)] - StepResult.save(self.operator, [n], res_list, [t, t], - source_rate, self._i_res + len(self), proc_time, path) + res_final = self.operator(n, source_rate if final_step else 0.0) + StepResult.save( + self.operator, + n, + res_final, + [t, t], + source_rate, + self._i_res + len(self), + proc_time, + write_rates=write_rates, + path=path + ) self.operator.write_bos_data(len(self) + self._i_res) self.operator.finalize() @@ -1171,10 +1176,40 @@ class SIIntegrator(Integrator): self.operator.settings.particles //= self.n_steps return inherited + @abstractmethod + def __call__(self, n, rates, dt, source_rate, i): + """Perform the integration across one time step + + Parameters + ---------- + n : list of numpy.ndarray + List of atom number arrays for each material. Each array has + shape ``(n_nucs,)`` where ``n_nucs`` is the number of nuclides + rates : openmc.deplete.ReactionRates + Reaction rates (from transport operator) + dt : float + Time step in [s] + source_rate : float + Power in [W] or source rate in [neutron/sec] + i : int + Current time step index + + Returns + ------- + proc_time : float + Time spent in transport simulation + n_end : list of numpy.ndarray + Updated atom number densities for each material + op_result : OperatorResult + Eigenvalue and reaction rates resulting from transport simulation + + """ + def integrate( self, output: bool = True, - path: PathLike = "depletion_results.h5" + path: PathLike = "depletion_results.h5", + write_rates: bool = False ): """Perform the entire depletion process across all steps @@ -1186,11 +1221,17 @@ class SIIntegrator(Integrator): Path to file to write. Defaults to 'depletion_results.h5'. .. versionadded:: 0.15.0 + write_rates : bool, optional + Whether reaction rates should be written to the results file for + each step. Defaults to ``False`` to reduce file size. + + .. versionadded:: 0.15.3 """ with change_directory(self.operator.output_dir): n = self.operator.initial_condition() t, self._i_res = self._get_start_data() + res_end = None # Will be set in first iteration for i, (dt, p) in enumerate(self): if output: print(f"[openmc.deplete] t={t} s, dt={dt} s, source={p}") @@ -1200,28 +1241,38 @@ class SIIntegrator(Integrator): n, res = self._get_bos_data_from_operator(i, p, n) else: n, res = self._get_bos_data_from_restart(p, n) - else: - # Pull rates, k from previous iteration w/o - # re-running transport - res = res_list[-1] # defined in previous i iteration - proc_time, n_list, res_list = self(n, res.rates, dt, p, i) + proc_time, n_end, res_end = self(n, res.rates, dt, p, i) - # Insert BOS concentration, transport results - n_list.insert(0, n) - res_list.insert(0, res) - - # Remove actual EOS concentration for next step - n = n_list.pop() - - StepResult.save(self.operator, n_list, res_list, [t, t + dt], - p, self._i_res + i, proc_time, path) + StepResult.save( + self.operator, + n, + res, + [t, t + dt], + p, + self._i_res + i, + proc_time, + write_rates=write_rates, + path=path + ) + # Update for next step + n = n_end + res = res_end t += dt # No final simulation for SIE, use last iteration results - StepResult.save(self.operator, [n], [res_list[-1]], [t, t], - p, self._i_res + len(self), proc_time, path) + StepResult.save( + self.operator, + n, + res_end, + [t, t], + p, + self._i_res + len(self), + proc_time, + write_rates=write_rates, + path=path + ) self.operator.write_bos_data(self._i_res + len(self)) self.operator.finalize() diff --git a/openmc/deplete/integrators.py b/openmc/deplete/integrators.py index 7c543a6cb8..25e64cb2ef 100644 --- a/openmc/deplete/integrators.py +++ b/openmc/deplete/integrators.py @@ -46,15 +46,12 @@ class PredictorIntegrator(Integrator): ------- proc_time : float Time spent in CRAM routines for all materials in [s] - n_list : list of list of numpy.ndarray + n_end : list of numpy.ndarray Concentrations at end of interval - op_results : empty list - Kept for consistency with API. No intermediate calls to operator - with predictor """ proc_time, n_end = self._timed_deplete(n, rates, dt, _i) - return proc_time, [n_end], [] + return proc_time, n_end @add_params @@ -98,11 +95,8 @@ class CECMIntegrator(Integrator): ------- proc_time : float Time spent in CRAM routines for all materials in [s] - n_list : list of list of numpy.ndarray - Concentrations at each of the intermediate points with - the final concentration as the last element - op_results : list of openmc.deplete.OperatorResult - Eigenvalue and reaction rates from transport simulations + n_end : list of numpy.ndarray + Concentrations at end of interval """ # deplete across first half of interval time0, n_middle = self._timed_deplete(n, rates, dt / 2, _i) @@ -112,7 +106,7 @@ class CECMIntegrator(Integrator): # MOS reaction rates time1, n_end = self._timed_deplete(n, res_middle.rates, dt, _i) - return time0 + time1, [n_middle, n_end], [res_middle] + return time0 + time1, n_end @add_params @@ -162,12 +156,8 @@ class CF4Integrator(Integrator): ------- proc_time : float Time spent in CRAM routines for all materials in [s] - n_list : list of numpy.ndarray - Concentrations at each of the intermediate points with - the final concentration as the last element - op_results : list of openmc.deplete.OperatorResult - Eigenvalue and reaction rates from intermediate transport - simulations + n_end : list of numpy.ndarray + Concentrations at end of interval """ # Step 1: deplete with matrix 1/2*A(y0) time1, n_eos1 = self._timed_deplete( @@ -192,9 +182,7 @@ class CF4Integrator(Integrator): time5, n_eos5 = self._timed_deplete( n_inter, list_rates, dt, _i, matrix_func=cf4_f4) - return (time1 + time2 + time3 + time4 + time5, - [n_eos1, n_eos2, n_eos3, n_eos5], - [res1, res2, res3]) + return time1 + time2 + time3 + time4 + time5, n_eos5 @add_params @@ -240,12 +228,8 @@ class CELIIntegrator(Integrator): ------- proc_time : float Time spent in CRAM routines for all materials in [s] - n_list : list of list of numpy.ndarray - Concentrations at each of the intermediate points with - the final concentration as the last element - op_results : list of openmc.deplete.OperatorResult - Eigenvalue and reaction rates from intermediate transport - simulation + n_end : list of numpy.ndarray + Concentrations at end of interval """ # deplete to end using BOS rates proc_time, n_ce = self._timed_deplete(n_bos, rates, dt, _i) @@ -260,7 +244,7 @@ class CELIIntegrator(Integrator): time_le2, n_end = self._timed_deplete( n_inter, list_rates, dt, _i, matrix_func=celi_f2) - return proc_time + time_le1 + time_le1, [n_ce, n_end], [res_ce] + return proc_time + time_le1 + time_le2, n_end @add_params @@ -306,12 +290,8 @@ class EPCRK4Integrator(Integrator): ------- proc_time : float Time spent in CRAM routines for all materials in [s] - n_list : list of list of numpy.ndarray - Concentrations at each of the intermediate points with - the final concentration as the last element - op_results : list of openmc.deplete.OperatorResult - Eigenvalue and reaction rates from intermediate transport - simulations + n_end : list of numpy.ndarray + Concentrations at end of interval """ # Step 1: deplete with matrix A(y0) / 2 @@ -330,7 +310,7 @@ class EPCRK4Integrator(Integrator): list_rates = list(zip(rates, res1.rates, res2.rates, res3.rates)) time4, n4 = self._timed_deplete(n, list_rates, dt, _i, matrix_func=rk4_f4) - return (time1 + time2 + time3 + time4, [n1, n2, n3, n4], [res1, res2, res3]) + return time1 + time2 + time3 + time4, n4 @add_params @@ -388,12 +368,8 @@ class LEQIIntegrator(Integrator): ------- proc_time : float Time spent in CRAM routines for all materials in [s] - n_list : list of list of numpy.ndarray - Concentrations at each of the intermediate points with - the final concentration as the last element - op_results : list of openmc.deplete.OperatorResult - Eigenvalue and reaction rates from intermediate transport - simulation + n_end : list of numpy.ndarray + Concentrations at end of interval """ if i == 0: if self._i_res < 1: # need at least previous transport solution @@ -402,7 +378,7 @@ class LEQIIntegrator(Integrator): self, n_bos, bos_rates, dt, source_rate, i) prev_res = self.operator.prev_res[-2] prev_dt = self.timesteps[i] - prev_res.time[0] - self._prev_rates = prev_res.rates[0] + self._prev_rates = prev_res.rates else: prev_dt = self.timesteps[i - 1] @@ -431,9 +407,7 @@ class LEQIIntegrator(Integrator): # store updated rates self._prev_rates = copy.deepcopy(bos_res.rates) - return ( - time1 + time2 + time3 + time4, [n_eos0, n_eos1], - [bos_res, res_inter]) + return time1 + time2 + time3 + time4, n_eos1 @add_params @@ -470,10 +444,9 @@ class SICELIIntegrator(SIIntegrator): ------- proc_time : float Time spent in CRAM routines for all materials in [s] - n_bos_list : list of list of numpy.ndarray - Concentrations at each of the intermediate points with - the final concentration as the last element - op_results : list of openmc.deplete.OperatorResult + n_end : list of numpy.ndarray + Concentrations at end of interval + op_result : openmc.deplete.OperatorResult Eigenvalue and reaction rates from intermediate transport simulations """ @@ -499,7 +472,7 @@ class SICELIIntegrator(SIIntegrator): proc_time += time1 + time2 # end iteration - return proc_time, [n_eos, n_inter], [res_bar] + return proc_time, n_inter, res_bar @add_params @@ -536,10 +509,9 @@ class SILEQIIntegrator(SIIntegrator): ------- proc_time : float Time spent in CRAM routines for all materials in [s] - n_list : list of list of numpy.ndarray - Concentrations at each of the intermediate points with - the final concentration as the last element - op_results : list of openmc.deplete.OperatorResult + n_end : list of numpy.ndarray + Concentrations at end of interval + op_result : openmc.deplete.OperatorResult Eigenvalue and reaction rates from intermediate transport simulation """ @@ -551,7 +523,7 @@ class SILEQIIntegrator(SIIntegrator): self, n_bos, bos_rates, dt, source_rate, i) prev_res = self.operator.prev_res[-2] prev_dt = self.timesteps[i] - prev_res.time[0] - self._prev_rates = prev_res.rates[0] + self._prev_rates = prev_res.rates else: prev_dt = self.timesteps[i - 1] @@ -584,7 +556,10 @@ class SILEQIIntegrator(SIIntegrator): n_inter, inputs, dt, i, matrix_func=leqi_f4) proc_time += time1 + time2 - return proc_time, [n_eos, n_inter], [res_bar] + # Store updated rates for next step + self._prev_rates = copy.deepcopy(bos_rates) + + return proc_time, n_inter, res_bar integrator_by_name = { diff --git a/openmc/deplete/results.py b/openmc/deplete/results.py index 7427abd735..e1fcb26b6d 100644 --- a/openmc/deplete/results.py +++ b/openmc/deplete/results.py @@ -203,7 +203,7 @@ class Results(list): # Evaluate value in each region for i, result in enumerate(self): times[i] = result.time[0] - concentrations[i] = result[0, mat_id, nuc] + concentrations[i] = result[mat_id, nuc] # Unit conversions times = _get_time_as(times, time_units) @@ -363,7 +363,7 @@ class Results(list): # Evaluate value in each region for i, result in enumerate(self): times[i] = result.time[0] - rates[i] = result.rates[0].get(mat_id, nuc, rx) * result[0, mat, nuc] + rates[i] = result.rates.get(mat_id, nuc, rx) * result[mat, nuc] return times, rates @@ -397,7 +397,7 @@ class Results(list): # Get time/eigenvalue at each point for i, result in enumerate(self): times[i] = result.time[0] - eigenvalues[i] = result.k[0] + eigenvalues[i] = result.k # Convert time units if necessary times = _get_time_as(times, time_units) @@ -630,7 +630,7 @@ class Results(list): for nuc in result.index_nuc: if nuc not in available_cross_sections: continue - atoms = result[0, mat_id, nuc] + atoms = result[mat_id, nuc] if atoms > 0.0: atoms_per_barn_cm = 1e-24 * atoms / mat.volume mat.remove_nuclide(nuc) # Replace if it's there diff --git a/openmc/deplete/stepresult.py b/openmc/deplete/stepresult.py index 1a26cbe346..ff39e9acb6 100644 --- a/openmc/deplete/stepresult.py +++ b/openmc/deplete/stepresult.py @@ -16,7 +16,7 @@ from openmc.mpi import comm, MPI from openmc.checkvalue import PathLike from .reaction_rates import ReactionRates -VERSION_RESULTS = (1, 1) +VERSION_RESULTS = (1, 2) __all__ = ["StepResult"] @@ -30,8 +30,8 @@ class StepResult: Attributes ---------- - k : list of (float, float) - Eigenvalue and uncertainty for each substep. + k : tuple of (float, float) + Eigenvalue and uncertainty at end of step. time : list of float Time at beginning, end of step, in seconds. source_rate : float @@ -40,8 +40,8 @@ class StepResult: Number of mats. n_nuc : int Number of nuclides. - rates : list of ReactionRates - The reaction rates for each substep. + rates : ReactionRates + The reaction rates at end of step. volume : dict of str to float Dictionary mapping mat id to volume. index_mat : dict of str to int @@ -52,10 +52,8 @@ class StepResult: A dictionary mapping mat ID as string to global index. n_hdf5_mats : int Number of materials in entire geometry. - n_stages : int - Number of stages in simulation. data : numpy.ndarray - Atom quantity, stored by stage, mat, then by nuclide. + Atom quantity, stored by mat, then by nuclide. proc_time : int Average time spent depleting a material across all materials and processes @@ -86,17 +84,17 @@ class StepResult: Parameters ---------- pos : tuple - A three-length tuple containing a stage index, mat index and a nuc - index. All can be integers or slices. The second two can be + A two-length tuple containing a mat index and a nuc + index. Both can be integers or slices, or can be strings corresponding to their respective dictionary. Returns ------- float - The atoms for stage, mat, nuc + The atoms for mat, nuc """ - stage, mat, nuc = pos + mat, nuc = pos if isinstance(mat, openmc.Material): mat = str(mat.id) if isinstance(mat, str): @@ -104,7 +102,7 @@ class StepResult: if isinstance(nuc, str): nuc = self.index_nuc[nuc] - return self.data[stage, mat, nuc] + return self.data[mat, nuc] def __setitem__(self, pos, val): """Sets an item from results. @@ -112,21 +110,21 @@ class StepResult: Parameters ---------- pos : tuple - A three-length tuple containing a stage index, mat index and a nuc - index. All can be integers or slices. The second two can be + A two-length tuple containing a mat index and a nuc + index. Both can be integers or slices, or can be strings corresponding to their respective dictionary. val : float The value to set data to. """ - stage, mat, nuc = pos + mat, nuc = pos if isinstance(mat, str): mat = self.index_mat[mat] if isinstance(nuc, str): nuc = self.index_nuc[nuc] - self.data[stage, mat, nuc] = val + self.data[mat, nuc] = val @property def n_mat(self): @@ -140,11 +138,7 @@ class StepResult: def n_hdf5_mats(self): return len(self.mat_to_hdf5_ind) - @property - def n_stages(self): - return self.data.shape[0] - - def allocate(self, volume, nuc_list, burn_list, full_burn_list, stages): + def allocate(self, volume, nuc_list, burn_list, full_burn_list): """Allocate memory for depletion step data Parameters @@ -157,8 +151,6 @@ class StepResult: A list of all mat IDs to be burned. Used for sorting the simulation. full_burn_list : list of str List of all burnable material IDs - stages : int - Number of stages in simulation. """ self.volume = copy.deepcopy(volume) @@ -167,7 +159,7 @@ class StepResult: self.mat_to_hdf5_ind = {mat: i for i, mat in enumerate(full_burn_list)} # Create storage array - self.data = np.zeros((stages, self.n_mat, self.n_nuc)) + self.data = np.zeros((self.n_mat, self.n_nuc)) def distribute(self, local_materials, ranges): """Create a new object containing data for distributed materials @@ -196,8 +188,8 @@ class StepResult: for attr in direct_attrs: setattr(new, attr, getattr(self, attr)) # Get applicable slice of data - new.data = self.data[:, ranges] - new.rates = [r[ranges] for r in self.rates] + new.data = self.data[ranges] + new.rates = self.rates[ranges] return new def get_material(self, mat_id): @@ -232,7 +224,7 @@ class StepResult: f'values are {list(self.volume.keys())}' ) from e for nuc, _ in sorted(self.index_nuc.items(), key=lambda x: x[1]): - atoms = self[0, mat_id, nuc] + atoms = self[mat_id, nuc] if atoms <= 0.0: continue atom_per_bcm = atoms / vol * 1e-24 @@ -240,7 +232,7 @@ class StepResult: material.volume = vol return material - def export_to_hdf5(self, filename, step): + def export_to_hdf5(self, filename, step, write_rates: bool = False): """Export results to an HDF5 file Parameters @@ -249,6 +241,8 @@ class StepResult: The filename to write to step : int What step is this? + write_rates : bool, optional + Whether to include reaction rate datasets in the results file. """ # Write new file if first time step, else add to existing file @@ -259,7 +253,8 @@ class StepResult: kwargs['driver'] = 'mpio' kwargs['comm'] = comm with h5py.File(filename, **kwargs) as handle: - self._to_hdf5(handle, step, parallel=True) + self._to_hdf5(handle, step, parallel=True, + write_rates=write_rates) else: # Gather results at root process all_results = comm.gather(self) @@ -268,15 +263,18 @@ class StepResult: if comm.rank == 0: with h5py.File(filename, **kwargs) as handle: for res in all_results: - res._to_hdf5(handle, step, parallel=False) + res._to_hdf5(handle, step, parallel=False, + write_rates=write_rates) - def _write_hdf5_metadata(self, handle): + def _write_hdf5_metadata(self, handle, write_rates): """Writes result metadata in HDF5 file Parameters ---------- handle : h5py.File or h5py.Group An hdf5 file or group type to store this in. + write_rates : bool + Whether reaction rate datasets are being written. """ # Create and save the 5 dictionaries: @@ -284,8 +282,8 @@ class StepResult: # self.index_mat -> self.volume (TODO: support for changing volumes) # self.index_nuc # reactions - # self.rates[0].index_nuc (can be different from above, above is superset) - # self.rates[0].index_rx + # self.rates.index_nuc (can be different from above, above is superset) + # self.rates.index_rx # these are shared by every step of the simulation, and should be deduplicated. # Store concentration mat and nuclide dictionaries (along with volumes) @@ -295,13 +293,19 @@ class StepResult: mat_list = sorted(self.mat_to_hdf5_ind, key=int) nuc_list = sorted(self.index_nuc) - rxn_list = sorted(self.rates[0].index_rx) + + include_rates = ( + write_rates + and self.rates is not None + and bool(self.rates.index_nuc) + and bool(self.rates.index_rx) + ) + rxn_list = sorted(self.rates.index_rx) if include_rates else [] n_mats = self.n_hdf5_mats n_nuc_number = len(nuc_list) - n_nuc_rxn = len(self.rates[0].index_nuc) + n_nuc_rxn = len(self.rates.index_nuc) if include_rates else 0 n_rxn = len(rxn_list) - n_stages = self.n_stages mat_group = handle.create_group("materials") @@ -315,41 +319,44 @@ class StepResult: for nuc in nuc_list: nuc_single_group = nuc_group.create_group(nuc) nuc_single_group.attrs["atom number index"] = self.index_nuc[nuc] - if nuc in self.rates[0].index_nuc: - nuc_single_group.attrs["reaction rate index"] = self.rates[0].index_nuc[nuc] + if include_rates and nuc in self.rates.index_nuc: + nuc_single_group.attrs["reaction rate index"] = ( + self.rates.index_nuc[nuc]) - rxn_group = handle.create_group("reactions") + if include_rates: + rxn_group = handle.create_group("reactions") - for rxn in rxn_list: - rxn_single_group = rxn_group.create_group(rxn) - rxn_single_group.attrs["index"] = self.rates[0].index_rx[rxn] + for rxn in rxn_list: + rxn_single_group = rxn_group.create_group(rxn) + rxn_single_group.attrs["index"] = ( + self.rates.index_rx[rxn]) # Construct array storage - handle.create_dataset("number", (1, n_stages, n_mats, n_nuc_number), - maxshape=(None, n_stages, n_mats, n_nuc_number), + handle.create_dataset("number", (1, n_mats, n_nuc_number), + maxshape=(None, n_mats, n_nuc_number), chunks=True, dtype='float64') - if n_nuc_rxn > 0 and n_rxn > 0: - handle.create_dataset("reaction rates", (1, n_stages, n_mats, n_nuc_rxn, n_rxn), - maxshape=(None, n_stages, n_mats, n_nuc_rxn, n_rxn), - chunks=True, - dtype='float64') + if include_rates and n_nuc_rxn > 0 and n_rxn > 0: + handle.create_dataset( + "reaction rates", (1, n_mats, n_nuc_rxn, n_rxn), + maxshape=(None, n_mats, n_nuc_rxn, n_rxn), + chunks=True, dtype='float64') - handle.create_dataset("eigenvalues", (1, n_stages, 2), - maxshape=(None, n_stages, 2), dtype='float64') + handle.create_dataset("eigenvalues", (1, 2), + maxshape=(None, 2), dtype='float64') handle.create_dataset("time", (1, 2), maxshape=(None, 2), dtype='float64') - handle.create_dataset("source_rate", (1, n_stages), maxshape=(None, n_stages), + handle.create_dataset("source_rate", (1,), maxshape=(None,), dtype='float64') handle.create_dataset( "depletion time", (1,), maxshape=(None,), dtype="float64") - def _to_hdf5(self, handle, index, parallel=False): + def _to_hdf5(self, handle, index, parallel=False, write_rates: bool = False): """Converts results object into an hdf5 object. Parameters @@ -360,12 +367,14 @@ class StepResult: What step is this? parallel : bool Being called with parallel HDF5? + write_rates : bool, optional + Whether reaction rate datasets are being written. """ if "/number" not in handle: if parallel: comm.barrier() - self._write_hdf5_metadata(handle) + self._write_hdf5_metadata(handle, write_rates) if parallel: comm.barrier() @@ -417,18 +426,14 @@ class StepResult: return # Add data - # Note, for the last step, self.n_stages = 1, even if n_stages != 1. - n_stages = self.n_stages inds = [self.mat_to_hdf5_ind[mat] for mat in self.index_mat] low = min(inds) high = max(inds) - for i in range(n_stages): - number_dset[index, i, low:high+1] = self.data[i] - if has_reactions: - rxn_dset[index, i, low:high+1] = self.rates[i] - if comm.rank == 0: - eigenvalues_dset[index, i] = self.k[i] + number_dset[index, low:high+1] = self.data + if has_reactions: + rxn_dset[index, low:high+1] = self.rates if comm.rank == 0: + eigenvalues_dset[index] = self.k time_dset[index] = self.time source_rate_dset[index] = self.source_rate if self.proc_time is not None: @@ -459,10 +464,24 @@ class StepResult: # Older versions used "power" instead of "source_rate" source_rate_dset = handle["/power"] - results.data = number_dset[step, :, :, :] - results.k = eigenvalues_dset[step, :] + # Check if this is an old format file (with stages dimension) or new format + # Old format: number has shape (n_steps, n_stages, n_mats, n_nucs) + # New format: number has shape (n_steps, n_mats, n_nucs) + has_stages = len(number_dset.shape) == 4 + + if has_stages: + # Old format - extract data from first stage (index 0) + results.data = number_dset[step, 0, :, :] + results.k = eigenvalues_dset[step, 0, :] + # source_rate had shape (n_steps, n_stages) in old format + results.source_rate = source_rate_dset[step, 0] + else: + # New format - no stages dimension + results.data = number_dset[step, :, :] + results.k = eigenvalues_dset[step, :] + results.source_rate = source_rate_dset[step] + results.time = time_dset[step, :] - results.source_rate = source_rate_dset[step, 0] if "depletion time" in handle: proc_time_dset = handle["/depletion time"] @@ -493,33 +512,45 @@ class StepResult: if "reaction rate index" in nuc_handle.attrs: rxn_nuc_to_ind[nuc] = nuc_handle.attrs["reaction rate index"] - for rxn, rxn_handle in handle["/reactions"].items(): - rxn_to_ind[rxn] = rxn_handle.attrs["index"] + if "reactions" in handle: + for rxn, rxn_handle in handle["/reactions"].items(): + rxn_to_ind[rxn] = rxn_handle.attrs["index"] - results.rates = [] - # Reconstruct reactions - for i in range(results.n_stages): - rate = ReactionRates(results.index_mat, rxn_nuc_to_ind, rxn_to_ind, True) - - if "reaction rates" in handle: - rate[:] = handle["/reaction rates"][step, i, :, :, :] - results.rates.append(rate) + # Reconstruct reaction rates + rate = ReactionRates(results.index_mat, rxn_nuc_to_ind, rxn_to_ind, True) + if "reaction rates" in handle: + if has_stages: + # Old format: (n_steps, n_stages, n_mats, n_nucs, n_rxns) + rate[:] = handle["/reaction rates"][step, 0, :, :, :] + else: + # New format: (n_steps, n_mats, n_nucs, n_rxns) + rate[:] = handle["/reaction rates"][step, :, :, :] + results.rates = rate return results @staticmethod - def save(op, x, op_results, t, source_rate, step_ind, proc_time=None, - path: PathLike = "depletion_results.h5"): + def save( + op, + x, + op_results, + t, + source_rate, + step_ind, + proc_time=None, + write_rates: bool = False, + path: PathLike = "depletion_results.h5" + ): """Creates and writes depletion results to disk Parameters ---------- op : openmc.deplete.abc.TransportOperator The operator used to generate these results. - x : list of list of numpy.array - The prior x vectors. Indexed [i][cell] using the above equation. - op_results : list of openmc.deplete.OperatorResult - Results of applying transport operator + x : numpy.array + End-of-step concentrations for each material + op_results : openmc.deplete.OperatorResult + Result of applying transport operator at end of step t : list of float Time indices. source_rate : float @@ -530,7 +561,8 @@ class StepResult: Total process time spent depleting materials. This may be process-dependent and will be reduced across MPI processes. - + write_rates : bool, optional + Whether reaction rates should be written to the results file. path : PathLike Path to file to write. Defaults to 'depletion_results.h5'. @@ -539,26 +571,20 @@ class StepResult: # Get indexing terms vol_dict, nuc_list, burn_list, full_burn_list = op.get_results_info() - stages = len(x) - # Create results results = StepResult() - results.allocate(vol_dict, nuc_list, burn_list, full_burn_list, stages) + results.allocate(vol_dict, nuc_list, burn_list, full_burn_list) n_mat = len(burn_list) - for i in range(stages): - for mat_i in range(n_mat): - results[i, mat_i, :] = x[i][mat_i] + for mat_i in range(n_mat): + results[mat_i, :] = x[mat_i] - ks = [] - for r in op_results: - if isinstance(r.k, type(None)): - ks += [(None, None)] - else: - ks += [(r.k.nominal_value, r.k.std_dev)] - results.k = ks - results.rates = [r.rates for r in op_results] + if isinstance(op_results.k, type(None)): + results.k = (None, None) + else: + results.k = (op_results.k.nominal_value, op_results.k.std_dev) + results.rates = op_results.rates results.time = t results.source_rate = source_rate results.proc_time = proc_time @@ -567,7 +593,7 @@ class StepResult: if not Path(path).is_file(): Path(path).parent.mkdir(parents=True, exist_ok=True) - results.export_to_hdf5(path, step_ind) + results.export_to_hdf5(path, step_ind, write_rates) def transfer_volumes(self, model): """Transfers volumes from depletion results to geometry diff --git a/tests/unit_tests/test_deplete_continue.py b/tests/unit_tests/test_deplete_continue.py index 637c9d5e44..1b6eac2384 100644 --- a/tests/unit_tests/test_deplete_continue.py +++ b/tests/unit_tests/test_deplete_continue.py @@ -17,7 +17,7 @@ def test_continue(run_in_tmpdir): operator = dummy_operator.DummyOperator() # initial depletion - bundle.solver(operator, [1.0, 2.0], [1.0, 2.0]).integrate() + bundle.solver(operator, [1.0, 2.0], [1.0, 2.0]).integrate(write_rates=True) # set up continue run prev_res = openmc.deplete.Results(operator.output_dir / "depletion_results.h5") @@ -25,7 +25,7 @@ def test_continue(run_in_tmpdir): # if continue run happens, test passes bundle.solver(operator, [1.0, 2.0, 3.0, 4.0], [1.0, 2.0, 3.0, 4.0], - continue_timesteps=True).integrate() + continue_timesteps=True).integrate(write_rates=True) final_res = openmc.deplete.Results(operator.output_dir / "depletion_results.h5") @@ -42,7 +42,7 @@ def test_continue_continue(run_in_tmpdir): operator = dummy_operator.DummyOperator() # initial depletion - bundle.solver(operator, [1.0, 2.0], [1.0, 2.0]).integrate() + bundle.solver(operator, [1.0, 2.0], [1.0, 2.0]).integrate(write_rates=True) # set up continue run prev_res = openmc.deplete.Results(operator.output_dir / "depletion_results.h5") @@ -50,7 +50,7 @@ def test_continue_continue(run_in_tmpdir): # first continue run bundle.solver(operator, [1.0, 2.0, 3.0, 4.0], [1.0, 2.0, 3.0, 4.0], - continue_timesteps=True).integrate() + continue_timesteps=True).integrate(write_rates=True) prev_res = openmc.deplete.Results(operator.output_dir / "depletion_results.h5") # second continue run diff --git a/tests/unit_tests/test_deplete_integrator.py b/tests/unit_tests/test_deplete_integrator.py index b1d2cb950e..6463eaa208 100644 --- a/tests/unit_tests/test_deplete_integrator.py +++ b/tests/unit_tests/test_deplete_integrator.py @@ -10,6 +10,7 @@ import copy from random import uniform from unittest.mock import MagicMock +import h5py import numpy as np from uncertainties import ufloat import pytest @@ -38,8 +39,6 @@ INTEGRATORS = [ def test_results_save(run_in_tmpdir): """Test data save module""" - stages = 3 - rng = np.random.RandomState(comm.rank) # Mock geometry @@ -63,31 +62,22 @@ def test_results_save(run_in_tmpdir): op.get_results_info.return_value = ( vol_dict, nuc_list, burn_list, full_burn_list) - # Construct x - x1 = [] - x2 = [] + # Construct end-of-step concentrations + x1 = [rng.random(2), rng.random(2)] + x2 = [rng.random(2), rng.random(2)] - for i in range(stages): - x1.append([rng.random(2), rng.random(2)]) - x2.append([rng.random(2), rng.random(2)]) - - # Construct r + # Construct reaction rates r1 = ReactionRates(burn_list, ["na", "nb"], ["ra", "rb"]) r1[:] = rng.random((2, 2, 2)) + rate1 = copy.deepcopy(r1) - rate1 = [] - rate2 = [] + r2 = ReactionRates(burn_list, ["na", "nb"], ["ra", "rb"]) + r2[:] = rng.random((2, 2, 2)) + rate2 = copy.deepcopy(r2) - for i in range(stages): - rate1.append(copy.deepcopy(r1)) - r1[:] = rng.random((2, 2, 2)) - rate2.append(copy.deepcopy(r1)) - r1[:] = rng.random((2, 2, 2)) - - # Create global terms - # Col 0: eig, Col 1: uncertainty - eigvl1 = rng.random((stages, 2)) - eigvl2 = rng.random((stages, 2)) + # Create global terms (eigenvalue and uncertainty) + eigvl1 = rng.random(2) + eigvl2 = rng.random(2) eigvl1 = comm.bcast(eigvl1, root=0) eigvl2 = comm.bcast(eigvl2, root=0) @@ -95,29 +85,35 @@ def test_results_save(run_in_tmpdir): t1 = [0.0, 1.0] t2 = [1.0, 2.0] - op_result1 = [OperatorResult(ufloat(*k), rates) - for k, rates in zip(eigvl1, rate1)] - op_result2 = [OperatorResult(ufloat(*k), rates) - for k, rates in zip(eigvl2, rate2)] + op_result1 = OperatorResult(ufloat(*eigvl1), rate1) + op_result2 = OperatorResult(ufloat(*eigvl2), rate2) # saves within a subdirectory - StepResult.save(op, x1, op_result1, t1, 0, 0, path='out/put/depletion.h5') + StepResult.save( + op, + x1, + op_result1, + t1, + 0, + 0, + write_rates=True, + path='out/put/depletion.h5' + ) res = Results('out/put/depletion.h5') # saves with default filename - StepResult.save(op, x1, op_result1, t1, 0, 0) - StepResult.save(op, x2, op_result2, t2, 0, 1) + StepResult.save(op, x1, op_result1, t1, 0, 0, write_rates=True) + StepResult.save(op, x2, op_result2, t2, 0, 1, write_rates=True) # Load the files res = Results("depletion_results.h5") - for i in range(stages): - for mat_i, mat in enumerate(burn_list): - for nuc_i, nuc in enumerate(nuc_list): - assert res[0][i, mat, nuc] == x1[i][mat_i][nuc_i] - assert res[1][i, mat, nuc] == x2[i][mat_i][nuc_i] - np.testing.assert_array_equal(res[0].rates[i], rate1[i]) - np.testing.assert_array_equal(res[1].rates[i], rate2[i]) + for mat_i, mat in enumerate(burn_list): + for nuc_i, nuc in enumerate(nuc_list): + assert res[0][mat, nuc] == x1[mat_i][nuc_i] + assert res[1][mat, nuc] == x2[mat_i][nuc_i] + np.testing.assert_array_equal(res[0].rates, rate1) + np.testing.assert_array_equal(res[1].rates, rate2) np.testing.assert_array_equal(res[0].k, eigvl1) np.testing.assert_array_equal(res[0].time, t1) @@ -126,6 +122,31 @@ def test_results_save(run_in_tmpdir): np.testing.assert_array_equal(res[1].time, t2) +def test_results_save_without_rates(run_in_tmpdir): + """StepResult.save skips reaction-rate datasets by default""" + + op = MagicMock() + op.prev_res = None + vol_dict = {"0": 1.0} + nuc_list = ["na"] + burn_list = ["0"] + op.get_results_info.return_value = (vol_dict, nuc_list, burn_list, burn_list) + + x = [np.array([1.0])] + rates = ReactionRates(burn_list, nuc_list, ["ra"]) + rates[:] = np.array([[[2.0]]]) + op_result = OperatorResult(ufloat(1.0, 0.1), rates) + + StepResult.save(op, x, op_result, [0.0, 1.0], 0.0, 0) + + with h5py.File('depletion_results.h5', 'r') as handle: + assert 'reaction rates' not in handle + assert 'reactions' not in handle + + res = Results('depletion_results.h5') + assert res[0].rates.size == 0 + + def test_bad_integrator_inputs(): """Test failure modes for Integrator inputs""" diff --git a/tests/unit_tests/test_deplete_restart.py b/tests/unit_tests/test_deplete_restart.py index e8bfc062a0..1cbff30a5f 100644 --- a/tests/unit_tests/test_deplete_restart.py +++ b/tests/unit_tests/test_deplete_restart.py @@ -21,7 +21,7 @@ def test_restart_predictor_cecm(run_in_tmpdir): # Perform simulation using the predictor algorithm dt = [0.75] power = 1.0 - openmc.deplete.PredictorIntegrator(op, dt, power).integrate() + openmc.deplete.PredictorIntegrator(op, dt, power).integrate(write_rates=True) # Load the files prev_res = openmc.deplete.Results(op.output_dir / "depletion_results.h5") @@ -30,10 +30,6 @@ def test_restart_predictor_cecm(run_in_tmpdir): op = dummy_operator.DummyOperator(prev_res) op.output_dir = output_dir - # check ValueError is raised, indicating previous and current stages - with pytest.raises(ValueError, match="incompatible.* 1.*2"): - openmc.deplete.CECMIntegrator(op, dt, power) - def test_restart_cecm_predictor(run_in_tmpdir): """Integral regression test of integrator algorithm using CE/CM for the @@ -47,7 +43,7 @@ def test_restart_cecm_predictor(run_in_tmpdir): dt = [0.75] power = 1.0 cecm = openmc.deplete.CECMIntegrator(op, dt, power) - cecm.integrate() + cecm.integrate(write_rates=True) # Load the files prev_res = openmc.deplete.Results(op.output_dir / "depletion_results.h5") @@ -56,10 +52,6 @@ def test_restart_cecm_predictor(run_in_tmpdir): op = dummy_operator.DummyOperator(prev_res) op.output_dir = output_dir - # check ValueError is raised, indicating previous and current stages - with pytest.raises(ValueError, match="incompatible.* 2.*1"): - openmc.deplete.PredictorIntegrator(op, dt, power) - @pytest.mark.parametrize("scheme", dummy_operator.SCHEMES) def test_restart(run_in_tmpdir, scheme): @@ -70,7 +62,7 @@ def test_restart(run_in_tmpdir, scheme): operator = dummy_operator.DummyOperator() # take first step - bundle.solver(operator, [0.75], 1.0).integrate() + bundle.solver(operator, [0.75], 1.0).integrate(write_rates=True) # restart prev_res = openmc.deplete.Results(