diff --git a/docs/source/pythonapi/examples.rst b/docs/source/pythonapi/examples.rst index e7ef523c0e3..9efbd29c710 100644 --- a/docs/source/pythonapi/examples.rst +++ b/docs/source/pythonapi/examples.rst @@ -11,6 +11,7 @@ Simple Models :template: myfunction.rst openmc.examples.slab_mg + openmc.examples.sphere_with_shielded_pocket Reactor Models -------------- diff --git a/docs/source/usersguide/random_ray.rst b/docs/source/usersguide/random_ray.rst index 06d4e50ed7f..c793cf22ffb 100644 --- a/docs/source/usersguide/random_ray.rst +++ b/docs/source/usersguide/random_ray.rst @@ -642,13 +642,11 @@ model to use these multigroup cross sections. An example is given below:: model.convert_to_multigroup( method="material_wise", groups="CASMO-2", - nparticles=2000, overwrite_mgxs_library=False, mgxs_path="mgxs.h5", correction=None, source_energy=None, - temperatures=None, - temperature_settings=None + temperatures=None ) The most important parameter to set is the ``method`` parameter, which can be @@ -672,7 +670,9 @@ of these methods is given below: both spatial and resonance self shielding effects - * Potentially slower as the full geometry must be run * If a material is only present far from the source and doesn't get tallied - to in the CE simulation, the MGXS will be zero for that material. + to in the CE simulation, the MGXS will be zero for that material. This + can be mitigated by supplying weight windows via the generation + settings (see :ref:`mgxs_bootstrap`). * - ``stochastic_slab`` - * Medium Fidelity * Runs a CE simulation with a greatly simplified geometry, where materials @@ -693,12 +693,30 @@ of these methods is given below: When selecting a non-default energy group structure, you can manually define group boundaries or specify the name of a known group structure (a list of which -can be found at :data:`openmc.mgxs.GROUP_STRUCTURES`). The ``nparticles`` -parameter can be adjusted upward to improve the fidelity of the generated cross -section library. The ``correction`` parameter can be set to ``"P0"`` to enable -P0 transport correction. The ``overwrite_mgxs_library`` parameter can be set to -``True`` to overwrite an existing MGXS library file, or ``False`` to skip -generation and use an existing library file. +can be found at :data:`openmc.mgxs.GROUP_STRUCTURES`). The ``correction`` +parameter can be set to ``"P0"`` to enable P0 transport correction. The +``overwrite_mgxs_library`` parameter can be set to ``True`` to overwrite an +existing MGXS library file, or ``False`` to skip generation and use an existing +library file. + +The continuous energy simulations used to generate the cross section library +can be customized via the ``settings`` parameter. To do so, start from the +default settings returned by :meth:`openmc.Model.mgxs_generation_settings`, +modify them as desired, and pass the result back. For example, the number of +particles per batch (2,000 by default) can be increased to improve the fidelity +of the generated cross section library as:: + + settings = model.mgxs_generation_settings() + settings.particles = 100_000 + model.convert_to_multigroup(settings=settings) + +The settings returned by :meth:`openmc.Model.mgxs_generation_settings` record +the method they were generated for, so a non-default method only needs to be +given once:: + + settings = model.mgxs_generation_settings("stochastic_slab") + settings.particles = 100_000 + model.convert_to_multigroup(settings=settings) .. note:: MGXS transport correction (via setting the ``correction`` parameter in the @@ -739,12 +757,10 @@ The ``temperatures`` parameter can be provided if temperature-dependent multi-group cross sections are desired for multi-physics simulations. An individual cross section generation calculation is run for each temperature provided, where the materials in the model are set to the temperature. The -temperature settings used during cross section generation can be specified with the -``temperature_settings`` parameter. If no ``temperature_settings`` are provided, -the settings contained in the model will be used. The valid keys and values in the -``temperature_settings`` dictionary are identical to -:attr:`openmc.Settings.temperature_settings`; more information can be found in -:class:`openmc.Settings` . This approach yields isothermal cross section interpolation +temperature settings used during cross section generation default to those +contained in the model and can be customized by setting +:attr:`openmc.Settings.temperature` on the object passed via the ``settings`` +parameter. This approach yields isothermal cross section interpolation tables, which can be inaccurate for systems with large differences between temperatures in each material (often the case in fission reactors). If a more sophisticated temperature-dependence is required, we recommend generating cross sections manually. @@ -757,6 +773,51 @@ simulation, and if more fidelity is needed the user may wish to follow the instructions below or experiment with transport correction techniques to improve the fidelity of the generated MGXS data. +.. _mgxs_bootstrap: + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Bootstrapping Material-Wise MGXS with Weight Windows +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``"material_wise"`` method runs a continuous energy simulation of the +original geometry, so it produces the highest fidelity cross sections of the +three methods. However, it has a notable weakness: if a material only appears +far from the source (for example, a detector or structural material located +outside a thick shield), an analog continuous energy simulation may be unable to +transport any particles to that material. No tallies are scored there, and the +resulting cross sections for that material are zero. This situation is common in +shielding problems. + +This limitation can be overcome by "bootstrapping" the cross section generation +with weight windows. The idea is to first cheaply produce a set of weight +windows that cover the entire problem and then reuse them to push particles into +the far regions during the higher fidelity ``"material_wise"`` solve. The weight +windows are generated using the ``"stochastic_slab"`` method (which produces +cross sections for *all* materials regardless of their location) together with +the random ray solver and a :class:`~openmc.WeightWindowGenerator`, exactly as +described in the :ref:`FW-CADIS user guide `. The resulting +``weight_windows.h5`` file is then supplied to a second, higher fidelity +``"material_wise"`` cross section generation by setting ``weight_windows_file`` +on the generation settings:: + + # First, generate weight windows with the stochastic slab method and random + # ray (see the FW-CADIS user guide), producing a weight_windows.h5 file. + ... + + # Then, bootstrap a higher fidelity material-wise library, applying those + # weight windows during the continuous energy solve so that particles can + # reach materials far from the source. + settings = model.mgxs_generation_settings() + settings.weight_windows_file = "weight_windows.h5" + model.convert_to_multigroup(settings=settings, overwrite_mgxs_library=True) + +A weight windows file on the generation settings is only used with the +``"material_wise"`` method, as the ``"stochastic_slab"`` and +``"infinite_medium"`` methods use simplified surrogate geometries that are +incompatible with a weight window mesh defined over the original geometry (and +do not need weight windows, since they already tally all materials). A warning +is issued and the file is ignored if it is supplied to another method. + ~~~~~~~~~~~~ The Hard Way ~~~~~~~~~~~~ @@ -1075,9 +1136,9 @@ The adjoint flux random ray solver mode can be enabled as:: settings.random_ray['adjoint'] = True -When enabled, OpenMC will first run a forward transport simulation if there are -no user-specified adjoint sources present, followed by an adjoint transport -simulation. Fixed adjoint sources can be specified on the +When enabled, OpenMC will first run a forward transport simulation if there are +no user-specified adjoint sources present, followed by an adjoint transport +simulation. Fixed adjoint sources can be specified on the :attr:`openmc.Settings.random_ray` dictionary as follows:: # Geometry definition @@ -1090,21 +1151,21 @@ simulation. Fixed adjoint sources can be specified on the energy_distribution = openmc.stats.Discrete(x=midpoints, p=strengths) adj_source = openmc.IndependentSource( - energy=energy_distribution, + energy=energy_distribution, constraints={'domains': [detector_cell]} ) # Add to random_ray dict settings.random_ray['adjoint_source'] = adj_source -The same constraints apply to the user-defined adjoint source as to the forward -source, described in the :ref:`Fixed Source and Eigenvalue section -`. If this source is not provided, a forward -solve must take place to compute the adjoint external source when a forward -external source is present in the problem. Simulation settings (e.g., number of -rays, batches, etc.) will be identical for both calculations. At the -conclusion of the run, all results (e.g., tallies, plots, etc.) will be -derived from the adjoint flux rather than the forward flux but are not labeled +The same constraints apply to the user-defined adjoint source as to the forward +source, described in the :ref:`Fixed Source and Eigenvalue section +`. If this source is not provided, a forward +solve must take place to compute the adjoint external source when a forward +external source is present in the problem. Simulation settings (e.g., number of +rays, batches, etc.) will be identical for both calculations. At the +conclusion of the run, all results (e.g., tallies, plots, etc.) will be +derived from the adjoint flux rather than the forward flux but are not labeled any differently. When an initial forward solve is performed (i.e., when no user-specified adjoint source is present), its output files are also written to disk with a ``forward`` infix, so they are not overwritten by the subsequent @@ -1116,10 +1177,10 @@ generating FW-CADIS weight windows, no weight window file is written for the forward solve, as only the final adjoint-derived weight windows are meaningful. .. note:: - Use of the automated - :ref:`FW-CADIS weight window generator` is not - currently compatible with user-defined adjoint sources. Instead, the - initial forward calculation is used to assign "forward-weighted" adjoint + Use of the automated + :ref:`FW-CADIS weight window generator` is not + currently compatible with user-defined adjoint sources. Instead, the + initial forward calculation is used to assign "forward-weighted" adjoint sources to the tally regions of interest. --------------------------------------- diff --git a/include/openmc/constants.h b/include/openmc/constants.h index 26b4a224c0e..a1d94e5819e 100644 --- a/include/openmc/constants.h +++ b/include/openmc/constants.h @@ -75,6 +75,19 @@ constexpr double ZERO_FLUX_CUTOFF {1e-22}; // value will be converted to pure void. constexpr double MINIMUM_MACRO_XS {1e-6}; +// Relative dead band applied to weight window comparisons: particles split +// only above upper * (1 + tol) and roulette only below lower * (1 - tol). +// Weight window arithmetic can land a particle's weight exactly back on a +// bound value (e.g., a roulette survivor is assigned survival_ratio * lower +// and a later split divides that back down), in which case the branch taken +// would be decided by the last ulp of the bound. Since window data carries +// ulp-level noise from non-associative parallel reductions in the solver that +// generated it, transport results would otherwise be chaotically sensitive to +// bit-level differences in the weight window file. Treating weights within +// the band as inside the window is statistically negligible, and weight +// window games are unbiased regardless of where the thresholds sit. +constexpr double WEIGHT_WINDOW_REL_TOL {1e-9}; + // ============================================================================ // MATH AND PHYSICAL CONSTANTS diff --git a/openmc/examples.py b/openmc/examples.py index 6895bd94b53..6c18b81e41e 100644 --- a/openmc/examples.py +++ b/openmc/examples.py @@ -1314,17 +1314,17 @@ def fill_cube(N, n_1, n_2, fill_1, fill_2, fill_3): def random_ray_three_region_cube_with_detectors() -> openmc.Model: """Create a three region cube model with two external tally regions. - This is an adaptation of the simple monoenergetic problem of a cube with - three concentric cubic regions. The innermost region is near void (with - Sigma_t around 10^-5) and contains an external isotropic source term, the - middle region is a mild scatterer (with Sigma_t around 10^-3), and the - outer region of the cube is a scatterer and absorber (with Sigma_t around + This is an adaptation of the simple monoenergetic problem of a cube with + three concentric cubic regions. The innermost region is near void (with + Sigma_t around 10^-5) and contains an external isotropic source term, the + middle region is a mild scatterer (with Sigma_t around 10^-3), and the + outer region of the cube is a scatterer and absorber (with Sigma_t around 1). - Two cubic "detector" regions are found outside this geometry, one along the - y-axis near z=0, and the other in the upper right corner of the system. - The size of each detector is scaled to be equal to that of the source - region. The model returned by this function contains cell tallies on each + Two cubic "detector" regions are found outside this geometry, one along the + y-axis near z=0, and the other in the upper right corner of the system. + The size of each detector is scaled to be equal to that of the source + region. The model returned by this function contains cell tallies on each detector. Returns @@ -1498,29 +1498,29 @@ def fill_cube(N, n_1, n_2, fill_1, fill_2, fill_3): fill=absorber_mat, region=detector2_region ) - + external_x = ( - +x_high & +y_low & +z_low & -x_outer & + +x_high & +y_low & +z_low & -x_outer & ((-y_outer & -z_high) | (-y_high & +z_high & -z_outer)) ) external_y = ( - +y_high & -y_outer & + +y_high & -y_outer & ( - (+detector1_right & -x_high & +z_low & -z_outer) | - (-detector1_right & +x_low & +detector1_top & -z_outer) | + (+detector1_right & -x_high & +z_low & -z_outer) | + (-detector1_right & +x_low & +detector1_top & -z_outer) | (+x_high & -x_outer & +z_low & -z_high) ) ) external_z = ( - +x_low & +y_low & +z_high & -z_outer & + +x_low & +y_low & +z_high & -z_outer & ((-y_outer & -x_high) | (-y_high & +x_high & -x_outer)) ) - external_cell = openmc.Cell(fill=cavity_mat, - region=(external_x | external_y | external_z), + external_cell = openmc.Cell(fill=cavity_mat, + region=(external_x | external_y | external_z), name='outside cube') root = openmc.Universe( - name='root universe', + name='root universe', cells=[cube_domain, detector1, detector2, external_cell] ) @@ -1604,8 +1604,8 @@ def fill_cube(N, n_1, n_2, fill_1, fill_2, fill_3): source_tally.estimator = estimator # Instantiate a Tallies collection and export to XML - tallies = openmc.Tallies([detector1_tally, - detector2_tally, + tallies = openmc.Tallies([detector1_tally, + detector2_tally, absorber_tally, cavity_tally, source_tally]) @@ -1619,3 +1619,116 @@ def fill_cube(N, n_1, n_2, fill_1, fill_2, fill_3): model.tallies = tallies return model + + +def sphere_with_shielded_pocket() -> openmc.Model: + """Create a continuous energy deep-shielding model with a far detector pocket. + + A concrete sphere is centered at the origin. A 2 MeV isotropic neutron + source sits in a small air cavity just inside the sphere surface on the -x + side, and a small steel pocket is embedded flush with the surface on the + +x axis, so roughly a meter of concrete separates the source from the + pocket while only a few centimeters of concrete back the cavity. The + sphere is enclosed in a vacuum-bounded box, with a void gap in between, so + that solvers which sample uniformly over a rectangular domain (e.g., + random ray) can be applied directly. The geometry is designed for testing + weight window and variance reduction workflows: + + - The probability that an analog source neutron reaches the steel pocket is + ~4e-5 (the product of the concrete attenuation and the pocket's small + solid angle), so an analog simulation with a few hundred histories + essentially never tallies the steel, while even crude global weight + windows allow particles to reach it reliably. + - Because the cavity sits near the surface, deep shielding (and thus a wide + weight window dynamic range) exists only within the small solid angle + subtended by the pocket, which keeps weight window splitting cheap and + convergent and the whole model fast enough for regression testing. + + Returns + ------- + model : openmc.Model + A deep-shielding model with a steel pocket behind a thick concrete + shield + + """ + model = openmc.Model() + + ########################################################################### + # Materials (few nuclides, to keep data loading cheap in multi-solve tests) + + air = openmc.Material(name='Air') + air.set_density('g/cm3', 0.001225) + air.add_nuclide('N14', 0.79, 'ao') + air.add_nuclide('O16', 0.21, 'ao') + + concrete = openmc.Material(name='Concrete') + concrete.set_density('g/cm3', 2.3) + concrete.add_nuclide('H1', 0.168759, 'ao') + concrete.add_nuclide('O16', 0.562489, 'ao') + concrete.add_nuclide('Si28', 0.203031, 'ao') + concrete.add_nuclide('Ca40', 0.044849, 'ao') + concrete.add_nuclide('Al27', 0.020872, 'ao') + + steel = openmc.Material(name='Steel') + steel.set_density('g/cm3', 7.87) + steel.add_nuclide('Fe56', 1.0) + + ########################################################################### + # Geometry + + # ~92 cm of concrete separates the cavity from the pocket face, while only + # ~6 cm of concrete backs the cavity on the -x side, so deep shielding is + # confined to the solid angle subtended by the pocket. + r_sphere = 66.0 + box_half_width = 70.0 + cavity_center_x = -54.0 + cavity_half_width = 6.0 + pocket_inner_face = 44.0 + pocket_half_width = 4.0 + + sphere = openmc.Sphere(r=r_sphere) + outer_box = openmc.model.RectangularParallelepiped( + -box_half_width, box_half_width, + -box_half_width, box_half_width, + -box_half_width, box_half_width, boundary_type='vacuum') + cavity_box = openmc.model.RectangularParallelepiped( + cavity_center_x - cavity_half_width, cavity_center_x + cavity_half_width, + -cavity_half_width, cavity_half_width, + -cavity_half_width, cavity_half_width) + # The pocket box extends past the sphere surface and is clipped by it, so + # the pocket sits flush with (and just inside) the outer surface. + pocket_box = openmc.model.RectangularParallelepiped( + pocket_inner_face, r_sphere + 1.0, + -pocket_half_width, pocket_half_width, + -pocket_half_width, pocket_half_width) + + cavity_cell = openmc.Cell(name='cavity', fill=air, region=-cavity_box) + pocket_cell = openmc.Cell(name='pocket', fill=steel, + region=-pocket_box & -sphere) + concrete_cell = openmc.Cell( + name='concrete', fill=concrete, + region=-sphere & +cavity_box & ~(-pocket_box & -sphere)) + void_cell = openmc.Cell(name='void', region=+sphere & -outer_box) + + model.geometry = openmc.Geometry( + [cavity_cell, pocket_cell, concrete_cell, void_cell]) + + ########################################################################### + # Source and settings + + source = openmc.IndependentSource() + source.space = openmc.stats.Box( + [cavity_center_x - cavity_half_width, + -cavity_half_width, -cavity_half_width], + [cavity_center_x + cavity_half_width, + cavity_half_width, cavity_half_width]) + source.constraints = {'domains': [cavity_cell]} + source.angle = openmc.stats.Isotropic() + source.energy = openmc.stats.delta_function(2.0e6) + + model.settings.run_mode = 'fixed source' + model.settings.source = source + model.settings.particles = 1000 + model.settings.batches = 10 + + return model diff --git a/openmc/model/model.py b/openmc/model/model.py index 3284942885e..8728e3aa9c9 100644 --- a/openmc/model/model.py +++ b/openmc/model/model.py @@ -1880,12 +1880,10 @@ def _auto_generate_mgxs_lib( Parameters ---------- + model : openmc.Model + The model to generate the MGXS library from. groups : openmc.mgxs.EnergyGroups Energy group structure for the MGXS. - nparticles : int - Number of particles to simulate per batch when generating MGXS. - mgxs_path : str - Filename for the MGXS HDF5 file. correction : str Transport correction to apply to the MGXS. Options are None and "P0". @@ -2037,11 +2035,10 @@ def _create_mgxs_sources( def _isothermal_infinite_media_mgxs( material: openmc.Material, groups: openmc.mgxs.EnergyGroups, - nparticles: int, + settings: openmc.Settings, correction: str | None, directory: PathLike, source: openmc.IndependentSource, - temperature_settings: dict, temperature: float | None = None, ) -> openmc.XSdata: """Generate a single MGXS set for one material, where the geometry is an @@ -2053,8 +2050,9 @@ def _isothermal_infinite_media_mgxs( The material to generate MGXS for groups : openmc.mgxs.EnergyGroups Energy group structure for the MGXS. - nparticles : int - Number of particles to simulate per batch when generating MGXS. + settings : openmc.Settings + Settings for the generation run, used verbatim except for the + fields owned by the infinite medium method. correction : str Transport correction to apply to the MGXS. Options are None and "P0". @@ -2062,10 +2060,6 @@ def _isothermal_infinite_media_mgxs( Directory to run the simulation in, so as to contain XML files. source : openmc.IndependentSource Source to use when generating MGXS. - temperature_settings : dict - A dictionary of temperature settings to use when generating MGXS. - Valid entries for temperature_settings are the same as the valid - entries in openmc.Settings.temperature_settings. temperature : float, optional The isothermal temperature value to apply to the material. If not specified, defaults to the temperature in the material. @@ -2082,18 +2076,13 @@ def _isothermal_infinite_media_mgxs( if temperature is not None: model.materials[-1].temperature = temperature - # Settings - model.settings.batches = 100 - model.settings.particles = nparticles - + # The provided settings are used verbatim, except for the fields + # owned by the infinite medium method + model.settings = copy.deepcopy(settings) model.settings.source = source - model.settings.run_mode = 'fixed source' model.settings.create_fission_neutrons = False - model.settings.output = {'summary': True, 'tallies': False} - model.settings.temperature = temperature_settings - # Geometry box = openmc.model.RectangularPrism( 100000.0, 100000.0, boundary_type='reflective') @@ -2115,13 +2104,12 @@ def _isothermal_infinite_media_mgxs( def _generate_infinite_medium_mgxs( self, groups: openmc.mgxs.EnergyGroups, - nparticles: int, + settings: openmc.Settings, mgxs_path: PathLike, correction: str | None, directory: PathLike, source_energy: openmc.stats.Univariate | None = None, temperatures: Sequence[float] | None = None, - temperature_settings: dict | None = None, ) -> None: """Generate a MGXS library by running multiple OpenMC simulations, each representing an infinite medium simulation of a single isolated @@ -2150,8 +2138,9 @@ def _generate_infinite_medium_mgxs( ---------- groups : openmc.mgxs.EnergyGroups Energy group structure for the MGXS. - nparticles : int - Number of particles to simulate per batch when generating MGXS. + settings : openmc.Settings + Settings for the generation run(s), used verbatim except for the + fields owned by the infinite medium method. mgxs_path : str Filename for the MGXS HDF5 file. correction : str @@ -2166,10 +2155,6 @@ def _generate_infinite_medium_mgxs( A list of temperatures to generate MGXS at. Each infinite material region is isothermal at a given temperature data point for cross section generation. - temperature_settings : dict, optional - A dictionary of temperature settings to use when generating MGXS. - Valid entries for temperature_settings are the same as the valid - entries in openmc.Settings.temperature_settings. """ src = self._create_mgxs_sources( @@ -2178,23 +2163,16 @@ def _generate_infinite_medium_mgxs( source_energy=source_energy ) - temp_settings = {} - if temperature_settings is None: - temp_settings = self.settings.temperature - else: - temp_settings = temperature_settings - if temperatures is None: mgxs_sets = [] for material in self.materials: xs_data = Model._isothermal_infinite_media_mgxs( material, groups, - nparticles, + settings, correction, directory, - src, - temp_settings + src ) mgxs_sets.append(xs_data) @@ -2212,11 +2190,10 @@ def _generate_infinite_medium_mgxs( xs_data = Model._isothermal_infinite_media_mgxs( material, groups, - nparticles, + settings, correction, directory, src, - temp_settings, temperature ) raw_mgxs_sets[temperature].append(xs_data) @@ -2314,11 +2291,10 @@ def _create_stochastic_slab_geometry( def _isothermal_stochastic_slab_mgxs( stoch_geom: openmc.Geometry, groups: openmc.mgxs.EnergyGroups, - nparticles: int, + settings: openmc.Settings, correction: str | None, directory: PathLike, source: openmc.IndependentSource, - temperature_settings: dict, temperature: float | None = None, ) -> dict[str, openmc.XSdata]: """Generate MGXS assuming a stochastic "sandwich" of materials in a layered @@ -2331,8 +2307,9 @@ def _isothermal_stochastic_slab_mgxs( The stochastic slab geometry. groups : openmc.mgxs.EnergyGroups Energy group structure for the MGXS. - nparticles : int - Number of particles to simulate per batch when generating MGXS. + settings : openmc.Settings + Settings for the generation run, used verbatim except for the + fields owned by the stochastic slab method. correction : str Transport correction to apply to the MGXS. Options are None and "P0". @@ -2340,10 +2317,6 @@ def _isothermal_stochastic_slab_mgxs( Directory to run the simulation in, so as to contain XML files. source : openmc.IndependentSource Source to use when generating MGXS. - temperature_settings : dict - A dictionary of temperature settings to use when generating MGXS. - Valid entries for temperature_settings are the same as the valid - entries in openmc.Settings.temperature_settings. temperature : float, optional The isothermal temperature value to apply to the materials in the slab. If not specified, defaults to the temperature in the materials. @@ -2361,21 +2334,13 @@ def _isothermal_stochastic_slab_mgxs( for material in model.geometry.get_all_materials().values(): material.temperature = temperature - # Settings - model.settings.batches = 200 - model.settings.inactive = 100 - model.settings.particles = nparticles - model.settings.output = {'summary': True, 'tallies': False} - model.settings.temperature = temperature_settings - - # Define the sources + # The provided settings are used verbatim, except for the fields + # owned by the stochastic slab method + model.settings = copy.deepcopy(settings) model.settings.source = source - model.settings.run_mode = 'fixed source' model.settings.create_fission_neutrons = False - model.settings.output = {'summary': True, 'tallies': False} - # Generate MGXS mgxs_lib = Model._auto_generate_mgxs_lib( model, groups, correction, directory) @@ -2396,13 +2361,12 @@ def _isothermal_stochastic_slab_mgxs( def _generate_stochastic_slab_mgxs( self, groups: openmc.mgxs.EnergyGroups, - nparticles: int, + settings: openmc.Settings, mgxs_path: PathLike, correction: str | None, directory: PathLike, source_energy: openmc.stats.Univariate | None = None, temperatures: Sequence[float] | None = None, - temperature_settings: dict | None = None, ) -> None: """Generate MGXS assuming a stochastic "sandwich" of materials in a layered slab geometry. While geometry-specific spatial shielding effects are not @@ -2420,8 +2384,9 @@ def _generate_stochastic_slab_mgxs( ---------- groups : openmc.mgxs.EnergyGroups Energy group structure for the MGXS. - nparticles : int - Number of particles to simulate per batch when generating MGXS. + settings : openmc.Settings + Settings for the generation run(s), used verbatim except for the + fields owned by the stochastic slab method. mgxs_path : str Filename for the MGXS HDF5 file. correction : str @@ -2450,10 +2415,6 @@ def _generate_stochastic_slab_mgxs( A list of temperatures to generate MGXS at. Each infinite material region is isothermal at a given temperature data point for cross section generation. - temperature_settings : dict, optional - A dictionary of temperature settings to use when generating MGXS. - Valid entries for temperature_settings are the same as the valid - entries in openmc.Settings.temperature_settings. """ # Stochastic slab geometry @@ -2466,21 +2427,14 @@ def _generate_stochastic_slab_mgxs( source_energy=source_energy ) - temp_settings = {} - if temperature_settings is None: - temp_settings = self.settings.temperature - else: - temp_settings = temperature_settings - if temperatures is None: mgxs_sets = Model._isothermal_stochastic_slab_mgxs( geo, groups, - nparticles, + settings, correction, directory, - src, - temp_settings + src ).values() # Write the file to disk. @@ -2495,11 +2449,10 @@ def _generate_stochastic_slab_mgxs( raw_mgxs_sets[temperature] = Model._isothermal_stochastic_slab_mgxs( geo, groups, - nparticles, + settings, correction, directory, src, - temp_settings, temperature ) @@ -2521,10 +2474,9 @@ def _generate_stochastic_slab_mgxs( def _isothermal_materialwise_mgxs( input_model: openmc.Model, groups: openmc.mgxs.EnergyGroups, - nparticles: int, + settings: openmc.Settings, correction: str | None, directory: PathLike, - temperature_settings: dict, temperature: float | None = None, ) -> dict[str, openmc.XSdata]: """Generate a material-wise MGXS library for the model by running the @@ -2541,17 +2493,13 @@ def _isothermal_materialwise_mgxs( The model to use when computing material-wise MGXS. groups : openmc.mgxs.EnergyGroups Energy group structure for the MGXS. - nparticles : int - Number of particles to simulate per batch when generating MGXS. + settings : openmc.Settings + Settings for the generation run, used verbatim. correction : str Transport correction to apply to the MGXS. Options are None and "P0". directory : str Directory to run the simulation in, so as to contain XML files. - temperature_settings : dict - A dictionary of temperature settings to use when generating MGXS. - Valid entries for temperature_settings are the same as the valid - entries in openmc.Settings.temperature_settings. temperature : float, optional The isothermal temperature value to apply to the materials in the input model. If not specified, defaults to the temperatures in the @@ -2569,12 +2517,8 @@ def _isothermal_materialwise_mgxs( for material in model.geometry.get_all_materials().values(): material.temperature = temperature - # Settings - model.settings.batches = 200 - model.settings.inactive = 100 - model.settings.particles = nparticles - model.settings.output = {'summary': True, 'tallies': False} - model.settings.temperature = temperature_settings + # The provided settings are used verbatim + model.settings = copy.deepcopy(settings) # Generate MGXS mgxs_lib = Model._auto_generate_mgxs_lib( @@ -2596,12 +2540,11 @@ def _isothermal_materialwise_mgxs( def _generate_material_wise_mgxs( self, groups: openmc.mgxs.EnergyGroups, - nparticles: int, + settings: openmc.Settings, mgxs_path: PathLike, correction: str | None, directory: PathLike, temperatures: Sequence[float] | None = None, - temperature_settings: dict | None = None, ) -> None: """Generate a material-wise MGXS library for the model by running the original continuous energy OpenMC simulation of the full material @@ -2617,8 +2560,8 @@ def _generate_material_wise_mgxs( ---------- groups : openmc.mgxs.EnergyGroups Energy group structure for the MGXS. - nparticles : int - Number of particles to simulate per batch when generating MGXS. + settings : openmc.Settings + Settings for the generation run(s), used verbatim. mgxs_path : PathLike Filename for the MGXS HDF5 file. correction : str @@ -2630,26 +2573,10 @@ def _generate_material_wise_mgxs( A list of temperatures to generate MGXS at. Each infinite material region is isothermal at a given temperature data point for cross section generation. - temperature_settings : dict, optional - A dictionary of temperature settings to use when generating MGXS. - Valid entries for temperature_settings are the same as the valid - entries in openmc.Settings.temperature_settings. """ - temp_settings = {} - if temperature_settings is None: - temp_settings = self.settings.temperature - else: - temp_settings = temperature_settings - if temperatures is None: mgxs_sets = Model._isothermal_materialwise_mgxs( - self, - groups, - nparticles, - correction, - directory, - temp_settings - ).values() + self, groups, settings, correction, directory).values() # Write the file to disk. mgxs_file = openmc.MGXSLibrary(energy_groups=groups) @@ -2661,14 +2588,7 @@ def _generate_material_wise_mgxs( raw_mgxs_sets = {} for temperature in temperatures: raw_mgxs_sets[temperature] = Model._isothermal_materialwise_mgxs( - self, - groups, - nparticles, - correction, - directory, - temp_settings, - temperature - ) + self, groups, settings, correction, directory, temperature) # Unpack the isothermal XSData objects and build a single XSData object per material. mgxs_sets = [] @@ -2684,17 +2604,75 @@ def _generate_material_wise_mgxs( mgxs_file.add_xsdata(mgxs_set) mgxs_file.export_to_hdf5(mgxs_path) + def mgxs_generation_settings( + self, method: str = "material_wise" + ) -> openmc.Settings: + """Default settings for the simulations used to generate a MGXS library. + + Returns the :class:`openmc.Settings` object that + :meth:`Model.convert_to_multigroup` would use by default for the given + generation method: a copy of the model's own settings for the + ``"material_wise"`` method, or a fresh Settings object for the + surrogate-geometry methods (``"stochastic_slab"`` and + ``"infinite_medium"``, which also inherit the model's temperature + settings), with the generation defaults for batches, inactive + batches, particles, and output applied. To customize MGXS + generation, modify the returned object and pass it back via the + ``settings`` argument:: + + settings = model.mgxs_generation_settings() + settings.particles = 100_000 + model.convert_to_multigroup(settings=settings) + + The returned object records the method it was generated for, which + :meth:`Model.convert_to_multigroup` uses as its default ``method``, + so a non-default method only needs to be given here. + + .. versionadded:: 0.15.4 + + Parameters + ---------- + method : {"material_wise", "stochastic_slab", "infinite_medium"}, optional + MGXS generation method the settings are intended for. + + Returns + ------- + openmc.Settings + Default settings for the MGXS generation run(s). + """ + check_value('method', method, + ('material_wise', 'stochastic_slab', 'infinite_medium')) + if method == 'material_wise': + settings = copy.deepcopy(self.settings) + else: + settings = openmc.Settings() + settings.temperature = copy.deepcopy(self.settings.temperature) + # The surrogate-geometry methods always run in fixed source mode + # with fission treated as capture (nu-fission is still tallied) + settings.run_mode = 'fixed source' + settings.create_fission_neutrons = False + + settings.batches = 100 if method == 'infinite_medium' else 200 + if method != 'infinite_medium': + settings.inactive = 100 + settings.particles = 2000 + settings.output = {'summary': True, 'tallies': False} + # Record the method so convert_to_multigroup can default to it + settings._mgxs_generation_method = method + return settings + def convert_to_multigroup( self, - method: str = "material_wise", + method: str | None = None, groups: str | Sequence[float] | openmc.mgxs.EnergyGroups = "CASMO-2", - nparticles: int = 2000, + nparticles: int | None = None, overwrite_mgxs_library: bool = False, mgxs_path: PathLike = "mgxs.h5", correction: str | None = None, source_energy: openmc.stats.Univariate | None = None, temperatures: Sequence[float] | None = None, temperature_settings: dict | None = None, + settings: openmc.Settings | None = None, ): """Convert all materials from continuous energy to multigroup. @@ -2704,7 +2682,11 @@ def convert_to_multigroup( Parameters ---------- method : {"material_wise", "stochastic_slab", "infinite_medium"}, optional - Method to generate the MGXS. + Method to generate the MGXS. If not given, defaults to the + method that the ``settings`` object was generated for by + :meth:`Model.mgxs_generation_settings`, or ``"material_wise"`` + otherwise. Giving a method here that conflicts with the one the + settings were generated for is an error. groups : openmc.mgxs.EnergyGroups, str, or sequence of float, optional Energy group structure for the MGXS. Can be an :class:`openmc.mgxs.EnergyGroups` object, a string name of a @@ -2714,6 +2696,11 @@ def convert_to_multigroup( Defaults to ``"CASMO-2"``. nparticles : int, optional Number of particles to simulate per batch when generating MGXS. + Defaults to 2000. + + .. deprecated:: 0.15.4 + Set ``particles`` on the object passed via the ``settings`` + argument instead. overwrite_mgxs_library : bool, optional Whether to overwrite an existing MGXS library file. mgxs_path : str, optional @@ -2747,10 +2734,131 @@ def convert_to_multigroup( A dictionary of temperature settings to use when generating MGXS. Valid entries for temperature_settings are the same as the valid entries in openmc.Settings.temperature_settings. + + .. deprecated:: 0.15.4 + Set ``temperature`` on the object passed via the ``settings`` + argument instead. + settings : openmc.Settings, optional + Settings used verbatim for the continuous energy simulation(s) + that generate the MGXS library. If not provided, defaults are + taken from :meth:`Model.mgxs_generation_settings`; to customize + a run, start from those defaults, modify them, and pass the + result here. Note that the ``"stochastic_slab"`` and + ``"infinite_medium"`` methods construct their own fixed source + and set ``run_mode``, ``source``, and + ``create_fission_neutrons`` accordingly. If the settings include + a ``weight_windows_file`` (e.g., ``"weight_windows.h5"``), the + ``"material_wise"`` method loads and applies those weight + windows during the continuous energy generation simulation + (``weight_windows_on`` is enabled automatically). Applying + weight windows allows the simulation to obtain tallies -- and + thus nonzero cross sections -- for materials located far from + the source, for example behind a thick shield, which an analog + simulation may be unable to reach. A typical use case is to + first generate weight windows with the ``"stochastic_slab"`` + method and the random ray solver, then "bootstrap" a + higher-fidelity ``"material_wise"`` library by setting those + weight windows here; a warning is issued and the file is ignored + for the ``"stochastic_slab"`` and ``"infinite_medium"`` methods. + Cannot be combined with the deprecated ``nparticles`` or + ``temperature_settings`` arguments. + + .. versionadded:: 0.15.4 """ if not isinstance(groups, openmc.mgxs.EnergyGroups): groups = openmc.mgxs.EnergyGroups(groups) + # Resolve the generation method: an explicit argument wins, otherwise + # the method the provided settings were generated for (recorded by + # mgxs_generation_settings) is used, defaulting to "material_wise". + # Conflicting specifications are rejected, since the generation + # defaults differ by method. + if settings is not None: + check_type('settings', settings, openmc.Settings) + settings_method = (settings._mgxs_generation_method + if settings is not None else None) + if method is None: + method = settings_method or 'material_wise' + elif settings_method is not None and method != settings_method: + raise ValueError( + f'The "{method}" generation method conflicts with the ' + 'provided settings, which were generated for the ' + f'"{settings_method}" method by ' + 'Model.mgxs_generation_settings().') + check_value('method', method, + ('material_wise', 'stochastic_slab', 'infinite_medium')) + + # The model may reference its materials only through the geometry. + # The materials are converted in place and library-wide attributes + # like cross_sections must persist on the model afterwards, so + # populate the explicit collection if it is empty (sorted by ID for + # reproducibility, since geometry traversal order is arbitrary) + if not self.materials: + self.materials = openmc.Materials(sorted( + self.geometry.get_all_materials().values(), + key=lambda mat: mat.id)) + + # Resolve the settings for the MGXS generation run(s): the provided + # settings are used verbatim, otherwise the generation defaults are + # used (with the deprecated arguments applied, if given). + if nparticles is not None or temperature_settings is not None: + warnings.warn( + 'The "nparticles" and "temperature_settings" arguments are ' + 'deprecated. Customize MGXS generation by modifying the ' + 'Settings object returned by Model.mgxs_generation_settings()' + ' and passing it via the "settings" argument.', FutureWarning) + if settings is not None: + raise ValueError( + 'The deprecated "nparticles" and "temperature_settings" ' + 'arguments cannot be combined with the "settings" ' + 'argument.') + + if settings is None: + settings = self.mgxs_generation_settings(method) + if nparticles is not None: + settings.particles = nparticles + if temperature_settings is not None: + settings.temperature = temperature_settings + else: + # batches and particles are required for any OpenMC transport + # run; catch their absence here so a hand-built settings object + # fails with a pointer to the defaults rather than a mysterious + # error from inside the generation run + if settings.batches is None or settings.particles is None: + raise ValueError( + 'The provided settings are missing required attributes ' + '(batches, particles). Start from the defaults returned ' + 'by Model.mgxs_generation_settings() and modify them ' + 'rather than building a Settings object from scratch.') + settings = copy.deepcopy(settings) + + # The surrogate-geometry methods construct their own sources + if method != "material_wise" and len(settings.source) > 0: + warnings.warn( + 'The sources defined in "settings" are ignored by the ' + f'"{method}" MGXS generation method, which constructs ' + 'its own sources.') + + # A weight windows file on the generation settings is loaded and + # applied during the "material_wise" method's continuous energy + # simulation of the original geometry, allowing materials far from + # the source -- which an analog simulation may struggle to reach -- + # to still be tallied, and thus obtain nonzero cross sections. The + # "stochastic_slab" and "infinite_medium" methods use simplified + # surrogate geometries for which weight windows defined over the + # original geometry are neither applicable nor needed. + if settings.weight_windows_file is not None: + if method == "material_wise": + settings.weight_windows_on = True + else: + warnings.warn( + 'The weight windows file set on the generation settings ' + 'is only applicable to the "material_wise" MGXS ' + f'generation method and will be ignored for the ' + f'"{method}" method.' + ) + settings.weight_windows_file = None + # Do all work (including MGXS generation) in a temporary directory # to avoid polluting the working directory with residual XML files with TemporaryDirectory() as tmpdir: @@ -2786,16 +2894,16 @@ def convert_to_multigroup( if not Path(mgxs_path).is_file() or overwrite_mgxs_library: if method == "infinite_medium": self._generate_infinite_medium_mgxs( - groups, nparticles, mgxs_path, correction, tmpdir, source_energy, - temperatures, temperature_settings) + groups, settings, mgxs_path, correction, tmpdir, + source_energy, temperatures) elif method == "material_wise": self._generate_material_wise_mgxs( - groups, nparticles, mgxs_path, correction, tmpdir, - temperatures, temperature_settings) + groups, settings, mgxs_path, correction, tmpdir, + temperatures) elif method == "stochastic_slab": self._generate_stochastic_slab_mgxs( - groups, nparticles, mgxs_path, correction, tmpdir, source_energy, - temperatures, temperature_settings) + groups, settings, mgxs_path, correction, tmpdir, + source_energy, temperatures) else: raise ValueError( f'MGXS generation method "{method}" not recognized') diff --git a/openmc/settings.py b/openmc/settings.py index 8120eb073e6..1ecd483c125 100644 --- a/openmc/settings.py +++ b/openmc/settings.py @@ -501,6 +501,11 @@ def __init__(self, **kwargs): self._random_ray = {} + # MGXS generation method recorded by Model.mgxs_generation_settings() + # and read back by Model.convert_to_multigroup; provenance only, not + # written to XML + self._mgxs_generation_method = None + for key, value in kwargs.items(): setattr(self, key, value) diff --git a/src/random_ray/random_ray_simulation.cpp b/src/random_ray/random_ray_simulation.cpp index 00fff99a7c9..6d6f2c743de 100644 --- a/src/random_ray/random_ray_simulation.cpp +++ b/src/random_ray/random_ray_simulation.cpp @@ -392,6 +392,11 @@ void RandomRaySimulation::simulate() // Begin main simulation timer simulation::time_total.start(); + // Reset per-solve accumulators, as simulate() may run more than once on the + // same object (e.g. forward then adjoint when generating weight windows) + avg_miss_rate_ = 0.0; + total_geometric_intersections_ = 0; + // Random ray power iteration loop while (simulation::current_batch < settings::n_batches) { // Initialize the current batch diff --git a/src/weight_windows.cpp b/src/weight_windows.cpp index 39e46026cf0..416d1421c3e 100644 --- a/src/weight_windows.cpp +++ b/src/weight_windows.cpp @@ -945,6 +945,11 @@ void apply_weight_windows(Particle& p) if (!settings::weight_windows_on) return; + // Random ray rays are not Monte Carlo particles and must not be biased by + // weight windows; the solver generates weight windows but never applies them + if (settings::solver_type == SolverType::RANDOM_RAY) + return; + // WW on photon and neutron only if (!p.type().is_neutron() && !p.type().is_photon()) return; @@ -1003,14 +1008,25 @@ void apply_weight_window(Particle& p, WeightWindow weight_window) if (p.ww_factor() > 1.0) weight_window.scale(p.ww_factor()); - // if particle's weight is above the weight window split until they are within - // the window - if (weight > weight_window.upper_weight) { + // If the particle's weight is above the weight window, split it until the + // resulting particles are within the window. The comparisons use a relative + // dead band so that the branch taken is insensitive to bit-level differences + // in the window bounds (see WEIGHT_WINDOW_REL_TOL). + if (weight > weight_window.upper_weight * (1.0 + WEIGHT_WINDOW_REL_TOL)) { // do not further split the particle if above the limit if (p.n_split() >= settings::max_history_splits) return; - double n_split = std::ceil(weight / weight_window.upper_weight); + // The (1 - WEIGHT_WINDOW_REL_TOL) factor keeps the number of splits + // stable when the weight-to-bound ratio sits within rounding of an exact + // integer, which the weight window arithmetic itself can produce (e.g., a + // roulette survivor assigned weight * max_split, later split against an + // upper bound that is an exact multiple of the same lower bound). Ratios + // within the dead band of an integer consistently round down; the lower + // clamp of 2 preserves the guarantee that the split branch always splits. + double n_split = + std::max(2.0, std::ceil((1.0 - WEIGHT_WINDOW_REL_TOL) * weight / + weight_window.upper_weight)); double max_split = weight_window.max_split; n_split = std::min(n_split, max_split); @@ -1024,7 +1040,8 @@ void apply_weight_window(Particle& p, WeightWindow weight_window) // remaining weight is applied to current particle p.wgt() = weight / n_split; - } else if (weight <= weight_window.lower_weight) { + } else if (weight < + weight_window.lower_weight * (1.0 - WEIGHT_WINDOW_REL_TOL)) { // if the particle weight is below the window, play Russian roulette double weight_survive = std::min(weight * weight_window.max_split, weight_window.survival_weight); diff --git a/tests/regression_tests/random_ray_auto_convert/test.py b/tests/regression_tests/random_ray_auto_convert/test.py index 99a931dce86..a1570c64bea 100644 --- a/tests/regression_tests/random_ray_auto_convert/test.py +++ b/tests/regression_tests/random_ray_auto_convert/test.py @@ -26,8 +26,10 @@ def test_random_ray_auto_convert(method): model = pwr_pin_cell() # Convert to a multi-group model + mgxs_settings = model.mgxs_generation_settings(method) + mgxs_settings.particles = 100 model.convert_to_multigroup( - method=method, groups='CASMO-2', nparticles=100, + method=method, groups='CASMO-2', settings=mgxs_settings, overwrite_mgxs_library=False, mgxs_path="mgxs.h5" ) diff --git a/tests/regression_tests/random_ray_auto_convert_bootstrap/__init__.py b/tests/regression_tests/random_ray_auto_convert_bootstrap/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/regression_tests/random_ray_auto_convert_bootstrap/inputs_true.dat b/tests/regression_tests/random_ray_auto_convert_bootstrap/inputs_true.dat new file mode 100644 index 00000000000..87043f116d6 --- /dev/null +++ b/tests/regression_tests/random_ray_auto_convert_bootstrap/inputs_true.dat @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + fixed source + 20 + 10 + + + -60.0 -6.0 -6.0 -48.0 6.0 6.0 + + + + 2000000.0 1.0 + + + cell + 1 + + + true + weight_windows.h5 + + true + true + + 100000 + + + + 1 2 3 4 + + + 193 + flux + + + diff --git a/tests/regression_tests/random_ray_auto_convert_bootstrap/results_true.dat b/tests/regression_tests/random_ray_auto_convert_bootstrap/results_true.dat new file mode 100644 index 00000000000..f5d1b050692 --- /dev/null +++ b/tests/regression_tests/random_ray_auto_convert_bootstrap/results_true.dat @@ -0,0 +1,9 @@ +tally 1: +9.500337E+01 +9.709273E+02 +2.417257E-04 +1.153221E-08 +9.046900E+02 +8.904339E+04 +1.034722E+02 +1.134807E+03 diff --git a/tests/regression_tests/random_ray_auto_convert_bootstrap/test.py b/tests/regression_tests/random_ray_auto_convert_bootstrap/test.py new file mode 100644 index 00000000000..6e43d86592c --- /dev/null +++ b/tests/regression_tests/random_ray_auto_convert_bootstrap/test.py @@ -0,0 +1,106 @@ +import copy +import os +from pathlib import Path + +import numpy as np +import openmc +import openmc.mgxs +from openmc.examples import sphere_with_shielded_pocket + +from tests.testing_harness import TolerantPyAPITestHarness + + +GROUPS = 'CASMO-4' + + +class MGXSTestHarness(TolerantPyAPITestHarness): + def _cleanup(self): + super()._cleanup() + for f in ('mgxs.h5', 'weight_windows.h5'): + if os.path.exists(f): + os.remove(f) + + +def generate_weight_windows(model, mesh): + """Convert a multigroup model to random ray and run a FW-CADIS weight + window generation solve, producing weight_windows.h5.""" + model.convert_to_random_ray() + rr = model.settings.random_ray + rr['source_region_meshes'] = [(mesh, [model.geometry.root_universe])] + rr['distance_inactive'] = 100.0 + rr['distance_active'] = 400.0 + rr['source_shape'] = 'flat' + rr['sample_method'] = 'halton' + rr['volume_estimator'] = 'hybrid' + model.settings.particles = 1000 + model.settings.batches = 20 + model.settings.inactive = 15 + model.settings.weight_window_generators = openmc.WeightWindowGenerator( + method='fw_cadis', mesh=mesh, max_realizations=20, + energy_bounds=openmc.mgxs.EnergyGroups(GROUPS).group_edges) + model.run() + + +def test_random_ray_auto_convert_bootstrap(): + # Tests the weight window bootstrapped MGXS generation workflow, which + # consists of five OpenMC runs: a stochastic_slab MGXS generation, a random + # ray FW-CADIS weight window generation, a material_wise MGXS generation + # using those weight windows (which reach the steel pocket that an analog + # solve essentially never tallies), a second FW-CADIS generation using the + # bootstrapped library, and a final continuous energy Monte Carlo solve + # using the improved weight windows, whose tallies are compared against + # the reference results. + openmc.reset_auto_ids() + model = sphere_with_shielded_pocket() + + # Used by the continuous energy solves below; these have no effect on the + # random ray solves (random ray generates weight windows but never applies + # them). + model.settings.weight_window_checkpoints = { + 'collision': True, 'surface': True} + model.settings.max_history_splits = 100_000 + + # Overlay a ~10 cm source region / weight window mesh on the geometry + bbox = model.geometry.bounding_box + mesh = openmc.RegularMesh() + mesh.dimension = np.round( + (np.asarray(bbox.upper_right) - np.asarray(bbox.lower_left)) / 10.0 + ).astype(int) + mesh.lower_left = bbox.lower_left + mesh.upper_right = bbox.upper_right + + # Generate weight windows covering the whole problem with the + # stochastic_slab method and a random ray FW-CADIS solve + slab_model = copy.deepcopy(model) + slab_settings = slab_model.mgxs_generation_settings('stochastic_slab') + slab_settings.particles = 50 + slab_model.convert_to_multigroup( + groups=GROUPS, settings=slab_settings, + overwrite_mgxs_library=True, mgxs_path='mgxs.h5') + generate_weight_windows(slab_model, mesh) + + # Bootstrap the material-wise MGXS generation with those weight windows, + # then regenerate the weight windows from the higher-fidelity library + boot_model = copy.deepcopy(model) + boot_settings = boot_model.mgxs_generation_settings() + boot_settings.particles = 1 + boot_settings.weight_windows_file = Path('weight_windows.h5').resolve() + boot_model.convert_to_multigroup( + groups=GROUPS, settings=boot_settings, + overwrite_mgxs_library=True, mgxs_path='mgxs.h5') + generate_weight_windows(boot_model, mesh) + + # Run the continuous energy model with the improved weight windows, + # tallying the flux in every region + model.settings.weight_windows_file = 'weight_windows.h5' + model.settings.weight_windows_on = True + model.settings.particles = 20 + model.settings.batches = 10 + tally = openmc.Tally(name='flux') + tally.filters = [ + openmc.CellFilter(list(model.geometry.get_all_cells().values()))] + tally.scores = ['flux'] + model.tallies = openmc.Tallies([tally]) + + harness = MGXSTestHarness('statepoint.10.h5', model) + harness.main() diff --git a/tests/regression_tests/random_ray_auto_convert_kappa_fission/test.py b/tests/regression_tests/random_ray_auto_convert_kappa_fission/test.py index 6decf165a7f..3f2468532ef 100644 --- a/tests/regression_tests/random_ray_auto_convert_kappa_fission/test.py +++ b/tests/regression_tests/random_ray_auto_convert_kappa_fission/test.py @@ -26,8 +26,10 @@ def test_random_ray_auto_convert(method): model = pwr_pin_cell() # Convert to a multi-group model + mgxs_settings = model.mgxs_generation_settings(method) + mgxs_settings.particles = 100 model.convert_to_multigroup( - method=method, groups='CASMO-2', nparticles=100, + method=method, groups='CASMO-2', settings=mgxs_settings, overwrite_mgxs_library=False, mgxs_path="mgxs.h5" ) diff --git a/tests/regression_tests/random_ray_auto_convert_source_energy/test.py b/tests/regression_tests/random_ray_auto_convert_source_energy/test.py index bb9119d8953..bfea6ae9f92 100644 --- a/tests/regression_tests/random_ray_auto_convert_source_energy/test.py +++ b/tests/regression_tests/random_ray_auto_convert_source_energy/test.py @@ -37,8 +37,10 @@ def test_random_ray_auto_convert_source_energy(method, source_type): source_energy = openmc.stats.delta_function(1.0e4) # Convert to a multi-group model + mgxs_settings = model.mgxs_generation_settings(method) + mgxs_settings.particles = 100 model.convert_to_multigroup( - method=method, groups='CASMO-8', nparticles=100, + method=method, groups='CASMO-8', settings=mgxs_settings, overwrite_mgxs_library=False, mgxs_path="mgxs.h5", source_energy=source_energy ) diff --git a/tests/regression_tests/random_ray_auto_convert_temperature/test.py b/tests/regression_tests/random_ray_auto_convert_temperature/test.py index 99c99e6147f..2cd7133e5c1 100644 --- a/tests/regression_tests/random_ray_auto_convert_temperature/test.py +++ b/tests/regression_tests/random_ray_auto_convert_temperature/test.py @@ -33,10 +33,13 @@ def test_random_ray_auto_convert(method): } # Convert to a multi-group model + mgxs_settings = model.mgxs_generation_settings(method) + mgxs_settings.particles = 100 + mgxs_settings.temperature = temp_settings model.convert_to_multigroup( - method=method, groups='CASMO-2', nparticles=100, + method=method, groups='CASMO-2', settings=mgxs_settings, overwrite_mgxs_library=False, mgxs_path="mgxs.h5", - temperatures=[294.0, 394.0], temperature_settings=temp_settings + temperatures=[294.0, 394.0] ) # Convert to a random ray model diff --git a/tests/regression_tests/random_ray_diagonal_stabilization/test.py b/tests/regression_tests/random_ray_diagonal_stabilization/test.py index 8d36e1d2581..08d8a50847b 100644 --- a/tests/regression_tests/random_ray_diagonal_stabilization/test.py +++ b/tests/regression_tests/random_ray_diagonal_stabilization/test.py @@ -22,8 +22,10 @@ def test_random_ray_diagonal_stabilization(): # and transport correction enabled. This will generate # MGXS data with some negatives on the diagonal, in order # to trigger diagonal correction. + mgxs_settings = model.mgxs_generation_settings('material_wise') + mgxs_settings.particles = 13 model.convert_to_multigroup( - method='material_wise', groups='CASMO-70', nparticles=13, + method='material_wise', groups='CASMO-70', settings=mgxs_settings, overwrite_mgxs_library=True, mgxs_path="mgxs.h5", correction='P0' ) diff --git a/tests/regression_tests/weightwindows/local/results_true.dat b/tests/regression_tests/weightwindows/local/results_true.dat index d3dfa119d84..ebff4edacbd 100644 --- a/tests/regression_tests/weightwindows/local/results_true.dat +++ b/tests/regression_tests/weightwindows/local/results_true.dat @@ -1 +1 @@ -a78972fe3c0dadfc256cfb139c5ca8c7b634738e1895ad2d6286ed5e2567c6dc518e499363908e9058432684148a706a179a39110f703d5322491184a1d0c3e4 \ No newline at end of file +41d8ba482783b724c61bf058a67ff701109076ac460f0be69b3a33c7a9e3e2aa5b5773442d33561b71c69500a9535f1ffb0f5e81f6ce364288c5094bbbc0a292 \ No newline at end of file diff --git a/tests/unit_tests/dagmc/test_convert_to_multigroup.py b/tests/unit_tests/dagmc/test_convert_to_multigroup.py index 069dc658e52..4d6243c7cc3 100644 --- a/tests/unit_tests/dagmc/test_convert_to_multigroup.py +++ b/tests/unit_tests/dagmc/test_convert_to_multigroup.py @@ -42,10 +42,12 @@ def test_convert_to_multigroup_without_particles_batches(run_in_tmpdir): # This should work without requiring particles/batches to be set # convert_to_multigroup handles initialization internally using non-transport mode + mgxs_settings = model.mgxs_generation_settings('material_wise') + mgxs_settings.particles = 10 model.convert_to_multigroup( method='material_wise', groups='CASMO-2', - nparticles=10, + settings=mgxs_settings, overwrite_mgxs_library=True ) diff --git a/tests/unit_tests/test_model.py b/tests/unit_tests/test_model.py index 028a83b0bdc..abc6a29ffd6 100644 --- a/tests/unit_tests/test_model.py +++ b/tests/unit_tests/test_model.py @@ -1067,3 +1067,221 @@ def test_convert_to_multigroup_preserves_material_names(run_in_tmpdir): macro = [m._macroscopic for m in model.materials] assert macro == [f"Steel_Plate__1_{a.id}", f"Steel_Plate__1_{b.id}"] assert len(set(macro)) == 2 + + +class _GenerationCaptured(Exception): + """Raised by the patched MGXS library builder to end generation early.""" + + +def _steel_water_model(): + a = openmc.Material(name='steel') + a.add_element('Fe', 1.0) + a.set_density('g/cm3', 7.9) + b = openmc.Material(name='water') + b.add_element('H', 2.0) + b.add_element('O', 1.0) + b.set_density('g/cm3', 1.0) + s1 = openmc.Sphere(r=1.0) + s2 = openmc.Sphere(r=2.0, boundary_type='vacuum') + c1 = openmc.Cell(fill=a, region=-s1) + c2 = openmc.Cell(fill=b, region=+s1 & -s2) + return openmc.Model(openmc.Geometry([c1, c2]), openmc.Materials([a, b])) + + +def _capture_generation_settings(monkeypatch, model, **kwargs): + """Return the Settings of the MGXS generation run by capturing the + generation model just before transport would start.""" + captured = {} + + def fake_auto_generate(gen_model, groups, correction, directory): + captured['settings'] = gen_model.settings + raise _GenerationCaptured() + + monkeypatch.setattr(openmc.Model, '_auto_generate_mgxs_lib', + fake_auto_generate) + with pytest.raises(_GenerationCaptured): + model.convert_to_multigroup(**kwargs) + return captured['settings'] + + +def test_mgxs_generation_settings(): + model = _steel_water_model() + model.settings.run_mode = 'fixed source' + model.settings.photon_transport = True + model.settings.particles = 50 # tuned for the final multigroup solve + model.settings.temperature = {'method': 'interpolation'} + + # material_wise: the model's own settings plus the generation defaults + s = model.mgxs_generation_settings('material_wise') + assert s.batches == 200 + assert s.inactive == 100 + assert s.particles == 2000 + assert s.output == {'summary': True, 'tallies': False} + assert s.run_mode == 'fixed source' + assert s.photon_transport is True + assert s.temperature == {'method': 'interpolation'} + + # The returned object is a copy: modifying it leaves the model untouched + s.particles = 100_000 + assert model.settings.particles == 50 + + # Surrogate methods: fresh settings that only inherit the temperature + for method, batches in (('stochastic_slab', 200), ('infinite_medium', 100)): + s = model.mgxs_generation_settings(method) + assert s.batches == batches + assert s.particles == 2000 + assert s.run_mode == 'fixed source' + assert s.create_fission_neutrons is False + assert s.temperature == {'method': 'interpolation'} + assert s.photon_transport is None + + with pytest.raises(ValueError): + model.mgxs_generation_settings('not_a_method') + + +def test_convert_to_multigroup_settings_material_wise(run_in_tmpdir, monkeypatch): + model = _steel_water_model() + model.settings.run_mode = 'fixed source' + model.settings.source = openmc.IndependentSource(space=openmc.stats.Point()) + model.settings.photon_transport = True + + user = model.mgxs_generation_settings('material_wise') + user.particles = 12345 + user.seed = 7 + gen = _capture_generation_settings( + monkeypatch, model, method='material_wise', settings=user) + + # The provided settings are used verbatim... + assert gen.particles == 12345 + assert gen.seed == 7 + assert gen.batches == 200 + assert gen.inactive == 100 + assert gen.output == {'summary': True, 'tallies': False} + # ...including the model settings baked in by mgxs_generation_settings() + assert gen.run_mode == 'fixed source' + assert gen.photon_transport is True + assert len(gen.source) == 1 + + +def test_convert_to_multigroup_settings_stochastic_slab(run_in_tmpdir, monkeypatch): + model = _steel_water_model() + + user = model.mgxs_generation_settings('stochastic_slab') + user.particles = 999 + user.batches = 50 + user.max_history_splits = 42 + user.source = openmc.IndependentSource(space=openmc.stats.Point()) + + with pytest.warns(UserWarning, match='constructs its own'): + gen = _capture_generation_settings( + monkeypatch, model, method='stochastic_slab', settings=user) + + assert gen.particles == 999 + assert gen.batches == 50 + assert gen.max_history_splits == 42 + # The method constructs its own source and run mode; the user's source + # was discarded with a warning + assert gen.run_mode == 'fixed source' + assert gen.create_fission_neutrons is False + assert len(gen.source) > 0 + assert all(s is not user.source[0] for s in gen.source) + # The caller's object is never mutated + assert len(user.source) == 1 + + +def test_convert_to_multigroup_settings_weight_windows(run_in_tmpdir, monkeypatch): + model = _steel_water_model() + ww_path = Path('ww.h5').resolve() + + user = model.mgxs_generation_settings('material_wise') + user.weight_windows_file = ww_path + gen = _capture_generation_settings( + monkeypatch, model, method='material_wise', settings=user) + + # A weight windows file on the generation settings is applied to the + # "material_wise" generation run with weight windows turned on + assert gen.weight_windows_file == ww_path + assert gen.weight_windows_on is True + # The caller's object is never mutated + assert user.weight_windows_file == ww_path + assert user.weight_windows_on is None + + # The surrogate-geometry methods ignore the file with a warning + user = model.mgxs_generation_settings('stochastic_slab') + user.weight_windows_file = ww_path + with pytest.warns(UserWarning, match='material_wise'): + gen = _capture_generation_settings( + monkeypatch, model, method='stochastic_slab', settings=user) + assert gen.weight_windows_file is None + assert user.weight_windows_file == ww_path + + +def test_convert_to_multigroup_settings_method_recorded(run_in_tmpdir, + monkeypatch): + model = _steel_water_model() + + # Settings record the method they were generated for, so a non-default + # method only needs to be given to mgxs_generation_settings() + user = model.mgxs_generation_settings('stochastic_slab') + gen = _capture_generation_settings(monkeypatch, model, settings=user) + assert gen.run_mode == 'fixed source' + assert gen.create_fission_neutrons is False + # The stochastic slab generation constructs its own source, proving the + # slab method was dispatched + assert len(gen.source) > 0 + + # An explicit method that conflicts with the settings is rejected + with pytest.raises(ValueError, match='stochastic_slab'): + model.convert_to_multigroup(method='material_wise', settings=user) + + +def test_convert_to_multigroup_settings_validation(run_in_tmpdir): + model = _steel_water_model() + + with pytest.raises(TypeError): + model.convert_to_multigroup(settings={'particles': 100}) + + # A hand-built settings object missing attributes required for any + # transport run is rejected with a pointer to the defaults + with pytest.raises(ValueError, match='mgxs_generation_settings'): + model.convert_to_multigroup(settings=openmc.Settings(particles=5000)) + + # The deprecated arguments cannot be combined with settings + settings = model.mgxs_generation_settings() + with pytest.raises(ValueError, match='deprecated'), \ + pytest.warns(FutureWarning): + model.convert_to_multigroup(nparticles=1000, settings=settings) + with pytest.raises(ValueError, match='deprecated'), \ + pytest.warns(FutureWarning): + model.convert_to_multigroup( + temperature_settings={'method': 'interpolation'}, + settings=settings) + + +def test_convert_to_multigroup_deprecated_args(run_in_tmpdir, monkeypatch): + # The deprecated arguments still work, with a FutureWarning + model = _steel_water_model() + with pytest.warns(FutureWarning, match='deprecated'): + gen = _capture_generation_settings( + monkeypatch, model, method='stochastic_slab', nparticles=1234, + temperature_settings={'method': 'interpolation'}) + assert gen.particles == 1234 + assert gen.temperature == {'method': 'interpolation'} + assert gen.batches == 200 # generation default unchanged + + +def test_convert_to_multigroup_materials_from_geometry(run_in_tmpdir, monkeypatch): + # A model whose materials are only referenced through the geometry gets + # its materials collection populated (sorted by ID) for the conversion + model = _steel_water_model() + model.materials = openmc.Materials() + ids = sorted(model.geometry.get_all_materials()) + + def fake_auto_generate(gen_model, groups, correction, directory): + raise _GenerationCaptured() + + monkeypatch.setattr(openmc.Model, '_auto_generate_mgxs_lib', + fake_auto_generate) + with pytest.raises(_GenerationCaptured): + model.convert_to_multigroup(method='stochastic_slab') + assert [m.id for m in model.materials] == ids diff --git a/tests/unit_tests/weightwindows/test.py b/tests/unit_tests/weightwindows/test.py index efa203b510a..c9b24e3059f 100644 --- a/tests/unit_tests/weightwindows/test.py +++ b/tests/unit_tests/weightwindows/test.py @@ -249,7 +249,7 @@ def test_photon_heating(run_in_tmpdir, shared_secondary): model.settings.run_mode = 'fixed source' model.settings.batches = 5 - model.settings.particles = 101 + model.settings.particles = 100 model.settings.shared_secondary_bank = shared_secondary tally = openmc.Tally() diff --git a/tests/unit_tests/weightwindows/test_ww_gen.py b/tests/unit_tests/weightwindows/test_ww_gen.py index a4456e6809a..fbb316860b2 100644 --- a/tests/unit_tests/weightwindows/test_ww_gen.py +++ b/tests/unit_tests/weightwindows/test_ww_gen.py @@ -363,10 +363,12 @@ def test_ww_generation_with_dagmc(run_in_tmpdir): rr_model = copy.deepcopy(model) rr_model.settings.inactive = 3 + mgxs_settings = rr_model.mgxs_generation_settings("stochastic_slab") + mgxs_settings.particles = 10 rr_model.convert_to_multigroup( method="stochastic_slab", overwrite_mgxs_library=True, - nparticles=10, + settings=mgxs_settings, groups="CASMO-2" )