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..5a81495dc80 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,20 @@ 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. Only the attributes that are +populated on the passed :class:`openmc.Settings` object override the +generation defaults, so a sparse object adjusts just the fields you set. 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:: + + model.convert_to_multigroup(settings=openmc.Settings(particles=100_000)) .. note:: MGXS transport correction (via setting the ``correction`` parameter in the @@ -739,12 +747,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 +763,50 @@ 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 = openmc.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 +1125,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 +1140,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 +1166,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..5429a59d479 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,114 @@ 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 that 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 c437d20337f..8cc3ac13b89 100644 --- a/openmc/model/model.py +++ b/openmc/model/model.py @@ -1898,12 +1898,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". @@ -2055,11 +2053,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 @@ -2071,8 +2068,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". @@ -2080,10 +2078,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. @@ -2100,18 +2094,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') @@ -2133,13 +2122,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 @@ -2168,8 +2156,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 @@ -2184,10 +2173,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( @@ -2196,23 +2181,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) @@ -2230,11 +2208,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) @@ -2332,11 +2309,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 @@ -2349,8 +2325,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". @@ -2358,10 +2335,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. @@ -2379,21 +2352,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) @@ -2414,13 +2379,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 @@ -2438,8 +2402,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 @@ -2468,10 +2433,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 @@ -2484,21 +2445,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. @@ -2513,11 +2467,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 ) @@ -2539,10 +2492,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 @@ -2559,17 +2511,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 @@ -2587,12 +2535,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( @@ -2614,12 +2558,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 @@ -2635,8 +2578,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 @@ -2648,26 +2591,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) @@ -2679,14 +2606,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 = [] @@ -2706,13 +2626,14 @@ def convert_to_multigroup( self, method: str = "material_wise", 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. @@ -2732,6 +2653,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 @@ -2765,10 +2691,119 @@ 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 overrides for the continuous energy simulation(s) used + to generate the MGXS library. Attributes populated on this + object take precedence over the generation defaults, so a + sparse object such as ``openmc.Settings(particles=100_000)`` + adjusts just that field (see :meth:`openmc.Settings.update`). + The run mode cannot be overridden, as it is determined by the + generation method. A ``weight_windows_file`` is applied during + ``"material_wise"`` generation and ignored with a warning by + the other methods; see the user guide for the weight window + "bootstrapping" workflow this enables. 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) + check_value('method', method, + ('material_wise', 'stochastic_slab', 'infinite_medium')) + if settings is not None: + check_type('settings', settings, openmc.Settings) + + # 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)) + + if nparticles is not None or temperature_settings is not None: + warnings.warn( + 'The "nparticles" and "temperature_settings" arguments are ' + 'deprecated. Pass a Settings object with the desired ' + 'attributes via the "settings" argument instead.', + FutureWarning) + if settings is not None: + raise ValueError( + 'The deprecated "nparticles" and "temperature_settings" ' + 'arguments cannot be combined with the "settings" ' + 'argument.') + + # Resolve the settings for the MGXS generation run(s) in three + # layers, with later layers taking precedence: the model's own + # settings ("material_wise") or a fresh Settings object (surrogate + # methods), then the generation defaults, then any attributes the + # user populated on the provided settings. + user_settings = settings + if method == 'material_wise': + settings = copy.deepcopy(self.settings) + else: + settings = openmc.Settings() + settings.temperature = copy.deepcopy(self.settings.temperature) + # The surrogate-geometry methods treat fission as capture + # (nu-fission is still tallied) + 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} + if nparticles is not None: + settings.particles = nparticles + if temperature_settings is not None: + settings.temperature = temperature_settings + + if user_settings is not None: + # The surrogate-geometry methods construct their own sources + if method != "material_wise" and len(user_settings.source) > 0: + warnings.warn( + 'The sources defined in "settings" are ignored by the ' + f'"{method}" MGXS generation method, which constructs ' + 'its own sources.') + settings.update(user_settings) + + # The run mode is the one attribute that cannot be detected as + # user-populated (a fresh Settings object defaults it to + # 'eigenvalue'), so it is owned by the generation method: + # "material_wise" always takes it from the model, while the + # surrogate-geometry methods always run in fixed source mode. + if method == 'material_wise': + settings.run_mode = self.settings.run_mode + else: + settings.run_mode = 'fixed source' + + # A weight windows file on the generation settings is loaded and + # applied (specifying a file turns weight windows on) 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 and \ + method != "material_wise": + 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: @@ -2804,16 +2839,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..cf1d9b47eea 100644 --- a/openmc/settings.py +++ b/openmc/settings.py @@ -1,9 +1,11 @@ from collections.abc import Iterable, Mapping, MutableSequence, Sequence +import copy from enum import Enum import itertools from math import ceil from numbers import Integral, Real from pathlib import Path +from typing import Self import traceback import lxml.etree as ET @@ -241,7 +243,7 @@ class Settings: also tends to dampen the convergence rate of the solver, thus requiring more iterations to converge. :adjoint_source: - Source object used to define localized adjoint source/detector response + Source object used to define localized adjoint source/detector response function. .. versionadded:: 0.15.0 @@ -514,6 +516,46 @@ def __setattr__(self, name: str, value): warnings.warn(msg, stacklevel=2) super().__setattr__(name, value) + def update(self, other: Self): + """Update this object with all populated attributes of another instance. + + Every attribute of `other` that has been populated -- i.e., that is + not None and not an empty collection -- is copied to this object, + overwriting any existing value. Attributes that were never set on + `other` leave the corresponding attribute of this object untouched. + This allows a sparsely-populated Settings object to be applied as a + set of overrides on top of fully-populated defaults. + + Note that :attr:`run_mode` always carries a value, so an explicitly + assigned 'eigenvalue' run mode cannot be distinguished from the + default; the run mode is therefore only copied from `other` when it + differs from 'eigenvalue'. + + .. versionadded:: 0.15.4 + + Parameters + ---------- + other : openmc.Settings + Settings object whose populated attributes are applied to this + object. + + """ + cv.check_type('other', other, Settings) + for name, value in vars(other).items(): + # run_mode defaults to 'eigenvalue' rather than to an "unset" + # state, so only a non-default value is detectable + if name == '_run_mode': + if value is not RunMode.EIGENVALUE: + self._run_mode = value + continue + + # None or an empty collection means the attribute was never set + if value is None or (hasattr(value, '__len__') and len(value) == 0): + continue + + # deepcopy so mutations of `other` cannot leak into this object + setattr(self, name, copy.deepcopy(value)) + @property def run_mode(self) -> str: return self._run_mode.value @@ -2031,11 +2073,11 @@ def _create_random_ray_subelement(self, root, mesh_memo=None): path = f"./mesh[@id='{mesh.id}']" if root.find(path) is None: root.append(mesh.to_xml_element()) - if mesh_memo is not None: + if mesh_memo is not None: mesh_memo.add(mesh.id) elif key == 'adjoint_source': subelement = ET.SubElement(element, 'adjoint_source') - # Check that all entries are valid SourceBase instances, in case + # Check that all entries are valid SourceBase instances, in case # the random_ray setter was not used to populate dict entries. if not isinstance(value, MutableSequence): value = [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 0d268ce1752..704d4de66e1 100644 --- a/src/weight_windows.cpp +++ b/src/weight_windows.cpp @@ -946,6 +946,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; @@ -1004,14 +1009,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); @@ -1025,7 +1041,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..c9df2d6c723 100644 --- a/tests/regression_tests/random_ray_auto_convert/test.py +++ b/tests/regression_tests/random_ray_auto_convert/test.py @@ -27,7 +27,8 @@ def test_random_ray_auto_convert(method): # Convert to a multi-group model model.convert_to_multigroup( - method=method, groups='CASMO-2', nparticles=100, + method=method, groups='CASMO-2', + settings=openmc.Settings(particles=100), 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..825e8dbdb99 --- /dev/null +++ b/tests/regression_tests/random_ray_auto_convert_bootstrap/inputs_true.dat @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + fixed source + 20 + 10 + + + -60.0 -6.0 -6.0 -48.0 6.0 6.0 + + + + 2000000.0 1.0 + + + cell + 1 + + + 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..12636c2942f --- /dev/null +++ b/tests/regression_tests/random_ray_auto_convert_bootstrap/test.py @@ -0,0 +1,103 @@ +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_model.convert_to_multigroup( + method='stochastic_slab', groups=GROUPS, + settings=openmc.Settings(particles=50), + 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 = openmc.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.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..b3b8e84c95d 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 @@ -27,7 +27,8 @@ def test_random_ray_auto_convert(method): # Convert to a multi-group model model.convert_to_multigroup( - method=method, groups='CASMO-2', nparticles=100, + method=method, groups='CASMO-2', + settings=openmc.Settings(particles=100), 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..8dd95e73eeb 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 @@ -38,7 +38,8 @@ def test_random_ray_auto_convert_source_energy(method, source_type): # Convert to a multi-group model model.convert_to_multigroup( - method=method, groups='CASMO-8', nparticles=100, + method=method, groups='CASMO-8', + settings=openmc.Settings(particles=100), 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..e6047613be3 100644 --- a/tests/regression_tests/random_ray_auto_convert_temperature/test.py +++ b/tests/regression_tests/random_ray_auto_convert_temperature/test.py @@ -34,9 +34,10 @@ def test_random_ray_auto_convert(method): # Convert to a multi-group model model.convert_to_multigroup( - method=method, groups='CASMO-2', nparticles=100, + method=method, groups='CASMO-2', + settings=openmc.Settings(particles=100, temperature=temp_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..76d2c70bd3e 100644 --- a/tests/regression_tests/random_ray_diagonal_stabilization/test.py +++ b/tests/regression_tests/random_ray_diagonal_stabilization/test.py @@ -1,5 +1,6 @@ import os +import openmc from openmc.examples import pwr_pin_cell from openmc import RegularMesh @@ -23,7 +24,8 @@ def test_random_ray_diagonal_stabilization(): # MGXS data with some negatives on the diagonal, in order # to trigger diagonal correction. model.convert_to_multigroup( - method='material_wise', groups='CASMO-70', nparticles=13, + method='material_wise', groups='CASMO-70', + settings=openmc.Settings(particles=13), 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..d66ebae3952 100644 --- a/tests/unit_tests/dagmc/test_convert_to_multigroup.py +++ b/tests/unit_tests/dagmc/test_convert_to_multigroup.py @@ -45,7 +45,7 @@ def test_convert_to_multigroup_without_particles_batches(run_in_tmpdir): model.convert_to_multigroup( method='material_wise', groups='CASMO-2', - nparticles=10, + settings=openmc.Settings(particles=10), overwrite_mgxs_library=True ) diff --git a/tests/unit_tests/test_model.py b/tests/unit_tests/test_model.py index f60c458b6cc..828e7cca0e0 100644 --- a/tests/unit_tests/test_model.py +++ b/tests/unit_tests/test_model.py @@ -1090,3 +1090,173 @@ 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_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 + model.settings.batches = 1200 # tuned for the final multigroup solve + + user = openmc.Settings(particles=12345, seed=7) + gen = _capture_generation_settings( + monkeypatch, model, method='material_wise', settings=user) + + # User-populated attributes override the generation defaults... + assert gen.particles == 12345 + assert gen.seed == 7 + # ...the generation defaults override the model's own settings... + assert gen.batches == 200 + assert gen.inactive == 100 + assert gen.output == {'summary': True, 'tallies': False} + # ...and everything else is inherited from the model + assert gen.run_mode == 'fixed source' + assert gen.photon_transport is True + assert len(gen.source) == 1 + # The caller's object is never mutated + assert user.batches is None + + # The run mode is owned by the generation method: material_wise always + # takes it from the model, even when set on the provided settings + model.settings.run_mode = 'eigenvalue' + gen = _capture_generation_settings( + monkeypatch, model, method='material_wise', + settings=openmc.Settings(run_mode='fixed source')) + assert gen.run_mode == 'eigenvalue' + + +def test_convert_to_multigroup_settings_stochastic_slab(run_in_tmpdir, monkeypatch): + model = _steel_water_model() + + user = openmc.Settings(particles=999, batches=50, 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 = openmc.Settings(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 passed through to + # the "material_wise" generation run (specifying a file turns weight + # windows on at run time) + assert gen.weight_windows_file == ww_path + # The caller's object is never mutated + assert user.weight_windows_file == ww_path + + # An explicit weight_windows_on=False rides along with the file and + # overrides the run-time file-implied enable + user = openmc.Settings(weight_windows_file=ww_path, + weight_windows_on=False) + gen = _capture_generation_settings( + monkeypatch, model, method='material_wise', settings=user) + assert gen.weight_windows_file == ww_path + assert gen.weight_windows_on is False + + # The surrogate-geometry methods ignore the file with a warning + 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_validation(run_in_tmpdir): + model = _steel_water_model() + + with pytest.raises(TypeError): + model.convert_to_multigroup(settings={'particles': 100}) + + with pytest.raises(ValueError): + model.convert_to_multigroup(method='not_a_method') + + # The deprecated arguments cannot be combined with settings + with pytest.raises(ValueError, match='deprecated'), \ + pytest.warns(FutureWarning): + model.convert_to_multigroup(nparticles=1000, + settings=openmc.Settings()) + with pytest.raises(ValueError, match='deprecated'), \ + pytest.warns(FutureWarning): + model.convert_to_multigroup( + temperature_settings={'method': 'interpolation'}, + settings=openmc.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): + # 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()) + + Path('mgxs.h5').touch() + model.convert_to_multigroup( + method='stochastic_slab', mgxs_path='mgxs.h5') + assert [m.id for m in model.materials] == ids diff --git a/tests/unit_tests/test_settings.py b/tests/unit_tests/test_settings.py index bdb3ea8fe9f..1bbde93c350 100644 --- a/tests/unit_tests/test_settings.py +++ b/tests/unit_tests/test_settings.py @@ -192,6 +192,35 @@ def test_export_to_xml(run_in_tmpdir): assert s.free_gas_threshold == 800.0 +def test_update(): + base = openmc.Settings(particles=100, batches=10, + output={'tallies': False}) + other = openmc.Settings(particles=500) + other.source = openmc.IndependentSource(space=openmc.stats.Point()) + + base.update(other) + + # Populated attributes of `other` overwrite those of `base`... + assert base.particles == 500 + assert len(base.source) == 1 + # ...while unset attributes leave `base` untouched + assert base.batches == 10 + assert base.output == {'tallies': False} + # Copied values are independent of `other` + assert base.source[0] is not other.source[0] + + # run_mode always carries a value, so only a non-default ('eigenvalue') + # run mode is detectable and copied + base.run_mode = 'fixed source' + base.update(openmc.Settings()) + assert base.run_mode == 'fixed source' + base.update(openmc.Settings(run_mode='volume')) + assert base.run_mode == 'volume' + + with pytest.raises(TypeError): + base.update({'particles': 5}) + + def test_properties_file_load(tmp_path, mpi_intracomm): model = openmc.examples.pwr_assembly() 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..8209bb85055 100644 --- a/tests/unit_tests/weightwindows/test_ww_gen.py +++ b/tests/unit_tests/weightwindows/test_ww_gen.py @@ -366,7 +366,7 @@ def test_ww_generation_with_dagmc(run_in_tmpdir): rr_model.convert_to_multigroup( method="stochastic_slab", overwrite_mgxs_library=True, - nparticles=10, + settings=openmc.Settings(particles=10), groups="CASMO-2" )