From 23f19a0310f462c81313d4e21f3cb08a3695e85f Mon Sep 17 00:00:00 2001 From: Micah Gale Date: Thu, 21 Mar 2024 14:22:35 -0500 Subject: [PATCH 01/48] Implemented contains for BoundingBox containing other BoundingBox (#2906) Co-authored-by: Jonathan Shimwell --- openmc/bounding_box.py | 20 +++++++++++--- tests/unit_tests/test_bounding_box.py | 38 ++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/openmc/bounding_box.py b/openmc/bounding_box.py index e5655cf8ca..6e58ca8ba0 100644 --- a/openmc/bounding_box.py +++ b/openmc/bounding_box.py @@ -95,9 +95,23 @@ class BoundingBox: new |= other return new - def __contains__(self, point): - """Check whether or not a point is in the bounding box""" - return all(point > self.lower_left) and all(point < self.upper_right) + def __contains__(self, other): + """Check whether or not a point or another bounding box is in the bounding box. + + For another bounding box to be in the parent it must lie fully inside of it. + """ + # test for a single point + if isinstance(other, (tuple, list, np.ndarray)): + point = other + check_length("Point", point, 3, 3) + return all(point > self.lower_left) and all(point < self.upper_right) + elif isinstance(other, BoundingBox): + return all([p in self for p in [other.lower_left, other.upper_right]]) + else: + raise TypeError( + f"Unable to determine if {other} is in the bounding box." + f" Expected a tuple or a bounding box, but {type(other)} given" + ) @property def center(self) -> np.ndarray: diff --git a/tests/unit_tests/test_bounding_box.py b/tests/unit_tests/test_bounding_box.py index 89e5042715..57c880092e 100644 --- a/tests/unit_tests/test_bounding_box.py +++ b/tests/unit_tests/test_bounding_box.py @@ -78,9 +78,9 @@ def test_bounding_box_input_checking(): def test_bounding_box_extents(): - assert test_bb_1.extent['xy'] == (-10., 1., -20., 2.) - assert test_bb_1.extent['xz'] == (-10., 1., -30., 3.) - assert test_bb_1.extent['yz'] == (-20., 2., -30., 3.) + assert test_bb_1.extent["xy"] == (-10.0, 1.0, -20.0, 2.0) + assert test_bb_1.extent["xz"] == (-10.0, 1.0, -30.0, 3.0) + assert test_bb_1.extent["yz"] == (-20.0, 2.0, -30.0, 3.0) def test_bounding_box_methods(): @@ -156,3 +156,35 @@ def test_bounding_box_methods(): assert all(test_bb[0] == [-50.1, -50.1, -12.1]) assert all(test_bb[1] == [50.1, 14.1, 50.1]) + + +@pytest.mark.parametrize( + "bb, other, expected", + [ + (test_bb_1, (0, 0, 0), True), + (test_bb_2, (3, 3, 3), False), + # completely disjoint + (test_bb_1, test_bb_2, False), + # contained but touching border + (test_bb_1, test_bb_3, False), + # Fully contained + (test_bb_1, openmc.BoundingBox((-9, -19, -29), (0, 0, 0)), True), + # intersecting boxes + (test_bb_1, openmc.BoundingBox((-9, -19, -29), (1, 2, 5)), False), + ], +) +def test_bounding_box_contains(bb, other, expected): + assert (other in bb) == expected + + +@pytest.mark.parametrize( + "invalid, ex", + [ + ((1, 0), ValueError), + ((1, 2, 3, 4), ValueError), + ("foo", TypeError), + ], +) +def test_bounding_box_contains_checking(invalid, ex): + with pytest.raises(ex): + invalid in test_bb_1 From ce7efa415e2b8fae049266f72c714d543575de96 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Sat, 23 Mar 2024 17:29:09 -0500 Subject: [PATCH 02/48] Fix Chain.form_matrix to work with scipy 1.12 (#2922) --- openmc/deplete/chain.py | 23 ++++++++++++----------- setup.py | 2 +- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/openmc/deplete/chain.py b/openmc/deplete/chain.py index 6071689554..e15b127d08 100644 --- a/openmc/deplete/chain.py +++ b/openmc/deplete/chain.py @@ -597,9 +597,12 @@ class Chain: -------- :meth:`get_default_fission_yields` """ - matrix = defaultdict(float) reactions = set() + # Use DOK matrix as intermediate representation for matrix + n = len(self) + matrix = sp.dok_matrix((n, n)) + if fission_yields is None: fission_yields = self.get_default_fission_yields() @@ -674,11 +677,8 @@ class Chain: # Clear set of reactions reactions.clear() - # Use DOK matrix as intermediate representation, then convert to CSC and return - n = len(self) - matrix_dok = sp.dok_matrix((n, n)) - dict.update(matrix_dok, matrix) - return matrix_dok.tocsc() + # Return CSC representation instead of DOK + return matrix.tocsc() def form_rr_term(self, tr_rates, mats): """Function to form the transfer rate term matrices. @@ -711,7 +711,9 @@ class Chain: Sparse matrix representing transfer term. """ - matrix = defaultdict(float) + # Use DOK as intermediate representation + n = len(self) + matrix = sp.dok_matrix((n, n)) for i, nuc in enumerate(self.nuclides): elm = re.split(r'\d+', nuc.name)[0] @@ -737,10 +739,9 @@ class Chain: else: matrix[i, i] = 0.0 #Nothing else is allowed - n = len(self) - matrix_dok = sp.dok_matrix((n, n)) - dict.update(matrix_dok, matrix) - return matrix_dok.tocsc() + + # Return CSC instead of DOK + return matrix.tocsc() def get_branch_ratios(self, reaction="(n,gamma)"): """Return a dictionary with reaction branching ratios diff --git a/setup.py b/setup.py index 84a2ad367e..eeb0b3f293 100755 --- a/setup.py +++ b/setup.py @@ -64,7 +64,7 @@ kwargs = { # Dependencies 'python_requires': '>=3.7', 'install_requires': [ - 'numpy>=1.9', 'h5py', 'scipy<1.12', 'ipython', 'matplotlib', + 'numpy>=1.9', 'h5py', 'scipy', 'ipython', 'matplotlib', 'pandas', 'lxml', 'uncertainties' ], 'extras_require': { From 9fee6534b6e873d24a4df7fe42d0bf5a988e207a Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Tue, 26 Mar 2024 11:00:06 -0500 Subject: [PATCH 03/48] Prepare for NumPy 2.0 (#2845) --- openmc/data/correlated.py | 2 +- openmc/data/energy_distribution.py | 14 +++---- openmc/data/function.py | 6 +-- openmc/data/kalbach_mann.py | 2 +- openmc/data/multipole.py | 2 +- openmc/data/nbody.py | 2 +- openmc/data/neutron.py | 2 +- openmc/data/photon.py | 2 +- openmc/data/product.py | 4 +- openmc/data/reaction.py | 4 +- openmc/data/resonance_covariance.py | 7 ++-- openmc/data/thermal.py | 6 +-- openmc/data/thermal_angle_energy.py | 10 ++--- openmc/data/uncorrelated.py | 2 +- openmc/deplete/stepresult.py | 2 +- openmc/mgxs_library.py | 8 ++-- openmc/plots.py | 4 +- openmc/source.py | 2 +- openmc/stats/univariate.py | 44 +++++++++++---------- tests/unit_tests/test_deplete_integrator.py | 16 ++++---- tests/unit_tests/test_source_file.py | 2 +- tests/unit_tests/test_stats.py | 3 +- 22 files changed, 74 insertions(+), 72 deletions(-) diff --git a/openmc/data/correlated.py b/openmc/data/correlated.py index f131ce30d5..2ff095a5c4 100644 --- a/openmc/data/correlated.py +++ b/openmc/data/correlated.py @@ -113,7 +113,7 @@ class CorrelatedAngleEnergy(AngleEnergy): HDF5 group to write to """ - group.attrs['type'] = np.string_(self._name) + group.attrs['type'] = np.bytes_(self._name) dset = group.create_dataset('energy', data=self.energy) dset.attrs['interpolation'] = np.vstack((self.breakpoints, diff --git a/openmc/data/energy_distribution.py b/openmc/data/energy_distribution.py index a13893a68f..b3566e9990 100644 --- a/openmc/data/energy_distribution.py +++ b/openmc/data/energy_distribution.py @@ -277,7 +277,7 @@ class MaxwellEnergy(EnergyDistribution): """ - group.attrs['type'] = np.string_('maxwell') + group.attrs['type'] = np.bytes_('maxwell') group.attrs['u'] = self.u self.theta.to_hdf5(group, 'theta') @@ -410,7 +410,7 @@ class Evaporation(EnergyDistribution): """ - group.attrs['type'] = np.string_('evaporation') + group.attrs['type'] = np.bytes_('evaporation') group.attrs['u'] = self.u self.theta.to_hdf5(group, 'theta') @@ -556,7 +556,7 @@ class WattEnergy(EnergyDistribution): """ - group.attrs['type'] = np.string_('watt') + group.attrs['type'] = np.bytes_('watt') group.attrs['u'] = self.u self.a.to_hdf5(group, 'a') self.b.to_hdf5(group, 'b') @@ -728,7 +728,7 @@ class MadlandNix(EnergyDistribution): """ - group.attrs['type'] = np.string_('madland-nix') + group.attrs['type'] = np.bytes_('madland-nix') group.attrs['efl'] = self.efl group.attrs['efh'] = self.efh self.tm.to_hdf5(group) @@ -846,7 +846,7 @@ class DiscretePhoton(EnergyDistribution): """ - group.attrs['type'] = np.string_('discrete_photon') + group.attrs['type'] = np.bytes_('discrete_photon') group.attrs['primary_flag'] = self.primary_flag group.attrs['energy'] = self.energy group.attrs['atomic_weight_ratio'] = self.atomic_weight_ratio @@ -945,7 +945,7 @@ class LevelInelastic(EnergyDistribution): """ - group.attrs['type'] = np.string_('level') + group.attrs['type'] = np.bytes_('level') group.attrs['threshold'] = self.threshold group.attrs['mass_ratio'] = self.mass_ratio @@ -1074,7 +1074,7 @@ class ContinuousTabular(EnergyDistribution): """ - group.attrs['type'] = np.string_('continuous') + group.attrs['type'] = np.bytes_('continuous') dset = group.create_dataset('energy', data=self.energy) dset.attrs['interpolation'] = np.vstack((self.breakpoints, diff --git a/openmc/data/function.py b/openmc/data/function.py index 299924b37c..23fd5e9d4f 100644 --- a/openmc/data/function.py +++ b/openmc/data/function.py @@ -364,7 +364,7 @@ class Tabulated1D(Function1D): """ dataset = group.create_dataset(name, data=np.vstack( [self.x, self.y])) - dataset.attrs['type'] = np.string_(type(self).__name__) + dataset.attrs['type'] = np.bytes_(type(self).__name__) dataset.attrs['breakpoints'] = self.breakpoints dataset.attrs['interpolation'] = self.interpolation @@ -460,7 +460,7 @@ class Polynomial(np.polynomial.Polynomial, Function1D): """ dataset = group.create_dataset(name, data=self.coef) - dataset.attrs['type'] = np.string_(type(self).__name__) + dataset.attrs['type'] = np.bytes_(type(self).__name__) @classmethod def from_hdf5(cls, dataset): @@ -592,7 +592,7 @@ class Sum(Function1D): """ sum_group = group.create_group(name) - sum_group.attrs['type'] = np.string_(type(self).__name__) + sum_group.attrs['type'] = np.bytes_(type(self).__name__) sum_group.attrs['n'] = len(self.functions) for i, f in enumerate(self.functions): f.to_hdf5(sum_group, f'func_{i+1}') diff --git a/openmc/data/kalbach_mann.py b/openmc/data/kalbach_mann.py index b49399139d..d92bf9c213 100644 --- a/openmc/data/kalbach_mann.py +++ b/openmc/data/kalbach_mann.py @@ -366,7 +366,7 @@ class KalbachMann(AngleEnergy): HDF5 group to write to """ - group.attrs['type'] = np.string_('kalbach-mann') + group.attrs['type'] = np.bytes_('kalbach-mann') dset = group.create_dataset('energy', data=self.energy) dset.attrs['interpolation'] = np.vstack((self.breakpoints, diff --git a/openmc/data/multipole.py b/openmc/data/multipole.py index 9fd6c9a9be..d45d2beb6f 100644 --- a/openmc/data/multipole.py +++ b/openmc/data/multipole.py @@ -1273,7 +1273,7 @@ class WindowedMultipole(EqualityMixin): # Open file and write version. with h5py.File(str(path), mode, libver=libver) as f: - f.attrs['filetype'] = np.string_('data_wmp') + f.attrs['filetype'] = np.bytes_('data_wmp') f.attrs['version'] = np.array(WMP_VERSION) g = f.create_group(self.name) diff --git a/openmc/data/nbody.py b/openmc/data/nbody.py index 1f2ff5b4d1..ec1ac25c0f 100644 --- a/openmc/data/nbody.py +++ b/openmc/data/nbody.py @@ -91,7 +91,7 @@ class NBodyPhaseSpace(AngleEnergy): HDF5 group to write to """ - group.attrs['type'] = np.string_('nbody') + group.attrs['type'] = np.bytes_('nbody') group.attrs['total_mass'] = self.total_mass group.attrs['n_particles'] = self.n_particles group.attrs['atomic_weight_ratio'] = self.atomic_weight_ratio diff --git a/openmc/data/neutron.py b/openmc/data/neutron.py index e0c0032f07..94833d4b34 100644 --- a/openmc/data/neutron.py +++ b/openmc/data/neutron.py @@ -425,7 +425,7 @@ class IncidentNeutron(EqualityMixin): # Open file and write version with h5py.File(str(path), mode, libver=libver) as f: - f.attrs['filetype'] = np.string_('data_neutron') + f.attrs['filetype'] = np.bytes_('data_neutron') f.attrs['version'] = np.array(HDF5_VERSION) # Write basic data diff --git a/openmc/data/photon.py b/openmc/data/photon.py index 7c96e9fac0..ba4bf5c88b 100644 --- a/openmc/data/photon.py +++ b/openmc/data/photon.py @@ -764,7 +764,7 @@ class IncidentPhoton(EqualityMixin): """ with h5py.File(str(path), mode, libver=libver) as f: # Write filetype and version - f.attrs['filetype'] = np.string_('data_photon') + f.attrs['filetype'] = np.bytes_('data_photon') if 'version' not in f.attrs: f.attrs['version'] = np.array(HDF5_VERSION) diff --git a/openmc/data/product.py b/openmc/data/product.py index 93697a9e7b..4a9b07aee2 100644 --- a/openmc/data/product.py +++ b/openmc/data/product.py @@ -124,8 +124,8 @@ class Product(EqualityMixin): HDF5 group to write to """ - group.attrs['particle'] = np.string_(self.particle) - group.attrs['emission_mode'] = np.string_(self.emission_mode) + group.attrs['particle'] = np.bytes_(self.particle) + group.attrs['emission_mode'] = np.bytes_(self.emission_mode) if self.decay_rate > 0.0: group.attrs['decay_rate'] = self.decay_rate diff --git a/openmc/data/reaction.py b/openmc/data/reaction.py index 04a68d7399..8ba4787826 100644 --- a/openmc/data/reaction.py +++ b/openmc/data/reaction.py @@ -920,9 +920,9 @@ class Reaction(EqualityMixin): group.attrs['mt'] = self.mt if self.mt in REACTION_NAME: - group.attrs['label'] = np.string_(REACTION_NAME[self.mt]) + group.attrs['label'] = np.bytes_(REACTION_NAME[self.mt]) else: - group.attrs['label'] = np.string_(self.mt) + group.attrs['label'] = np.bytes_(self.mt) group.attrs['Q_value'] = self.q_value group.attrs['center_of_mass'] = 1 if self.center_of_mass else 0 group.attrs['redundant'] = 1 if self.redundant else 0 diff --git a/openmc/data/resonance_covariance.py b/openmc/data/resonance_covariance.py index 9ba429cb48..7096570449 100644 --- a/openmc/data/resonance_covariance.py +++ b/openmc/data/resonance_covariance.py @@ -239,14 +239,14 @@ class ResonanceCovarianceRange: samples = [] # Handling MLBW/SLBW sampling + rng = np.random.default_rng() if formalism == 'mlbw' or formalism == 'slbw': params = ['energy', 'neutronWidth', 'captureWidth', 'fissionWidth', 'competitiveWidth'] param_list = params[:mpar] mean_array = parameters[param_list].values mean = mean_array.flatten() - par_samples = np.random.multivariate_normal(mean, cov, - size=n_samples) + par_samples = rng.multivariate_normal(mean, cov, size=n_samples) spin = parameters['J'].values l_value = parameters['L'].values for sample in par_samples: @@ -277,8 +277,7 @@ class ResonanceCovarianceRange: param_list = params[:mpar] mean_array = parameters[param_list].values mean = mean_array.flatten() - par_samples = np.random.multivariate_normal(mean, cov, - size=n_samples) + par_samples = rng.multivariate_normal(mean, cov, size=n_samples) spin = parameters['J'].values l_value = parameters['L'].values for sample in par_samples: diff --git a/openmc/data/thermal.py b/openmc/data/thermal.py index 994a8ed2ae..f8dd43ed9a 100644 --- a/openmc/data/thermal.py +++ b/openmc/data/thermal.py @@ -221,7 +221,7 @@ class CoherentElastic(Function1D): """ dataset = group.create_dataset(name, data=np.vstack( [self.bragg_edges, self.factors])) - dataset.attrs['type'] = np.string_(type(self).__name__) + dataset.attrs['type'] = np.bytes_(type(self).__name__) @classmethod def from_hdf5(cls, dataset): @@ -294,7 +294,7 @@ class IncoherentElastic(Function1D): """ data = np.array([self.bound_xs, self.debye_waller]) dataset = group.create_dataset(name, data=data) - dataset.attrs['type'] = np.string_(type(self).__name__) + dataset.attrs['type'] = np.bytes_(type(self).__name__) @classmethod def from_hdf5(cls, dataset): @@ -464,7 +464,7 @@ class ThermalScattering(EqualityMixin): """ # Open file and write version with h5py.File(str(path), mode, libver=libver) as f: - f.attrs['filetype'] = np.string_('data_thermal') + f.attrs['filetype'] = np.bytes_('data_thermal') f.attrs['version'] = np.array(HDF5_VERSION) # Write basic data diff --git a/openmc/data/thermal_angle_energy.py b/openmc/data/thermal_angle_energy.py index 17a560092d..0dd2a0b7a5 100644 --- a/openmc/data/thermal_angle_energy.py +++ b/openmc/data/thermal_angle_energy.py @@ -43,7 +43,7 @@ class CoherentElasticAE(AngleEnergy): HDF5 group to write to """ - group.attrs['type'] = np.string_('coherent_elastic') + group.attrs['type'] = np.bytes_('coherent_elastic') self.coherent_xs.to_hdf5(group, 'coherent_xs') @classmethod @@ -104,7 +104,7 @@ class IncoherentElasticAE(AngleEnergy): HDF5 group to write to """ - group.attrs['type'] = np.string_('incoherent_elastic') + group.attrs['type'] = np.bytes_('incoherent_elastic') group.create_dataset('debye_waller', data=self.debye_waller) @classmethod @@ -146,7 +146,7 @@ class IncoherentElasticAEDiscrete(AngleEnergy): HDF5 group to write to """ - group.attrs['type'] = np.string_('incoherent_elastic_discrete') + group.attrs['type'] = np.bytes_('incoherent_elastic_discrete') group.create_dataset('mu_out', data=self.mu_out) @classmethod @@ -203,7 +203,7 @@ class IncoherentInelasticAEDiscrete(AngleEnergy): HDF5 group to write to """ - group.attrs['type'] = np.string_('incoherent_inelastic_discrete') + group.attrs['type'] = np.bytes_('incoherent_inelastic_discrete') group.create_dataset('energy_out', data=self.energy_out) group.create_dataset('mu_out', data=self.mu_out) group.create_dataset('skewed', data=self.skewed) @@ -266,7 +266,7 @@ class MixedElasticAE(AngleEnergy): HDF5 group to write to """ - group.attrs['type'] = np.string_('mixed_elastic') + group.attrs['type'] = np.bytes_('mixed_elastic') coherent_group = group.create_group('coherent') self.coherent.to_hdf5(coherent_group) incoherent_group = group.create_group('incoherent') diff --git a/openmc/data/uncorrelated.py b/openmc/data/uncorrelated.py index 9dcb18bf61..141007b70a 100644 --- a/openmc/data/uncorrelated.py +++ b/openmc/data/uncorrelated.py @@ -63,7 +63,7 @@ class UncorrelatedAngleEnergy(AngleEnergy): HDF5 group to write to """ - group.attrs['type'] = np.string_('uncorrelated') + group.attrs['type'] = np.bytes_('uncorrelated') if self.angle is not None: angle_group = group.create_group('angle') self.angle.to_hdf5(angle_group) diff --git a/openmc/deplete/stepresult.py b/openmc/deplete/stepresult.py index 372f7cf25d..9cf33898f3 100644 --- a/openmc/deplete/stepresult.py +++ b/openmc/deplete/stepresult.py @@ -291,7 +291,7 @@ class StepResult: # Store concentration mat and nuclide dictionaries (along with volumes) handle.attrs['version'] = np.array(VERSION_RESULTS) - handle.attrs['filetype'] = np.string_('depletion results') + handle.attrs['filetype'] = np.bytes_('depletion results') mat_list = sorted(self.mat_to_hdf5_ind, key=int) nuc_list = sorted(self.index_nuc) diff --git a/openmc/mgxs_library.py b/openmc/mgxs_library.py index 85e15f8ebc..642da83d17 100644 --- a/openmc/mgxs_library.py +++ b/openmc/mgxs_library.py @@ -1964,7 +1964,7 @@ class XSdata: grp.attrs['fissionable'] = self.fissionable if self.representation is not None: - grp.attrs['representation'] = np.string_(self.representation) + grp.attrs['representation'] = np.bytes_(self.representation) if self.representation == REPRESENTATION_ANGLE: if self.num_azimuthal is not None: grp.attrs['num_azimuthal'] = self.num_azimuthal @@ -1972,9 +1972,9 @@ class XSdata: if self.num_polar is not None: grp.attrs['num_polar'] = self.num_polar - grp.attrs['scatter_shape'] = np.string_("[G][G'][Order]") + grp.attrs['scatter_shape'] = np.bytes_("[G][G'][Order]") if self.scatter_format is not None: - grp.attrs['scatter_format'] = np.string_(self.scatter_format) + grp.attrs['scatter_format'] = np.bytes_(self.scatter_format) if self.order is not None: grp.attrs['order'] = self.order @@ -2516,7 +2516,7 @@ class MGXSLibrary: # Create and write to the HDF5 file file = h5py.File(filename, "w", libver=libver) - file.attrs['filetype'] = np.string_(_FILETYPE_MGXS_LIBRARY) + file.attrs['filetype'] = np.bytes_(_FILETYPE_MGXS_LIBRARY) file.attrs['version'] = [_VERSION_MGXS_LIBRARY, 0] file.attrs['energy_groups'] = self.energy_groups.num_groups file.attrs['delayed_groups'] = self.num_delayed_groups diff --git a/openmc/plots.py b/openmc/plots.py index c73e3cdf73..5552b18a85 100644 --- a/openmc/plots.py +++ b/openmc/plots.py @@ -465,11 +465,11 @@ class PlotBase(IDManagerMixin): domains = geometry.get_all_cells().values() # Set the seed for the random number generator - np.random.seed(seed) + rng = np.random.RandomState(seed) # Generate random colors for each feature for domain in domains: - self.colors[domain] = np.random.randint(0, 256, (3,)) + self.colors[domain] = rng.randint(0, 256, (3,)) def to_xml_element(self): """Save common plot attributes to XML element diff --git a/openmc/source.py b/openmc/source.py index 441bf4e76b..5942a3107c 100644 --- a/openmc/source.py +++ b/openmc/source.py @@ -883,7 +883,7 @@ def write_source_file( # Write array to file kwargs.setdefault('mode', 'w') with h5py.File(filename, **kwargs) as fh: - fh.attrs['filetype'] = np.string_("source") + fh.attrs['filetype'] = np.bytes_("source") fh.create_dataset('source_bank', data=arr, dtype=source_dtype) diff --git a/openmc/stats/univariate.py b/openmc/stats/univariate.py index e7ff64257a..3d5f647ace 100644 --- a/openmc/stats/univariate.py +++ b/openmc/stats/univariate.py @@ -10,6 +10,7 @@ from warnings import warn import lxml.etree as ET import numpy as np +from scipy.integrate import trapezoid import openmc.checkvalue as cv from .._xml import get_text @@ -155,9 +156,9 @@ class Discrete(Univariate): return np.insert(np.cumsum(self.p), 0, 0.0) def sample(self, n_samples=1, seed=None): - np.random.seed(seed) + rng = np.random.RandomState(seed) p = self.p / self.p.sum() - return np.random.choice(self.x, n_samples, p=p) + return rng.choice(self.x, n_samples, p=p) def normalize(self): """Normalize the probabilities stored on the distribution""" @@ -363,8 +364,8 @@ class Uniform(Univariate): return t def sample(self, n_samples=1, seed=None): - np.random.seed(seed) - return np.random.uniform(self.a, self.b, n_samples) + rng = np.random.RandomState(seed) + return rng.uniform(self.a, self.b, n_samples) def to_xml_element(self, element_name: str): """Return XML representation of the uniform distribution @@ -468,8 +469,8 @@ class PowerLaw(Univariate): self._n = n def sample(self, n_samples=1, seed=None): - np.random.seed(seed) - xi = np.random.rand(n_samples) + rng = np.random.RandomState(seed) + xi = rng.random(n_samples) pwr = self.n + 1 offset = self.a**pwr span = self.b**pwr - offset @@ -549,12 +550,14 @@ class Maxwell(Univariate): self._theta = theta def sample(self, n_samples=1, seed=None): - np.random.seed(seed) - return self.sample_maxwell(self.theta, n_samples) + rng = np.random.RandomState(seed) + return self.sample_maxwell(self.theta, n_samples, rng=rng) @staticmethod - def sample_maxwell(t, n_samples: int): - r1, r2, r3 = np.random.rand(3, n_samples) + def sample_maxwell(t, n_samples: int, rng=None): + if rng is None: + rng = np.random.default_rng() + r1, r2, r3 = rng.random((3, n_samples)) c = np.cos(0.5 * np.pi * r3) return -t * (np.log(r1) + np.log(r2) * c * c) @@ -647,9 +650,9 @@ class Watt(Univariate): self._b = b def sample(self, n_samples=1, seed=None): - np.random.seed(seed) - w = Maxwell.sample_maxwell(self.a, n_samples) - u = np.random.uniform(-1., 1., n_samples) + rng = np.random.RandomState(seed) + w = Maxwell.sample_maxwell(self.a, n_samples, rng=rng) + u = rng.uniform(-1., 1., n_samples) aab = self.a * self.a * self.b return w + 0.25*aab + u*np.sqrt(aab*w) @@ -740,8 +743,8 @@ class Normal(Univariate): self._std_dev = std_dev def sample(self, n_samples=1, seed=None): - np.random.seed(seed) - return np.random.normal(self.mean_value, self.std_dev, n_samples) + rng = np.random.RandomState(seed) + return rng.normal(self.mean_value, self.std_dev, n_samples) def to_xml_element(self, element_name: str): """Return XML representation of the Normal distribution @@ -952,8 +955,8 @@ class Tabular(Univariate): self.p /= self.cdf().max() def sample(self, n_samples: int = 1, seed: typing.Optional[int] = None): - np.random.seed(seed) - xi = np.random.rand(n_samples) + rng = np.random.RandomState(seed) + xi = rng.random(n_samples) # always use normalized probabilities when sampling cdf = self.cdf() @@ -1069,7 +1072,7 @@ class Tabular(Univariate): if self.interpolation == 'histogram': return np.sum(np.diff(self.x) * self.p[:-1]) elif self.interpolation == 'linear-linear': - return np.trapz(self.p, self.x) + return trapezoid(self.p, self.x) else: raise NotImplementedError( f'integral() not supported for {self.inteprolation} interpolation') @@ -1185,7 +1188,7 @@ class Mixture(Univariate): return np.insert(np.cumsum(self.probability), 0, 0.0) def sample(self, n_samples=1, seed=None): - np.random.seed(seed) + rng = np.random.RandomState(seed) # Get probability of each distribution accounting for its intensity p = np.array([prob*dist.integral() for prob, dist in @@ -1193,8 +1196,7 @@ class Mixture(Univariate): p /= p.sum() # Sample from the distributions - idx = np.random.choice(range(len(self.distribution)), - n_samples, p=p) + idx = rng.choice(range(len(self.distribution)), n_samples, p=p) # Draw samples from the distributions sampled above out = np.empty_like(idx, dtype=float) diff --git a/tests/unit_tests/test_deplete_integrator.py b/tests/unit_tests/test_deplete_integrator.py index ba625a9998..b1d2cb950e 100644 --- a/tests/unit_tests/test_deplete_integrator.py +++ b/tests/unit_tests/test_deplete_integrator.py @@ -40,7 +40,7 @@ def test_results_save(run_in_tmpdir): stages = 3 - np.random.seed(comm.rank) + rng = np.random.RandomState(comm.rank) # Mock geometry op = MagicMock() @@ -68,26 +68,26 @@ def test_results_save(run_in_tmpdir): x2 = [] for i in range(stages): - x1.append([np.random.rand(2), np.random.rand(2)]) - x2.append([np.random.rand(2), np.random.rand(2)]) + x1.append([rng.random(2), rng.random(2)]) + x2.append([rng.random(2), rng.random(2)]) # Construct r r1 = ReactionRates(burn_list, ["na", "nb"], ["ra", "rb"]) - r1[:] = np.random.rand(2, 2, 2) + r1[:] = rng.random((2, 2, 2)) rate1 = [] rate2 = [] for i in range(stages): rate1.append(copy.deepcopy(r1)) - r1[:] = np.random.rand(2, 2, 2) + r1[:] = rng.random((2, 2, 2)) rate2.append(copy.deepcopy(r1)) - r1[:] = np.random.rand(2, 2, 2) + r1[:] = rng.random((2, 2, 2)) # Create global terms # Col 0: eig, Col 1: uncertainty - eigvl1 = np.random.rand(stages, 2) - eigvl2 = np.random.rand(stages, 2) + eigvl1 = rng.random((stages, 2)) + eigvl2 = rng.random((stages, 2)) eigvl1 = comm.bcast(eigvl1, root=0) eigvl2 = comm.bcast(eigvl2, root=0) diff --git a/tests/unit_tests/test_source_file.py b/tests/unit_tests/test_source_file.py index d9fb3c1f90..1b5549b008 100644 --- a/tests/unit_tests/test_source_file.py +++ b/tests/unit_tests/test_source_file.py @@ -78,7 +78,7 @@ def test_wrong_source_attributes(run_in_tmpdir): ]) arr = np.array([(1.0, 2.0, 3), (4.0, 5.0, 6), (7.0, 8.0, 9)], dtype=source_dtype) with h5py.File('animal_source.h5', 'w') as fh: - fh.attrs['filetype'] = np.string_("source") + fh.attrs['filetype'] = np.bytes_("source") fh.create_dataset('source_bank', data=arr) # Create a simple model that uses this lovely animal source diff --git a/tests/unit_tests/test_stats.py b/tests/unit_tests/test_stats.py index f5368e912f..761f26ab3d 100644 --- a/tests/unit_tests/test_stats.py +++ b/tests/unit_tests/test_stats.py @@ -4,6 +4,7 @@ import numpy as np import pytest import openmc import openmc.stats +from scipy.integrate import trapezoid def assert_sample_mean(samples, expected_mean): @@ -226,7 +227,7 @@ def test_legendre(): # Integrating distribution should yield one mu = np.linspace(-1., 1., 1000) - assert np.trapz(d(mu), mu) == pytest.approx(1.0, rel=1e-4) + assert trapezoid(d(mu), mu) == pytest.approx(1.0, rel=1e-4) with pytest.raises(NotImplementedError): d.to_xml_element('distribution') From 1a34ddf121cf3ec4929ba7cef160c96a3896dbd9 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Thu, 4 Apr 2024 02:54:13 +0200 Subject: [PATCH 04/48] Fix CMFD to work with scipy 1.13 (#2936) --- openmc/cmfd.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/openmc/cmfd.py b/openmc/cmfd.py index 1b4bfa5e27..9b0b4ca83c 100644 --- a/openmc/cmfd.py +++ b/openmc/cmfd.py @@ -982,14 +982,16 @@ class CMFDRun: temp_data = np.ones(len(loss_row)) temp_loss = sparse.csr_matrix((temp_data, (loss_row, loss_col)), shape=(n, n)) + temp_loss.sort_indices() # Pass coremap as 1-d array of 32-bit integers coremap = np.swapaxes(self._coremap, 0, 2).flatten().astype(np.int32) - args = temp_loss.indptr, len(temp_loss.indptr), \ - temp_loss.indices, len(temp_loss.indices), n, \ + return openmc.lib._dll.openmc_initialize_linsolver( + temp_loss.indptr.astype(np.int32), len(temp_loss.indptr), + temp_loss.indices.astype(np.int32), len(temp_loss.indices), n, self._spectral, coremap, self._use_all_threads - return openmc.lib._dll.openmc_initialize_linsolver(*args) + ) def _write_cmfd_output(self): """Write CMFD output to buffer at the end of each batch""" @@ -1585,6 +1587,7 @@ class CMFDRun: loss_row = self._loss_row loss_col = self._loss_col loss = sparse.csr_matrix((data, (loss_row, loss_col)), shape=(n, n)) + loss.sort_indices() return loss def _build_prod_matrix(self, adjoint): @@ -1611,6 +1614,7 @@ class CMFDRun: prod_row = self._prod_row prod_col = self._prod_col prod = sparse.csr_matrix((data, (prod_row, prod_col)), shape=(n, n)) + prod.sort_indices() return prod def _execute_power_iter(self, loss, prod): @@ -2307,8 +2311,8 @@ class CMFDRun: constant_values=_CMFD_NOACCEL)[:,:,1:] # Create empty row and column vectors to store for loss matrix - row = np.array([]) - col = np.array([]) + row = np.array([], dtype=int) + col = np.array([], dtype=int) # Store all indices used to populate production and loss matrix is_accel = self._coremap != _CMFD_NOACCEL From cc848effe77c90decdcb453679f0f5ee18e872f7 Mon Sep 17 00:00:00 2001 From: Joffrey Dorville <54550047+JoffreyDorville@users.noreply.github.com> Date: Thu, 4 Apr 2024 16:32:21 -0500 Subject: [PATCH 05/48] Meshborn filter (#2925) Co-authored-by: Paul Romano --- CMakeLists.txt | 1 + docs/source/capi/index.rst | 102 ++++++++++++ docs/source/pythonapi/base.rst | 1 + docs/source/pythonapi/capi.rst | 1 + include/openmc/particle_data.h | 5 + include/openmc/tallies/filter.h | 1 + include/openmc/tallies/filter_meshborn.h | 26 +++ openmc/filter.py | 28 ++++ openmc/lib/filter.py | 151 ++++++++++++++++-- src/particle.cpp | 1 + src/tallies/filter.cpp | 3 + src/tallies/filter_mesh.cpp | 2 + src/tallies/filter_meshborn.cpp | 61 +++++++ src/tallies/filter_meshsurface.cpp | 12 ++ src/tallies/tally.cpp | 1 + .../filter_meshborn/__init__.py | 0 .../filter_meshborn/inputs_true.dat | 51 ++++++ .../filter_meshborn/results_true.dat | 54 +++++++ .../regression_tests/filter_meshborn/test.py | 107 +++++++++++++ tests/unit_tests/test_filter_meshborn.py | 116 ++++++++++++++ tests/unit_tests/test_tallies.py | 7 +- 21 files changed, 720 insertions(+), 11 deletions(-) create mode 100644 include/openmc/tallies/filter_meshborn.h create mode 100644 src/tallies/filter_meshborn.cpp create mode 100644 tests/regression_tests/filter_meshborn/__init__.py create mode 100644 tests/regression_tests/filter_meshborn/inputs_true.dat create mode 100644 tests/regression_tests/filter_meshborn/results_true.dat create mode 100644 tests/regression_tests/filter_meshborn/test.py create mode 100644 tests/unit_tests/test_filter_meshborn.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 1280daf2b9..6922e6b89a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -411,6 +411,7 @@ list(APPEND libopenmc_SOURCES src/tallies/filter_material.cpp src/tallies/filter_materialfrom.cpp src/tallies/filter_mesh.cpp + src/tallies/filter_meshborn.cpp src/tallies/filter_meshsurface.cpp src/tallies/filter_mu.cpp src/tallies/filter_particle.cpp diff --git a/docs/source/capi/index.rst b/docs/source/capi/index.rst index 52c0b2406a..d9ac0d1e01 100644 --- a/docs/source/capi/index.rst +++ b/docs/source/capi/index.rst @@ -420,6 +420,16 @@ Functions :return: Return status (negative if an error occurred) :rtype: int +.. c:function:: int openmc_mesh_filter_get_mesh(int32_t index, int32_t* index_mesh) + + Get the mesh for a mesh filter + + :param int32_t index: Index in the filters array + :param index_mesh: Index in the meshes array + :type index_mesh: int32_t* + :return: Return status (negative if an error occurred) + :rtype: int + .. c:function:: int openmc_mesh_filter_set_mesh(int32_t index, int32_t index_mesh) Set the mesh for a mesh filter @@ -429,6 +439,98 @@ Functions :return: Return status (negative if an error occurred) :rtype: int +.. c:function:: int openmc_mesh_filter_get_translation(int32_t index, double translation[3]) + + Get the 3-D translation coordinates for a mesh filter + + :param int32_t index: Index in the filters array + :param double[3] translation: 3-D translation coordinates + :return: Return status (negative if an error occurred) + :rtype: int + +.. c:function:: int openmc_mesh_filter_set_translation(int32_t index, double translation[3]) + + Set the 3-D translation coordinates for a mesh filter + + :param int32_t index: Index in the filters array + :param double[3] translation: 3-D translation coordinates + :return: Return status (negative if an error occurred) + :rtype: int + +.. c:function:: int openmc_meshborn_filter_get_mesh(int32_t index, int32_t* index_mesh) + + Get the mesh for a meshborn filter + + :param int32_t index: Index in the filters array + :param index_mesh: Index in the meshes array + :type index_mesh: int32_t* + :return: Return status (negative if an error occurred) + :rtype: int + +.. c:function:: int openmc_meshborn_filter_set_mesh(int32_t index, int32_t index_mesh) + + Set the mesh for a meshborn filter + + :param int32_t index: Index in the filters array + :param int32_t index_mesh: Index in the meshes array + :return: Return status (negative if an error occurred) + :rtype: int + +.. c:function:: int openmc_meshborn_filter_get_translation(int32_t index, double translation[3]) + + Get the 3-D translation coordinates for a meshborn filter + + :param int32_t index: Index in the filters array + :param double[3] translation: 3-D translation coordinates + :return: Return status (negative if an error occurred) + :rtype: int + +.. c:function:: int openmc_meshborn_filter_set_translation(int32_t index, double translation[3]) + + Set the 3-D translation coordinates for a meshborn filter + + :param int32_t index: Index in the filters array + :param double[3] translation: 3-D translation coordinates + :return: Return status (negative if an error occurred) + :rtype: int + +.. c:function:: int openmc_meshsurface_filter_get_mesh(int32_t index, int32_t* index_mesh) + + Get the mesh for a mesh surface filter + + :param int32_t index: Index in the filters array + :param index_mesh: Index in the meshes array + :type index_mesh: int32_t* + :return: Return status (negative if an error occurred) + :rtype: int + +.. c:function:: int openmc_meshsurface_filter_set_mesh(int32_t index, int32_t index_mesh) + + Set the mesh for a mesh surface filter + + :param int32_t index: Index in the filters array + :param int32_t index_mesh: Index in the meshes array + :return: Return status (negative if an error occurred) + :rtype: int + +.. c:function:: int openmc_meshsurface_filter_get_translation(int32_t index, double translation[3]) + + Get the 3-D translation coordinates for a mesh surface filter + + :param int32_t index: Index in the filters array + :param double[3] translation: 3-D translation coordinates + :return: Return status (negative if an error occurred) + :rtype: int + +.. c:function:: int openmc_meshsurface_filter_set_translation(int32_t index, double translation[3]) + + Set the 3-D translation coordinates for a mesh surface filter + + :param int32_t index: Index in the filters array + :param double[3] translation: 3-D translation coordinates + :return: Return status (negative if an error occurred) + :rtype: int + .. c:function:: int openmc_next_batch() Simulate next batch of particles. Must be called after openmc_simulation_init(). diff --git a/docs/source/pythonapi/base.rst b/docs/source/pythonapi/base.rst index 7b924e3749..5ae9f20edf 100644 --- a/docs/source/pythonapi/base.rst +++ b/docs/source/pythonapi/base.rst @@ -128,6 +128,7 @@ Constructing Tallies openmc.CollisionFilter openmc.SurfaceFilter openmc.MeshFilter + openmc.MeshBornFilter openmc.MeshSurfaceFilter openmc.EnergyFilter openmc.EnergyoutFilter diff --git a/docs/source/pythonapi/capi.rst b/docs/source/pythonapi/capi.rst index ef713801cc..16bb68ca43 100644 --- a/docs/source/pythonapi/capi.rst +++ b/docs/source/pythonapi/capi.rst @@ -59,6 +59,7 @@ Classes MaterialFilter Material MeshFilter + MeshBornFilter MeshSurfaceFilter Nuclide RectilinearMesh diff --git a/include/openmc/particle_data.h b/include/openmc/particle_data.h index cb68fc4574..cceab1d110 100644 --- a/include/openmc/particle_data.h +++ b/include/openmc/particle_data.h @@ -262,6 +262,10 @@ public: int& cell_last(int i) { return cell_last_[i]; } const int& cell_last(int i) const { return cell_last_[i]; } + // Coordinates at birth + Position& r_born() { return r_born_; } + const Position& r_born() const { return r_born_; } + // Coordinates of last collision or reflective/periodic surface // crossing for current tallies Position& r_last_current() { return r_last_current_; } @@ -323,6 +327,7 @@ private: int n_coord_last_ {1}; //!< number of current coordinates vector cell_last_; //!< coordinates for all levels + Position r_born_; //!< coordinates at birth Position r_last_current_; //!< coordinates of the last collision or //!< reflective/periodic surface crossing for //!< current tallies diff --git a/include/openmc/tallies/filter.h b/include/openmc/tallies/filter.h index dc5872ce67..1166c0eee5 100644 --- a/include/openmc/tallies/filter.h +++ b/include/openmc/tallies/filter.h @@ -33,6 +33,7 @@ enum class FilterType { MATERIAL, MATERIALFROM, MESH, + MESHBORN, MESH_SURFACE, MU, PARTICLE, diff --git a/include/openmc/tallies/filter_meshborn.h b/include/openmc/tallies/filter_meshborn.h new file mode 100644 index 0000000000..8ab7a8c766 --- /dev/null +++ b/include/openmc/tallies/filter_meshborn.h @@ -0,0 +1,26 @@ +#ifndef OPENMC_TALLIES_FILTER_MESHBORN_H +#define OPENMC_TALLIES_FILTER_MESHBORN_H + +#include + +#include "openmc/position.h" +#include "openmc/tallies/filter_mesh.h" + +namespace openmc { + +class MeshBornFilter : public MeshFilter { +public: + //---------------------------------------------------------------------------- + // Methods + + std::string type_str() const override { return "meshborn"; } + FilterType type() const override { return FilterType::MESHBORN; } + + void get_all_bins(const Particle& p, TallyEstimator estimator, + FilterMatch& match) const override; + + std::string text_label(int bin) const override; +}; + +} // namespace openmc +#endif // OPENMC_TALLIES_FILTER_MESHBORN_H diff --git a/openmc/filter.py b/openmc/filter.py index f2bbf2f505..04b30261da 100644 --- a/openmc/filter.py +++ b/openmc/filter.py @@ -982,6 +982,34 @@ class MeshFilter(Filter): if translation: out.translation = [float(x) for x in translation.split()] return out + + +class MeshBornFilter(MeshFilter): + """Filter events by the mesh cell a particle originated from. + + Parameters + ---------- + mesh : openmc.MeshBase + The mesh object that events will be tallied onto + filter_id : int + Unique identifier for the filter + + Attributes + ---------- + mesh : openmc.MeshBase + The mesh object that events will be tallied onto + id : int + Unique identifier for the filter + translation : Iterable of float + This array specifies a vector that is used to translate (shift) + the mesh for this filter + bins : list of tuple + A list of mesh indices for each filter bin, e.g. [(1, 1, 1), (2, 1, 1), + ...] + num_bins : Integral + The number of filter bins + + """ class MeshSurfaceFilter(MeshFilter): diff --git a/openmc/lib/filter.py b/openmc/lib/filter.py index 7c2e7e5c45..340c2fa344 100644 --- a/openmc/lib/filter.py +++ b/openmc/lib/filter.py @@ -20,7 +20,8 @@ __all__ = [ 'Filter', 'AzimuthalFilter', 'CellFilter', 'CellbornFilter', 'CellfromFilter', 'CellInstanceFilter', 'CollisionFilter', 'DistribcellFilter', 'DelayedGroupFilter', 'EnergyFilter', 'EnergyoutFilter', 'EnergyFunctionFilter', 'LegendreFilter', - 'MaterialFilter', 'MaterialFromFilter', 'MeshFilter', 'MeshSurfaceFilter', 'MuFilter', 'ParticleFilter', + 'MaterialFilter', 'MaterialFromFilter', 'MeshFilter', 'MeshBornFilter', + 'MeshSurfaceFilter', 'MuFilter', 'ParticleFilter', 'PolarFilter', 'SphericalHarmonicsFilter', 'SpatialLegendreFilter', 'SurfaceFilter', 'UniverseFilter', 'ZernikeFilter', 'ZernikeRadialFilter', 'filters' ] @@ -89,18 +90,36 @@ _dll.openmc_mesh_filter_get_mesh.errcheck = _error_handler _dll.openmc_mesh_filter_set_mesh.argtypes = [c_int32, c_int32] _dll.openmc_mesh_filter_set_mesh.restype = c_int _dll.openmc_mesh_filter_set_mesh.errcheck = _error_handler -_dll.openmc_meshsurface_filter_get_mesh.argtypes = [c_int32, POINTER(c_int32)] -_dll.openmc_meshsurface_filter_get_mesh.restype = c_int -_dll.openmc_meshsurface_filter_get_mesh.errcheck = _error_handler -_dll.openmc_meshsurface_filter_set_mesh.argtypes = [c_int32, c_int32] -_dll.openmc_meshsurface_filter_set_mesh.restype = c_int -_dll.openmc_meshsurface_filter_set_mesh.errcheck = _error_handler _dll.openmc_mesh_filter_get_translation.argtypes = [c_int32, POINTER(c_double*3)] _dll.openmc_mesh_filter_get_translation.restype = c_int _dll.openmc_mesh_filter_get_translation.errcheck = _error_handler _dll.openmc_mesh_filter_set_translation.argtypes = [c_int32, POINTER(c_double*3)] _dll.openmc_mesh_filter_set_translation.restype = c_int _dll.openmc_mesh_filter_set_translation.errcheck = _error_handler +_dll.openmc_meshborn_filter_get_mesh.argtypes = [c_int32, POINTER(c_int32)] +_dll.openmc_meshborn_filter_get_mesh.restype = c_int +_dll.openmc_meshborn_filter_get_mesh.errcheck = _error_handler +_dll.openmc_meshborn_filter_set_mesh.argtypes = [c_int32, c_int32] +_dll.openmc_meshborn_filter_set_mesh.restype = c_int +_dll.openmc_meshborn_filter_set_mesh.errcheck = _error_handler +_dll.openmc_meshborn_filter_get_translation.argtypes = [c_int32, POINTER(c_double*3)] +_dll.openmc_meshborn_filter_get_translation.restype = c_int +_dll.openmc_meshborn_filter_get_translation.errcheck = _error_handler +_dll.openmc_meshborn_filter_set_translation.argtypes = [c_int32, POINTER(c_double*3)] +_dll.openmc_meshborn_filter_set_translation.restype = c_int +_dll.openmc_meshborn_filter_set_translation.errcheck = _error_handler +_dll.openmc_meshsurface_filter_get_mesh.argtypes = [c_int32, POINTER(c_int32)] +_dll.openmc_meshsurface_filter_get_mesh.restype = c_int +_dll.openmc_meshsurface_filter_get_mesh.errcheck = _error_handler +_dll.openmc_meshsurface_filter_set_mesh.argtypes = [c_int32, c_int32] +_dll.openmc_meshsurface_filter_set_mesh.restype = c_int +_dll.openmc_meshsurface_filter_set_mesh.errcheck = _error_handler +_dll.openmc_meshsurface_filter_get_translation.argtypes = [c_int32, POINTER(c_double*3)] +_dll.openmc_meshsurface_filter_get_translation.restype = c_int +_dll.openmc_meshsurface_filter_get_translation.errcheck = _error_handler +_dll.openmc_meshsurface_filter_set_translation.argtypes = [c_int32, POINTER(c_double*3)] +_dll.openmc_meshsurface_filter_set_translation.restype = c_int +_dll.openmc_meshsurface_filter_set_translation.errcheck = _error_handler _dll.openmc_new_filter.argtypes = [c_char_p, POINTER(c_int32)] _dll.openmc_new_filter.restype = c_int _dll.openmc_new_filter.errcheck = _error_handler @@ -347,6 +366,34 @@ class MaterialFromFilter(Filter): class MeshFilter(Filter): + """Mesh filter stored internally. + + This class exposes a Mesh filter that is stored internally in the OpenMC + library. To obtain a view of a Mesh filter with a given ID, use the + :data:`openmc.lib.filters` mapping. + + Parameters + ---------- + mesh : openmc.lib.Mesh + Mesh to use for the filter + uid : int or None + Unique ID of the Mesh filter + new : bool + When `index` is None, this argument controls whether a new object is + created or a view of an existing object is returned. + index : int + Index in the `filters` array. + + Attributes + ---------- + filter_type : str + Type of filter + mesh : openmc.lib.Mesh + Mesh used for the filter + translation : Iterable of float + 3-D coordinates of the translation vector + + """ filter_type = 'mesh' def __init__(self, mesh=None, uid=None, new=True, index=None): @@ -375,7 +422,92 @@ class MeshFilter(Filter): _dll.openmc_mesh_filter_set_translation(self._index, (c_double*3)(*translation)) +class MeshBornFilter(Filter): + """MeshBorn filter stored internally. + + This class exposes a MeshBorn filter that is stored internally in the OpenMC + library. To obtain a view of a MeshBorn filter with a given ID, use the + :data:`openmc.lib.filters` mapping. + + Parameters + ---------- + mesh : openmc.lib.Mesh + Mesh to use for the filter + uid : int or None + Unique ID of the MeshBorn filter + new : bool + When `index` is None, this argument controls whether a new object is + created or a view of an existing object is returned. + index : int + Index in the `filters` array. + + Attributes + ---------- + filter_type : str + Type of filter + mesh : openmc.lib.Mesh + Mesh used for the filter + translation : Iterable of float + 3-D coordinates of the translation vector + + """ + filter_type = 'meshborn' + + def __init__(self, mesh=None, uid=None, new=True, index=None): + super().__init__(uid, new, index) + if mesh is not None: + self.mesh = mesh + + @property + def mesh(self): + index_mesh = c_int32() + _dll.openmc_meshborn_filter_get_mesh(self._index, index_mesh) + return _get_mesh(index_mesh.value) + + @mesh.setter + def mesh(self, mesh): + _dll.openmc_meshborn_filter_set_mesh(self._index, mesh._index) + + @property + def translation(self): + translation = (c_double*3)() + _dll.openmc_meshborn_filter_get_translation(self._index, translation) + return tuple(translation) + + @translation.setter + def translation(self, translation): + _dll.openmc_meshborn_filter_set_translation(self._index, (c_double*3)(*translation)) + + class MeshSurfaceFilter(Filter): + """MeshSurface filter stored internally. + + This class exposes a MeshSurface filter that is stored internally in the + OpenMC library. To obtain a view of a MeshSurface filter with a given ID, + use the :data:`openmc.lib.filters` mapping. + + Parameters + ---------- + mesh : openmc.lib.Mesh + Mesh to use for the filter + uid : int or None + Unique ID of the MeshSurface filter + new : bool + When `index` is None, this argument controls whether a new object is + created or a view of an existing object is returned. + index : int + Index in the `filters` array. + + Attributes + ---------- + filter_type : str + Type of filter + mesh : openmc.lib.Mesh + Mesh used for the filter + translation : Iterable of float + 3-D coordinates of the translation vector + + """ filter_type = 'meshsurface' def __init__(self, mesh=None, uid=None, new=True, index=None): @@ -396,12 +528,12 @@ class MeshSurfaceFilter(Filter): @property def translation(self): translation = (c_double*3)() - _dll.openmc_mesh_filter_get_translation(self._index, translation) + _dll.openmc_meshsurface_filter_get_translation(self._index, translation) return tuple(translation) @translation.setter def translation(self, translation): - _dll.openmc_mesh_filter_set_translation(self._index, (c_double*3)(*translation)) + _dll.openmc_meshsurface_filter_set_translation(self._index, (c_double*3)(*translation)) class MuFilter(Filter): @@ -507,6 +639,7 @@ _FILTER_TYPE_MAP = { 'material': MaterialFilter, 'materialfrom': MaterialFromFilter, 'mesh': MeshFilter, + 'meshborn': MeshBornFilter, 'meshsurface': MeshSurfaceFilter, 'mu': MuFilter, 'particle': ParticleFilter, diff --git a/src/particle.cpp b/src/particle.cpp index 549de5cff6..c2e340201b 100644 --- a/src/particle.cpp +++ b/src/particle.cpp @@ -121,6 +121,7 @@ void Particle::from_source(const SourceSite* src) wgt_last() = src->wgt; r() = src->r; u() = src->u; + r_born() = src->r; r_last_current() = src->r; r_last() = src->r; u_last() = src->u; diff --git a/src/tallies/filter.cpp b/src/tallies/filter.cpp index 5fae4cf600..ff7a3416b9 100644 --- a/src/tallies/filter.cpp +++ b/src/tallies/filter.cpp @@ -23,6 +23,7 @@ #include "openmc/tallies/filter_material.h" #include "openmc/tallies/filter_materialfrom.h" #include "openmc/tallies/filter_mesh.h" +#include "openmc/tallies/filter_meshborn.h" #include "openmc/tallies/filter_meshsurface.h" #include "openmc/tallies/filter_mu.h" #include "openmc/tallies/filter_particle.h" @@ -126,6 +127,8 @@ Filter* Filter::create(const std::string& type, int32_t id) return Filter::create(id); } else if (type == "mesh") { return Filter::create(id); + } else if (type == "meshborn") { + return Filter::create(id); } else if (type == "meshsurface") { return Filter::create(id); } else if (type == "mu") { diff --git a/src/tallies/filter_mesh.cpp b/src/tallies/filter_mesh.cpp index ed9c716673..5b01da1f65 100644 --- a/src/tallies/filter_mesh.cpp +++ b/src/tallies/filter_mesh.cpp @@ -161,6 +161,7 @@ extern "C" int openmc_mesh_filter_get_translation( // Check the filter type const auto& filter = model::tally_filters[index]; if (filter->type() != FilterType::MESH && + filter->type() != FilterType::MESHBORN && filter->type() != FilterType::MESH_SURFACE) { set_errmsg("Tried to get a translation from a non-mesh-based filter."); return OPENMC_E_INVALID_TYPE; @@ -186,6 +187,7 @@ extern "C" int openmc_mesh_filter_set_translation( const auto& filter = model::tally_filters[index]; // Check the filter type if (filter->type() != FilterType::MESH && + filter->type() != FilterType::MESHBORN && filter->type() != FilterType::MESH_SURFACE) { set_errmsg("Tried to set mesh on a non-mesh-based filter."); return OPENMC_E_INVALID_TYPE; diff --git a/src/tallies/filter_meshborn.cpp b/src/tallies/filter_meshborn.cpp new file mode 100644 index 0000000000..c95dc3dc78 --- /dev/null +++ b/src/tallies/filter_meshborn.cpp @@ -0,0 +1,61 @@ +#include "openmc/tallies/filter_meshborn.h" + +#include "openmc/capi.h" +#include "openmc/constants.h" +#include "openmc/error.h" +#include "openmc/mesh.h" + +namespace openmc { + +void MeshBornFilter::get_all_bins( + const Particle& p, TallyEstimator estimator, FilterMatch& match) const +{ + Position r_born = p.r_born(); + + // apply translation if present + if (translated_) { + r_born -= translation(); + } + + auto bin = model::meshes[mesh_]->get_bin(r_born); + if (bin >= 0) { + match.bins_.push_back(bin); + match.weights_.push_back(1.0); + } +} + +std::string MeshBornFilter::text_label(int bin) const +{ + auto& mesh = *model::meshes.at(mesh_); + return mesh.bin_label(bin) + " (born)"; +} + +//============================================================================== +// C-API functions +//============================================================================== + +extern "C" int openmc_meshborn_filter_get_mesh( + int32_t index, int32_t* index_mesh) +{ + return openmc_mesh_filter_get_mesh(index, index_mesh); +} + +extern "C" int openmc_meshborn_filter_set_mesh( + int32_t index, int32_t index_mesh) +{ + return openmc_mesh_filter_set_mesh(index, index_mesh); +} + +extern "C" int openmc_meshborn_filter_get_translation( + int32_t index, double translation[3]) +{ + return openmc_mesh_filter_get_translation(index, translation); +} + +extern "C" int openmc_meshborn_filter_set_translation( + int32_t index, double translation[3]) +{ + return openmc_mesh_filter_set_translation(index, translation); +} + +} // namespace openmc diff --git a/src/tallies/filter_meshsurface.cpp b/src/tallies/filter_meshsurface.cpp index b22085ebbf..b26cd198b3 100644 --- a/src/tallies/filter_meshsurface.cpp +++ b/src/tallies/filter_meshsurface.cpp @@ -100,4 +100,16 @@ extern "C" int openmc_meshsurface_filter_set_mesh( return openmc_mesh_filter_set_mesh(index, index_mesh); } +extern "C" int openmc_meshsurface_filter_get_translation( + int32_t index, double translation[3]) +{ + return openmc_mesh_filter_get_translation(index, translation); +} + +extern "C" int openmc_meshsurface_filter_set_translation( + int32_t index, double translation[3]) +{ + return openmc_mesh_filter_set_translation(index, translation); +} + } // namespace openmc diff --git a/src/tallies/tally.cpp b/src/tallies/tally.cpp index 7fd444a58b..df1ec5ea79 100644 --- a/src/tallies/tally.cpp +++ b/src/tallies/tally.cpp @@ -26,6 +26,7 @@ #include "openmc/tallies/filter_energy.h" #include "openmc/tallies/filter_legendre.h" #include "openmc/tallies/filter_mesh.h" +#include "openmc/tallies/filter_meshborn.h" #include "openmc/tallies/filter_meshsurface.h" #include "openmc/tallies/filter_particle.h" #include "openmc/tallies/filter_sph_harm.h" diff --git a/tests/regression_tests/filter_meshborn/__init__.py b/tests/regression_tests/filter_meshborn/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/regression_tests/filter_meshborn/inputs_true.dat b/tests/regression_tests/filter_meshborn/inputs_true.dat new file mode 100644 index 0000000000..3ba38d56e6 --- /dev/null +++ b/tests/regression_tests/filter_meshborn/inputs_true.dat @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + fixed source + 2000 + 8 + + + 0.0 -10.0 -10.0 10.0 10.0 10.0 + + + + + + 2 2 1 + -10.0 -10.0 -10.0 + 10.0 10.0 10.0 + + + 1 + + + 1 + + + 1 2 + scatter + + + 1 + scatter + + + 2 + scatter + + + scatter + + + diff --git a/tests/regression_tests/filter_meshborn/results_true.dat b/tests/regression_tests/filter_meshborn/results_true.dat new file mode 100644 index 0000000000..62f2707ffc --- /dev/null +++ b/tests/regression_tests/filter_meshborn/results_true.dat @@ -0,0 +1,54 @@ +tally 1: +0.000000E+00 +0.000000E+00 +2.631246E+01 +8.845079E+01 +0.000000E+00 +0.000000E+00 +2.450265E+00 +9.462266E-01 +0.000000E+00 +0.000000E+00 +3.878380E+02 +1.881752E+04 +0.000000E+00 +0.000000E+00 +2.932956E+01 +1.091674E+02 +0.000000E+00 +0.000000E+00 +1.837753E+00 +5.195343E-01 +0.000000E+00 +0.000000E+00 +2.944919E+01 +1.095819E+02 +0.000000E+00 +0.000000E+00 +2.921731E+01 +1.097387E+02 +0.000000E+00 +0.000000E+00 +4.019442E+02 +2.021184E+04 +tally 2: +2.876273E+01 +1.060244E+02 +4.171676E+02 +2.176683E+04 +3.128695E+01 +1.238772E+02 +4.311615E+02 +2.325871E+04 +tally 3: +0.000000E+00 +0.000000E+00 +4.452055E+02 +2.478148E+04 +0.000000E+00 +0.000000E+00 +4.631732E+02 +2.683862E+04 +tally 4: +9.083787E+02 +1.031695E+05 diff --git a/tests/regression_tests/filter_meshborn/test.py b/tests/regression_tests/filter_meshborn/test.py new file mode 100644 index 0000000000..ff4adbc9f0 --- /dev/null +++ b/tests/regression_tests/filter_meshborn/test.py @@ -0,0 +1,107 @@ +"""Test the meshborn filter using a fixed source calculation on a H1 sphere. + +""" + +from numpy.testing import assert_allclose +import numpy as np +import openmc +import pytest + +from tests.testing_harness import PyAPITestHarness + + +RTOL = 1.0e-7 +ATOL = 0.0 + + +@pytest.fixture +def model(): + """Sphere of H1 with one hemisphere containing the source (x>0) and one + hemisphere with no source (x<0). + + """ + openmc.reset_auto_ids() + model = openmc.Model() + + # Materials + h1 = openmc.Material() + h1.add_nuclide("H1", 1.0) + h1.set_density("g/cm3", 1.0) + model.materials = openmc.Materials([h1]) + + # Core geometry + r = 10.0 + sphere = openmc.Sphere(r=r, boundary_type="reflective") + core = openmc.Cell(fill=h1, region=-sphere) + model.geometry = openmc.Geometry([core]) + + # Settings + model.settings.run_mode = 'fixed source' + model.settings.particles = 2000 + model.settings.batches = 8 + distribution = openmc.stats.Box((0., -r, -r), (r, r, r)) + model.settings.source = openmc.IndependentSource(space=distribution) + + # Tallies + mesh = openmc.RegularMesh() + mesh.dimension = (2, 2, 1) + mesh.lower_left = (-r, -r, -r) + mesh.upper_right = (r, r, r) + + f_1 = openmc.MeshFilter(mesh) + f_2 = openmc.MeshBornFilter(mesh) + + t_1 = openmc.Tally(name="scatter") + t_1.filters = [f_1, f_2] + t_1.scores = ["scatter"] + + t_2 = openmc.Tally(name="scatter-mesh") + t_2.filters = [f_1] + t_2.scores = ["scatter"] + + t_3 = openmc.Tally(name="scatter-meshborn") + t_3.filters = [f_2] + t_3.scores = ["scatter"] + + t_4 = openmc.Tally(name="scatter-total") + t_4.scores = ["scatter"] + + model.tallies = [t_1, t_2, t_3, t_4] + + return model + + +class MeshBornFilterTest(PyAPITestHarness): + + def _compare_results(self): + """Additional unit tests on the tally results to check consistency.""" + with openmc.StatePoint(self.statepoint_name) as sp: + + t1 = sp.get_tally(name="scatter").mean.reshape(4, 4) + t2 = sp.get_tally(name="scatter-mesh").mean.reshape(4) + t3 = sp.get_tally(name="scatter-meshborn").mean.reshape(4) + t4 = sp.get_tally(name="scatter-total").mean.reshape(1) + + # Consistency between mesh+meshborn matrix tally and meshborn tally + for i in range(4): + assert_allclose(t1[:, i].sum(), t3[i], rtol=RTOL, atol=ATOL) + + # Consistency between mesh+meshborn matrix tally and mesh tally + for i in range(4): + assert_allclose(t1[i, :].sum(), t2[i], rtol=RTOL, atol=ATOL) + + # Mesh cells in x<0 do not contribute to meshborn + assert_allclose(t1[:, 0].sum(), np.zeros(4), rtol=RTOL, atol=ATOL) + assert_allclose(t1[:, 2].sum(), np.zeros(4), rtol=RTOL, atol=ATOL) + + # Consistency with total scattering + assert_allclose(t1.sum(), t4, rtol=RTOL, atol=ATOL) + assert_allclose(t2.sum(), t4, rtol=RTOL, atol=ATOL) + assert_allclose(t3.sum(), t4, rtol=RTOL, atol=ATOL) + + super()._compare_results() + + +def test_filter_meshborn(model): + harness = MeshBornFilterTest("statepoint.8.h5", model) + harness.main() diff --git a/tests/unit_tests/test_filter_meshborn.py b/tests/unit_tests/test_filter_meshborn.py new file mode 100644 index 0000000000..62fa1174e7 --- /dev/null +++ b/tests/unit_tests/test_filter_meshborn.py @@ -0,0 +1,116 @@ +"""Test the meshborn filter using a fixed source calculation on a H1 sphere. + +""" + +import numpy as np +from uncertainties import unumpy +import openmc +import pytest + + +@pytest.fixture +def model(): + """Sphere of H1 with one hemisphere containing the source (x>0) and one + hemisphere with no source (x<0). + + """ + openmc.reset_auto_ids() + model = openmc.Model() + + # Materials + h1 = openmc.Material() + h1.add_nuclide("H1", 1.0) + h1.set_density("g/cm3", 1.0) + model.materials = openmc.Materials([h1]) + + # Core geometry + r = 10.0 + sphere = openmc.Sphere(r=r, boundary_type="reflective") + core = openmc.Cell(fill=h1, region=-sphere) + model.geometry = openmc.Geometry([core]) + + # Settings + model.settings.run_mode = 'fixed source' + model.settings.particles = 2000 + model.settings.batches = 8 + + distribution = openmc.stats.Box((0., -r, -r), (r, r, r)) + model.settings.source = openmc.IndependentSource(space=distribution) + + # ============================================================================= + # Tallies + # ============================================================================= + + mesh = openmc.RegularMesh() + mesh.dimension = (2, 2, 1) + mesh.lower_left = (-r, -r, -r) + mesh.upper_right = (r, r, r) + + f = openmc.MeshBornFilter(mesh) + t_1 = openmc.Tally(name="scatter-collision") + t_1.filters = [f] + t_1.scores = ["scatter"] + t_1.estimator = "collision" + + t_2 = openmc.Tally(name="scatter-tracklength") + t_2.filters = [f] + t_2.scores = ["scatter"] + t_2.estimator = "tracklength" + + model.tallies = [t_1, t_2] + + return model + + +def test_estimator_consistency(model, run_in_tmpdir): + """Test that resuts obtained from a tracklength estimator are + consistent with results obtained from a collision estimator. + + """ + # Run OpenMC + sp_filename = model.run() + + # Get radial flux distribution + with openmc.StatePoint(sp_filename) as sp: + scatter_collision = sp.get_tally(name="scatter-collision").mean.ravel() + scatter_collision_std_dev = sp.get_tally(name="scatter-collision").std_dev.ravel() + scatter_tracklength = sp.get_tally(name="scatter-tracklength").mean.ravel() + scatter_tracklength_std_dev = sp.get_tally(name="scatter-tracklength").std_dev.ravel() + + collision = unumpy.uarray(scatter_collision, scatter_collision_std_dev) + tracklength = unumpy.uarray(scatter_tracklength, scatter_tracklength_std_dev) + delta = abs(collision - tracklength) + + diff = unumpy.nominal_values(delta) + std_dev = unumpy.std_devs(delta) + assert np.all(diff <= 3 * std_dev) + + +def test_xml_serialization(): + """Test xml serialization of the meshborn filter.""" + openmc.reset_auto_ids() + + mesh = openmc.RegularMesh() + mesh.dimension = (1, 1, 1) + mesh.lower_left = (0.0, 0.0, 0.0) + mesh.upper_right = (1.0, 1.0, 1.0) + + filter = openmc.MeshBornFilter(mesh) + filter.translation = (2.0, 2.0, 2.0) + assert filter.mesh.id == 1 + assert filter.mesh.dimension == (1, 1, 1) + assert filter.mesh.lower_left == (0.0, 0.0, 0.0) + assert filter.mesh.upper_right == (1.0, 1.0, 1.0) + + repr(filter) + + elem = filter.to_xml_element() + assert elem.tag == 'filter' + assert elem.attrib['type'] == 'meshborn' + assert elem[0].text == "1" + assert elem.get("translation") == "2.0 2.0 2.0" + + meshes = {1: mesh} + new_filter = openmc.Filter.from_xml_element(elem, meshes=meshes) + assert new_filter.bins == filter.bins + np.testing.assert_equal(new_filter.translation, [2.0, 2.0, 2.0]) diff --git a/tests/unit_tests/test_tallies.py b/tests/unit_tests/test_tallies.py index 14319a0a29..5444433125 100644 --- a/tests/unit_tests/test_tallies.py +++ b/tests/unit_tests/test_tallies.py @@ -10,8 +10,9 @@ def test_xml_roundtrip(run_in_tmpdir): mesh.upper_right = (10., 10., 10.,) mesh.dimension = (5, 5, 5) mesh_filter = openmc.MeshFilter(mesh) + meshborn_filter = openmc.MeshBornFilter(mesh) tally = openmc.Tally() - tally.filters = [mesh_filter] + tally.filters = [mesh_filter, meshborn_filter] tally.nuclides = ['U235', 'I135', 'Li6'] tally.scores = ['total', 'fission', 'heating'] tally.derivative = openmc.TallyDerivative( @@ -27,9 +28,11 @@ def test_xml_roundtrip(run_in_tmpdir): assert len(new_tallies) == 1 new_tally = new_tallies[0] assert new_tally.id == tally.id - assert len(new_tally.filters) == 1 + assert len(new_tally.filters) == 2 assert isinstance(new_tally.filters[0], openmc.MeshFilter) assert np.allclose(new_tally.filters[0].mesh.lower_left, mesh.lower_left) + assert isinstance(new_tally.filters[1], openmc.MeshBornFilter) + assert np.allclose(new_tally.filters[1].mesh.lower_left, mesh.lower_left) assert new_tally.nuclides == tally.nuclides assert new_tally.scores == tally.scores assert new_tally.derivative.variable == tally.derivative.variable From 0aad22d54158271cd33a3c245936d7164a17e2f6 Mon Sep 17 00:00:00 2001 From: Ethan Peterson Date: Fri, 5 Apr 2024 02:35:15 -0400 Subject: [PATCH 06/48] Allow get_microxs_and_flux to use OPENMC_CHAIN_FILE environment variable (#2934) --- openmc/deplete/microxs.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openmc/deplete/microxs.py b/openmc/deplete/microxs.py index 0721535ca4..c1c4cb7acf 100644 --- a/openmc/deplete/microxs.py +++ b/openmc/deplete/microxs.py @@ -27,10 +27,9 @@ _valid_rxns.append('fission') def _resolve_chain_file_path(chain_file: str): - # Determine what reactions and nuclides are available in chain if chain_file is None: chain_file = openmc.config.get('chain_file') - if 'chain_file' in openmc.config: + if 'chain_file' not in openmc.config: raise DataError( "No depletion chain specified and could not find depletion " "chain in openmc.config['chain_file']" From 27bd315f0f2eb61b447603514bd790cc04309a91 Mon Sep 17 00:00:00 2001 From: Jonathan Shimwell Date: Fri, 5 Apr 2024 10:37:46 +0100 Subject: [PATCH 07/48] changing y axis label for heating plots (#2859) Co-authored-by: Paul Romano --- openmc/plotter.py | 35 ++++++++++++++++++++++---------- tests/unit_tests/test_plotter.py | 15 ++++++++++++++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/openmc/plotter.py b/openmc/plotter.py index 849672fceb..97ca5f9297 100644 --- a/openmc/plotter.py +++ b/openmc/plotter.py @@ -73,24 +73,37 @@ def _get_legend_label(this, type): def _get_yaxis_label(reactions, divisor_types): """Gets a y axis label for the type of data plotted""" - if all(isinstance(item, str) for item in reactions.keys()): - stem = 'Microscopic' - if divisor_types: - mid, units = 'Data', '' - else: - mid, units = 'Cross Section', '[b]' + heat_values = {"heating", "heating-local", "damage-energy"} + + # if all the types are heating a different stem and unit is needed + if all(set(value).issubset(heat_values) for value in reactions.values()): + stem = "Heating" + elif all(isinstance(item, str) for item in reactions.keys()): + for nuc_reactions in reactions.values(): + for reaction in nuc_reactions: + if reaction in heat_values: + raise TypeError( + "Mixture of heating and Microscopic reactions. " + "Invalid type for plotting" + ) + stem = "Microscopic" elif all(isinstance(item, openmc.Material) for item in reactions.keys()): stem = 'Macroscopic' - if divisor_types: - mid, units = 'Data', '' - else: - mid, units = 'Cross Section', '[1/cm]' else: msg = "Mixture of openmc.Material and elements/nuclides. Invalid type for plotting" raise TypeError(msg) - return f'{stem} {mid} {units}' + if divisor_types: + mid, units = "Data", "" + else: + mid = "Cross Section" + units = { + "Macroscopic": "[1/cm]", + "Microscopic": "[b]", + "Heating": "[eV-barn]", + }[stem] + return f'{stem} {mid} {units}' def _get_title(reactions): """Gets a title for the type of data plotted""" diff --git a/tests/unit_tests/test_plotter.py b/tests/unit_tests/test_plotter.py index fd635d89d5..2c195c5e12 100644 --- a/tests/unit_tests/test_plotter.py +++ b/tests/unit_tests/test_plotter.py @@ -118,6 +118,21 @@ def test_plot_axes_labels(): ) assert axis_label == 'Microscopic Cross Section [b]' + axis_label = openmc.plotter._get_yaxis_label( + reactions={ + "Li": ["heating", "heating-local"], + "Li7": ["heating"], + "Be": ["damage-energy"], + }, + divisor_types=False, + ) + assert axis_label == "Heating Cross Section [eV-barn]" + + with pytest.raises(TypeError): + axis_label = openmc.plotter.plot_xs( + reactions={"Li": ["heating", "heating-local"], "Be9": ["(n,2n)"]} + ) + # just materials mat1 = openmc.Material() mat1.add_nuclide('Fe56', 1) From 704cfbf1afb11a03d4dc6223d737e40680b49f39 Mon Sep 17 00:00:00 2001 From: Jonathan Shimwell Date: Sun, 7 Apr 2024 16:27:51 +0100 Subject: [PATCH 08/48] Updating docker file base to bookworm (#2890) --- Dockerfile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index fa223bd228..0b4742ada7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,7 @@ ARG compile_cores=1 ARG build_dagmc=off ARG build_libmesh=off -FROM debian:bullseye-slim AS dependencies +FROM debian:bookworm-slim AS dependencies ARG compile_cores ARG build_dagmc @@ -71,9 +71,13 @@ RUN apt-get update -y && \ apt-get install -y \ python3-pip python-is-python3 wget git build-essential cmake \ mpich libmpich-dev libhdf5-serial-dev libhdf5-mpich-dev \ - libpng-dev && \ + libpng-dev python3-venv && \ apt-get autoremove +# create virtual enviroment to avoid externally managed environment error +RUN python3 -m venv openmc_venv +ENV PATH=/openmc_venv/bin:$PATH + # Update system-provided pip RUN pip install --upgrade pip From db3b6f3e9ef30af313c372b48915ab7e301e9ab0 Mon Sep 17 00:00:00 2001 From: Jonathan Shimwell Date: Sun, 7 Apr 2024 17:50:44 +0100 Subject: [PATCH 09/48] added missing functions and classes to openmc.lib docs (#2847) Co-authored-by: Paul Romano --- docs/source/pythonapi/capi.rst | 76 ++++++++++++++++++++++++++++++++-- openmc/arithmetic.py | 12 +++--- openmc/lib/mesh.py | 6 ++- 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/docs/source/pythonapi/capi.rst b/docs/source/pythonapi/capi.rst index 16bb68ca43..3135431996 100644 --- a/docs/source/pythonapi/capi.rst +++ b/docs/source/pythonapi/capi.rst @@ -16,7 +16,6 @@ Functions current_batch export_properties export_weight_windows - import_weight_windows finalize find_cell find_material @@ -25,6 +24,7 @@ Functions hard_reset id_map import_properties + import_weight_windows init is_statepoint_batch iter_batches @@ -40,8 +40,8 @@ Functions run run_in_memory sample_external_source - simulation_init simulation_finalize + simulation_init source_bank statepoint_write @@ -53,16 +53,86 @@ Classes :nosignatures: :template: myclass.rst + AzimuthalFilter Cell + CellFilter + CellInstanceFilter + CellbornFilter + CellfromFilter + CollisionFilter CylindricalMesh + DelayedGroupFilter + DistribcellFilter EnergyFilter - MaterialFilter + EnergyFunctionFilter + EnergyoutFilter + Filter + LegendreFilter Material + MaterialFilter + MaterialFromFilter + Mesh MeshFilter MeshBornFilter MeshSurfaceFilter + MuFilter Nuclide + ParticleFilter + PolarFilter RectilinearMesh RegularMesh + SpatialLegendreFilter + SphericalHarmonicsFilter SphericalMesh + SurfaceFilter Tally + UniverseFilter + UnstructuredMesh + WeightWindows + ZernikeFilter + ZernikeRadialFilter + +Data +---- + +.. data:: cells + + Mapping of cell ID to :class:`openmc.lib.Cell` instances. + + :type: dict + +.. data:: filters + + Mapping of filter ID to :class:`openmc.lib.Filter` instances. + + :type: dict + +.. data:: materials + + Mapping of material ID to :class:`openmc.lib.Material` instances. + + :type: dict + +.. data:: meshes + + Mapping of mesh ID to :class:`openmc.lib.Mesh` instances. + + :type: dict + +.. data:: nuclides + + Mapping of nuclide name to :class:`openmc.lib.Nuclide` instances. + + :type: dict + +.. data:: tallies + + Mapping of tally ID to :class:`openmc.lib.Tally` instances. + + :type: dict + +.. data:: weight_windows + + Mapping of weight window ID to :class:`openmc.lib.WeightWindows` instances. + + :type: dict diff --git a/openmc/arithmetic.py b/openmc/arithmetic.py index 5ca7cc6668..c66efe5b81 100644 --- a/openmc/arithmetic.py +++ b/openmc/arithmetic.py @@ -188,9 +188,9 @@ class CrossFilter: Parameters ---------- - left_filter : Filter or CrossFilter + left_filter : openmc.Filter or CrossFilter The left filter in the outer product - right_filter : Filter or CrossFilter + right_filter : openmc.Filter or CrossFilter The right filter in the outer product binary_op : str The tally arithmetic binary operator (e.g., '+', '-', etc.) used to @@ -200,9 +200,9 @@ class CrossFilter: ---------- type : str The type of the crossfilter (e.g., 'energy / energy') - left_filter : Filter or CrossFilter + left_filter : openmc.Filter or CrossFilter The left filter in the outer product - right_filter : Filter or CrossFilter + right_filter : openmc.Filter or CrossFilter The right filter in the outer product binary_op : str The tally arithmetic binary operator (e.g., '+', '-', etc.) used to @@ -517,7 +517,7 @@ class AggregateFilter: Parameters ---------- - aggregate_filter : Filter or CrossFilter + aggregate_filter : openmc.Filter or CrossFilter The filter included in the aggregation bins : Iterable of tuple The filter bins included in the aggregation @@ -529,7 +529,7 @@ class AggregateFilter: ---------- type : str The type of the aggregatefilter (e.g., 'sum(energy)', 'sum(cell)') - aggregate_filter : filter + aggregate_filter : openmc.Filter The filter included in the aggregation aggregate_op : str The tally aggregation operator (e.g., 'sum', 'avg', etc.) used diff --git a/openmc/lib/mesh.py b/openmc/lib/mesh.py index 7f17dd81ff..4da7baba77 100644 --- a/openmc/lib/mesh.py +++ b/openmc/lib/mesh.py @@ -15,7 +15,10 @@ from .error import _error_handler from .material import Material from .plot import _Position -__all__ = ['RegularMesh', 'RectilinearMesh', 'CylindricalMesh', 'SphericalMesh', 'UnstructuredMesh', 'meshes'] +__all__ = [ + 'Mesh', 'RegularMesh', 'RectilinearMesh', 'CylindricalMesh', + 'SphericalMesh', 'UnstructuredMesh', 'meshes' +] class _MaterialVolume(Structure): @@ -553,6 +556,7 @@ class CylindricalMesh(Mesh): _dll.openmc_cylindrical_mesh_set_grid(self._index, r_grid, nr, phi_grid, nphi, z_grid, nz) + class SphericalMesh(Mesh): """SphericalMesh stored internally. From c85ba93040433ee2d4be7f17d37d0831da14d2f0 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Mon, 8 Apr 2024 05:46:59 -0500 Subject: [PATCH 10/48] Fix distribcell labels for lattices used as fill in multiple cells (#2813) Co-authored-by: Paul Romano --- src/geometry_aux.cpp | 8 ++++---- src/tallies/filter_distribcell.cpp | 3 ++- tests/unit_tests/test_cell_instance.py | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/geometry_aux.cpp b/src/geometry_aux.cpp index b0e88e8e36..10b239be83 100644 --- a/src/geometry_aux.cpp +++ b/src/geometry_aux.cpp @@ -539,7 +539,7 @@ std::string distribcell_path_inner(int32_t target_cell, int32_t map, // The desired cell is the first cell that gives an offset smaller or // equal to the target offset. - if (temp_offset <= target_offset) + if (temp_offset <= target_offset - c.offset_[map]) break; } } @@ -570,11 +570,11 @@ std::string distribcell_path_inner(int32_t target_cell, int32_t map, for (ReverseLatticeIter it = lat.rbegin(); it != lat.rend(); ++it) { int32_t indx = lat.universes_.size() * map + it.indx_; int32_t temp_offset = offset + lat.offsets_[indx]; - if (temp_offset <= target_offset) { + if (temp_offset <= target_offset - c.offset_[map]) { offset = temp_offset; path << "(" << lat.index_to_string(it.indx_) << ")->"; - path << distribcell_path_inner( - target_cell, map, target_offset, *model::universes[*it], offset); + path << distribcell_path_inner(target_cell, map, target_offset, + *model::universes[*it], offset + c.offset_[map]); return path.str(); } } diff --git a/src/tallies/filter_distribcell.cpp b/src/tallies/filter_distribcell.cpp index 89349b61f0..c754dbd44a 100644 --- a/src/tallies/filter_distribcell.cpp +++ b/src/tallies/filter_distribcell.cpp @@ -48,7 +48,8 @@ void DistribcellFilter::get_all_bins( auto& lat {*model::lattices[p.coord(i + 1).lattice]}; const auto& i_xyz {p.coord(i + 1).lattice_i}; if (lat.are_valid_indices(i_xyz)) { - offset += lat.offset(distribcell_index, i_xyz); + offset += + lat.offset(distribcell_index, i_xyz) + c.offset_[distribcell_index]; } } if (cell_ == p.coord(i).cell) { diff --git a/tests/unit_tests/test_cell_instance.py b/tests/unit_tests/test_cell_instance.py index 00424f9c5a..25c20cfef2 100644 --- a/tests/unit_tests/test_cell_instance.py +++ b/tests/unit_tests/test_cell_instance.py @@ -9,6 +9,7 @@ from tests import cdtemp @pytest.fixture(scope='module', autouse=True) def double_lattice_model(): + openmc.reset_auto_ids() model = openmc.Model() # Create a single material @@ -39,6 +40,18 @@ def double_lattice_model(): cell_with_lattice2.translation = (2., 0., 0.) model.geometry = openmc.Geometry([cell_with_lattice1, cell_with_lattice2]) + tally = openmc.Tally() + tally.filters = [openmc.DistribcellFilter(c)] + tally.scores = ['flux'] + model.tallies = [tally] + + # Add box source that covers the model space well + bbox = model.geometry.bounding_box + bbox[0][2] = -0.5 + bbox[1][2] = 0.5 + space = openmc.stats.Box(*bbox) + model.settings.source = openmc.IndependentSource(space=space) + # Add necessary settings and export model.settings.batches = 10 model.settings.inactive = 0 @@ -71,3 +84,9 @@ expected_results = [ def test_cell_instance_multilattice(r, expected_cell_instance): _, cell_instance = openmc.lib.find_cell(r) assert cell_instance == expected_cell_instance + + +def test_cell_instance_multilattice_results(): + openmc.lib.run() + tally_results = openmc.lib.tallies[1].mean + assert (tally_results != 0.0).all() From 463299d04aa15c87baedaf33bf84c07b77090eed Mon Sep 17 00:00:00 2001 From: Ethan Peterson Date: Mon, 8 Apr 2024 09:45:01 -0400 Subject: [PATCH 11/48] Polygon fix to better handle colinear points (#2935) Co-authored-by: Paul Romano --- openmc/model/surface_composite.py | 26 +++++++++++++--------- tests/unit_tests/test_surface_composite.py | 26 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/openmc/model/surface_composite.py b/openmc/model/surface_composite.py index 8f48b2f711..5543a88e82 100644 --- a/openmc/model/surface_composite.py +++ b/openmc/model/surface_composite.py @@ -635,7 +635,7 @@ class XConeOneSided(CompositeSurface): z-coordinate of the apex. Defaults to 0. r2 : float, optional Parameter related to the aperture [:math:`\\rm cm^2`]. - It can be interpreted as the increase in the radius squared per cm along + It can be interpreted as the increase in the radius squared per cm along the cone's axis of revolution. up : bool Whether to select the side of the cone that extends to infinity in the @@ -695,7 +695,7 @@ class YConeOneSided(CompositeSurface): z-coordinate of the apex. Defaults to 0. r2 : float, optional Parameter related to the aperture [:math:`\\rm cm^2`]. - It can be interpreted as the increase in the radius squared per cm along + It can be interpreted as the increase in the radius squared per cm along the cone's axis of revolution. up : bool Whether to select the side of the cone that extends to infinity in the @@ -749,7 +749,7 @@ class ZConeOneSided(CompositeSurface): z-coordinate of the apex. Defaults to 0. r2 : float, optional Parameter related to the aperture [:math:`\\rm cm^2`]. - It can be interpreted as the increase in the radius squared per cm along + It can be interpreted as the increase in the radius squared per cm along the cone's axis of revolution. up : bool Whether to select the side of the cone that extends to infinity in the @@ -1029,9 +1029,9 @@ class Polygon(CompositeSurface): ------- None """ - # Only attempt the triangulation up to 3 times. - if depth > 2: - raise RuntimeError('Could not create a valid triangulation after 3' + # Only attempt the triangulation up to 5 times. + if depth > 4: + raise RuntimeError('Could not create a valid triangulation after 5' ' attempts') tri = Delaunay(points, qhull_options='QJ') @@ -1039,7 +1039,7 @@ class Polygon(CompositeSurface): # included in the triangulation, break it into two line segments. n = len(points) new_pts = [] - for i, j in zip(range(n), range(1, n +1)): + for i, j in zip(range(n), range(1, n + 1)): # If both vertices of any edge are not found in any simplex, insert # a new point between them. if not any([i in s and j % n in s for s in tri.simplices]): @@ -1077,7 +1077,8 @@ class Polygon(CompositeSurface): return group # If group is empty, grab the next simplex in the dictionary and recurse if group is None: - sidx = next(iter(neighbor_map)) + # Start with smallest neighbor lists + sidx = sorted(neighbor_map.items(), key=lambda item: len(item[1]))[0][0] return self._group_simplices(neighbor_map, group=[sidx]) # Otherwise use the last simplex in the group else: @@ -1087,13 +1088,16 @@ class Polygon(CompositeSurface): # For each neighbor check if it is part of the same convex # hull as the rest of the group. If yes, recurse. If no, continue on. for n in neighbors: - if n in group or neighbor_map.get(n, None) is None: + if n in group or neighbor_map.get(n) is None: continue test_group = group + [n] test_point_idx = np.unique(self._tri.simplices[test_group, :]) test_points = self.points[test_point_idx] - # If test_points are convex keep adding to this group - if len(test_points) == len(ConvexHull(test_points).vertices): + test_hull = ConvexHull(test_points, qhull_options='Qc') + pts_on_hull = len(test_hull.vertices) + len(test_hull.coplanar) + # If test_points are convex (including coplanar) keep adding to + # this group + if len(test_points) == pts_on_hull: group = self._group_simplices(neighbor_map, group=test_group) return group diff --git a/tests/unit_tests/test_surface_composite.py b/tests/unit_tests/test_surface_composite.py index 73519c3836..19221212e0 100644 --- a/tests/unit_tests/test_surface_composite.py +++ b/tests/unit_tests/test_surface_composite.py @@ -399,6 +399,32 @@ def test_polygon(): with pytest.raises(ValueError): openmc.model.Polygon(rz_points) + # Test "M" shaped polygon + points = np.array([[8.5151581, -17.988337], + [10.381711000000001, -17.988337], + [12.744357, -24.288728000000003], + [15.119406000000001, -17.988337], + [16.985959, -17.988337], + [16.985959, -27.246687], + [15.764328, -27.246687], + [15.764328, -19.116951], + [13.376877, -25.466951], + [12.118039, -25.466951], + [9.7305877, -19.116951], + [9.7305877, -27.246687], + [8.5151581, -27.246687]]) + + # Test points inside and outside by using offset method + m_polygon = openmc.model.Polygon(points, basis='xz') + inner_pts = m_polygon.offset(-0.1).points + assert all([(pt[0], 0, pt[1]) in -m_polygon for pt in inner_pts]) + outer_pts = m_polygon.offset(0.1).points + assert all([(pt[0], 0, pt[1]) in +m_polygon for pt in outer_pts]) + + # Offset of -0.2 will cause self-intersection + with pytest.raises(ValueError): + m_polygon.offset(-0.2) + @pytest.mark.parametrize("axis", ["x", "y", "z"]) def test_cruciform_prism(axis): From 70ba23a3672740db9e92fe3fc7cd6d8628edd036 Mon Sep 17 00:00:00 2001 From: Hunter Belanger Date: Mon, 8 Apr 2024 10:08:18 -0400 Subject: [PATCH 12/48] Update xtl and xtensor submodules (#2941) --- vendor/xtensor | 2 +- vendor/xtl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor/xtensor b/vendor/xtensor index 31acec1e90..3634f2ded1 160000 --- a/vendor/xtensor +++ b/vendor/xtensor @@ -1 +1 @@ -Subproject commit 31acec1e90bbea6d4bc17af0710a123bd5da6689 +Subproject commit 3634f2ded19e0cf38208c8b86cea9e1d7c8e397d diff --git a/vendor/xtl b/vendor/xtl index c19750fb14..a7c1c5444d 160000 --- a/vendor/xtl +++ b/vendor/xtl @@ -1 +1 @@ -Subproject commit c19750fb1488369dc41f6069bc2b8446fc093e75 +Subproject commit a7c1c5444dfc57f76620391af4c94785ff82c8d6 From fea90e0926be94627b67f9d9d2500d0def7d17ce Mon Sep 17 00:00:00 2001 From: Jonathan Shimwell Date: Mon, 8 Apr 2024 16:08:15 +0100 Subject: [PATCH 13/48] updated package versions in Dockerfile (#2946) --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0b4742ada7..107df4afe7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,21 +34,21 @@ ARG build_libmesh ENV HOME=/root # Embree variables -ENV EMBREE_TAG='v3.12.2' +ENV EMBREE_TAG='v4.3.1' ENV EMBREE_REPO='https://github.com/embree/embree' ENV EMBREE_INSTALL_DIR=$HOME/EMBREE/ # MOAB variables -ENV MOAB_TAG='5.3.0' +ENV MOAB_TAG='5.5.1' ENV MOAB_REPO='https://bitbucket.org/fathomteam/moab/' # Double-Down variables -ENV DD_TAG='v1.0.0' +ENV DD_TAG='v1.1.0' ENV DD_REPO='https://github.com/pshriwise/double-down' ENV DD_INSTALL_DIR=$HOME/Double_down # DAGMC variables -ENV DAGMC_BRANCH='v3.2.1' +ENV DAGMC_BRANCH='v3.2.3' ENV DAGMC_REPO='https://github.com/svalinn/DAGMC' ENV DAGMC_INSTALL_DIR=$HOME/DAGMC/ From 569c087597adf873a4869d7785884b94f539b543 Mon Sep 17 00:00:00 2001 From: Lorenzo Chierici Date: Tue, 9 Apr 2024 21:26:03 +0200 Subject: [PATCH 14/48] Depletion restart with mpi (#2778) Co-authored-by: Paul Romano --- openmc/deplete/coupled_operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmc/deplete/coupled_operator.py b/openmc/deplete/coupled_operator.py index 856a26958f..8d43e2b2ce 100644 --- a/openmc/deplete/coupled_operator.py +++ b/openmc/deplete/coupled_operator.py @@ -291,7 +291,7 @@ class CoupledOperator(OpenMCOperator): # on this process if comm.size != 1: prev_results = self.prev_res - self.prev_res = Results() + self.prev_res = Results(filename=None) mat_indexes = _distribute(range(len(self.burnable_mats))) for res_obj in prev_results: new_res = res_obj.distribute(self.local_mats, mat_indexes) From 256150f5675f4d1c61f9c07b5e29cdb8293cffc0 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Thu, 11 Apr 2024 15:00:41 +0200 Subject: [PATCH 15/48] Ensure that two surfaces with different boundary type are not considered redundant (#2942) --- openmc/geometry.py | 2 +- tests/unit_tests/test_geometry.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/openmc/geometry.py b/openmc/geometry.py index 8815f2c838..175cef2bf3 100644 --- a/openmc/geometry.py +++ b/openmc/geometry.py @@ -701,7 +701,7 @@ class Geometry: coeffs = tuple(round(surf._coefficients[k], self.surface_precision) for k in surf._coeff_keys) - key = (surf._type,) + coeffs + key = (surf._type, surf._boundary_type) + coeffs redundancies[key].append(surf) redundant_surfaces = {replace.id: keep diff --git a/tests/unit_tests/test_geometry.py b/tests/unit_tests/test_geometry.py index f5a1bd7db6..6cc577c820 100644 --- a/tests/unit_tests/test_geometry.py +++ b/tests/unit_tests/test_geometry.py @@ -390,3 +390,16 @@ def test_get_all_nuclides(): c2 = openmc.Cell(fill=m2, region=+s) geom = openmc.Geometry([c1, c2]) assert geom.get_all_nuclides() == ['Be9', 'Fe56'] + + +def test_redundant_surfaces(): + # Make sure boundary condition is accounted for + s1 = openmc.Sphere(r=5.0) + s2 = openmc.Sphere(r=5.0, boundary_type="vacuum") + c1 = openmc.Cell(region=-s1) + c2 = openmc.Cell(region=+s1) + u_lower = openmc.Universe(cells=[c1, c2]) + c3 = openmc.Cell(fill=u_lower, region=-s2) + geom = openmc.Geometry([c3]) + redundant_surfs = geom.remove_redundant_surfaces() + assert len(redundant_surfs) == 0 From 26280bc9d514568d7439885f02267aa40176b710 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Thu, 11 Apr 2024 08:10:45 -0500 Subject: [PATCH 16/48] Add MPI calls to DAGMC external test. (#2948) --- tests/regression_tests/dagmc/external/main.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/regression_tests/dagmc/external/main.cpp b/tests/regression_tests/dagmc/external/main.cpp index bebe088f60..3765cf79ae 100644 --- a/tests/regression_tests/dagmc/external/main.cpp +++ b/tests/regression_tests/dagmc/external/main.cpp @@ -5,6 +5,7 @@ #include "openmc/geometry.h" #include "openmc/geometry_aux.h" #include "openmc/material.h" +#include "openmc/message_passing.h" #include "openmc/nuclide.h" #include @@ -14,7 +15,12 @@ int main(int argc, char* argv[]) int openmc_err; // Initialise OpenMC +#ifdef OPENMC_MPI + MPI_Comm world = MPI_COMM_WORLD; + openmc_err = openmc_init(argc, argv, &world); +#else openmc_err = openmc_init(argc, argv, nullptr); +#endif if (openmc_err == -1) { // This happens for the -h and -v flags return EXIT_SUCCESS; @@ -104,5 +110,9 @@ int main(int argc, char* argv[]) if (openmc_err) fatal_error(openmc_err_msg); +#ifdef OPENMC_MPI + MPI_Finalize(); +#endif + return EXIT_SUCCESS; } From cfebe16127c0f6636d0370ac141df8a769bebded Mon Sep 17 00:00:00 2001 From: Jonathan Shimwell Date: Thu, 11 Apr 2024 16:35:29 +0100 Subject: [PATCH 17/48] added check for length of value passed into EnergyFilter (#2887) --- openmc/filter.py | 4 ++++ tests/unit_tests/test_filters.py | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/openmc/filter.py b/openmc/filter.py index 04b30261da..c1d86126a7 100644 --- a/openmc/filter.py +++ b/openmc/filter.py @@ -1377,6 +1377,10 @@ class EnergyFilter(RealFilter): """ units = 'eV' + def __init__(self, values, filter_id=None): + cv.check_length('values', values, 2) + super().__init__(values, filter_id) + def get_bin_index(self, filter_bin): # Use lower energy bound to find index for RealFilters deltas = np.abs(self.bins[:, 1] - filter_bin[1]) / filter_bin[1] diff --git a/tests/unit_tests/test_filters.py b/tests/unit_tests/test_filters.py index 4ace59e723..55bb62075b 100644 --- a/tests/unit_tests/test_filters.py +++ b/tests/unit_tests/test_filters.py @@ -1,6 +1,6 @@ import numpy as np import openmc -from pytest import fixture, approx +from pytest import fixture, approx, raises @fixture(scope='module') @@ -248,6 +248,11 @@ def test_energy(): assert len(f.values) == 710 +def test_energyfilter_error_handling(): + with raises(ValueError): + openmc.EnergyFilter([1e6]) + + def test_lethargy_bin_width(): f = openmc.EnergyFilter.from_group_structure('VITAMIN-J-175') assert len(f.lethargy_bin_width) == 175 From 9fd096b84338fe468c848b6c7bb992374c8ff072 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Thu, 11 Apr 2024 12:09:23 -0500 Subject: [PATCH 18/48] Add a `max_events` setting. (#2945) Co-authored-by: Paul Romano --- docs/source/io_formats/settings.rst | 22 +++++++++++++++------- include/openmc/particle_data.h | 3 --- include/openmc/settings.h | 4 ++-- openmc/settings.py | 27 +++++++++++++++++++++++++++ src/finalize.cpp | 1 + src/particle.cpp | 2 +- src/settings.cpp | 7 +++++++ tests/unit_tests/test_settings.py | 9 ++++++--- 8 files changed, 59 insertions(+), 16 deletions(-) diff --git a/docs/source/io_formats/settings.rst b/docs/source/io_formats/settings.rst index da71e4e600..9a339fb471 100644 --- a/docs/source/io_formats/settings.rst +++ b/docs/source/io_formats/settings.rst @@ -252,9 +252,9 @@ to false. *Default*: true ----------------------------------------- +------------------------------------- ```` Element ----------------------------------------- +------------------------------------- This element indicates the number of neutrons to run in flight concurrently when using event-based parallelism. A higher value uses more memory, but @@ -262,9 +262,17 @@ may be more efficient computationally. *Default*: 100000 ---------------------------- +--------------------------------- +```` Element +--------------------------------- + +This element indicates the maximum number of events a particle can undergo. + + *Default*: 1000000 + +----------------------- ```` Element ---------------------------- +----------------------- The ```` element allows the user to set a maximum scattering order to apply to every nuclide/material in the problem. That is, if the data @@ -276,11 +284,11 @@ then, OpenMC will only use up to the :math:`P_1` data. .. note:: This element is not used in the continuous-energy :ref:`energy_mode`. ---------------------------- +------------------------ ```` Element ---------------------------- +------------------------ -The ```` element indicates the number of times a particle can split during a history. +The ```` element indicates the number of times a particle can split during a history. *Default*: 1000 diff --git a/include/openmc/particle_data.h b/include/openmc/particle_data.h index cceab1d110..5c765e2e60 100644 --- a/include/openmc/particle_data.h +++ b/include/openmc/particle_data.h @@ -27,9 +27,6 @@ constexpr int MAX_DELAYED_GROUPS {8}; constexpr double CACHE_INVALID {-1.0}; -// Maximum number of collisions/crossings -constexpr int MAX_EVENTS {1000000}; - //========================================================================== // Aliases and type definitions diff --git a/include/openmc/settings.h b/include/openmc/settings.h index 69a8d7d13b..5e60009bcd 100644 --- a/include/openmc/settings.h +++ b/include/openmc/settings.h @@ -93,8 +93,8 @@ extern "C" int32_t gen_per_batch; //!< number of generations per batch extern "C" int64_t n_particles; //!< number of particles per generation extern int64_t - max_particles_in_flight; //!< Max num. event-based particles in flight - + max_particles_in_flight; //!< Max num. event-based particles in flight +extern int max_particle_events; //!< Maximum number of particle events extern ElectronTreatment electron_treatment; //!< how to treat secondary electrons extern array diff --git a/openmc/settings.py b/openmc/settings.py index a6d273901e..828d5aef44 100644 --- a/openmc/settings.py +++ b/openmc/settings.py @@ -109,6 +109,10 @@ class Settings: parallelism. .. versionadded:: 0.12 + max_particle_events : int + Maximum number of allowed particle events per source particle. + + .. versionadded:: 0.14.1 max_order : None or int Maximum scattering order to apply globally when in multi-group mode. max_splits : int @@ -334,6 +338,7 @@ class Settings: self._event_based = None self._max_particles_in_flight = None + self._max_particle_events = None self._write_initial_source = None self._weight_windows = cv.CheckedList(WeightWindows, 'weight windows') self._weight_window_generators = cv.CheckedList(WeightWindowGenerator, 'weight window generators') @@ -938,6 +943,16 @@ class Settings: cv.check_greater_than('max particles in flight', value, 0) self._max_particles_in_flight = value + @property + def max_particle_events(self) -> int: + return self._max_particle_events + + @max_particle_events.setter + def max_particle_events(self, value: int): + cv.check_type('max particle events', value, Integral) + cv.check_greater_than('max particle events', value, 0) + self._max_particle_events = value + @property def write_initial_source(self) -> bool: return self._write_initial_source @@ -1341,6 +1356,11 @@ class Settings: elem = ET.SubElement(root, "max_particles_in_flight") elem.text = str(self._max_particles_in_flight).lower() + def _create_max_events_subelement(self, root): + if self._max_particle_events is not None: + elem = ET.SubElement(root, "max_particle_events") + elem.text = str(self._max_particle_events).lower() + def _create_material_cell_offsets_subelement(self, root): if self._material_cell_offsets is not None: elem = ET.SubElement(root, "material_cell_offsets") @@ -1719,6 +1739,11 @@ class Settings: if text is not None: self.max_particles_in_flight = int(text) + def _max_particle_events_from_xml_element(self, root): + text = get_text(root, 'max_particle_events') + if text is not None: + self.max_particle_events = int(text) + def _material_cell_offsets_from_xml_element(self, root): text = get_text(root, 'material_cell_offsets') if text is not None: @@ -1820,6 +1845,7 @@ class Settings: self._create_delayed_photon_scaling_subelement(element) self._create_event_based_subelement(element) self._create_max_particles_in_flight_subelement(element) + self._create_max_events_subelement(element) self._create_material_cell_offsets_subelement(element) self._create_log_grid_bins_subelement(element) self._create_write_initial_source_subelement(element) @@ -1923,6 +1949,7 @@ class Settings: settings._delayed_photon_scaling_from_xml_element(elem) settings._event_based_from_xml_element(elem) settings._max_particles_in_flight_from_xml_element(elem) + settings._max_particle_events_from_xml_element(elem) settings._material_cell_offsets_from_xml_element(elem) settings._log_grid_bins_from_xml_element(elem) settings._write_initial_source_from_xml_element(elem) diff --git a/src/finalize.cpp b/src/finalize.cpp index a41c2f783a..26efc9723a 100644 --- a/src/finalize.cpp +++ b/src/finalize.cpp @@ -91,6 +91,7 @@ int openmc_finalize() settings::max_lost_particles = 10; settings::max_order = 0; settings::max_particles_in_flight = 100000; + settings::max_particle_events = 1000000; settings::max_splits = 1000; settings::max_tracks = 1000; settings::max_write_lost_particles = -1; diff --git a/src/particle.cpp b/src/particle.cpp index c2e340201b..a91113c61a 100644 --- a/src/particle.cpp +++ b/src/particle.cpp @@ -381,7 +381,7 @@ void Particle::event_revive_from_secondary() { // If particle has too many events, display warning and kill it ++n_event(); - if (n_event() == MAX_EVENTS) { + if (n_event() == settings::max_particle_events) { warning("Particle " + std::to_string(id()) + " underwent maximum number of events."); wgt() = 0.0; diff --git a/src/settings.cpp b/src/settings.cpp index a5256c9c26..9f183a6c16 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -96,6 +96,7 @@ int32_t gen_per_batch {1}; int64_t n_particles {-1}; int64_t max_particles_in_flight {100000}; +int max_particle_events {1000000}; ElectronTreatment electron_treatment {ElectronTreatment::TTB}; array energy_cutoff {0.0, 1000.0, 0.0, 0.0}; @@ -156,6 +157,12 @@ void get_run_parameters(pugi::xml_node node_base) std::stoll(get_node_value(node_base, "max_particles_in_flight")); } + // Get maximum number of events allowed per particle + if (check_for_node(node_base, "max_particle_events")) { + max_particle_events = + std::stoll(get_node_value(node_base, "max_particle_events")); + } + // Get number of basic batches if (check_for_node(node_base, "batches")) { n_batches = std::stoi(get_node_value(node_base, "batches")); diff --git a/tests/unit_tests/test_settings.py b/tests/unit_tests/test_settings.py index b1737c4614..30930ecdff 100644 --- a/tests/unit_tests/test_settings.py +++ b/tests/unit_tests/test_settings.py @@ -27,8 +27,8 @@ def test_export_to_xml(run_in_tmpdir): s.survival_biasing = True s.cutoff = {'weight': 0.25, 'weight_avg': 0.5, 'energy_neutron': 1.0e-5, 'energy_photon': 1000.0, 'energy_electron': 1.0e-5, - 'energy_positron': 1.0e-5, 'time_neutron': 1.0e-5, - 'time_photon': 1.0e-5, 'time_electron': 1.0e-5, + 'energy_positron': 1.0e-5, 'time_neutron': 1.0e-5, + 'time_photon': 1.0e-5, 'time_electron': 1.0e-5, 'time_positron': 1.0e-5} mesh = openmc.RegularMesh() mesh.lower_left = (-10., -10., -10.) @@ -59,6 +59,8 @@ def test_export_to_xml(run_in_tmpdir): s.write_initial_source = True s.weight_window_checkpoints = {'surface': True, 'collision': False} + s.max_particle_events = 100 + # Make sure exporting XML works s.export_to_xml() @@ -92,7 +94,7 @@ def test_export_to_xml(run_in_tmpdir): assert s.cutoff == {'weight': 0.25, 'weight_avg': 0.5, 'energy_neutron': 1.0e-5, 'energy_photon': 1000.0, 'energy_electron': 1.0e-5, 'energy_positron': 1.0e-5, - 'time_neutron': 1.0e-5, 'time_photon': 1.0e-5, + 'time_neutron': 1.0e-5, 'time_photon': 1.0e-5, 'time_electron': 1.0e-5, 'time_positron': 1.0e-5} assert isinstance(s.entropy_mesh, openmc.RegularMesh) assert s.entropy_mesh.lower_left == [-10., -10., -10.] @@ -128,3 +130,4 @@ def test_export_to_xml(run_in_tmpdir): assert vol.lower_left == (-10., -10., -10.) assert vol.upper_right == (10., 10., 10.) assert s.weight_window_checkpoints == {'surface': True, 'collision': False} + assert s.max_particle_events == 100 From 4ba053ca472a983a773abf706979455e2bf79a43 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Fri, 12 Apr 2024 11:03:19 -0500 Subject: [PATCH 19/48] Generate Region Plots Directly (#2895) Co-authored-by: Paul Romano --- openmc/cell.py | 6 ++-- openmc/region.py | 55 +++++++++++++++++++++++++++++++++ tests/unit_tests/test_region.py | 10 ++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/openmc/cell.py b/openmc/cell.py index 998640930a..6de8eadec8 100644 --- a/openmc/cell.py +++ b/openmc/cell.py @@ -594,7 +594,7 @@ class Cell(IDManagerMixin): # Make water blue water = openmc.Cell(fill=h2o) - universe.plot(..., colors={water: (0., 0., 1.)) + water.plot(colors={water: (0., 0., 1.)}) seed : int Seed for the random number generator openmc_exec : str @@ -619,8 +619,10 @@ class Cell(IDManagerMixin): """ # Create dummy universe but preserve used_ids - u = openmc.Universe(cells=[self], universe_id=openmc.Universe.next_id + 1) + next_id = openmc.Universe.next_id + u = openmc.Universe(cells=[self]) openmc.Universe.used_ids.remove(u.id) + openmc.Universe.next_id = next_id return u.plot(*args, **kwargs) def create_xml_subelement(self, xml_element, memo=None): diff --git a/openmc/region.py b/openmc/region.py index 534ee9b826..d3c03b8983 100644 --- a/openmc/region.py +++ b/openmc/region.py @@ -1,9 +1,11 @@ from abc import ABC, abstractmethod from collections.abc import MutableSequence from copy import deepcopy +import warnings import numpy as np +import openmc from .bounding_box import BoundingBox @@ -334,6 +336,59 @@ class Region(ABC): return type(self)(n.rotate(rotation, pivot=pivot, order=order, inplace=inplace, memo=memo) for n in self) + def plot(self, *args, **kwargs): + """Display a slice plot of the region. + + .. versionadded:: 0.14.1 + + Parameters + ---------- + origin : iterable of float + Coordinates at the origin of the plot. If left as None then the + bounding box center will be used to attempt to ascertain the origin. + Defaults to (0, 0, 0) if the bounding box is not finite + width : iterable of float + Width of the plot in each basis direction. If left as none then the + bounding box width will be used to attempt to ascertain the plot + width. Defaults to (10, 10) if the bounding box is not finite + pixels : Iterable of int or int + If iterable of ints provided, then this directly sets the number of + pixels to use in each basis direction. If int provided, then this + sets the total number of pixels in the plot and the number of pixels + in each basis direction is calculated from this total and the image + aspect ratio. + basis : {'xy', 'xz', 'yz'} + The basis directions for the plot + seed : int + Seed for the random number generator + openmc_exec : str + Path to OpenMC executable. + axes : matplotlib.Axes + Axes to draw to + outline : bool + Whether outlines between color boundaries should be drawn + axis_units : {'km', 'm', 'cm', 'mm'} + Units used on the plot axis + **kwargs + Keyword arguments passed to :func:`matplotlib.pyplot.imshow` + + Returns + ------- + matplotlib.axes.Axes + Axes containing resulting image + + """ + for key in ('color_by', 'colors', 'legend', 'legend_kwargs'): + if key in kwargs: + warnings.warn(f"The '{key}' argument is present but won't be applied in a region plot") + + # Create cell while not perturbing use of autogenerated IDs + next_id = openmc.Cell.next_id + c = openmc.Cell(region=self) + openmc.Cell.used_ids.remove(c.id) + openmc.Cell.next_id = next_id + return c.plot(*args, **kwargs) + class Intersection(Region, MutableSequence): r"""Intersection of two or more regions. diff --git a/tests/unit_tests/test_region.py b/tests/unit_tests/test_region.py index 3197a8d724..8c9e0afe4d 100644 --- a/tests/unit_tests/test_region.py +++ b/tests/unit_tests/test_region.py @@ -241,3 +241,13 @@ def test_invalid_operands(): with pytest.raises(ValueError, match='must be of type Region'): openmc.Complement(z) + +def test_plot(): + # Create region and plot + region = -openmc.Sphere() & +openmc.XPlane() + c_before = openmc.Cell() + region.plot() + + # Ensure that calling plot doesn't affect cell ID space + c_after = openmc.Cell() + assert c_after.id - 1 == c_before.id From e77a5247b67a230f4fa7e9294f460e6d43f16cde Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Fri, 12 Apr 2024 12:14:13 -0500 Subject: [PATCH 20/48] Support UnstructuredMesh for IndependentSource (#2949) Co-authored-by: Jonathan Shimwell Co-authored-by: Paul Romano --- openmc/filter.py | 8 ++-- openmc/mesh.py | 42 ++++++++++++++++++- openmc/source.py | 10 +++-- tests/unit_tests/test_mesh.py | 19 +++++++++ tests/unit_tests/test_source_mesh.py | 63 +++++++++++++++++++++++----- 5 files changed, 123 insertions(+), 19 deletions(-) diff --git a/openmc/filter.py b/openmc/filter.py index c1d86126a7..66d832c694 100644 --- a/openmc/filter.py +++ b/openmc/filter.py @@ -864,10 +864,10 @@ class MeshFilter(Filter): cv.check_type('filter mesh', mesh, openmc.MeshBase) self._mesh = mesh if isinstance(mesh, openmc.UnstructuredMesh): - if mesh.volumes is None: - self.bins = [] - else: + if mesh.has_statepoint_data: self.bins = list(range(len(mesh.volumes))) + else: + self.bins = [] else: self.bins = list(mesh.indices) @@ -982,7 +982,7 @@ class MeshFilter(Filter): if translation: out.translation = [float(x) for x in translation.split()] return out - + class MeshBornFilter(MeshFilter): """Filter events by the mesh cell a particle originated from. diff --git a/openmc/mesh.py b/openmc/mesh.py index a12bd58534..280f447fab 100644 --- a/openmc/mesh.py +++ b/openmc/mesh.py @@ -3,6 +3,7 @@ import typing import warnings from abc import ABC, abstractmethod from collections.abc import Iterable +from functools import wraps from math import pi, sqrt, atan2 from numbers import Integral, Real from pathlib import Path @@ -39,7 +40,8 @@ class MeshBase(IDManagerMixin, ABC): bounding_box : openmc.BoundingBox Axis-aligned bounding box of the mesh as defined by the upper-right and lower-left coordinates. - + indices : Iterable of tuple + An iterable of mesh indices for each mesh element, e.g. [(1, 1, 1), (2, 1, 1), ...] """ next_id = 1 @@ -66,6 +68,11 @@ class MeshBase(IDManagerMixin, ABC): def bounding_box(self) -> openmc.BoundingBox: return openmc.BoundingBox(self.lower_left, self.upper_right) + @property + @abstractmethod + def indices(self): + pass + def __repr__(self): string = type(self).__name__ + '\n' string += '{0: <16}{1}{2}\n'.format('\tID', '=\t', self._id) @@ -1914,6 +1921,17 @@ class SphericalMesh(StructuredMesh): return arr +def require_statepoint_data(func): + @wraps(func) + def wrapper(self: UnstructuredMesh, *args, **kwargs): + if not self._has_statepoint_data: + raise AttributeError(f'The "{func.__name__}" property requires ' + 'information about this mesh to be loaded ' + 'from a statepoint file.') + return func(self, *args, **kwargs) + return wrapper + + class UnstructuredMesh(MeshBase): """A 3D unstructured mesh @@ -1990,6 +2008,7 @@ class UnstructuredMesh(MeshBase): self.library = library self._output = False self.length_multiplier = length_multiplier + self._has_statepoint_data = False @property def filename(self): @@ -2010,6 +2029,7 @@ class UnstructuredMesh(MeshBase): self._library = lib @property + @require_statepoint_data def size(self): return self._size @@ -2028,6 +2048,7 @@ class UnstructuredMesh(MeshBase): self._output = val @property + @require_statepoint_data def volumes(self): """Return Volumes for every mesh cell if populated by a StatePoint file @@ -2046,26 +2067,32 @@ class UnstructuredMesh(MeshBase): self._volumes = volumes @property + @require_statepoint_data def total_volume(self): return np.sum(self.volumes) @property + @require_statepoint_data def vertices(self): return self._vertices @property + @require_statepoint_data def connectivity(self): return self._connectivity @property + @require_statepoint_data def element_types(self): return self._element_types @property + @require_statepoint_data def centroids(self): return np.array([self.centroid(i) for i in range(self.n_elements)]) @property + @require_statepoint_data def n_elements(self): if self._n_elements is None: raise RuntimeError("No information about this mesh has " @@ -2096,6 +2123,15 @@ class UnstructuredMesh(MeshBase): def n_dimension(self): return 3 + @property + @require_statepoint_data + def indices(self): + return [(i,) for i in range(self.n_elements)] + + @property + def has_statepoint_data(self) -> bool: + return self._has_statepoint_data + def __repr__(self): string = super().__repr__() string += '{: <16}=\t{}\n'.format('\tFilename', self.filename) @@ -2106,13 +2142,16 @@ class UnstructuredMesh(MeshBase): return string @property + @require_statepoint_data def lower_left(self): return self.vertices.min(axis=0) @property + @require_statepoint_data def upper_right(self): return self.vertices.max(axis=0) + @require_statepoint_data def centroid(self, bin: int): """Return the vertex averaged centroid of an element @@ -2257,6 +2296,7 @@ class UnstructuredMesh(MeshBase): library = group['library'][()].decode() mesh = cls(filename=filename, library=library, mesh_id=mesh_id) + mesh._has_statepoint_data = True vol_data = group['volumes'][()] mesh.volumes = np.reshape(vol_data, (vol_data.shape[0],)) mesh.n_elements = mesh.volumes.size diff --git a/openmc/source.py b/openmc/source.py index 5942a3107c..a77ca8b04c 100644 --- a/openmc/source.py +++ b/openmc/source.py @@ -500,9 +500,13 @@ class MeshSource(SourceBase): elem.set("mesh", str(self.mesh.id)) # write in the order of mesh indices - for idx in self.mesh.indices: - idx = tuple(i - 1 for i in idx) - elem.append(self.sources[idx].to_xml_element()) + if isinstance(self.mesh, openmc.UnstructuredMesh): + for s in self.sources: + elem.append(s.to_xml_element()) + else: + for idx in self.mesh.indices: + idx = tuple(i - 1 for i in idx) + elem.append(self.sources[idx].to_xml_element()) @classmethod def from_xml_element(cls, elem: ET.Element, meshes) -> openmc.MeshSource: diff --git a/tests/unit_tests/test_mesh.py b/tests/unit_tests/test_mesh.py index 50e1236a82..d50d2968e0 100644 --- a/tests/unit_tests/test_mesh.py +++ b/tests/unit_tests/test_mesh.py @@ -298,3 +298,22 @@ def test_CylindricalMesh_get_indices_at_coords(): assert mesh.get_indices_at_coords([98, 200.1, 299]) == (0, 1, 0) # second angle quadrant assert mesh.get_indices_at_coords([98, 199.9, 299]) == (0, 2, 0) # third angle quadrant assert mesh.get_indices_at_coords([102, 199.1, 299]) == (0, 3, 0) # forth angle quadrant + +def test_umesh_roundtrip(run_in_tmpdir, request): + umesh = openmc.UnstructuredMesh(request.path.parent / 'test_mesh_tets.e', 'moab') + umesh.output = True + + # create a tally using this mesh + mf = openmc.MeshFilter(umesh) + tally = openmc.Tally() + tally.filters = [mf] + tally.scores = ['flux'] + + tallies = openmc.Tallies([tally]) + tallies.export_to_xml() + + xml_tallies = openmc.Tallies.from_xml() + xml_tally = xml_tallies[0] + xml_mesh = xml_tally.filters[0].mesh + + assert umesh.id == xml_mesh.id diff --git a/tests/unit_tests/test_source_mesh.py b/tests/unit_tests/test_source_mesh.py index 7bec2d0724..f0813d2f9e 100644 --- a/tests/unit_tests/test_source_mesh.py +++ b/tests/unit_tests/test_source_mesh.py @@ -208,23 +208,29 @@ def test_roundtrip(run_in_tmpdir, model, request): ################### # MeshSource tests ################### -@pytest.mark.parametrize('mesh_type', ('rectangular', 'cylindrical')) -def test_mesh_source_independent(run_in_tmpdir, mesh_type): +@pytest.fixture +def void_model(): """ A void model containing a single box """ - min, max = -10, 10 - box = openmc.model.RectangularParallelepiped( - min, max, min, max, min, max, boundary_type='vacuum') + model = openmc.Model() - geometry = openmc.Geometry([openmc.Cell(region=-box)]) + box = openmc.model.RectangularParallelepiped(*[-10, 10]*3, boundary_type='vacuum') + model.geometry = openmc.Geometry([openmc.Cell(region=-box)]) - settings = openmc.Settings() - settings.particles = 100 - settings.batches = 10 - settings.run_mode = 'fixed source' + model.settings.particles = 100 + model.settings.batches = 10 + model.settings.run_mode = 'fixed source' - model = openmc.Model(geometry=geometry, settings=settings) + return model + + +@pytest.mark.parametrize('mesh_type', ('rectangular', 'cylindrical')) +def test_mesh_source_independent(run_in_tmpdir, void_model, mesh_type): + """ + A void model containing a single box + """ + model = void_model # define a 2 x 2 x 2 mesh if mesh_type == 'rectangular': @@ -310,6 +316,41 @@ def test_mesh_source_independent(run_in_tmpdir, mesh_type): assert mesh_source.strength == 1.0 +@pytest.mark.parametrize("library", ('moab', 'libmesh')) +def test_umesh_source_independent(run_in_tmpdir, request, void_model, library): + import openmc.lib + # skip the test if the library is not enabled + if library == 'moab' and not openmc.lib._dagmc_enabled(): + pytest.skip("DAGMC (and MOAB) mesh not enabled in this build.") + + if library == 'libmesh' and not openmc.lib._libmesh_enabled(): + pytest.skip("LibMesh is not enabled in this build.") + + model = void_model + + mesh_filename = Path(request.fspath).parent / "test_mesh_tets.e" + uscd_mesh = openmc.UnstructuredMesh(mesh_filename, library) + ind_source = openmc.IndependentSource() + n_elements = 12_000 + model.settings.source = openmc.MeshSource(uscd_mesh, n_elements*[ind_source]) + model.export_to_model_xml() + try: + openmc.lib.init() + openmc.lib.simulation_init() + sites = openmc.lib.sample_external_source(10) + openmc.lib.statepoint_write('statepoint.h5') + finally: + openmc.lib.finalize() + + with openmc.StatePoint('statepoint.h5') as sp: + uscd_mesh = sp.meshes[uscd_mesh.id] + + # ensure at least that all sites are inside the mesh + bounding_box = uscd_mesh.bounding_box + for site in sites: + assert site.r in bounding_box + + def test_mesh_source_file(run_in_tmpdir): # Creating a source file with a single particle source_particle = openmc.SourceParticle(time=10.0) From 2974d53b3c07dc1a822f2b31ecd27b6553cbd458 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Mon, 15 Apr 2024 09:25:32 -0500 Subject: [PATCH 21/48] Update minimum Python version to 3.8 (#2958) --- .github/workflows/ci.yml | 6 +++--- docs/source/devguide/styleguide.rst | 2 +- docs/source/usersguide/install.rst | 2 +- setup.py | 7 +++---- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99d2155fad..c5accc5e0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,9 +35,6 @@ jobs: vectfit: [n] include: - - python-version: "3.7" - omp: n - mpi: n - python-version: "3.8" omp: n mpi: n @@ -47,6 +44,9 @@ jobs: - python-version: "3.11" omp: n mpi: n + - python-version: "3.12" + omp: n + mpi: n - dagmc: y python-version: "3.10" mpi: y diff --git a/docs/source/devguide/styleguide.rst b/docs/source/devguide/styleguide.rst index 9f6f82f066..3c71d14ad9 100644 --- a/docs/source/devguide/styleguide.rst +++ b/docs/source/devguide/styleguide.rst @@ -146,7 +146,7 @@ Style for Python code should follow PEP8_. Docstrings for functions and methods should follow numpydoc_ style. -Python code should work with Python 3.7+. +Python code should work with Python 3.8+. Use of third-party Python packages should be limited to numpy_, scipy_, matplotlib_, pandas_, and h5py_. Use of other third-party packages must be diff --git a/docs/source/usersguide/install.rst b/docs/source/usersguide/install.rst index ab2cbe6454..25329a77fd 100644 --- a/docs/source/usersguide/install.rst +++ b/docs/source/usersguide/install.rst @@ -540,7 +540,7 @@ to install the Python package in :ref:`"editable" mode `. Prerequisites ------------- -The Python API works with Python 3.7+. In addition to Python itself, the API +The Python API works with Python 3.8+. In addition to Python itself, the API relies on a number of third-party packages. All prerequisites can be installed using Conda_ (recommended), pip_, or through the package manager in most Linux distributions. diff --git a/setup.py b/setup.py index eeb0b3f293..a33037ad3e 100755 --- a/setup.py +++ b/setup.py @@ -53,19 +53,18 @@ kwargs = { 'Topic :: Scientific/Engineering' 'Programming Language :: C++', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', ], # Dependencies - 'python_requires': '>=3.7', + 'python_requires': '>=3.8', 'install_requires': [ 'numpy>=1.9', 'h5py', 'scipy', 'ipython', 'matplotlib', - 'pandas', 'lxml', 'uncertainties' + 'pandas', 'lxml', 'uncertainties', 'setuptools' ], 'extras_require': { 'depletion-mpi': ['mpi4py'], From d53155d234f681548534be5085383d423ecf88d6 Mon Sep 17 00:00:00 2001 From: Aidan Crilly <20111148+aidancrilly@users.noreply.github.com> Date: Thu, 18 Apr 2024 17:46:18 +0100 Subject: [PATCH 22/48] Added fix to cfloat_endf for length 11 endf floats (#2967) Co-authored-by: aidancrilly --- openmc/data/endf.c | 2 +- tests/unit_tests/test_endf.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/openmc/data/endf.c b/openmc/data/endf.c index 936fd3bbbe..dd5eee5418 100644 --- a/openmc/data/endf.c +++ b/openmc/data/endf.c @@ -14,7 +14,7 @@ double cfloat_endf(const char* buffer, int n) { - char arr[12]; // 11 characters plus a null terminator + char arr[13]; // 11 characters plus e and a null terminator int j = 0; // current position in arr int found_significand = 0; int found_exponent = 0; diff --git a/tests/unit_tests/test_endf.py b/tests/unit_tests/test_endf.py index 9e69708673..1d4982054c 100644 --- a/tests/unit_tests/test_endf.py +++ b/tests/unit_tests/test_endf.py @@ -23,6 +23,7 @@ def test_float_endf(): assert endf.float_endf('-1.+2') == approx(-100.0) assert endf.float_endf(' ') == 0.0 assert endf.float_endf('9.876540000000000') == approx(9.87654) + assert endf.float_endf('-2.225002+6') == approx(-2.225002e+6) def test_int_endf(): From 5111aa2621ec74280ca9b762ccc149cde70c531d Mon Sep 17 00:00:00 2001 From: John Tramm Date: Thu, 18 Apr 2024 17:10:16 -0500 Subject: [PATCH 23/48] Random Ray Transport (#2823) Co-authored-by: Gavin Ridley Co-authored-by: Paul Romano --- CMakeLists.txt | 3 + docs/source/_images/2x2_fsrs.jpeg | Bin 0 -> 271045 bytes docs/source/_images/2x2_materials.jpeg | Bin 0 -> 59424 bytes docs/source/io_formats/settings.rst | 26 + docs/source/methods/index.rst | 1 + docs/source/methods/random_ray.rst | 804 ++++++++++++++++++ docs/source/usersguide/data.rst | 2 + docs/source/usersguide/index.rst | 2 + docs/source/usersguide/random_ray.rst | 480 +++++++++++ examples/pincell_random_ray/build_xml.py | 203 +++++ include/openmc/boundary_condition.h | 1 + include/openmc/cell.h | 36 + include/openmc/constants.h | 2 + include/openmc/mgxs.h | 3 +- include/openmc/output.h | 4 +- include/openmc/plot.h | 1 + .../openmc/random_ray/flat_source_domain.h | 141 +++ include/openmc/random_ray/random_ray.h | 55 ++ .../openmc/random_ray/random_ray_simulation.h | 59 ++ include/openmc/settings.h | 6 +- include/openmc/timer.h | 1 + openmc/settings.py | 90 +- src/boundary_condition.cpp | 14 +- src/eigenvalue.cpp | 23 +- src/geometry.cpp | 12 +- src/main.cpp | 11 +- src/mesh.cpp | 2 +- src/output.cpp | 3 +- src/random_ray/flat_source_domain.cpp | 686 +++++++++++++++ src/random_ray/random_ray.cpp | 289 +++++++ src/random_ray/random_ray_simulation.cpp | 397 +++++++++ src/settings.cpp | 42 + src/simulation.cpp | 28 +- src/source.cpp | 52 +- src/tallies/tally.cpp | 7 +- src/timer.cpp | 2 + .../random_ray_basic/__init__.py | 0 .../random_ray_basic/inputs_true.dat | 108 +++ .../random_ray_basic/results_true.dat | 171 ++++ .../regression_tests/random_ray_basic/test.py | 232 +++++ .../random_ray_vacuum/__init__.py | 0 .../random_ray_vacuum/inputs_true.dat | 108 +++ .../random_ray_vacuum/results_true.dat | 171 ++++ .../random_ray_vacuum/test.py | 235 +++++ tests/testing_harness.py | 57 ++ tests/unit_tests/test_settings.py | 11 + 46 files changed, 4512 insertions(+), 69 deletions(-) create mode 100644 docs/source/_images/2x2_fsrs.jpeg create mode 100644 docs/source/_images/2x2_materials.jpeg create mode 100644 docs/source/methods/random_ray.rst create mode 100644 docs/source/usersguide/random_ray.rst create mode 100644 examples/pincell_random_ray/build_xml.py create mode 100644 include/openmc/random_ray/flat_source_domain.h create mode 100644 include/openmc/random_ray/random_ray.h create mode 100644 include/openmc/random_ray/random_ray_simulation.h create mode 100644 src/random_ray/flat_source_domain.cpp create mode 100644 src/random_ray/random_ray.cpp create mode 100644 src/random_ray/random_ray_simulation.cpp create mode 100644 tests/regression_tests/random_ray_basic/__init__.py create mode 100644 tests/regression_tests/random_ray_basic/inputs_true.dat create mode 100644 tests/regression_tests/random_ray_basic/results_true.dat create mode 100644 tests/regression_tests/random_ray_basic/test.py create mode 100644 tests/regression_tests/random_ray_vacuum/__init__.py create mode 100644 tests/regression_tests/random_ray_vacuum/inputs_true.dat create mode 100644 tests/regression_tests/random_ray_vacuum/results_true.dat create mode 100644 tests/regression_tests/random_ray_vacuum/test.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 6922e6b89a..3f87cbd7ec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -380,6 +380,9 @@ list(APPEND libopenmc_SOURCES src/progress_bar.cpp src/random_dist.cpp src/random_lcg.cpp + src/random_ray/random_ray_simulation.cpp + src/random_ray/random_ray.cpp + src/random_ray/flat_source_domain.cpp src/reaction.cpp src/reaction_product.cpp src/scattdata.cpp diff --git a/docs/source/_images/2x2_fsrs.jpeg b/docs/source/_images/2x2_fsrs.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..1c8e474d0e8f7b37adeb6e832b7197f6458f071e GIT binary patch literal 271045 zcmd43cU%@b31Bb)V9-DC zdmKmrEZD_%vB$dDu;CYb9BgbjHZBe>?w>zA0(@LN0z6z?d_sJJOBWk-L_~Or=%R9Q zlYd-_g#(A;_z6`$%J?r$$zb^m(@C#Sqn_j|3n3Sg@ z4jaZjKYfDk3~dIUr)|1L@k++3T4_W0Xx$be!OrBLf10pt@uCL{izKyg{i%YDRpfIM z2QPMa_hWU`I;V(!y!W35Z1Z%=pC#eV4>!c{0zB;aDPsT8UvQP3Xd_FZzjPUK+O6;Q zQlu2Y{*Np47{A=Ive&@bv-#80Ztk9QN+r9R$IWPD`UuA!lSA(_Z8KU#<)2n~GCFMf zk#^|{QH)eEeDOEvBaPlBIGOtS!Zc;|yyvhoN#mbSgYO%%#KFfUGjExEm1r;oy1Hw8 zxSr#q!=qP^7Z2dWWM5w%yBpDU509E^5JuJ>E{tH-4bNE(jJEku9BW?oJm&PgZpUg; zTsU6TE3{(^O(M7F%X&4&1dGe{DEAn@Z$h_(_bwri4W2J^6eSl8)Guao!k22HfyflO zUT$6PJA)UF28bGnWHIq}jZ%$L50%mQyu5e{?rOGQ8LRC@KGqiJ;O&-osvUIF+1>Q4 z`TkV8=zMj+ZbYV-(=++4;P~YEVVSa>m2Q`tBN@VCa;}z-ZU@F?^rYP#{(jz|$>eU* zAKJX?ZlG6&6nPr{%TIBs1oG(l&qI*Y-tzS@F^$1XMSlbEda%yDlX1DV&4$2Vw#{`xemUMi0BQR?)5Wkj zCT2{h9Hpm+#-=vD#tz+>!)>miyLl_G2U!o*aM{X=2Uk`LC7hT9%(b4Yhh^*cx3eDs zARy1c=E=3GxRDl^cX)}%8(=FD4!Ae+)QG?NCsOdiTV3`}Pl`p$a_DQK-Cz3k0J>{; z?#T-bPZO#*-qkmn0pP$DGr!U0Zdo;Vk8aKEjeHL@B$X+T@pIo2LTOz9Nb#!Hy3D~y zxHV21XLM#VnOW1?uE~7e+6)l$!tJm`br2HXCq23QuIoqCx=25O^&Dd$Sn#IVhy1H- z6+S))vXJWf4Svw`&s^R1maqLcXvm~B`=XF10S0^%!Z#yp0c@1JIH5qWxuc6`a}a;R z8<1WTJn2Rz&h@EocIY}r_<9lMx|k__Z64EBpY$>k-N+G2_ozZs?-g}eWTw7Q;w-7x zK!4Ps+t(4=2ljfV7FcZWS4HNibrQtkrUloL&F<=W>Ee>?71`W*`-2W=r{iNEL?ele zizWGj)H%!%@t0J*frzKJtnaH{Q##WlxN$sY@?jx$C|3e zG=DfN1ZGKW6l&jEjVs;=3XXzSKKN#^o&RA($NqHt^WbKWz~Y*+@Y#fX}&OiPZRIoGYi zCT5dQAT}GbXr||m8+x-E0{#WAqt?E8O#W6)yLntFXxC1 z+!}@xK3$hu)F4fhNso_T+q%^{?+wd~TFf%pS1EZ`BB+<R)}CmWrag|4 z;$!-kIy@3*odJwRlS+6QLG3N`+BtWomU?O$ybpfm+=r`R<5f5l;lB8KC8Dw_=;hEy2xURY#4=2r@weDug({)T8&ewH}FBVk@<9}^o?4WjQ z=dL^*I;`}lz9Tf@>RO7|17P8e)yI=`@yGO5#=7y6WCWh>nt18RTNRVG^3R52sW_V0 zi(Zk8!NB;8)`3-$1uY>rADL_ju;eo!jYOG???1)m@70>wayx0obrJPQD=KkYHJy5; z9vhbx7Bw4pwh&x;N!u@H3B?@vdLr^?qo<_)jW_680ECwkv2xfA`C#e2>4z8}-~QnC zk_y|wv|vZ8=N=;>X($O#69do0oE>`Rx!=I>F7Gsx&U1DRR219Ds`hX2%TN<~Vj)Yn zKm(5=K^a6r}z83vgfh1su{UA>!-&HnF`i0AX?L$%ZwReN zx2zL}$^Bwz9J<>p1;eo89l^o2W1 zh%vL^tby3!znL_~Rl>sDAkVir^gWj@k*Efp`T*sCR0_l3mx$}6;vatle^rspu7$gI zZSotu0p8ohG)ITw@`ZQRq3U6&-&|uZMF4=)Q6!jE#LL_27VHg2?k#qsjb8pcB9IX|9QGe2d|5dCL-!e)ASV~N3l<#w23}q=^rpy%5(}fMCq)?6tYaHK z(Q48J;Cbk_nRR*|KlG@T%>{a^u*JRO%*{$H8V;xeNTo7pFmi`1Xt!HNHB#*fU@S{X zx#i6Md&X*x$!o8=JI#srtvjvMqnkoW=2TsdfUn!2Z0r^sTLArWk z?XP544pa9y(rRyrkTR!KrQ(#7GSZjZQ0>85brU=CScnWmmbxn@XmxN@paWk995fk9 ze0=OIXqB@n4YV}&Cs;ns6Ewe-gHvs_WOy9Fy(hvGXj!E)L!+Xab7AL5-~5Z6)2K`= z=JSL&aGl))))8bDKP_b&bU;s_@);GkQZp7eYKcJhE6qzu6x(R%1bKPd9ysl`sp3pR zN*db!!hukYq`L&AhHya{5w*CaBH_9k&u$G~iW-yMCrQW>%zqMbsYoy#qe4d)HYkVW zIlz#{d3UcjCu@?)29`3Y#r_kt_fOYKtBxe>0j!>Mt8SlF%y)TkUd+^ft>owcZSu%w zb)t4cJ{G+`h;g40eu3VNlVWChB*Eamy@<5%QDHTy~F67uMLyD&nY#_4!}AY>u8Z23{s;T)P=Cu+ID74srh>mGg~Cp%hAt3}PiiHm7~^Nu&v z89un$baLz0Sv35(FrO27#ysXg+T}75OxI-j-1zP;N2wd;@;l98a}$P3+O_)mm+7){1pPGZLzd86`8zEpuG1cniHb*(BX!BM zJfnuBV4}6+)K@{9-t%H$rzI|1+h1!;@qWsm?8n1`UHKZGv1#5hU&&Y!?x4|ejq(#t zGRehX_n*E_FAQA(JN_B%QhK~owMP;8IWM{egUPmg6O?{>M(rW(aiiBNU~pTnC~+-3 z%D}4n8=H)E=;2d?P4Rj1aDzo#{7FcNE0Waz*f$m)tjAIW@xzEm^Ll;>L{nlyrpufn z1jJK(GHm=?)b9^&!g@TF!UfzjGOT{oc4GyO0N`dE8Pkb)DYEIk9z$XMnUSB+Jomch z5QP+#f6%vuT(F~mGeG+hY*26ZyPayr;DUc^eXKZuwT&(f9vA-x_xa4EvWs6!JBENK z`vq1jm9ZPr_XQ|~ZX|0*&vSh+B?%z%R3vfd&*YPJJ6vw$zwJGsnh-DzxDSV1gsCQw zaa5J+-5<3ClZ~F8ibB~{ zy%kU4k2h&WPqA``yL^VbbOs;NjMlQ|vPQXE=ZbM-EOK>J#+Zt)j}#th=+^>Ruj}h= zXFl!9mMT$8_QD3TFEG-`JEq9V-3^wk{pN525WUaBO&ndoKa$G(mQ|d15gk){FsfwxJHwoGiY-qN%#7LS0_G zDvLdFYce2^dgSj5_wzrNFBNdfcnMU{0E(i4k%9Om3!)jkJTFs%HQ3cdmQNnB)F5u9Tat}OcV+5i&Jod@y0xTehF4R&bGZEO zlY-Oumy%nOcC{S-Q{CV63M3440t_N zsRyw3kjr=_PmXmJ2*D+AhVJ5)TWBh*Ru_wT-Bfdyaz~|mrRb6NP9mKP3u`NI8Bx5=H6cTQb5xLP>GvAtmv8rcD*#{1=2KXUgy6%##MJ zNM2w1dO=hsFIsmWt+M|@^1ueF(pMOyrSo~ImYE{f40yk^TXH_85_SQ$+rI=E?$h7x zuMh@2i{GXvaaT5EhWKup4DQh^dA6DhX!nxXjxlhoHg|{5p(Tu3bp9JJveBK$15eMn zGe1;yMG!gvgmB|kEk!u`uw#E|PAL-rqV7AtLH|PR`7WMQ1*JKgzdZG=K#0#G2d0~UjjB2 zIWy^2>V5EnTp!Ji1padw>d$D-7eyx@rOhWN(_Mn%da}4` z4C&=F=mM<5w$%Z4vo?-&wpT_`1v>E+KG7GW|4m$>I&sv#0jAuuTg`)=(kSm;S3 zEucr-LDaCko6w`__U^B0^p?Hrc2lt9dBJr@F&$|U#jdWFic)UpPl9;`$)%M&qqf6M z^hMLxY=yA7g>b$!n=E_meIm7ra2?=nwPe?CF9=w?2vA5V@Fo_}BT zJ^!!kmdwUAV$OJc5G62G^ToQg#hlfI!1WoZnqQq+;ryn9Ipu8%=ld?3vVuS%!G*iE z``ZA)Wv!_2N>`Jea;VW-Jw+|r|Q zqDJ1;E8JhzU}^6H|FN^ft_;8xTDsu01O5cNoW}r*KuFVlI4nY4&K~Bh2!Niwsv4~^ zo1jT}E!1%U;;q^~p#(ra`LTJmUq!7G(KGl^Ard%yP#m`ujgE*m5CkQ`sJ?i*VSdVP zUV0frs$;H6yGodRa`~>^^WWfe^N-37rQuQk$`%PPlD%IH`@3E7M;;S7eRc0U!S0E0 zg@l%G&#*2K{tZM_1p8y^R_^2wabyB!J|ptmBV*ECo}QZ|77%EE7fC+(!7r|KWt@EE z+~Tm=`u4k2rCX84HVgpIFE|N>63V<=_JV0z#4~I;ZtacAy!%@7f@#(uK5A2Zp{u+g zgGNx}Qx|ar9r7hcYe(v8uEf<`d(rctQdt3|CQ%Mj14W5&I<7-*6Ih4I5 znKQ!a5n}Xqcb-ytB8;STTOo#(@>WI7cO6_(ZE<+8ALHyG`kYB+{W5ib{$OJ&X_7>?L%9s~i?qEq z@-W*twAx@`EKBZR;nF;Ev%%nrayx>;f=Q7{@mGZ{>y&`wK=xUt(&t24U~J^bBnyf3C!70PG@x{t6qOpk=5NOl9o4`>AJHV0?Nl zAg-x$*s^6+7rz&pz^$HDGL{!!(@0cwl3$=3D$w}C%wx7@S9Kubo7qlws*R{$?`-ux z)>xW#mU(Wzgv4eot*fIEcNIK|=P7_;4Sq6C>8}zd`?d{@awUW9Q<#MZlktPy@rem~ z&%X&GZud!^)p!AON`1=3a)-!@MTR=$iO=m1057_Kkhbd>_w7W2u=a3{7~d(nmELsO zyDG74m?h6*>$Vkhtme!8oTy0F=m zrTVNFm^~n%7Q#*%bLjFqwi+4=;NRS^-O^UpG8O2=nN+6aeIMGapOoTi@mGW(Pe%i} zn71Y83(9bD7|#g|&A;+EL*?Y%7cK8$E&rsg>EiScJH%x0ls z3E21J_wDm`vJfG6H`S`9jyLgM-rDW@E-bk^kO1yp%UCTY>Y~eL;i% z>1dE}@gYCnbl6`E2^!Ybj=>7A*fLHYa&BFyGpIG$5ulahc?y{vr)Mtv!Jg;#cQB-g z+WvAKW5?)BhaOp*2$rSSo=5g6s0&7->s<8h2&%qymVK1;afG9rM&QkecAdcnyZ`f> zm&<0RCf6wjgUoNWBf785CowoWuT_T0Uo+n9%=;!rm$e|x!zE{Z8qUkSsGetm zYfd;ClYjMfVfj=Br>NW1ELODluG^_Qmk#bA(q1eY zT0rY+xoPzet)%Msbo9Yyoi$U*1^4_T?BODEM!u0$uYnJFSTu#)%23X}TsLr-f70Y6 z)3K-f7&qOVmJl^RW9=0pJ?PG~SbiMA!>8%EBemh&L@QTkVxu-eQosL+-n5gt_N)1* z_vAzzJg4C2_N75@Kv3;W7BMH6gWrt&LlO)-Hd|z^n_?lAZ}O|0dd#|r6Sb7XpC$l% zKdcoxw32z9nOhuK1w~k%8`Hae%#Ma#ifS-d9Oh=}5_GQ-K1qqMiNOq1{F7%2`LBL% zKGqH>I>Wj=`kFpYUM0f9TzvF012AuHSPM|m8)SO1*ZPlvu5{e^6mAj7 zIG7U+|JJI#2Z2HJ;ZkJ6pH@5_gRRKYZ?)oplwT?dmFSEwX+NVv&08Z#+)LfcH^9B; zyhPbc%>N+tf=9Aas4hh5%o0ms(5=__)UD&7d=f^BxL1JzOF@KqUX z862aBqC?+ZlvfLA7&Siqqik`>8ybviJW=6HA7d+3|DVc(xMR|BVdn?$KORoqj&Mo* zVARYQaotx_pUU=chp4FC%$YiLiiva%@(ds<|B zm*~?{G6%BcV{-5Z8eK0Jue{qEs_MhW!1-z{cyvH;p51^AI|1SjubbV z`N=f}ZmcFOaKHe>}IErq;WX?mM~9 z1BOMsJ0yyqT3DIEu7n>c@jO-@f#NhdTyIduci*Uc|9SCP_4BqP1|d|ZSbfcrU!RNy z?2opW<#F6S_4611)8YE6e^+J^p6t7;&{C1xuM1UV{ljY08uq2xSol>0A%}TEPOpi* zDDBme6Qj4?GZh6foUX!;bh^$M$WDKQtu2H&unlTsaxancUFk~zd21hw%fF3C1eZVl zb9`JH^f1wbmD!BxSMy@YcGbvKDHZ~k5A#h<}E)%p3x8I zWt6_LC3>e@Y4W|E-!EZ>i#~v{=eDBCqDQ%Q;Ne{wF=T_TBXZRUo;-i=8uGL_OqXR*DN0W6_TYd!~Fr#b5Hj4qUx&@PHr~; zDsEx$%K>IZYI)@g3O0y*0K*8sk>TR7YheC#F&Z`|e>Sg0OxEfaHkKF2(vGA`!zWNw zT51|?FU_;~7q1W*5vI2^+6Oz}K+)h$l>R+TMf6*b3~$-Qf-#DzP)ozES?0?G;V!mI z^8do+i^jF0{uF+3bIYKGmc27Tq^#0#@BN&Vf?X?sIbdeLh|;Zw1TNM>G+ehx;U)ES z9KMI0&x8Hnz+mZEn}4kqFtsVQ_fv(s0l4n1uCJrz<9n8|s?hzE^9ov5fA*a{0yN}C zPZq9cxgj`hLjyJm6M1g@1_@lp#Fd$n#WHg`6`&fo@+)&4w>NBl zJ6;PtIPur!#I5e~Me%S+Z!9sws7HGd3h#uBys@jNhHoS7`etpGNOB>Bu$oKmx_0|5 zZKGytVc8h6iy2C{- zN~DOP`_($1!}La>uW@24P6%>H^@;A>9+=^cH)cnboCL6Sivyz5AUJ=Uenrzl2mra| zn;NwCd(Cr@_W3XVfdWCz2&7g_#wUsf(pHxr03M9WOP^;7>ZuC2P7n!15Xc|?%pr8- zZQ`(7C|41u2es(=ae+_m0xI9(uhs+aK~u2SXg48s*=N0ip`OK{PtVZ;(JByS0lLg4NHw00H*D-A6(BIZ%GuZ0vK0GQM(Rp z;Xic_b}_S;%#Hkq)rGGCjJmJOd-%+#>JhgH%u%7@xwUTu5t4gpPk(phXX~n!vCbaO zZ}1_kz0ut8Un&@v&7*-hrg6A&?^BGBg`;3^dm}%#h39M7@d;0mSYFhUvJ<_vI1G}$ z{@lp*AQN0frFZyLAePnWixgx0Z4YF25k|f4r_Dg^H#Fyazjf?({68JkKxt$$_Yq?BBJX{tJ z(G`asW7ITs%zq&J>4dMX!6L@n@9niTyq(4mc&6umU(+#gHihyG<~7G`mq$Iy_0h{x zlzA?-&OcsQdkXPW@BNT3aSLv zSC!JHhJN%Y3VBg%Y|}4d|DY{MV#S-M11Gl5+2jH4qcI(mp2L)Y$w?nI3&^>)asue% zb848*TrK34?OdcG*XI85H8U;GG0%zl?$7IYdoAV=r)$}U1ct96dhk#0m=gABNnhRw zzo71~a;MFmp*I1BAoyG@evC*^KT%o zbM#U3FM4TPKeg6CcvM#Fmjj#HLJ4ZA81>B2ahc`h7M{xMK9{KpvnS75X7(XjyLV{$ z%zj#GLuVQ_fz*Vk_!~Q2L|@%^1k?%%2N#yAzY}Q&0C0vnveU?jY0xtZ!K+#N#wUe7 zXt_==>Xzz4`y1Ta(>1I5fcs6ip!ClA5@^-nSZ(3H{&}L|Te&QWJgNs==h%v~#H$NR zSb!p=%z>(T7K_G=jtbkEC9uMxql@vSX6>J9e_nmzXK|r?K*ajFuUgr2y*<{$q-u{8 zD8WU?h{@nCgb!X!^E`YBLhNr$gvX_*Y}8QM6MJiA)4=ZNFr!1K)(vc}bay0Z)ufy)kr?^=!j z&J@@n>-a(1@x0Chezc#fxGwr$P-uD18{rji<7Rr>ceUD^2)hx+jg}tI7ZbB}I}+}l zC+=z~ar#Can^pT)?Id#ct`|vEA=?z!4T$9BUQxQzTnewAT)d-aMs^F6FHmM zms~wPSnMbp%A90&0HdHNz%_>X(j95`A8- zC*URB|ImMHFj&)>QW$TnmUHaW{VQP;*}`Q7NwCAi!(awLrwQBKeg3Gu+=q5gWPPn$}Euhl_n$ z)1uDW_KD1MQds~i_D8XCMh(hEOcvq;C<>oUM(OYL>#fgbQBlpcP3`wp;l7yL3SU%? zm!&uJoY-VfCM$Q!tc8ZV1=x2F4ikm&f^Sd>lah-=0RoW`jud*KukSlgn8Lgy&G^9P zF?yJ1c~5QLr-{F$g~wYqpFS0_w~Rc2vo0L$iM}1W^nJu{?V%^ZrKFb86c&5wx=&D6Q61+J2&6YiucLa@h@enU#JQc`FPF|Id@&C+xlUKBw zRhT~FE1$YJ+*fWV0tFyg;sIU+O-%EIzVP??%VAHnH_p8c3&(Bw_<0TjVzda9BO-zu z7(IGt+xJiQ1MO!O5}@HBxj`+XTZ}xpyF8r!)WPX2Wk}PR3zHlAi_lvmo8sVgHdFh6 zGep{XXXUpw4TdBOkz!{70e%vs)*vG_3Y?wpx6V(+Ng3EF=g!?b`lf6~7#JtMbDW!K zhb2R;IyK#Dh>8@{<^INhhbaVCp3(nVd=*{m`T@?DTCJ+Rjo2;?^eqN^hChQ}g3IvB z%kal=u$0>TIbn#*8+Q~LiFJ%UJ!4RdYEg6kp0|Hv&3isMbk z>}q*bsdPTfO>k@Y*r9J&_DfQ0aIwl}&vi$w)&rhDm*UY&Wi+#c5ok%Qk5#!NoPWJD zCOTL&=+}$TC%|c6*e!3NP}4`&()?v`t0{5uHE7)cWnaLOqf?WkyD6?}KKMi9WLfiY zg7c={#$ey&U>$&CkXg~Ibo_Z(2tGV+WQgFHaFr}L;^Gfj5p^ca&_&QYs$Bmz>n3Nl zR=j2^wEDC{Ws@#bLUZ`(8AAS!N^p*9Nlw?+G}+S&6tz0 za(?{0AD*2*JHGwMMMzv~RDYi;MeW z((?ACDe_S(jhHIixOCPB!+ORq@D_CR3dg-ecJ#yO`e&)Lhdl%w;cL;RBHXjN<(ODM zn``$tika7%w!RH(NK28$h>6)9F+hnHLBSCJsVu5w3}?Cz53UW%M9|lWyPhFj)^nN# zamv7nxaYYc%S!~ZyKo_)Gyykv&Tiz}9T$-k+g}WkYZN$v9mh>oBUrmF z6VChAIQAJo#_IQ7ot-jCy+p3w;$m~Kc<1)eJm?I8ggndVFg;hpXSc;BErlxwMNM?N zOqLhr=x?zKyaC|bfExW;w zgz_)}sVG}!i=kcbXMvlS9G~!e%sHDPXG>1l6|9&tN(`47Fx$4rhuKN(k`D6EaVy7W zE@WI?=kniVycR3tcxT@#xc#XE?_@ti6h}6t!?d*6b3fE;+8w#-=wqh}ja(X1Y|i`Q z(i>V{TWxv=%vz{lFYol)T3iSa9n#%T?urCevNQ4gtUmKp4L|wD2hFjICNX)G#mUj2 zz2L#bnZ4u`5zAwj^D3Th7IL8u>;i^d^Mx#tyd_C(>JE74{*+Xin_T(Ai;g*OxT>vy zg=~Z{;tXuNPm2zn*}KSb@g8ZbW*qDYmGv88yE79G295`<-wKaT4i)ttggHL>++6J% zf3o(Gt|%b1A(rOF#qejX2H}Hf&23rB(|-23vcm4EpS$6YnAx`7JJ=G@kgx%~U;1Is zXtCG>>r5K1M!=4L4aGDGh~Qgs*ceu7(s-fY&V;URG&(wU7Mj6Vyv;;5sXuuDxMN|e zWAY9mgT$W9#wP*Q03i!uL472w$-|tT6B`c-azpIzpLSmZT59rJ>c$f zP=J%NMy0xIDYB^o^G>G+xbm(UUZIurFV@gHp;vHu{DlM$8P2jJ-MXM{vaHrdCo*7p zK?jz*UB3#K=oWa}bwSC4=G#hNXUt;|Ox$s`kru5kKgCE#nw)d&&DCfX$0#0Z%m7Uq z_V2}{G0d&AD7p0z04pDRRo-{>K7`LRSE)Em<8s?O2^N4Kd})q98b8L5j_%u02_hpv zV0*;_kP8FcD?X!+*(x#`oKN&jxM$77VdDeHZ=Go=5|nu!7<#zpa%cma&w0kGteQOCG|t>iO3jt znd$=)2Mv91*vAgGqao=QH$56FSlY*!`!;Jc6$yBo21MvNlP}q>3MKiMGh>BZuhMU% zN<|)T1J)8wZhQ2>HvY{X*pX*m9>xvL)m|-N;AQz(cil;5be{Vd952J^9xkL!3Ze+s zMh_TSzXKo=8`oh`s$p!*k6R*ZP;-Z$#O62f2LDM%!bnueDn}mwVt_3LNgu7_%kq9{ zb`%r9)wvExXq!yrHk=oN+-0Jf6!nvHRkjvV3?Xr7GeA_z-XlL*?e5rDk}l-Tc`jh9 zmy=(e_5O8W*keO0W6{;zug^w^pyXQmcD#7Vkr?Lj^z_;u)^iVI<=ET4kGaesmE)wn z5N=K+n=pY z#g5hW3)$WU$o+ox?xna}Z^JaJ?`K)tAw;|Zwn9{wLd|Vy36xmjflFIpsT+W-*0mhw z(NWX5O9B8yPEb#B2c4Eq2sE7kM-(N>hVuTP2x(>G=MhZ}E*-ga+^BO{!9JuOa48DP zzH;pgij=DgBR?sq6yEC)(iW*^*Gv$B(w$SSZZ)fz;;~z9Lm7WhHbpv>uCemk=ia?! z%TW1QvJGcO8M1+h&fGc3V>v=Brkv(mV^_p6P-jE#t&i55ctTcZ^#4R`w%7ElOOVH^ zZf!dxbDXjH$14%}wP%D1!;%Qf6JZT+K*VCH9!sxkdE!dZtGuH8>L{g(^A=H`z$Yqq zNM>K^t2nW;INUW`*#@Ly!-+8OMN^%~s+Ydcme-#vU6R@G5r7mFSa0pGK)e42%C0l$ zi@Dsu#@wMEARuj}SY%^p}+;hV3{iUTAI(gb? zjPMu^#RN()KAdxUCV8kN9Bq;8`19a>FKJL)=5&{VeT3Y43Y)jmzLS3X`vn;+-gi36 zXQy20-q6(omD~IB9Azmg>`d0c#4pf0QCoi z(pfHv)pox@41e(*)*>Q=Jv}vPoH*3d&|s0GTpw1qeqYRgh=^k9-T}3Fe>vfr)}znv z4%$rl{2E;;vWAd@AgZSd?a^XpEYTPxG}PIDY5L;6h#FpG^|X$VMa$9(q^T}8Su0|z z$QDmc_pBe|1-tZy#PONb$GL8B%GNIOsBo4}W&QuL%xbua9EHs;g!{G*@4*dSmu$+1 zMjvKbex?48Kf_lh5T7`5P^>*SuiBlSd0^3t>4^KGhI<&%`+NAv|Lw$YaA2Lf6fgKm zfAZ|X1hH)KwY)*W5%T}3h{`qkA~Q1c%5tKOCLZ@Svq(WPv*#RSJozdFQA&h*xG{=r zy8kR2MSFVOC-26Wnvk(UMkA* zJx2Vf`s89K+T(~cgd7Je#1$2hu9TgW15dIg7RFZ@j-5_Eh-y`Te{6SziskIH+%Qef z>Z4YlWcR8^p)2;;hnmAkTP0rvx?<6PcA6)0ej3W0=La(R5$0l1_0Z!lcjZ}@+Jp>)AU5L)v?)6M@o99pmc1|;tERfX2?tT|Z# zi7R_|U!haenXa9l%m^S6qYWu|$^&_nAcZ!Z6!AE(EK~MO^>~+$ zugL!WYL~%ka>hdZEc=sBzY-mBq<6wyg@p>TJJ=xe8*d{?IbGeFd~mn>dCahmH1(Ee zE3aRsrUA4Tso^gPs*XCEC|&kwrWgkJUSl;y6}y=MBAT%OpCAOFY~3x~y*>o2is!A; zdPQD|wr1$NRzFk%Fj#$h8eW%XkR?#{i1RLR^f(^#6F1s%PiKF6%twpp4s3e$IwGg_ zsg2&NR)Tk;dJ1DbRQR>PVYXn+y%pPdLC=VKYhh$$z{yl5CG-~b$wGqKuUeV!T#C-x z2ggQcPkDXM7+c+RXw-jt-U5x9B9b4Ty#X;eO)YK-# zUKNG6I0{Lw^$_#{ zrwjA9Xy+dSHK@)o331?hda@_}J%moBw5pm(*@|K3g~4int3mn+#$>I71t-w5%b05Y z?u9V_H`0&^#hSG=feZJv4V2@pvQ@e4k@N1lsW_r4Wnf{5W>v}=wJ{RDyz;cQsTS5d z9pKlduQZU&+BGRkz6E*E^y(INX#6McfD4u594cPv58x$f=*tdgYTyFPddh*09;b$Vh%K~`btsa4NkgtP1blxOKJ-bdOVJLJj?}%%>@8NO#T3jNO{lfd+BXtj}``Ee6 zu8I>GuvmV*Q}T(Ar=6F0Vn2N+k#D+5?mdehr2{}+_Mu~rpRQB|V6k$1oO*(_dkX-| z+CB{QEM1$3I4eYZ|3kqc0MfFeqI^}B68s!~0vKaVU8;~@43mHtj{ZFM7y!g~fqWtm}g~|u7wL^)pzv`{^wkd`pargTxOQWT< z{CWUs|Bi|FSId1|`%2d}a}nwM&16DV;heGL$D*z(6e6znb0JqWq?VTFTXG$?_u`S( zI;1to&F-3-hme)}c;S=zh7($6A2)xhcZV(-RxBj6+w%IpV1eqpQ|)$1X^#7F@4BBDq&qX zv4LYRSZIiFo=|2tdtzY}sz81VvzAnOrp1)_R)ZSA){why2b6{z7DXAl3mF`*6`)lY z$l(y&oo`~eIpht=6{@#?aP1Ux*t)smMp`2dYlvPN`%IS4n_V-ZR98<{q)A$g;mn)n zJ7`dJtHRCu39gr)4Eh^cfAG0glVo)vK$mtKa_$H#U_F0*QG@VUT05}8taYB9 zVwM4(Cl|ZgV6@sR`>hv)Zy#Enozi5OobFC_(JMb<6s@&cWY2UA)Ouzw_V!>&E%(67 z&v-(m14m^8nfnAP9&bx>GH6-CJvR&J-}i;D5#P4T?Tef2T|LQizg?Y`MOdy_u)3m9 zzoO8fUvhsjwGZ6H(eeuws}hv%yOaA2$da>@AW>Zp*&Ve}bIqPF z^XjBE)TCJ^w}s8l8Yn2>{8p?2YInC9GrB#ue z$?O|x39PF4KfmgKX{8?BbFWb6(}`pEWnIP(s7R?&ubS><>wZ;D@9gUyb}7yq=GmjN zXVuE&Bo4Wksl$aTzSIH&oxa~7ie6?e;vu3GU|({_Do;#<8$rxTXm7&%#dzkPZrv|$ z*!3^XOs;N6_J}3OR7Q|``Q(%Z@L_WR>dAb;$wN*>QhUfh?YW_zJ+PoveBHeVTqNsp zHZA&g1r^eJ^4!;WzC>}(bvvJz;i*A_pwJ9=%pzsCUHn5F!B{~h-529_1AI7_!NHWG z+>o>`4cF@Z@(UJ&zoTei6`7Tk&~vBJo9D^r<|)YQVWD`!_0hKWs8zSZCQauXY5J{J zeb(yL{;zN8jTuJMjH^-Q80M%`5f|tN4u|@AWifumB8-c7GVmKe;EFNqqg8h>upG>t zJ5(C-a=Wj;PTgK}*Yb$Ek_#mD4w5Zn+o3PJoc&(6zD-R4Is?RKH2L_GknbwZprT9| zfUq;JAOj z04O%Q)>yh~4eNZ`02ViEfaLD}_M%8PWcLU|^BSIx{dEg`Rlr+e=vc9-{u8kX0PeYZ zm{wYQg~i>&1n{ry{AfBwrv<5yAlyd};5e~a4EdEW7DYC>()<<&@whR)MSd}Je#+&nFTSsbFaS7c}wb(pT6207>5sR(8=`< zY)}+;uNpx}g+9N0Kz*X?_wg-)h0KdXT_|$rhL#)LqVvTpx5T?9l0}nKI@)rt7>w4l zLaCwwAuWj#jpBRnDk_pkQJu{u_+0@^e0Kw;?>lEnTebX}?26SLF<+m@y&rJBTWz4K zjl0pr7Zu1RWS7(`1!ts%lW9CPm}skdM8Ih-S^xU+LGV{g%nPvD?s%a76y5~oUla~4 zp(@xG;Rf5hqHUy*fAH_PLfJ&xZJ`fO$uNR>$;M=yAD|o}dmGcZ8^JdDDOk7Jm3Puj zY38fBvVN`TF-L(KGBeu@O#U#C6>>Ko-4y-o!%Fm(o%Jxr^O4x+o+1w**iSH$^ zThP<-V{*`)M{icG1$n`GoEX3D!iN_+6si|F3%sEmvMu>(dY?%`mX4tc7ItT&|Ha%} z0Oi>%>!L62?hxDq1PK=0-66OIcXtm23l1R=JUGGK-7UDgySpX#&AH2SGqa^@ei3C8WCP0Kee6-r{(+yEBvgQca>o6Lhtt zjkNF^L9#vvOfN`lekrW_OF+&G{Gpd(sj3euyUYdC{G@js`H^j3Sjm3}21FT!@E0%} zjMq`6_EGt*gQ$52kSK1d?(Tj*s`6JgsX-S58 z8E&SlKe&={N)W422%t1f`!+1-{sL(M9t?5BM9u;rBIfiy&*}MB2NDT@rxh4pS2wqZ z6fYP8aP`b#!}Ak}0zwdYU&K!jHL}fP!HhEz6!leP+z!1<0g&w#3@@2LWOVtSApkk^ zbG^Fd?aT+#kpL)*T6c?riSUXQ!UyH0L=q8h0{Cq&aKn)^>K!~^d31msZlbNM;Q}_` z?(+1k``DWc21%axkB@G|_*KCwQkk`n_w!aFDdr3TD5#8%<(H6f&?*92?hU5>f(yBK?32P=K@ zo_SUQ06?*>2fIV!$K3I~d;WuKVA%g#aXqfzsnJ)K{!->p=N2;**6CrD$;@NZ?xM z2F5ew8%@b8GGLKOZ&CVM--1h`{U7ERHFf^RI)-~LUu3bZ>8LbS%YKP$>>eViZ^!ns zQ<4rGAg-dN`v=P!f(&3hBGNt{$Xkj1gG0w&1()E5Y(-rFCe=H5%j?~#z7TSaCg|`( zYHfNI;g@PXrdDJCK)$*1oyALq>OC~Y=Y@2$^!_fzh~r1%t9rMa{$?sG#M%so56$Ko zH=~uG+g(R1gLIehIfnbI{26btx$Fo7=ap94tl*b+v!dEI3Ih++q`!ErsEPb3FNNZb z(V-_R48$%rI=%T#oQ&2vMt#lFWrC*69^xU)jW zw{Xu^C+^6GbDP>sF4rPca1{c4i#Gcd=>Q%yo;@C2C zdkRxDk9G%tz|GThTd_3Y(FDLLx@VrS{E4=KxE3>L9~YLce4(f}{FkfO#+w&k-}ytX z$^h^oaI4%7Qmdv3>+{ws`DO)Rx?S-QLJ^?l0Z~fCEG&JY48hyMS+7>^QC?a~h+30W zUtFgXjph$PTxK5j4|(1MD3*Z3eZzF&4sZsya!9gjn$;N(1_qWkZy%(43jyHclsh$7 z%5d<`Jn)v=W<*7i;Z@X37646x#vODc$pO9j3V`2^euw+bC?tQS1Av?GcFV3e#Biv~ z2EZ++{xtnN5*=MX{&F|ygK4Og?nJM~T5)C~WX<@jvi4=kfT7!q9aCB`H4apaqP@ls zh@%crEC9k(OMA{#-0t{0PV;}camMClF58WVF=xkpQ!v~HzF?RQmRxB*9~(w10uVfb zBNx-Hlmt>RZ=Kb@r4oABBo9sh*u!sco_|-@@;{0`yK43Ssp#NNeO;ld`wu^tz6%*V z?$8vJLwRcjygVO295FKHfuj=*R3HAO&^5BZq{20gD|K^GIqd_riS=o~(sY!ycw)@y z;@&j7Ink*M84!6WeCWO?(&mD;hN3xSI*?Z*GkSXJ^$CF zcHZ+{C9-AEhLy%?sYEv`NmFo*U(YtluktO<1N`e1f*_mA!tpt`*mKU&j zjgOp7Bp@=h>5U*6tQ&nwg$ zuVsLfP&d@}#LV~C!_>ZI;O5a`ed{YjiFQtC@J$@MRb{>wyA^!@Q)KI1u>)<(3JNjb zBV=TWczr}TtE>Au_4J`&`5dqIveW0ufDr>MBC5Oz@IxjXwUxz9kK6MTW%5ykJKtIi z@%i>d8n?3QZ(>W={In34!D2#oHIhF$bK^B`&Qj|&jsq4Sr>hO)gt`3A3YAtL8~nMx z&L|%nQtRCz9SqC5ri)E)^)akPK1GWQ#f@s+mkcR+NBbaB&tLG9jW%EFj$Hz|*sxwK z%5CUKmkPP>QXmd5A6XkM(RVZI0Du$m15}1p*uJLnUqk~4S`q^X`b@n9#gA^kz1Ym$ z@9s^ps{EONfiLLhL%fm9{MDNQ@8AF^A+F!RUWnaf0G5vu+SuB#^;#$NlARBmqM#nE zw^YxgcasUYkgyJb{$J_9wu4Xybd@Bm+7$pqRMy!LS{{3>y}TNL#8zF+XnjUD{ZzSHj62aL&QaKNuSNp~Fa z{wByC-Tw$!a7)n`uIh3qFn)A)|%5wmzRaF-4-7S!LU8uLcy z*x_Lxcq7Xb*Ib(q&GI$tFX=|NORzNFeBTREBuE%qFz>5e^lH!d*WvKXos#R7UjOSE zn5k=0IWB?x5;Wxiz-RJN=dwxI6cUTk@DBit1?!(|#j0qHz3LwW-lpi&POVH)2g9`k zK%wnnsH`0@xfB2Z7oTUq=$S2?pOP|9H6ZU&b0Pu>Z*{gUQ%@2Sm z=S3;!ny&FttOSR(oervZaI|OZfkOch;0V`^rfwkmD>$cu+>Mho(Jps`0hdTI8*@?E+(;e**xn{4VgBDH0`&MH$z0%)GpPTqwSwNiln^_~$7AWnsa?rLQazt>GNl zxP&{n8Ml<9X8GxXxqT#-`%I0SP?RV#ck>~Wa zr}ft_+pwh+@rOrx)RI%A|U} z*y`4~FSkg(K|9&35|ccNx)L@@sA`M-n;JAKALw-u8vMH&9Kz*Z$J*=tr3RJ9#b;8} zSpT60dmm~aA2Xs5ZrT4*gOJtjlV@~GPz@RwPkUz&ElN+QW{;2iJ_gF%6zklSctjRdc zo}0lKugnD}I;%3B98QNM9teCs0KvbA0H7F536`d`t61h6<(`9RB~6vGJi?vnhc<8n zh)o5V8A?~C>p^QBf`WqnuDewg^h) z9Y*03-Wo&aa}nR^Y;8-ZsrEGI`%+O;&m-<-&$b`OoVbfpQKj_K7?o5KJ@K^r!_UR# zE4;cF$uqcf0=43`-D=)$xz>u=BLQ2M9{M}X8F&pI`WqW*;=Xq!qNhM|{fE9m zJUvuI$Y)g}Hfah>0I-Rt=7W(p8Gt-1XmD@@q2cU9KQ~Las70+LO>Y>WDSNcGy^&po zFn0jZGWI|((&Wp=6`JME+>@yO;=8VM2{y>s+4IivxBj|s!Q+fBxPSAj=%h!2YO`d4o2Ypd}aJOH9+ zjtQtNP7ex3jzTBRY;m|03O|-m#j7AW;hLf{tC{zG`2PhQpb45<`VGwUZP^?MHPi)k z*?C#d8id_=LD5I-gpCdn`=;&8d{ig|Xe#pBqbgtW&-Rf;Z>@GFeReTD?m;+^Gv3lh z4eo*N$PoMnQT%9rQ?2nso_)%WGPTxVZvU)Mg<}) z;iilC4J-zPItp9#*YK#%fa(SSZcOldCf}`XhYjeApbmk>S6*PA-~Feqn8A6N7cftN z=Ll)LtHsc62Y@7LJ?DjDx7eH>KWR}=eV7UaR)a~HB@|QFk(ufDZbQrQ4Ekg^X zWt#;+QiI`9PW>XJW)?ulmAXDUcCQc9?w<0lc|D3DHqQxq%ZLNYJiqCe2=%$Eqw#%W zQK$gU+pI^pdS0B-!_mPr!eSQy7%(u#JCS^9d#B;z@4ER?7wr{Xp9Kp3?}iA><`Q~> zL^_A^4P~C`$3tF4(rh!BXlmA(3_v&V=h%{KT_q0vgfwIbx}NZ=}}uaLj*~sO{ z+z>oG`62j@RsuR5AJeA=QA1;U=U9LCqBW@R7L910LHqVNa+sWBsMmxwak3W&EHkaa zmpwbPo9UJ>!;bV5f0fn#!I&VXlSQ5??zplO%5eN>Dx-I{oyUY)auwexeK4L@&Q+r3 zAQaYNBJ`XaYnEoA1Z%Gw)bjL2%MP7qbq4qifW6MG<)k|P%yD9l0SVJBa^FZPgXX71 zD1JQfj44V*oMm2bV3A7`?*+&I3N_l0$gkPy>6G69T0NQY+Zc$7RMjil4;;(YR+*Vi zKTY5wcIER*R2em;7(1Y@&Vy}%iV@Ckt+qTp@$DS?(hg$UQO)w^Zlr^8a5vgJJG11| zPpBPaZs1^Yz#R_By!cxp&)kEM3BDTNs;hic5?L<0u zY`eQ4H>}qi05CmG_t$<|UR~j@skmW=me7d48Swzm#Y7HnIeA``xFrMVkKS*i7Z8Iq zDH7&eE@5rp3w2e%AvKnjpQQ~=^H*E-TN2=8C*iTghceQSqwm_djCw-^SA_swL`M$5X{G?3g7iX+d z5VaZjIDkfq?4HsfHS;m3(>%u*A{B6~9)zZ~giA@!l;pVcVnZiu3jz2H1jY#&-h}nv z`59N2SL3k#-$O?3^6{(FE2N3Nwotf5L%(B%rl-qf-%2B=B`#&Sc8sR3t5Dzeas<4V z_#UULP#9WN)K3M=;Xo&i8##iFFx&YG;P27vA(W*r(I z2xrRK@Lxd-zEv@-N^sq%ZsVCRv1p0{pfgQ4D7xZ%%J)#6Pb?_h8u@&hB9eRDN{+Yg>#I5?Rp=(*7?0>Ba{SykP^Oxt#tv2sl4?z-!pm6}{iP%+n%HQC| zT;i0U2FEnVzIA?#lV!xp;m!(7h@xp}FfrXp3I@E6thOJDXg*ebq%BsArMebsHxC`q zJ2KF7euZp*E0x-xV@Vd38^|%m@cfu1=o1~XyQVYXbJG$udx^{$W-k%fbqAg=6R)ce zJ|zK1eZG1bN6UVyU$q*pNvOa2K7^Zw(B8ckG@T%E%h(zCyjaqRfw?UvTRoFu#ZS@1 zYrV&R{XVcf<||?wE?DXm>SM2I>7^$={XqMNg_kkw%DQL-EhRqTyNZG^qC=05;d{0| z4|nEN?_vFA?Y$B7OU_EDy%?Dn&$WoYU6uQ7S0afi)HeSMV~B{$J(fG=j7l9`05^T1 z5Ld2_z;xyfOZq8(xY&=uoVTj{s`h*WCz)d(RKfu6OJZXEv9Y$6=wI#n4S({c!Ue6Z zKi#1-%m4tK@Y+N4rDsvx`E}cSFy010(KoUlr=eL0YEv4+(%ijgPd%ndHM{g6-akA| z5)vunhKPJPq`oqH57TKUR2vV``e%BbTKN&&`;2Ew>t-Xh4;CQ6tE?$qyE&MihC9(k;7&s}{Oi!{2W;wdbU;bt z1jaU{6!taf>~$vLpMx?U#aJ7a0Q;Xo_9I`3EW>{l`~1T!$>FLP66+O2;_YhScw?ct z1(9n<2)v|=@p?b|#*GH;`}uX;p8MP!R2|k?(fTkj;8{5*WtmZeWFt-Eg_A^6Ju4?# zc(^-@J9zfJx@VjB*RPZ_Ae_@OBQ3q-nh6%ze&n;ycsLjE0y9+t?mjMKwlA4;n1#o= zQ&RzXN?Um|lPjptGO3m=?F4mG)IqD}dne02sHKo8R&A7KAv|6H(F#TMKw~pL?iv8$ zrs8ha6CKhM9RLs>a?SC8p6!KlWt8%hz2RqY((BzKa2)bllPifEY+cK}vnM#BDS2bX zO8XU66U-hCDM}tA<;5H)VgLca!1#@;FZ>ucDi8v|W56*3@6K{Xi4pv zn1{3s@20s3xhUs(6tDG+13;7@0>;(_rb7@PgupLgh6T3}=A=3KmZX~Jb;@@RB>0(Q zyxVj72;8p&mgINtN9&Tf`FCe@^JA9wx^b$&b(&K!_Mp2GiDVxDG}H4|8%c^9NeY44 z%!!WDg#h?pai_a+U0fC7oa+GpDyf?&4kOWU8t4L6ecrWz5s+Ct4*Y~+I7U|~0D?TW zKbIeG;5&0b0Aygh1%u6u1*;F3$J_9>{Iv+LJ#Ml>NSC6gSWGRGI#` zvL9PsmikW!AlSdF;4Rf~wNMBb@)B8djO0=@#zJFFIv0B#$eC;DDyM)vVS81LTNV9^ zw?SK$8?6SE_B2l8-Mv|MLJp$Z_I^h~4y2;XkuMzW_iO&Eh{&qYiMyXrd*DA1*Z!eD z1;7GGJ_MNl)*n@BDmanQ#dPiKpRWl$6RCAhGzcrJn_O7F0Iy+{1CCwVo z?jSYMNht}Pkfsy5=+q;3#VFQOiG?{b>Nq4Eq?p~9+7`Nd`2G@kGu1hHJxvWvrb>yvEg6wMvY>J8#&;Wr=!m%KR9^f zf^uO04LH2z33~&^X{Mt3r%GO;usKPf0bu!vqOtNjd)G36BvDpl^LC*!Bt-!4W&Ga2 zlm>~O1tj7dbN=$N&cu!YsL=zTCj|*k8YuJBQsP$iZw|fyaDRMqMR_G?JiLHC?>ja& z|2)7q4XT~NHEg$X-x+$>U%7jb724EoM&1e+=@#3%x?>Lqu7fkc%!qOt=la_|7i>`N z;82N|1t!)7CjLWa@|BbSLFOxX-)K3z+!G|s$2bSDD7)%saL&bv(K@LyCuO2{;Q86x zn%Xs5#R!z*ON(f@#S!c2H_dk1)LP2|DRY9%EgDe^xkVgL?ODx#OtheZKd%!3uO(^f ztkwwBjv-hnx5MeiZ4AV1^tw5V=jd?n2mKy^P_%hbbW@Imssn(g650_0VEx^$6Wn$P zuc^?-0nuIbr&qi6QC#zXpe$Pj@3uHh1|8|L=%YDNP-|blr}pkNtH-Yw#Qo!^jqc{q zMPA?3o}GF@j9_aPz!LS$QZSrZ>Vg(b8pXUe@G6&7=ntbcn&Q*F2<|~fU%}@^TWg1; z9tZqy84YKDLu*^D{pk%0831Nxo#wlVg5;zD3B5vu3nlPL6%pJkz~NdHGqwv$V%D*j z(bDHnb>^~#zae@Q)8fO=helXR~P-NJ$SJFurJ^Z-qFMj@*W1fQVJ0g@~^9! zpaAgpCKy-%f(4EY9S;K&3mXTQor6;dp7kvkIX(p?H5(NTtwR8Ke-r#)w>yD(k)G$! zr^#ch3knVgs7jR8u+RnrZQ!JB(hsD5a*q{2x4$G!QEjOvFR&`fb2Vl=Gat4{DJV*C zW^4{IL>!iC$2Gn^EiS0EHYq=aMG@Zh1(#&RuLD^F!@N9YJoN{xwF%o8dPNwMa^7o1>eXI}ylb-o1A)7{38D*tN)V{v-OPHE7 z?GpT@X@LK9_=<<7E@~q=|fVG0D(&CwOdp4+PR{l7v3>bqYmF4i~djE|v_9 zT21xQxY~dJ+!v$~DRCwtn!T%>;)%}o)l$vji8Nf{mSRYcSJM2kyvZ<=M2<+hF3E3Dfw#3v ziUY#9*h8u%lDi8>QIM zv(6ZKe*xuwb^p~>gLqLXIsZRjnB4I#q$=L1WQ#1q3zrHeLoKr2^E&J zPtUa|SsXu!0N+ekbbt>m+!-0oOqFMboA>=|RWhy^t?{R*)+=hpj6w3*OZGx?!k${K z3gV?vr{r!^Za3_E2>#a0_~H(03a$sThDv2pyAc>6F18>pJ|HgVZGvz6mTI~5(({aZ z0QgSMP7>UeDw2U^BJJ5a+qZaPp}Zr)A8*rZ@-~>hl54NJ(YW8(QV4Cv4Wywd>V4h6 ztFZC;aQ204nPN3h#>Hf~mRvMMa0U0%6(lx=0f;;{ZLn+Te)ug2nPSl=J;GtXy z{0{=bXayBRmY-4MYf<&0n2Gz!D&e_$ z9K=PHRYelTd9`To^~yzN2vGJ%r4yo4YkBN~RRjn4<>keH$sPi9?SluwMb(#)#gmGI znh@79I@g`Hi`y6Vl|~Mhwr)(n0U5cYay#AG97K*EO0Nbf6%Ja#u3aO!MJbO|*T#U| zz+Hfjs^k1B%j6QD9(pIvmBK^+j%rIP*y6Mh3HO$#*R3YD~_)v4)0^9_L zsy1H9q=V>kE_s>d59vf3?L^(63i3AA!FD+4X*walEqfoik$t5;r zf2HH=jVXAkIzVfnT<7SB@cP~2n@hmk_gx6>rH@5d9KB;@Q=?O=lz^?{;`&(8L$nCd zsy%IiDW;;%|f$D&cwSzjsR!usuDwoexeHg=k+-q-xmqLz$I zi=2FnY~Qt?Niv!lS@b<$t^}s*MfjitnaNJGlV;3 z_N9BNBnlFDv(iuA2Mwp(TMxPP1!jqZocs9WPU+;*$~@ufuy+XGTcb%^Gmc2HG?hS$ zZXT9yKdxvvzEjs?p8XVF$uy^g`cD-2Ip^;YVZX7tnAo``u(2&1sU{U-))wP^`;z%$ zD$?p~ls>{EEowGip9)`V4)Hs>Vn6#90ES zb*;bkt@Yw`)4w0xLSkgmd-!8)Gz z>^WL!QOK_c`O;X{qP7yHx?&S#4mv(I8mELki$e)}XHdx_3wceOC-xy5vwIHy24=kt zvd>TKQ+pLcj-X##w#i~z$HzYqy2lW;X)v75Af>D@o%go}K~>krEh&*f_#*@-OQ!}v z5V#D8AlVs53P?pq93bg9s8Y*Cru_yI44@uSoc8L9*+#VWxtc3DTEX<$1dfJWev$h{ zlW)IpVnQ-oFW!^46ZO)@dl-t5Bdb~J@g{E-uc26)35`h1{De=HinkI8>hatw;@VI6 z4V33tU+W{4I~5MbA@tZegjxqV(W*_sQxxtV=$1D-1`NZ^b%GjtdaBp+9dWZ&vF5(M zYlN{&ZeALSPmG2!!dB#?aY{pe4CedzT$y0OiDxh^5sN|YFsGXh7T?Fp^VT2n1=?cy z=-*3pT+GE4;?jhn^J$XED^3E5E?U@VCl@9ojahu;8466yXuj`Ds}zW3+C=-zk1C8Q zFk3m#Q?zPUt~O&aRI&wGC;H7dokSE9>K&C3y})YBg&m%7J_k$fh!d&=*5QLazNILU ztZVAjc2bAIQgv=0;|;rqhdGXInNMO)ED{jBoq)uWz)d4Vc?!N3xx^;(xXMgxVr}YmoskNdBb68*(NYn~5C3ex+-nKTqjrBZ`|1eF_ zZ!`jtwZs&pslJuN^V%hH6P}utPgi*zwCzycO>Qp5>V*!fHIq#qVw=Q|5sWZUMtjim zxzesE-cgs@bFG>Q%<76($4F&aZbiR=zMmDPdXGv(GhLDqb2I7$a2PEKoe zwgslhO^HKKb(_33>m6i2zY8rDUagg^zqWL54j@g{pBHeI9o{7_y(k zk(kO(OxiRc%UG$1jcxhnHPs?c(|Czmx!NwGrCga@*(Y*jT^Gsc!P|GeIm{wX zQg5VJZgO{6PLn6bmgx!gIvQC}A-DZFObp2xSZ(#TV5fD$lOd&sC8f73WiypY>f*hA zqEt5564@`IPoYtulxK`zbLHWjgtOI&E6uaUDdU9=eXfQ%nNsmZo(h+q9P6$BM89TK zCDl0}&!7}6W@eVYx}-`rRr?mYuYg}*BY*}*+d7}ihHJ}vXY9KJua{VB0W98Wze$r> zdO%(Tb_0F4;j$X`jp00JU-A9L;$3Z5_BF;Q%|LQM+xP)U?3-;@B$l0S+c0SIF+Qmf zH;DY;LU#EcqYCU6(li^3`o?!!Oy%C6FX8XiKMAA5bm2ibCDNH&i1n-lcaFB^@&2ly zrBYO({f5wR&AXF4lPlB8UVjl!cBXQ|k}4A2i=Dc9w|W1v(qL&8 zLD5@9bkRD6S4lzZrM6O0Y8TDOyb^;DdCcVUA>@@AlJ;E#d@(q3cyG|3O#68n16s4j zT^Cu5*C4-^S>-9%@4mh2hQ;zYV;y(kuENp7@l@>0(ZIws(ul=}3gf&KK9CF%ra*G& zP26i2dWYBQi3pg!x(K4K9?ssXNuq69UxeYiS)Xk~q~I6>hrS~^^wIR!E1Lo10&VU( zjuS8Pr+2HHU;BbgKv;D(a2Laka+2tm(^4~7LOM`8qt)O?AMJerDv@H?&N^3v{*1ZS zGN=oo6<4xW)^4|BwfOeA z_WDT`ZzI8GnU>8uIxM4y8BxW?qr~m9ly1BWa&Nt@?G>Wwe3WUA*$)^F4B$mws;QRc zlW2d>8+zNUK$QpcuAXo?$Hnn@7LWZ9QC$-LuEN1zZ;80U7{O4t##(-XC?;w`2!QM$ zyFSxxB(l=V&wuUls7-u1mQ$#$g+HxU{lXT1a1?SvGVxJH4E+R3QO$X5-{}WBIs}-l?Q`*Uk7Yz zeOM2^tzcd0@3F5~JAh^_JOs$%;~_k)ccS3goS&XI&i*nuEG02GLezReSJtXMTiceB zx%C*F-v5uLRo&K}ut(|z+YK_9>+;6^;6SVkrO;dY!GDasU*A@|d|2OCmyDZ zzM5nh)MqIuPQc`KV`ud%<1<<@%cFa3xvkMS=q*~yt!HB_I`Sw$I4)jhix29*Mla+{ zl{4|4qT7)DplnW8T8p|!<#nU4$JQIghcN2y2}Dt`w)SKys#h>vIsHt#k1f2n!uMhN z2@kaul48y3HTU>?t-Pw~%lTC;{y}1dO87##aAuJYIRsxJBgrn?K1#Z%Cmy(_9o;l` zBDz!Amki;jd92RjWTLy{G}r8$(ENB1@icq`E{0&C*^i6kY{ z3NWs*%+Ihh3gdgqk7%LK{!XJG5nQhw5mA*SY-WX`h{h=}k0O+oumY_KdEkW z)ws5NaOfb#hG)|@%bnPG$!wz7?^N8=?A7xo-duf&M^mDjz%f(dJ~EPl7eTW1xo(aT zN)(Sa!XXsyF`zEhR{fCyW6*k8c;G~{g+w6X=pw#05k>*jeKj0;ihRa&5vJ$X0L>%${knC$6ma1|F$KM;AZpW&2 z5{g!416#I9jH{Nd`fT$_o;}|v5`#oB>DZ;AX;IHzJDd2tPtHox|dOpzQeeIyfXN z#6^!s?EtB>n3T9fcrTxmynJ&0p8PQpD<$qzHxGBRfdYzCD7F(*3J!bYYcB4B6B&^G zK{){{c&BoTh6b1jtipvV5QutxXhqhmckKi=vMp!uVhpvtk!;~)U)Fmb4p{YPaT3`A zTv(7R?P}}%ilsRqC-YnTzG(Fhd>b)#+bj(Dp-{vfU%03hMAz5rt~=E@Vc@0O17HN1 zS~KOr9<3EyuN9bO2}Sro1Sd<~{yNB~G0LKmNhi>(HQs6n=bql}aJ@MQ%(i`qm9wv`Ac!iaaYc2pYQ!H%O%tgwqnG{Qe8r|Bc8qFHOZt1V%5?XA*f8yBKc)=pD`MhAauA8*yw%*=5Ic>$9&t&11N3P`ZVIh;r&0W71#2 zj$WTxSPCu;QpoS?B2_IU*CNV4hBS5C%nG0<7lxAao44=bYLJS6z-m4_@QZd&*%Ev@)FODwb8hi?OB`A0N1Sb z;%z&D%$twkL|)W5CF3~BgrG*a4rH1QPY@K-al^BZ0=TNC>1@^C@4P=-hRXI9`EZk) zpLb6=le15OYI@Ic{GIr)@?Ul0P1AnH4$>_lhlc{MncEC`Q&CY}b9+M_dHWj>zM3>Q zHahvCgK@?kSE|1JgPMZkJ;@5U)p;Wv9#!iF%8A383D+hODYjGb<8tCb3 z&wuWjN)8#vp%7%YJc0l`Qsx-@6Xv$ov4;L&T3o_!=1Yq7U7m%jkr{B11jj8;pWp08 z&+advOaEFKf(g&7Izrx{j&gfg6j+K}WoJM_4U_4apjatH5da4wLaEJ-HlXz3 z`I^~%43R#Dqc6^P1pXHx_RJJ4A7v6|>GI*g`D*#9t>*oyE#|~`DQM91klfHVB+ygMl62(T?8$p9oQ;)D#S%UVUrn2ZK@{c;)1VO9V)#hCyS!HuEv* z#Hg6Mc^2r7VNJ+^W|(q^RT#=Qs;PeSArc0xTahk9uk4k=V?Tp$juZW3uxU0R zKezL4O&p^r&lGgNAUzw_mNC?PKb5Zg(-4rFabQE1_g7W3VG76WHG)J8dRAVq6pa## zdKA$NVWlaBb47A(zS566D%~QoyP(zUTYLU6{mw9R`L;sv-q-IEB!!bjMl@Y)A?VGH z@fi)}>BM3nz8|2T_{2bO^C|y$hgc`c!dLAGHe@nr?yZS3v(Z|E`wzO$qFw)28!is}`yKboqKGNE0}BGA!%Er^6>NR=W$?)q$(@T2kw?IY~buJ|Iy;8hiX1ED%0nM^R#-OV4ADNjm5}-ht zV+|1kJZ)KvhM_qY&nj)3J}0$E+=p?ZtKrPkZTt~il~;V((DHHU4)KL+)v!Z}I)=;7 zzptD8R1E|A!y4#$I&-w?O0O@$xfJ&_&U|Ws+dl4!mfZzD?f`F6gc!GEYvu>HBQz6R zE_bU%i>9oOuFN{Uud;+E;PSitv5jw?t%RsuJ_?NQ*+o>j<(t+_lYq9-xldf1*bMXJ zIH%-}pJ7j~h(~#|XMa{umxO#24LGuM64Oa_ zswp7X=ogvOQzQE1y>LraFB26$bPT_k{TtYVf*_86HK{pg-+DD^XKZ{@-IfKFpnwAH zcv_7z3B59L60itB?jVXZ!7)MruV!|8 z0(*NF?Ct&5oKw`N>yv5ZYQ4n(Nn^UL*Kjg#(AUCL#~%wl;`M&=%+{9HfZIpYFf~YV z{eO0oVPZfF^Be-{iWzM zj`}?sbV`E$5bVkSR~w6v|NQQ(c)D7<{NEis3)UC%ullITk1zt(UQTA69v}UC5mR&Y zo;EFW@Efr^1pjeltcweAm%H+iBpsw;_Y1@fFCa zg5vA4Er|N(RQq3Gv_{es_cSwo$Ak{jo1n$@E;XJQA|9qeC z4BIrwV~XqL4;#OMokjPHb>rK$VvtPN5B9$#Kr$Wi!%=HjbkKbSKgVXF+q2*%;bOgfg=60LR$csJfZ98|M2f%u50E`<>U9iJmeodHO2FV%^#Cj{PXb zb(Ci}{eSpjzf_T|6TG1NX62}P&b{nRZ?(BqetSnOysZMVv0Hbe%wdk3ULxxuIrI8y z%HUwPeQFfy;)JP)@Q5R)`s4?lzo+4R!9&Boa4Qq9>)X<89~6xP(HkR2)E8fIl#-F2 zM=B%Gdc8;r+IEEAyVX3tWR_vZ#7!IOnF(+%+g})ldfU%9J6GvQGdog6NlVPKAj3i( z;#n_yaI}n$Pulnt7-cFeWk%YlJ};w{jJUrY6X zZsF1HZr`Tk^V+$sc|CX?IcDiB*QcEA8B9E_)Tny2=OAb{aQ#zIdGzO?t@pA<+~ezi zR(Mc{xAe|%w)&XXx5m^ad zZxxUgvn9yFDA6Vp(ib=kr4Mc^m-5W9zaG5rQoghbj>s!N5c$~}^Jr)1CTqRA*(&vd z2S5GA))?q86!`L*)y43}c}s)+9z zlb^+WWWWlE!3r7wu5comU_287cc0c8-hXd%#jffUGbTZe%>TI|s$MvCGvwBuRQhir zHz==lja8IuBcUJy9(gqgoOZL8h#B%sk>#%gA?N8O&lDhS2vfaO)QLMbT{M@;CscZj4G6wq4dr!``}GpJIo9~FP!NfY z_-S~Hvnch4i>C+ZD@d}-o6GugTL|}kojN7NDGEoMv87D`lU&IeutCino<_>xjl4n1 zz6st)>v6gi@TKv(<%sC6vWs8ZW)%8jUD5wY8o>LN1(^m4q2mOp^Oe@}ic@3b!k-Z7 zQ?6fxkChB(Wq=_I0s946fD;}6sYa6jyc;S47A51`yX z3E0DzLD29F!S`c^yFc?p94F%&K!4sq7q=0?3BZHV(5{B~aEMo%+}5`mY!5k z5?-;7v$+20{=j-IaKzo35?`5b-9&l9N0~0kzZdN1;NWwqGO(Y!Y4xcJ&uN%zc%3mg zEBSOHPSh{H_@sWKmXxuP9sUh)Q@o*|R;T^DX6TrS5C5zgnd8S#MSSWr_ZZ1~O9u5` z(c>vcklI!i*FoX5_-VVfJ3MtK)3??wV&~X~;jHgEl@#D3ReQ=KK;*IhM!rP^9`bOV z3nv*pX`FaK=2*qi==B{}WX|?7#b@v4Hp<}>#17>gs=nGzN70XeD_A$>wPw!W3btnL zvifG&OnsSf^2!>o33<(Jz8%k)r>LRJkG1B*5tLa3zx#dP(qdOl(btQ;XXL}Dx9&Yg z8Pwi7IV9OnG@N*yl(-M7QG*OHrBe=Yg(#)HZo{1HI1AZZApFpK2s{DEG(ngFZsvxO z?^Xy`PXA$8+UQBM`w8QtN(}LlVfSTe!^%6o=Q+Q$>VrEhYJ=;}&^T*@?B4XbPG5j{ zIBpyg^h>Rkj54m2GS+dl!+;BlwHls^b(GkD)SUoScQ%%`JO1JK9}(ZbMppNOroM?> z?#s;LlYX;DsL811V@gW1g{iy*MxqQfTUh(Oxv3X-_e|QTYh=mnhCgKO z;zc6F5E@H!BO-30hu7z~pgLZ~on|ZLndLe>cpbw#gnnX5~8x!St8Gx3~Kd z;`8&|jo%h>7eEEw2rVN{!M*b)YSuJ{0OvB*NT4G;yO{-B28R`AQbD*=Sg4xI8Oq81tEex9iW+1qel*XcB@5qZQ(DWLMWC z=a#OXP#Ruqug|2Qc*Y-lyb%}60}BrF1EyrjihLp;L}yR9jH`7>u|_Ll;5G2;MEqD= zg?M(3C$yv^#!sg$0(O|Vz)c{?a`nm{+o9bIcbOm%?@X^Jf^RCxX}zs0m##(|xrolA6h`0ENN`((*D`StDF!%I0W{}`W7bBK2rJpV6ES}84h-;$j z_QV=A4amN4&ius*aw1^e&3KE@PlM1?tdciSez6GU+_iarK`cube>;>zMcjmm@(l=p z(eVD}2VWb6!IMyH7aeD9SsKNj>`#t)*qE|QbPt^KOEF*&lgKu)< za?f&^?KS6f{&a-1|B+5eq%}_%&!-{PQpKkC)Y7VO*2QhFWM zMKmJB0^MS6TLY)Xm+ZUM_0^~u>>un^uTN^Dz8>0x>sy=iheai~1=nxt20<$pOWsfj zGtVo>T-r3-P$o@I#haqy9!!KkxmMtkv%O@jAZ+K*@2#oMmm1Ghkt`k4hplXq)H|Ao z)a1UO8pL!d^@1xd6E2~e+F4v){TEsyf6$sQRfbm?%`1@Aa~Gkki)~q)t!-$gtB_qm zxbH`J_7yYw(6IT$dSU0FKHbD~n;|ybWQk3oKHiF$-OuE9M7P0-*@kl?U! z5AN>nPSD`)Y~0-m?k>S01h@Pvubl75f4_6<)}C6mdatUH?jC!lyJx29vh~Bs^r7Ed z(=t#`!>N!T7PYh;O%c}@^#Rv%BG^UD3e`}n8R3>N)qG84LSUj2bi}j~f)@$xd017V zIp`V*-&!7tDMlIYo^hL&Av|sl82)MTUZH<$f1sUk+)W*!lbmsg39 zyMaL)mK7IA3EOD82q)rA$p2KkLVA|5pu6yGf_0u@Y1NMw`MuStL)zm4xu4nV})mD)~a0P_)331rT>JzzP3c|jpvs%9(W~`p) z!buLOmMbi^=!33H7(f{)#jQ&~545Jkaw*^fS6KCj6cfOd(}GJXjQZ`<`6MVBOCMhW6bEWM}1kHnoq>_*xN%FyOWc({$(rNXyC(F#UF{%t_Rd^`uP z2pYwd`O+vw@lSTvmnCP4pb$3^YtE{E2X^{O`cMa zIVwWUS0N`nJxi{ctLZ_q1*NUe~Y*p~03!@r7+p0LhStm3F zF)&PjJYJ`bmCofvB+W&mTg|U*ZxA)Ed5@{OhDe8~xX34(Z{IqZpFuZ;1>nsHqNlmYx# z5suolIN<#3g;vgFh%8;A^{-h4H|Bkti+MS;*k3C#rO1_`h0%lhj02r94B-B#T)uum zJWjND@$&4c;{y1E(bmH1q#;MjDa03&i8mo2;ul26Ot=iATnEGLx=fcJ%q$4d#{T9x zExi?z_~xXmPD*c#t29mRY;BG_;T3~|&skmJv5Ds5u{J30LQa*w(ucEl6Y@_tg2woM zYlwBr2bl=Lv-WaELY)!Zan@O033UKTK!EEtc#i!r({G80U!C>TLq(khwYVOPm%u-&l&oZtiAD3_L#6O4$!v|bp`|P|A zPF7H9T~+Z|zwJg0=cN+@4pIO_+vw}M0*~J|aMy1eDjjWN$=fj0)%ERy8eyFLcQlKN zfq|P&?oEXG3@d#>Q&^!6FXB&>ybhKDE$y674itTjjpXJM{@Ro1w$3#D(3#OPy!tk= zEn(fdBvdc$YnATSMwyLnyNjdV4Fh{3Lbkw`7UtA)(fsJ9$tFqSg)LMF;!|rP60uuR z1Tv+(96{pll3|4hXkl}H$yV15=G5nQoh6j1I`ia>usL7Ah|)*Mn6mEXZ&Gx15N52{ z=7CYQ>a5c$C3sM8c5QmtVueQJgI?Z`l17F1P+a6M>h5ky2%`tnNYwlRpasMqFF)>1 z+{nt01C`sTY_UjYt#aFSI=%*nOP72#PmHb3;D#3ykrRdph?97En`&+hBQj(q!N4z0 zA;l?FL&8o^I!M!%X}P@Bs%5GR`Qf)%8wLTfE?x!H;@(~P%H=}z=!pwd_OVD%v^c>q zyg_uLEqIIFF60~lLG%GP-GbR^Yy6dJ=)A_sr2D=mH~%z@OP zMSIm}2EiY`!thKExk|+gL6kYE>@Z;^IsJ#9lrY!uP#2__oY)UdaLqmZiNZHwd`YTa z9;=0_%eQ?cgyEGUl7#VJt!OcvJ(-<8Rh z6e3Wa+|!1=iXw`46CN#@yTaL zFA*I$p&(a`an2)2iVl4H#WT(os_aK(q+HFFhjBuDkB-PblqCURGGay7`f4Tn`PL$E zkAYrEzkt^|{*eAB)=bgs*H}#mO61I<70s$$$YNng-y~E4E;+ZKI!@CX&C?iJ6^F@4 zH89WBFIW3B+C7E9X(9~XN>ap!NLzn^UDyOG3n=$uB=8R13pHy>@T)Ypn;Gj9PZMg*AW!m&c^#voOKb@73iQ0mzu zMytMKn&U0J&gwZZ{Q#gt)Dm$i?B;;={(|wiI9Lvy5grzZBB*w{bZWW&}LDU`|dZprP0%f-@H8fO*MAFN!2Ib`zIa_Ns8|k$GdM#j&)T>R!F_dS|@ zfz=jQ6<}ORt!bY$Fp9xoqu2~ya!`y)Nc^tkB$X7vRJBrDBwL_SPN;d(k(yJE@w~$d z^Gd21l3W~Le?Tun)MHbT@uaM%2nm?-HaXS{%qI#N!qSBOaj^XL3q!LiHy1aj^Xw2m z%2G|vD(+@~ZgD%;5GD6zVQbiR+%T|cN2Cgvoa$gJ!e`0O4j6*x-{jL)#~ zKA>%%*jt9@3(fGs<(u{@zaxR!7tu7>qXFy7kMG}y7M_(Hwt%MVsfnew`WbId#FF)U zt$jx_D%bL@sFrF9z+bp-o0XNx$@Wm$Ah)7=?(#I27Cy@Vp8o)A40#^65@~%GD|_Ly z5U5c~hX;CrR9^OVU_xl3K(|)j?m6Tg5%~;-#I;!ebG-)ij9vQSTl^?ZDV#JA9G(CC zvf7Z>xT zL}wA{nKo;Npi2>zP^W}AZT)ET?Y89>Tq4q|sVQK#j;PFT&z0E3q7gJa*PO|bu9nOU zS(xUQDkV47z)nM?0O&f(GN8f!BCpR{C*?JLi=_?h^jT}ip+n@fh|jLEJoQP*Lq9aX zk>6ERcF8@_epV?a^wrUHUjuoV=l2rt3QcRU7V`+-izoPr z9C?(SaS|AbPCmNk5JIaTJp3HB3FPxKsW20as}nuwp=xx#DII^ewgqTy2hiFkQS|7M z^gvbK<%PQajG9)R^N-m}r@#_rJU4s)W9GKf#Hgv9xPvNG48K%rY91pfczHG~KN5c4 zS!HGh{0m~knb1ZVa0XbJ?Q#lYtnmIZ#Bsy-{PDryWfGXLJIs=PK|KEK9H*S>*>H*) zSI1%e)bu2VNoiKwqf;$z)NAn@udI_2ta6bw;$NUB+)NjNh`{AG`^mdxttqW@a*E?^ zj4OwEuPRoBVl}T@5(a51WqAz#;S0qoiyi^%50OkP`A1c1fU3Br*(g)tsjN}V4&5!i z7K&PaH*^Q5LqzrICqg_MK|J-@d*ZA#f>D=w=WX*x1*&P}0_wx6`FCTfRw4i-{_gi6 zu>^E%`!PG1EF3yyO7=*ru=&yYq_z>;nfDarj8=9+A)*~Be!}%(uR5GaM5Mudj^F7$ zok&$Bx4(C@Nubw+F#I9VAHezvHhp$%=;@S13w2)HrQ7?mnU$?I$T_NkK%P*FN*bp; zhI!EdwBsk7Up(qOTbv;G?pTNS2)beySU52SC9>1iLV(QvuG#Me$?)%*Rc(AzT7{~# zgGFcBXi+3JP?6ynV9l2}#Ppb2mOdjCl-(1Zsvef&ZZnXuxZL%xFJTB)J0~?N8&bCt zmo_X|yniw)L81T#2{I#=rnjSG9%coP*a*C${De|t&7di5z_VO3+h}-%5u@Ygc2;+jkz`xH? z$dCJ`vKQx}m|zy?tXyi_cja6+2qn$gkYq`maiqqcREbeU==u||pvH~w)D`|qArXIX znb`1N_H;Y*$pcmSD|?!QB6GYnitel)3V?y`vo9%+co@Yi?|ACWl;1-d$9fUqCzgH2Mmd$sR; zX#SuIA0*24j-JRmJB#tF<%dPxInl2@N8+X`KaX;?Ynny1wAL_}{FO$jr7V|!K}ecn z>v)T0$V7fHpL}!pa)1Ei4ygw>A2Ag3nKU+(a;5`SA0xb@^iwHr)>4r?90C%hj4cy{ z)?xM^`#a5R`_h~Cj{2yJ`q{>Ly~blm`XLA&W~&2PGLP;G24ty1SSD5L3u1GpyA!(! zwoO{{HY_v|-$)k0Nk567dQ+3f(RiJ5v$PL-5aUGRSyd`><8r5BUdq29eq6irn2D*J zPF?LPp&skDXdMfT2&x5us;2Vq1PMA^{PjO37-2?BWprhm7dmBcrE? zg!1J?IqHRWVRFe%s!SmxpLmR^X=djcu;Vyu#E^H;spYru_KwJ~Cwh^`&Bl4JMDHrt z_FoXndQZmp`v^V_o0Lc?KR1=1?l>GCb}Rz#(^ryt==1}~t-%07LGfMuri*cw#^2og zYfEtfS`1?Kk<~AV16JcV zx|6(XSI+Z?bhw>2ldLk&jga`-o!PIlzjx3z&WJC$um` z9#1!SG!g!Sna3Vr(O?JPidbbGu^Bt`DwYC0m^s4BbV=gq!gAx93vEC*OE2Zo`W0)l zj{uC|WPpTVB`ayVhYZ_o0mj`l4JK=-J`+n_aPWh35^qZk&bi*=_x(>-pVzrm2#U*d zax9eGaSPTXXOnJo*U-MK4k04VUESe$32;UFHZMm7b>c}#tPkZOa>N07wFkL(-HCv2dR5`5LJ088tW$DXJ>WT|x(6=#CN|eFafYx-N2_ZpV zFgxB9bPd)OUqTKZJ_XK4SO2AWQ#{dI=*|^ejG)o`3;GvN!uQX1p;xO0Q&6Bo1_KP* zuFV6#;-aOVz3p}Lesnm5UQD8<$t)ZcrR%b)aGbNID5|`!O5&ly6rnY&HDO}}*Rf0dzYnxlRSKA0!noD`|B^~R6A7t7%tC73^*-IRTeu+TF9H|o z8%PsQjJH9Xlx_t?^KWTQ?kGN^wT7n^tL)z%AmuD(x-%KZ7RJhh%GTVLv+(0TU(;H? z;Xs&V!YUKtxW)5=jG`C5P=fa*Pyaz&J+u>*+92CiAvyeK%0FdEse*I<_XO`;25w%? z61<;t@n>u+s=Ul<9dSRWKmPM|+Cp`AU1Pz+1nhcD{clNQD_Lwn>P>DY7`Jd2o#q2w(o?}T{-P<-lC;4tnJk^f@+MQ*#;_c2T^uHk9ED~KD9k|zK zU@=~GZa$g3#0}y^+!eP%B$FI{DiRlsxA2I*g%d>)<2^#`4Rj9xY@BhIL>{K0i1+r^L~6A6R)@M=4ch8N+&?7L(H!ewfZR? zx^wrPAVFuoofu^Yn=8aCKXJU~ty3gImtoA?Ugs<~`f!g~A_(dEGLXwu;A8d_x-e^- za6El&9}+qN*kAM~zU|DjykUC5GEfru++#h9keBI?!m|IlFbSZr8qaj-);|lg9bEh* zKrfvbXksgdH35M<6^D^jlFKpTnOl+f09-oIQ-D#x zFQlfRGKV*%?nijJu5S^)VL?4gq+*_2%RMx_{bvO>fRT7-qLm^ErU}409(}8dxsjeE$n>=*XflyIm~LAa%~O2P}!ZoLD{tt zqTV6ruX9OE7P&FaJId4>8B<+kDAS>OX=xw=%Pvs^2!q0Om?+>d)gJ>{v7ItaQqJ(n zJ2JOb)PSe=Ghxz+D-W}Kvwm5MhmnK1=!wjUn=*a6;~;Raj}E`pkur99lDVw7_tJkY zdh^7DlF?&yND;t6`_86n#puNcl>M(+EG}|BG>^-w{dhL$p=5O(lPq^nI#EBj1XZel z78+hBLkZDDxO$In1ScGsm!hm6t>g5;Q@}S!@;{yocuW05wQ252VV`*VFF#|R$Nr(# zYZx(iwH0S)IW}~?ux59>^|pyJ(#Cm{oKlPEUp4m&X}==QCH9h-MCOc^fcjHzg;}bs zBbD9-o%Y=BJXBE43Uy$$Kev7k*ysqVnDNjpT-gz%>aNtCYoY|T!o~x(bl)~@ddqcb zx>@c~(FS6#IbbhXG$mFWY;MfGnd1q>W!1agW`s%O+;VI+U65n zGcMMx&$q*OL@G6rMa4=2~3;lI)&{}NAm<$i5z+Q^wDV{`f6 zuMA^og`g>O&f}3<_sjCEwldl(hT*@6u!zpZY3*-|RoI{ZBUu&K&Wglu&iBX(%36Or zw76*G`S(=BJFB0xCe9MGXzK`_RI_K0MK!QI`q3|rx(Rfve7dbg99A+@rirPjtg^Nej{+&DE` zW$-`&6Ql+{ZOd(0rHLcQMv44rnO9j8Mo1vT^_|1(n%ti}A|Sh2k7Z5iHiNW=7OC9% zj%$a1d84z<4cmtHRaF;%+RV(h0_RW9LIHUI;L-5gp||v%CFXb<{X1^(svEh2bmBfW zPsa1;N!N>AfN}>~fVhC7zuM^{?$$h6g5K1}V@&MM9nyxTw8o9r{&JBs5>ZU~((wRfKf|`nHDlhxP$<>ZA zG$48}@8%tc;T}G<_nMzIiT*to3ieAa;7{ufjcJhZWd+eps6}{d{nO`pgh?+!H|XnQ z39u*(9QhdJ2Zvc)M>X47xPCu{3e9e-*azku{LyAK#<2YJ!$Gk$#rdIT7gM=PJn-#k zD$~sdfWPk))u#9D_7iICSpl<4g*25k@qgjY7OeSc1~+e2Ep#g<8|brKFklPRf7-|3IO!iY=1Us>Arr6G7=vP<{j0KJkvI`DK)gQLJcEb;Yx)_gY!R= z>0I)A+JPb}(=yfqAjEJu6OtB@(pDW1 zIy-HgOp2f`RmhZ)c+-`L=%)0y&)SW(tBU?qeIu{(D`pxL-*cUOYu4~eKExXm2(6g% zy0CMiY2p-kMvo+9_YRXs&~4oOvoDDWmRAGD+fXAD53LcsrQ+g)oZoqx&ScfZQOM_H z)}N@R^M72tZ!ULHOBOk(1tLqNuYV|f!ec(ErciS%1dzK%nJ@K3 z35p(1PVxLnl*w6=eIc+Miv;hBYyPBUc8g1H8yHuOWna&uf!OB<93I0+$8F=Z_gcf= z)RUsknbVZrT(hJ=rosXMtO=mOrz~q6%{Ini1uBD9Gq^r@rb{{TdQ9fP_WZo_!>Ch4 z*Yznr`Rzfxp3u`GwHjc}-CYErhcVD)tE}SBDbNr)X2JJ_Wu%ROWL3rc%dp=utvu#- z&%zQ#lqtID#o)T3^>F>XG(dXqzQl z^Mzg)Y3ysM?bqsnT@|Vb`HH3ED&!Fy%=wgBr~FWP8Ar&JaiH+oj)&%aBNP(k*Tia+ zC}$+lR*Q0LH<3IQ7`lzTRV$kW)#p8S3SdRr@to3W(v5h}1x+^YuW`D_@re8*Bdkw?7AVX`CdGS$d>$yPPby7N>N+|sR z$Oo*i>hyv<(_f+OcQf6b5PAixoZ(Y?j|Rwh5H*Mw_T_8?XSMFOSaluKQ1k34fB`su zc^NS<3I+BI?6LyEi(I>)KD>N0WXytfc<4WEukaB3T4y*(0_};93*dGewYMjWuXSm< z0`9A2_*-GK^^-h;;WuhGXR3lv6M!&WgGw8pdocjHLU!@)L`Xl}BpQEdq`NEC{BJ zn=?;r*>>GTH`;_A*5!hhB{vFNgCOEY|1|}@&ijsc^;K2DA_vvN`iA!Sz@{Wr58+Xbmx-?P7W_5+P@$|)C9NYChz{H6w@Bj@cEu5LEffSg6s>~UydiouD|cQ|NP1C zcAphQFMLU=)Vba)@vr#;g)l6OQ51Ki@^ZD_%MG*@?tql-s_2^*5?3uptU33%2`}@K z7{x8&`G)-4MuPt{W#B-bRI!v-u`vHfswgbNG;kU-(cBjyad;iw96P8zWLRnRc7YB5 z=placWK*1{;ing@-ZO{1; ztC_^LJnYBMi#Gz}Rfatqn9j@b8)sdD>t=0ro*`4#fjy#4A0T8Tm)O6A+&Ej5~u>zl_( zyku)&ZBd;nnF0@ipg(aG)AZivgTOfGaEd=MA=+)+nUa|M2kE&14BJcL^ad%+?nonJlNcq?OwX z@MpvWKg_R`P_43LI2y|hWnEC-35;3px)ME`porXPBYRgtk0N!Jbmx%Gy(8eSGy1B- z*mNAXCfTt03nIdX?D$Y@FP%ytB9aC3kN!d?p%i*3=SSW$?0lrb)k4bi*~dB4_P^vA zsl(#wbTGPv`t27)`XhJAb>A53 z0$a>uRtvJ)O~Y$f8g-p#c@+4UlCI&?bh77#=nk~!N2y;BG@E4SM`C+kMF?oQ#oAQX zA`lP?0vU-U&d4a-hY}6Wq^TRY39YR~uLJ{520DPv{Jh?*pmEpW(S>Yv>q7=1#EsZ}=^2y*S zOhEgW38UywfgUhild;yDa)|3d6>eXjpW16ZCuXJMkqfGrA#<| z=V=>LDMf^S0)j^V^>h4EmVXZ2?n?5{KQ?pekUp*{NHxmixa*;ZT`zlqW-{%VN{NO4 z;~#LMNV!*s{k(5@l;cTV7I=>{uO}Y9dpUG8Z)l%a{-6JF;_E5?QZLB=O7obFerd3K@*YZlGzQjEes@DMWGhQIFxu_u*a8+ z>zknMTlqnaoG}(>2yqt%=>wc{s#^;v`oS0RZx5eJz27cB*sExKYAlS&*Q4%x?e^=< zHrkDqTHop&`i|o`hthWC>}i^@w8JVzzd6!{&m=>z^*k21NS`IGF}A2VRvzk1rwPs4 zZ01kQ$qCuaVDGS@{en=P^)76*c+cbWI8{&atuA{pv_oVw6q3tp_G)(+Rj@M z{LpM~5yzZ6D8=Di{H6d9#TFq z%c-dW=5vj>Rf=^Q^``w6HwO=bkye?3TCM|_UAxuREbn|sr(H6YH}ht_Qew=y^EMV2 z;U2p0nJ=^n6@%*TSjG4QplouN3XV>c8}Tq7KM}>Em5X1>5jv#L1-TQS{+WNnK$T+S z-}$4%qKoIGU+Z5Con;z4X^T0SwEefLlMgy$W(l_NmBo9{Px*AhW|yD8eH#BO@A)dl z*1z%|htMZZHVwG+T50}Z=gq_kojM6ans?IXA&x@&GSDSBk2Y-Mzd0SroZanDe}Yfs zg*#BdKRjs^^~T9*Io3-IpX0tuO4t6k&h-$%uB5e#KbuTtfeOwCY#~C`bq3)4m}#zsBqegaVNh{i6VKr?2W9g&QYbD^K_o^9TCBRd5jRE**~` zj_lH!NypYkvUpZHT{U?(fJj+o4hVc=)m}EM_m{%F1tNjfKhS()p$$+Vv!YJdhZlV- zy3EpzzqxsUsd_i=`cFTURbj+T19_>{1Mollr{U(e^kfRXD3Q2{x8;EptLPRIEk`)_ z2%RF33s-uPs;C#@kB6OhTP}3~54IFJDcRunP?tDjKE>|8AQY}&a*m%x79K*XuF-^^ zf?0Bx8?rtXjp6Jg{)BP!Z|er>Rk5n2GMJLlDiKKB4P;%vq?SFuReLa~y6V?G4DX!5 zf%_4e_OX!u>+3V8)#K&`hsy6d6RnvpJdxt!2l3xz7J%_3X;=D^`uBjN>i;TK0oR_- zzRmfcezqKAR?$`~g%*z{q->%tste6+6E*4_TL=8Ia(~Gjwn{PNFPZn}*y_trH*qFQ ze%6w&F(=~BcA|_Zy0`B#YuUG<(h;%c(<&%}j#y?jCjnO97wqmCSm{2k(62VG-w{7s z!_Pfva2@7lJGpL?FY5D^6$x5kT%&a@*AL7csA6u$K>a16pt4zmzeL0e4TvZWV^=*G zZNyx|0@vV4Cz`NStbcUXd$bX(7x&18LIo^QbXEMFfD6~}h@YL|2Ww7EwYDZ&sexFY zqjogT1$r|}5aGNYe`Ow4Hf#7-=G+I+5NzBhh;xM>l0Q9p=1N8U_>s_kH4~B2^qgX2 z(M@^tnNaGI54ocZ`WP8*>Eivv2}X?J8jc-O9vXp?OtIDKs?B3HE)d~F@$OAUtLC2r z-DfLM9!;IuWcb|aRD-}ki{wu-LlGjA?)$fNNV1ezl+r2RgPnT_G+moLVwRk5$}t5^ zy7RLsyGD zAmNuXrsN?_k#A6e+$H8EGGqe%fAKsTa2d9f7vZi1wc&xR81H5Jobo?ct z<9nG+eQgdAt9Sgk4Q^pA{qzOVO5zKB=4g$1o5}QjGgV@0W;AH$MyI_DI%cWboObAf z?V%j2oR!S9>-%xFIDd>M!p|O3E*W$nu3jA35@&EbzBNIoRCj`FY6032OXq z$EnBi4;NqlIy(^1l)_{R+0`%!Gm?Wi?ce5or3CzMljF*zH`5u_ibnyTqijn_c?JLh z(^M(#Akl4q2;sGwS=>nvZww)pt&q;v*+G`t!8oncQE@g)+R&f^Pe}1Q#DCP@O;~tO zb^Qk+r#hFi=hBc+!`Mh`$T_??Ztu*lof*0CC!r-_h@CC9eHe823<@#fK1|JEkPe|^y3 z=P+E{188>c3P{naimqWHQOk0K8N7$Pe3{@yeB${@yUVpwuWf-9WSRPga||zSA8FhH zX1}6XzJ3**|Mf-S+!jJv50+XWO%=Qx2tBRsHK(hvg%KSU=jKe~G57n=$y|#GE)WJx zVg!aV%V9W^fE}q;q_z5YA|63n%XqA~vRR{8$uvWhnF>`PCVHJTSgp707ChWdB;s9i zL@_QFzw>137i6(Eljq@9d7)d%s#Qvs!;Pne=Nq%vo#VsY4Rz~@`p)=e&;PY|>KSa6 zP*;}|TzySs&cOPJkL9)-3_Z2IP>xzNLqseusHYKIveN4}0-?;H6nQXT6i8j9l&Sv? zP-RBIuC7swfRO&j^SXGebCN|>h}yYH=h2eA@w-I=JSozgABnaH>vM<*l9^LZ8LKa2 zKQ|ZQU$hDFE?3b7UAlIH_0L>i|AHXynmc&M^ybMrK$2eEG|RT;9=9qVW@%13qcq*I z4_&-PWrce&-rW7KWx{izixc^3eVyjz=l&eeeoJ|quQ8YZXLA=<)-slZr^?`Pv9XF< zgK+gt0xZ>vSI-4P-Yz}rhJmwbF`G$s80K_QE6nB#hgOWwJvK!%h2hyk7{Q zo7CuDqdKC=k2}UF8zYq9RwQ^}luS98mF!9$Ay;xR*d+B2b2v1q(>*Ks78dXy`80Rc0-_W%f6kkV4z&(fCkdizF9tgN24EJ$P z7x1VSF0-?@I(_@XV?L51Q86KpK&bVd?Yb_E`G3!y2s>#3^%!*1#vr$I;4E0H(N_bz zi3ck##PU0iTBx7?Z=;%3!H`4H4N8o%-6>E8>){E`sDhE~Dar}6MoMtxSq4Am*0NJZBj6Ni zryC9{>qlx71BUpZ5}_Q+OG>khSOZy;;ZYObz6b^go2+{YgDyZ%JM30>}5H z@%MWU@^0EQS!(|vC>45RSt(_~Hka{NT1;A|)h+Lr-~36K@ql?y%v*C)isko2 zuPZvRSX$q9;UmK#V21){KYe1c$hu5m9%R(DeR z1#!DbNwyaQ8;Mi_n2{zdMR-zJuHH%J3t7PrP{MS_i(3%I+est#dH^P9=W31O5@=h4 zfV&Z4@6erl&vfjhr$CC^H(sx$c1Ls#U=SY~+zaOFIe$l~sztemJsi`Jqb!+K=v zjL@^XZek9KnA_CvE%hPq#=Bu6WPCPl6M{d{H(=7*sl9cy$^+cJ28#NlT!c51`Z9D| znw`s%84YY-h$$2#%`C-4SPcgzhK|X#idqEbFxR+fMdjzAcVjc6CecvFP}2PPooC_3 zYtX`;zv%W?b3Lr$_VgcJux`x>#_B~p;-cWZ)ODGwa!{c3dKFKt9s_FtU;bfSdSM5? zFm_)y&>H7baHWb9jBZ6GS#Rwvxb%C11?Kdru%_{WII^2Qk24T#MHwfAwO7$kzR7cSsY)wz`fd~I`SE1rh1suhezO7hjhbHwY6%cGLU*y+tm-<8n#$=ePr{)J zFt1<4aI#nc-%_a;AXLQn4K7l^_g)!+A`XS?!)2J*XyKxJhL*Q?*T2Q6InB*~W^N&< zbTI>>FfN08)rZC9;Y0df#;Ud#iM*_l^bM|E@okT?hgij)BVfY;*l|ZGyD;w023mt3 z=qRfj8~G|fw|ahQ*Yoia7CYz&6qKX-iKYMW7C$1r#jW`5nt=IilxF;q;^P&&_Suw| z!(5I;{Q5I@H~L-nQqq$alubqVXC_b;6e@}GJuG}@joy^*F`u1l(^?357~awrLMqYv zDv>#K1!5Z0bTq04Uhaq#96i^Y+&Z`&gJoW{$Ozj&bU46Ij+#(C(%r);?)|28^u9`O z`0F2h5S^0TU%X3Psdb6ER*qSEkb2g?%jp%$iZNAwSz0NDSl{5b2I z*S^LvFhRE8B=q&9YosYLWmQs)>|w!vfbJW_xifuUcH{JJ^ZBuw4*MQGx^HNMeKs@@ zg)8?suueg;Cyq=v>NJ=IENKWLuWb`DcJ&|m0h6?i#Mpd0lEfV zPgbZ`dRg>49IG;BYu0E#%!PlSFITz#uF`LL^pflN(?^jp{UJDAdFR7H;!m$4$+F+U8=NFH)90z-}j(dpyBgLwwc2ibP zA(_{6&5ec{+iw$SHwCM)yiWR)5Sm%EwV;aQ!9T-q)3)vfAk}w6hA_<6SyTTVq@gT z%b*WqpDL90-YMlaijS^9o9?*6cvp7>D(~LW&L4&Ya2CwP3*FX}ADZY4U<8v3>Cbzz zNb$q26&V8Z!}8@=<{odlW?=!ozp>^`2U}tZMQ9u!hS*pJ^M|x=CT68GxUJu)KX2zs z2^hKSpv+QZqYW-K6_Z6zr%(F~!l>nEN7rmBn}nM`n#GFmjH~tS9xP8p8{R9#y^p!% z9u^5k4-~<4^riJW?O^}WS%-Zuj&R<0fp+bG=YwtgBJ?x~o8Y6n8U4t0_ZLIBOC`UU zzllp?)?hL|>P2}8*>UBWdAc0`5ZaRMPl~vCVFzK486GV%L4TTpw84Ud4_o(~g_38Tm*c4|wog?_L?0hcOD++$ z_qX@0^?;BC%)jG8PkPuzKG~e~0|qA^Klq@Y^*YGy* z;~$cEBBY=M?L$C(^%6TRULCOwz=z0?8XkMF>B-)Xj~i$qeAVc)fSUy24Ckv0o1o^`1=bU0~!GXx1p?92tLGscIf&UngsslxFyU`!S%9&v8)fDK zH~mXcDeU^+T}zAW$@qm39qKbac8JljHA?;87IL zN_F!J`(8QAWI{{r>KVf z6+ocf=BxIG9A|V0#{LrW4?>San*vt_dtzFWkF%xiKT^3LN-?nI`UP>_2;2mufdK&t z2@M5{hKK_73icHgBn0Fuh~IYsVUVL^zJ4pD2#1AD!KS2dXYUu6Q`0?#gNsMW%E2k@ zKh4f1qWsAqJ~ywnXX`@LXxm|jDj=b*_Xo8Yjp3!_ju>9;uk6xpR zFGLtkU7!`5*EeQJ2*y!CYM3?8gs78P#;}Wu_w|#wT`I1dRnauUtI}wJDaqo$9gFll zTv~F-Hxi=TKO0+{5ZGnl9~NM}jZq_j<-AVcJ#7>}mwyqYq{vfFlfo$Yz{4zc@L5v-xmk6k1vV-stEm3G5+q|txL?5 z?oVFX;(+h&ckG_*#9ootGlPtDt_+kz@emetUeCrLzu?fI#06)Yq}-exZ;G^_Vi|#`V<;VHN?O2!5t8>R3qb8X_RteYJ&3lu#7wbR;>i9^i zI54C6o`7Yc2o$+PbJrjVnV6e9D>KconU%Qubz{~;uyogmDD*qbO7|m&C)B;iGxK+h zNxj%O4YT{n61369zIaP62&($Zpox9WJz5!gC%yf_Z`_(XAB?{F(m&g%wEEhk0uTTn zAKM$^uT&zhU=o=t>kl9=BnXq0j`eG5oA zW^`a3jocix`vuX<=($=jE2FYN1fA(qp4o7)Hks{1fTNy=Eo~#6Ti+ylEIw>%kTui9 z`a|D>QFwP&v0ve^!_`^l-NcQ4)4StZNG6i+da=xHOPWgP^|LwZW~J|$!^-nPBohOX zd5RZ{I>!V10`enEQc5^VHA@*&E{dG(f%9ctB1_*UGulI3b=q(+^kSjt#l-h8ufm&X z3%KdHuSqA5S_xpc-*-D-c#o4oyZ}KFW!(B4Rz2@md0VeDP{`Ol@NLAQe?h!midQJf zm@bNG(`sKeb#=;ePMrE8nn@`wX0bv{d$V1G#p)M$XX?l2CHwM4dQHz6yON>BgW#Gw zoVK3_%DZlHlHT#qH4*mWv?58;RQlfF+fV$vp}-dJixOIVTF4FL66`OotF&=hieXsY zstKw;C@ZoQz6jq?i$ppFhklK?hg;1ncji#R$rRpd>mXAD;;fsvA08)x+N9uIV3x~F zV@p)~lTYQx#Rs_Dx6A2UbLK-dy>{my z^z=B%Uhh(x23l^b>z1Vt0Dlt=$7oJ1bSM@lHr=Xk1E#Ekbea^#tTL-;DkJLhCINdg zy!pzX3{^Zf*;~}Gxy;}ao$E@KU$v)6emBK}$IlLMMf*q{I{cZ~-oicO{pDr-KxAL< ze3O^E+b;-b@&Lse&II9co!K_Tp{jk>83adJ@JsTY4RMJ37CEd^c+@iWA&%`wX=??W zBmV3;12&XC2mjN}=QQih;ZKfF8mM0Tkx?G_m-p(mNSMR2o@*~j$lo#6VD2pu_b4ONXiBOca{X8{_-X9lHnj&U`hVO^hI4us?QJ;l+@EWvYc5 zheyHvR{gtxa`$O}$2V5=_(j`&61);t=myr)kwk>;Ej07lqNQM`5=PAT>sL z&JfD|YJyFZTBz0~K>N3CQ~N^K%9+&lKN1T+VzN&{zh4p|KAuP6^+4f{os?-BJzfci zDlv@~K=19g1l2#Ib?Q2&#xNUQ+V^XjPR1#Pm8l1}va1QO{FZSuN_frwuyYhwP9urU zoarh=Oqchn_T!Q^I+&{*DwAPn)JfqN^}wyA;&L6tRJi&XU$#9p5g}2 zA2b8p8D2Kvq$fI(fnim%Yl0UW|Gp_|$Qi_Yy6~+KukmmDw znc!yJMCE>pCTAIcPlr-oez6TbJ{3m53h{lJ<*RQF$(^AE9nN?I-ydx>Ai-c@`)d2( zGjO%ta?1p-`VI|)g8z(E_ zu_dM<7UdqLRHaw;eI%vlrW^V(?{aPolCgZER#MCLoIUmMuOU$+prsRuP^BaLcZ1jh z^MwgbNAbw!OHL>~}3IMkV>OV9vPlP?pvgOb-6viqnFQewT_9FWX}DygYmakXGl5lFYv5cNkxcn;WLM?$^|#>KQ$)ELais~P~1BBkgi?}Z{worOoC zOTfbQ+PF2(Jq#^IodTTO?x9|%Ic$BaI6IcM*%x8N;zxYdWIV{g<9J8v5z}XuTd0s_ zlXDVdn**Yx{anUfHEke6w6#muKwpSLN`xr{V+9SPsc;RT6DE=}i9C3KS}Y+8a7kLt zzdkw6%}056>+Y;sl0Y!Ch65csTV2rS9PTZyi!q&LVq z0m;)AC;Ks{R_KMx;Bmp8yCeo+G)K6X_wZ@!Q`I>~04N7{9P~-hJ5mnj&&Ija>11Yn zS@pgIcvms(wfu(KO77Iw-EJr*z$XLDu!XZ7s(BU?IT6=`FvcDCTjAi)SiFeUY=304VPbCLK8 zuDdVy+W$r-mRcsuuX2gvd&@e3zoAgCO>QL?Z!P$~G%VDRbf^r3G!ar9Q0-#cJiu+8 z6#Qz$9LMJ+ke9?Aogk*&`kqu~-62)k$bnUeCekRH&6_x*!ZZ2meQgfNx?cPsN8_p+ zgQR>x3Uh{2*1Tvo(B?ND@yuwV=bk?#Tl zcQ^W+My8MC-jmfif1Q5&vf*L?q+QG!UDe{^s*XZJ{H5TEIqa_AhZ%Jm^p1i;Z|ZFl z9wBUzqp!=PU(z1GXDE~G7j$bR(Zk6E5vz&N7=58~&W;~VliF%DGtV|>F~T`_hHu41 zR=Yi@lc`z;4n4Y+2p5;>#kDbzz6jB8=II&2hE3JSetbyth}h^LDQ#?evg-Yq!HuuU zyBvd2wQe?5dUGnpa3%Z6Eqa3rTO0dyHcul?wuw;<*8PYnQ3)hNAp)~s?#K~}JL-dS zO*u$2Fo`DZe}Vg|I_acPV3hEK)SF6FxfQtVzXxj(?EizohRhY%H(7msuE3ms5?3r%(59+pFYqS--_tN*AHE3Tb431ds`j; znQN;#5_|L(VzLC~kNb@BOLsB0bgHcOkO*j^zjfznyF{|a$9&YqsgY&!BP{z8 zAzfZ6N~zVdtic|d(PLE&-ovZ@;e1RvoW+k-xMS7Wx?9bmfE7tP&ZNv!77{*NgWTlV zV$cyZ3`&;KFCr_XrIcQ$CcwJzD$JWC)woxV{Uv`npy zb(oxtw=Wdv+g z%&HWzRV&T+-jozW73;8SMD_;Txj^4~I6D}>L(^=PE!)iw=j-s=At6-g7q}%+bpd`Y zV`R++q|WuVgWWbiFUHKYIWA8oZaaJ0G_l5UM^xFnmG3j6AwFT9r`)L8ONh6Ab=2?R z#y4a2V+-(a*MhZ4NH2I)Rq~F~l5m^^Bj>MDUKeP~FH6&(C%vJ;zcC^*i7nn%RjSyW z)(T4>nZBRz_4o~yWm6>JOr1|>Y0zX8hbOj0(6OJ)$>d)=7`s0-kX)!XgwzT>yOKR&G znW4Tv>5DV36Fb09AqNQX_8G4VbwsEz%wl}FK{CDs?-BWW&?NP!NFE{Z&fV;uf;g{@ zeyliugSc@16}$`+a{c}vm1~^>m(|2|)8NqBSe8;`$@ame!6EY28k|R~ND`(>N&1L9 zq1^@b>Wg>gcwCnaC1u_jeQkd+@Ggz``ib5iK7>y)bgWFU4<~00 za%M!6saA(epq@^)m>(~Tp|-FX>2X<=^WTjE2$5JYmPZ1O}&@UEPPX~fY!+;@gYn*tCE(x=d(B!Clboc z3Chd$YNl81##yVdQ0bkJT9>oSCpW5O!Dz4WW$9Bw3uqzVJIM02bCgLmblMkcLM19$ z!;4RmrNUi=#(J{6D8~zcKnq0eDn;seGqWA?#&c{BHCSEEA~X_NKCEdaI??ZW{Og-P zA%Ygq6^k-L$mKk0HCV^Y<45?CIdlq76ULaAeGGb6yScHPj2V}0Q&WjQAmtF zu5g=^c6UgHPVQl$%A=BaT&P2D45?_LdcD8YWWMCqO)p)dSnq91uy;WYHJ^ZI^Moqc zbDRK^H(H>u!SG}^G1$U|^QjJ5HyU|sls>4{l}<+dv-#WoaD0M$nI{`M#hCs^6a!a3 z+nxC>+bTd1uedt*)TSddBcT+pc9wd|4#|dY<{homDd+%Ng7GE@A z#&GwG@nw$RaIcp)bV)v<4ehe?6s*l>;PFElv9qSj@nWy_| zL~JhSVUjC!q>ZXn^#>9D(9-#{Q}r-0BI@s<9lmNfkafHP>9ZOGM@Ge3S!r?gvQ2)? zxl!G!)pB)%l4Q6vqzEhM#E zOt=FFVsT&-Z(~27?AJnECU~jJl)OeSOlO%?A(|@Z2d(B1dVC5?hc)`6$}&Md{7C`7 zsoREFo#6V0JnG)v%LQ{mL3|xV13_+H1;ZJycDZ$GIj_i%qkJB=3P#qv-kzsJbi(*q4(;O#jrTjJ+x2JLS#-7I*+s`T*tDsA zp4CVhXd>(gh z2O8|0k<~=cw1Xn8j>KO*#2&`vB^vXtBBZwQ&-0vPgyDn0J%M2r$d}#T3-5?bhfZq*5ONW7{UO)v%O6fN`GZLPO)+CZBP7NU z<{UyIzOTjCp6TrhY1i1oY3}DszDLYin1q#$w9CV4QJQ&Z7X3ZMsD}a?7L(mffLpv^ z-!yw(&k|+;n7gYA*e-h@?a7L!Bj3L0aJQP9GwVZA-g_*dA`Nk}MU8(ElJySqNX^VuvtAc-S(jWE=rBRy2GrAWbT)Cn_#RSnG&bc%_e5 z%_vJic6Odgg*gJOOVCIb1cBY~BHaE9jP8gXl+(4gJczj_!2j6u<)sHQlt_-ea4m>K zpj9IK=+lFlpCgZ>+u9Tp#g8I-i78q|j{D=*yv&R|G7^iA&M50=>|soW_kjyagqlyE z>pmd2v=nknaG>(Y%OfG5qvIhHodTc;df?)wNJO1j5i(@rcl=yY4BW`=a_oLYeM6Gi z|Jz?2e0Z?3{X?1q2=mNXrZ@h@Jeh+EM8Vw#=C#3P%iS|kpPE8ry8Cq+qsJ3@eLrP9 zihI!5j2vkvrc36suZ)HKhO+zP2G%{L=}sXXxrmZu-G1IvBJvj%9h8?9*9BH|Y6$2- zdEq!<9;t=6-ZHCbTK{Dzor9F3dGAbE|2quiQTK>1bADyv2ZWxRGtL23Yae-~PNSGD z;F5eu*gJB8qaQ82Su37kA6CQkZ_@qpA%ldky&(4h5UZiK?AeyZE#Y%+4YE}V)6^ZIql`l@lpWDD>RWgI-vZTCrGh*nx7(f@EAigDPL zy}U&7VV%F;PkUEKxm#A$o=0~>N{O@;X=;Y}JWFIMIT$=sXSHdfE0iM8O7us~Y3FxD zkPRxUMthjX5D0y&WSVeaf;hCV-VW-v%{VU?nVAsq$r3*(sBEUl;U*auroQ&OROf`vlxZN z=*hOAPn9{gZ4Tx&8P$Rn{&jFAgELeO9 z|1W;QKZTIc?p1hn8yKq{(aLPi@Eb}j!}(0jx~An~)d9Y7=P1Bwqh%e#YGC?x=Z8}} z)l6*B%Ygrmr+f*C?5~)Y{>w}S!PI-AU(s8fKtJYlzI%dhJMJfgRgZJ7pk=3!`n3{zJX^!d?Da_xQ~I-gJXwyn6m$r*;?=SBu&V zMSS8Ck35Pt))sh~D%_)eJ%t}O2^4*@8V_2Rn3T|ANS3y|ClR5W?*t2Bkug_EAgx@hmdw4rO4{68n^T^w+ zTG`J4@0y*8KngC)%k&~W-IAhkBIVQG+VyiOmES`u09GPW%VkZnr)T8`7IuJNeAcN; zE2^F2%SCtYbIc9j%lxwb^+&79$KDKhWo6ki@+4r7Tj@wCf(|w7h3>ogrW0`!CVniN z7!JZB&{Bf$!n%fyM+3!UO*`bF&84pmy!5KpM-%ruuRuB->C|I>d+yCXY>FAbhR^Kp zOmjHylb6>{hXidd=$roYay7h!^=A@Zkd8|0=yKM$`Rut1XNgOCscJd=9=>x?iCxlJ z(%L|(9nXY~2eDq`fk2gX>2uz*4D;IVxK4)^&Y%C)#Gv6lIljOEHZw0s#X_d|5jRAooc9rYyH0ExiO(d^UZ60OwH-hgy^sTIFU&V%LR7%} z<)D?|AeLLQsI>lOm=D1}cfZR*0>}G-3&{Fts|0uCB%`}23&L-kEn6;Lj< zN6oTkEiYYQ#bBRt{}#572kLCxJ5wYe0)DG1r=@d&dPt5q6qQ+@Fq}GL4AZ4hdC~Mm z`_b`sEhp!pt7G2YGUHwc@ggXIUg} z=bIz>VU$0ZLTs-;=&8Zzd=%B3F^7hgX? zbH^FSaI=){oz*%i;#HGaMI=}XvT4!Kp)93v%E&;+nP0r8@YJ(g6UflwWd6~Z+=?8Swu2FeU8uH)aM&&y} z(^)HRlXY63P7Q^+QP`8&bOY!&a8U$WE9Vj9-<6gYsm2d07`4r2BuY$cu5p~dLyB;I zvjV);o6On%=4sRR(s$P?C@RLivcO~{JfIzm{bpgvSP{NrzuIV=k_Ng%pV$ViVul>M zYQ{Oc7w#t9invtjFzepoN0*jXtQj+iJa*eM!h2>!3b>ZYlrB$3NUq_Fn5c8a!1zzsA~*Ch zO!LHNP8gF7RX<8pzyFzS$kdzVrom1XNT2>AVlZhPT)OaN|2JRQL1rG1=XB?2ks6rJ zJ+)T$_OwNY=!E7sR-$^uyq$_0`4;Jg+-^xp9X4kovea~4Qoje(zPX{VNSXa#B%{(& z5iHElvFN}3OE=Rs#+WKGG&ZrWmn+mBvb!Mq`96Kg--%D&*8*z4`s6-CC7GGU)`Oar zx8C;U&W%2yop9{mn5J5i?26)ibqb(Q)XyC)we&WzlhuXTrhec_%veG6+o(;&+(y)Q zS?1 zX>HeDZZKqK;QUOp<~2YI-VWc!g%ke&wS=pCZlOK46U};#4V=7sqe9ZXV7zruw9S^^ z86v_lKP2!gS#MQP+UQ+^d^pVABPzxd87g`&(O9a^6&?P+aFN#ETvY#ui#V-ZS7j;1 zX?gF{;IwuR8uE5Q0BJXokk+G`Fhe4Xm{agwDvY;)P2ymcD7F7DHhG;#p7a0ECl!fY zo^Q`p(zP;_2g;MY242?;2H*0^CC)q4BkZO7|HN3Q8 zLB_Q_dJTalFH_#9@Y%D38us|gFDvCm-Bl;+I6bn}nw58fKB!VR{CPpY{1$en*~a&h z?{l{!Y|`g&+p1BfsgF)yL$h}sviY}&Q$%LZjHLVVH3-v_T4$ARr%^1oIbEd^?$u+; zjydh!p7m>Yd-^OywZ$@L==?pD=dNeYo7aEkq{}jVHXVQ7#Zt)gW zL?pmU(urEpNTSzB@!b|-T1)j%eq&lRqh|7qIcum?ROZF0A?+4yFjxYXxT_pE8hOpMk>{uuQ;jynoO zt#RRgZ6?}hSHLz2{%pPU2A8p#R!3{zX+@iBJc4@Pd^_0I>zH4|#20+<{$_P@-qe^f z_T#K0h$9B!hxemx93~y3oGD$w%s>p(JiTrV{7_$HL{I93(FWy710cZ$&XohL@VpEY z83erxi6s(*X2uscXwtHy=jSH_Bx!jO1kxt13oWRew1aT=E^`ilnY8Madz|In z)#BMm1?5yxAhfxMd%Kp0N9CatWrxVVqJ)68kn-_f+vBk=FEdtrHowY3uywz^idI^> zqJ~eMMI-EURN@5E?u)DcfxL8=H`ciXfIdoP-O3LA%!OxSp6W?y>7Joy% zSe#Z2{;k*^5iF!!8_Ut5PF_qhxd}k6Twu*anGFSVnqZGqyivB4kPsI*L5PIp;Ys-rkmkQgboB~JH6lEachT%;5_0?l_zPeaiR5$P z$4Rdb7T1vCgcT=HkG)@hfr|dr5XA+Gb zpRTPlNGn&ag&9;h$4IX$S|I1alDVyfLkdvSmsT2i+)=+O;leEbWk$Iybq_%3sv9}D z7uwYY{X8d4`SNIN4~gtSiV<21Uyn=o)-@C!KyTh)eTqOPM40tzR@jl+b*$&s=`~Vb zq8B<#W4Fa*@5WOgDQ-+DROMt-kUhY^=ea5gXvV(nfPbZdfXIr(e9os;=DGNBDp)_N ziJ@kJ<-;Bg4m<0uSRVpvB>2i?QYvP5tcfIjLf`n`c(u{H{>|EudJtO$k z>YlynxuDrlYNn{UJ7TQHIy*d_ZL7$2=FG%&uNnElJ@i}Ar$zA+li)D2UWo1-Ef(d< z=5PPztN}WXzBNXYxS{_RNcuc`6tNurWn;M72#75M2ns4xg~GG zgz6j4Xcd|s3pm9~G*vu6t>Wl2kaE;tNrKYp=V}snh__cI6m)&&^m62ZgL}*- z`rZYwN4;=(7H9|&5@{Xr13%nDClC|t6B@P`zUoPaU9126MvrZ51e6dh>)D-8MhIJF zls&AJ$4kSU(@D0)dQzMxd{}<_md*l`kQ=M?`cLd(aLd%OIqVN&9dMmW-qwb;*BZws9Hg!-8(B?`rB63N0NN}POFL=hJv>C z9FhaASv4bqe=HOb4I_zTo)(+ovOERelFW=`^q%v{>BmKh5)JLn&1Pt~B<~EhRR%uB zXLoqM9B1=$lxB>-EK^F(Zi|>-NOseK3DNv~J!hfr6nqk4njFE#M1LR9}_LQACc1H!M?Hf;7c7e92EmJho}+uJOA~ZuiqFlE`Ap1GwcMzzUZYW&D0vwNMDbYgn}JBO>Lh zhUP)to!G*?JmZd&%&zrsdTlKOgfQU!^Symd+I7l?h9VQ@n03QfgO4(waRj`W{lDA$ zVl}}8Q1~$OCTUd;we9BEDT17l(RGqF?MJK^-zY5TJ{diy_M}3?9Leyk4)L9#gsowRQ{-m>7YnzHnCd`?xjxY3<;2@ zuvcgj*Xj9Q991$A^Iqe#eFX~3ndQ;!xT&wtw1ll_!s<6gs4K&;u&JuCxU98_eR>Qj zFzkNeX$;dmMWWuYmC$+nfQ+lJN;|aco`9%}|7n668ec}+HnswX`;U6p6B8sJ^ejpJvSSqn(wXEByy$(e~sGD z1yh%v$FA>$Vx1&A($!E3zjAh6J||NYIWKeW^k*J~qDu(!_jtsYs0;-?vGj`5wfB%o z7z!otndQ;{mESZraA7ka8K>lg@xD-$8>>npch+8PJ60wh;ewch@OaoeWN%rK-%YY` zf^4q7H$F=e$7&TON5BX5R#6{+wm^$#s9%c!b#lUW!6*N;%?OP?T1+LdTJfryGfCd4 zgynsNQ`j|O?jms9>#^=qfSIg8K1mnH^&qU?>FmOZ$xPovG9bHSMwG`-fIj zsjv{esO-GpSlp!#>m%(!PS)1$XNpZrTDS9#3(6t>9jUUO>#9K) z|23rO{Ozo+R%Xm8ly$KVW}KKk?!s?6A4<38b~Iv-OB#3g#!N)CSqu%;IKvb=&fnRS z3pIL$oTwOx7liO)IqRJ`>Bf(8s6;dRP+UhAzQt!FP^p0>cebMo%=%o8O<|)*!_$8v zZ;E0%1H=MaCCqwjPkYbqMY$s< z0Q;9%pO}P@%E2)mpf1tPQl?M5xE2H6TFHQqR;JGon1A*r@1@VF!gwG?^CxHEI*-?< zHAt4awV(9Z$BZL;#c(%G#d#jJfkYSoZB9~P2UDuf+xL}v`L4-(#L){|u2pd-1RbQn zPB+v;es>$`Zlt01by!KL(v~LGr2m=JZ4mVITY@!d>3&#*I|d4SH><(kU5Fky)UlCY zj$bGDcKBO@bCjTn*TI3O7aZz@F%|$?TuYlo9!#v-O&rb}t4WvVWg+`xc7Veex$ zcic~$lkS4A00&-iVLW#GYJ?S`M z3FqC#HGvppi{`EbE-1Rl)d+n*XdBF7xSabNioo!}J)7Ts55|V$O;Y2DSUN*H?J9MQ z{{Fz3*`3XX)gB#i8YZB(y3kJLD}TD@N$ne|+*$n)%y=ADMHu3H3np`*^rl4|U2ufF z;Z-2eoKc!)){h{1I>$(Qv4WJSbwI^3K*ar%^fxZO!dGJO+4$0HZqIPdU=>Wiu=p#1Z}D6Dfp8h+Ar z+OaZGamUZEbqb$(hlah=Zv2|Em8;TaCagQzpw0%Tv*NW9UN%l^_J0g-^>rm0O+tux3a~mZ;_A?&atp43k(~_ zyjdIPZQ!VaSkb6*+03X}tY)|AJn)T4oDjd56M3Eg>=GL_LHmGAuRZqJp$V;kHRG@qRT2MeJ9!nNPB=?!RR{|+4x6#kWb~dRp2hc?-icD zoLG*b9kw%dnQc9lou~;H^U})^e@mk#B`9AY&YPaWTV1xdiSjKv#|TVB-EDw@#$oj* z?8$#L81>4Co_cTf8D-;S!-d+%!z1B%H_nCnvjhl1isOs8r%S^l`$64DhIg_FZ<}H0pqMn(oq9@9+ylD)~Q~tHZ zY+J%HxO_(9Fk<$yr8TTq?PYuq>L_9=Qn+QTD-%QEK4-6kXOYc!+or<3?Y$`;w7%}< z#cqrt9#I;dKcQH(f>PVV7UFnVDl$u_K=nN_DTdkE`S@B<~m)Q_z_dpU2fVvSDq zM)?vR+*$9wQgGsW@mI74i3Xv2iYKkOvU<`hB|z$~LGvF}Z{YEh5y#V}%oO_JlXCCD zsagCPdHEXjLnpTkgmMf0g$PT%1@tq$cq`5JiQZq;7UJJ31Nf(nEk>foy*xBbo?CyEC zlN!9-CuT_2+v$MA#6gYVLb4B=I&Qd+i*BuM^4#P4MO-|e;*uHsVW#!DEJykN=z&sW zbixc=?Jgz27;!4p^8wi?pG-}q5R0fwM`W^17~K)nL8AF}EH*v2kvCKUx1{%+8jh!? zESI9b)g0GCm|9R@CFBrBB6r*nz8jp7l&hMFS)D3#2t>|8x6xWGpaa3WEPO2^qRN;K zJ5#~0M(|UO>S@Lsv4ZFj7224r0V;Gfj?$|)z^u(eotUe*JWB7*1IW!r7RPpRg} z&e0nmoQTo^V3TX(l2xZQBELpa8nIBFWNoz03< zzR#DthizE)&X%f4`{w+vk?_g@PpPv5Lh171ERg=7`f&13W`f#&X(quM9xFA%lwLVK zkP-(*R-!P?!>y`cu{dIZYtu_84HY=WXxD+T6VidsG5jB ze**ptU}?h6pJdmElW_~SFs&@SeG})lri}`Qp~Lz%!-Kx6eno=dVn+1cr#&q?~r4pRFGRYCcs3j2{vLZi=##=q*+y2422}h8&oDkNfENjfx;|rbz zhjl7)ufdU8E9RY!OX@rX;2&@=QTw{Z*eEq{AMzuo269S-7I~3?c8GF7+}W}u+g|Fg zurdjC+VbHfMXMJ}vQ0I{c&NzV9=xVVB@kJmSH zAiUDP(GDr8cgszup*1q@bqr6$++GkWG%)UZ^T4u9xBtUd7Fu6m$~Yvxj;+$ImMrKF ztSrroW6R-J*HOB3FlO0C+$LJ(wN{M#ha$9cZTk(?-_e&iI4yu&^eMqW^dYr`W-$vq z&$H|y4jEez0Jgtv#bhZ2@2+zYSbBn&s;G)Rcrti%bXNK5({ z`s2wJ%GPQxsNI_$iAYi(v>sQJ;sH=ogf6zoJI}2{$#$K!8Ux!D+>H?_>u6&7A_0z)AB> z=D5-3Hp$Zb0r(M0v76OXZj6g_K>r=juTM-ta$10 z;m49c^#fP%#_11?XM*mFJA?37q%DtM%)P&%U@t*0soMd8|8eOAyQcdK%S^Zz$k^8LjfYM%4Qp%6}42X$C zE+<}MA#J?BuR{z@-Lt{nN{`T;P~_{1r^V|4IAFyA@i)}sEAubAt(;#Zke=Lj?Vo-_ znLPYBYYquG4ZwN%{@>TDR^rFpyTAU*xaGKtzKe1rQe@%JRA)UhT00~jd&O@!XM@MwF8XMx{uK7X9(I1G_pV?w~#VD^9Ua#+>GqD zn&?zIVf8vSSU;G;jG9cTH9K;*#^`y^3t4xy^gkb6X$#QExwAid(GOVqbH1#DQQWt8PkCTc%Rv{D$?zu`z0o zs#;flx`E7b^@Oqr=M6b=S2Cs_bMD^HY?Mprb`J*|bEn^XSU~B3?3sHw~rOu6)zD1Av2I)>V ziQiD=*VmbtI@1W7+40UHzCRkCsx7~Mw(d+HL2GM78I6AoCH9dPNwG#e#-r)3bT4vs z_TavjgcQP2Tmg}oCi9-kRb4+5gkfE0cTZx%Mtw%DqI!;Qt;sYQ9XHC`O7z})L++?% zOungRPNzs0^G?Vy*$Ls%snI@^P2<66w#4EL*_Z!}b@I*&zS>D;s`bkialSSgJ{j71 z$|DTjsJO%^gRb0%%g62rRko0Vn`L6&60|u|uNRf`Z2L%sEXJz~z~tp&sJO`P$TV|{ z%u!wL#qGw`Z>TFT+_Shkto@O70c_J(vwmB^MWnJW0WX2COfaN*_c>EfM{%-y5m|T+aaX7cw`-rshzZqijrjJ9eWXp1-IZy! zId0g>s_+y?Ugmrthtg%j3Vp?TEzH-*N+Vy%`s!S*HTL`qRgMGzLrU>5e~G>%4Xeix)N%z|LOE3=={lhVRI5`#z4_MrEkeHR!vtzZe6oc}AMhMD zPAQ!s3?}F(mUDQh3y;^sJ^`Jck*$~w&;ISzi&lJS4i2B>VLWbwDnV|1mZqZ8k!88E zP!vZssi^1rjp~SJb3~_BomKTJ;aw^ zsWF&IrRc)L%Lz2zp>f}f!k39fmQ$!Ly*^lLR^{z7GWqXglgRq!CYw_gj4yxxaDXvP zpx))X?Imix$P84ZvDU&tXyZXos>JXCavW#e%6k`gs94%JQU!N^gER!Y$6mInso zU*ya`H( z53@avG>1KVaTglj#P^c7Yp3uV%F+uziqA9b)&NDL@?X5d{-j0b#76~fc2cP3iHxY` zw1N2SjvCyqDI;#$gf+R{mS5cCwb5~YwGYA7xFNWjyfp5YKBtof1)|hgEPw=HCK`1! zufpSP7OU3yySr?=yL142`;w%p0Eam)#*qD|v1GfIg&1{6?UaPlbTle8;3*E`r4^jm zb}rc2Q5H>GEyBBl=FZfbsgMb@_FPucD1DI4xI?_=;D$e(G;Zm!`M_u8>jY1UM=5fx(d6ACfe! zDXtxv7TQ`Tt$ZOI;4gzLb=ur&DNm76xU{y-n8>$#h>rmlx0=PXwKBjo8a2;76F%W( zXY+ydaEf^?%VDO^vO84q(xD;pR}h+ zhk@1*(ry9G`wTn5^zm#B^mYQ}Izqh`->;rulk?X>EW@Rbs8Spdt@&!iST<`B?QtJU ztS_jZBENSXif>DH5z4)R$0Xo-c3mI(t=8$=Y=omoKy zlH?UT%5azoF4DzZgs3Ev$Z@}#@e0hD$1KkD zzmr#;WyfF9H*+$YxRN3-3D(JDQG{^c*8Pw;N zQ%4KToJgPPF4Ud}fDlkVPGuP=@E0hrs4noUG2?4mY3{2Yz#*oU8j7Hx(wRL%6pl1# zItyXeU1m7Iw)>RtYELH$QbcH<_?+4_x9h5KedrH=R7ZBR%ls7f+&v!t17EF6J}_bo z?V{Sg?0aPfqZ`jhjIG4yW$kZh)CPxO?`dKAo zg&2V^3GW#N*<@P?q-PM5&#A~!u_UypprMy12qh!U7pi8k`9Bre=tW7N@O{_s2T8o! zvWQ2~-{DNHZPtsEkvA$0=9QgHqfKa9!gfBV_wYzXX8Vj-goQ0ruNfHN?SRy#c%{Ff z5kJK@k@rAd+#5L)_v4uS`U5YOuayQ2Co(;|P6cXCT*J zOoZ|g$9vbSl*|}FCkWxt11y${MN5Der3N%>$US%n%+H-U$5wakGFt~%*xM9>Kl5^}mmb>h z@UCz;pdEFkq&?CXWe(M3N(wFGEnA_<8<5l`$)2*Tb zGB28yZNc9jAj6~PXn^UvTMk|G zi16z~Ef42YN1ujPdwFgpu!tr$F!E6<|77tVtYKzxBb}L(Z=emw?|uhdQ*(Lb5cKy0)3gqEkAlNH1u_Nv+g? z!z{e~q9J}Ff5TD)Xqyc+x^gLi|3y{g{C0w<-L*bAB5cr3s?Y^Nm_?3)%!ZoS@KZ{G ztNz=_&qv5CrKicsnosmUc`w&K(x_^);r0krP|1dK5TGhLBzkJ`5ul~>M+uS}laDR5 z+YUcZ_Xf@|d7K^wgS)9AsVmT#@kf~Jg-3FOtT>U2w7aV*$UsogC1C`BusBGJu|5$6EyFTdrI3^tromko1kt|^YKmlnpR;?;-MugF-uEIVqPwd*dMdNC zva-HPkZPsD9roB;;PRla))=e#8A|>-J~JvQY>8z7gj}6C7zJv(ODX=uH#|VjLv?&z zDXTUX;BIN*aHp|wYKBsQBgsc~^G|$^nX_LnZiG^kn)?!q`4_;J`6@*3mp39;g6uwO z2re7&v@L83NpVh!%$qpvGtwW3G%36HFF+NYSXn3J$JA3-Y*on-NR7)}j0yUdSQZq!c&$)&h7AlSgzE zCA{X^;4NV;Gc-ud8b%|BsipDD64*h$<3+i`o__)4yrVZK$zIU6UaZb+A1o+lazI5Y z^^IJ=OYsLizGuAR>PQ@o1eYt;LJ<^n;I$^!7V^Hro!RcncSqfx(bRW@bHA`m*VPQg zOO;F=)}*w+1Mth~%6Hj8CLpW$%nf@<*czeu!Zn`A$ijsXUqrDIvTk3evfLp`FlwE= zB3@U7%pFC~-SP_XOj-XQt{azz32(5MZ)^{&7&+hQeM`mqtJhe{;Uo`88Xxft#@0Ne z>ke%AhE^P!bM~ycwAFWRieoU)EISnhZ4^)&d87aScV5uHvmUGuh`{ft6Bau7-gtRy zm>XBg?2l^UK4$DKPR`rkRD9G7wlCrVHav=)puY%`s?`nY?NgskD!-}j3gA^d)~i+QmhGf&OX98Y5LtB>5|NwxSer737OLDOh%)1 z)6xWGvbyg6qIdyW&6M@hZ-hBmN^Jtq{QQ*(QtlaYdLqUYpy)OCGytUV?xI)4#Sg9? zPcH{8?TXfe$BIgeVOw8;RHZh{LL-ogOp+||N&Mt(3aVJO!bs+`_-Bd_;=NpGEVfxk z^!I{F+$sd3hXGVW7+LW0ViX|%R+5b>)*2INPCr*1xXOK6b%FFD(vGhYgW;7KDTD|u zXm&v7E5o6f+&AA}Tfcn7{oM;MP>$=tj;2~gO2)2NoUZ~VH2O1f$-Oi+VA;(t2cHz~ zvfq^qlV7-aNoH&n-W6gi4nq}%6t=`rBZ^{pDe`pPEXBgiv}NLG(G?A?ICQ)3)hsyA z6-pakxad@k4J#xEn9KGhfdUhV(&ef{DY8+r@(|Gh4rHc4t|`4!zqWijdbIRdhh2pT zFXy&|o}0lQwbVdFS0e(8nNv(XFYNll(!T&8_Zg1A07UV5U(8>FxM>C5Z);Fa8aiDF zyokSlygD2T*T?+WCzru=UB^$OGh`4F3zT300ib@SEc#&*k7>v%(3?xW>B_ z>tLm-*uaYd5|x(i;n}+6o4iO;*RZWA)oDdL=5ot9P$Twg12I`0{{sB{3vl=sVDE+% zev-&8R4fdc-?@)~T_dKyHVD(O8-Hb%X1A|Z>brywL!WQa({;*SMsarWrMfX_o%4La-o<%vB6KJW@b_Y18qyLMbG~Q z=tPvEGXBkDky~Xbkd$ywIi`B#TM4b6q|TuOzE}D1-&m1m)Q=iRM6Xs*dwtB=W!gXw zTY&RFv^|$F!;c1*o$bYUG!bMuu+jR37PYHa-_ul7VV~Mw7jY#&WOiC0nRu66;)rgS+Ijo~ULTNYo%D<&xZS8(V#T1~fDv@X5{+@9a^yNt zc=mq2!<=N3_@L8gLb*-$)a$AFyxJl@KkayUG`H)PRF9shRvp=y4;y1 zd+Y@K*o^E;NmcqBvW}Z@k*D!n%2lxB!{{sB4qW_s;)Aw6M@$Kmp*|B^`|8(^2H15i zfooh%8f)L68yzic8#j^74;S?un;g36 zG~JUo(jVvtbiTDv`~{#GURdz%4bSc`{0Tl9bf+CCx6fI3wGGNrb6sN zOR(Bt<$!cPu-9t85`FI#O>-OSxhamkZLQc7t(Zl_3KBRcg?bQl%}z;CIt;09$WNei zI54~o^#%%18@;|~gLd_m%+BhS$f)Uv{1IsI;U1vr9y`+JeOj?=zmYRt+~}ZNHZrIh zpGX~J90eM9ZW;fzi<=T6oc%b$2CzeMDi5lHhZX(f-yybyskevi_UiFYpK&#gkRCgu zh<>c(`{L$vs#}1T?8muA?`zhI3JNiBRRdn!EtBD~KM-I49-lVID!SJa%%D&Uz7;*e zZcR+Jh8uYh!;JSV&R%pR$j73h)TD5j%MK;Sd>_NhO=#1VN5g$lECp8zKS@}OIb5pp zrUcYWdy40B@fY@wDCe}F@eaDsY9Ms|geC~v(ioZR;dJfV|N0I!2>-MQ+xK1JokR4M z=waS4z{AT<&6IR!vD;eK2k>npQ%Fqwiv?9p#Y;w1x3joWGCDK2HrzL%Rf8%b_3!Z= z?*;mjuOJ|4zMcM2JM{jQ=255nRN!{YSuc9ahVc2az1BIeTsHcbRg0vjma-r2q7j9A zV`_S)lzaL=4k^OwLRNQ8FWPT(J}Esi`o*<#%O}4ZwC;cd;E|85jtC#i4nM}x@SIDR zU4%Qe8&nYWE%?`or(G1qwnQCpxmF-JII_v;a;VZ>Lg3{lYVnhv5o82|Y%KzomK z$5mmjz@x@R!-;ML3Aap3Q@JKoljbzKs&)bJdep}?T`#J_K;lE#{QK$K(&bQ$*Ixjj zemn4Kb~^X9@=>P;m+ST~fV0m}p`pGx_;bO^*9o^FULET1m>04>^I0w}4n_}IdrcjQ zM2$xT)f;JVrJU!s_oZ7bkBMu*p9@5;|&HHFu9o%zKMI32Zi8&XK6+U$#rj#@3OrX}!VByV2#UU*Rpw0$qD zo#X#a+-bxc>E8`4e;SIEsfx+6c+7o8(UQH1E(s~DzY20M9KPMA z6y62gl$j?6lAQ9(XXHM$h&a{^nEe2zyogQ1@^N5%Q_&mlfw4AMdNcdgv%K%5di(Sn zOo2a=+yc#g4Rw#(lNlecO@^DE7mdVJ)KK+d`PfaqMB4vQ5UjHmIk42tJdtxx;=T~h z$SwI<8sT(BYlN*u4KJxUZpg+_4?QwbqL`2-COS}26yx37z7P^OI0w_kbO;Lga`br~ zm+GSY>2p9-gq18JnuUX`bfemIkv*@JEvjPFBl z8**|vX~Bc+Lsc@Ni2Z7p8)5_>2C1>)bciZNN<}PRcm!MT7hx0Tprk(P&*W-kju#+k zhc$#gPV6L>k=R-#^Kr`dPgl?XrK>FF@iFX9J z?I88@b%$uy&m6u@EGiWsy_#n;vGcvI=T*p1FgV2DlAuO(eYJr;Z-_Oe)Xi;OYhrNO zo5@N8iDQ56E^I-l)+*yH3 zwmsqzU136qdCeHcS(p7QcXAz0QQFGTiVaw33!kn@u|#(7tXqQmvD0NQwz7Gj4Yu$b zKDm{n7BNA>+upqsPxj6zf5-`C{(&ypeQkXe1Mwn;QqMRpEQrX!Z*7~ z<=1&d?vWYOxBi8lG2nd6`YqH&k;_(^3x_jl^RNE;j&g$LLxcw_BH!xS5#=)1Ha9xb;ZAu@~7tBUO5WG$C$|1dN zs04MpSV@BnpOqL#pUCTkfiV+UsvuQa=?clC4ZLbe3j{sl$a#=zS}EnY%vw$KP+EGc z5Rhb`4hiRECM-1MW?F!t&g~q)C3zhwJ@vT7smJbEc5S<8cPX7#J~R4U;&4KbU%X}y zra?Y>Cts1l%LRob6W47Sy)$btq)&>~fJxv*?RwU|x)OAow3^}m^3)C)L39$|X+7ah z{2Ek~iD5OJ-23x4ImLD!1zIHt=KX_V8!laR2C|5T9qrgtLB`dJ@Z67@trT_lT9Ki1 z8kz&Pvj1-?Y_goAnBd#`e`;9{3)T(UPfn>i5RSQrG)%~>!%e_K#tbi?413a&eJ9DF zK%Fy?dx30$suc%`7bpQ+L4Q=YID;`Q_mk@7s`vML*dwa%the{qPVg84^Q3O8X)i** zHea? zQY_YN$}lN>BUsEC2!U6U8FX6=+9;lFshNdi-r>_Q_`e~eLzeupQdS#^7rJ7f2S|0` z!pd5-Bk)UBx_hmu zzBL$cj=I1>k=WTTuovccW&Z5lPTCHXm*+{TUwGF^@ej%ti=hEr6s&(I2VuRsBhA)J9oO-;>cvkfWHLvH0* z!u~44uCwZSe8jbQy;li~cuT+FY7a}V)9ql}4U_;e2sOUUo5E4@;Jy-k|EuDgh#e>w zC`aaxk!>|B%!&jqsR@>Zg^FYe<+O9Q3uq%yV5xIr(!C!1wB;W)UQkGqm%Y`kCdXQY zyjSV+Q#;17XVpc9yR(9j__uCfDSbS=Gwg^iYIr~D^{Yq8~k+b&$Lh z>(3&Mi^{uTiLm+PNy@@jb4OvEt4U*}uIymU+oW8fMj&m}xZFw*4T@7=l2=(YrVN3{ zB->1K?lJsq#Sru)5zx6ZEudGxeosM=-He2sc@jDo>!dQpc9ATRHH83A!8s#;O4h?og>NnAQ;+(IU zT|NzbtK2vD&E(rUt-IWTOeac1yfw5sKFVA&>rQqbd=`sFdS|8xsAPKgdJOWg7Ff|O z?4cQ4nm4x^&M+Ua?Q{v98pEW4ESXzQ80kCS9dV334QN|I-fr4-Mu;!|(E)&E~pGM!ES z+7N7FR+DtFBix{uEgjDm)OjC5`fCaV@;hAR8y3(Di*EkJ82>{|F4buzhj8T$DN+2; zsH-|p9n+#)OEfgpJJ*xbna}z;*oQ-XBm$m2AO}UF(>Teg{G?`wmNYe6W+S&kMS-(X zZ?!nxq7RS*y&jX)TL)r+#*ms#GymQC(YK<%CkWHn$6&}>&YSSoW8!hHma~VlK7Xbr z9<)rJ;C;S?xm)qAYm@NNh^*P4LYH?fp}?+}GWKY~E&g#GNO*j1Ek~Iop+K4l3zMwg;6OJ3DS0HLv?XIDsr&5+S&g01FyqaT(&m4fpw9A2wDjW^u>Rbabj#D z5(zi*+umFEWj1YNjJ2l^Vff-;W$FYA@^~v- zgUX12K%=iRsa`p}rn#!gq>?UQCKUPLn%hul8NskKx{TPzLco|k&;PmTEp9CU`Y`c9 zL@wU|Uplu_q%i=c08xaP4u}&vRe6qi5!HW`{1E9XZg4yHD%O!tbX)N%7SEB%{UaEiD3M1g;;L#JbYtC;uI^4yY~J$-PF(~JpusgX(zr5FtfJPHjs zk&+uPu}ToV(PlP5dKNQuq5J(1^+CeOm0s%>=9RR*Q347#LyX6@TC(#8BXXdO#dPyv z9l|CMTsJ#-OWDTd|jeRH$YbAXT^rVWp!dnBlh**9YfpXSh*cJYM8Y@=$pusRo3ec z)4PO)`ocWR>ys6WU~`@Yvl)&In-*yD(4xef>QRTMGidp9$q4@7Ol_M3#_*Qx^G>U@ zbK9bDxn`I#`?7@1V?`2AI#Vexpv4C@;m4OK!E3*xYCduZq}x^!g#W>p+zDX$2+;co zFe!3^KrM2x@m&`j=(1GA`FC9chJAPp19J`--NT z{JB2j;B)>>h5XSl{Bj`L5Z_*wt%K@9Ot@Ux_)7u0HWGQ1MBN3Ur&`ms)nPY6qIQK1 zom?^ITp$FQc13fFQcG>8v3e6%L(Wu+%lUc|1hFb)cvSoAQ2QR1c{br38mS>v>zTMg zu1{b6%J0o6jrf_w*;!taSwr_@$=ti;XNCCuTwDWAbti?VTdXtRHL-B$BB<-{pYF&Q zUd(?L407H{a~j=L91uKzq3=N6C?HuC&fw#ls_Gh?n~jhCw5e`yjAg#a7?BXS^UyVA zUA}pCY>xdTmBi_bRJosfAi=cJ`aGT%Dz?55OtuFNLnp;b6&%*>OQp8k9BMRRA7kG+ zPbE9{JIO?plVbHo$1v&^q0n?+EGam7Ow2NvGRSbmOqI$fe^RDVcU;Wa7y>!i>)D4C zELih(MMq%sDdjp{m`*D@Tr{j*oLdTLQdyWC|}16zM4%pO?1mg7~y ze2W6edt^g-5QIyo^c}uV*->rf>>g9hu`F?6omAzO*V))334E@6hQy$pPY*0&QIr2@0W(StJJEngX6aS1kHzD95;5S@1m%dsRH@^kzLC z5!_U6e1me_%5oj6weQ54_Oaih?ix}Lng8K~`upta8}FZo@p?ON|MBqc^q+@|R0(aU zvp6F?8Zq+KG9oH}<#!YuG1(rsPUy7LvtV9CMh!L!C5y^ZdC5E)tA1m;;E}X_G;$d| zI{Wv7IhEtH&KuXcs4a)nhAAD811I)2x}#)|>D|8NcLP{KFkxV7yItuw8fj%li2aW? z>wSy(&!s&kAd0mMb6FqH3b($&t2!R$+>mZrG;yXVn|;E?ftP}M)l+{E!+)f_9J_V~ zrcD@-D@KHc{R5eRB#z7-{7)o>+oOf1TSi#CfJ`lHHDSKIsJYahkPWYcq|?(qLrL;L zrO0o`gxAw~p_b7znn;H9KSUADxyHbH~*vlzR^ zsFTb0b{3W|WBJ1?Q)Q`BeESHyG2{4qav%@Yn-Zs!`J~MbNpQyzKUGUa>Ud~1zVJRr z7oawCB)WhU|4wDm${!8CZ;_hSzRAZbCsR) zGN%%>bo-+(+nCb&r1L+bqJV7YN2s65a6(uD|G6Yy`CZyTDvo=(vpVJn8E(Jy$;7F? zG9dvQS90vR(Lr@_(v1{8z{{!FcK21UiKQiGiQtcLEa%yM-Cm7hgn0H<8|U?m;=k0$u9PUHlkW=#u-a`3TlWymjDRWnD;U z?&zmz`CSzGCj#2AR*i}-kstC;7CnW8m+pd~iUfXz)45i=Y=!Llt67|m2p88YWjY|v zC;pE(|BTCXRIz;@ned!MR`KypRJcO2GL{gmec|6J;TJ_iG{`^vhU$NaGG&P0gWJDf zyyNzUaU( z-<;!Q`3rFF`aGDLNdx^cL*6Qf540*7=%4J2Cb%86^B5c?r&)zsxOx>DS<4|Z=fYFz zKZho@bo0*Jw^2ZD#E&lDc?gVe;^m)lU@r|_LlXaAAB9GOuHeFxaZPV*anJnU9Rko0 zzp~wYZ~EW-8ouyKRbehF@i>m@sEM9WQ`xAYx9}D>smgEMr2NmC8E5vF6uIz}`_7?> zZDimZ@%@`<>1Q0EWI`!diX7Neq9%(ASuD&a*0fo)bW#=Ar68MV*+KUg>ehuu@Ra*) zpoxw9TZID>;$SvxR6xC`t}t{tN^X(`b)~sHd_6~f5f#sIY^{LQ)?G(c)MlE-GAz37 zz(xu6_oGDTu{4&UuSR2Q`R^+?%^=?iYj-U{$|db&>5Pav%OtO>U#!O=)9lSE1`w3%DIi*3RHgCF`i0#MnqL-ra?` zzC4*qk$thZZ|;RlfItU>j-(@z+C{P>(p7dUqUE0i3Uu5o9%$*(6UjeZ*K`bFXxDv~ zz|16bs#!9jNjyI1o&V;DWE+j?ntGg=#l=%RDQ;~vFpH!6&_(V7A8;^y@zgi0Bqrx% zU*x$UF!(yh0MC<=G!)_z6}N)fn;l`8)gp#ZaJUqS0k*;VFiG!I`_yAa+y-1|r{U>D zJ>V%=4jO$tZYw9&P4r^7H!)X*?L$+@r2D*u*nK+9U|VCcg~f@|CTf0S_NHjOp7H@- zm8hM-NRZ{QA`%H&!VcjO*F7L6MPF4*zIp%Pb3o`tacVMi^tJ`d>{G~5K?tilrT5$s z(Ewld=Pe?PSf0837A>*bipyaCo$Y3Pcg=XLKKC=p{t;~et>m0Y2OUD@ZAC|-dQ-A9 zSf4nPXrbV8%FX7jGg2s?jlmRl_le^HzP7cYtx!=^&5{!rf9&LQ$>t$G5SO;fF`gMV zh84i7t)X;lE0E?9*p#fLwt!y78sjmf7~H7&B{Dqt_$f_8)xK%s|7r9 zHBuHblT2XXQTk!|PzQ;Kb^UdIfkh^_@||Iw89o~*Q}Df`mr$jhP8ekj8&0uEEYE@W zL-5v9N9=;Yb>7yGphvfNo~%71U_)6tb;G94 zrfvSro=p{3KNgVQt1wb75#0e@3RbV0(!ra3ev@o+Rz%#WDulf9XwwuW>JzEG4l z6^>U#?h#bLF2a|W5+)fdf@9t#=2E^wy~q9~stFNgr97=AB~iU&aKst2b2Bcw1mt{<8XH-# z?t`Zi;wb*1?@J(|X)W~lgUrYr0l%50F(k4wScr=A1Y`>e3vvUzZiWZ|U~8k2t!OSu zazzpqA+#C(m;+$3up!(f)Stqozt)+e`pu*%Cd9M0IYQ0O0S-+(h9vzFpkfDy9 zu=f=l{&wbhfhybsmYoRWb zj6@wypGfS(^@n_l$OI+eoUcfpeK}nRs;cy9)`J@ zWW8LIPI7fwM^Bb`%FXV*GxEO;`7*f?)dc2NJqY|>;jlwk8a>58F^oQ3n0owUmYJnChSHHy{z5RaKnWih2?PC6_#cL^k_vWw}Ja z`9_^(f;ScpiDHJdfUZWm71~g4P9_%wevGNa_r_*0nN@;GC7cdMWr2M$V|ww~go$3{ z$QofZh9%l z=0Rc)ls7b8__791y>TqE!UQ5<(imZlpG0|i0hBKskekn&hYq1!B8jc8#8^d6G5lAL zyN8$b*H}S}+6ZSafm4SYiERTIE^9Gp4KknG=)*WEl9VHMQ#F+OqdfczuNYCViohkE zL-mwxg^Mhis=EycmY7B_E!e0AH-r|?Tw0?OoA>j&;;50#(OGC zK5Qf1lK)D=mFT1mV1Q^-Yz!0_&E;Eg3oX-9O%i>ChJ!L*R6+;FnPUb?Lu{5w?Sb9w z5_w(5hH>UmS8#GWF-n7CR++oWSanyh%SO2E8{s%l2poDyzhzVbp`@MCt4iU;{Du97rO zgr1%R64NQ{0R)~-Oq(EkYd=MMJ_iY}DK!!(QA`J+DJnW5DQkMt+FGbY{n{?O97qH; zDX3(e#ww^07G|cIP!KD0tBkPwC6{R0hwHHMRflMiAcyFxl*j0hebsu`?$RJ}R!D0( z$MD~(Bp-f7NzSKN7vWHpI=4W9RlOoR`;xEHt29}sl;`)=FlIT3sLmf-V^^uzCJ5Qlh~eCWx??vNtV!;n#78*{yS=4l zrl|rNzx12kXnAx>eH7XICq5#)TwOp>L!*TSV+8W?P@Wc#mLxVPX(l5h^Qg8>UQCaS z5Jxr$CWz(bM-+dc&CH){|1sbqnO4#Blzyj%0%jn|#F!*pS-D$N(rYR%v_lb~hhiGq zm!yV{eTW8x#HQR^s6&=ahr%o9w8~8F)w^4P8C;9Xd4m$!> zcuuJ#&kvgtGjjA5NW;Av)s-leS9^W5+uoiMkMHKk9JY)6##*`n6y1T%{&A>y0)-Zv zg(Hn)ue%38aG546M%`9erXWh^KW6s6ppW@XGLjD%#1%A+e5$iJ#VMX%{0o3}o&mE{ zgV~+y@FgRX#?kMyo2%RbPhcaISeY2u!LJT|R*uzSc54qh008bofqW@H>JJ3buCW>|G`?LU;!C#6G%(5`X`hae@3PpjR;H@@l;Jl{R~J_TX(l=HdVyzh4kwgc;{Rwc;JbY-K8MmWKdQ)<%z+=Av3s3KE@)BLe=dDQ`N|5AX#@cP)CrF^T@h});jAPFZg z(??_2Ch>T|>;=7nR)H|ZH=)-yhlPa~MIcsNWTyYpm%6=-f04_k>=~Z1AL(?Mu7WWm z0VZ`6N`kUgpFXipf{J2vGV*spPX_yUPZt*F5X`wtZ=cIyZxw2-}-fcz;L9>7}-f(Ou0&?V3IPgIFcKP!f)4XHqrM%DbFlv zX8nfyfU!@<$+P71KCV*F2D%P9L02*xW!zv8SCHOa4}YGGpjY2R8W%W}R&K6d{#1y& z8yf^&HB~X`LZNpz5g53GZu(seJCm8v%Gl1pfNXh6z+=Mdrfz`@_g8%K(UlQivYYHx zD6`=Ke8rDy_=srXwdh~Y9t;tp%R5_^b~Qz=n+yF}kQiFITH0tEhETvEKg``oWylXO zltI67XXdtZj<044PDNA@m#fN}Ga`}{;VKNFMfby8=`DsJgbc6Y{2LGRkTe>D9idRa zOLW`g;)nn<(u+C)MhqI3ZlBh3Ic!ZyVA27>aDacvWWyBAr=~M^Ei&mi4WSHYbx3dUfUrFv{>>U3RcoWDS~`>2%efS<$rj=Wd0BIz`z4Mn=*_o62=h7+pXa zHWpNSZzJNkB+76}DX>C#HxcYGu^o1da3;6Atzcku^i~fv)ab)#nM#?t;f`=A2crv= z*mkXe<)*}={H16Uvq7F&FW+*AZ6tj3&AIxKv@Xwx3G=QY z2?##~qWIo%L#^)1+Rc=K@@+pi*&!JW@afw8Z68PvJM!_imk@a-S`Jc4>clm;2J_CO z_fDDzt`f{O_JfSyBM6ch6IbaUNBBm=N}`hG^s?ihoR9?Sf2Y+jnEI z5A+YlwuBd>AFM`PDE+{eP#BiI8gbnCLY$ddr|cLFb{$8DKS}Y`w!r}*i4PtI^8ioO zZ8%}HNj$0GBGmynY;Cgz?KvFFo{_*W6ZrIVIq$h{Bjm~9_{2iyv(Pr=$qf5SKfT@b zd~sEo=v4WSAUIL&kHm%w1}=9T1vi3ODH%5SPP>GkqlI$vVqKH12RMWH zndA>2uD0(ak`Nsn5Q(|gWF=;k;vD$V0V`iezP2>7Agq;)=7sgY7Ww$>6cx3=DH0{sZu>b1Ze5&3JC&S8wuQzOp#qANupah;;!I)CX+qS197q{N}xS|+=QJu+j|Fv&;axL!$k z>0{WUJ9~+*8%QfzsNWN&sP+xV0#CyM=c0|XSrC*bI0ZRoM5>n*x2B0#^^pT3VHY;+ ze&_WOij<1zNz~NwnB=1wv^P;7QPsBX45#x8nTAKk8q59s4IEse-AV6~mxI47#YoNM zeIWps^5nr5FObgu2-t7LnLMtI+0K(cY&x*+XBzw4LNNmuF@zRD(JI8a+~lr>@h}bU z&-rwXku`(Z9st1ij)o245y~&;>|v5ANdS3xEw~y)!Y_rGwGwI_I&E(N5U>fLzmlkFuVy zaqzwwk&!7)5-oU1gPW|h7Nf+F!KbTrxX2F;#QxkV%YMA`3;UVwAV>UJ_7~U*?W_Vz zY=yy(A-qBr*07MDlBTWfC-Q#Zo9+75nXv5rMPgnaEMdNQ^(IN6OXHYfCNV{Ol+A$o z6%8owPjQLQ?gJdXq^@ZiG!g`)^xX7z^~O6U7ua!annF1ZUyG-c>@~5i{l%f;Ty&j~ zHB2;V4qMz?*+Ndr4y*40ldLUv?UJ!3gG~h3-7HgYdlgrqB{rhl#Lw93l*M7ekUO{; z?(i=qv4c{OK@|_|NsJcwU{k|fNt{{zL1uA`R&(Ge%JX08zYg(wYn_gTi&e=~>xCXc z&2GNF;7X3YS%@T_H0M~fw1skrNB)C}!mvYrW(F+zHQ`nuh--(ZU;zaT4zgI5sP$Ti z3f$b9{A~9uM|jvXkTOAaPuOgYNN{f#7g~&kHo>aMI0;VrYn~Ilcz%V@ZY5Xl1Y=0t z0Iz}IB&k=458y7vyfl4yk)^kcYzisV*IRC*+iqsGL^SaO=8s-3;k+<_GF^pg18;Fe zWIGgSdsWh<$IU)>b+X9R*+Xqsr-3DNS0P+4p)vkN`e$D){Q>as6c3Sotv`zi1d@$$ zRw1y(nIL)To*9PA2~PZjm$G^L zfg?wDh1O;Ih)-4bw!${51z$~+?1)#JIfOi(&4M`zsZiw>3dLq_wRtBZM1?+;$iQJh zf0Vq_jSD3a$l-)9Wk9Dch@_;(w?NI196L7?Nj(0wm0@#XFgaT=_O}K=39!$@VeMti z`|Rw-($Jz=UoQ&6lvSb{ZG)zlBikj}WRAZ*+75MGQ**12O&RA2PbJ8MA+-CKH~Z z8u=Lxt+yP^t*bM_=*VeG1QrR(FbzQwrx>rMF^XDOm_xVK)aQE9O=NBc+oBM))Rp+1 zWDH-J(;XZhqnN~2#&|A*9OyPTtr=toT4qe3pSmLvW&1FC&)sEK=H!KefSgz{M9eU_ z9P_XAvEEXoG)>t^3Wc{m{}4p#Kcl(gw;&qNL-a+&wL}Qt@v5012(-lqE5cJ1u_B@D zH+V}F1gLG23=Lr!4Ek-gB-kkKbj>A?NDLnX{R z&T3v@iVzNu0wJE?V{w(n$aAUIR2<}Cw8p;?XlNAcee59R>Lz+bOCu(qW1`F{>ocG(PCFtBp!Up3qx@?j`&!4hTB;i!x9it>JU0h$t< z+A?AvIpKCHzF(phngT6?8oO1F@ZK7C+Po-s!Z&inuG)mnOou>eoD~U12`_g zQ(JDKaph*~e)vI|HxW8QYhh&MBpq?^4WDJIsa@m} z#E+;TQ~LR=?!(v7;ddjaDG@TQ-N7tqDd&YJvH4-#Fju6T$mUn0mZo+}{a3zZ{Nc;s zKly@mjlefPIi(%(Sxf~DpAiX4jY&)e#U2rT1gqMWOiwP3-j_1WS+1fFt4&;tV>Fa8 zjR*H%X2kLwUi>-D)R7mkEO$SNPRp{_TJnmWa7HTk5AjE+m-PRtby)vs?2P|TJT95- zWm;`;AUr)zrS^Omv00p_j#+tA%;tb?l)A-);klj8Ur_FvOR2sX%f$Pf^A?aUvzNo^ zjTs=GNE3KFp4z3s)lwUO2&LmoCP!Ni`*b)dL66jZPiErQ4#UNtX>pk8ndazZUIzhL zte-qA$RzpqctN^3^9&7$@u(lTR7wq)72g1IQY>Wa?5ChzsxmracpNVJ;}QZtjzJ*_ zAuau@Aedzj%(1p82a}9Czlhs4S2e1tCr=L%N!#t?a(B#E&ecQ!Y%XQ2jM3Ktn!lO!CR^pMPlS#+2B}iFC_E?_J|P;CawCZfR6hqmhl)D3 z&X=5Peh+)rDD-wg`Cyh>sE2QZ4`zy>3a*GK$X;|2M!$RO1?SQ`1Dtf&yp42a8~2=| z+gPT%E=okwZkM^cEkg}%xz>5Nf!F*ww9dzi;*b;guKKE5kFQdVuBA!rnwiN zpq7`9(ieu}2N5PrdHZf)g8zLsWo3FXbg+zHm$n*2s=nPUWzqv*(tqxhxzFDcKH*w3CO_yn;7 ztBaRYhXd3j`xx%3!ffh8^nE=-$rw-}5)n)(9T#J$yd!D3j`w?cmV+{t8=I%4?ZEF@ z%?3pj6erPd-Cok^HfFwW?!qGZTph9&OSu20D8*yD&_R$!8yKSt+e!xSw z5@i`0>?^Kt#xw(~jf~P(Kcol-0tP&OfKWBPA@>qi^?18V9^ENiodim#u#)R$64ku< z?L2pXbEf*yO)0dCV=3&^=Uqyr*OGsduI$)hCv?m^59`aQ^6Gxt!ApI7BQ}qVpah0U z5`;6m9~%?iiPrCj&Docc(fp$ZQcmh8zPUsT#R0i5YDDe44q&kX7mLbdF6hDiv>23yZ*-qu8 zlTpjS!K1wBdey5s2*QYmi9!3&e&EuYwa&XCiiM2`kh7QgY-Cj&?e+tSC`I|r1Ym2d z&;%mUpEK1t!}z81Tq(CD{qT*N2@PQL`DH}>zW7;~?g3%h+dj+%&-mc;VJt0-(E(xk zswa4i#gDFH(C$M&N=ZS8EB0dAsZ1k)$8DNl!}D|Va`I)b+U4z|f+6fEBSM8q6hJyU z(#=kefF(Kn)}*9l1~>9V?h@AdEoMUor+@(hXOrK^(Uiv6*rFvXV4!?_(s! z5OSsh%sn*rt%|x@*VZmaUY-9G`@Q^)C3?n(liEW0V-rS|f((g_f0Yuy-3++f>HMl-5r8E1W9m$C%C&i3>qK_1Peiey9EvI65NA_+)duk-gDPo_niMf zYt8Ov&kTEdS65Z{u6nA<>`iXNYoV|eaQuUxP6sJmy)T2nQo4;U;4&-h3r=Mu2Tcq_ z_HMFc6+cL4E=dUp!rG}PkFOCyiMN1l#;H| z90eX${K2nZTP4#sBX3r?m?PNjh#yffuKl;PhM|d%zc<}SqENFv1MEdB^tRh6>KOs- zAcYvkaz^vSt`gyzE=REgCNYtu(dh%SQAAzo59Q^4^5`e@C6}rdsUpv#%CD#c!=OCC zvogy>0l||KUww3QKQqM5!8ZKs%YH5Z5tlW@Ym)vo4$k^Xme5Q9S#tk}EZL2itf1~N zGdocYi4)cxY@8t#i?9e=>4ZtA$A~A*2&;$$#d>EgFEi+%MoUU6;lm%0Vc)&i>6= z+0u$FgHq2l5n>B)LXtDI#OhZl~wFPJ!lI9|f8-hQ9& z=UbylPLRLVdn$7v^SoIaMsxZL_O@kwHChR+E}l4Uhek@|`jYQam=jBE0hD>uri|-< ziC(r|7a}*TCJh1uMvX^=ofbjKvtkr+613fip&acO15Ru~q_x9V_mp}q6iDCS5%jMN zehW^Y{*e4CQS&$;{E_ba3IF<_ysGHN9|k(X9;7(Dywtl6{?Rm?C|^gZst~g8axKl~ zZ^H7&sR|f!^0sc+UI3^OIV%)l%|Lm_PBi^eMr8?uf8YBLg<1N{2rgSV!f{)q+IFEd zjm;?m3`p`?-8_iVHzOTr&t-n7Y}7Su+AV zR9Up2C5xCsY>zWUEArO@jW=w^?pplf3o4%WE7MOK6b{S|uFO(YhuHna{(Eo^v_J4U z?b+pm^0`SOI*ie8ZeAu6rOH#;;M{afetzCVXD#*cd4}s4C9?24ZLDuLIqsX#HiEtB zYX&y)7-+t+Up9niq zrqTQ~{ZmH;U??P<@6JR>OF5Zt(~t@7mal+_30Z4E2sOvIm)0F1Cml+cS;;{5yRnNve6Lz;@X0d!;^Y6zRjc+ z9%GI-aqqzX#! z?3J=ADPEHPwWQ8F-k}zD;b6$jRy+( zwEBD@l@+H0rf-od@YhH2G1Kop>B;tU^)Ve-h)KJ)%i0!Esgg6a#O~uTleFU#jBh9d zAc^7dA;(6;sv@ae(d9Kg`xrC=ef2cBzli0D2^U7ghRUQo`jb{@r@7Ns&G$_{pT-x;v$YtBn*&b8yPK8*euE6` zyHgMdqOGuJWl0gBcg0GXl1}W6lUv%3-xSF53Y%*Mz=2rDJCJQUSa2|vD9evZkpzD% z;wH*gSaKcr8am@l(S(YCa?|f=!8igPAAVefLOC*CfuX(OG8mT91A~gY$?1WJNr$C* zTU{3G2?Jx}*Zk^;Y9g<b#v%p1s0)Gkt48UFUSDiI>!w3D(;~@j@%-*1#|tJqS>D znd2jGFiB^Ao~dy< zzA5rE4ohsPUr*4T2$ORijio3UI>nP4 z*s6paNTq^+P7e-R5Xc%FU7^_5^FWhS4ipozO-nkLptf{54eu}!^gyx#|IpcRkE3Cw zX)S;96xr)OQ1;GnF=Y{ewykxU_JW2500df(0GN~#v*mW?RS$ab5CHm#-Mz_~cQHyX zC#it>$ow|0_4FlngwC240u3ulqNeQxTqKE8t{VY7`v{(68<>8(w^ySN zHjHKgx?iT9I=)MNs{a}e8ZzlToCek0P0fu=I~$oMW<{4n4@bB74Qeq|9GZLeF=BPO zFC=_7#9n@WK;Uu`qw;OJhl%96?MOw)C4PV&yRLn)zTW?m3Q$G{x5L&5q_KAp%W?{mV1U^l0ASJP(%3uZ~&$h2J zO25Y|%Rgp-RJ8+S-MGOy8K~6NO`age55%Aw#Q1$pd=HKabl%G>B$&Hskp+_NiM>Ii zsZRLt0ds5$-$|t1Q$UCW7T?O(sSdP%7^x+wf;vs8)DS%7V_h@d5}r%s9ql18SXF%* z*9xPVj2EhZ`-~m2L!(1ufV?=~o?u)06shDd{Zp}&A2zSThXx7?PRv(V?9}HS9yjkH zagTqMZvT{^hGP@yAnxbj zX`W%d(?)IFOp)jif7IC07-HIvw@Wf;&50nQyoC66?dag>&hxMZd2$|iWYW>aGNMzsb*5Gl;ZSZRlTFeH9&a^w?o-x*AH zeFD}S0~;dt4|x$gkplAmqCqhk&(4{LBAz^tX=a@dCc0osXEQ!OA^MbL&tKA0c1cM^Aw}m&7slch9s(hwY?k3{ zN&PCljPWm%!wG{2Po=_Eq#Yx<{C502B!9vvYIL4i$rQft*83uw8NvRwWGnw|Y7JMj zusz2Hqif`q6=6^(_}PO%l~WkB&`BEuA0KOKpZAOZQ z#*Yz_1x+P}L8)7R?I+Y){zEZOLI!h(47q6q7kFzT2jXVy{^oV0aGIBUP^3VY1&B<4 zH_~8*0=!Jo6*k3KABd%lo}E(sGTUq~qox#^jYH0bN$X1o;vsUt-$mq6d*%Wiro6y< zi4xkaFsrLG!8*~KUlgJ3kPgbnf%lJPmXef|nzF>47#SZhDxq7sjI;+^mD!0{o=Gud z&SGy}d#JLN2Fa9#3u0R3loZYmrk6yY7e4v#O7%_yh;$8j8KG=7Zp}Rr_8FrvJ&2

ZrQ10ehaiac%Zk`^}gI{TWjZ!rCro>7-%8F-BNiI z=T|y%us$-5c3M+vqQ}0jJh>@qxP}y%Rh_wgeHO!W#7+CaBMnW$vBoSd+a(Xapo|-$ zXPRc??$pmSV|a`~SEP2xPo0$WYz77jw&hVpxk=0|KO_MUb9+%H3%4j#4PK-^$$DXF z0e3Y^{1IyvLa#W@x5+udhWj6qsTKReP1&QUHf`{Q%9;hHL~!M+Zl%8ENLlxZ=CK?f z$5c}Nf#;S;@e|15df93&+VsK1$NnPDbokp!uM;DC;!za1-X};u{TrlHC#j_Gp&~Qj zQ`V=_o!Yt{o!BwRl)OhE)0Z5N4KCF5lF9h|-DEYXwjiGn}1o%JfY z?ou{%UUTnPZ%>9gQ)M z1lR>DHV!>Zu*>8`0WqmhNM9za%%~k%=PE7{c?y7w zV;OLRK3d3da+J7Oz@TOqzoDZ*ae!PNr|*uG7{DIGraz}YFU=%Yp(mB|w#KC<6d%bz z)U4Z-mfWg~W&eTEs14Z|C(tp%dGT-K_8Y~&tsEN@FP9-j0cVYwi{)-|a#-Yjk` z%_ApYGU>!elaz8r)$eV}%?Zq=#qkIeB3WOX#uvOm^i0JKIu^VBmogD}LRI^?;W|dy z=)W?gMK-+KfhM{G+dnKa$p#(jay9xiO8>7mhZOV?vKAB8&Q;Mt-TeB7;MX#xgkN#0 zpE>HTnwo3}O|b?_d2sZsB9Wop&$nK2MN@k4%tQ>{d{7EfNF{=-VFlnxTU(2M4{60X zU0TY#xg{=TM03-CG9d>d5^8GlfhzvPoVKNxhSfolJj&D_a?@NE(_8HUjRYisS<W_ zfv#Y}whcqagd$&lCz7^jZ1M%or-yBLOw;h!$;Xg&9hryDv%UdnOcR?nD@^wLy1MR- zj%2!Kj(SwNk|U{ai&oCAh`C=$4Tp;GYhv7iZSDkdObO%oH!MUbfZXJ+ho)k=Ubk#y zB9kR=U2l_q!?e~zaY2J*O_HJ$Xn32OW1OTPSYCXAbLnGrzPCe>MLUg9l zhU#CiQMex3k_aP5M3V6A@o9F6lQNS_CB?dmHWZ!4{9>jddu5Ta%JZM9SX;Jewpmlg zWfPpYqqJX28kkxx>VHQa2=bC0cPiW{EV-->9I_0UU~cTw>@eRT^9Z9hR?;@?bNA#e zwTKW#^I~PaQ2N64QgJrf#PIoCN@EG(vs4$1iCB@mSVch;hAegBT8>$Gev}A07Z@BU zDIU#ge&5l-&hP=2EhVk=x)Voz75SjN!XCvbwKB5Cw+SKz?f5U=ya<^7oq473P``w) ztN0xfOBoF9kn51dXaqz=gO1KHK5T4K#Fku7uJw%*B=!wbK^ALX$+XDdAc_M;<#}c5 z%3P>=7k$>3P;oD9#P^(Fr`~PCIDvFyaW75HuaSX-f!$4Gom5LTl}<0zWI7d_o5^YC zK!b{rxO_BVj|7EUwc}j`lteTrq{<5abIo_0y4K!xqPyoZ0Lm3uy3NwmpEwZ$&MR_e zJ9cPYVLJ}QaQ7hoO6 zQa`@9&uI?uDp*>cx^I2liTt>XL`Pm;vk2BNk{5E)%VPn;Bb;@r1jayN%P~n=ybgR>$u5IW3g#0A=Rxb02p| zEQynTejTl2-|}5u7#gad!KiEh`0E2#2v*3CYwmg#3ME>1d9s(Sqg5AtEhA%QhtB@ zK0kXCV03Q?$LQBcEZg_f)t{8HEL)%)Gkq@Luj~8<{h5}(ou$_Nitn3=SUEmsbD5Im zNyV^%%U~uo&U_^eT#>|@_JPvq-spwd0Pr&@X|vs$K2N`={!neSw8-$N_wm>fNEOWd z{hAL*W1M#2mrYLVSBNDGOpokoDK8Pvga`gfj=9-}sQR&EP(b8Q@utrRkF?W|JKjl= z5*f2S&y)D3rJ`B8Rf_?T+vCTZ-=LQcewBn9MEGKs-~h$uh)BPolR?@*ju@89+WW$) zqNoz1;y9SR^wh~Ws~+d3)bEPYO-%=T8X55HAV0_ZXJ|0VQsc!l7Hm~DmQ3@b6KP)2 zrjoVnIwJ%N?Fd9{#>Ei2@E1%Deidf^B7jBio)}HI)v3Var|s>xi~8y8DoYU#dv&vGK%9CSc`K^{_od_TL&Fshw6!4V#J zBs=cIhE(-H>Nj7on(^I_o91?WFFcu zS0AL4q?YmRQLC%il89U-w*oEWsqIxO4yJ`NB40Bxe7%S{LX4#@K>iLY4TNM9Z1o5r z?4o7^Ktv#OHjvfSC+vn>C)7uGHQa2Pz@|SVBG@pQY|p&_v#KIMHNPF{-n#r~-C?kx z*vV|0;SRQ>>RZKQd7wE9AIn?VV3B?t3ds%91a|wDa8F$uLSFiE zT4~ws52Qp5jMWhXrZsgX?mSwC{WMZ^j3+h&#^9?<#21{vbHudd%=VU@Ot2$}c6?w^ zP^oQu{K2NUES&z``Iq0I=Rc#4co?(?)l$wK$}>TrlaPuyx=q;{A@A;nI0ma{4LCbT zHq=S@UiQQSVWIGdLFLIqMGgu)+^w&r*7O<2&_kwL2DGv&-ZNR#=2|0oDd8E-@a2YF zH%Zej=1hfjCVEq)u1CHVJR1WTK~sczFcLrx5b#>xB|NG>wq2l|zNkPxf8UPYUd}X} zz>Wpq@Qi=*hZwnu)?<$7Tdb==O(4BWin!cT`;hu2f}-jM!a9<~XeH%LJLOSD#Z|dq zrnSxk0=!2Y5=VR>JJrb59f~8$OXR5Vqzcm1!~qg9@96?x!ut?mxl#{9ZbkT6loRFe z0(abMTAAGvJWNtb0#(>bSH|xaE{sOTmzBWUsAlPSm{Mz)zA`>pL-K>~a0BTNHOI`G zDGk$Ac_vv0VFO2nOtG0*_V>@$6%Z%!>qas{MJ!;Kc5ev%lByB9j$OzFkT#R@3s0(~ zrM9qlw`<+Nd)d3i5_P$+q#e$N7kV_J(|1D@bW*t-yEKJOzjv4~WwK(9kA9!XWWGc> zHCa)Hx)O6m^r63cBA6E8($-Rk)2qLo7OKU)hgZ!`swSjYzoyM{fJ-G3hSbo1LXqi&KE^xxIc& z9joY#Q=TVSdC@cPV%^Hul<4r$#8!F;0?2;S2}r}d+rM6)E9mf)^$M|?9_LBdZvT3b zj3T5O2II>|wPfK?uH~G^cyZC~ssop4$-=#4cqpFhM2#lx`E%W0L)b(z-vTAMkf?pla}&hqQskk%B~4P_JdUelnn_c8 zbBiEt2!v#GijFnso#@_GAdFFblc~hi1;GzROX$HBKv13T>yATJm*_NYmVQb ziAqYp%HnS^loFS^NpGbwv8UghY0sn=C}uo}LmPNGNa++@FlKb``*qf_RAsO693p-? z_uo4xR6saofoS}8E+e?NqCp9)S^6Ll1Q9ZF_i%ve$#sI*1o3yPFG>66S{JS8h%*=q zL{O{Kmw?qoK(z&Y@tXDC)Uz+rg9pFJz%Xa%T+NLLsEE9glz4GMWP|=HYJdrm;^L+3 zv%2>?%d2)y7KaZ&i5ti%wN_um4T}+vHkD1BrXdPtQ!o z0T08DwWi`Sl`03cFc8)|R(A+{mmN#43F00vF*b24bxYyX{KtqRxgfaX`P$pxxE!j< z8D|csXrh)^I4#l>h*@?unNjakzt91Kky|2_29A6>I)z))FBmk(-)JELj7hEsk{z`Bef@1N+OY zfbKgqZEea@*|qHVC-D_s6xlJy^na}sqZkpby=DrBfm&An+CAfuMy;e2@;a~B1vW`G zIDx%=)=IvrB-)vTEm)O_PI#*5#|Akjpvk1d@f zb9qRn`9;iqNOgLN;Qp4gTpj-L3X3X?<`kt*;tJ$*;%@oHM*p}dfwfs;cPKBVzs^sa zcat{1L0G%2sw!{Oh=Lue+XUVr`#P|H$re=j7 zhDU4%vUg=pfUG(zVEq#07wq>JHGoqXSzEin^<{2`QHQAq05g7vZrgzXsZ&tN(4 zQ@y79VN6BI6QJW}H#n%-`@@(nd^`&|suMSiIH;xM&4HJ~{rBUf>kqe#-@`!~nHb;w z(R!sneb;hmz76onwig?A1Ig-Z$EX}j}vl8bVj!@a_pn!=)b zI^k@BiN3BWf=ib4fC*0;TV(RSH2-%q8EyFWi{x`}CJBD(7vPWQqj>j>g0gJ}MFBPp zkj7yi^}~{F!rK>7zfyCH#-oH{nXE zeeFI%+(JxA=A2ZQ2|D5h%z@Gv#ET|mv@eUT9X(#fRmCM0yUnkEXKSWYK-}6vb3=Re z^JF|D)yTH`ZD?!JqNv{`-8NzHkY42QT#*|Hgdo5Z`1&6@+nxyKcv@>uOZMKavlzFkiorTBqrcTky^ zkg&s>5YQo=-rTi}6rT1>X!iGOts|)#NN-b*6VL@rWK?08D7OVS2WF1;fWKUXI~4D*GSt`dQxHw6?OGpqfRLVR6wzx>&4 zyhjk9bwV!jBSCaiXKwHMnjNMqpgAhVaZ+*64*Fojx4uLZvyAar92NnA6`AOofTx)g zx5uQab*-^6-rfj?DES~JR&@dK=j73G zJ)8a_IrUuQR>1>7(d^RZAVg2=1X@*aE50l$@%cmH`TgIBYeTh;6Vt->W{6+9zoBXO zU35uHsvlKs+~Hdr5rhMN;tNflq+l;YYEP*Dg15YwM0w${iz-sgT<|8;w^GJ3o}+}l zTD}R>bsU2@qxzc}-xg6VP>{5)KpEI8fYqRrwCTkKvFRW9xN$@CGi)HEk>1&8Y)dP< zsyFN}hIbNdHcJ#iN`xqWwd7O|>yRnkQKJfPV($PwVg>-GzsU(#A~J{nKqieq9P zchG)0G#E~8QhYrJvn7$-4ZJ6(s8kpYD?&If4km4rV}GtI0DcL053r{=yTRBIvy8LZ99k&eCPGCU>G`Gr6hv{?G}5Nv{h86_xdax&IeBE*d* zOB0{y#p&Zs3KHz6;_(%^ru1)T4g}mKL1%VjmtmLnVGYlQf6)YQmU#fkF{gm5PQx0$ER%2M8WsMjpgBX#Ham_cur*?_;fj;8joiG{ehR>1uCk*ngrdEYbnt49dTxJnP>I z!)Z|~Qlx-kEQS}B=R@B>3v$1HT+WnVK)L;oH<*fCuA{_GlbKTcu{%IiGy?AY?v}I< zcyBeGc4WSy0F~3O_}yttLw|!({m7bXD5ac-fWo&gurQOx#!j5!Ri&0EwZWwvT7~_{ z$Wt!}=S8yb9nALe0ILCg+d{@&=#gW}VD;zH$3kI)A#S8dcAwYYKwV(%)b0V;*#<7& zBb7KmZCcamfyp>)Wr!I_g@QnRnl(V$AN=v(4NbgsFP0n65#W#&xSiUMAmuUC$m5Ei zATPJSXgak6WlMH744)NXS$eued=io>9QEL}#F?wa4pPPVeosuq=YI&~lZf*YCt>$a z!NQUEE_|JiLnciyCS+(J(wk4yg;gklxW;L`Gh9Hf*W-lcNr--YkT>sEsVteOpMabu zHOH--X)YiqtA#jY(nuk;BDf5M(ZiO#D=>JVY-t^)BvF>QoQ}K|HATMEHS5xGIqR~2 zPja4|YXYOKMOh}hM)Bd}-Sgj|N4NG5GrWS7kMQkr^dxv9^!41&l4m2jS1g^Wf~g~*TrpA> zY~v9xCp|a8#b;5)8eZO0nMI{-c3YG&24@pqL@7_f*V7t-h43G!=tvQL*seXJ^7;v} zCw_z4vP$cP%{oRb7xEV)S$B5x7JCPwOb$Lj3p(#xMrzMe>#!*OPg znY5LyB;X~*mnFkjx`;*879BxW>=oHVF*A-q{hc89!MdUF7z>9XQ5L&Uf9W1{6oLx2l?tPTkipOIy0{&Dx1LGb7GlgGU^4r@6Bsuve9np7-~nV< z3eyuOVb%8w8ByHAk>R*R)U=bKUcf%`Q(Ll1_2bsThCxhCEDY^M&yuziP%d7!kxw=$ z;-!9nkbyrv`Yqnxe+P47cwHJc3}&;goZj=y(hfB{|BdbG+v5wCRvT8hDJ|NqpWgJl zmF=PB>zkod&w01G&pZ%R72alM1p3xC?iu|C-3~q0=}aFCD+Q#Bmjlu|L` z-a_mMUCxWOmKN+Gpq|}axW<;#aSvAnt(Px#svuc& zg6gt-*>Uldm*!slPOP=liX)o}Mosw($!*6tX)Y}0R}LhJJ_h!zC)bZ|F3Sqd1UM^O;LBy(?xventzZdai!p*@o!yy(e%Emq$S7OO@qes3` zvpVZCL;7*du;Lr_XG^7#{yoW8sjYH|CPh-ojC8TA`V0~S6V$2FNi6h2~`kU}S4~70` zYBk3^3E{PahnpZLiv)@0{-p?4}8$O`HsA+#?8=b)JnDm{j%Tt@Ir)Srxd z1=~m>dL=|70Kw01WlmOGB>QNm~$zrpoi)16-3HLBkH4RZNO7m*a9pzD9u zD%TK&Jj-PllqU6UYY+!b?#1RReiGLTzItEg8bXJnN z>-Kl(WKhZ!2oSvHzau;VY}reqiWXZIlLTfWu4>qq)5&cb{P4a~wz?^DIW_j~h*3l3 z2ILmjZQyrs$XW=UlEHbO0u8BF>423S2q9Env}dizt&Qj%I6Xgd(}1}c5U3;h%uRM_ zTH|MA#n~=B}y4e>R# z^5h#>Tgk{=j;j^mFWmvg5s$Iw6F2ui`HIi6{ha5f?0<8MYyy8+n<9+Jc0xzE;-HXer?D8`%aEaQ*CHQHs^j* zzVEL7KYQDAbHcxLBQO235AUF zMJT^~UCY;KL<@9WgL%(l-St7s-y=?LBWhYcVy#|a0cE2o+jjuNWW9L62;iM34@+W# zf9RfDUJbodv-0HkS{F+J2)lfvUIGwS$+q9F2WEYAd{zlu4>C|V8%HNCP&&J7tWvjc zQ@072|LXCp>{c}GH|WLBsjHl=JH1~y!2M3T>2_Ghk_+hQ8Z8V?DQH+3(m3-VG5eP* zm^A>gj%F5ZI?vb_fcXh2TRefo~HYNcHHZ>#GF3UjoMi-iu z1Yke8gxpyCgZ-r!EbH#muCgP>SIp(ec1}cr5+8;kSD>3M{a8lYXN>&gQe^l=uCP%P z4#p1w&-z?fU(Yw3sjM*WIS`d%MKcIl$ag%1x;`a_P!W9oh+nkNnT#@%Td7YpGLp+FOS=EW8u5rJe;JGtDN?i$2%nql@eSleGQwXzY@mVx92Y&@ZQe!MNJWMGFxtAm zK^0HWvIi$Z;XaHf;gn@1>Vv~Bs^h%cOVi=rcfJn$X_qqxrvaZ0vopfDKD6R_=tg?q zwbFV_yPvjdLnf`KWy*)(IE;is5s>M?uPmcfsCem6^}bYGjEnBzp}dU~CzzS8&-8f; zd_+t>H|bLmSV``3T_z(=&_JX*uxn2tnBY`hiLp@>FuN-z<2OFTDIB?Cmp)=qwj8FhCplFhxn_myT-w$+`F^GptAmmgZ;{x-OA z0(MuOi?yAPU@sztu02dzEII+fN~Ion0@YWA^4_6cX2t5guV1s$Q%Il}C&HApX1Jzc z@|S$d^^)tOorZ}N+KIX4G{H+<-eRespDN_VAEyM@^{KjT%W0w{q3WVUeL>Bx{-yj6 zchEHVpt?WDFJ79rYDU3ML6!MCCEWc}Q+hh>W#7y~e%0zsx9=HvBfiHz`{^yWSN>z& zyG%0=zx6BjEj&{G=W@*8P|y3wZJ14Ta!D<*gDQ)%qRFEs(V2CQjNPd-6BA{{h(tKZ z7Ra8RZppekZjS}PM%P3>h1NA~JgVr)N@agBdX5@dip45$`rxWguI(V@IB$*VhE!@{Z~fJ zNIvGW=(|kEi{Jf=4^0%Frd+4&gGPDs3j?i0&KhKtL{#E;G4K7~famDl*kE|R-4zeR z7vQV%N24NQV-&K$j6J_qN^f`giFZY|&1CrN{j~a=b85xQt7=Vg3#fX6+sHn;`Ss!q zk(eWq&Qwz|6<@EpSj2b!m#?0~?Y^<{{QS>TEjabypK%MF6I?1~Cbb$48Bb)xFXN8^ddy}sL$fc8cD|Xf zImUPP-x%*40sU7Z!4@N{PtLMqPT%LY$R;jw8Vab@*bbJkzwpGoL^1(F#{boj%;Uq} zWNJ_?PrX@nhx1ase=`D9#^XP_=J4eASZ!SD7*?3)C(|%9(`eSYiks-l=Fmq3ed6LD z?1{Z+uX^`+l>wLgu&p_N(LYz=TkYMx->WnFwPXXFH{qo&A5$l-%*kAcWyq{u3lv98 z`{wa&CohO}}mnmDdN32m1vi2j2-e+mhPQ}kimqz5|#s^~Z_R=yk#`}NF|SP?`!q|XEj zo0*1be)>u)IEIp_B9J8=9gWft9@}nGp#O)Knf`HL7IJ-^fzdIo-i4rF5T|3-5vPZR z&)z4L{BDr8gz1ZSv@!N>1@0+KnGJo4vtDG_;VUuBp#`Xv>~|WhP>jm-(~x?heZz?X z{VyUW*nHu2eD=VG>;FflnkNG2nTwiK1*M`tHJaG%Adtx;eZ@O$NLWlT0hfK1(r0 zFj)ZnIYg+HU~j211KcU%MySxkDX&@o3LH%6Z*8NciJ+3z6@(6_xjoNNFG~jps^xmY z^pk)&8ortH16aof97y~dbUA4XX!i)RNqqpzZ-w(GjPywb$a^zp7CuM)QDs`y?`ABT zM&nGnzH9f1LuE&W#{Qq<75blycdO-S0{zgU4*B&60o`Q*&Tr6GA||C~b3}}&f`{NS z)0Li=+oZ+Lm|!**y|D!we`1%#lVX1%VVj{#9w#xM-fA`&y?cUdDo=4pv4H@E?RuS4 zmmyM-JUe6t?#9YBZt51(=-1={^4o>g8}v?BIdx?Eqn{CdbU36Tj7up|UNFaNQ$(Ij zs3b-uRuQ=P+)e8Pm-d;2YykZ6)4+oJ;(&TY&miUWWq}t)+e(Tj3Acam3`8|cy3eXn zz_W674y?=Xzz=c%r9lsnl&8t$zWm%O+pxh}jbm?5=;0oW8m%om&Nplh4CJ#B5-{G{ zIWf&G^Rz_&;(;;TY@!b-UBZdIS)Y~Bo<7qz)!cGS?mADPWhQcuB)m%FS>(!m=k6n% zGx(g1UQvrJ6QYc0o+mR<0Uv=X;fR=e{zf0~2xcv=iQ*$Qj}Z8pz>m45$^OX=Gztn7 zk}zSnUk8LTV>yxjkH?dkWS`g#_yG(X{U;r5NQwS7n%U2jZu|l@HmYqi~L>FC0~l*sAy z)s6?V#@0R&Y6mhmUYl=EG0%yVEVN}G@CNPAF5^_S+G27-4L_|i&~~!RDu~C%$A8M& znx%(Kr*M*)V2V(DI{1he-XfbDd-%ni!eR*hjE-ivH>d|iAuc3Rn(ftZ(48GT%p~ld zpZZfMMcZMJt``plzhqc?*awwoUHz&u*OEXUZ$PArWAkHW#84-MxT{T$sth=44Nshsts1fKjqLx3$y1@W7&$>+*) z%M`bP@ZAzz*6@QeCzUIc#l)W$wTuGb&fczE5TAyXI}Xfbh?xOUSqcA70DM%P?p^;R zFR#)an-h*hw|{N|{8s|1M=l|uF53~6s0vgDl^+UgZVMO~0na3-@zGqveF7b{SKtAt zdd-rF`p%~=lkiSLpOoFNP-~eD{2E@7@bEqD3r{p!YI8=+Ids6qAT89nuI8;N0~NIE z9(MM|u?rPD_@T9lvT8cC6F0sX74Hsl<@`vJhYMxvD;{dxwzv3P^7#>IR!luU!o+pl}<3cE&Zd!)s-iAg%ZsKSDp`}us+9T=Q2gzSb0NbvknqKt ze>~+p!it^2`hKD+-9w=}k1Iieujg3-uxFP3MdQa>g92cc4#t5}Z@@E{lp-J~m*SHQ zwUbqd;?G&1h9{;tq$JJt`Cm(YGiu-;T^_KPp z$Njs(5AR+zVRuHs?=9;D5zdX|VpWtrW(kv7X}KTT2b8Jx%}wXtlbY?N)P$=HcHaGo zkSOA5?s+cDg1{IHf-wgnH~e~Ydj9XGK0{Z!ofw_x$QKX}?Gis|Q3;`d#}xi^7!Htm zG4fjP+(cw|kIBa24Jsj`y8k$eR&g4#XdL4P*y7kaP1U+%FOG`W-oYV#VY-3Wsy3Mt zMk!4$B`yq2{uB2(k0sYVnIFLll7uA&o-_r%&TNu(GLtNnp(a0{{YR(u$zPso1@;eb zv6feC%wide{()!vD@h?lph<4yO5V%I~ z<)yo=p~}%U#>Oa6>Sp8`BmeB@T}FfkkjkK$`*QCoJvT`q&g`7zqce(Y>Z@xssbVKh z>pcZKKCZa7U%Y!S%SxlEm9;RGO?YYbWk8ICsa!vorxB1nm+nt^c$}MgY7ah~mseJV z_L0fF@bc8%x_)MBYrU-9#Qdx}+kVy5Dg4SX;s5qtbWtrJlVcT-8z|1d?T;FzEW)VMx-a$?P`;tMW=nZL2 zHH*mUx4ZC?wDX`Zw3B#ZmgPG;E$3^|)Cw`u=40bOUd~x?EQk|l?qzXyYuTlIFuZ%2e zL~jM<=gpI9guJY`DQ#M64-$d0Z!Ky~NI4BH_5X2Fu%w$nj2SrL9)i{Jxu-~Y<2<2u z5jXNAW=L%Kl@e_mJLH}v73PYx!gLxpN}H^(DBHziU{N5<8Eb%I>eUWjt2p(WviT7P z@3&I-s&$!arw?IQIkwn&>$hk}e530|-RDFLjyYY85nJr_i~LXjW@cYpn@@=!@?kei z1yRrrRRXf-n9$}N^KEXC(tnkxpyspKZ5z9|l^4tfqt895vUo9CVA0`6e%achAoQ;OjEr`72TIM0IpwK%5p|bel=Sv1@tIdx#ROx|9)Hc-Hf`lL?-kC; zJ{b;%04`P3Fltn(wM-Shcr%Ya2pB@Bi=!^9ADZpvw@6pd_fk-p{(Zsg0?%BEjKM&?&!J}_JqV0m)mDVKb>L)EL|1@S$O`l z&M4AQOtM>X4$f_!0u`6ml=S>?k(w06g;Q`iMWD@3sgym$$=y4&MQIU-oG7HX+OI*c z>kgY!+5J+2Ev}_M*i(3V`}p{2-8A*|4biGxYK@(irgoKqtjUwSSm9ACtJPp<|9DZt zPdKn(-|*7ps~80W_6ne8k~$G;vh$Dy)Zy#R#ASl2LP8J*x0oeRgT`yS6JIKcFia{j zjM_zo9w1(GW!0qxtC7K#s&xM6UbLXmdB7g#pTbFGfuq9K&D)xnS~K7=>Shjo$-XAn ztaiZqg_R{1+#RNAZIbAP0oCOklIR*@vJvk7gO1|&3G~L>tH3{ZSc3Dw(5M3d9$$1) z-fCmO>7Mi@l=wQx_#@kAUEm2eRXu#6iPWMQFW15@VFQBcfTAmUnz-ftDuas#wjLD9 zZWR*J!Xk>1hKqr?@fJrmCBExxX)u%cp0&wNZ0cNju;?GyfD3rU4fIL6A<#q4xgOyS zgUn^M^))TOh-P!>!j?tc)92Pv+hE$&XR z0&Qt=f+x7UyHlXWiWUeVc#8xN?$Q=_DDLjs778s;nb3QG?|bj8-^_Prt(mo+gsg{u z&bH6qXP0r<&Z9-$1R_Wf1`vxI{i*MC8TDl&sRya zc9+K~@>}=N;s)ApHa43DOQZguy!^|B_O|9*9q(wRuUxt>mf|8{OeIouKe$l zQbOBH|D>V2??-@1Kasn~@2UKVTAif=j_H{1`pn>_tuO1Sus8daRk4oVKN>m-ox$y!^t>$qiQV z@Dv!lpl*}usg9LTYFET(YPM(Acs9uT?yWDn@(}m1KQqoSF>oKu&% z%gS9rsERto@FytRN+IGhf@w?YAkMDzw`{3;$=@O$yAV~IWFpsCw@GxR!?C>-(@+hL zLMsW1Zw(j}_>m3{X#+h*&*iWa9ogVYO{S5b+Ys81hJS9nj3t4!`f#B0AU zX$>ryHS~mG{nbu}V|bVaW45jjS-`hDq{)V<1z~c?I@wkK_rEccYVsJK@_Mdth!yKq zdU`lotl<`^_0$cy^w3Y%bLT%$+RZo6jq}+zBW&?u`SE_0R>Yg=Vr5*$K@Qo}2x1)Q zA3%I*R=D^V9eKWyZ}lmr20kEHyx8Hjfm_wmHNzQs$eeUNLpUQq4ED;Auq9AvsFup! zZ!6P|@0+WK;t-c^;zrqKOH6V0;sgABLR1r^pPrrzHDQcCL_Nkf&$oHkDQFNFXI>zo z)iM^<98XD9blhrHNFKVx&JDp4*vv0EkT)3~-g=$Ve1m7<^IBSuPd&W4k^8dO5{52>e)#8|e1)8bao_Rkbz ztot9H_e+tD>1-!@zA2|$?t!VZ?~yOzN~v=Fto1J_tbMaoB>3^{CSiuwzFI6>^Ri!P zJX7kxv*Y_NW#8t)_g?4s{CkN{R^yNps+zwH0ITsQvqQQ4Agt||l@TrwE~_S%AgYGt!r0g#koi6T%E0IK&SF_5yYXB* zPOVkuuTzxk*WAN+O+L%J!>l|c*{2E&X1ki!Ve(PafxEgo&|OaMg3=_Pr1Ox8UcmDO z;FtO>54cG{&5{?MH`Q#>7z-6BPrq*w6In%R z+?wN}iLxp*$-TKiGi--P3ZHh#j{jJXOR*8QKxS~3r58+Q%!)qG8sVHZkx9ww-g$XK zELQ6EgF{sLZ0nvs|9h5Pu}6WOtw3Ldy%x45Gj;<{^EV()-s6{oWVSYJRQX43HJ{!W<18WcAr7!{OL(mJ zkXfWl@;s0#*w4I0epFmId6w}`a{+u0X>9X6?p4{?UM>h;>sKHP_wDfB0mr1KLgT9R z2cNU7yL^`a#H-B4G}i2^<(a2N+xxP}c1_i#Sgj#5EK=($dFn7|{|zouz)9a=ng!sh zFk&A$F((!FO)4y7Q3jxO^r6&L3ulPkTKZ8`2cW$<-n0FD?a$b*`jVRi={OswZY;~zr}V{@x2-5Fm!q+K>?gh zcm7rl=>U$3koXaQNxxzvY4GcJ+)A`l7*1#%vA03uO_Z-D5LOhlI3-HjDrE8TSJ>MC zwMI)N0}_97zWGunsx4}GQ|fa=1Dt(Ppy3I;zk?|{W}OO};}bEk~H$|z|Z!XTp|Hcrhip#E$u6P)o zNQ7eIZp>zM>`e>hK2UipxclO;zJ;nG{+}PM zcD^2Qegadu%>ZW+nKGjTmkP1I{y({&&!e51WOdW`d~Nth8?bV#E?^2?QiU@p;}bZM zpCxxw6kRLW!0Wpt?UA31SjJ1_&dm2S^4kDTnp(&{*ouv0@eJ}NqY<8tBcVv_^H`?I2Z(Q$hRblX7s&JI*LVcq#xmzCNNuUbZG2I21_@)(cQ2*#2Lb>w(#%Qfb zIX+ZtlW9ypl^=<(#^Z?wB%UNpe?3w=vWvUZTpA5be>CKlh@YHlQ@GuVqmt;NCMCuo z4y5Imszm~A6nV2oHOOs|uQ0CqiGJMajxTAF!voYCo6p@W_e`(9%4Y(*kRq zdlE`ndF8%!jhBsQqr7LoV`f-i5zXy&m@OtC|X|#x(FKko2`v zuRKvnapcnMwWN+Dee&U%CyKog`P?b}WtH0Bz)Wxp%mP=IU;>hhm3re6T?L^4P0T@L zqV;HqR}J7zp=ql@Z$)wiZ#i?}QD3Bl&uF5bg4QQl1?huY$JkwZTisB}W!VIGDwRo% z5gu-%j~t6u=^u%B-V>ci8ruoi?z>>2TtI|Zpd;;)A5FZn8Y@Xg1L`z&))k90{WG-f8b2Ax1J*n)JCM{jtUjgXRjV!JlbFoYs%cd& ziGTL^t%!$g>NI(V!Ayc!*EsBM8V?PEtNK=cqyLoOH;&7DHgO4))IAPzKTnw?`1B zUTyAB$eGB*-Kmn57%wu_iItdcfI5HUo&3>OCil@v<)h<+52})dK40&yjA5Jvm38k$ z)MZ}tE%+Tx$gsy1H6F6>2W+L)Jya7BO)kQhVm&F`pvLh^ndASB!B_$K-sit5vJld6 z*+;zM#5$<$*cl_>arVDNM0YD9UdexoXkdp917SNR=piQNpXXIqMFVu7MKUm?vh$d} znkO*evT5C{FL42-tLzO6zpBDFJcUC)ZiShq8c+6{2<}zuvPdMs93p&)#mJ>+J>Ses zT&XY2vSeuUXdatP+?6)go{Yq+v%^1=_oNtYSqzUJwD6MQ1(bbk7UU8v_G-UMzL`BE zPEHwYAKPtoZNbhjVh1SeNPW0$Thh#k0ustiQbrc8vzR4&X3>S-k8&ZCL(dlms>xQ? znIF5My8(@a5BMelT0$&$4~UM5eXjDnC`jPVo3UvP^g}Vj=A;oR${(;jdqrO^`ItK% zJ@dWD6P`q2GfC;joexh9w%u=4Mwu${X;wrTmFG(En32X``#SVP79?6vn)o7i7^IHd zN+(;pBe@y!8Q`PjWJ<$W1%{CtnAIMPYrJeN01IB(0!?^!X%oRL9aW3TmCG~^n_NDe z$YZ!ATh7qTj9)#{$naySLnGx<%oLMlKJPO)b6w6wqVGepY$Zt(E5dNL)YmVXI;o{= z*dw9?PZwzvw8{ZFljTe@7oMs;bg@Ed)_`#`GZ4Unm;Czr^0sD+Is_q*kM$@f zWkdIuyhyZ{uNM}+dv5i@M#g+At`Myq)PT7Tf`G4hwjKx))||MTGC6x3NS=#?t{;<9 z0c2=oyTpF%6D1AMKs%-)#p958^of2%lZb4Y0p#=ht~)sh4op>B8)&UpoX9y;J!OW>Fh9Qya#Og45_4oEN{~;c>^sySXf8TpX{vHn+)77*QYo2Hdx4SmVrmbRV?~a9OG!EDa*ottDHmB-vkd8qqFOgLxNMFxH%<*FAsFj1XxGC=pDoLlhMR(~4gnJ`5U3;>_8 zTC+_bRnjL#?7R@qM4FVN;0}RgE5Np-3e|Pmb6kP5uvdBV_ zy3f89BQhD3$)~qPypX%nu5*rTtjSl&)2X*4OqqcyK)?5%i6~}XOUH^lY)!MTED-br z(z^O>kWZel5jQ5KGBLkR680nTvogmDy{-JCknM)1&tA zDnM$e%75TFr8(A&kY#J67Sglo>1HsFJ*Vr==1lAzEp-6PqP{X@I3%D<(jtpq1#Qrrs*G@}e#<_Fjs6#_N^lz%Tt4f7JA+ z9ROZPYT^Sa*%E)#z$y7G=0l}p>iW1MkgHfzyD&2qmUQMMK~Y7+3Dz6P$aK)2R3r#^ z3g~x~sqv6k#x99XJ*!3;wTAD&MjF;k2-Xtr*|P;J$~6@bOXTz+dTF_0e?K{^>w5F84LB~MXLe}Buq(JLZv=~fa~@ahMJE=tqPm{WojU?&{YX2U!jIWO#o+#< zu1sz!Mj1p){&l|vs`xP*&lL;B%f(HFe9HIj1OPAwl=D{$f0NdGHG0o!|BQ3w^w1!` zHNJDe-J)eJLRGasRcujCGa~lo3M@)$cz0j}%RQAiI$Y-NwY4`^RS7@R4&W zUJG9X8Q&rQ7C-@Kzhn`I38U+Wg?A+NwQ$y1I99Ak2C_IUb1HGbSMN*~cMaQ&CR?J0 z*k1r0*UpV;PxChDG|bjxy;zIqieBP0Bvw(0BDFjTH2L15)EO8J zR0tJ%Em4(Gu(}#HrGzF&Sw#VZPvoE_LNsx(cbe&mXf!XcPs%}Sl^-QU@uN$D&7MkA zS)!fT6a%sFqoL;AuU{vs9t6MGCi8B9stdc_)A*Su>(L+Nr)KM`V4vG+sYNL3fun8< zX-o|~u!F7`h0S1pY{}1sYyFbaK2*fM+LF#=XLDe+#y9nT&s-g_{%d9Ac)J8ISE{q+ zdOiWwtujtGG&Dv$YEK9cHd&r+jOohSnWWUA0_zzjk>JS+8v5XHE0-#>P=@vd)E6wP zMSjpN06@lhowJ)BUUab`7gZQsm^kI+pFFVcDCrAK;5s)OM$6W`G$V33kA*mZx5n~<6gP1ErRPh4PIiHT~!gDJ!iejdoIN)S<+3PZY z#T<|&1Q)99IFNW1`W_on&TFLxE)^nRmCuXQ1<+@YHHk5syF-pwj#)h<{VrAg=F30w z%Z%*!O8duZNVHo4ggBDV%c}SA=R)&O5*RG9u0n!GoGETzB{A^DQ(^T`xEM zsxooR5T+1(VMyKgzDxGm%F-cNeWXgQlF8O2;6p?)eX8Rffc4tWTpKP)^pWMuSeZs7 zMP;&g71w6C&_m%Ss7Glp)Y+2`9-F#|#1<&sRyZy= z_i;*&X?TUfV8BqV<5QSjg^c-}V9Q{C?6Lt$MNA(XTGv8_>Con&pFo;GP)jIRhThC) z(uiv?MSo_?IIJh}dwz>&REa9*G8ubU@3=BjbNv<*a!&R|QXD3H_UW9-&$1=EMm379 z%seSX_}dEkK**<@9m$`?zB7M=)0##~J%?H3fFiqh@Zkf-fv16SWJ&XPNS+jmxLD1?lk{LRZ)T59w zRZ~+(JgCy-zNADTr|_6pZDhNP-bG?|vy9Y6M0ipUeHC`SvzZ0KrQ9pM+gj^t-I)F> zyON$SmO8VukLaPylhzaT-b0+vKBKAy-GYGKNVHyV=k#g`9?2A>Ld%=1IB9!oMrvl9 zD19@eRx!+?y1@%@!>CrhRP)j#T@serMhx=(lnUPn{*=gJ$^Bo+X zH@Lzx$HUNVi;nSyJ(Wk$ALvxHHrPBqiTb)PWTGV~@$63qRVCMbUFqdb^Vkz~!K;k> z39&zat9#b;yyYMKz1gLsOP$U-o9fFg-YOoV=8L}xkXFY93{`d@j7An1mlJQ!#YUXecuf+YncxVHqPO-GZKrA5ZR5`X`BMN*h(iR(%vR>D-halX1u z>0{ZMS^q}X@eX`;N&a7+O!bh!7V_KExxBp~V26q(3B55x_4b3`YSi}_|>9vC1fW`9ovpaHoaF>)Y z0AbXCoHyyJ;0N|;1teOjTzo1(fFJqtCVlK*%rQGx5Gk!^-ig_*-(Nb8>O7Z29!T0q z@|g*~alw1&_3GFxGYI54UdJ2<%kTfrF_=(qce4Xa(?e%>pp(T zDzl@(WHf`OHZ?4QmShEcOgM>yj?rO`0k`~Yv9{Aizn}e9uqvD6~$Z?D-_Pxj+oRF}{b`J>a&mF-^Ssi&X>MtrOY!)WC{W)*R}=jAnl?YnwR1b?H&2>sI=3#-c;?(sAdFtvn68Nj`so-PccZm|RqtuSitR zO`fP^wI1c#)WnBUydJI!%rsPHvMfg|NMtM23#m?=nuuQW!)j`pLWl7Oi?(k)tiQ1b zgpIjYjjl=FfHcPPsI=R?HKTr5L0U)neR%CD6QYoIHVOA>^0Ls?VoX%=Z z+X*pM4JaICeLwhha&j2>tx?aZ2I0ggcb3oc2>xV8&pWzWCgIZwu19~gtJlH5|65%Z@TjBMZDITD| zl7Kx97cz8()LUf==QVWHD>3w9MCjJ-6Q1LSjVuc&iTtz?7sP9kM*BAw9gT&oM*F-%lyXTVj-GlpelGNtPk z@L7yJb}Lg&3YCxT%{S*=N6WeEr-h%J2Om16;pboajS)m<6i`*DQ_Vr6m(sjzP#O?) zH;>;v0^9i=_n8$<=j@Djijy)OQ|TFo!H0D^N@ish z3G6JNpISH8Z# z3@ttg08qV#CVt8@rnULQmi56$k5`t6WQdEJ7!4c zssAC5O9gv-QP;NBc3Eov(r)^Sz0VSh?a-z{B=0eiCXMXH#4%YcKdlWX{U9~hr`&EU z?MM&r&St3v=3<@jT3VEXwZqklg<|vG&Roa#Y&GRE3}V7vngqJxSzF}WlmKqMhfrR9 z97)c}u?elpmSduL13}N00=wcY5CtYvA~DrKbgeQS9|7K*_6TaKY3`XRJt# zioF@X06(V)df{XhcB(|>3+z^L;gdM-X!Zj!f>Gy)W|DeWJAQ=>Nvf7OKg+MXVgZJD zJf7fZn^4`G;HGa@^vr0dl&cCW2kY70@rnF7X518GkvfZp6*BI`daJqWidF7^aO&3% zbFBv^K}eD~*Dq=vx+P*ZECc3Ine%z)L(RAr8{^!b%{X{rc!l#3*$sEH*)Wn6lg;=_ zCT+FjI>QIuJ-bv{(qe%M%ahoJb4OjZcesjtN|)cn|6^HIII0ZEItxRQd(jbPLGDtT ztxpzUwBtXnfN7FuSRd3h`^-o1jCRBj)+WB6*SnR0&d;g}{B~V7n&M|AG{BxdO zAi_>~B!a_o^bgM*LQH0u>_JF-5UIJ=RmHdio$+fuqx}^Q`L>;?14C*LlKRajA1XNK zp*8PYXqx1H?Z{Pogp&**^Vv+<2IYNQm(r_fk!*KG5dr+n9qvvsfxZT9VQ1c87*Z2N z7BZ|XQQ54}ccKpqER*lKMBRXY+VZ@EaLG4~3blSbYE&3K^&Ti879L}wQKE4TC4-it zb;`;8(9b8oL--+&SSf)xKcmfu#+iy|00pJx=4=XHpi6n2ExJEsnrqX}S7j`2&$tOC zmV0o&ua+14m;QHdo)np429|-pLeoBro7p)3U+_aD!}e$n`h*68$2hdXFkA86(VwS5 zUf{{-o-2nM45>e>ImB{l@?gmN07I0|WZ(4EzsgmZKTf+@w2M zh=~KR%N!1Zf<63;fIWG-+$JLqATuqqDg7Tc!LI6&5)}S1P}~^DGBWNDAM-$F%k%u1 z1x^%b3^F(Ot#YRRD_^H8m?*yp_ncIZF}%^rR%!6evJD1FQ!SaWPAf1o#gFdJJTK5q zfp3!pt(y3^DpvrXdGgCgLhm=RQJfiEz%=O43A52AwPNP#YU?iCA7bklU!cvx z2ai{klmO097Sb2Nfau9UR=% z>=)hzF@5|DmpO7f;)+oAkE=1&f>L;mlD77`LmIPTQIqHiCrx(UI-RC(orJwNRkm}3 zam{%41Ak-m2N4;^@swZ1GO{tv=oV2{#N9td&Fi-9a~V=<*Jnq4`7tm8@!WA6F3D}f z;5xHd79aIrX0b3hs$OM=FnV-XKR{<2aWS78>shn!io(}5Y;cnbvL?1XYS3cMd5jjM zRG7MY!IRksJCWL{I>u8BD2`hPrIeGDVZ`6)^faL?pV=_Sf=2Sp+x}RW_*sD4O3UP< zQLVPu`MWXqb8s7}UDb5mKoqmU=+LjSgO zhPW@PWnup1^jT`XMC<9`WO{{B-A3tpm8Rde>R^pb}Lg{C`%RJd2} z%is*jjO@})9Ph=%2<)|;Rf+>ZhF;oLOzI@L&h=JxH$6S1NNVr|Sf{j!3Ex~tkWe9x zxdv-945p_eO4jdMKWuJC9HzTSPwMZ5UAFjt6VQG9VfU5k#2>}=FNVZ0H}gP+ox~Hh zRn@WW$(nwB`*c{#=>QpR&oxhQo$uP+HRkc?rQ%cM+V-rNvB`I*|C+4dcq?C%_IJ+b z3KT!bFaf3@8+(*IaMz4D@v z^Q--r=J{LSlOMEKy+|7oE{B0Ob&fSDpR~Oy2At} zPnm|Ko2K|6`EnvXF2%%$iSl9PLp;|`_tPY~-7oT+xwX37?&Y!o;O^p}J0&rGArRsC zrzqzw*ZO?81tSvBHu~IeeifV5 zf&-f=!cy*WlPceqRz!7Ty?&{Zo(x-R*3aUXgp;Tm zPrr1{ICVA~Dc_Ul8Pz`}=#p&${BqRdDrH)n#I5NPV8|#3K)hN4!1VX8td_>;+Jib!E?I_ zX=wK2kWbfs86gX&u`)|5QERW`a;XO-MXBWHOrk11p}N%nG_2Y6Z-nm9>p0Qx6Qvq*HVLP#!!OR&RcY)3XKi>4_`!#U-_LCz+5l%m=s>!*4KYpw4xU@A^LHcwb=D`+Zx zXYPytO_Th@AdVv6MsQ-_LAetRZsiiZ*jURBHSF;@tT3~)$8F*B z^k+TuF((lN_BRW2;@XqL5@wQ=iWX>cETU2(n8Z2daGtOjPD=<|)l^`wFd$LJ*JrufLM4!*YoWmLd4j*qfR9c;1% zvSV4Q*A^qHyOrfgvr?JvyiG@S>nkT~$dKTXm2Epy&;Nc_qJDnsMo<$H6a=Z+-M z91r>2CcgfzJGUwKjz5ThE?F^Zof&FpyrQbgyAiyt#TfN#nk|Z6g*0M051$G?WBC)CtR0=BYWS^5?KPw{-S4wq3_0UCdBuco&j{oqNKI|cQlqY{cYT$ zg8~-w(dU(<4~5qQbB{L?zcDO7@NwWvFqQUL`V>~U#vKG?R`Z+g4TOj%GFsk!^EVMp zg~wTg)|fXRgVSC1Y`e<15RkzI3A{I2p7lquycaF5vVU33)~&^S(#n*cn>eJj_4DDK zFi|kymJnS2bbC04?@}L;M?g!C3zmaB>t`K`r7HCpJJ<~Uf-g#FPDgMY$CtE7m_Cd4xbdh~rGRMl2{L@46>q3pl8zkM4kR(_!?7=3;(Pr=Sl zdz5P0mgH$T@z@V$LzA~rOG+xF$uAyQPd}cCfwEsA-bc&A_Df8)+?=$D<}UjGn1jN~ z*5N$(!k1sNvaEsWzxp%Ih7RllnE#3%_zOMEy0jsC9`%_vR;7nJt1+Z>%=CizZIJ zRpV~}dNQxno&oK8-gq`J3$L1#Lcr~EBu5R&-IqVynyBEPCCdGeODv^KhKoH|(`IRK z{>AKOy^EeL2c7oQmL$Ga1_4IXHbh@9L)&%we^+UX=Xd9Ch8AV1x7U6b6XEj}jcvw% zmqEV+6bjIV3=}BT$32|@MvIGAu}5{uH1+kOd$ic&6{6HK(6>qxA7~g5l>#d0!rm%O zY`y;eTFg5rCaDFGYN67ylK@NG3u*or7t13rI9Ka45f|R3!;8EE_m6j-uEUei*e;!| z6)TdxexF{7Vk$A>hem|AK=EC*ynvOS31w`(*&-?N&%cBw)}p^8CjNL7@9nqo!>SKPRS!JnE9tLSGZaKT!PVJ5n6#%(hQm zf**g?d`^S~_8&a&lR75sNzdcN(Ea{LU#;F;3yuGz z-TM{q^&3M$@;62S5Zyfd|IPm%8LhQI!LK@{Lkhh^H-fE-?niz}Ob?;yld|}3Mtczb z_p5jjH2Jbuf>QKucs3-R^3Cz_Cz)=KH+p)R zQZ!TF73Ho&@%PO%HVBQp-A(4HX7{>8b@6}geeKuz)pTuD`WwUOrx1D?7wT8~Vd(5{ z3^j`17V&jhvy{F!{z*2yRvGf@&~5I~Gw-M> z^vTT~*q%7^ZV8&)A67+X;a?InLhklS*?fOQdw^K~|M=DY=r1t=YExo%NYd8xu<|65 zQGLhxh_n)2N=ROFdZ%8Bl>E`7qW_=21V4tRr7gxgl22b#rr8n_3O2R#NwY$U%Toa-8_=o>C9d+ z(JSg?)H41_JV)tca(KT|aQFT6BV#YM>1JOfI{(gTo1nrU(WdOoL*)Y~AZkJqNmD@l z^rfu=-8kltZON-Rznf_oUvh2~-9k+k;=mT)w$eKM<);mzX(6>lf)`w3@2rw$kYV-Q z)gEM}whxDzDuSK1T|b%54CK;Jx!zSx8c*mFQ#p0kTk8ndvE$8LmW{{Z+~8N_2B+8z zUMB%v9v8-T(x+lpSt&HEY2aSV<9**SRTD2i9cMN|AM{NHsEF4NZmek&$EWm~s>u%1 zHy}y_Lek8m9Cwu@_xChWdnZ<#=TD$-llj`?JGW(_M|&DZ!m8ohKh-hmlKPuvzCM0{Jb*!C=3U^jkKioS;fP^us+Wm`OcqjWw$ zICM$gYYGV#DC`^hj(eflxUK4su1HcAuUxVlD?fP!&ze#1UghVMe1u>d({JJ zLh(yH=|AUn>anVTtJ1eY4(5CSbaKoQ#OiZMPy@c46kuR%-ky{Nej+-f&aoT&Jng1> z^p@x6l@B|*1hIR+7u_ZHjAHG1G;9cFo#7c$h{vGG#ayjWkfwB`=j|sr-@Nd1ic=%= zy~{aXjof&?8=xVY{gzKF!-HuLC7FX`!8emVUQN1hn5wzv6bg10!41e{sBgBxUKcko z?Uq&kZ0vI*p7izA)R9mUW)KDMQN4oqAzK{jLPf*Kk(I8IsdA^JgP*PG$oQ|D+3wH@2>Rb6l28v)!pH8b_}*C6h&J zpkTIl674ZrH1m?Y6Xs`v8;&Z!aOmFyvuuog9%r`baEHCOEyulAMe(u&C7)gEmMH@7rcEVK+`aohCUbk%9u4s zH|am%o~KzDZuVxx z9&sx$wzq~$TF-8LwLDO$b<E3!tif>l49T_0jWnhQ%= zOZkw-qqU;HF$gbjWgo)2D>(tps)@VZ{K(f;&PDAOp3K841eO%-1*msh%l=kPwz(f4 zq?>s=XT|uHVUd7V2I$o=d(+(l8Wj*MIQQmQ{70#nGJ@mqu}8z56dr6N5bNtz32fsR zVfuXN+wLjX=!0{CFYi;FOtRji@-J7JP^)T9=!;K2M9I#6lFyI>vTD1s|VLO z0|Qrghe<+bPYtB{Un|DFU9tJUR$T7M(gsOb?KoqYgECAzZ~VPnte8GxORSilJpEeE znkP^WQ9R$J|8jN;&DrjMaW+n`lq#OMXotb>=hd-|y)9x)+t}MV+w7C=RjJt5v1Zbo zDP3lRygV07u0@&%U*^g`tM4(@rZj}V%A3B+`DiOtTw&$~VmkFiFMQM-U8?{jMfmhF zJWq8sYPl`?Vij^ekr#88DQ?`P zX>F7E8N+^@{ znaA>ohmEIe`EyQd=f}pv^#xMjUY34LI6BbO(RW6Wtat8}T{xb!2(O|Gmh@@@1bhlJ zucUrha-YwH%!n?4)Y*{p@(MJL;aqzhmew# zjIISJjvn%z@o&{yaHnK|(JDhlT zcD!uATy~q@{KuYM(C#s6?%(!2tvp=RAW;^5z6tzeqe&WR-&*494o+~=FD`W zSLgFro09ZMutEj=$Bl`VqTQIwzucH2dmi17r3Y$~MJEakFc^Vni*G_tZIH?HrAt<0 z;ADAmP{FUm<#v|Z&LBQ?jr$e&c-1b)3sXI0;GE}2FydfTw7_@*g*|GZlXM#7TnY6C z^w$i^^F4xklDx=AdkOp!Bhbdvm*pYQjN5GVyu4MCH5-jKF5X=mn_RLmv%Nxd`F#%h zuX^>bqaSvQze-mS5rsLRuKq7GQ6l~009@KKDS-yorA1>gQF+`GkqS~LbMo&77$ z8lars#2MHlkopNna1TB75)sdPLOYq;d?W6*quryu7?c1Hi>6 zwo+RHRJ7bHJT>8j31anO?nd-c6rM4e$r*CZnU4^xs#5zZFkW9A6H;9;$HJE8c}jU- zlsPT8ToUk642^44gub_4r9{%uwsu0|t_7}&d?0^EXUfoyAgRDqIG{SK2b7d8q}lN& z2I|l)h^?sgmKjAF2pizCLsik^)az)-4{;o6Nvdzd$0jr?Fh2QPMB?BP6gQ%>G5NLh z!eVS>Y;J%BmzctK} z5wAoOeu%gyx~J=1Blk~hk%KRWwP#ApuD`&AJoBP@MW*NP?5 zr08vO7_ZY4hiS@E4uDdxD;gf8UTAw>2iB_`TZj_NZsz2hm`d9kC{{97Hm@*nW~Dyo z7u}T0y1nZS%Fb7Zn!jCJCDocr>~p(UjGc z5BiS^a2AZr6lmCHmqryMfvqmvIC+NaYuO)_b1g(q6KJGQLV;ogIq6sr2ViraUePZ+ zavVPwJ7SO2o+ec&EZoAm(QGByva2G)GB4CyA;ur#Pp<%?C1xR)zqkeP(KFX^Q8&3Z zwF^m)d4OD!vRbD4QL&LD`7r0-1@)3N%B2(pz|nDwa2}~axo;;hG>R1c;jp?mpshZH7OU%L!)g!235vqHJXM)cJ9j# zcogesr&#DZPqfn88Z!pE!6b!oeUdpUHq@v(8K+#BN?aAE&-8&lfeWaePy?-((N!O0lt1R|M~Sc||MK2xX$0-IE9 zBy%B>1%oEa)F&y3%0_*fCK_b*y_B}`?S`=2itdpu5`@tha zJ8)z1C}E;DrbTgbOP!UgiL|Wi#gKq;cBbb}W0GzIFiep*B2~4X-)4z}y=6gsqiJe{ zj>gUT2)~4IOi#JX4OIDUTu}h@W%1&4PUz3f+t6i&a~ibX#OjJz zuqMlu-OyTi560AJF6Kl~g>4BJUUWCiF%;NPc%oY&WGm^oGC?A-QcXj;^6yJmOt!R6 zx=U3ug=&&UQ6{>sx28XoT0%tjTHYZn8-8vOque;92@QZe|MD~?dr>)zF-7UwK)Lz? z6suX|jpc6)T%o|CQkqKZT^*g`YN&jW_-!oXHlw69f)v^BDLWijwE7Y$cwn@XsmonYzv)opGZEmawHEr;8?YoX1A6#Mfk)dQ*3pAcE0{s(p9_m}R+2qIH8LB_? z>c^dl=4;LlR9!XnkWfcB{@UM;S$I+8>M+hWb8XDM>-b@RKCB+SS#aT@cTV=q8=#DQ zkdh&qGpF7-&6cJ~o*J8y8jqpBN0D;iAvHv5nrBS#OEucR&+id;(U{5?#wE(zpta4z zg2gz!#hQksk8^jNz6~>W@PD+q0Z|-Gah--4(KD&lY3KRS9-a9AgS58*i~8I8#W6?$ zC5A?%VUX^j6lo*|n4!B<8UY2RlwY&e^e2~OTvk=xk^%osLnycUUsHlc>J zcqc^6H;^GSaN|B53zy^?K)0jEg|Q5N*-wlRSN#=_v`M*}>7)$hV7t?8F2DD|wp>uA z&%Byjj-PA4kE>;@1TQPhD69@Uu7g}T_$Q_gEn(G{#@mboq~m+zx?gQdZ`-ZwXF+^o z`HYL+hUGU}-qqw1y#V3e`drG!Bo(vC*H>yPamx!yje_B0gbx#ztTpAdda*LhpNYOB z;+r;VV>^|T6LOjRVN3n0`$7Cvj^mWm?t?~;IRElM7&WOYe?pY$29JHAdr;wM<*^4Q zmGRpb_SMwn=QgWB$uW+Fd2@r-B@8BE&={@^nLCBN$>zp=g+uuX3Ow#eSG@1m*+KW@ z79l_5?|@jWCiB~yg@uXQNVc%LebRY-oP2^4l!GxEUQRSs((vcM-i-AThTS+Jr=EGn zFEVOq4|{cfd*`&Vx0=;a7a6a+Z=<%7vgd!hs`i zzPVB3^D_3aW(*Pcvwndtpr9Z-_d#44J)ye*?aP)~g)tj74KDvT!K?xy3r;XvIwJ54 z`XtV>OO+<*+ZPD&YCBJ%Cf{qArtKHRP2!2>>qhIKj(I0WwLaz23`5A=>WdfJo0Pe|nAdCM;FtjBTjjDJcdUx7$$DL%+0^7zjS!#etd*ii_vqFw=~kgbMt~u) z9%^t?NCF(Uczuf(%-E7%s(;VZIXZ|uo@9}xIA>yK9;^n^x zxz2MuWctt#!hfE--4!sDYfS!_qQ}yLxPLINj46-t1(sk@o@K1weBq~u2k(39z0(EC z2zrn{1$z7dJl)uZnP4p3_^;>p+Ms3Q{EfyPZC9=@-(9oDGCMO{*`!SG#kjew$s%6v z5axkd{U|xuL#>9LKiit$PZ%5(<+xS5Jq1|~yDdNEdBt^MD#ic!0MYCO!)lNTr2N@9 z&=>rQ*_QO#u~h$>^i+Y)*;*Ne=^ZRGw9mfo9>To0>**NBW)zpD@;aR-+x`8(u-Q|W z9=_BgRnkPYz0cE6R=HiriVgKVe*=x>CAnlG3Cv7jDQbIQ7Oi*KD5|FFg=&s)pZ`4y= zcy?Ap|bElhu2sQpa3fe+JH}v^XG&@vRIsP0QQ_or0 z_NHw1tN}mX+rq-og`+p$!$e5PoR*aT=s;buD;qr#V4~309}$*fveDOim~u*c&F%%x z(u2XYz(_1N>2B|Pu{LZ8Ue)o;WD|t#lh6wXoBLm&&dLL#t@JOm3X_!Bf1`a=Dr``) zAN}HjM%_r+K_ELCW8YsnQ-sG88W&2E&zuT(r5MM*h( znwSfDb6D+0qqtqpepS`GGC&Zm88PBs8K$rcP58Q8QAvD%R=Ua(f! z+z`3`J{t}eRF*wZNsLHFbm&J>YLrgqqbuRvt83rl@cCKmsCZ1iU%-s6tOLW(lPhK&?h`rzc zy@=P(z#Z32mYP03W|ey5zG|jA>|aqNfp@V! zNojB|!SZEI$@dh_wJoa-;@I@0yH1{eo-Mc&lD8S7V&X_C|CdsP)dF)inHKgL=fL#6 zRJAD=!?R`6e)>&mPNt3%nc@=+BXRW4fg9gs6UXAO9+HiC3*2;)4S5O=8U2zW%_RP+ zw`32V0~rJvH;ytA^)e&F?ov5S1LDj6CbbA6??3zF^PIrjs5Q;|WGhx`-uV&bPpcw#FRC(3fQNFM_y2 zsplzxI{P)rlIqf=O(Qyv1(?Nv{Ad*lwQn{bcFrcAtY)LzqpS5`y-%E@Safm3l~sHO zzos&`5FU?P|C5eG`o|xY)dLGB0^A*FtrUQw>$_{NCa#!qbLuJSF4kO`Z)gYL7g@n% z&s&GdMZCHF?;hujZHWyNm3|FPt|3uw;|BE)(7x>}HTNw>94{aVk7M@SP}Zt*woH_~ z#LuG=wMQj{928`o@=u<5@bRS$(q&)PoRAF1N^b=~T}4BkkH3ZAQwj-8_Aws`2+QXU zdTtL~CQBSHhzXZDvPy$LPVoW8q#&bu|Y*P-et>S<8?$---lRCB+AEEQm!Nf7;8)H}OYGCZ zVR3cy){OwS0XD_xrA)X26cSxSeS27wGKzMjx?$weYGzyU6NKil7oLw);B`&}8ooaA^bx6+*Av(s-5 z8YT5S0bhEluJO%l-m(>VSBezDG3Cpop z=(Ytc6A-zv?gZW`N6Je2j9+v>em_ZuJkUWVSn6B)Ge)Z&Skua$DlKngR?(JIuum=J z+l132C)E91T!u8&CUA)U%T?W8(GydfBR+eE!(OjR*wD=*YHMS8)iN+9^~M}>=Fdsw zt5!*n$ED-MPw@JGp2USHcGG3qV!8^0fla=hJT4@M?tSe-yyD$EqGzxEH+ zIM@o0HJ@w1a;-!cpmpS!B2!^$`=RVv&r9+4f{( z3~nnqr;A&GHh+<>N4JH$F?*IdG(scsxs4Vz`)kd5)SDE67?1Tr)57Ng_`@h^_VZ2jC(0fuSplXjLb7m&AC7pB+OyLFT3{+( zl++044_t-2hKgR}t-Wox2|8PMK7Bg=(VXAXimvoz1$4)!5&O(x-hdnZf@C2nzPPzE zjb%pE<6=E^QZrw^%VgCzNq9Sh<$?V(|BwEK1j|836QDbH)L(+3Vdao8W-A#_aIe$h zniy1dZmNBAujzI8J=X5m2#HLurkamru||JWsNxDNH)Kpkm-UY>U8WkpKGwRfRu;(L zp3;O3(Q{=_g@?P`bTFE0E85rn1EuKG$ht|mrU+uiiO?UoR2KhSzLUkKuIF*F1};Q( zjbK48=yq`K+xiz$e^>8N&SvGbjCi=1!&?hpX6as}wtDQ>|9$uK!2=&{VL*jxWD^S_xfEQD=ds@sZz}7O1QOL}0Q(`@%&y zo-$_^0?IT4tGNXVtn&53ZakSzVLLHSWJ5awE{H2BYg5tqxA=Ij;AS(Al@@z{Lg4(e zB&1Zbxc8&u0@~I|F-)xAtDrA(?qHZmt)(Etg9yRRyRTXe4>5f*=eO4nRtvXL zix44@Q!5l6-LiMr6;UPA*CZF9Lz;@LI5>ufzo5e5h$7$jk1#G>v1qZOQcX^_j9Ie< z0k+%ksebNuHNe)W{cKF=?FS2CcucG=+mu#&lX(Z-rgb|xw=PGZJ*DLv;_tEc?zLzb zJSMApTYKN|A3n8IE~TaRaMa~ zEQiYMr@wN)=Ey%6SIfRCHia~Zn>)>8<=0D@K47{Yq1?QK;<2YjY97Wa3N=N+dJ(zC z$}^StmAg^d!SdWLd~xCe(z1gf@lD4z)PR9jv-w1+ZSJ&1#Fb!pl68JxoD(BG_InrU zvfS9fSKHtPAtP{Xrst_G{j&_A4Fw)3JH3|qS|#n4-_n;3e3}5XYdLEO92)9j&`TW1 zA)w9y(AtqQ@NFg<>adag2^!33a390y`8f<@+&mPXL70XbR)coH0>&IwyS9?!CPg^% zp*2*pVC4pXZmB?J#f?VgaFJvWx*X1f7#qludLr9A!63f7TbRB;Wf{Wa&@Y3~ZjT$0 zrVwee6vAO<9`k;d6FaoAF_6=t;~+Nuj+>RXGN{BE^t5)1F!yWXD~{QIbN3dVg=l7> zzva%&EC1-v%q%w!=gVk$D`)o^^1aS?Rb+s+b0-axZ?(M;sE%&cGh}qCC7cyGOy&l} zreGR+4XXw;11iLC%B8IjEgD8tvYE99@vi{Uvdi&)0b$GKg=~g&qbE!Tfk~ay?9I^| z+oiA$8Eobla?tI9X+mmmUKXyV5(md&hT+9Rc#mwP>`+Lmg@DV>0MTDl^>7wUq z$lyo&To6kd&@ha-=s|odJP>5`YRx;HQ?a596ngRJ+OSJ@=rYxjk)X9%mDl%vmXa$( zEyQOBXPjXt$}K!P$qhX@r14VQy<2Pe@hkDfUapbP&1I&pfsfp}G&D#=UT;>ddoT%q zSMK;OZKWzAbM1rcG489)Jy< z@e%yVMkxbU{TvOKu2&_F4F{8b9e^qjMx5Od9XK%aV`jujrD?Y!jAT!@hGLosr!$;Q zy?&|DvN0G>A0k}#c=kY3!)m)qApKk>MCn@}B71pJZY^@Q2J%eDRaT5&hCG5s;ExB!^YdcnIOGXE;v(Qy?=ofPQ7VcyYeN3wg%JdI@g+6y)*ORvl_(HQgclB0+p=ZE42lPPs0vK#puz=ejGG)bFGu-l$E#;D`QT6c8++K=eBXR3Zbc?MIQCB@r*@Ga zeMs`B=%(J$mu-&!4)1ucy8lgj4DnVh(>@7{FfCXjq}sKOcFUh%gsWZ;&ficxn1QQv zq-H0Tw(>X3S;Aiz+>4kPRW^E=rXTH;iglG>4id0HAaOtpvD{XcRrkI=lsFr+GU@Rs z3Nz8_bm8GrmOgX8E%&vU9-mIEi1@f09;*QjgWhP3*OWK2k|fy6F`K5-Qc_&tJziE} z+A}PvZye7*YbBQWhTMG=h1P|h;o8d4Rs-i`e#1UQ&{Akxvq2%p;jB{j+#tAo%hZHG z9;ztg!e4-=cf2Jf==511TOsZ@8i$$Y7L}?xuS{9jo#7;THPO0~ zL*_R+TXE@h{q*$)L^V909Rd;XiyQjx4e^(jgQO3KEb%6X%!3t_m#75ll|AXQEf}`2 z|Alx8tB?B={|XB$CnZiN+#7piU#|C3pIIu^w7*7I0le2?qZudd>c+C?ji0|4@}M** zAMHzDiiNLzQH1dy-pbgo#K-w2Wtce&9B#SLU0s*aZ zuKdgBVVuU8p=I6XjSy53aX49jZb*4tIB6o4rmWg9f93Ao^Q=aeJt8y{1(;f7Vgy@e zPChxs0pByEp{K}dk63E*3;F#teM#_jc0YLEoX_Wts=Cxjs$!DMjL=%jSg1KoXM*5e zj)dagZl&@7hwh=)%Gk8Oflhdit^SJ9CzI=OutFH8bI*4v>jry66Nz3yLpE7F&57cw z*h1@~PAKt2{D<}4B&6!NQ(3fIMaGUf_Z=L3sO;f2PW1lJup5#nDYhC~w!MS9b+#ME z$1pRWs3FX&z>`m?-B@PAHCDA|KS~XWzjQ^}0uPDn{;88)iqu+O5X^C-9-^h#U2$2F86{L&USIOSUfGU_u{@(^rPDg zT!Plk7aqqu9V<9Zcy#+UAl7SInl5YNj(T{dnp?jeKynILm?NFk>Jj*s1s;s?6yUqA z_I(v0#HZlCyjZ9FB&AO$-4U5FlrHjX5ryl%&9YSR(viON^66Fz9BGGiiy6&2;XoRp z)|y;zlOnxgheqD-evBJ=6tj3SZ#Ho<2k%c&8P_{tMssI~?nvZiLB6c!uX6D;KI>?`$HckKOI*nw#Nox2MzNrR@`kF zNm2NijQ?bf+8G~7&d=AJa`O4HiEoekRJDdibYa(jw=jRDc8+1PeJ*`9ikON;<$6j^ zuKn?LWR37#YT&9Qn=Q{pX(B&K6q5VQIX)SirqdStxZ92KX`F3Zfrq-Kjbw;Nj{DYc zw2j6NNHVW8kuuM&Yq>yi7`ENG9C@xUb-=_{EWwE^`)I6C&9;*uSa~H36Y3(XCNRLw z%CKvo!_*mwM+pEbQk@XyFYF-^*`U%%3gTU3X1;i^zO1f1vGbTMrdZ)M_=2O&Kin+3 zxhS9p!DtI-$iKTI8`H;=jZy0)-IeQa{jJ=V*`4c&ClKY&?Vy)5K@`Qy_7$YSatuxJ ze@?#IrFHHDc!#+R>o|te%#nGS)*bN}(P}YHE>a&itCiWBwA(iw3hl|f;GUDB0ezqV zh(i#h>Rb2wIK12$0pHYpq<}bkFcQ(0c#_y#&fEP76=1_PZv}>Sp#`+dH<3N>o*BQQ zheX9InzE7&9po=g%$U9aQOgvIt7pJ+v>;lgovo+3zlzGw1^2CAWnZ1`TYR|Ds6G#$ zL9~ajnb^s?nAlkz=3Q+pZH2!N#hPGE(&SFmJXF@tCgoB41TN*E0z>>78@uNZrZtSx zIH=78Oq1lr%XhP>I)d*td9|Clh;FoPvnbsa@M)AZ2NmwL49}B2>{UOpzwPr& zBf{O-%OYO3IRR+57p5=53%=>B>b$avyoj)1T!iemLEAty zC%D5y{7Z51hm%xuQ9g$43ME&zkr(_TPcLpL^hV#Re^50h@K-ID)JVH5SKTwI{^HSA z;tS(n#<&h=`$3bj7`FT)@WvL-a=7+-CY4}eCY3^D$5+^6cFoA7gFT=$6y0{|-8CH% z*V&hmf(S54z00`OE4!6rX|>X(qv_&x>48wWM2cUgTM@71+iZI?C`xs{*L;VW5Xo9Z zxW$FZN%a*UOTN6CcNxUBZyUkfV=BbTxAH8Kc2|npefweXP7@6u;la~YYlQ~wBN(*4 z)2@hI(q+Gx+D@Mx78{nK_^&B3%(|VD-)Omtut;pzi*nRSguShnyP z48QI#OTYG?NmqF*-J@|i;ClF+m28E#ra>`c@_kAT%UX^4^Ny3E>-jfMF02|={Lujy zreaB+iqP)7;%WyV4AX!)VBaxP{IN^w2}hjXev@*Df!!ip1Q8&qm4{O}Jz>-6E#)@|(XdBlCRxPv()SH&N|_HYn<=SCm91 zBB?~CvTr%JIJ$RozlGjtM%y&i>Qh#?Fp zkRCuJD>sQ0hgn&xpGlC7j0InEhPepW;V+ubQjk1q+xYw~oSN(~B;{w$%H~9DX*MiL zA6rSh?|B8JxeU{u`Mw}~il-pMQdhw|sgnI(q$r(NM5_In9w@szQA_W$}G7)meq|MdK*&d z4JwqiedE{Kilb)8eUdWG(Mn{q5YV?7u^Ew4o~2}-cW~O=rd6s9=Y-MItqj|amb$gy zf{D}2mYbJh?ZqjF{yyFDza9=dbeq|Za`>3KvmAE*egC?9C^6ePdpbB4H5z{mvz-g* z>lbtmz=?@A%~To4yZ1zBfI}xjxSt#pZ1Jh-vPe>fpJjq>1;6OjmjNz{)QBHw5d45|lvBvayy^&{5*{c-Y zn|pNsx-cS%_bdluG(7il$6vyRJ|Fjg8!CwLT_}ivcrsu2yUQ#?B&&}EnG-@LID&js7Wz#YeO zspNH&F!;K0+|}KVsKsM$c0`@VFE|fkd#EM;Panuw8(-9NCB1B==1PnM>C17Ca2(J< z^nRnwlI;LxYG$T+!`IOfi4tcr|LdSXjFS6|38MLbec>7fe#N_m?)RExiGISr#A{rB zytUfe{xv#0dWy(I?8!>VPDS`cmQh`cYr#E5`@9R#!uVS1=kp;>Y}Xy81%&MjO#l8Z zA75_cFJofcu!i{Dtw~m<_GOMSz|edos`0mxs_eM)N$>obb?z zctdI~J4R)8g{h9Q8V|?cQ5Z^LJHOEy;WycmwQ*0)9$wD@{(wsM)w)(mEA^4d*PG`O zhy5n4{lw{>QkjraeTrV?({SvY0-zhiw*n58Y)L(9XZ;KoY;zDA7@GtqImMM0dz_^s zw{YN0Mg^IcWYcaIh1(x=b->=6KTGI+LTj~OzX4TomPlL67{?r7u1`} zwW*lSx)55Fz$SY{5MY=ccCGRSgnI_FXbOC-z#o}{yMZp^;WVtHT4et%6!%-aRY@n3 zs-?sGkUH)*qX#LD{65uS(jE7UW=J}ud32eC^EF|EJknqzRQzrR#lD580Twb_0x0=A z(68nkm?E>=ZgwfVQ#S?x#NLq$gK`ZmPeOZgC7p_en+|j^pMB{PIt)F*&%**bT}@&b z1U%C`nGyt;e%CwF44NWx9Dk!dISiLlt?|R>GHR7^VHUZUCkjnhRxGNH**0w>&chVV ze|)#Nn-R~1=c81c4|(ogjJMzxQkhaliq%UIa~<&aeQC2}+=XISF)z1L%i9a?q=xSY z{!S9Uf{`2Nr?91nbENmD_)h7NpHH>E6MvsJ59~ zedDinC1V<6^K4plnQ$jgHf)#L)CINIQA2^*u?`#s`SRqHWQ2nJX!bR?PQ7gJ39lSy zW2_;ui)gN;$BFk!* zf$zQjpeJ+~Ehan2o|wv?Jv9k^H2EM!b__+Y-$5cRpR@|^iPbsz=OfzC37f~A@J6}S z{fThy7E5%dtb|giZ~F?9fDdKgXtjP-Sjj+P^V$}fbo838JK#5(Ia{(}8&2gm>gy=&|Vx+CW=52Ox2@LK6rLavOq3FVSiFidAlzuvtg# zlfC^O#GS1DVmWnK2!=udLC1UA34R_GWSZB*=cx;NVHn)Hq%(I4PP-ZHKPRS|Wxt?p zEqyj{U{{1OmYa($-{Yu%Rsy*c4f|_tf?~aNo%nOt2(qaLc2ufvZf{G}Fxhh#J#JHl zw-2}IQ2N`4p`O0Yh1;)7 zau!xoW$cZOT9sJ<8=o$++my+mcwAi(kxi{^_5hHKsIfGY=QNGS4h;tHLp~J6fdoV= z;)S`HLZ_M8t8bs7ex#d{Q3c9Atvy=FV;8|T*X7$eu|LAA^f0!eI&gi@uF!KI_tHn` zZ0Qdss?a86AGkOFYXT}-y`@99ZU(-`0(P536kU{mrv27#U@+&Zk7 zL<-`E>^DA}kGpJ>G&&1#Dm-PvUz1OBHKc{xecONTHW?2DsSyb%UZSVP3UVtOAEE>G z`|He{su*61N59-ew zyFAAk0!huh8GHksShz){tw!Vn7yfEPVmCpF>y&duQR8p4{b>}n@~}f6X9%Hw4$ZTs z4tPj5ZEr5a!U_)eILWOqq;+Xb&29QEz!eo)M*s}X{BuYO`kBa{Vk@P%0Mp{BwLr36 zk*sjxovU?O#idJLY7XE1FjwGzsNx>my7|(hn0ZP6_pzUCUphLhrYBrlS&=EfNsc~7qw%Y0i+?80sU18pn6<3*xc#~O%Gi))hj?a$P*M!w)wH*fo)%`PkwijvCP6B2E^W^!`)@1+)SmK+nBM!G_*3iy}19z#Lm(u z@`T8wdZxGZV%%i*=2aC+;IES^R?}>);0P}ipk7;=+V%9;(x08F+^_%*jE3!{j#R!| zAD;}ih~U`Xe-S(G0BYLpOM~{QFs*22hlu$F-Ps(sRen!pX>yO)`_8NA!ThOoiu0gu zgl$I;x=sY*txg3VD_w+9t<^}}Nj1v5A1tp|-LYw&)0Hq^NZaw zrYMZh6Mo{8uh*50W4vI!{_;PPS^U3b4#~}+Ab3LPmA58 zGvRm#eLg2yCe9NU8)f?~^)m2T-cDRm6F(+~@XN%TCG}V*X4lqByOc5wpSdvI{5YbrWIj)T^4Va&atjD_U>leQR8iiPHlN(OTi0C1 zUXd(X8!MHX(UIp;scrB*sf z3pp^J>=`4Kga^()33r5bDL9|%4-Pb}Ogp(PGXtbur(?v=XSf6Vk);hqKZxzt2wcbC zzc8BHVmIw`%TjD{bj$CkQ$G%>tgS|ZD7!5ev^U&NgtOHWC(@pfxXY6zZc`wl3D9gYIK%J4h76|13ZSR*EqOBeSQ~?{6d<%smLgjWTNNVrpa}%fGI0c;9igMc(ln+yEc#AibdwH_GxbW3(6#%D7 zC0|BdbR!Ae zs7zP*>1~Dh(6?G3C4FC+x#t#{gUQl9q|+0(XRyGjoGJEeg9+*y7!pT2Cx+5=bY#;r zP#jlF(&*b`t$3M+=*3kV1D6%d`m_m$}pwC`36d9%Kq~Qn%R;cZ+>TQP@k;L z+@37x>{I-1AAXRUKg5vC#_1tJ>I(R^iMU{RNWA6(R9e@du_E> zE(t#-O{d!M>B-*hdPtwACQ8Qbw=v{uy5fsW^V0K`QIC3Q#D=-`o?X1!YT+Itn1E@i z?oQ<~{zl`{8nOd;_yFsM=LvF*HhF(_XpB?}eSCYvvl`C7+L);YSX)El_zFkzk9Og1 zZUTKA5kp-P@&ne(Zpv(~%Ud6uJ9N3@4xQKp@-7~E1btl(8Vfr*4Zq9wN#G@U@0LT{ zn|HPk%z%=hI-r5xN8C0J&8T(Jw2fANth-4uocmVB{t$;=_*sSBYHu z>}y);hdWr?zH-uId4n%3h;zBHz@HHWBfcLqgtjHRRH$5OR+%Fp508j?s4>rj*CPC{ zq>g%?|1x1@_3SR!Nl3Au#_CkX!Qj)_D?G?eJHy(om@xcFUpx%;U5F?VlLjv7kkV7q z>o2?2{~Qk^FJ!inK6IuUxUt+JHAr%kXT{LA2Hi8@p$B?J0jNPfWM24FYonnt9M)Iy z6Dgl6Si<;%DeX!^AhzcZRwGhw?w%v^+uyaZ>{NUOJh8tGnqA*L&wiq^@UDf?KAOzh zxM8f}|M1O!{c}5Uz#npx9m2fJH+j#GZ!|xg&l>TkT~!K&5A=>ap*4j~>iD~7E!=Z!q!^fvj=&ZVIAQtxQ3)<0!8Fr{;zbEag0Bg) zcET-=ZwuXHi8PwxFHfQrRq!t)#l!ikla5$G2#r4$EcE0!KjFZvY-KoDNezKhBqm8>htnS3v z3i34sz&_l@nbdu7w(idWMX>iwyie@8Tsr%r_$&26Mujs*lG?|yeeLKb9%Pg`mh=??D+IMNx z&PoJl+$gO2lu(2)b>;N6Kim=anuQtRFV=8?D~pG^`D^e19fm6GaWUH5M%@_95OQ|F9FxHEk3X>8>VC zg$@RCsymuy$302D@#o5U#BaezV5a;uVRyV+vXhzmmuhk<;3)@PV9uV|*WAy=d3O(# z{D(F%bYyI+BjPZ2(phxD*btZiS zuFrSuna|iSkH+Ry_H}=*<<(@))E|D9LlixwKL>_K#wDP@LGYivp3rDGea_jK*HWJ& za3f>NyE3JwWIPM`U*(Lxl)glcR!La3yF&zIzmXnvhj=OOl`IBrE@mdeo`nQX)lIl; z`XE;$-V9HPLWh%o^@IqYNN63&id*hIX4x9JHbqfgWR|{0WbmFw5<3r^YBFQ^<62Yw zLJp-L;|pym19&x;f;avV!sBhvd#HAnpR7f=9!5F!KwNt7>S$>^ufTQb@-)q;-2i^O z&T$6IR+*;?vCC9TL5T34fjWxn6zJd<@p?G{)c>hO4CO**o-ar!w3cnrmh*OdTf3q# z(+fgkyUV;35t#BT_$T7;Kj7n07neHv3ODFWx>W3=f6B^)=r4)gur6%A6 z9@JH;9Z<(8;cvA6K5m?1+4nj}2dZ#Cn}JYZaCd26O=+*tR*_}K)D^ChwtT1PLMAb( z(~#FqxBK(0(@vSUBQxGNGcty3{L@KM4upom24|HDGZis7KbzUo5B$*uba`tchr zVaVW{P6ZeB7uOL-A7lVR6z7Wamp?+v9Gr$4 z5DS5kn%-xjh zGyE9XuFG@@c|bQdm_1*4yQR%N`&=~iXSgZvFCdNcxp<`sSkUR>>kVH5R@GzJeoI<( z)2IFPMO{|apOK8zUnbQaQ%vJ(Aw0YQP8%u^klDGE4y%vcM^gVc4J4et7mW3QSeEN3 z?{sdKU)kG~L-RKIs|&DBC?^2Vrd66#NA9f$>H(i`4Ke^VqPK)!PhsnLR)#Ek^U)=js*Kbn(^BW03_f*y8LBgjw ze&e0MesGvN!K__j-I8coouQHpZZClyhjVh5r3=X>E!`HDf6XllcIm6I=0UmAE&YEYld;p_wct^S!dp{2CvEYCB0-7MGBJ zC~&}sKBK_7eEC$?$yEBS#8#up(!oV5y*Ax(gsuMc!z8C=Sc#`(LNZOClWtE8V7o=5 z<<$wd;vH=5874E{JQ0|CcH7J|C0yATyQ^gO8&dihHZ#C*5xzloQ2S)iSI-%`5DueV zDRV@H2zmhc5P(=HqaW0kt4Tov2y`Sg5`=~0Em ze?G&DgL}OgBIIsteLkd}n#Y0QW9b*kkkV!h%r}o%!6tggWrOy)Db>RPz~o}f72b(e z8w~e!i{uYJILsGN5R&}PsdWrp2nEq=OWBaqm+|>;-7f180VbIvq!=pnv0&BhrXm*6 zR9P)YwJx6Yy2Dz!A)Br%N~7s_n<8B(s6P4bW?rj?ygFlY=_hk(({5_)jQ|3xzLYD} z>{qV>j1WC@SG2+L1tJac*oDS>@&6kuDVo@381LgSSCwpP(6+3!7s04OfUD*|K0Hd1 z=If(W2nLbZqM9k5jz8T0ji%wIvTIllDLCbvrdI!zxEE1$t$5m_aRV@k7W=AQJ55Ry z`84ciPPFU&|N3HgDBlNX_0#7mLsWU=V29= zwdX*RPM1fiUwRz^?4n{G0K4epW`JEZQXgO!?J2+cxNAD?EA{{Li!k4kYp%t6m%NaT zJUxAV&Hp0T1hC8Hkn+=q)6|+?ANC?jT3?*@z?QNBRUE2bJ7o>3DKBL1MxHyEQ`O~k z=;LQCgz+EA;80G96O0+&^0of+#V-ipYsHD;xl@^gH|42+K)!NH|3XxJh&QH;39E-} zjmk94DT2FONOybi?znqZaic*~%aK3-<6Es~-fCUtR_g_~T35fFo}%07(YT$S;*>T+>0pO`;8|5D@!f2)@5KFC>zso z#q;5Ftv+fa7#r?BmsOMi7S&T=QT=mBhC&edA^QtDy&8k&(RfXm9P~_vLhfy1H^lmu zv9;hUKx3Qv{wN~F2lIA_=5IslUdd|Gi*9+5VOG3q+4ao75XHlQ$|fxff#;|O*h<~B z0pe2NwhibO4(K)<-zI23aWBYPJ9tzmacRg;H8iA>2)P-5h*5@msj6-V4^8+xnlLO_WH6TW|Go6j>3 zIv0NprI)Vfb|;sNAy$u+G0pHq{JTHdHPaRCy-rKLX2*;I@tuNU75eS=$97sJwEDhU zyLJ~V>ZW72v_LX<@Hgvu)Y`%0%JNw*2a95L-TEzV)QVE{Rk_J3@kUzxK(6M7vALqg z`3t??vF2hrjCTptdW*=~;DpLo=)}og@y`tH>v`L$xS8|-4mQi;a_cSO<*~m0bk&83 zyP%l=E@jVrk;_(~Ql!Eyeas{O1!w9(2eUjMSbkdUv#rG@Z~L(3*SnCIAe9@F(PMBk zs7}I-Zp>r2t`DwF{~(q{C0;N7rzK43HP5*KpU2pZ+9`3tshN*%9q(^6=^J= zD^KijxKx*cLPd(L;mm4QowI4p4D@RTas+zHcB)wp0(*qi_(0&o?VO*HnRM6be zvF_!=e>-1LH`8AqfhgQCphL3sM-r?qL_z|?!M30L#$?vys%ave2t=RiY%!^nSS6^=zKp z{b<4s#H!$n0mXakrg=lcH-Yj6$l`Y~#*~Ju`hu*?(iLvXLuY#p-Q6^%{W<;rF$D^~ zaM_J!Q;8pjTPEJkB%;6_Rp#J&f1%xE6$ogD9GSTVfUlWNI$)NBiB21z3#^BYZsXmJL48)+2uI5d}snB-oly#fbTd7%j2oUH+gkbwq9cG`$Xt;FlUo>OegftMSrbBj{biMzIKov;=`mWogLxau99bh@xu}!GcUp!bsA|ma!HcFse-V{pJ z+rp-zWk9^Puj?nA=ThO%=2})%PP4I!> zLh$~KZoL(oKXHomU56cT9?h>&)GspbhB=r+#h(sp~ z+Dft$+PDyPd04(1ZkLGdnIy)2^vaKQmbn-B*guB7>q;@CVt5WT`~G zp5s}Huu(lgcn|QS`vCmtN=n;Aua2K7@&tuL+-XX#z#(@((Wkny{rfM+%C+GVFT9ft zOl!crIrgbsdv?D-JSUsKKme=U8Mx{f=x+1pm;crI^uf54?b~ldF3fUFR;kXVRTY`# z>&xJG2mkF)XcfB&4vI!7YgqE{d$D*W#5!x#^L<#sA8MC~^BMK;rXhUkm$V^FDJt%F zOS4jMeN+IU0sej>#N%PHA1;5LARoU~3@j#I-5GAwAsOnPg6i!QPA z8w94C%fNIt~1aq;HAbJN_19Ld?OY{{;HwjGLsNh5J1rh8|*BM!y!n!C|F7_jV^t-0ydCyo}1 zAfIVN=nI6U{atQA_5>CbmW@wRCtDi2v=>Xr%SM?-abrf0@##_rfX5^2) zXa9_62Hv2$*k0JVQ8C<9aqMNn37yZb+FPs+t8}L29xYttWVn;@u-cy^xiE`PSFJTL zPKi02EwA#Nsbt(69L!~H(eb(!T1Vcj#jf(|Vk_m0k5Lsml+I%`3e@nYq`j41kuJaf z`CRp)!M=66QRh3jjrb=FtfF)2D^NXR*Yq$X2f<#ewexo<#C?k;J0D|JbxC5eEjDNz zLr$2grCnYVG@A%H3&3+C`mz-Z>gCBjH9P?AU}pc@<1`5lHvBi*A(iyI{&uGD0sDAi z8I`~`?giJkpV(A(8X{)YG&ZRQszpJ!Wh{!+v%#<%uN{|*)6}GR3$)duG_;w%v`XJ5 z?QC>!rgWDVzbEotVD|1HGyN0o<<*X7MDlE}f0fj$5cR-a`P&)2M2TRk5>C&j@V=kc z=8m1Dv}t6xH%gURso&Qr%F%E>3ODwpt#thtnVSU%+y6V6W6iu=@)X8=ZsUO;=}l@C}%@!Y{$*XgJCdo}B~h6}~u8sm^A&D0M+V6?7hn zZ!?PM5N2-@tuu8tEF#n5bZR{*guOmG=kEix+z{Q?Y~kFDGI(u^CBd#LP@g=H&zsrK zcg?tT@?Y&^wa8&a0scc%Z3U=XNRQ2=I3bj}VguvI)1WMkXKI_{Av=-W9w|A$z`;?l z+H8Mx0yAL1ar&SJ^=LEAFh<69a~?wWN8ugNLg#<1>;Y+R9~IC-?FqwQ0MbmCM)frJ z_4${t;E-i2u#ML?77@!K>p=hTgD;$!uQKtjvS5ZSc9)(iy^k?N_F}yK{9ZM|*pT(c zBNv@cir;CImR&=V-D}gq${v+T!mBc7rDO)Qe%Iw~M?{(I^>HoF^SQ=y=~L&|45Pu$ zo5Fxz7#E#V_9avn7>C%EBI135GyE2vWwZIW9jOCC>Or%|fydCOKUAcz)}*Yxd;z9^ zeQDUc%u13q;1pL)5eX=4{(P=$Vv@sKv>6q!=NigbLX^s>Pv16b1TVjqbnF7KjLzEo zM%w{wCD6Nzi=#U;dz%dxv~r$I(DSGxK$v@wR&2pn5`~QX~3-Zu#1? z-jOODcioM2Wik9%S9JCB0g)QGa$kjWc~rTcB1=IiHLK>eEvI;n)xLsta+;i%hK9Rq zh5Pn;SE2QYu8@w5se=uI!2%(_OOTk<+2W;?$d?a^FGojssP#sjL{Mb+at{T$=0YQW zckW2x%gh_#z=SmizdP54nK9_rJdK`3u#S(#RSAb|3*Yth?51^`Cm)~hIr_!0l+uTM z!KI_)%SNeoA|k>22p}6>(lt#CWX&H|3f4n9(x@ZFDsU9Rv@^vVWwG>Tvg!YPlatY^ z#Gd40D4ss0qH1Q(0Om9LEy!*zfDNoS>L!AadP#iweuN-=mUcNTq5{CtfYdr<+!Ga$ zHOKN_X5Iv=`^idE?#tQLgc5GYm3!}Ivwl+|bPE$I+XXuh&kMLr;QlB9^Y)XdUk~$` zo#gQJGY!XI_n)T{D1E`QBU+)5jiaBu0 zHdn<;3YWm8+J8{>-pwoO%5V8!{Fb0$-V<Ef2@VZbpJVz-b>EfI<*BTmprb*4i9h(m^;4}@ z_m=~M1qNmmHw{|%gQaKL%ir}!b;#iNn0Gs|>lCFw3Z-h=Sbg*?KlgNXFs(H7@59T? zR!K-R8YL2)8e`V8pqTIsEo_33BtL#E5kogrNUBSvV&|m@WgDL>nlI7PubO?|T9yV$ z-~gk@xCg~3uPRFE+JE9&kl0{YUhH2u=BHcvZ+*Yw9tZl~|2v1UcNpvmPz%1leHE41 zHMhsz`h=``>Zh88bZ+%F$t>wl^HSFa;0ElqbR>K*TOrCVV0FeSARCEkQCYThu^40l zqQQX&Z0_I0O??>!vSo0IwyjoTag`rZ`Vu&~bW&;F(vFnzccz{vy~`YD93Hu=Z;%uC zhvhVjuN*qyN+37JpnFwZK83m+TvP4(&Orm9-@}UHEm%~LCXJCED7x6WIRzr?{z|P; ztSXyIA_^Pw)6r=6o|Sp^$-9iWQH_YY^q<+)_>B5jW_=1#-1aIPu#a_V*DaR7eD66c z35Oa0f?49+M7pT9o8N17H21wr3P(Ft2y?&Kj=Ay|TG?C0yS3l(K) zn1Ht%UT!wYWQ#NB8fZ6Zj%TAZVzRa930oCaU8od6s{O_SnZ%Uv@hew#Q{8Z!CuUQ5 z*{5T6lGF-xGu9Vg04(3SH5`(K<+xPPfqZMmyz4yvdlJsx+wQkc#^hcio-@KLPVTBP zTD+!U!yRRE2k_>@2lWTTX_q(l0YilvMm|oK-hopi-WK)o1NrUSIT#+_qg~or-HCr#tT&LaM3mkymn9s2Zi{!n-#!7mE)uuyKHd|8O+c zk<}!xs()YIlo0pT`Q5u$|B-4uq~Nx4AB27KD_#@m$&OlB?_}IU#MM*zVj?dPokF>d z(^1DlZ|?KEQax_Ko+c;LMNy0O#f|(xOaCH`;W%$g;eL5iGEE9ox6ggojMwEx5zTsw zr9u8~IWH(OW=fHISAd(b$%tQMKzPGY%(F>@{e{}>X5N(1Qn&=z0)Qic3&7B0SFyYT zyX-BV9r1oAmCmPzm``EZGm21jtdv$!zCH-2fdoD&BK$54yLU6tQV%- zU*s}3f_iZLF8=grB9kU79Sy!>)A7dyyG%0|it0Vrf063PA5t|+xpGLkK99-82)cS) zF6SL(SO~JRw5Mh%sv%F$oKueT#Frfh#tvR1rQNDOrQo1tWtcP6I3G(reIYwBOvzxD z7iurj*Hjx(oDWi~&MW17nE>&0rcQFEX{0{)o+3PdqgZJ`Ci+E6gkuC`&Jfevwfy zP)StO{~BWIq8**ZPk@uC9nw>vYU`dA+(eav0idMPC%Xff%-Cv(c=I}KEIbK3wX z`i$n>xhE~?KNJ+^)E;(1I80ernjS>$258V11b+|XgSyrjCSrcpa2b~S1$wRTGV=>G zs72`B{m*6*Kl+xfGK{a#NQ6^EFv9}3QiA{WM}l@`QCoxXiei9u;{El+2MWT&8P(Z)R*YsYwz$T_mEceU#EZ{5rDmUAS_`g}cEFEuFa9<27zUTlK z_Ba`TstZe!Sxu9|;@DN6NpOGFDrZwcJZD@IcYpp=H+!i8i2BK=0uc3SUKhB|MmZp= z&^OKBqLw#Z4B>0QIqDFqkjW}m(k<$#=s;F-14LB=0q}-;4{zzH{r`YysrXZu7`N*cM9K@%B$NUx;>RK?n$&y zhMEp8mc72MWXnLcF_i@sK0Dq6yBCvl1=Bc)WBcgCu`*Pewm{1r&R^HVPUa|MEB z0VviB+_6#B=vGMsLA1&c@UTCubg5CR%ornH8iafj+ymGJ@N~?wV`;@C2D8-+be%v_ zxrDt3zdZ2@t_2qG>MM~3mn)10YxheHTQ=MDf8@cfH-95s)$?8xK#|sB#nYS2?@AHA zt`bjlPV#jP_T12%Ka2UH^Yz9(}h@ zdQoxWSUY^1i*2y1D(rTlW`>MQWTDGx)(P_uHvx$9GG>r(01$h2mZogH z=3x9W%NvUQjf;+za%EEZwsO~gtsE75?@tdUq`GDAsyPU=WCSiQ@gdQSC8dr~Bq>d0l4H!P~#>4lqb`uFQZ6zXY^d8+ZUB(J-E zwq-ML(txr1y(J!h%+mhgr%X}eN9SAcv_W;Z8^dNoV!a>#X-87baHC@P|LDlxoYU+Z?gG2|uj!K(IkL0WD7k~Ut#rEn zk!jXL$&yNxHqXXL}4xm$rwDj8fnk`AYE6k$J7N*OWfCJpP;{N5!{&NR;1hVD@`ne!bSKtcao| zO4!YkWawS#1!Zipe7iaAKbx_ItRdqKwFU(^xO4H}hzlm5nvTvD$5qN7%WE7?vwp5; z7gH=Zj*GwyJM>!S{GU*P53V^*pDUcr+rB6)cd1ta`qUD<1 zK2=;QBbL$vdbeL$rl|4r#)v7Qh((M{KFX7oXOuaxqO!ho*V6ydjA#v?_MiF*M9c9%>&*^n2&I@l2XkI-n zK(Gf0;5X)E0mO#s#bNW{Cn%*?n!huTW|A+!PJx;8y3aU{HeNe<{@S&Ve5Z=)3|P<| z9s3i5+8u470Eof^L5q%BCxu3W=f+k+k0^DI+{e1y&jz-!?Z#GCvCsRoTZ2MDp+t~Y zpo$(P_M07wfEZe)*$ujTlPnS!}RQ%y{8;R~^!rrQrv{ zR+hNru9*r(3T->u$FQZS@!0=V?kPn@$PDvOjbOce{L&KnFQZKss+IH>E7Q-MJJA_k z##Py*L*Ap2%8N49%Rnyxa>lc=!O|n;kOg@v_n5i@i!Xg=eeOo8ZO=__-DFeQlN2)h zZ)wn%6d7)s$^on^LVg3VF?oUVg`LT#QUJ}`9sm?V zp#MYLO;H8|`LWqY7E`ouqOndW*0kGO&+DvuW&xu~AoYG}I=E5iSwbgrq(_Z0={}yo zfsF1qG$h`tbPnnEA@*L6MbCv2&6ad&t8UW5kXCa*e}aYRJPZn#TI}^wi)~bI!*6`8 z=o3GC0}lPbY1zc|zJ_vIgOWmNcBM>I`E?u?HZ49U=*-Ur5>i^b37tfA&mPyq zvcg0YJU~6*)?p&rXD^=JA`@p#I7^BE}3^DeaMxX3NCw%&!96aYnWEMeEQlCn5Xo2lteM(qB#bTN$ zKK`quK_a5>GCM#OW6EnODL)MFf&#eZk|;X=)qa6}N3Dr!g>j9;l${mynH|53_bxLv z!$ySe71xWyMD%}%-%a$7#u}gzm-a>|L*F^W(&CP=Fes~t$V2Mw4@qEa1MAe!1Yq$j z2H4tQmiIydY;C{=c6dj=JARa)vyj6jq5!FYleK>40T*-92ExQ7pMD@r%vvA>!o&?A zOeCWI7A|-M#FV_3k_fN)t82hiMO64L4z;-%(PePznvC+|EuP#Y(TZijxDv?rO}T*oP^wr=8COex3B*I6O+3-V~tY26au&_ zSycd8*fsGHpr%SNqjc@~>YEZH+t828_!!bRy;fe@%cD`-!8%U0qB%v*TSTPijPZEn+1Cn77=_KTsayXD>Y?j#yofpUmcrUgv)m>M@2oZ9{NUT zt^t0Q?>3N*Sld&MRh`LS6MAAiGpQLp<|3g9p@{B>!g5KplgN0U>>J z!SkzXz?wUSH}qq(8`2>2Rp$Vtzd&eY_U46^Bz-?794IDnrUR@M^!#NP16FN-dRQVz zsW6enVl%F|%uVCCH3}*Gp6MQlL-X6ct- zyUrc8x!F_VhZhmHDyq(6I^MgXzE0S0=#4gHQrrLhp9}N+Uxm@YW#`Zg)gGn6-oyT6 zBdt7?f~Zf7|E5Vdbr+Q{FOGtkA6<{^v+;N^QKfb){er{&loJD^G zYk&CwRKNJWXTV8(Z;LKf=1KZ@`T9b2(1?>tsQmQF#G0U$-c`(wO=2kW2T?GqB$Fi1 zFfJ;YEx<1%Tl#?M-lWbk65YHh7S^J!#VIGpz222TLOWiB5gV$Cd_B0j+?2@a4`VTb z?4n+OcyO zH|9F@_BUzs@9jJz@0RD1M;m{H8hn%lz`Rs+uR(0K`X>(YRTdU#`US<+fCd~0GybV* zSe(=sg{2a9+^l&7Z8-|~GgVg-QPY(TrPkZOK;8fdGmEHn8(B+%zxotvtcxFyn{$sI z*j^_Eo|c-`h~}5_Pg1?*C->Y(AH;Q|0cItpn^+!HlK#g3pFCK(@kZzCD|pPc@iv^r zWu2g2q<&a>?8^OlY_`?SpH`#aH(ZB(B;g&eI(+VCFC4h{mJ^8W9iiv9wHC*!W< z>i`n{40$R8Byu`ZYRmZl$Eh*Lg5@4S!jqUhlAkM(FbD7>0--@Qc1tU2?n^YNfbDu@)M{Vf4$ZQnj|0Me?GEEh!aWyb#;Csdi6 z3%|Dph${+(8-PA?lD%XCeJn#gn;wLT%>JrUO{P87iv2~%`mrld=;RN@|Ep{NTc>{w znw3AtQ0M=_X~$om{rcyn{*zO%MY=2=zq+2`8P@b1WnqhEIy+4Z4^sjkNJ zU;hYXv@$Fuqm|r9H?d$d^7p3_BI(Q_h?uR;Lfkt))uYec`+bp;TYe(@yJ-sPB|Zj2 z;X`_KG9^T%@GlUsruJ(9^ac$A1OW*J2Mq=B`vn4lAkaZ%tfDHUEG902c~>F{b-ifQ z*W1d*&H=wxK=8o7KnRol0^RBk+gd5V)bt~be3JdJhQXA;(8KB21}}uXmoTWteEfk2 z2?gS{$3fQ7*NKx4R7u#q48b=LGqp1T`_czF$*Q|LbVDd=af7fIiKeTUa;BE9clqcC zJi<;0iatHp+MOz2>uh$~jqfWy34D?avj0gl-A#K#yW*;%WEmC>%9fp8L@AFzP(8Sk zRTRoRagde}Q2$aboRM)bTNyMgci)W!Ekhz<+@Z_k;*Qv=nu~DfEfh(p;T$f}?9R*(ViSp}9b6PFhFupUB|bWRb9qKX64#eF(K7y^ea#USgo!z=r$-K3 zp-6%<8I^Vt{E;SbD+D$`+x2ed4Q9h}5e%HL3X}4*F=nJL-}b!9pi8@z(O$yHomah! zAK}@{f~Z;1lTjEXWn3^8_;Khze;+%r=hsW>76oZd3;@vAktSLT}Q}R8Wqo z7oAGfoy3ao0>?rVtl0C?0^+<;d9Oe7{99fwh$)`M5!M?`hH2q)6u99~qp5E&-Y|9F z0tJr>^1HPCv3zLAO6oT1=x;p8s)U9}#heB{x)FXW`UMJwzC|jX>ZlgKBW!f6xT%K@hM{-{o4N{LRfH(4$;nIv2zw&y+ zlc)*r7;xITA1-_%|2$BmGA3?6FiWY<xDn{VbDbIa()UE(s0qo(&Eqqhh}CPdcXJqmW_4f$zi!Z zZ$kB$EljT*Bg^ldNl|s}ZB+k!z~J*w10vQT+d#>zRI(PsH~KvIFfpH#>_SL=TJAEn zUYkFd3YM`9!+&b|QDMy1M<$gDO!8{6_v!Iwgf%3gqJWA>HsVx}`_ZPIB>pRj4IjxJ zAOGmX-k%Q(J*4YA)Yfc)-PuFda_}xNV9Sa1n)7SN_^H7*W&6hEjg!eqG^zJ+DtEW9VN&?_zGKJ zif`mzxk?6V8PD*LUYte#5Py@2zrDByZZ|(9am_B1`Y_Gkgy-3@A4#r+`KfSHoLX_q z+dg=X^VM`z++aoDK7DAWn)zTF@R(xcbcW|u2TXm{gd;G^vZ4^d43VaV}E+Oi3Td?X{E82T>!~i zwXn)x*>R^nHuJXbD4*N7nZ3~Knlq0GDKj%$VM>ibCdfKt${4&4?C>CX|HtU_!N=93 zfd6^SKK&}Gk#jFPbjzkfdU?cA^*IhVc8dO$K4d;m;78+-lVQ(BITCXiLi&@(K|BF^ zTkN{|T$)>{FBPrY3w)g-p2-1)NrbIpcb}Z&iWz#<`tpWuIC>6*3hpZ~e9AowKx$0q z=pxSh=Ynu%OmcnkxwU98ju z+B=U;v6~`t|1_mU0c_|TZ8MUls$V0DtP!`j%#lE~Cao~Wh44)In@Ash=Z@F}kAIl7 zLc0hdWZNpq84K>N*epC7^L_EGApOCdpNTY<|7~@6RjGF(#G{hUENiTAvVNuUd#hr!{y4t6dw9*rR$wH+Pr)PZqQp7m7`%k+UUwrS zy-5i1ch5Kq8Cjz0vnJ|JYcpZVZ5CnUmc}3Xpt0as;RPx}&PiX{Fk&a4A^(-`}j5TTdq-tf7N#L`I6@(5Qus9xlNW|z>2tFHa{l~*F}vESl$=)j!x zc5*r_o5aC=nXY-BZ1((`RPRuVdmfItvQcm+zEQx^DkRBwhj48=*iwjtfzAn!!KyId z%ese&vC$M!dc29fmJ%tZC=LqhV2}=vVAEu+2#4%@K1SwM=l&FCsjJ1iUYH!x4st$V&y$TvTC`u2+k_Ub8t;;siO`yY?Q1(kq`@f zvf?Vbi4VB=Xjn^e!y6`95Ywy>j^uZ1P|tglEOaYI37&cNO7ZOks7rC)mz$-ktZdko zUE5_wih`?66kipu&U^%YOm^t9h?0Ghd4h&Hyvdm1z^=s}KAi=>S_1OLBnPTr-}B&& z+_G?k?WmLFTP=$1itCVl+pb`eDKrdB=mH#ZlO3la+$k51H!x$oSSJM5BVQbqpHXq6 zzBAbf7kucA6%mqbb?fb`y6C+bO$9=Te5AXcpRIF6wX0m3uI=1-7Jb>QBO#37wMJ_! z+!Y-HYo(EfW`L9pT?f~5xO$D2XaIE-Chj{KTfK2p?vA@PWfO*YXIvU`OBOCk{0mp1 ze_P~y|Dki$%Wmpc9-eD2hQcd}4hRri5FJR&R46{(-X+mqWrZ>Sv}PA%#BNY97oJSM|vDiq1E)r=L=KEN7f?q0ZZ{t zmggsx0^9DjQC7_QU%edD?#G18H&yWxEDem`zpjGS8D4@s4b9f&2!(Zr%ikv+xD%C> z%(%kPNx96@BF%L(-yUe5d(Xna!F|5rQL^{2XruQu{H9loZ9j-IGcZvW3-krGpJvRE z`Dnz3lkCBp=)|4LM#~@u;{CWMbT29H_G-09ac*E^n6-dM-pVIAO>H7JK@i&|d1~Od z8aAyAde%OMG0p@09#lQ$qqn_xPzAZ4Ias#XG$lm3VP&rDTjNMMUO}2t`*I8Qf~M)W zcM>(h@9|vwZ_JcY6RP#EG%TK@*f?y?LTi%{=O;Kf!=hQzJ?6I+Vd}GROcEoP+Kh6% zwiR*r`mm*LNeI{V%hAJ;^If<~(98r0uWHgV8I7O`1!0Qalg?hpcwf^Pu}KY@|418G zu@r!h5WFghA^vE}{R9v#yzd!}CECSoL(|Jt+ z%o#P?1_^>HZchquTc{v5E_eKyw6|J;(mv8ArPV5Tid`YLEf<^oWWgBnu*el>R6z>TJLwZva0$s#a(G^YZt7XH8D&jnGUEb2Pzrm8k zh_QvZ;T+9DsBEVeh^N9!W}*scP8agN-3X&dH8Y(l*{Ws3C(uFb_~ufV<94bT#M*00 ztzI=X#b^>>KY$d@9BYliJ&QZI<&t}?4Pt?!@SbDGz|qWyJiA1Vm9~x?7De^(hCapQ4$5dff%;K%T$?aTUTKSJUA}`4SNXX z@p)ZN<)ENCc5U9CRgvj4#7y!JQ*y|o!$|au#~eqJ*^M0;ns`dWRv0A4A`8aQ%hvnujx?l?DQGADU*ak3sHz!dE%YO z!M)DgK_b2znotv~y!zpLMyLB+zHTHT0Exj9D{a=}Sp42)eo^7P=g=)y{uk(qYqi8H z&pS|JXo;Q3^Sxk;eWXD6F#K3#U<24)b53k#>F(eHATN5c?PF1p#>RIwiu&K*~OuH&B1 zk+=m_;P?cc{!qtHWr<}H_EW39hyGK zlk5t~+U#UF{SPAUBwH7z^dqWX%ck9Nq$)ics3W=JcftMcv|j*{lHfNC6+4T07*aQ; zKGM)PZr_VEHSe^rWRCPBHCnwhYB}6V;kOAer=wm!pw96WG__? zn%5k&2&D8@vgL#eW1ByoC3vF~DLQ&T zRT0PJ#|~l?(R&E2-?9#@C;9MFw!&!^Q9h%SijpHCXM14Cw2elGD5~M9*{1N+ zeH_0T)ctaYOB_s2Mw=N#2|l=7QmSnTN4zEfj5D_3i6$*8?}%$Vo4Q;jHdO$d0)h61 zPbzZFvtKIFY&cXZ3?hJI)vU*kqs-M8qIe{+0mjLsbU(IF4!74BRxiHN#n%>BN{i82 zto{txpEmpx^w$G5G~lVZF@vGxAuoxzu#oAeN@3HEP?1lw=?&~TA8__GJ>jBA(;XaV zPQ1Vjsz%Amx8JiO@D5jKP)mc1{I=3g_m#H)(@`!YyAM7~*4$RjRFM{m>H$}c<9P#95$9q3E)mBDY9LFyYNVOTnScYL7ZgCJ)#Df*k&%i*YuZSj z8#3TYN|hM)B^G52I`NA_>|im=YR`AGqF3A|YZzZJn2dyh?RDHRDLLb5`d2Bafm>*B zdnj)FmF+Q)I}v^M647{l0EK+d-8=MGMl%Y@%6)t+M9Cm{QWjyMZ;|o>ihNAz_Vm%( z#Ts<;#MJ9Wb|x-ni)+hB3}cYrT&6hF+twQqF9_r7tlhR>7_zKvjA{tWTy!17`x z-UR3A)gm{oAOY0k^iO!FBeY}!=iFB~8RLeOre^hlI|`MWd#gA+$eEz601=X8OeC@~ zSfy1lPZF2xK6HyD6T&3^6m%<_uA*FX1%-Ap7pSmy^T2!TS}YQ1@zI{kP5T|k<%$MM z@vMkBFO!z*WvJ|lQ>I-Z?|ha|Ax7)U+qso9s`{ zlKf_I7@8Qxm38DgUys-yk#c1h)F<9mZ^)Hy+12N}XIYjWmUGedxd?JgwF~e`!r+Zd= zSj#Y9qoq2swwSnVD+aL-npXS%6n0^n6Yt1py3Y%>UtB9P73>jWAg~_)#uV~uTSmD?2@a9n|?nrM>;C<9?5PmEdF=(F(qQxaJ4D%GlcP0>g@04*2bKNRynH!m*#Cwoi;7%lpBu89NHNE35LNYj;mzT?IIXv8c!1`=~{T?N-wxk2jhNv ze3+!(Cx&9bhR&RGTei&v_hw()1}-!iir~*Y0ksc2!X@+)@;lN4OPG0Ig1?+94mxM> zOCM#+;Bw;j9PW_#3fiJXmXH?+vG|I9{=n+J3?v6AVRczw;)Vr>aAHXYTe6e%?Cl_i z;x|cO^J1Jx7+d%rQ>hw;wHh^Q4fUiwthZ@GJ`I7 z#MG-Xh$fWj5y(7mz-6o8O)f9+I5?Sn8E#XdmvGTm!@?ido!}AQY7$G5XnZ;J3NT$7 ztdwAh!E=3_Xl}s*R{>fVjFR@k7cJ(L!M2>s!!ogpd!su?5_NH|R6@+)A!*{l6{xUu;u2E>xC2@PoyQ@vAZ;q$O1DYMA!}ZP394zl-7}|X3l_J zX{UJTX+mzA6gD2WnTW{2=b9QH0oHN1#c7O$(CioSR!ttl0#TY}eOuG-Y?7?}@4`;w zZrz`^5f2<+9U0y(x0I#^n-f@kg?wjC5n16BdyAiOhFOJ=i4Jl8*@G=(i5YjN62a-j z^65PLvE?;6l_$~>_g^|7vJnAY^(I64!sg(0?^yi$eeBXz#10i(h5)`QROMSo-*(6{ zG8S88xu@FEOL#D?Td)ltVFpyBCRC-_&W{6r#5r=6aD2rgj2Q28^8EfN78`kqzI3i* z+JUs|F8)`9CYFt|a>t{0qxO5MboS`WdfAz)X4)tmVUHjM^nUBt(A6+T?c&fbnZ#IM zb|;PGrO7$JXp(1dns?l|;o;C7Kev18?F=^4W)_u{-egf)W_0v=#ih?s?BZ z{fyE9S53zeRL1$@ZQvFwLY6evTV z5OiLys@X_4i?cSSJ72>zHMnXrm}8>&*iU-d2*>i4(SNBPLPcRz14kp)XHmr5Wi*1fF0-XDiX}gyP@c_*t*r)t97=sDxm9g}Z->HpH!f)rR+wdF;Up zgaFhdQ=G0WDz+8Vp)-#-?`N%m`k}SVh4{`Vf8oW6XZP)km8ot%A=D2 z^%nT++TOw{eveBb_ZLWOFiGe&PtY?KhiM01TeV+pJWaj|{Nh{RMql@nT#6tSazCCv z)>&t_HdT7U=9%ycsST8vX4d)+a6*}{8xq$M&U78 zcS*enm`?)W5uKi-a+$s(uDMjc%zVHlEFJxxtWH@OmJ`<>O7O z5yCF~u)<8lK6nW+_moRf&Hbn}xy6eb{j|j)v z$s@#kS$%9On&im^Ps};}8L2A1Gxf^U7H+Ymf!b0 zG~v=LHr4nvwJ$iwm+&d?bm};0LF2jc0n(-iPv_DR2paF&6A+0 zn|Nq+0{adFDH&eSpv(Ah5&`b7Dre<=_6db>gaRn2c8*O9Em>$WF74Xm0JTY(=COFP9Y)uf~rX>~M`eTUH1iJBCdZzQ=1 z&B0LjPfHmG?xvw_wGO>QYo!=X6mR(vq~hL&3zld;9GHl`qI6bLl@Xh4(#3&R^g&1Z zBT!mKU?eL{|B=q0wVZOl_7z@1>?WgjYdhBXDOrw`pPlo_Vb)!WI;^&?E(`mN=&5=C zTYI+U*}Gq$kHgFlSAd7-(E+Ua_N4d5L-^#xnlhR=rQBI}*Z%7JcrNi$LRNdT28!bO z>z1T@2)?tyJ*4lx;7vlVdG|7M%8ELB%)LlIUx#u2WL@~c78&ObL4EGOZ)k=oL#iuBBTehxA`5+g=3L-bM`GMsCBA3Ir1n#j2{wPo5_HP$ z%m{_2Oz0J)qw4*LZOjBZnToIsHXnTz02Azb-PCO`fhxJ?$F`okvc)=U5;#<1Ie2`* zJ;3j8OVvKK1kFv^ql&_n<~92AjLd!r#`Y2{aCh6_?%ck;ZNq&X`LTQd=)$6-(N63N z(V6RtthaP`&K?E(s=cfEd*8c?np;!uR~JgB)f=eYuEZg*p}}RB>usOA*%8pBzx-g6 z3+7|&(TAANLmP)3NyP*q!k$=@9^g4zAc8lTx1@M?6(UFY6@(Oz#CAx#hH9{UYZf*M z^&c?cS!|miFyC8cJZ$_u@`tV0{u=og{i;!8uKC8Yw+b7p8FKvkBycvwX%ALrNu(lk zB{pO4Seph6rA>~EI?m+rq}0#%ygwt_)Ab-}hGC5IXzwRJFM8if#IYlQw&Lw`LQ)p6 zMH~8PeE0g){(8hW=-Z4dLrsK0uT^z0Iwcw@YVlUR*sxj~xr8;M)Ppd=_Yg}=;` z7*V+i{bohxLQPP#&&l3nQxTHPRQS*O8kv1_R*6Sn~YB2XZ2OT+gU zW@2XGnbB9E$5=S{*&eS(;1XnC4R9TknPBg`Di+yB-lzhyXmNU@07ywUIYOE!9M} zHD0w0-5`~x4_QIfL}BWTpJ^Ssq1RAUNq2Q5RTof2|Dj@@zLL{_j@Zdudr~Q)r?4HI zJPO#R9-3K)bF9nTOQz8aWId9_Q*Nn%{ksF0z(iJ9fdhx0hBi z7%ob&kzz*KSY2FeEo-{L5()2vr~iI(iSmORyp9OyX^iNUb4c~d%21?&ukgMRZ?1m< znV%zYXJX#HJV90>58y^58RgXtF?TH`R~igN>3jed7C(^u!=ydm#|GzIcV41?`Xq7i znLpiVB>h9~U(=anY`i&^gO$2#u_L);I|*N+zzaK}cHe@kJW5#8ashDE`o zO)gW4XP%o2E8;^t^e!Q=l?;21)Sx8zHp%oCNOXqO*Abp2vnuj#aD4^#9YIoX%Meq@VDYK1;0twVRli^Qx7xFY}wo`t!PfNxZaH2o* zpku(xBov2cnQD#V_>S*Z-;)@or9FKIW+{Y>i`LJCe|q~Lik^l87y1`?jV;sdB))`X z2F>1mjRcs-ab45WS8L-hr*}Ve5@^jx=6~y|(ipYrCE;fn_|a7OL|a%LS|9^X950#z zI&LL`o&VUsB)l*>PphxeEOzmHCXtV7_OkMQlRqB)U~>KVFIRXZwO#$Et^U9ah3a`= z?sRT+zp|aTq>M3#u;29~W|z{RP4-> zViHK)#E#L}A~7fc58||Iyk@&_86(T5vm>f0$k0CTaCi1P%#r*Y(|%h{|X>1>6#K&AKt;o0iA1?rSDK!)6Ej>sZhxLAU3>Rx8^3u>~ z{hhzZ{>x(8!~fpfc|A&_(*;GKcA>8|2!vNsgnO;eh$+ZGCD054jU4h7VBaLr&HN9x z-a0I_HWAW@j?}8UY*jk7=nM?M2 ztGG@G1_p)z>9iPctjRU%t#6)RvF+Jk&q~#bTy%Xcq~e~Xw>Gx41#Ga@O|kl4k4QXvXlU+cd9NbA+kf)q-TvELmr`p%rqB@X(r0{A$TVaIE-+pZ z2a(Mwn#G2#0dX{G4rT z7i7J8>Dny^15KW`JqRzgoov*KuV+A*H7oA!bTiqFMs#RSp5jm^S@CJBLy;hty+BPm z^GR1~hAV?9LsGuS^i_J@=8`1#0e&2PPG1J3kTSO$`6=F2cow#xFtp|h8mXdSWis9Q-U zHR(FUlZQ+o2y6u^ZmjK?D^CuYWwqX24gfqdl&!gYbee$UNDQDDM@iyEOr=HC-8CUB z*k}FPq*Ce2$ym}Wyk8dVIuyzFkoS$r1=Jv)XRvI`n-|wV2VIzKqw4JIe`fx^9y1k* z&b|2V#9b8FFGzKQd}o(_8ZuAWEOFZGFxX)y9>Fi`?)@)*+nOu7=-xkO!rIGH`Jf`= zqP%;R+@KB&pAZKkqioy>uf~2rU15jLOLX)D2H6Uh^G7%W(y-7hriK5*``khEB^0f1 z{hryJ0v2K=37j|V34zZm*~jDd%F9VmA0`iMs0HAB`q>X%v9hpwXn_s-gOXBC_J(I) z^Vw;qFleom(2}PBWGpD4;fZo^76H4+1cG>6esBraM$Ixeu?_I?h9Ae^iI5!uCFyQ8 z!QOmNB78D%wfBJ3pM-aIjzWFXq7DfA51B(69OPragj48~i{*pK(c=5X?V5*8T_&Vn zGcN~9SK}VxBrTVo3$kxY#@0Pgp=An3~G5Ke8yB0~kGSu_7t>viLlLWJ2JgR&$ zVyM$tN?b*K+3;Nx|!qzvyiUk^w^Om$n* zea3XZ{cl`4FcK7pfZs)R4lOx`0UU5LZXS+8moXy9U`^4=`D{*0kRa0727#=UVAUY^%IA zlXWK+9H!F^GTMNeUYS+{TO$ron7I3PHLx3t&{moh%O9=!#CH8Jsd8Fs5-oqnQ>7MK z8h!9-hNkV?OoIi$jd>ar#`ZqEBO*5$>~As=B5k{_?0*PWnRJmCs_aGOxxy#VZY6^v zzRQiM&|^HW4Ba*De;4?^S4v@oa^R$j=v)MvB-NC*ak%}h48Mmi0ff#XYpP0V@zagn z0%6ovmYj;G$(3ji`OrL;k%)43brmu(D#{C^fBni3laVL_X3nRr4Y2zFx9&fv!gKz2hVA(1PW zU=_Y|45XJg_zN@{@d>}EldO(7$aZPT!ja+K4v%S##f!TB=)KL`0Z67v!oyabT z@2-0-dby3Hn@44P7Gtv_l4h$}yzdsdHc*)x_mPs}t9x{IeAmxr|5B&t{9HGj#-;C$ z?B_0X5HrOW%<&^kQ1m4^IV^NP_q#DkuZZ+H`s+A+iE>9ggfgBI4^5H3caskq4m0wF zh=ih)mWD=!X*J@?`g{#_pFP)ycYOZqoz@mABh6PuXpVP3>(Ef5kq zmfD`R>BIQX;@X~!Q>EhA57()^|E1rH>){{tU-@G;!?25Pg$(Dpy{s}On`x0!Db=Jd zW(ck(9|8j(R-rtk-)Bfi^CNjcuE2-<($rXeCMzYG9JkdaEY36I{31pbw+Sg*hqHI> z)enG#0^HUS>wp1nF#cF3{1D7#uB}G*U51k;fh=%>j!Q9V)VC{6>>CCp*3OVV;z@=p zd&9e}JtIgKaeVYJQLHcj-7uc(?Eax7{jg#)h1ed<&P$Hoij4>F@-`ctFZ#uLSIvX! z*cg459(%nM+CrXj7?_kh$HG2uO2mmg^DEK!OZQP~aO@fzZx{8sDOMJaJw~R{AAcQ{ z8$ZYXv#l!NBFM(eV-mONVDN`XeQuAZu z4V&dMA}L659Ay7I2*|Nw;>&PW`iMu<+)jc+)Lyi z6)+=3m>3tw{>0PKR%L{KNYGWfZWU7^1oq_QS|9?*7BO(|@b4d1$D|{N`}5>o71|kt z-mmIZ)j^m(3Zg=XWAw<`^9k~bnPSMU-`R;^$noR$&4=G~m|cHbZ_LpFrQ+Q%_Sezg7f3PGIXA z`ZLkGcRiAVu+TjCxRAGN802e$IGWDy*CwQwnSlx@YJK#YhLO|wJWl7|V z(y;}57Z~wm*7uMa^=^ik0xzPLwRQmE^DG>6%1aci4)C?!(WlBNUlQQYwn%P2ar70U z+8I}=hK~ZcyvWT3Md@g9GR*$I8j!!KQ%VVJS;bc5MU$e}3Z69ZzGJ#2x_>C#hK4Y) z6vL+rwqi&yS--e0uw=C4qYNv_0GHeIsR#JSw4*C}yZVLdIARCWqvXr7FEZHo@M$1J zDz>}Evl7wEd>mRh%c;x5qN-l0Bu)Z?0kUT@L&l?A?hG08XbZY|-oO@tWk7H&rGjSE z6c8BIzzx;RgCfQ5w$x-Y6+mxa5uDPs+yUiNUp!qNczyh|)1lhAR8ZU&< z`lsjpbJ(C3gL$bB^dRHY*sApo>B>Hz01NOTfFhsB2u3zz-=Z^H*NJkeb?h7rXv6XU zFEU>YOKjr!B(N(1Zw$CTT*1spf}Z1UlaXHg*0{o?Mfz^r*yJi?r{J-?DhuP1AkKy` zc1h%IJihu)ef$h>f2b{-_CbSYGrfr=+PI2!7?KHAx9k7{fM>_kbk?TIo2=HU(*TR$ zjX-VRvEjPB4=jTbQLsdHX87hzKOuP!jdd+OYBP3EbjH)HOxG0eK_n?CeN_I}e6P{`yr7lLxV9I(Ds0s zqhl31&PJF8)#6cpvYCSei5l`$Lt3-aEfQdoE`%uXjW&EChk_;Rjk}#`9$v@qYeYeb zW5RuAeMe6uk`T|&L>lk!`X@xSk}lg{1{O#Ho(>o#_NKjQDW?Qd{I7OZwJ(jU=J7G9 zB68;na7{k*T=2%YLMUng%0!KQvpiXY@^)E8mh+Nqu?vXYKRwip{W7WLw?~1}G-T+; z{hZCCjEcSAbYH55{LIOeQ0R&5TB7@dxNIvdIcZf%is<=+F~XiDPl6*O%!J%>4b<$d z@`bHIVVi7RV|9tUjb`p7Z^eg4Qah33h{#k5`RKJn%&6%Ady_55A$UDPaX#Q6On3(H z3xW%WL?4hj6z=Jyy(|BC&_>G{*5cUO1QlnqI zF(DMc#4|N~Y0GAVOr&Ov6o$f+8nbGn?>E3R!ag?%X$GANmO;ANB9D3OpP`GS}nJNE$}% z%Mf)_@i64N@f$D{`{yWu(O%S_6CKGO{MjFT@Oqe4yC4~e4Wn{iiABO-TZA#Bf1M&h zdJ^Q!iDI5L=Gv3>G;CAFq2f{`bW%)$G;-^cd&Xb+RkXC!bVwGA4qIHp$|)AtLnGmU zg>~&806ECwjU922EZc@b?p?OvG`;D`j*$waXaWponY$YK3itn+N$`~AzlOyQkB){U zC}Rh6ou2d!w0ZCxf@6$8MBMnzyJHW#wX8LODNYtcB-s8@ovgzz0};t+h^#qKXa(#oK;*K)3V%8=*?$=K_`w7CuGwP z2!JglV0Lzl=_svUA~d!6eaOBCn_ZUZd|fdnWidL=;*cPaW5X=LWw{GOd|YrX3Tc{0 zMdrHL^fV}-iJzs5bSpXnQ+%S@AtYA7)gZ6}Tl~DUt})c}DjyjNgWkRu7MkXhz$>q@elnnMM=34U|o!9(HUmaPBjY&mA4v0(>3ztG|x@5(79rT9B zIPS4Rg9PmR#l?+?8X>gkE+2wy-uaiiw-ML(l0cL}C2kpJnUdE)CdqueG|8wKG`AMa z|GKhV1`1d*W8&$oE~A08X*IZ@{Ba)}D4|US_Q0NFE>-kE^Rfs)IqH@}AfwpPg~{@& zLLuVB5(?aZ*DP-!k4;>BqL6P4%L>ABHXRY|>X4?|s z$m!Q=4N`nY^7a<#XZY@{t-5zLwffnqW-uI|cx{Mu zILwBGdFrl8iO~33y-Bx2$M#X+H$cefUCeTQWH;+>83C{4F3%ov^5M|Lp2bWNfl2zF zvFFfctwM4)%&%x_DgyF%_MHk^Dti=@P^y($SSBtgF;V9$;;<1CzXHgK^-EE)Ek)^S zeSzV5J0Pp8F>tXrFE&9zw>x{YS}7`&I)t~I$iQcwE42ZSw{-8RdF@qWGJR*Oip;{>OL$z#{*qdh?o$Nn)QN7R`l7sfFjsQP-RXY?{CsBT8;3z`@ zdY#i9u!2751X}mieFj@)Oe^-KQ}!^lR$sOVj6u9sl4hthU!r39RDjn|3P&(-zJrM|e`LQY z8H^c4Z8S=szPb`;+2@kfZha2sLj9lw$3zH&8wjThN z!e$o--{f#xEZ8Dtc+kTQb5qeTgaO%!n@1;^ji9V>wvV-36jXdJKebqSs`FeNFwcdU zZ7Juk(v7o!S^pgqbE<;;EL!j&1p^-{5E#^NnWnN@q9`KbR2IhbMMnG#6{#SERX8EM)hHCXC1e51Jt zV@szq073bL7IKua$Kk5CwN37$3ZQ(hxp-t+(EzbZ+2WUcg#R@srbo#To}Oi~qb6nr zak2W+KfDGJ{*mnD^!jvJ~>Khclxd(8D$ z{JKX9#tLW6jdlO@or(0T%;Bh+NJ@F5FSN5q*C33BMr?msk_N9{TK$webU3Q{5Ceo- zeH-zSRc{5+plWpo4<6_8*bim5;-`?^eC4ufX@H>`|DHnB;`G!`LV+siS}%jXMN++2 zOith?Us4L%VNDu**+v|yN(URR8|LoVKjv8e23QZ=>?GV-Q-zir{RSkZt#$v&+A5eL z8o$ra7U8=P8ZoiGlakrEvBoSZv&UN179qRFVTfY7z2~fv{9+DpCzNIDR7%WmKq6RPwddc*}uW&;R4%wS#PC_kw0NwiJLAhhsMsrG?G9>C;O?{gj>te`2mt#y|5ab~Q!!$?_|JMVRHKMqnWC z;y!gBAKuz@3n=MwkE)Jg!(@1aN!6gDMZ=-8RxMQbs!4o36ElYuc%jQ#Le=T=eeMy9 z0Hv4wESQ7E5q84=24D9Qvi7GNToP0L_e9NiOUl$%vcL3ALp;o~fx$E741N=ZX)xzs zj+m(-oJ!2Vo2Ic$Pk8^epA_fWyoiVS4)stwR|jc`!n+Vuga>i})82wX{S=r}TypW| z<-OF^UY|M^6PNmCQ)y!c%HctqQ?jRSSg0c2fcB$MtYNVT0DD} znVr?k`&b) z&*g5BMnwu#3gZFYOPG+dkoL}VKNB$NybCr=FC&)a-HpT~Hhg8VQv#I=n;+J^Uoiuv zku@^8Q`06$vB}UGi&B9KLdv4g?rH{_te{V__?+#h*6;20?>Y;wlyUjYH8EI)VZ-5T z_jq2sJ!OjIfE6)3@Z$;Y*eO7JT%FgF0021Tz#i2=9v_3nN(EWQ3;IcN8OTwZt1Lwm zVRrP|ae!DqiuQtFm|YG&3|&^Z@rLY&=s|+3Ps2$(oHh=odA0I%91FxE6UO^&H}m5Z zDroJx>d$`EqGn|C@Z{+C*S0^Dh@X$Z9YeanO$`t$k|2W!Xd-y`A&;}T;`X`EVMMyK zKKv&THPYDhy11Tf>-E!Q1vTk~BYOPj(}!m4i-~LAmec%4cOk7n!=nZ>Db;9;$QrlC z?Y|a6=%gVgl@!u4kLq^~&6zRdEq{yN28YlVx=1!-{;aFb#9A|CJiq6#`vIL!fu^>dMQ!i*7kV6+bZ4#DfJS8+6Nvr_jY!#6m5Hwsw)Mw$ZbE z?sBEMlz|RhyToHGo+=fTgNSi*i?*Ka+awN@)jcHRZH)8cqHABsSehRSl#1Hii06S`YCGez5= zzcq#gX4FQ{1TQUPs+BQMCXkzW7^em4OLS&9Rqi(N+Ru0%Neq@1*lHUhdNQc^yMni; z+*^FtI!_yaZ16oc*@R86`dD=dyQ2d2R^ZUT!V!ru zb8iv%FAMS&iVya}hYRWw_%|+-%`_s9ISef48M-s=HqemA%m9OJhj%W@JxvgcBVV#5 zZq7OCwdlvEh;wX(F(t~4n@R@Et-) zBI3~*2VgF&nGxdH4(}`TKmt`ILrYM8X^L7Pvuyg24F-D&;)7b#l+? z_t%MMTYANheO^8<<_C?R# zPYW7{Z{}I#b5&l|BzRR38MsEA5ESk=y{9WS))Q_OU=1fMC6FXc zoYGn|5p}J2vU=U}jcjm)wk)wK@TOPDQGZ-5%O0WniUsPofjiOnW{Y-<@&ES(6$R*x5hLTr-&Z{WCR zH_R_dwi_w1hG-|j_x3e^Oula3O+B!qCPK2>sEme@rmLp)@Si5}k)Q^w|%ob)u2*y`+Zk)FYki*M5U zDv32$UdtEH-t4SqvO?^Pfa=^yNV`gR%i3WRAp(tVdm7;MA6J^>%=^1iokr*HcW1wm?25(K9Xydg^HjZ{%?g@b*V5|Z8jYm$*hR9A_S;vODf4Sg?I6?A;i|;ceK2=oZTd@xOvX)jL%6WoBAd*aS@K~GXh*jSr z;L0~8xe84uD!7<3y?~_8ShjkJG4X+Ys`^1YtdPFN<#eMtm$X|URk-f_4ewIIy|jg1 zQjRWdB#|bBvRcS8OpHl!(At)9`~-&bvvFeEo2-Ve9%5HLG8*@bOM#^dGF(1ubj{Mg z$NPkgkpGoWFAO_V0*V}WzeRT=X^L@R<~5><5wSp$SLNadL7myH1Qih~X^g-;lWV66 zTF@n3#7e$f3(32sfLX%BVk}yMci7!sWsoJ}&B7Bk9>Qfl2cnt)+ezz%;8Mf)LM$kP zdQY`!Vx7VH(CD5>e`m&WdQY_)TjV!w0}04pQBG&Ji_gU=4mY3?e%#~P_ZShoYBeRA zxtb6&UounDa5bkF4P51}{;e%0x>xydLsnxe ziA)srZ6EwYfQ${1Es(Dh1_Zx>)7B1+cPx|VaY^kw?cF#mW*|YyHx{;#S=D{v_aimw zF4P+vdBMDQmLgA5s-kanjjResC7=BAu7 zKe2)JG1{BMDK%suTvJu^3ruzgA<_)HYTXkG16*;yKrpo{-xbebgU7f|mQ1fBGcpUd z7W{5QD7Kst^d2akkpg>3(8^=~crQg;uRM))!~|9x-LRy8)WlxroWbeF*8WlGUhwU* zPa;ce1Fi@S-q=I`lf*_Pc~P1h{qz*VUU_F0(kzByu1jVsMT;hcd zsJDJi@juI>6){0B^Gf!fB+e5_)DiCbRH#0jN02mXBd|fT=I@?ZshJz9e}PBwIynA^ zycdsg{qlY=3}=M0qMzh`{YzV3)Mb|w5nvtWBtoov-jRM1FxZesyOwYkI%5x#tfIm%U3^wrXPgvjTGfTb520A@)3zqJJUJT+y_K^DgN-iA9YD|JC zqt?E98wt+0wI}EWOp@sxerDM{?s01%&)Dy?$~fk@{1(~#l;z+}7l;BsC|#MFbCj*j zfcynvPNrRy{C)HU;c#eAs&Mn;S7h-*^~LMV4$Y|P-vG?~?6;Unt4B9s-0i8Bz)RA@ zmQLnyjR3WQuQQ5#ZB7IJ@Z2_YH$NEb% z@-&;hLD84;`MQQf|L6@X_ zTLNDB(jA4cu=1)Ivb5EwW%Dkx`ywq!Qb?yM1X=xOX-Tx0Q2&v!$mn^126T%V;tCJk ztTs!DR4l%PAcyGtYtEI98|)J}-cRYVHl+^hpejf=YU8xY%tFqs`E@szuhDrNuix_V zQYLAe(0}FoLy`vCh6gBsfj&AVI9zTY{>7K&|HYT-mP-?}XHIb8JpDmy@=5Xr?XysZ z9*)t)NBbfL^0kZ(y%8H)fQwaf)H}gN{`mwU`rPn1*J8OClwY(R%sn^;K5cQ}kZila zLdp)^wRx6AaubH%WSO0o~oZ+flD1!5sc zr!p76xCzXxN;#`2pQ>p8Z!6C;3OlUAefqzR`>6u=`qfz#0}ekbNqYN);bHlWt1G5z z`^%qB{3{>5n*__&FF234qsz(GzqgCY)>E_Rqak}dL!E55JRJJ>5>p6wcXMa}Sb(Xm zjgdy%O@HsBLIB2xfP7QnF?^^8#ioN>Lo&H+5~RdGNpHZP zg-+{ZIjb;?w7UNd2tdBzfD|u#ZYOo3mCfE3ez;xXmZ{|3$aqntMzac3~bbCj% z-UxH+u-W(Uj^e(uQkd}L4;q0_%5q#J_?>fyI(kGXs3hT@?=2$oRjI`kIdT=}9Wr-} zit-e`5y4YtNHnA17qg;|JR5`?10{)U#iudf*>=TxQ?A*Au>(89g`-GNyDlYdgDWxF z{dXJ&^3N1v-c_3S{8XY2+V9|k7H@!Pr5M^O%K5 zDv0zT`<3$9vs+431)&yfVcqG#<{fmo`;@WyuMi36tboBBn73e#n)eygEKmC{8O=l zSI281eHUh4bDP}gcGZh{B4{K!@LaN=r;8n98{Sf^Cq6}(uJyd9vj++LaVCXjyJu#* zN~nq%CWi+hBfU2s3c*4y$5II+OyCd?(Wx9>V5D(=Q8yP5wtHbX2zsd50M{TzHb0Og zL!+O+sCWGc70MsoJ%X^y?(VYJB$8dOX>Mp7vJNBiak$Ut@!?dEQ)^U`GtRRX%QjJ3HqTL%4h5^MXOA$2r;Jm?i<*GzaXrw2Z8W0 zt~Nmgv~8a8c|%a22|K)3W$`8QYj96#QAe6?UNt!9UNBT@_OR1?2_#`{4W)8ah-&L_ zrYIpexp90DYT^Rg{iwEjSNQ1{%l6PfktU|D#+UIGY0Pv2n!Y7N`z2G?F~Y1UtZzeV z-S1L(+Y+Y}>v`fwbsWrO@wB5W$$CXivb!!Af*=zQ+e-Lo%y+jQd1fe4yWI*^9LhUf zv>6DJzOYQwthU=^B+r}W;^c6Viok2q2RY0taLg*O2M^aT!@S>ZKqnIkfGxPfMst?$ z%jG~Fs_!NG@*20U?{)w8CbqeGS!^9mp%466{Li1s{__X-Fd2sKxAK3svR)T(7=i%1 zi9G&0zrAB%7MIOZoi~yJN6zvIRpATb+vl#-s@?KO>;HB}g2DC9+(}peZxdcM&Y>si zrfLiWw7iX68?^qTt+!75%4r1YDt#WqS0BwJu2`xwAIFG9x?pCz=?PURqSY(KDT_es zYr0#>kflW=GU+k(y)#i%rZfUEdh8;FI}cTU-}>clWMO+K@XbKddNH3H8pux9{YGT) z;Btn?X-4QSh}`^!ZKZdBDk!hWo;rLNj$9UCPLYC>AI3v03JMc!hPgl#zY)Xp^^WbB zM3HIU{CukWMekK@|D_M<+-ZpA3W7Q=`nEi+qf;CZ)>UoWTU%>g$K@D6cV7RHF$_*fqT;9>J<1c33OPtJSie082B--h zl5@f{s*hyIeolHdnziA8ogAOEfaun$g4>$ zZ78ARph5+pg=MLQ=m}|59?O9@pZmnIK0xCghf-&%K}L~x$2U?K$Ktviw8VDDcFR*F z8kXJyO&X%Ek!tRUB&jp;*Et?Nzj2MG`m~Ye@o$2#qBGT=_zo+>i%E$@)_WJ^F-%1! z*wcxJ1fPzfG4ffUT_kmUk?DNi{79q>UcvPX)9znAH}MmQV`;u<|G=xcbnT;I=zSCJ z$c7IJ6W~q1Z~=a2Q6!kVe{YCa;4?gpe=dtWW=M3*)xmUr@Z}RcGH+*K^IrAyDcz21 zEUPtrb!dR#<=25Hy6qgAwe_+((!RNfB(yY?pS3^RZ(Uwy0AL&oD2lNYo}!`{YyoiC zaIE<>6f}*Q;C0k8!4=HEU@_)e5BL!*Q(%VGn#}L zQv*j!JFK*3Ic$r-qYe7YntCO5Pr3xfij&-!^@OK;kowMj{|$iPgC!=+oo_*dF-%Z~ zAj&h7%&h98J*8<7R<)o(+#or1vpp-UZ8Ld5A_xs((Ngd!v)fq0*A zrUWHeF9Q)dC8;4;w@q}|D;OOl(#`Et?n;2zLtbU(XC-#Qm<-BTar3kuJIbu+-3XnW>PaBXsgh3wCl|SLH|KQj5Wap)E(`j?w_;oP~td+)J z=bhUXXZn$-wMslumw)i4(ZSRY7S%5J*Gj{C2tEnQ911ep(hv>o#~irP!jSCwU^)8y z`-5T^5PL|zZGdmjO;bD-a5$+%yxl56A$J>l&D?zqj4jxfei6*jeU0G4^$-6(sn~ z%-QaJIIJJ6YdFwPC}h zXs%H?lvwrMq|L_4*5k@{%J{-9)4pnV%`x`NCScdMpg%ik5rY&4<`4Pt(XlX`T>wwW_ye&%_rfLE#qnA` z1<*6Y3xlot7yIN@)fhsvJW3t528Y~GvZn(erFrA*N?-fAY{6O+QqHMYqD26eW{+}V z3sgGAU4C4YVo-C2Jes*Tc)ViRsenTrzg122*_0{OifM`-GQ7^B)x{60nAT4hp`Gh` zP*`-i1MuyoiA0gRh>8iT1dDMt_ek1!SX-aT@^p09_yZR)KA#QbF*FQ#eNq1_g7)Ce zJuUzowNv(?VJZ|31&*8IYj|Szs(*bMup9H3p~&`Ab*~Fj+=fb`Ka~)BEW)G^|J!^Q zJi>tf*edlofifjP1YZ>aK@@8}0?U}7wDiLa%!7^vTfTB@JSpmkdg>ilM``IRDTw!i zC-0WJu@%twT$=9ou&8J+%ZQyg!7JwsQ6G$J*=N=Q@qr@kif&j0lN5gtdOwf&-g+Gx zdYjqYHt{}&4#S@ex$FG~)DTgoAHo!r0j3+RAti5j8;C+kyxy*W0b5 z+!Gxux+9}iZjIw#agj@AGGCb!=|!B!5@IIE0X)Lv?WK>i^hb)_s+u9;xtp|~H?hF+ zzrr`d_WRviezU2|2LL~Du$NI2*Z5~1mKiW5rrjWSk3GufZ+x4WZ>Pd+;>2m`{}11Q zJ2)ntA9pV>xPRJB@(hj zy)781A&t>J#%H^RnW{P6e;W!|Q_%qAJwG@_t7GbQnsdGKE0(h%3=s(kHYtq6Z?SC&v{%fK;O#k>(8{+%Ng?2YaS0C#0n5 z07+TM_fq3l4O?ez8VB-#^qbH7xb5oAV)h&;)A2q%g6Yp^rs!^VcU2BReHo+^x2DI@q9p_^%7U*r~F86EUCj;cTgVy327MZT9gQRxZIw&X#hqLWFqm#B1r@a*|xj``F)r#n>00HlK2vzRJLJDzMW@RHC;V zIp(HKL%=ZUcEL%o@tlO;K_UMdtKLT6Br%--O$ny-g-u@yw-8}X5JRwP_hv&7bBleb z>)2Se=%MVqY3##Dh9{aa|K;7O>yIMLR;2cXg~=2cTrh?q_W>2hY<}XU-YH>#wnYoD zAv|a?FLANnc_2S_J*fv=2nl&Gq5mMzElynN%OkjApTgTf+^_z%I4~Z>GE_62Z`8l2 z6)ZRX2eolxtnQHWSeqbBC@^TG3|E5)vHB|)z@De}H(+w39*omuGPHFg%0F00+wjtv z4c82BgHi4x(76l@cP5NWn|{Vvk83h%umS8;TA42Y27FP;5t)Jl?~6vU&ZzsloQ+Av z;kR7O38X%c?KpWCV;)5M;k52I;&)wXv;OyxT~^PF$*wUw{GzC(j%OqZx-ikgYMXw7 zeiD4BwzoyGfI@d9QakX7G3v(;H){l$%Ouv_#-zV=BORe>geW*qpf>e zOX8T%w$C|B(E>+BD?Om-`Z%4cj zF8M9vRcaruC))kKU%Zv~K2@O|wG02dTeMg6{h;WAsQAz$)zYA8*XdNB;Z&~P>#oVoQU4~-Q#25Uh64Yn;@9<}3KmWF zL5Po-hJ`|WivQ-@!D6ia^S7-ZbF5LC$^ZPR54KAuv4$4w@}B?v_a_*Hd$gW^Mq966 zyxsM$u8s{Bb5*j@()9HFsufP%xK&rzVIij)=DVDIMt^m^cXRs~cc3bhis%;I%2EN^ zN2$xm$XP!red?XO`jZ+w>#(}II`cedQDo=l<`!GyrRwudsuyw3*o|ui*gO-hXOL?2 zEOjZcalwekwIaDTCty+oR&Jkz`!Ny>#rW>N_^5;P}yvl62sXC;+Yjici`Qzo9@ zdu6TPtU)o$%X?q-z2CBPbXwus))ZU@-8W}4Ve;in;WvOw5ckajXG}@_L|~5iYySpl zbn>P%nbff>_7l;DKTGBAl`rx~gkzTbYgr6(gzzURf!@VDg^}Wz+htoqOfA?i5RX@x zPgM-%;8%M#^sIKGN&E(6^w(PO?7OY?JsF?YZ`7VS2wD9^lM&kWBPs&}B^f=O8PM+Z zvET7BiX6@a632O5ui2|o) zMwyHGM&@)zC2_UNVIk7REZ8 zKiznq6B*gO`()F4;|YxsyA#j(sjqiQXZgs{jhgq`4|_MeplQE72b;))9eZ5!8Nd8< ze$|g=X?mm1%}C-&IZ59Co!BN&r$W!)B$4=xy=+wL2;%Ha6d+zpA4_t(;EZ5I8U5}t zS52=sP4u97S?@Vln`FP`u}KIvYPHzLuXAgScyuZa)c2D2^+{io-c;LA*~w3sn}^8?RJWilR zJs}uef?Y?;eo<(GK)pchk)AR9Y4~L*n&Wzo?fOvPpx?vq-R_HK+9kN_4$e-8rwabH zcO{Z;T#?3iY8f6b<5cWQ&VZgui-NU2I1!RNML|Jrrrcfj%WR@nGw*37XLYqsvo?I= z!+1_6W_ODt6%YqRV&j*X3lk&a-y1(JWZZPe(uUh|I<}=lwfh$nL+0as8|xfh{8$4 ze5xmMLra=Zn?wqlf^cm?HcC6M>_4Qv9eEYxnvxx!1BLmEB%3Q=P|o)@JmwVrm(dfu zS9bD$WAsVsA_Q4;Cm2x_|y_2evY3C|f3f+qtPsg@f(_yCD^nykodzC#aQth&4 z_x7m@p=v&VyV*OpqSIZM#2*oR=?u5KClOog$OFnWD(3;+jnJ^Xk5cb*y0 zy!>6UZ%-tfopi;P93vp0e2kag!daNgVL3T+> z%MP!G#Tlhm_e~^FhKP6+SF4pe=$#6_gX?Mnn^{Vv5(_cUv%Z1;cC1c={gE3;T)5p$ z2upvDd|~5>7Cc(e75tg&m$r|`PEos;2O`j$gw#ljR~c~5lX`(IPv6Su1#Tp{&R(-! zxX~d|AS98YE`m2n#xgbx1J*X4gqnZuD*>%eg;E|iLlf$6l+RUL!ma=FFG(+g z=vRI0t#AD>uz}5VIEhAjog%{Q+AC;(5`0bi4VY0c@Q@_U4S3C4^;3ARIuv$!r9JEV z`nqtF*ZP)pFKJQxc5uxm(6#Dk+*M<+SaqU#$MF2_HS(?hqvw2X_Iq!nR1lSP0)=Ej zZ;5Z&g*ni8MSgcQjY&QUW608pS>0^F<)!JUEA>_LYW@40&aJ_yZ~3F$yx)hXs@<+n zhaX~rW9ev?2Cn}fX>T46)%QP+pF1=Fki$V*((!TA-y9ESJhke}krUt}78G*fH}$I*G)~2uwY^j~ zlpFCS=bwKqoFSdkyvEzB9S@IlUFFU<_#Kz~Q~rZv%z)@4IG(?WOK|Egx>0`0TTow3#mix@ITlntK*(q}fUhyKD(BSah?OMN5Jgwt&|e zIPAr3(ysdx(h={^J(2Ytamw%`x;mNhF-8a~d;79J^K|vzZujK%*mGwShPAIEU;~L#Arm4m|kbY;~_BjV0=3=<$@j`A2?jbsP@6v1Ut7>UNXZpG0Z8 z4bE($-v8;Q+=3z46otJu?jW+R@6>nU8ykYrWe%k?4fnTazInBd@6WmQ zWg;j1PNwwGHQTnc?@>HIyE>|iM6~a9L5?}zR7QpQI}>n@P7QE(g0lG_*2&>h+s9vi ze=C0H=PuS=`>ft_oO4Zc*ktvscyU|vE}tn;mhKDCb3 z_I%ISe}p4G&sDkFvt#Jg2BTA62HEZHewcjc$3u5^1YP?^-dn-3uk=*!61OlhyCt+; zui7p$vA+4Lp_2QY8ji7Zw(6I^ww>;hMS8LVH@D$|DgVd;!FM4-&zE+w!Uv@3qiM>HIP+YEGBCX->j~ z{Ho{slk38cub-u*G-ho(@GH3#?oX|&ZWJpDB-_qen2S5i|yms1oJ?2o^6jS5&B!n%ech;aq=g-sSKd+Wg{jY;n z?y^W*_P012nOU>%t$)HulPTJhDPwL(;?+}5+b=ci6DazG94Dc}1qY(8<$7PsJO9Zb zDXxQ?L;Xy|joSBprqUM_p9VcSBUW^*Q}(j<<-<^f#690d!uJG{dSe*VHEt?fk{ipS z44id@?{0Z#e4oQaY(wYIuiEkZQsR>`N9XOLE=lj)#24SjU?s$Oj1KP*b&_<@dL2Lh zR-X5s$2L3u#{tUEd6O*11ys^=ZV5bO>(nREiAH;lbbU_3E(>vdJing#T`)YmXt8OM zeBjLWWZlWg?s};=!?Tr3d1=I$Ak`b6?3%x+!h7IOw>QO24@Ima@CMuAd{iR&M(g{@#EsZ~P_LJT zCs|6S70ul;Zg?i0B+~6k@SDm7Z!fmgwbd;>Ud0V+X<2OCymD;R%2nUp3a_l4RjOFm zcf0$g-Nf5sI;;QY*LZE`8C3sBRtTicdwNMEw%jTn_u(idkoHN5#F(wZ)+;9Gq_7 z70@*?Iyw6F;P;{mKVpOy6+^CUljdR{9Yg1KyCe2+)Ht~{C ztC$%p+LyRt$M8cTyqaA+ukaCnr83!^CCOV!2f+A!d=0;z-5IyzuO;@IHJ-PPy1OM) z?7_Mf<5c*h{3+>E)J)Hr3dhT=np?+3vH7>Q5L^@k7fI<{YMKn3LB3Wa`>?%2+6W!Z?ff+ zb2OfMmq>`F{XK*@bjD+%F!9 zzNhW;Nqbjg{~3{wfp9qh*(mE%=|tdmRL4Q+3?zgZdz6jC&dyJHx29xQWi zCt3J*uyUO5>4KkjNXg5O=qneVudV!)*xmK4uD$2Q27c3Lt*5UQnu_tkeI{ZT%siTQ zOZ9nAs#$WF4A1F2KRy0oy<#VqXyU*HJ+crt{_JJbkfT!C8f=@;*M>j)SGRUQ4MJl1 zBew62IKAV)4~0wka?O_hJ}$hkA(__1A#7l zecsz&IV)c)Idz+Jju@Mqb?<~?*Xg}8zyHs%N9b3oTxc%J?~vR3KoV#=R3}-uPbo( zieVML|5(mvUe*1BNhvFd^;N9t`UY0+&LG~~?b}?U@2I1djTy^siS#?YUX!n4H`&K0 zoOcgNlWoowdPKNS1x}8}66M!-sC|_S z-)po_@_wugg^v=3uYbIQrDBX`7tiS&j~7SAMEuCMC1Lx6w0HlpPz?+-$QIe%8veYh z=H#cxXVl%F%Y8V`c$X0Qa@X#vPOIy$!{Tn-d3f5^R_UFXMon(7Z8!JRf+Nq^A2j{B zXq58k(UVwhga;85Yh>B=%+#a<*u43(O%2TEgC?O^L;pr3`M??aAZivK86IbKKt@e*fU&cKRLd z*~7W}dV>poON{oFdt$B0Y_ve-`uLHHSw%Y{$HPLl_WUs1`g+wLK3Ld!fS~WIOJsYZ7%N5o2$?I z1m`!45yYOJdgi>htifbht>N3VypQ zG=~dbWdHJ_;@#3=A3t3r>4;LPK z>qtDq7{q@t2B$e!>C)MPnc6(f-j<0um5<(=KNS{zzn2(9I>F!n?KaQ(hO3l@T(nPo z;VMxhKWN3>Xz5$=F{?k8wyAdOw3 zXSws@df^`@+x}O?ShWT&M0_y(EHMT*cLW_Yl*b-nX6NT%-te4lDKp_9n6K}GcTKG> zZ3)vnx!*BtGxqp&rCUqoCmQpfr*>c=%t2&Bu(?ULksw)?tt|u_@ZLsp@v_I5sW{HQ zH-V4zp}rYLhc0X!3%+{^*D$#7e~+&V6(>4B+e|3o;H;Vl{~~<_$an@(?gHo#k%PcW1Xn5mxZWMoyr{g9JmASG z1b~JjsTl9{3Vz6$IE4;uhjtR!O=!k?0K{1Xz{Ubj{Z;BkNQel4n*j zA)ksg0}d+f-(q-@8atwm0+YjQ;v-bRf}*^sumW*C$$$jR;6Ah17qg`fMsYBpLJ(+z zKp;#-0bmt+5T&A#tOVMBsX$HiLy8#3R982Y0wIJ15E6X=;7VZrFhIu892!50 z71E*FM^S`C=m2aexeypZ9F>&>grJ_z6~YX##sWNK2l9!y@FW2RKtY=UE2N)DN3lW* z7M!I*AA@FPFmpI19Ew22Awy)WkRJ;GF9Q;#16-jdV5c7i1^|a4hEf3p0v52A6he;% zNFWjma|qoG01E(EXn2eW3;aRe@Q^;P0wl4p<5Jl`A>gF}xZNCl^#PuMRZv02Dga^) zyOk2m;b?r2B^W~yAQ7fQV46o7fCL~&BGIpgqnIZF0u+ZygbrOAwhpt<&_+y>(lA8@ zU_YdX5rMR^EKr7$D*+O2Oo0TOU`HVz5Ib7PRERbmRwe)-2JnE&0D!?P#MI3s0u=(; zfI`s$kOFMLggpqj07r-|#LxqUP$4)>YFG;R0GP*Gq~oFC0T9K|kQ1m7h=hPqq9as* zOQjbAA}oS=2m^%(aA7c$Ff;=sMj`Y#1al|AzJ|wh(wS_)N+>|6fJ~s5RNw%CfgMa3 zC^U{;OTm!D02L|#tV3vUGdN5?Ou$=E*AgHJ0DC||tmg?N6qEN~Jl%x8R>KIMMFA)z zE<;VA7L#aAD15>XJ37GML=}lz6wA{n{s19S^igW`n(IK{_>{U9UW{1iTov*F(AQiCvVpQQP5jrF7yy-b1USS1h4t|+%ro1;9zbcpl6Vw=5+HyC z;*${Q)&DXQj;U^q6UZ<$I*Jjj55NP3+JF_IXiQEZ zO^6D_K_+oD*rWnHnF^H%=~%-mkTntvZ@`~DAWPDjT7`62QHWsI&n3Ju0!`UV41#+qU(1crYg6X7TV1d~`8uoEHL0KqXpI4>pu z3mpc4_FM_mFanF9vCAQbjSgfPLRgtR7C4Wk0z@9L5{S@huw6Dxdj`fRP(|UK4i$oH zPXY*3BLWyyz<&b}l;D(yH}C}%*aIq+)ELI4T$diGalb8#tJYw^qER5#Jsh z4VFSFSd)+_K>CS|Corc)hgcSov$7RQiBJ##3MfFH!UG6lSnmUXE0Bo72!p>p1F?QC zO+X1Ka8^kMzz~iEoKzGRL%>9apjSxZcLiercLtLb5d|FbSW_6VFheAr0CU4n=!0kH(I z5rK5|I`M9>f<#hK2dK|hOf-@B$N9na#$6;CIfz{=t zfs{!a9RNB&MiOX1kpipIO$ssBfTIo08Wy=pV{K3j$Wl;sltl*gd=yY>!A7{kL`2%5 zBJ<(Ohk$#MVF1g($~Zig3dfB?=no0i-74E0fi9;QB*7h2o(kpkgl~Ds6aLdFug2+rUIakA{HpqNsu@ihE@WU{J%AU zD>K1wm_T>O7={d3qs&-9o*99)oDj^=0H#N1Vit%~1OSd9my3y}nXoVTJduspSg3UJLRz^M_Y;Mld=1K+74GW02wXttp9 zdQ~O>pGcq>X-`}b>fcpCjoyQugM(`Gr2@=;e*x*DVUq&zN3j_Vla~NXVwt6w>#hL{ zow<;}236!60YsvZ5ey7){0uDS)<>rRCP)?3 z6@^PZfdKR=MbP;W4wNne#X+%}Yw%aS8<~t@D99Xa1XG0w3fT}S1YmCv#ADiqBib@t z;B=qu1qcnRxkl!%dPxj`FtAEYn-&6Wr5X!Z+OZW84pxSM8IGwVu!CXH3#_bpNYwZq zy$LckeWzGZl-_D1v;}#!NAK=4?YljClWju!fRzp%hCqRfG?;*}MhRNSC>cSm3V{%X zj^PMYCShnX(m&!u|6coYH^8s`IV%Ejlg%vPM!2`D@G0a0! zaLq#kEJ@f{ib4#=ClFAAQ-TY?1@gj9L88Xenn?Myrkp~3xcVYn>l0je=~d_xcq}#g zjLEbvctQfyI2}s8rg)4`$c++gh6<-NR>01{5SSovkpO98V+hkZnOtF|@Z2Ka3g!S! zfagJ=V}Z#>Dhwzf2|;0}_!sER1SExn2`B;?rkJG&1G=ju%q6T5p}=x{5+K3|_9BGD zC}*CD1y~VuPCAn-NCz|1T4_0sfeFBcG8Y?6g%Dvn0b4S`&&yI^yAUuvNy7^_*jVbC zZcz-4jUlW6_`rT-0=)~iLV(;sdaQ?GAt$6G54Dj*iXqcrUbRigCYEB*qqm$wYZ4X< zir1^^)T` z7?_N(Bdh@h;~4^E4I4GrO z3s8Y;O*4lt`oCPkg;f-(&`}<~g#&LVVuvI4mJW8z6DWq#(hSlvM8a2xpjtXe)H4KF zlN#+<0V@E63V=%^fro*(K*=CTGDB#MH0oLkO2%^x0n`Ru zhLKq4fEUGd!i2+shq{)6GP^*h|LX$~2UW!EGBC+tN{=}O{x1@{!eIgfYaNpU;|iu6;e|7Eg$7LMa1O1&*aJe?X_}E(CxbOH#jgRB0s|UW zfSrvw0IVPg|5|}pUe*+rq%nvG29z}T4+|6M0C9t34(TRiEsxC^On|z@s!*9`!bmaT zA0vpueF2frYSXEP`~5V3dj}A8G_I*(_fH za1soNLtJ10D7_4TY@pImAWuiBG3>OaXb{a_Ue!s3Bj-w|!We;am_RX3qNaWja~*&u z&;Z^aBozXLr5on3DT`+u2Hrrm9pDzRM*c$Knunxff-uzyL)Zl%W5B8xPmrX?2EruX z3>0WfyAWV+I2qVg{{;p!0XgJ{i8WH#+(!YB4G4&QBQsv(6DC=7 zF|HuUNU!Myx)7{JgBbMRAq`%+V*-*1YHRaB6M3amsR|zL*ojakI3ui;G?Hn35CMw} z;Y6tfXGvNU&}X3-kfa+)(!rfdQTTtF!A2hVuL%y%$^!pch8lu^@Bj%_eH#kZH7F{r zh*sJ7eEH)z-38u!AR%}J*-n8wm3IU_nYqjL=$pnb=?J`S$~C9#UZYfgsh;4IpK3%d z9cWitzs({1hxOdAf(vZ>%s22q%o^+q=6YVoIR5q?f7O<#7NJx;Wm4Ygq^LD%o0L}N zk(ORH%^NfAqc#`pXLVTecOR+75m6AlRl(}C?L0lIqfv)smAqOiE{z8JK4|JLd6&Y= zS3LJDm;Zp(+lQg~^YN^kOMiURnERKVInpY?*$$R0K z#GYhn)4ub*zwstsQM}~vqt%F_LKXZ+!za@MahXk_*C{8~C4G5SaaF!{*ZBtzu0O6` zVdHdCI(nm#>mS$&F&QfOpFCE#OFBZE>O!*(lS+BsSYE?hkKy(nWmm4CHf?+Qs&lm1 z(3VC!D*2)hHk*mzxEs$C@i znA&2!b2(AbtTXVm^NAxS7xo1L-uuE`3lZ)MCK-t~q5Hx$#lY+l>k7TpMWms1^S7Om zzXDEu5`s^j9^we)SKt^O3`0qyV_O4;*j?|gZVuHFFiedByfH~dywbAMm+cv>f|Y75 zi5$w*xOc*GO_I`yuNmw3$sD?8^sC^EUeN&lfrv`VQ|A`b;|Gi-`eTL^On%9yUvSWtwoQK?^J-(Lm}&Ue zeF;bP&gbe%FT1z*g$H%c6-5Z>JLQBH8ti>F*rG2qN;kB;)*tY%9h(AXeBSf;jB+*W z^ykK!q~@`v-dc87=Bq(6vrwbLK#|)%6Qz4!gz@MbS%Vm#&7ix|Zft+#nTFnk543Q( zHxCmtSK7|nZlXr+%GLa>xwR~6-@*~L1;Zul)a}LK4(nSfbHm7}(VZ>ET{;$F5+7@G zeA?;L=Za;on#3D!GI%XGZW7I>+z3&e!2(rEE*u||EHY+PEN{|R>0#c}c&&IXm*-cV{kZKs zU+~Y%gq&1MJPr*S+v+=H%{%S}pC{ zHH$2?(O=z8jRLnAi-wnUZn!R{%B%UtOFk`U&Bw+rdmO`FOJJ5A$5~1pJ9t;6(t`T3 z{w}^-S?{xLQB?m4(X@t2#c%j{qwA)BTnGG*db8!ro|qr>t_+@!Pz!mKxbJeir;}R` z@#lpBd!1W$yFOmX%&$9-eCp~<C%fABo$0>d#;5L6T9{&)XU6? zKGBwtcy8w@Aur{Ddv~)%-NEU6tl(aB^FH=Kse8*S=e~*U&ncGh-9-=P{$>_%CUpb; zS&xX#)spbdDxuFq&vBfn8#ojnz|~k6vt%WQ7#SQuzMaxKt-deN&5`(c_MyWa{B_p& znvHEPiYzQc)svxPDs>x_#C!~kYC<~tbnZnt95fV7n=`*wW%VOp)TXKUvDi;}?LmUa z&}cxyh0UbVLxQh1H}^PQM($vR4VI2fs2)st!#|xJPV9vznpw1N{zCN(lm^_ z4f32URKn|{mn8?fUXu($))zQ?AI!MlRN?RPGd+>vry$<7-csl1(kZXo1MSDr5dsY18+c)otJyv=bW&|8sjo#$(m?XaoDonek%_)zE5t0!Z+`Q?J~ zZw~97$WY{v{Pw1};(^hdCl6bkLbjQN_ZGg>`+iq%`IW#Is_KN{`Vh|(w3R=j?`XpH z$|jTf;atM9ZduJ)^>b<#FP(CXKCKtqH!9(a&}gUl2x&ZKo}x;uY(XBH$7u9+Ao| zl-FrKnwV;*v`IZW-r0D~hq1R852P{&T&9`m3#wzf$qSZ2R{eh!xXXqBeT(;-> zQky0?J_jAIe|+`Yf{62;wAz#7DzZ|gMwvo1*0}f-uE5vjX$i&`P32rN>HCFOOc;y! zuAl=Q_BIxwH8h{QLZ$t(sevB9Qxj4XJ$4eh-W&~4uao&A9G7ZqPF`%+JEw7OKhfI*wV3$MIFFd}PYhIJDGF4tpPm(kj75Aovo!xLO z{iLzv?2Q>=DUSt{lzu0F*~~NMaEw1paVk0@u-!>B##Nq940u<)$^4R;%&n^mx2_P` zpKnP-ge3O?!f}mEPY}ofp2W zJTOZS6v{m%*86z6BDNU`wFj~@QNBj0pem7%5NKKRWk}~AP`_Q>?&`Cal;fX%$IW<} zDB1XXCPmc>wBDOzJ?4mJ9ssu-EJ8VMk-M&MuD5o0W#IgMY+Imqb#c&n#hWufwqLz| zg;P=&tbeC1{NN?0fl=ed36Cy0;arfK86Drt&|7|<%I9mC%SB4PWF{{<<)sxu8`Dim znysY{*>@D(O-+}nP!N_^Sh;VwQD)y}hfw1vRW;%B+`i_$v9Z-|{qv1mVyBy=B3Jl; zOau#FI^;`oc!|k2#cx4{t8~PaLqT|!|Jq>2BePa6&*jZ2UeUXJBad@Q2A|sS=yNNN z|Mf*KJl}oYj@BrdOL65gzR&c&>JeU<@rjTpSug)gc8k27?Ky8Rmi5Yug41HW$g3@G zF4R|yvbSbUWpxyAYebzcaw>4Co`~NimnCO_V+33|>k-qT7LMyY4MK%?@bqT{xtT4&Q2!BmkFZI{09;xzIZ;1kl%nrN7OdFc(pU{s}}3J zUcK=?`I2krJ_}_~H5!GrLPRPrg>-dQSr~gv2#P#* zyy2Fd@3|5yGqTI+%12+8atFDTLQ|<)TcR~+u>+gE-8&`YcgLFw+u5xAB8;6A8(!!i z%zRK=4wEes@NUA`DrJ}?8Dp~&qT*&-seM05Jp4S*@Y(6-E=L7s#X=`{1&q1|i#SS|1X}j9 zxm67Hi98kCJ(FD<>&V%jN8Y-sW+|eP-PLv4Wwx?Q1rJ{v8d7-`8JSL=0@$GH$Ws$O1T@w(=_KT}%6*HhfI zhmQHNYZ&=iu`WN}RM1_S-M*=p_9A9WVmOi-xiItA*R*S_Z!mmBB9|<~tFU9=I{!Ob z9#`_^GOFgRvY@J}LJTsRwF$jxUC2%)oL`$F56_WrS2zMrUAwlx#<>pv#WY*}M*m^C zbel#U-?+l1I=IL@=hIkQP49Eb-dq0^mzEh?@XBGdIY^vcm9DnE!h%oE@0V=6;GDpi zB0H%fDJAg?El-!0olYHVefi>v7$fb4fBx4!vz6Cm$j{nagR;B~x0tl=^)EZ-_@d3< zz1H=^#S4cAhO$^yyo=@ZaDLW*G<15|BDi=<;qDEiZ`YYxT(C@U{5E;ZOE-0fq8%v} z)bVip@&nR^$>q7kYTiKK#b7fYRh~5MWX&B>YMSep0xhqJt|#p>VN6tOU)9>3S)|Pc zFIjwT`Dl)gV!w?C_y~6zZ&s6EUe+B`Ba>b*aqX?(`v=xB?v;mbr1ct%E%@=CC27gd7o**3 z9Cy0-jfRwytV@;1aoQK^&uP3}IW=3r={3Baag=xzPr(OVUS&Cw|wk z2i%pUEyM>SEm!rTwQt;9br|8%MWgMC3}nNvj&~@{`ub{~A`S+5<-LKX9WP;}aqkY& z=8WYP5h5{?)OFOgO|xb+PSb2^tXKG|Kl&{VWzlcTxxbLn<;87DZgY+6ye+4cHhS6V z{JC-U=;g7>x%V6;sk96<|0-d+*ev$JrM;O;OOnm;mU^4i>3QWvb}lpm(mupGLGJ;$ zsPZD7_!FNuYZcYf)T1nSoX=`9u_MIC4tc87762A`dzE{q@_$a*MxjrFM)wto4J-DZ z6TYXk;O9yT$iZj+&@`>j-q==U)pqX7s6us%-MU_(h?1FCye$G+s=FC|O|7aC#F6ui zS4&F$VI4p7Mm5kGWC1Q{YoEB|bF#(%NEcmLCw}Oizs1wZYkFhByzi}b)QzQd7X{u* zR*rHz#qqy2+D<8Yi9Ea9aO*{j;};I$N8T%ZY4@*=-5ovI(S5P@7X>)kF@(#`D0T5q za0n?49Z~Tg+mMLY1MI}ASmX6?2L@gz1u~wo;5VPoLcDSMsmtll%0~fb#kkk#@YcWx zktO(TUNOVr-aMnt{q2V;E{zDB@>%?%sdun?+<-HCrPq{YR{f}-x-x@UimH^ML1mkc zRf>0V;lq|Sp<|A&oSON-N!3A*!zF5f)Q)=nL#6(CIkA(eiP|~&M|d^rN+R?298&ZZ z5X7f0M`#M|DR$P%d0?v79aSDBa@NVn-z>u9r-7r)IkUPYdWU*cK3VP12`iyuZBhlI z5F~UnK|)n)yRB`ok~@AgFY(AwOZ2BGYJ0fpterm~&fq4tzsY%im5pnvWq;%Fv2Shb z@3`&NdmonY?k*QWCn+=fsn~^G$3LDLF>}AmuJ0vxK`IT5`RPW}E-OjA*cJQX57YA^ zFW1%m+qp#NcJb&9uAkVl)TkXBB6v(Opl(q(pq}R~_p>JU1joQ>@wu5rCDO-k*0}zE z07uq2+l14!xelGI za~TyXZ==_DjXvaNy3(QaubRt!_#DZRC2 z$HER~`qmuWzP+J3%WdosTTMnvWEd^>gPQn$s#H2%PdwI)v$4@7s5s{q#lX$?x20uT zypTXTb@#sCE&^$KqT=}X-8~t^KQ}5r1f&OjkL7tTEdDvBkT)py(k(uh#fP0$hdYnj zzCP0O(@$k1jF+)SaMC~q|unbR*Z z*R3qy;5)nwwsPsIO!vsnza<_@Acl86>)pDmr?KLw=Mp>A+SRs->SSZ$-1co9&VRDU z{CsrNO^j>3kuLl|z7@WI$Bk1Pz6RUceBBoM{raOeb=nuNSX22Yb#kHj5f`2#TZ{J$ z1x_S>VD)31NdLL*q@G?HJ5FSL!AI?d33{Je;pQyt4twpp zyK#~JtvmTrIErOKmLDnzsq%$&zI7uD#u;Ba&jv5roS zc5oLS&2sRX5rbQ<-MjPR)Vt3I9|W+eICb7Sg&0Zk2_u4%2v5={%ZQK%x&9K zhw+=!x~=*5#_piO(|m$@t!9Y-A2)3mRK2lI3#i%$L*x!{K4BzLV)A0j#Wdbt{~={mJY* zsAc5{cPpQ=jk)hxEO*eHtJd+{)`9st-1GzSInCNB-#^*(;qMR1;);hqy-pfVetxLF z_DJk8d)>lgpC0$rAi3=`H`uqEFTe4A@tHd_57uHk4J*v{R6|dc8n$u9M>w1e617oW%h3S(Aawi-;TlOw6TLp zF~K>4=YET9c|W6^YbBT2!*_S}*q-rbIYT|;d5xB(%lS2&_o$yv?b`Y4XP|I-iVs@+ z=`rWbrGvIOuTHBfQbbmW(B ztBzRowoeBP%5s{jJtp*0!;{lfLhC%#Hh)EdhTSaf}9}&#!=4~5C5F3CBNLc znQrsrVBD(Jozf`4`M9pMZq1PX<5tqK@2diBYw=-*J;&4&{FEb4@R^kc4M$7`4Sdsb zGbgMS)86t_ByTuw(f#rm#>NEl_I{5|A5yc*B<%jYegmxvifuyTh+%K*fHGh zbCuk7&$xUh%_2-kA^J^PD{D}m}@P4D5*H_7VmewW3f%FKbOrQ0o zcUNaot;jlid@tT-(+eGipFkX4`!C}6rlyGN`vw>&adyeaf9%}Mkv}g*Q+@Irx5

o)5qG>Rvezi1bqK8 zrtTp20@*o#O0;{+qiY19bvnfM1mU3i!7mj<9_Y<6WaBbBJ%8Oja{pdM-8kdPJS2;z zBVtm!j^)aJ!0PT@Td`6QBf8DG=XaCyUsV706jKXp z%J+Yjr+2wx_~;;Xm$nr6P3RS^(e-7@!dqV4Jy05TcA?_gCZH-^d90x{mp^)MW!&_a z65ccAk=EMoQM+TBXD+~f&~{WmNvan1R;fasMe6(gdVN5S^K}fXWP#=2bt#WPOOd!@ zg^Z*So#U@B*0#43B~9`#9N`8cFBt=QUU$8>&yJwo9wB>JHeCG{pt0~HhXUUG?za5J z6WM+G)syHsJ9S}g#RbIMYT}@9M*?SxFaN_7u2FT|m(JVkk_zu={sg}pHJ$7yh`%H= ziLR@&9$9ryV)Y^oJnz|DNR*{drVok-dp@w(vMFWD?;qkjixS6VqRvgNA3&^2wdEbR z)(Uap_gXed?Y4gq8CImtXNK3=Nc6ksI2`xAoe)xlFxCDOW^xCm5Moan~RU89}aHe{6Qr8 z3J>h=CCAL}(_HHJJJlprb}FuO4{fs2KdheNt^2g<_Qu&dHr~+w#@xuu_lBJ8joBr{ zGv1r+(hiY)7Nz!T%S6S%PgPx)d*9nl-c#?d9v$x)DOjg`r16G1*VnX1C7e}v$<({9 zLbdiT?!R73^a#lW!aHs()Qola9r;X}cnSGbcd=;`acsGIEOh$EqUM3-Anr5eLByjW zaR^F47)qGgJk=U+v}zeRwyw%0aK2O)p3=T28tKmHRug{6W?f!+mE#sCYx~n%ME57# zc$zJYYR7lDC*#axJ7Xu5)_)iscte}XvWfYom=G~UdS;w_r!u5f{SIlEa3WVM#r++Z(zM~N7!4<(n+UWAF|j2Yj-H@hZk7gOWp+2mn(iH-g0CPG~x!O5|v; zaI>sZq{Q=Fm4VE6iyqBH=1asX`uUqbvWOS4vZd872Jor?5?XT-ji-$<}N19ZR$DE*_!EhDU)LpU8@MG&;X>Di6T3^e9Q5(!+%*2K(t-=-R_14(P&TPh@{ZlMte~4T z#+|96-2g~CbxQynXb9qy(_#x0)VGdR<9mAF?%95zDKAr!F2$K0;@a1xml$fPxRF2+ z^?H<5;V_VEf5E1XSCVn8GlUo_{Z{YDrJ|OGO2WxMTfZywnH06EiYe#02Pw)tQl6TT z^6=MN*ge&;8Z#Is;&6d+0^NG{fzTjhSJlI0xvDGAUTd%$$~UD8Ub(7wzGGKx_vujY zPqJ6)(J>I!$a#3Wtt9$P`-ig~;g?`4PYN4iC z!;5B~Nbb8rw_B)Pi$34W03<3^{nDL8kHvIxBMlbWC&D)JGdIg_h3|0B<$g2Npp+|n z1Wg<3JLWaxIm#W(+fg^VnL)2#srS#VNU+jn@y5y<)od=Gs z`mPzczzZWLk5%ebj@>DmarqST3N;mR2v-r??pdW@S3+;(iAonTNw`ubswd{>8fTbm z@ZOBO@MA$wp-lHiK=c{XzUSe%IlX;$VmeuUcZONRFCyYL;^JpuU=fcDvZ%(o@eHvy z?kaW7b#1ELlE))WRMyGuy-3qIATiG;&_lX%i2U8AB<G*Jybhb>sZK*plXJy=U_3wpP@h13sZs zytuR5H#|#2P)Wao4&MSHqg2f+zaA`_o{a4Z-`qLS6(W9cY2b|1kBFDlHr9h*ccXJ@ z%Z3Bf@gQBQ!2QP5fZJF6@bmFkVb-ll#IH$T!jkH)((!wJ#TFuo`V|d!BZGeoD~Ym!G+n> zmIH)aXJb;_^i^G-I&#z~-WQx6^wnI{%;l0jp6`|>*8XZME8RaSO*YMmuUPq%UONdL=m{SRwt+&!!{K ziBrBR+LcG6Wr~s;7WZV{uWSblseo8t{=+NV3$~x|7^^4xOl`Ph>Z;FsCQFBmWKxIZ z5j<&_k4Mci`kBb11+k@t6LxYaBhaXL$Hh&nhOZ6><;d|hHcrG-ijq=x2Id@-XWQ*d ztLA1D?0NC0`s>+Hqrm#*h_cbkU9=AxfFgP)zey|mjo91({rAc1EUCg5Ciq?KO@xf9tD(R!Rx6jQ^Oa91Q z*+WEhsB?pSs~{1WKqJNU*lCVSkMYif9_Fh!X(wXJtCl@*MUacjYrAIFcm!+P1=>B?Da7=AmK&6p>ioHN@+ib_*T zjNTk~jX{eDo~kZN-Vh<07^LlMC*5qi(SO(8A7WN_`>q{1$1|1n{q4%vp4{8Wv9dac zBt6w|?H5U+!?x+|V&=Y)Kjeqo_PxwYWhhamdeaAxjkvybxF&V#iy-q`K(v6T?oXY( zj!pw=E$I?g((1|Zd=YV}lan03F=Vq{L+TT!X?5-o1jtHrDCC?l%VDD#e~9&a|9!0GM|>(fL1E-Jy-KGnt0kj6^>Uxi zW?-iI?Qo=ym#9T;5wB6O+2dm2TLX1ZcOpJ!t_cMe=|*X<_=U?doV`W)p5~x=38!Vc zyUfFz*N^P|#U0kt@M82%%KGyc3*C@3ckjW2W|di5b_UpNbglW#j_=1t+{7o(H5qQR zfA1umC2P=l*t=aJ_}-bWCer`o>#d{OUb;TsB)Ge~ySo-EuEpKm-QC@aL$M;otq|PZ zi@Qs)Qry~e({s-I-1oifSvPC`NWu!^^V>7?-Jh+cq{uFw?xL2Kgp1rr+~}PfVd3J> z4>t1A5{l@*h5Di7gXvFW3V(FNqDQP~7#>-TbBs5nd3=KN(>YGUux8e!#|9i?p|cs$ zbX`3W6}eOI$4Ytd>6{CJe8Qp`dqmEPzD5L(yg>{Iu?L}o2Y9{SuFZZ)j=_MiFIUJR z+pbBo32h782&x>F*iqL&G+OthtI#l4{#f`Mc{FugBWb({RPK{iB;mBdnDQ%Jud?6; z1&hb>h6#lejtiW4zUuvk&}C!KQWIamRx{IgEhZK2s!1?Ajh)cGvVqUTrqUv0hG+Ko zt^8g4t7=4parQ`;#Zg!}dfzf;Tz+?Qb9?UXB=W^xbB=EeA*oewUtmFQXz9*82ndzq z?fZcR%X_5nX(GHIst2^qAN8Wh_d1huK0@yjRIP$)Lv5C5)y-th(i1T=@{cIx42&fQ znA%Rl@ySV*bDJ1Ws)p#6ADRF&=gldTd| z6umMK_3qVI(`SBMo;lHL(cBaVEZI8jbsY-Sk9>u8&dck{i|J+kYDO5<7kY1 z^?2880e(?Zj|_1Ohos+39VPxl&(+U#aQiZGoorLWlhbt9!WxD{dnl{o{MslA11K@W z3%#DnPWQfL(O2W%v)fO>+kBvU)n!j>UZvC2^c%;LsF2J*v}evdo?fE_RPo{U3wZ#`Hh?$i{6;jS+0-9neFzO z?Y30TD@w7*xkOlLSPO_Y4s)SIb$|aA>-J4U zGroO=#chSf`v`NdXv76Vf%a?vtx3nX8yi2=9p)k$cm_Ktv4itccvJi7?66KAlAJ25 zIAe!~h8iQlS5_9t(Y1#5yw7=3lAI286^Tnw?XS_L9y-4w;VkcG14@knj5kPg`xG@o zTYTe2!o5rd28#FrjX$NjgeAPA*AtB-oKt+iEXf$-`(q!5qYIN>-i{$-L>ztuHx$F^ zjWXjAvkyMSBFTlM)R-v@SIqHhKk(SIw|De5)eo$kwMDl-ueZ6hK?(1)pBrZ1q3})B zW?|%{iJ8XXn@~=#=CN4Ag?7c%&bYJJOyTUw0*;3#Rz=wS5befAy{fJohJ=9a2TrpR z(<3(U!csPmWzb-L3_R{)FpSkA6~1Y`szwj9dr5KvE0Ubq*wRW zxaPTjIbdD|BKMC0a8{czX#NGTMtnON4szd*8hmi6Jdqy=?T=tT91f~J?$x|Cu6q6~ zgAHPt0@gtpz=Ba+2#F;##H(Lnf=%i#u<>at(OBrCZ+_NqzpeZTveAXHq7I0}Fr!H& zz#{xzde&R{Qhz4;1x3Oqw)Z}lH3k+y9@(+WoQ>}&H32}-Y=A%$K?%0Arnvrsjl~`G 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%0AmZMe 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 Z=w}mN=0uEx=pg>ynj3}uW#8{F{}0?HuQ31s literal 0 HcmV?d00001 diff --git a/docs/source/_images/2x2_materials.jpeg b/docs/source/_images/2x2_materials.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..b76607eca66106f083c2c8fc2feb044f60d90a7a GIT binary patch literal 59424 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

N1Gkv&N5JUzVL7}n>CVY1IDMtkdfQfL<^Jc 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 literal 0 HcmV?d00001 diff --git a/docs/source/io_formats/settings.rst b/docs/source/io_formats/settings.rst index 9a339fb471..6318a99160 100644 --- a/docs/source/io_formats/settings.rst +++ b/docs/source/io_formats/settings.rst @@ -413,6 +413,32 @@ or sub-elements and can be set to either "false" or "true". .. note:: This element is not used in the multi-group :ref:`energy_mode`. +------------------------ +```` Element +------------------------ + +The ```` element enables random ray mode and contains a number of +settings relevant to the solver. Tips for selecting these parameters can be +found in the :ref:`random ray user guide `. + + :distance_inactive: + The inactive ray length (dead zone length) in [cm]. + + *Default*: None + + :distance_active: + The active ray length in [cm]. + + *Default*: None + + :source: + Specifies the starting ray distribution, and follows the format for + :ref:`source_element`. It must be uniform in space and angle and cover the + full domain. It does not represent a physical neutron or photon source -- it + is only used to sample integrating ray starting locations and directions. + + *Default*: None + ---------------------------------- ```` Element ---------------------------------- diff --git a/docs/source/methods/index.rst b/docs/source/methods/index.rst index 59892ac273..08aa7d0534 100644 --- a/docs/source/methods/index.rst +++ b/docs/source/methods/index.rst @@ -20,3 +20,4 @@ Theory and Methodology energy_deposition parallelization cmfd + random_ray \ No newline at end of file diff --git a/docs/source/methods/random_ray.rst b/docs/source/methods/random_ray.rst new file mode 100644 index 0000000000..aa43678476 --- /dev/null +++ b/docs/source/methods/random_ray.rst @@ -0,0 +1,804 @@ +.. _methods_random_ray: + +========== +Random Ray +========== + +.. _methods_random_ray_intro: + +------------------- +What is Random Ray? +------------------- + +`Random ray `_ is a stochastic transport method, closely related to +the deterministic Method of Characteristics (MOC) [Askew-1972]_. Rather than +each ray representing a single neutron as in Monte Carlo, it represents a +characteristic line through the simulation geometry upon which the transport +equation can be written as an ordinary differential equation that can be solved +analytically (although with discretization required in energy, making it a +multigroup method). The behavior of the governing transport equation can be +approximated by solving along many characteristic tracks (rays) through the +system. Unlike particles in Monte Carlo, rays in random ray or MOC are not +affected by the material characteristics of the simulated problem---rays are +selected so as to explore the full simulation problem with a statistically equal +distribution in space and angle. + +.. raw:: html + + + +The above animation is an example of the random ray integration process at work, +showing a series of random rays being sampled and transported through the +geometry. In the following sections, we will discuss how the random ray solver +works. + +---------------------------------------------- +Why is a Random Ray Solver Included in OpenMC? +---------------------------------------------- + +* One area that Monte Carlo struggles with is maintaining numerical efficiency + in regions of low physical particle flux. Random ray, on the other hand, has + approximately even variance throughout the entire global simulation domain, + such that areas with low neutron flux are no less well known that areas of + high neutron flux. Absent weight windows in MC, random ray can be several + orders of magnitude faster than multigroup Monte Carlo in classes of problems + where areas with low physical neutron flux need to be resolved. While MC + uncertainty can be greatly improved with variance reduction techniques, they + add some user complexity, and weight windows can often be expensive to + generate via MC transport alone (e.g., via the `MAGIC method + `_). The random ray solver + may be used in future versions of OpenMC as a fast way to generate weight + windows for subsequent usage by the MC solver in OpenMC. + +* In practical implementation terms, random ray is mechanically very similar to + how Monte Carlo works, in terms of the process of ray tracing on constructive + solid geometry (CSG) and handling stochastic convergence, etc. In the original + 1972 paper by Askew that introduces MOC (which random ray is a variant of), he + stated: + + .. epigraph:: + + "One of the features of the method proposed [MoC] is that ... the + tracking process needed to perform this operation is common to the + proposed method ... and to Monte Carlo methods. Thus a single tracking + routine capable of recognizing a geometric arrangement could be utilized + to service all types of solution, choice being made depending which was + more appropriate to the problem size and required accuracy." + + -- Askew [Askew-1972]_ + + This prediction holds up---the additional requirements needed in OpenMC to + handle random ray transport turned out to be fairly small. + +* It amortizes the code complexity in OpenMC for representing multigroup cross + sections. There is a significant amount of interface code, documentation, and + complexity in allowing OpenMC to generate and use multigroup XS data in its + MGMC mode. Random ray allows the same multigroup data to be used, making full + reuse of these existing capabilities. + +------------------------------- +Random Ray Numerical Derivation +------------------------------- + +In this section, we will derive the numerical basis for the random ray solver +mode in OpenMC. The derivation of random ray is also discussed in several papers +(`1 `_, `2 `_, `3 `_), and some of those +derivations are reproduced here verbatim. Several extensions are also made to +add clarity, particularly on the topic of OpenMC's treatment of cell volumes in +the random ray solver. + +~~~~~~~~~~~~~~~~~~~~~~~~~ +Method of Characteristics +~~~~~~~~~~~~~~~~~~~~~~~~~ + +The Boltzmann neutron transport equation is a partial differential equation +(PDE) that describes the angular flux within a system. It is a balance equation, +with the streaming and absorption terms typically appearing on the left hand +side, which are balanced by the scattering source and fission source terms on +the right hand side. + +.. math:: + :label: transport + + \begin{align*} + \mathbf{\Omega} \cdot \mathbf{\nabla} \psi(\mathbf{r},\mathbf{\Omega},E) & + \Sigma_t(\mathbf{r},E) \psi(\mathbf{r},\mathbf{\Omega},E) = \\ + & \int_0^\infty d E^\prime \int_{4\pi} d \Omega^{\prime} \Sigma_s(\mathbf{r},\mathbf{\Omega}^\prime \rightarrow \mathbf{\Omega}, E^\prime \rightarrow E) \psi(\mathbf{r},\mathbf{\Omega}^\prime, E^\prime) \\ + & + \frac{\chi(\mathbf{r}, E)}{4\pi k_{eff}} \int_0^\infty dE^\prime \nu \Sigma_f(\mathbf{r},E^\prime) \int_{4\pi}d \Omega^\prime \psi(\mathbf{r},\mathbf{\Omega}^\prime,E^\prime) + \end{align*} + +In Equation :eq:`transport`, :math:`\psi` is the angular neutron flux. This +parameter represents the total distance traveled by all neutrons in a particular +direction inside of a control volume per second, and is often given in units of +:math:`1/(\text{cm}^{2} \text{s})`. As OpenMC does not support time dependence +in the random ray solver mode, we consider the steady state equation, where the +units of flux become :math:`1/\text{cm}^{2}`. The angular direction unit vector, +:math:`\mathbf{\Omega}`, represents the direction of travel for the neutron. The +spatial position vector, :math:`\mathbf{r}`, represents the location within the +simulation. The neutron energy, :math:`E`, or speed in continuous space, is +often given in units of electron volts. The total macroscopic neutron cross +section is :math:`\Sigma_t`. This value represents the total probability of +interaction between a neutron traveling at a certain speed (i.e., neutron energy +:math:`E`) and a target nucleus (i.e., the material through which the neutron is +traveling) per unit path length, typically given in units of +:math:`1/\text{cm}`. Macroscopic cross section data is a combination of +empirical data and quantum mechanical modeling employed in order to generate an +evaluation represented either in pointwise form or resonance parameters for each +target isotope of interest in a material, as well as the density of the +material, and is provided as input to a simulation. The scattering neutron cross +section, :math:`\Sigma_s`, is similar to the total cross section but only +measures scattering interactions between the neutron and the target nucleus, and +depends on the change in angle and energy the neutron experiences as a result of +the interaction. Several additional reactions like (n,2n) and (n,3n) are +included in the scattering transfer cross section. The fission neutron cross +section, :math:`\Sigma_f`, is also similar to the total cross section but only +measures the fission interaction between a neutron and a target nucleus. The +energy spectrum for neutrons born from fission, :math:`\chi`, represents a known +distribution of outgoing neutron energies based on the material that fissioned, +which is taken as input data to a computation. The average number of neutrons +born per fission is :math:`\nu`. The eigenvalue of the equation, +:math:`k_{eff}`, represents the effective neutron multiplication factor. If the +right hand side of Equation :eq:`transport` is condensed into a single term, +represented by the total neutron source term :math:`Q(\mathbf{r}, \mathbf{\Omega},E)`, +the form given in Equation :eq:`transport_simple` is reached. + +.. math:: + :label: transport_simple + + \overbrace{\mathbf{\Omega} \cdot \mathbf{\nabla} \psi(\mathbf{r},\mathbf{\Omega},E)}^{\text{streaming term}} + \overbrace{\Sigma_t(\mathbf{r},E) \psi(\mathbf{r},\mathbf{\Omega},E)}^{\text{absorption term}} = \overbrace{Q(\mathbf{r}, \mathbf{\Omega},E)}^{\text{total neutron source term}} + +Fundamentally, MOC works by solving Equation :eq:`transport_simple` along a +single characteristic line, thus altering the full spatial and angular scope of +the transport equation into something that holds true only for a particular +linear path (or track) through the reactor. These tracks are linear for neutral +particles that are not subject to field effects. With our transport equation in +hand, we will now derive the solution along a track. To accomplish this, we +parameterize :math:`\mathbf{r}` with respect to some reference location +:math:`\mathbf{r}_0` such that :math:`\mathbf{r} = \mathbf{r}_0 + s\mathbf{\Omega}`. In this +manner, Equation :eq:`transport_simple` can be rewritten for a specific segment +length :math:`s` at a specific angle :math:`\mathbf{\Omega}` through a constant +cross section region of the reactor geometry as in Equation :eq:`char_long`. + +.. math:: + :label: char_long + + \mathbf{\Omega} \cdot \mathbf{\nabla} \psi(\mathbf{r}_0 + s\mathbf{\Omega},\mathbf{\Omega},E) + \Sigma_t(\mathbf{r}_0 + s\mathbf{\Omega},E) \psi(\mathbf{r}_0 + s\mathbf{\Omega},\mathbf{\Omega},E) = Q(\mathbf{r}_0 + s\mathbf{\Omega}, \mathbf{\Omega},E) + +As this equation holds along a one dimensional path, we can assume the +dependence of :math:`s` on :math:`\mathbf{r}_0` and :math:`\mathbf{\Omega}` such that +:math:`\mathbf{r}_0 + s\mathbf{\Omega}` simplifies to :math:`s`. When the differential +operator is also applied to the angular flux :math:`\psi`, we arrive at the +characteristic form of the Boltzmann Neutron Transport Equation given in +Equation :eq:`char`. + +.. math:: + :label: char + + \frac{d}{ds} \psi(s,\mathbf{\Omega},E) + \Sigma_t(s,E) \psi(s,\mathbf{\Omega},E) = Q(s, \mathbf{\Omega},E) + +An analytical solution to this characteristic equation can be achieved with the +use of an integrating factor: + +.. math:: + :label: int_factor + + e^{ \int_0^s ds' \Sigma_t (s', E)} + +to arrive at the final form of the characteristic equation shown in Equation +:eq:`full_char`. + +.. math:: + :label: full_char + + \psi(s,\mathbf{\Omega},E) = \psi(\mathbf{r}_0,\mathbf{\Omega},E) e^{-\int_0^s ds^\prime \Sigma_t(s^\prime,E)} + \int_0^s ds^{\prime\prime} Q(s^{\prime\prime},\mathbf{\Omega}, E) e^{-\int_{s^{\prime\prime}}^s ds^\prime \Sigma_t(s^\prime,E)} + +With this characteristic form of the transport equation, we now have an +analytical solution along a linear path through any constant cross section +region of a system. While the solution only holds along a linear track, no +discretizations have yet been made. + +Similar to many other solution approaches to the Boltzmann neutron transport +equation, the MOC approach also uses a "multigroup" approximation in order to +discretize the continuous energy spectrum of neutrons traveling through the +system into fixed set of energy groups :math:`G`, where each group :math:`g \in +G` has its own specific cross section parameters. This makes the difficult +non-linear continuous energy dependence much more manageable as group wise cross +section data can be precomputed and fed into a simulation as input data. The +computation of multigroup cross section data is not a trivial task and can +introduce errors in the simulation. However, this is an active field of research +common to all multigroup methods, and there are numerous generation methods +available that are capable of reducing the biases introduced by the multigroup +approximation. Commonly used methods include the subgroup self-shielding method +and use of fast (unconverged) Monte Carlo simulations to produce cross section +estimates. It is important to note that Monte Carlo methods are capable of +treating the energy variable of the neutron continuously, meaning that they do +not need to make this approximation and are therefore not subject to any +multigroup errors. + +Following the multigroup discretization, another assumption made is that a large +and complex problem can be broken up into small constant cross section regions, +and that these regions have group dependent, flat, isotropic sources (fission +and scattering), :math:`Q_g`. Anisotropic as well as higher order sources are +also possible with MOC-based methods but are not used yet in OpenMC for +simplicity. With these key assumptions, the multigroup MOC form of the neutron +transport equation can be written as in Equation :eq:`moc_final`. + +.. math:: + :label: moc_final + + \psi_g(s, \mathbf{\Omega}) = \psi_g(\mathbf{r_0}, \mathbf{\Omega}) e^{-\int_0^s ds^\prime \Sigma_{t_g}(s^\prime)} + \int_0^s ds^{\prime\prime} Q_g(s^{\prime\prime},\mathbf{\Omega}) e^{-\int_{s^{\prime\prime}}^s ds^\prime \Sigma_{t_g}(s^\prime)} + +The CSG definition of the system is used to create spatially defined source +regions (each region being denoted as :math:`i`). These neutron source regions +are often approximated as being constant +(flat) in source intensity but can also be defined using a higher order source +(linear, quadratic, etc.) that allows for fewer source regions to be required to +achieve a specified solution fidelity. In OpenMC, the approximation of a +spatially constant isotropic fission and scattering source :math:`Q_{i,g}` in +cell :math:`i` leads +to simple exponential attenuation along an individual characteristic of length +:math:`s` given by Equation :eq:`fsr_attenuation`. + +.. math:: + :label: fsr_attenuation + + \psi_g(s) = \psi_g(0) e^{-\Sigma_{t,i,g} s} + \frac{Q_{i,g}}{\Sigma_{t,i,g}} \left( 1 - e^{-\Sigma_{t,i,g} s} \right) + +For convenience, we can also write this equation in terms of the incoming and +outgoing angular flux (:math:`\psi_g^{in}` and :math:`\psi_g^{out}`), and +consider a specific tracklength for a particular ray :math:`r` crossing cell +:math:`i` as :math:`\ell_r`, as in: + +.. math:: + :label: fsr_attenuation_in_out + + \psi_g^{out} = \psi_g^{in} e^{-\Sigma_{t,i,g} \ell_r} + \frac{Q_{i,g}}{\Sigma_{t,i,g}} \left( 1 - e^{-\Sigma_{t,i,g} \ell_r} \right) . + +We can then define the average angular flux of a single ray passing through the +cell as: + +.. math:: + :label: average + + \overline{\psi}_{r,i,g} = \frac{1}{\ell_r} \int_0^{\ell_r} \psi_{g}(s)ds . + +We can then substitute in Equation :eq:`fsr_attenuation` and solve, resulting +in: + +.. math:: + :label: average_solved + + \overline{\psi}_{r,i,g} = \frac{Q_{i,g}}{\Sigma_{t,i,g}} - \frac{\psi_{r,g}^{out} - \psi_{r,g}^{in}}{\ell_r \Sigma_{t,i,g}} . + +By rearranging Equation :eq:`fsr_attenuation_in_out`, we can then define +:math:`\Delta \psi_{r,g}` as the change in angular flux for ray :math:`r` +passing through region :math:`i` as: + +.. math:: + :label: delta_psi + + \Delta \psi_{r,g} = \psi_{r,g}^{in} - \psi_{r,g}^{out} = \left(\psi_{r,g}^{in} - \frac{Q_{i,g}}{\Sigma_{t,i,g}} \right) \left( 1 - e^{-\Sigma_{t,i,g} \ell_r} \right) . + +Equation :eq:`delta_psi` is a useful expression as it is easily computed with +the known inputs for a ray crossing through the region. + +By substituting :eq:`delta_psi` into :eq:`average_solved`, we can arrive at a +final expression for the average angular flux for a ray crossing a region as: + +.. math:: + :label: average_psi_final + + \overline{\psi}_{r,i,g} = \frac{Q_{i,g}}{\Sigma_{t,i,g}} + \frac{\Delta \psi_{r,g}}{\ell_r \Sigma_{t,i,g}} + +~~~~~~~~~~~ +Random Rays +~~~~~~~~~~~ + +In the previous subsection, the governing characteristic equation along a 1D +line through the system was written, such that an analytical solution for the +ODE can be computed. If enough characteristic tracks (ODEs) are solved, then the +behavior of the governing PDE can be numerically approximated. In traditional +deterministic MOC, the selection of tracks is chosen deterministically, where +azimuthal and polar quadratures are defined along with even track spacing in +three dimensions. This is the point at which random ray diverges from +deterministic MOC numerically. In the random ray method, rays are randomly +sampled from a uniform distribution in space and angle and tracked along a +predefined distance through the geometry before terminating. **Importantly, +different rays are sampled each power iteration, leading to a fully stochastic +convergence process.** This results in a need to utilize both inactive and +active batches as in the Monte Carlo method. + +While Monte Carlo implicitly converges the scattering source fully within each +iteration, random ray (and MOC) solvers are not typically written to fully +converge the scattering source within a single iteration. Rather, both the +fission and scattering sources are updated each power iteration, thus requiring +enough outer iterations to reach a stationary distribution in both the fission +source and scattering source. So, even in a low dominance ratio problem like a +2D pincell, several hundred inactive batches may still be required with random +ray to allow the scattering source to fully develop, as neutrons undergoing +hundreds of scatters may constitute a non-trivial contribution to the fission +source. We note that use of a two-level second iteration scheme is sometimes +used by some MOC or random ray solvers so as to fully converge the scattering +source with many inner iterations before updating the fission source in the +outer iteration. It is typically more efficient to use the single level +iteration scheme, as there is little reason to spend so much work converging the +scattering source if the fission source is not yet converged. + +Overall, the difference in how random ray and Monte Carlo converge the +scattering source means that in practice, random ray typically requires more +inactive iterations than are required in Monte Carlo. While a Monte Carlo +simulation may need 100 inactive iterations to reach a stationary source +distribution for many problems, a random ray solve will likely require 1,000 +iterations or more. Source convergence metrics (e.g., Shannon entropy) are thus +recommended when performing random ray simulations to ascertain when the source +has fully developed. + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Converting Angular Flux to Scalar Flux +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Thus far in our derivation, we have been able to write analytical equations that +solve for the change in angular flux of a ray crossing a flat source region +(Equation :eq:`delta_psi`) as well as the ray's average angular flux through +that region (Equation :eq:`average_psi_final`). To determine the source for the +next power iteration, we need to assemble our estimates of angular fluxes from +all the sampled rays into scalar fluxes within each FSR. + +We can define the scalar flux in region :math:`i` as: + +.. math:: + :label: integral + + \phi_i = \frac{\int_{V_i} \int_{4\pi} \psi(r, \Omega) d\Omega d\mathbf{r}}{\int_{V_i} d\mathbf{r}} . + +The integral in the numerator: + +.. math:: + :label: numerator + + \int_{V_i} \int_{4\pi} \psi(r, \Omega) d\Omega d\mathbf{r} . + +is not known analytically, but with random ray, we are going the numerically +approximate it by discretizing over a finite number of tracks (with a finite +number of locations and angles) crossing the domain. We can then use the +characteristic method to determine the total angular flux along that line. + +Conceptually, this can be thought of as taking a volume-weighted sum of angular +fluxes for all :math:`N_i` rays that happen to pass through cell :math:`i` that +iteration. When written in discretized form (with the discretization happening +in terms of individual ray segments :math:`r` that pass through region +:math:`i`), we arrive at: + +.. math:: + :label: discretized + + \phi_{i,g} = \frac{\int_{V_i} \int_{4\pi} \psi(r, \Omega) d\Omega d\mathbf{r}}{\int_{V_i} d\mathbf{r}} = \overline{\overline{\psi}}_{i,g} \approx \frac{\sum\limits_{r=1}^{N_i} \ell_r w_r \overline{\psi}_{r,i,g}}{\sum\limits_{r=1}^{N_i} \ell_r w_r} . + +Here we introduce the term :math:`w_r`, which represents the "weight" of the ray +(its 2D area), such that the volume that a ray is responsible for can be +determined by multiplying its length :math:`\ell` by its weight :math:`w`. As +the scalar flux vector is a shape function only, we are actually free to +multiply all ray weights :math:`w` by any constant such that the overall shape +is still maintained, even if the magnitude of the shape function changes. Thus, +we can simply set :math:`w_r` to be unity for all rays, such that: + +.. math:: + :label: weights + + \text{Volume of cell } i = V_i \approx \sum\limits_{r=1}^{N_i} \ell_r w_r = \sum\limits_{r=1}^{N_i} \ell_r . + +We can then rewrite our discretized equation as: + +.. math:: + :label: discretized_2 + + \phi_{i,g} \approx \frac{\sum\limits_{r=1}^{N_i} \ell_r w_r \overline{\psi}_{r,i,g}}{\sum\limits_{r=1}^{N_i} \ell_r w_r} = \frac{\sum\limits_{r=1}^{N_i} \ell_r \overline{\psi}_{r,i,g}}{\sum\limits_{r=1}^{N_i} \ell_r} . + +Thus, the scalar flux can be inferred if we know the volume weighted sum of the +average angular fluxes that pass through the cell. Substituting +:eq:`average_psi_final` into :eq:`discretized_2`, we arrive at: + +.. math:: + :label: scalar_full + + \phi_{i,g} = \frac{\int_{V_i} \int_{4\pi} \psi(r, \Omega) d\Omega d\mathbf{r}}{\int_{V_i} d\mathbf{r}} = \overline{\overline{\psi}}_{i,g} = \frac{\sum\limits_{r=1}^{N_i} \ell_r \overline{\psi}_{r,i,g}}{\sum\limits_{r=1}^{N_i} \ell_r} = \frac{\sum\limits_{r=1}^{N_i} \ell_r \frac{Q_{i,g}}{\Sigma_{t,i,g}} + \frac{\Delta \psi_{r,g}}{\ell_r \Sigma_{t,i,g}}}{\sum\limits_{r=1}^{N_i} \ell_r}, + +which when partially simplified becomes: + +.. math:: + :label: scalar_four_vols + + \phi = \frac{Q_{i,g} \sum\limits_{r=1}^{N_i} \ell_r}{\Sigma_{t,i,g} \sum\limits_{r=1}^{N_i} \ell_r} + \frac{\sum\limits_{r=1}^{N_i} \ell_r \frac{\Delta \psi_i}{\ell_r}}{\Sigma_{t,i,g} \sum\limits_{r=1}^{N_i} \ell_r} . + +Note that there are now four (seemingly identical) volume terms in this equation. + +~~~~~~~~~~~~~~ +Volume Dilemma +~~~~~~~~~~~~~~ + +At first glance, Equation :eq:`scalar_four_vols` appears ripe for cancellation +of terms. Mathematically, such cancellation allows us to arrive at the following +"naive" estimator for the scalar flux: + +.. math:: + :label: phi_naive + + \phi_{i,g}^{naive} = \frac{Q_{i,g} }{\Sigma_{t,i,g}} + \frac{\sum\limits_{r=1}^{N_i} \Delta \psi_{r,g}}{\Sigma_{t,i,g} \sum\limits_{r=1}^{N_i} \ell_r} . + +This derivation appears mathematically sound at first glance but unfortunately +raises a serious issue as discussed in more depth by `Tramm et al. +`_ and `Cosgrove and Tramm `_. Namely, the second +term: + +.. math:: + :label: ratio_estimator + + \frac{\sum\limits_{r=1}^{N_i} \Delta \psi_{r,g}}{\Sigma_{t,i,g} \sum\limits_{r=1}^{N_i} \ell_r} + +features stochastic variables (the sums over random ray lengths and angular +fluxes) in both the numerator and denominator, making it a stochastic ratio +estimator, which is inherently biased. In practice, usage of the naive estimator +does result in a biased, but "consistent" estimator (i.e., it is biased, but +the bias tends towards zero as the sample size increases). Experimentally, the +right answer can be obtained with this estimator, though a very fine ray density +is required to eliminate the bias. + +How might we solve the biased ratio estimator problem? While there is no obvious +way to alter the numerator term (which arises from the characteristic +integration approach itself), there is potentially more flexibility in how we +treat the stochastic term in the denominator, :math:`\sum\limits_{r=1}^{N_i} +\ell_r` . From Equation :eq:`weights` we know that this term can be directly +inferred from the volume of the problem, which does not actually change between +iterations. Thus, an alternative treatment for this "volume" term in the +denominator is to replace the actual stochastically sampled total track length +with the expected value of the total track length. For instance, if the true +volume of the FSR is known (as is the total volume of the full simulation domain +and the total tracklength used for integration that iteration), then we know the +true expected value of the tracklength in that FSR. That is, if a FSR accounts +for 2% of the overall volume of a simulation domain, then we know that the +expected value of tracklength in that FSR will be 2% of the total tracklength +for all rays that iteration. This is a key insight, as it allows us to the +replace the actual tracklength that was accumulated inside that FSR each +iteration with the expected value. + +If we know the analytical volumes, then those can be used to directly compute +the expected value of the tracklength in each cell. However, as the analytical +volumes are not typically known in OpenMC due to the usage of user-defined +constructive solid geometry, we need to source this quantity from elsewhere. An +obvious choice is to simply accumulate the total tracklength through each FSR +across all iterations (batches) and to use that sum to compute the expected +average length per iteration, as: + +.. math:: + :label: sim_estimator + + \sum\limits^{}_{i} \ell_i \approx \frac{\sum\limits^{B}_{b}\sum\limits^{N_i}_{r} \ell_{b,r} }{B} + +where :math:`b` is a single batch in :math:`B` total batches simulated so far. + +In this manner, the expected value of the tracklength will become more refined +as iterations continue, until after many iterations the variance of the +denominator term becomes trivial compared to the numerator term, essentially +eliminating the presence of the stochastic ratio estimator. A "simulation +averaged" estimator is therefore: + +.. math:: + :label: phi_sim + + \phi_{i,g}^{simulation} = \frac{Q_{i,g} }{\Sigma_{t,i,g}} + \frac{\sum\limits_{r=1}^{N_i} \Delta \psi_{r,g}}{\Sigma_{t,i,g} \frac{\sum\limits^{B}_{b}\sum\limits^{N_i}_{r} \ell_{b,r} }{B}} + +In practical terms, the "simulation averaged" estimator is virtually +indistinguishable numerically from use of the true analytical volume to estimate +this term. Note also that the term "simulation averaged" refers only to the +volume/length treatment, the scalar flux estimate itself is computed fully again +each iteration. + +There are some drawbacks to this method. Recall, this denominator volume term +originally stemmed from taking a volume weighted integral of the angular flux, +in which case the denominator served as a normalization term for the numerator +integral in Equation :eq:`integral`. Essentially, we have now used a different +term for the volume in the numerator as compared to the normalizing volume in +the denominator. The inevitable mismatch (due to noise) between these two +quantities results in a significant increase in variance. Notably, the same +problem occurs if using a tracklength estimate based on the analytical volume, +as again the numerator integral and the normalizing denominator integral no +longer match on a per-iteration basis. + +In practice, the simulation averaged method does completely remove the bias, +though at the cost of a notable increase in variance. Empirical testing reveals +that on most problems, the simulation averaged estimator does win out overall in +numerical performance, as a much coarser quadrature can be used resulting in +faster runtimes overall. Thus, OpenMC uses the simulation averaged estimator in +its random ray mode. + +~~~~~~~~~~~~~~~ +Power Iteration +~~~~~~~~~~~~~~~ + +Given a starting source term, we now have a way of computing an estimate of the +scalar flux in each cell by way of transporting rays randomly through the +domain, recording the change in angular flux for the rays into each cell as they +make their traversals, and summing these contributions up as in Equation +:eq:`phi_sim`. How then do we turn this into an iterative process such that we +improve the estimate of the source and scalar flux over many iterations, given +that our initial starting source will just be a guess? + +The source :math:`Q^{n}` for iteration :math:`n` can be inferred +from the scalar flux from the previous iteration :math:`n-1` as: + +.. math:: + :label: source_update + + Q^{n}(i, g) = \frac{\chi}{k^{n-1}_{eff}} \nu \Sigma_f(i, g) \phi^{n-1}(g) + \sum\limits^{G}_{g'} \Sigma_{s}(i,g,g') \phi^{n-1}(g') + +where :math:`Q^{n}(i, g)` is the total source (fission + scattering) in region +:math:`i` and energy group :math:`g`. Notably, the in-scattering source in group +:math:`g` must be computed by summing over the contributions from all groups +:math:`g' \in G`. + +In a similar manner, the eigenvalue for iteration :math:`n` can be computed as: + +.. math:: + :label: eigenvalue_update + + k^{n}_{eff} = k^{n-1}_{eff} \frac{F^n}{F^{n-1}}, + +where the total spatial- and energy-integrated fission rate :math:`F^n` in +iteration :math:`n` can be computed as: + +.. math:: + :label: fission_source + + F^n = \sum\limits^{M}_{i} \left( V_i \sum\limits^{G}_{g} \nu \Sigma_f(i, g) \phi^{n}(g) \right) + +where :math:`M` is the total number of FSRs in the simulation. Similarly, the +total spatial- and energy-integrated fission rate :math:`F^{n-1}` in iteration +:math:`n-1` can be computed as: + +.. math:: + :label: fission_source_prev + + F^{n-1} = \sum\limits^{M}_{i} \left( V_i \sum\limits^{G}_{g} \nu \Sigma_f(i, g) \phi^{n-1}(g) \right) + +Notably, the volume term :math:`V_i` appears in the eigenvalue update equation. +The same logic applies to the treatment of this term as was discussed earlier. +In OpenMC, we use the "simulation averaged" volume derived from summing over all +ray tracklength contributions to a FSR over all iterations and dividing by the +total integration tracklength to date. Thus, Equation :eq:`fission_source` +becomes: + +.. math:: + :label: fission_source_volumed + + F^n = \sum\limits^{M}_{i} \left( \frac{\sum\limits^{B}_{b}\sum\limits^{N_i}_{r} \ell_{b,r} }{B} \sum\limits^{G}_{g} \nu \Sigma_f(i, g) \phi^{n}(g) \right) + +and a similar substitution can be made to update Equation +:eq:`fission_source_prev` . In OpenMC, the most up-to-date version of the volume +estimate is used, such that the total fission source from the previous iteration +(:math:`n-1`) is also recomputed each iteration. + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Ray Starting Conditions and Inactive Length +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Another key area of divergence between deterministic MOC and random ray is the +starting conditions for rays. In deterministic MOC, the angular flux spectrum +for rays are stored at any reflective or periodic boundaries so as to provide a +starting condition for the next iteration. As there are many tracks, storage of +angular fluxes can become costly in terms of memory consumption unless there are +only vacuum boundaries present. + +In random ray, as the starting locations of rays are sampled anew each +iteration, the initial angular flux spectrum for the ray is unknown. While a +guess can be made by taking the isotropic source from the FSR the ray was +sampled in, direct usage of this quantity would result in significant bias and +error being imparted on the simulation. + +Thus, an `on-the-fly approximation method `_ was developed (known +as the "dead zone"), where the first several mean free paths of a ray are +considered to be "inactive" or "read only". In this sense, the angular flux is +solved for using the MOC equation, but the ray does not "tally" any scalar flux +back to the FSRs that it travels through. After several mean free paths have +been traversed, the ray's angular flux spectrum typically becomes dominated by +the accumulated source terms from the cells it has traveled through, while the +(incorrect) starting conditions have been attenuated away. In the animation in +the :ref:`introductory section on this page `, the +yellow portion of the ray lengths is the dead zone. As can be seen in this +animation, the tallied :math:`\sum\limits_{r=1}^{N_i} \Delta \psi_{r,g}` term +that is plotted is not affected by the ray when the ray is within its inactive +length. Only when the ray enters its active mode does the ray contribute to the +:math:`\sum\limits_{r=1}^{N_i} \Delta \psi_{r,g}` sum for the iteration. + +~~~~~~~~~~~~~~~~~~~~~ +Ray Ending Conditions +~~~~~~~~~~~~~~~~~~~~~ + +To ensure that a uniform density of rays is integrated in space and angle +throughout the simulation domain, after exiting the initial inactive "dead zone" +portion of the ray, the rays are run for a user-specified distance. Typically, a +choice of at least several times the length of the inactive "dead zone" is made +so as to amortize the cost of the dead zone. For example, if a dead zone of 30 +cm is selected, then an active length of 300 cm might be selected so that the +cost of the dead zone is at most 10% of the overall runtime. + +-------------------- +Simplified Algorithm +-------------------- + +A simplified set of functions that execute a single random ray power iteration +are given below. Not all global variables are defined in this illustrative +example, but the high level components of the algorithm are shown. A number of +significant simplifications are made for clarity---for example, no inactive +"dead zone" length is shown, geometry operations are abstracted, no parallelism +(or thread safety) is expressed, a naive exponential treatment is used, and rays +are not halted at their exact termination distances, among other subtleties. +Nonetheless, the below algorithms may be useful for gaining intuition on the +basic components of the random ray process. Rather than expressing the algorithm +in abstract pseudocode, C++ is used to make the control flow easier to +understand. + +The first block below shows the logic for a single power iteration (batch): + +.. code-block:: C++ + + double power_iteration(double k_eff) { + + // Update source term (scattering + fission) + update_neutron_source(k_eff); + + // Reset scalar fluxes to zero + fill(global::scalar_flux_new, 0.0f); + + // Transport sweep over all random rays for the iteration + for (int i = 0; i < nrays; i++) { + RandomRay ray; + initialize_ray(ray); + transport_single_ray(ray); + } + + // Normalize scalar flux and update volumes + normalize_scalar_flux_and_volumes(); + + // Add source to scalar flux, compute number of FSR hits + add_source_to_scalar_flux(); + + // Compute k-eff using updated scalar flux + k_eff = compute_k_eff(k_eff); + + // Set phi_old = phi_new + global::scalar_flux_old.swap(global::scalar_flux_new); + + return k_eff; + } + +The second function shows the logic for transporting a single ray within the +transport loop: + +.. code-block:: C++ + + void transport_single_ray(RandomRay& ray) { + + // Reset distance to zero + double distance = 0.0; + + // Continue transport of ray until active length is reached + while (distance < user_setting::active_length) { + // Ray trace to find distance to next surface (i.e., segment length) + double s = distance_to_nearest_boundary(ray); + + // Attenuate flux (and accumulate source/attenuate) on segment + attenuate_flux(ray, s); + + // Advance particle to next surface + ray.location = ray.location + s * ray.direction; + + // Move ray across the surface + cross_surface(ray); + + // Add segment length "s" to total distance traveled + distance += s; + } + } + +The final function below shows the logic for solving for the characteristic MOC +equation (and accumulating the scalar flux contribution of the ray into the +scalar flux value for the FSR). + +.. code-block:: C++ + + void attenuate_flux(RandomRay& ray, double s) { + + // Determine which flat source region (FSR) the ray is currently in + int fsr = get_fsr_id(ray.location); + + // Determine material type + int material = get_material_type(fsr); + + // MOC incoming flux attenuation + source contribution/attenuation equation + for (int e = 0; e < global::n_energy_groups; e++) { + float sigma_t = global::macro_xs[material].total; + float tau = sigma_t * s; + float delta_psi = (ray.angular_flux[e] - global::source[fsr][e] / sigma_t) * (1 - exp(-tau)); + ray.angular_flux_[e] -= delta_psi; + global::scalar_flux_new[fsr][e] += delta_psi; + } + + // Record total tracklength in this FSR (to compute volume) + global::volume[fsr] += s; + } + +------------------------ +How are Tallies Handled? +------------------------ + +Most tallies, filters, and scores that you would expect to work with a +multigroup solver like random ray should work. For example, you can define 3D +mesh tallies with energy filters and flux, fission, and nu-fission scores, etc. +There are some restrictions though. For starters, it is assumed that all filter +mesh boundaries will conform to physical surface boundaries (or lattice +boundaries) in the simulation geometry. It is acceptable for multiple cells +(FSRs) to be contained within a filter mesh cell (e.g., pincell-level or +assembly-level tallies should work), but it is currently left as undefined +behavior if a single simulation cell is able to score to multiple filter mesh +cells. In the future, the capability to fully support mesh tallies may be added +to OpenMC, but for now this restriction needs to be respected. + +--------------------------- +Fundamental Sources of Bias +--------------------------- + +Compared to continuous energy Monte Carlo simulations, the known sources of bias +in random ray particle transport are: + + - **Multigroup Energy Discretization:** The multigroup treatment of flux and + cross sections incurs a significant bias, as a reaction rate (:math:`R_g = + V \phi_g \Sigma_g`) for an energy group :math:`g` can only be conserved + for a given choice of multigroup cross section :math:`\Sigma_g` if the + flux (:math:`\phi_g`) is known a priori. If the flux was already known, + then there would be no point to the simulation, resulting in a fundamental + need for approximating this quantity. There are numerous methods for + generating relatively accurate multigroup cross section libraries that can + each be applied to a narrow design area reliably, although there are + always limitations and/or complexities that arise with a multigroup energy + treatment. This is by far the most significant source of simulation bias + between Monte Carlo and random ray for most problems. While the other + areas typically have solutions that are highly effective at mitigating + bias, error stemming from multigroup energy discretization is much harder + to remedy. + - **Flat Source Approximation:**. In OpenMC, a "flat" (0th order) source + approximation is made, wherein the scattering and fission sources within a + cell are assumed to be spatially uniform. As the source in reality is a + continuous function, this leads to bias, although the bias can be reduced + to acceptable levels if the flat source regions are sufficiently small. + The bias can also be mitigated by assuming a higher-order source (e.g., + linear or quadratic), although OpenMC does not yet have this capability. + In practical terms, this source of bias can become very large if cells are + large (with dimensions beyond that of a typical particle mean free path), + but the subdivision of cells can often reduce this bias to trivial levels. + - **Anisotropic Source Approximation:** In OpenMC, the source is not only + assumed to be flat but also isotropic, leading to bias. It is possible for + MOC (and likely random ray) to treat anisotropy explicitly, but this is + not currently supported in OpenMC. This source of bias is not significant + for some problems, but becomes more problematic for others. Even in the + absence of explicit treatment of anistropy, use of transport-corrected + multigroup cross sections can often mitigate this bias, particularly for + light water reactor simulation problems. + - **Angular Flux Initial Conditions:** Each time a ray is sampled, its + starting angular flux is unknown, so a guess must be made (typically the + source term for the cell it starts in). Usage of an adequate inactive ray + length (dead zone) mitigates this error. As the starting guess is + attenuated at a rate of :math:`\exp(-\Sigma_t \ell)`, this bias can driven + below machine precision in a low cost manner on many problems. + +.. _Tramm-2017a: https://doi.org/10.1016/j.jcp.2017.04.038 +.. _Tramm-2017b: https://doi.org/10.1016/j.anucene.2017.10.015 +.. _Tramm-2018: https://dspace.mit.edu/handle/1721.1/119038 +.. _Tramm-2020: https://doi.org/10.1051/EPJCONF/202124703021 +.. _Cosgrove-2023: https://doi.org/10.1080/00295639.2023.2270618 + +.. only:: html + + .. rubric:: References + +.. [Askew-1972] Askew, “A Characteristics Formulation of the Neutron Transport + Equation in Complicated Geometries.” Technical Report AAEW-M 1108, UK Atomic + Energy Establishment (1972). diff --git a/docs/source/usersguide/data.rst b/docs/source/usersguide/data.rst index 48003ecb70..6af2973388 100644 --- a/docs/source/usersguide/data.rst +++ b/docs/source/usersguide/data.rst @@ -279,6 +279,8 @@ The `official ENDF/B-VII.1 HDF5 library multipole library, so if you are using this library, the windowed multipole data will already be available to you. +.. _create_mgxs: + ------------------------- Multigroup Cross Sections ------------------------- diff --git a/docs/source/usersguide/index.rst b/docs/source/usersguide/index.rst index 511bdc6cef..03a77e8706 100644 --- a/docs/source/usersguide/index.rst +++ b/docs/source/usersguide/index.rst @@ -25,4 +25,6 @@ essential aspects of using OpenMC to perform simulations. processing parallel volume + random_ray troubleshoot + \ No newline at end of file diff --git a/docs/source/usersguide/random_ray.rst b/docs/source/usersguide/random_ray.rst new file mode 100644 index 0000000000..dad86c826e --- /dev/null +++ b/docs/source/usersguide/random_ray.rst @@ -0,0 +1,480 @@ +.. _random_ray: + +================= +Random Ray Solver +================= + +In general, the random ray solver mode uses most of the same settings and +:ref:`run strategies ` as the standard Monte Carlo solver +mode. For instance, random ray solves are also split up into :ref:`inactive and +active batches `. However, there are a couple of settings +that are unique to the random ray solver and a few areas that the random ray +run strategy differs, both of which will be described in this section. + +------------------------ +Enabling Random Ray Mode +------------------------ + +To utilize the random ray solver, the :attr:`~openmc.Settings.random_ray` +dictionary must be present in the :class:`openmc.Settings` Python class. There +are a number of additional settings that must be specified within this +dictionary that will be discussed below. Additionally, the "multi-group" energy +mode must be specified. + +------- +Batches +------- + +In Monte Carlo simulations, inactive batches are used to let the fission source +develop into a stationary distribution before active batches are performed that +actually accumulate statistics. While this is true of random ray as well, in the +random ray mode the inactive batches are also used to let the scattering source +develop. Monte Carlo fully represents the scattering source within each +iteration (by its nature of fully simulating particles from birth to death +through any number of physical scattering events), whereas the scattering source +in random ray can only represent as many scattering events as batches have been +completed. For example, by iteration 10 in random ray, the scattering source +only captures the behavior of neutrons through their 10th scattering event. +Thus, while inactive batches are only required in an eigenvalue solve in Monte +Carlo, **inactive batches are required for both eigenvalue and fixed source +solves in random ray mode** due to this additional need to converge the +scattering source. + +The additional burden of converging the scattering source generally results in a +higher requirement for the number of inactive batches---often by an order of +magnitude or more. For instance, it may be reasonable to only use 50 inactive +batches for a light water reactor simulation with Monte Carlo, but random ray +might require 500 or more inactive batches. Similar to Monte Carlo, +:ref:`Shannon entropy ` can be used to gauge whether the +combined scattering and fission source has fully developed. + +Similar to Monte Carlo, active batches are used in the random ray solver mode to +accumulate and converge statistics on unknown quantities (i.e., the random ray +sources, scalar fluxes, as well as any user-specified tallies). + +The batch parameters are set in the same manner as with the regular Monte Carlo +solver:: + + settings = openmc.Settings() + settings.energy_mode = "multi-group" + settings.batches = 1200 + settings.inactive = 600 + +------------------------------- +Inactive Ray Length (Dead Zone) +------------------------------- + +A major issue with random ray is that the starting angular flux distribution for +each sampled ray is unknown. Thus, an on-the-fly method is used to build a high +quality approximation of the angular flux of the ray each iteration. This is +accomplished by running the ray through an inactive length (also known as a dead +zone length), where the ray is moved through the geometry and its angular flux +is solved for via the normal :ref:`MOC ` equation, but +no information is written back to the system. Thus, the ray is run in a "read +only" mode for the set inactive length. This parameter can be adjusted, in units +of cm, as:: + + settings.random_ray['distance_inactive'] = 40.0 + +After several mean free paths are traversed, the angular flux spectrum of the +ray becomes dominated by the in-scattering and fission source components that it +picked up when travelling through the geometry, while its original (incorrect) +starting angular flux is attenuated toward zero. Thus, longer selections of +inactive ray length will asymptotically approach the true angular flux. + +In practice, 10 mean free paths are sufficient (with light water reactors often +requiring only about 10--50 cm of inactive ray length for the error to become +undetectable). However, we caution that certain models with large quantities of +void regions (even if just limited to a few streaming channels) may require +significantly longer inactive ray lengths to ensure that the angular flux is +accurate before the conclusion of the inactive ray length. Additionally, +problems where a sensitive estimate of the uncollided flux is required (e.g., +the detector response to fast neutrons is required, and the detected is located +far away from the source in a moderator region) may require the user to specify +an inactive length that is derived from the pyhsical geometry of the simulation +problem rather than its material properties. For instance, consider a detector +placed 30 cm outside of a reactor core, with a moderator region separating the +detector from the core. In this case, rays sampled in the moderator region and +heading toward the detector will begin life with a highly scattered thermal +spectrum and will have an inaccurate fast spectrum. If the dead zone length is +only 20 cm, we might imagine such rays writing to the detector tally within +their active lengths, despite their innaccurate estimate of the uncollided fast +angular flux. Thus, an inactive length of 100--200 cm would ensure that any such +rays would still be within their inactive regions, and only rays that have +actually traversed through the core (and thus have an accurate representation of +the core's emitted fast flux) will score to the detector region while in their +active phase. + + +------------------------------------ +Active Ray Length and Number of Rays +------------------------------------ + +Once the inactive length of the ray has completed, the active region of the ray +begins. The ray is now run in regular mode, where changes in angular flux as it +traverses through each flat source region are written back to the system, so as +to contribute to the estimate for the iteration scalar flux (which is used to +compute the source for the next iteration). The active ray length can be +adjusted, in units of [cm], as:: + + settings.random_ray['distance_active'] = 400.0 + +Assuming that a sufficient inactive ray length is used so that the starting +angular flux is highly accurate, any selection of active length greater than +zero is theoretically acceptable. However, in order to adequately sample the +full integration domain, a selection of a very short track length would require +a very high number of rays to be selected. Due to the static costs per ray of +computing the starting angular flux in the dead zone, typically very short ray +lengths are undesireable. Thus, to amortize the per-ray cost of the inactive +region of the ray, it is desirable to select a very long inactive ray length. +For example, if the inactive length is set to 20 cm, a 200 cm active ray length +ensures that only about 10% of the overall simulation runtime is spent in the +inactive ray phase integration, making the dead zone a relatively inexpensive +way of estimating the angular flux. + +Thus, to fully amortize the cost of the dead zone integration, one might ask why +not simply run a single ray per iteration with an extremely long active length? +While this is also theoretically possible, this results in two issues. The first +problem is that each ray only represents a single angular sample. As we want to +sample the angular phase space of the simulation with similar fidelity to the +spatial phase space, we naturally want a lot of angles. This means in practice, +we want to balance the need to amortize the cost of the inactive region of the +ray with the need to sample lots of angles. The second problem is that +parallelism in OpenMC is expressed in terms of rays, with each being processed +by an independent MPI rank and/or OpenMP thread, thus we want to ensure each +thread has many rays to process. + +In practical terms, the best strategy is typically to set an active ray length +that is about 10 times that of the inactive ray length. This is often the right +balance between ensuring not too much time is spent in the dead zone, while +still adequately sampling the angular phase space. However, as discussed in the +previous section, some types of simulation may demand that additional thought be +applied to this parameter. For instance, in the same example where we have a +detector region far outside a reactor core, we want to make sure that there is +enough active ray length that rays exiting the core can reach the detector +region. For example, if the detector were to be 30 cm outside of the core, then +we would need to ensure that at least a few hundred cm of active length were +used so as to ensure even rays with indirect angles will be able to reach the +target region. + +The number of rays each iteration can be set by reusing the normal Monte Carlo +particle count selection parameter, as:: + + settings.particles = 2000 + +----------- +Ray Density +----------- + +In the preceding sections, it was argued that for most use cases, the inactive +length for a ray can be determined by taking a multiple of the mean free path +for the limiting energy group. The active ray length could then be set by taking +a multiple of the inactive length. With these parameters set, how many rays per +iteration should be run? + +There are three basic settings that control the density of the stochastic +quadrature being used to integrate the domain each iteration. These three +variables are: + +- The number of rays (in OpenMC settings parlance, "particles") +- The inactive distance per ray +- The active distance per ray + +While the inactive and active ray lengths can usually be chosen by simply +examining the geometry, tallies, and cross section data, one has much more +flexibility in the choice of the number of rays to run. Consider a few +scenarios: + +- If a choice of zero rays is made, then no information is gained by the system + after each batch. +- If a choice of rays close to zero is made, then some information is gained + after each batch, but many source regions may not have been visited that + iteration, which is not ideal numerically and can result in instability. + Empirically, we have found that the simulation can remain stable and produce + accurate results even when on average 20% or more of the cells have zero rays + passing through them each iteration. However, besides the cost of transporting + rays, a new neutron source must be computed based on the scalar flux at each + iteration. This cost is dictated only by the number of source regions and + energy groups---it is independent of the number of rays. Thus, in practical + terms, if too few rays are run, then the simulation runtime becomes dominated + by the fixed cost of source updates, making it inefficient overall given that + a huge number of active batches will likely be required to converge statistics + to acceptable levels. Additionally, if many cells are missed each iteration, + then the fission and scattering sources may not develop very quickly, + resulting in a need for far more inactive batches than might otherwise be + required. +- If a choice of running a very large number of rays is made such that you + guarantee that all cells are hit each iteration, this avoids any issues with + numerical instability. As even more rays are run, this reduces the number of + active batches that must be used to converge statistics and therefore + minimizes the fixed per-iteration source update costs. While this seems + advantageous, it has the same practical downside as with Monte Carlo---namely, + that the inactive batches tend to be overly well integrated, resulting in a + lot of wasted time. This issue is actually much more serious than in Monte + Carlo (where typically only tens of inactive batches are needed), as random + ray often requires hundreds or even thousands of inactive batches. Thus, + minimizing the cost of the source updates in the active phase needs to be + balanced against the increased cost of the inactive phase of the simulation. +- If a choice of rays is made such that relatively few (e.g., around 0.1%) of + cells are missed each iteration, the cost of the inactive batches of the + simulation is minimized. In this "goldilocks" regime, there is very little + chance of numerical instability, and enough information is gained by each cell + to progress the fission and scattering sources forward at their maximum rate. + However, the inactive batches can proceed with minimal cost. While this will + result in the active phase of the simulation requiring more batches (and + correspondingly higher source update costs), the added cost is typically far + less than the savings by making the inactive phase much cheaper. + +To help you set this parameter, OpenMC will report the average flat source +region miss rate at the end of the simulation. Additionally, OpenMC will alert +you if very high miss rates are detected, indicating that more rays and/or a +longer active ray length might improve numerical performance. Thus, a "guess and +check" approach to this parameter is recommended, where a very low guess is +made, a few iterations are performed, and then the simulation is restarted with +a larger value until the "low ray density" messages go away. + +.. note:: + In summary, the user should select an inactive length corresponding to many + times the mean free path of a particle, generally O(10--100) cm, to ensure accuracy of + the starting angular flux. The active length should be 10× the inactive + length to amortize its cost. The number of rays should be enough so that + nearly all :ref:`FSRs ` are hit at least once each power iteration (the hit fraction + is reported by OpenMC for empirical user adjustment). + +.. warning:: + For simulations where long range uncollided flux estimates need to be + accurately resolved (e.g., shielding, detector response, and problems with + significant void areas), make sure that selections for inactive and active + ray lengths are sufficiently long to allow for transport to occur between + source and target regions of interest. + +---------- +Ray Source +---------- + +Random ray requires that the ray source be uniform in space and isotropic in +angle. To facilitate sampling, the user must specify a single random ray source +for sampling rays in both eigenvalue and fixed source solver modes. The random +ray integration source should be of type :class:`openmc.IndependentSource`, and +is specified as part of the :attr:`openmc.Settings.random_ray` dictionary. Note +that the source must not be limited to only fissionable regions. Additionally, +the source box must cover the entire simulation domain. In the case of a +simulation domain that is not box shaped, a box source should still be used to +bound the domain but with the source limited to rejection sampling the actual +simulation universe (which can be specified via the ``domains`` field of the +:class:`openmc.IndependentSource` Python class). Similar to Monte Carlo sources, +for two-dimensional problems (e.g., a 2D pincell) it is desirable to make the +source bounded near the origin of the infinite dimension. An example of an +acceptable ray source for a two-dimensional 2x2 lattice would look like: + +:: + + pitch = 1.26 + lower_left = (-pitch, -pitch, -pitch) + upper_right = ( pitch, pitch, pitch) + uniform_dist = openmc.stats.Box(lower_left, upper_right) + settings.random_ray['ray_source'] = openmc.IndependentSource(space=uniform_dist) + +.. note:: + The random ray source is not related to the underlying particle flux or + source distribution of the simulation problem. It is akin to the selection + of an integration quadrature. Thus, in fixed source mode, the ray source + still needs to be provided and still needs to be uniform in space and angle + throughout the simulation domain. In fixed source mode, the user will + provide physical particle fixed sources in addition to the random ray + source. + +.. _subdivision_fsr: + +---------------------------------- +Subdivision of Flat Source Regions +---------------------------------- + +While the scattering and fission sources in Monte Carlo +are treated continuously, they are assumed to be invariant (flat) within a +MOC or random ray flat source region (FSR). This introduces bias into the +simulation, which can be remedied by reducing the physical size of the FSR +to dimensions below that of typical mean free paths of particles. + +In OpenMC, this subdivision currently must be done manually. The level of +subdivision needed will be dependent on the fidelity the user requires. For +typical light water reactor analysis, consider the following example subdivision +of a two-dimensional 2x2 reflective pincell lattice: + +.. figure:: ../_images/2x2_materials.jpeg + :class: with-border + :width: 400 + + Material definition for an asymmetrical 2x2 lattice (1.26 cm pitch) + +.. figure:: ../_images/2x2_fsrs.jpeg + :class: with-border + :width: 400 + + FSR decomposition for an asymmetrical 2x2 lattice (1.26 cm pitch) + +In the future, automated subdivision of FSRs via mesh overlay may be supported. + +------- +Tallies +------- + +Most tallies, filters, and scores that you would expect to work with a +multigroup solver like random ray are supported. For example, you can define 3D +mesh tallies with energy filters and flux, fission, and nu-fission scores, etc. +There are some restrictions though. For starters, it is assumed that all filter +mesh boundaries will conform to physical surface boundaries (or lattice +boundaries) in the simulation geometry. It is acceptable for multiple cells +(FSRs) to be contained within a mesh element (e.g., pincell-level or +assembly-level tallies should work), but it is currently left as undefined +behavior if a single simulation cell is contained in multiple mesh elements. + +Supported scores: + - flux + - total + - fission + - nu-fission + - events + +Supported Estimators: + - tracklength + +Supported Filters: + - cell + - cell instance + - distribcell + - energy + - material + - mesh + - universe + +Note that there is no difference between the analog, tracklength, and collision +estimators in random ray mode as individual particles are not being simulated. +Tracklength-style tally estimation is inherent to the random ray method. + +-------- +Plotting +-------- + +Visualization of geometry is handled in the same way as normal with OpenMC (see +:ref:`plotting guide ` for more details). That is, ``openmc +--plot`` is handled without any modifications, as the random ray solver uses the +same geometry definition as in Monte Carlo. + +In addition to OpenMC's standard geometry plotting mode, the random ray solver +also features an additional method of data visualization. If a ``plots.xml`` +file is present, any voxel plots that are defined will be output at the end of a +random ray simulation. Rather than being stored in HDF5 file format, the random +ray plotting will generate ``.vtk`` files that can be directly read and plotted +with `Paraview `_. + +In fixed source Monte Carlo (MC) simulations, by default the only thing global +tally provided is the leakage fraction. In a k-eigenvalue MC simulation, by +default global tallies are collected for the eigenvalue and leakage fraction. +Spatial flux information must be manually requested, and often fine-grained +spatial meshes are considered costly/unnecessary, so it is impractical in MC +mode to plot spatial flux or power info by default. Conversely, in random ray, +the solver functions by estimating the multigroup source and flux spectrums in +every fine-grained FSR each iteration. Thus, for random ray, in both fixed +source and eigenvalue simulations, the simulation always finishes with a well +converged flux estimate for all areas. As such, it is much more common in random +ray, MOC, and other deterministic codes to provide spatial flux information by +default. In the future, all FSR data will be made available in the statepoint +file, which facilitates plotting and manipulation through the Python API; at +present, statepoint support is not available. + +Only voxel plots will be used to generate output; other plot types present in +the ``plots.xml`` file will be ignored. The following fields will be written to +the VTK structured grid file: + + - material + - FSR index + - flux spectrum (for each energy group) + - total fission source (integrated across all energy groups) + +------------------------------------------ +Inputting Multigroup Cross Sections (MGXS) +------------------------------------------ + +Multigroup cross sections for use with OpenMC's random ray solver are input the +same way as with OpenMC's traditional multigroup Monte Carlo mode. There is more +information on generating multigroup cross sections via OpenMC in the +:ref:`multigroup materials ` user guide. You may also wish to +use an existing multigroup library. An example of using OpenMC's Python +interface to generate a correctly formatted ``mgxs.h5`` input file is given +in the `OpenMC Jupyter notebook collection +`_. + +.. note:: + Currently only isotropic and isothermal multigroup cross sections are + supported in random ray mode. To represent multiple material temperatures, + separate materials can be defined each with a separate multigroup dataset + corresponding to a given temperature. + +--------------------------------------- +Putting it All Together: Example Inputs +--------------------------------------- + +An example of a settings definition for random ray is given below:: + + # Geometry and MGXS material definition of 2x2 lattice (not shown) + pitch = 1.26 + group_edges = [1e-5, 0.0635, 10.0, 1.0e2, 1.0e3, 0.5e6, 1.0e6, 20.0e6] + ... + + # Instantiate a settings object for a random ray solve + settings = openmc.Settings() + settings.energy_mode = "multi-group" + settings.batches = 1200 + settings.inactive = 600 + settings.particles = 2000 + + settings.random_ray['distance_inactive'] = 40.0 + settings.random_ray['distance_active'] = 400.0 + + # Create an initial uniform spatial source distribution for sampling rays + lower_left = (-pitch, -pitch, -pitch) + upper_right = ( pitch, pitch, pitch) + uniform_dist = openmc.stats.Box(lower_left, upper_right) + settings.random_ray['ray_source'] = openmc.IndependentSource(space=uniform_dist) + + settings.export_to_xml() + + # Define tallies + + # Create a mesh filter + mesh = openmc.RegularMesh() + mesh.dimension = (2, 2) + mesh.lower_left = (-pitch/2, -pitch/2) + mesh.upper_right = (pitch/2, pitch/2) + mesh_filter = openmc.MeshFilter(mesh) + + # Create a multigroup energy filter + energy_filter = openmc.EnergyFilter(group_edges) + + # Create tally using our two filters and add scores + tally = openmc.Tally() + tally.filters = [mesh_filter, energy_filter] + tally.scores = ['flux', 'fission', 'nu-fission'] + + # Instantiate a Tallies collection and export to XML + tallies = openmc.Tallies([tally]) + tallies.export_to_xml() + + # Create voxel plot + plot = openmc.Plot() + plot.origin = [0, 0, 0] + plot.width = [2*pitch, 2*pitch, 1] + plot.pixels = [1000, 1000, 1] + plot.type = 'voxel' + + # Instantiate a Plots collection and export to XML + plots = openmc.Plots([plot]) + plots.export_to_xml() + +All other inputs (e.g., geometry, materials) will be unchanged from a typical +Monte Carlo run (see the :ref:`geometry ` and +:ref:`multigroup materials ` user guides for more information). + +There is also a complete example of a pincell available in the +``openmc/examples/pincell_random_ray`` folder. diff --git a/examples/pincell_random_ray/build_xml.py b/examples/pincell_random_ray/build_xml.py new file mode 100644 index 0000000000..b3dd8020a5 --- /dev/null +++ b/examples/pincell_random_ray/build_xml.py @@ -0,0 +1,203 @@ +import numpy as np +import openmc +import openmc.mgxs + +############################################################################### +# Create multigroup data + +# Instantiate the energy group data +group_edges = [1e-5, 0.0635, 10.0, 1.0e2, 1.0e3, 0.5e6, 1.0e6, 20.0e6] +groups = openmc.mgxs.EnergyGroups(group_edges) + +# Instantiate the 7-group (C5G7) cross section data +uo2_xsdata = openmc.XSdata('UO2', groups) +uo2_xsdata.order = 0 +uo2_xsdata.set_total( + [0.1779492, 0.3298048, 0.4803882, 0.5543674, 0.3118013, 0.3951678, + 0.5644058]) +uo2_xsdata.set_absorption([8.0248e-03, 3.7174e-03, 2.6769e-02, 9.6236e-02, + 3.0020e-02, 1.1126e-01, 2.8278e-01]) +scatter_matrix = np.array( + [[[0.1275370, 0.0423780, 0.0000094, 0.0000000, 0.0000000, 0.0000000, 0.0000000], + [0.0000000, 0.3244560, 0.0016314, 0.0000000, 0.0000000, 0.0000000, 0.0000000], + [0.0000000, 0.0000000, 0.4509400, 0.0026792, 0.0000000, 0.0000000, 0.0000000], + [0.0000000, 0.0000000, 0.0000000, 0.4525650, 0.0055664, 0.0000000, 0.0000000], + [0.0000000, 0.0000000, 0.0000000, 0.0001253, 0.2714010, 0.0102550, 0.0000000], + [0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0012968, 0.2658020, 0.0168090], + [0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0085458, 0.2730800]]]) +scatter_matrix = np.rollaxis(scatter_matrix, 0, 3) +uo2_xsdata.set_scatter_matrix(scatter_matrix) +uo2_xsdata.set_fission([7.21206e-03, 8.19301e-04, 6.45320e-03, + 1.85648e-02, 1.78084e-02, 8.30348e-02, + 2.16004e-01]) +uo2_xsdata.set_nu_fission([2.005998e-02, 2.027303e-03, 1.570599e-02, + 4.518301e-02, 4.334208e-02, 2.020901e-01, + 5.257105e-01]) +uo2_xsdata.set_chi([5.8791e-01, 4.1176e-01, 3.3906e-04, 1.1761e-07, 0.0000e+00, + 0.0000e+00, 0.0000e+00]) + +h2o_xsdata = openmc.XSdata('LWTR', groups) +h2o_xsdata.order = 0 +h2o_xsdata.set_total([0.15920605, 0.412969593, 0.59030986, 0.58435, + 0.718, 1.2544497, 2.650379]) +h2o_xsdata.set_absorption([6.0105e-04, 1.5793e-05, 3.3716e-04, + 1.9406e-03, 5.7416e-03, 1.5001e-02, + 3.7239e-02]) +scatter_matrix = np.array( + [[[0.0444777, 0.1134000, 0.0007235, 0.0000037, 0.0000001, 0.0000000, 0.0000000], + [0.0000000, 0.2823340, 0.1299400, 0.0006234, 0.0000480, 0.0000074, 0.0000010], + [0.0000000, 0.0000000, 0.3452560, 0.2245700, 0.0169990, 0.0026443, 0.0005034], + [0.0000000, 0.0000000, 0.0000000, 0.0910284, 0.4155100, 0.0637320, 0.0121390], + [0.0000000, 0.0000000, 0.0000000, 0.0000714, 0.1391380, 0.5118200, 0.0612290], + [0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0022157, 0.6999130, 0.5373200], + [0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.1324400, 2.4807000]]]) +scatter_matrix = np.rollaxis(scatter_matrix, 0, 3) +h2o_xsdata.set_scatter_matrix(scatter_matrix) + +mg_cross_sections_file = openmc.MGXSLibrary(groups) +mg_cross_sections_file.add_xsdatas([uo2_xsdata, h2o_xsdata]) +mg_cross_sections_file.export_to_hdf5() + +############################################################################### +# Create materials for the problem + +# Instantiate some Materials and register the appropriate macroscopic data +uo2 = openmc.Material(name='UO2 fuel') +uo2.set_density('macro', 1.0) +uo2.add_macroscopic('UO2') + +water = openmc.Material(name='Water') +water.set_density('macro', 1.0) +water.add_macroscopic('LWTR') + +# Instantiate a Materials collection and export to XML +materials_file = openmc.Materials([uo2, water]) +materials_file.cross_sections = "mgxs.h5" +materials_file.export_to_xml() + +############################################################################### +# Define problem geometry + +# The geometry we will define a simplified pincell with fuel radius 0.54 cm +# surrounded by moderator (same as in the multigroup example). +# In random ray, we typically want several radial regions and azimuthal +# sectors in both the fuel and moderator areas of the pincell. This is +# due to the flat source approximation requiring that source regions are +# small compared to the typical mean free path of a neutron. Below we +# sudivide the basic pincell into 8 aziumthal sectors (pizza slices) and +# 5 concentric rings in both the fuel and moderator. + +# TODO: When available in OpenMC, use cylindrical lattice instead to +# simplify definition and improve runtime performance. + +pincell_base = openmc.Universe() + +# These are the subdivided radii (creating 5 concentric regions in the +# fuel and moderator) +ring_radii = [0.241, 0.341, 0.418, 0.482, 0.54, 0.572, 0.612, 0.694, 0.786] +fills = [uo2, uo2, uo2, uo2, uo2, water, water, water, water, water] + +# We then create cells representing the bounded rings, with special +# treatment for both the innermost and outermost cells +cells = [] +for r in range(10): + cell = [] + if r == 0: + outer_bound = openmc.ZCylinder(r=ring_radii[r]) + cell = openmc.Cell(fill=fills[r], region=-outer_bound) + elif r == 9: + inner_bound = openmc.ZCylinder(r=ring_radii[r-1]) + cell = openmc.Cell(fill=fills[r], region=+inner_bound) + else: + inner_bound = openmc.ZCylinder(r=ring_radii[r-1]) + outer_bound = openmc.ZCylinder(r=ring_radii[r]) + cell = openmc.Cell(fill=fills[r], region=+inner_bound & -outer_bound) + pincell_base.add_cell(cell) + +# We then generate 8 planes to bound 8 azimuthal sectors +azimuthal_planes = [] +for i in range(8): + angle = 2 * i * openmc.pi / 8 + normal_vector = (-openmc.sin(angle), openmc.cos(angle), 0) + azimuthal_planes.append(openmc.Plane(a=normal_vector[0], b=normal_vector[1], c=normal_vector[2], d=0)) + +# Create a cell for each azimuthal sector using the pincell base class +azimuthal_cells = [] +for i in range(8): + azimuthal_cell = openmc.Cell(name=f'azimuthal_cell_{i}') + azimuthal_cell.fill = pincell_base + azimuthal_cell.region = +azimuthal_planes[i] & -azimuthal_planes[(i+1) % 8] + azimuthal_cells.append(azimuthal_cell) + +# Create the (subdivided) geometry with the azimuthal universes +pincell = openmc.Universe(cells=azimuthal_cells) + +# Create a region represented as the inside of a rectangular prism +pitch = 1.26 +box = openmc.model.RectangularPrism(pitch, pitch, boundary_type='reflective') +pincell_bounded = openmc.Cell(fill=pincell, region=-box, name='pincell') + +# Create a geometry (specifying merge surfaces option to remove +# all the redundant cylinder/plane surfaces) and export to XML +geometry = openmc.Geometry([pincell_bounded], merge_surfaces=True) +geometry.export_to_xml() + +############################################################################### +# Define problem settings + +# Instantiate a Settings object, set all runtime parameters, and export to XML +settings = openmc.Settings() +settings.energy_mode = "multi-group" +settings.batches = 600 +settings.inactive = 300 +settings.particles = 50 + +# Create an initial uniform spatial source distribution for sampling rays. +# Note that this must be uniform in space and angle. +lower_left = (-pitch/2, -pitch/2, -1) +upper_right = (pitch/2, pitch/2, 1) +uniform_dist = openmc.stats.Box(lower_left, upper_right) +settings.random_ray['ray_source'] = openmc.IndependentSource(space=uniform_dist) +settings.random_ray['distance_inactive'] = 40.0 +settings.random_ray['distance_active'] = 400.0 + +settings.export_to_xml() + +############################################################################### +# Define tallies + +# Create a mesh that will be used for tallying +mesh = openmc.RegularMesh() +mesh.dimension = (2, 2) +mesh.lower_left = (-pitch/2, -pitch/2) +mesh.upper_right = (pitch/2, pitch/2) + +# Create a mesh filter that can be used in a tally +mesh_filter = openmc.MeshFilter(mesh) + +# Let's also create a filter to measure each group +# indepdendently +energy_filter = openmc.EnergyFilter(group_edges) + +# Now use the mesh filter in a tally and indicate what scores are desired +tally = openmc.Tally(name="Mesh and Energy tally") +tally.filters = [mesh_filter, energy_filter] +tally.scores = ['flux', 'fission', 'nu-fission'] + +# Instantiate a Tallies collection and export to XML +tallies = openmc.Tallies([tally]) +tallies.export_to_xml() + +############################################################################### +# Exporting to OpenMC plots.xml file +############################################################################### + +plot = openmc.Plot() +plot.origin = [0, 0, 0] +plot.width = [pitch, pitch, pitch] +plot.pixels = [1000, 1000, 1] +plot.type = 'voxel' + +# Instantiate a Plots collection and export to XML +plots = openmc.Plots([plot]) +plots.export_to_xml() diff --git a/include/openmc/boundary_condition.h b/include/openmc/boundary_condition.h index e63e232e9a..cd8988162f 100644 --- a/include/openmc/boundary_condition.h +++ b/include/openmc/boundary_condition.h @@ -10,6 +10,7 @@ namespace openmc { // Forward declare some types used in function arguments. class Particle; +class RandomRay; class Surface; //============================================================================== diff --git a/include/openmc/cell.h b/include/openmc/cell.h index c405f536b5..e68443a32f 100644 --- a/include/openmc/cell.h +++ b/include/openmc/cell.h @@ -249,6 +249,42 @@ public: std::unordered_map> get_contained_cells( int32_t instance = 0, Position* hint = nullptr) const; + //! Determine the material index corresponding to a specific cell instance, + //! taking into account presence of distribcell material + //! \param[in] instance of the cell + //! \return material index + int32_t material(int32_t instance) const + { + // If distributed materials are used, then each instance has its own + // material definition. If distributed materials are not used, then + // all instances used the same material stored at material_[0]. The + // presence of distributed materials is inferred from the size of + // the material_ vector being greater than one. + if (material_.size() > 1) { + return material_[instance]; + } else { + return material_[0]; + } + } + + //! Determine the temperature index corresponding to a specific cell instance, + //! taking into account presence of distribcell temperature + //! \param[in] instance of the cell + //! \return temperature index + double sqrtkT(int32_t instance) const + { + // If distributed materials are used, then each instance has its own + // temperature definition. If distributed materials are not used, then + // all instances used the same temperature stored at sqrtkT_[0]. The + // presence of distributed materials is inferred from the size of + // the sqrtkT_ vector being greater than one. + if (sqrtkT_.size() > 1) { + return sqrtkT_[instance]; + } else { + return sqrtkT_[0]; + } + } + protected: //! Determine the path to this cell instance in the geometry hierarchy //! \param[in] instance of the cell to find parent cells for diff --git a/include/openmc/constants.h b/include/openmc/constants.h index ba558b0408..1c683e5f50 100644 --- a/include/openmc/constants.h +++ b/include/openmc/constants.h @@ -340,6 +340,8 @@ enum class RunMode { VOLUME }; +enum class SolverType { MONTE_CARLO, RANDOM_RAY }; + //============================================================================== // Geometry Constants diff --git a/include/openmc/mgxs.h b/include/openmc/mgxs.h index d77c34bd5f..3fb0608104 100644 --- a/include/openmc/mgxs.h +++ b/include/openmc/mgxs.h @@ -29,7 +29,6 @@ private: int num_delayed_groups; // number of delayed neutron groups vector xs; // Cross section data // MGXS Incoming Flux Angular grid information - bool is_isotropic; // used to skip search for angle indices if isotropic int n_pol; int n_azi; vector polar; @@ -85,6 +84,8 @@ public: std::string name; // name of dataset, e.g., UO2 double awr; // atomic weight ratio bool fissionable; // Is this fissionable + bool is_isotropic { + true}; // used to skip search for angle indices if isotropic Mgxs() = default; diff --git a/include/openmc/output.h b/include/openmc/output.h index 1ef95a8879..1ece3de960 100644 --- a/include/openmc/output.h +++ b/include/openmc/output.h @@ -57,6 +57,8 @@ void print_results(); void write_tallies(); +void show_time(const char* label, double secs, int indent_level = 0); + } // namespace openmc #endif // OPENMC_OUTPUT_H @@ -80,4 +82,4 @@ struct formatter> { } }; -} // namespace fmt \ No newline at end of file +} // namespace fmt diff --git a/include/openmc/plot.h b/include/openmc/plot.h index 7b66036fb7..ee6c3fbec5 100644 --- a/include/openmc/plot.h +++ b/include/openmc/plot.h @@ -100,6 +100,7 @@ public: virtual void print_info() const = 0; const std::string& path_plot() const { return path_plot_; } + std::string& path_plot() { return path_plot_; } int id() const { return id_; } int level() const { return level_; } diff --git a/include/openmc/random_ray/flat_source_domain.h b/include/openmc/random_ray/flat_source_domain.h new file mode 100644 index 0000000000..5f64914edb --- /dev/null +++ b/include/openmc/random_ray/flat_source_domain.h @@ -0,0 +1,141 @@ +#ifndef OPENMC_RANDOM_RAY_FLAT_SOURCE_DOMAIN_H +#define OPENMC_RANDOM_RAY_FLAT_SOURCE_DOMAIN_H + +#include "openmc/openmp_interface.h" +#include "openmc/position.h" + +namespace openmc { + +/* + * The FlatSourceDomain class encompasses data and methods for storing + * scalar flux and source region for all flat source regions in a + * random ray simulation domain. + */ + +class FlatSourceDomain { +public: + //---------------------------------------------------------------------------- + // Helper Structs + + // A mapping object that is used to map between a specific random ray + // source region and an OpenMC native tally bin that it should score to + // every iteration. + struct TallyTask { + int tally_idx; + int filter_idx; + int score_idx; + int score_type; + TallyTask(int tally_idx, int filter_idx, int score_idx, int score_type) + : tally_idx(tally_idx), filter_idx(filter_idx), score_idx(score_idx), + score_type(score_type) + {} + }; + + //---------------------------------------------------------------------------- + // Constructors + FlatSourceDomain(); + + //---------------------------------------------------------------------------- + // Methods + void update_neutron_source(double k_eff); + double compute_k_eff(double k_eff_old) const; + void normalize_scalar_flux_and_volumes( + double total_active_distance_per_iteration); + int64_t add_source_to_scalar_flux(); + void batch_reset(); + void convert_source_regions_to_tallies(); + void random_ray_tally() const; + void accumulate_iteration_flux(); + void output_to_vtk() const; + void all_reduce_replicated_source_regions(); + + //---------------------------------------------------------------------------- + // Public Data members + + bool mapped_all_tallies_ {false}; // If all source regions have been visited + + int64_t n_source_regions_ {0}; // Total number of source regions in the model + + // 1D array representing source region starting offset for each OpenMC Cell + // in model::cells + vector source_region_offsets_; + + // 1D arrays representing values for all source regions + vector lock_; + vector was_hit_; + vector volume_; + vector position_recorded_; + vector position_; + + // 2D arrays stored in 1D representing values for all source regions x energy + // groups + vector scalar_flux_old_; + vector scalar_flux_new_; + vector source_; + + //---------------------------------------------------------------------------- + // Private data members +private: + int negroups_; // Number of energy groups in simulation + int64_t n_source_elements_ {0}; // Total number of source regions in the model + // times the number of energy groups + + // 2D array representing values for all source regions x energy groups x tally + // tasks + vector> tally_task_; + + // 1D arrays representing values for all source regions + vector material_; + vector volume_t_; + + // 2D arrays stored in 1D representing values for all source regions x energy + // groups + vector scalar_flux_final_; + +}; // class FlatSourceDomain + +//============================================================================ +//! Non-member functions +//============================================================================ + +// Returns the inputted value in big endian byte ordering. If the system is +// little endian, the byte ordering is flipped. If the system is big endian, +// the inputted value is returned as is. This function is necessary as +// .vtk binary files use big endian byte ordering. +template +T convert_to_big_endian(T in) +{ + // 4 byte integer + uint32_t test = 1; + + // 1 byte pointer to first byte of test integer + uint8_t* ptr = reinterpret_cast(&test); + + // If the first byte of test is 0, then the system is big endian. In this + // case, we don't have to do anything as .vtk files are big endian + if (*ptr == 0) + return in; + + // Otherwise, the system is in little endian, so we need to flip the + // endianness + uint8_t* orig = reinterpret_cast(&in); + uint8_t swapper[sizeof(T)]; + for (int i = 0; i < sizeof(T); i++) { + swapper[i] = orig[sizeof(T) - i - 1]; + } + T out = *reinterpret_cast(&swapper); + return out; +} + +template +void parallel_fill(vector& arr, T value) +{ +#pragma omp parallel for schedule(static) + for (int i = 0; i < arr.size(); i++) { + arr[i] = value; + } +} + +} // namespace openmc + +#endif // OPENMC_RANDOM_RAY_FLAT_SOURCE_DOMAIN_H diff --git a/include/openmc/random_ray/random_ray.h b/include/openmc/random_ray/random_ray.h new file mode 100644 index 0000000000..c114007c63 --- /dev/null +++ b/include/openmc/random_ray/random_ray.h @@ -0,0 +1,55 @@ +#ifndef OPENMC_RANDOM_RAY_H +#define OPENMC_RANDOM_RAY_H + +#include "openmc/memory.h" +#include "openmc/particle.h" +#include "openmc/random_ray/flat_source_domain.h" +#include "openmc/source.h" + +namespace openmc { + +/* + * The RandomRay class encompasses data and methods for transporting random rays + * through the model. It is a small extension of the Particle class. + */ + +// TODO: Inherit from GeometryState instead of Particle +class RandomRay : public Particle { +public: + //---------------------------------------------------------------------------- + // Constructors + RandomRay(); + RandomRay(uint64_t ray_id, FlatSourceDomain* domain); + + //---------------------------------------------------------------------------- + // Methods + void event_advance_ray(); + void attenuate_flux(double distance, bool is_active); + void initialize_ray(uint64_t ray_id, FlatSourceDomain* domain); + uint64_t transport_history_based_single_ray(); + + //---------------------------------------------------------------------------- + // Static data members + static double distance_inactive_; // Inactive (dead zone) ray length + static double distance_active_; // Active ray length + static unique_ptr ray_source_; // Starting source for ray sampling + + //---------------------------------------------------------------------------- + // Public data members + vector angular_flux_; + + //---------------------------------------------------------------------------- + // Private data members +private: + FlatSourceDomain* domain_ {nullptr}; // pointer to domain that has flat source + // data needed for ray transport + vector delta_psi_; + double distance_travelled_ {0}; + int negroups_; + bool is_active_ {false}; + bool is_alive_ {true}; +}; // class RandomRay + +} // namespace openmc + +#endif // OPENMC_RANDOM_RAY_H diff --git a/include/openmc/random_ray/random_ray_simulation.h b/include/openmc/random_ray/random_ray_simulation.h new file mode 100644 index 0000000000..cde0d27dff --- /dev/null +++ b/include/openmc/random_ray/random_ray_simulation.h @@ -0,0 +1,59 @@ +#ifndef OPENMC_RANDOM_RAY_SIMULATION_H +#define OPENMC_RANDOM_RAY_SIMULATION_H + +#include "openmc/random_ray/flat_source_domain.h" + +namespace openmc { + +/* + * The RandomRaySimulation class encompasses data and methods for running a + * random ray simulation. + */ + +class RandomRaySimulation { +public: + //---------------------------------------------------------------------------- + // Constructors + RandomRaySimulation(); + + //---------------------------------------------------------------------------- + // Methods + void simulate(); + void reduce_simulation_statistics(); + void output_simulation_results() const; + void instability_check( + int64_t n_hits, double k_eff, double& avg_miss_rate) const; + void print_results_random_ray(uint64_t total_geometric_intersections, + double avg_miss_rate, int negroups, int64_t n_source_regions) const; + + //---------------------------------------------------------------------------- + // Data members +private: + // Contains all flat source region data + FlatSourceDomain domain_; + + // Random ray eigenvalue + double k_eff_ {1.0}; + + // Tracks the average FSR miss rate for analysis and reporting + double avg_miss_rate_ {0.0}; + + // Tracks the total number of geometric intersections by all rays for + // reporting + uint64_t total_geometric_intersections_ {0}; + + // Number of energy groups + int negroups_; + +}; // class RandomRaySimulation + +//============================================================================ +//! Non-member functions +//============================================================================ + +void openmc_run_random_ray(); +void validate_random_ray_inputs(); + +} // namespace openmc + +#endif // OPENMC_RANDOM_RAY_SIMULATION_H diff --git a/include/openmc/settings.h b/include/openmc/settings.h index 5e60009bcd..b71ec36493 100644 --- a/include/openmc/settings.h +++ b/include/openmc/settings.h @@ -112,8 +112,9 @@ extern ResScatMethod res_scat_method; //!< resonance upscattering method extern double res_scat_energy_min; //!< Min energy in [eV] for res. upscattering extern double res_scat_energy_max; //!< Max energy in [eV] for res. upscattering extern vector - res_scat_nuclides; //!< Nuclides using res. upscattering treatment -extern RunMode run_mode; //!< Run mode (eigenvalue, fixed src, etc.) + res_scat_nuclides; //!< Nuclides using res. upscattering treatment +extern RunMode run_mode; //!< Run mode (eigenvalue, fixed src, etc.) +extern SolverType solver_type; //!< Solver Type (Monte Carlo or Random Ray) extern std::unordered_set sourcepoint_batch; //!< Batches when source should be written extern std::unordered_set @@ -139,6 +140,7 @@ extern int trigger_batch_interval; //!< Batch interval for triggers extern "C" int verbosity; //!< How verbose to make output extern double weight_cutoff; //!< Weight cutoff for Russian roulette extern double weight_survive; //!< Survival weight after Russian roulette + } // namespace settings //============================================================================== diff --git a/include/openmc/timer.h b/include/openmc/timer.h index 62b97883f4..d928aad456 100644 --- a/include/openmc/timer.h +++ b/include/openmc/timer.h @@ -31,6 +31,7 @@ extern Timer time_event_advance_particle; extern Timer time_event_surface_crossing; extern Timer time_event_collision; extern Timer time_event_death; +extern Timer time_update_src; } // namespace simulation diff --git a/openmc/settings.py b/openmc/settings.py index 828d5aef44..6312460290 100644 --- a/openmc/settings.py +++ b/openmc/settings.py @@ -86,7 +86,8 @@ class Settings: .. versionadded:: 0.12 rel_max_lost_particles : float - Maximum number of lost particles, relative to the total number of particles + Maximum number of lost particles, relative to the total number of + particles .. versionadded:: 0.12 inactive : int @@ -146,6 +147,18 @@ class Settings: Initial seed for randomly generated plot colors. ptables : bool Determine whether probability tables are used. + random_ray : dict + Options for configuring the random ray solver. Acceptable keys are: + + :distance_inactive: + Indicates the total active distance in [cm] a ray should travel + :distance_active: + Indicates the total active distance in [cm] a ray should travel + :ray_source: + Starting ray distribution (must be uniform in space and angle) as + specified by a :class:`openmc.SourceBase` object. + + .. versionadded:: 0.14.1 resonance_scattering : dict Settings for resonance elastic scattering. Accepted keys are 'enable' (bool), 'method' (str), 'energy_min' (float), 'energy_max' (float), and @@ -153,10 +166,10 @@ class Settings: rejection correction) or 'rvs' (relative velocity sampling). If not specified, 'rvs' is the default method. The 'energy_min' and 'energy_max' values indicate the minimum and maximum energies above and - below which the resonance elastic scattering method is to be - applied. The 'nuclides' list indicates what nuclides the method should - be applied to. In its absence, the method will be applied to all - nuclides with 0 K elastic scattering data present. + below which the resonance elastic scattering method is to be applied. + The 'nuclides' list indicates what nuclides the method should be applied + to. In its absence, the method will be applied to all nuclides with 0 K + elastic scattering data present. run_mode : {'eigenvalue', 'fixed source', 'plot', 'volume', 'particle restart'} The type of calculation to perform (default is 'eigenvalue') seed : int @@ -185,26 +198,26 @@ class Settings: :surface_ids: List of surface ids at which crossing particles are to be banked (int) - :max_particles: Maximum number of particles to be banked on - surfaces per process (int) + :max_particles: Maximum number of particles to be banked on surfaces per + process (int) :mcpl: Output in the form of an MCPL-file (bool) survival_biasing : bool Indicate whether survival biasing is to be used tabular_legendre : dict Determines if a multi-group scattering moment kernel expanded via Legendre polynomials is to be converted to a tabular distribution or - not. Accepted keys are 'enable' and 'num_points'. The value for - 'enable' is a bool stating whether the conversion to tabular is - performed; the value for 'num_points' sets the number of points to use - in the tabular distribution, should 'enable' be True. + not. Accepted keys are 'enable' and 'num_points'. The value for 'enable' + is a bool stating whether the conversion to tabular is performed; the + value for 'num_points' sets the number of points to use in the tabular + distribution, should 'enable' be True. temperature : dict Defines a default temperature and method for treating intermediate temperatures at which nuclear data doesn't exist. Accepted keys are 'default', 'method', 'range', 'tolerance', and 'multipole'. The value for 'default' should be a float representing the default temperature in Kelvin. The value for 'method' should be 'nearest' or 'interpolation'. - If the method is 'nearest', 'tolerance' indicates a range of - temperature within which cross sections may be used. If the method is + If the method is 'nearest', 'tolerance' indicates a range of temperature + within which cross sections may be used. If the method is 'interpolation', 'tolerance' indicates the range of temperatures outside of the available cross section temperatures where cross sections will evaluate to the nearer bound. The value for 'range' should be a pair of @@ -348,6 +361,8 @@ class Settings: self._max_splits = None self._max_tracks = None + self._random_ray = {} + for key, value in kwargs.items(): setattr(self, key, value) @@ -1030,6 +1045,31 @@ class Settings: wwgs = [wwgs] self._weight_window_generators = cv.CheckedList(WeightWindowGenerator, 'weight window generators', wwgs) + @property + def random_ray(self) -> dict: + return self._random_ray + + @random_ray.setter + def random_ray(self, random_ray: dict): + if not isinstance(random_ray, Mapping): + raise ValueError(f'Unable to set random_ray from "{random_ray}" ' + 'which is not a dict.') + for key in random_ray: + if key == 'distance_active': + cv.check_type('active ray length', random_ray[key], Real) + cv.check_greater_than('active ray length', random_ray[key], 0.0) + elif key == 'distance_inactive': + cv.check_type('inactive ray length', random_ray[key], Real) + cv.check_greater_than('inactive ray length', + random_ray[key], 0.0, True) + elif key == 'ray_source': + cv.check_type('random ray source', random_ray[key], SourceBase) + else: + raise ValueError(f'Unable to set random ray to "{key}" which is ' + 'unsupported by OpenMC') + + self._random_ray = random_ray + def _create_run_mode_subelement(self, root): elem = ET.SubElement(root, "run_mode") elem.text = self._run_mode.value @@ -1442,6 +1482,17 @@ class Settings: elem = ET.SubElement(root, "max_tracks") elem.text = str(self._max_tracks) + def _create_random_ray_subelement(self, root): + if self._random_ray: + element = ET.SubElement(root, "random_ray") + for key, value in self._random_ray.items(): + if key == 'ray_source' and isinstance(value, SourceBase): + source_element = value.to_xml_element() + element.append(source_element) + else: + subelement = ET.SubElement(element, key) + subelement.text = str(value) + def _eigenvalue_from_xml_element(self, root): elem = root.find('eigenvalue') if elem is not None: @@ -1793,6 +1844,17 @@ class Settings: if text is not None: self.max_tracks = int(text) + def _random_ray_from_xml_element(self, root): + elem = root.find('random_ray') + if elem is not None: + self.random_ray = {} + for child in elem: + if child.tag in ('distance_inactive', 'distance_active'): + self.random_ray[child.tag] = float(child.text) + elif child.tag == 'source': + source = SourceBase.from_xml_element(child) + self.random_ray['ray_source'] = source + def to_xml_element(self, mesh_memo=None): """Create a 'settings' element to be written to an XML file. @@ -1855,6 +1917,7 @@ class Settings: self._create_weight_window_checkpoints_subelement(element) self._create_max_splits_subelement(element) self._create_max_tracks_subelement(element) + self._create_random_ray_subelement(element) # Clean the indentation in the file to be user-readable clean_indentation(element) @@ -1958,6 +2021,7 @@ class Settings: settings._weight_window_checkpoints_from_xml_element(elem) settings._max_splits_from_xml_element(elem) settings._max_tracks_from_xml_element(elem) + settings._random_ray_from_xml_element(elem) # TODO: Get volume calculations return settings diff --git a/src/boundary_condition.cpp b/src/boundary_condition.cpp index 42a8e27a16..b58054dce8 100644 --- a/src/boundary_condition.cpp +++ b/src/boundary_condition.cpp @@ -6,6 +6,7 @@ #include "openmc/constants.h" #include "openmc/error.h" +#include "openmc/random_ray/random_ray.h" #include "openmc/surface.h" namespace openmc { @@ -16,7 +17,18 @@ namespace openmc { void VacuumBC::handle_particle(Particle& p, const Surface& surf) const { - p.cross_vacuum_bc(surf); + // Random ray and Monte Carlo need different treatments at vacuum BCs + if (settings::solver_type == SolverType::RANDOM_RAY) { + // Reflect ray off of the surface + ReflectiveBC().handle_particle(p, surf); + + // Set ray's angular flux spectrum to vacuum conditions (zero) + RandomRay* r = static_cast(&p); + std::fill(r->angular_flux_.begin(), r->angular_flux_.end(), 0.0); + + } else { + p.cross_vacuum_bc(surf); + } } //============================================================================== diff --git a/src/eigenvalue.cpp b/src/eigenvalue.cpp index 5584210a1f..d5410094b1 100644 --- a/src/eigenvalue.cpp +++ b/src/eigenvalue.cpp @@ -57,16 +57,28 @@ void calculate_generation_keff() double keff_reduced; #ifdef OPENMC_MPI - // Combine values across all processors - MPI_Allreduce(&simulation::keff_generation, &keff_reduced, 1, MPI_DOUBLE, - MPI_SUM, mpi::intracomm); + if (settings::solver_type != SolverType::RANDOM_RAY) { + // Combine values across all processors + MPI_Allreduce(&simulation::keff_generation, &keff_reduced, 1, MPI_DOUBLE, + MPI_SUM, mpi::intracomm); + } else { + // If using random ray, MPI parallelism is provided by domain replication. + // As such, all fluxes will be reduced at the end of each transport sweep, + // such that all ranks have identical scalar flux vectors, and will all + // independently compute the same value of k. Thus, there is no need to + // perform any additional MPI reduction here. + keff_reduced = simulation::keff_generation; + } #else keff_reduced = simulation::keff_generation; #endif // Normalize single batch estimate of k // TODO: This should be normalized by total_weight, not by n_particles - keff_reduced /= settings::n_particles; + if (settings::solver_type != SolverType::RANDOM_RAY) { + keff_reduced /= settings::n_particles; + } + simulation::k_generation.push_back(keff_reduced); } @@ -370,7 +382,8 @@ int openmc_get_keff(double* k_combined) // Special case for n <=3. Notice that at the end, // there is a N-3 term in a denominator. - if (simulation::n_realizations <= 3) { + if (simulation::n_realizations <= 3 || + settings::solver_type == SolverType::RANDOM_RAY) { k_combined[0] = simulation::keff; k_combined[1] = simulation::keff_std; if (simulation::n_realizations <= 1) { diff --git a/src/geometry.cpp b/src/geometry.cpp index a16addd484..445b19faac 100644 --- a/src/geometry.cpp +++ b/src/geometry.cpp @@ -174,17 +174,9 @@ bool find_cell_inner( // Set the material and temperature. p.material_last() = p.material(); - if (c.material_.size() > 1) { - p.material() = c.material_[p.cell_instance()]; - } else { - p.material() = c.material_[0]; - } + p.material() = c.material(p.cell_instance()); p.sqrtkT_last() = p.sqrtkT(); - if (c.sqrtkT_.size() > 1) { - p.sqrtkT() = c.sqrtkT_[p.cell_instance()]; - } else { - p.sqrtkT() = c.sqrtkT_[0]; - } + p.sqrtkT() = c.sqrtkT(p.cell_instance()); return true; diff --git a/src/main.cpp b/src/main.cpp index 02a850ead8..88251ac723 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,6 +6,7 @@ #include "openmc/error.h" #include "openmc/message_passing.h" #include "openmc/particle_restart.h" +#include "openmc/random_ray/random_ray_simulation.h" #include "openmc/settings.h" int main(int argc, char* argv[]) @@ -31,7 +32,15 @@ int main(int argc, char* argv[]) switch (settings::run_mode) { case RunMode::FIXED_SOURCE: case RunMode::EIGENVALUE: - err = openmc_run(); + switch (settings::solver_type) { + case SolverType::MONTE_CARLO: + err = openmc_run(); + break; + case SolverType::RANDOM_RAY: + openmc_run_random_ray(); + err = 0; + break; + } break; case RunMode::PLOTTING: err = openmc_plot_geometry(); diff --git a/src/mesh.cpp b/src/mesh.cpp index bc6781ad31..c6e421b429 100644 --- a/src/mesh.cpp +++ b/src/mesh.cpp @@ -582,7 +582,7 @@ void StructuredMesh::raytrace_mesh( // Compute the length of the entire track. double total_distance = (r1 - r0).norm(); - if (total_distance == 0.0) + if (total_distance == 0.0 && settings::solver_type != SolverType::RANDOM_RAY) return; const int n = n_dimension_; diff --git a/src/output.cpp b/src/output.cpp index 9b4171d8d1..b1b963f7d3 100644 --- a/src/output.cpp +++ b/src/output.cpp @@ -31,6 +31,7 @@ #include "openmc/mgxs_interface.h" #include "openmc/nuclide.h" #include "openmc/plot.h" +#include "openmc/random_ray/flat_source_domain.h" #include "openmc/reaction.h" #include "openmc/settings.h" #include "openmc/simulation.h" @@ -409,7 +410,7 @@ void print_generation() //============================================================================== -void show_time(const char* label, double secs, int indent_level = 0) +void show_time(const char* label, double secs, int indent_level) { int width = 33 - indent_level * 2; fmt::print("{0:{1}} {2:<{3}} = {4:>10.4e} seconds\n", "", 2 * indent_level, diff --git a/src/random_ray/flat_source_domain.cpp b/src/random_ray/flat_source_domain.cpp new file mode 100644 index 0000000000..5e1194aa92 --- /dev/null +++ b/src/random_ray/flat_source_domain.cpp @@ -0,0 +1,686 @@ +#include "openmc/random_ray/flat_source_domain.h" + +#include "openmc/cell.h" +#include "openmc/geometry.h" +#include "openmc/message_passing.h" +#include "openmc/mgxs_interface.h" +#include "openmc/output.h" +#include "openmc/plot.h" +#include "openmc/random_ray/random_ray.h" +#include "openmc/simulation.h" +#include "openmc/tallies/filter.h" +#include "openmc/tallies/tally.h" +#include "openmc/tallies/tally_scoring.h" +#include "openmc/timer.h" + +#include + +namespace openmc { + +//============================================================================== +// FlatSourceDomain implementation +//============================================================================== + +FlatSourceDomain::FlatSourceDomain() : negroups_(data::mg.num_energy_groups_) +{ + // Count the number of source regions, compute the cell offset + // indices, and store the material type The reason for the offsets is that + // some cell types may not have material fills, and therefore do not + // produce FSRs. Thus, we cannot index into the global arrays directly + for (const auto& c : model::cells) { + if (c->type_ != Fill::MATERIAL) { + source_region_offsets_.push_back(-1); + } else { + source_region_offsets_.push_back(n_source_regions_); + n_source_regions_ += c->n_instances_; + n_source_elements_ += c->n_instances_ * negroups_; + } + } + + // Initialize cell-wise arrays + lock_.resize(n_source_regions_); + material_.resize(n_source_regions_); + position_recorded_.assign(n_source_regions_, 0); + position_.resize(n_source_regions_); + volume_.assign(n_source_regions_, 0.0); + volume_t_.assign(n_source_regions_, 0.0); + was_hit_.assign(n_source_regions_, 0); + + // Initialize element-wise arrays + scalar_flux_new_.assign(n_source_elements_, 0.0); + scalar_flux_old_.assign(n_source_elements_, 1.0); + scalar_flux_final_.assign(n_source_elements_, 0.0); + source_.resize(n_source_elements_); + tally_task_.resize(n_source_elements_); + + // Initialize material array + int64_t source_region_id = 0; + for (int i = 0; i < model::cells.size(); i++) { + Cell& cell = *model::cells[i]; + if (cell.type_ == Fill::MATERIAL) { + for (int j = 0; j < cell.n_instances_; j++) { + material_[source_region_id++] = cell.material(j); + } + } + } + + // Sanity check + if (source_region_id != n_source_regions_) { + fatal_error("Unexpected number of source regions"); + } +} + +void FlatSourceDomain::batch_reset() +{ + // Reset scalar fluxes, iteration volume tallies, and region hit flags to + // zero + parallel_fill(scalar_flux_new_, 0.0f); + parallel_fill(volume_, 0.0); + parallel_fill(was_hit_, 0); +} + +void FlatSourceDomain::accumulate_iteration_flux() +{ +#pragma omp parallel for + for (int64_t se = 0; se < n_source_elements_; se++) { + scalar_flux_final_[se] += scalar_flux_new_[se]; + } +} + +// Compute new estimate of scattering + fission sources in each source region +// based on the flux estimate from the previous iteration. +void FlatSourceDomain::update_neutron_source(double k_eff) +{ + simulation::time_update_src.start(); + + double inverse_k_eff = 1.0 / k_eff; + + // Temperature and angle indices, if using multiple temperature + // data sets and/or anisotropic data sets. + // TODO: Currently assumes we are only using single temp/single + // angle data. + const int t = 0; + const int a = 0; + +#pragma omp parallel for + for (int sr = 0; sr < n_source_regions_; sr++) { + int material = material_[sr]; + + for (int e_out = 0; e_out < negroups_; e_out++) { + float sigma_t = data::mg.macro_xs_[material].get_xs( + MgxsType::TOTAL, e_out, nullptr, nullptr, nullptr, t, a); + float scatter_source = 0.0f; + float fission_source = 0.0f; + + for (int e_in = 0; e_in < negroups_; e_in++) { + float scalar_flux = scalar_flux_old_[sr * negroups_ + e_in]; + float sigma_s = data::mg.macro_xs_[material].get_xs( + MgxsType::NU_SCATTER, e_in, &e_out, nullptr, nullptr, t, a); + float nu_sigma_f = data::mg.macro_xs_[material].get_xs( + MgxsType::NU_FISSION, e_in, nullptr, nullptr, nullptr, t, a); + float chi = data::mg.macro_xs_[material].get_xs( + MgxsType::CHI_PROMPT, e_in, &e_out, nullptr, nullptr, t, a); + scatter_source += sigma_s * scalar_flux; + fission_source += nu_sigma_f * scalar_flux * chi; + } + + fission_source *= inverse_k_eff; + float new_isotropic_source = (scatter_source + fission_source) / sigma_t; + source_[sr * negroups_ + e_out] = new_isotropic_source; + } + } + + simulation::time_update_src.stop(); +} + +// Normalizes flux and updates simulation-averaged volume estimate +void FlatSourceDomain::normalize_scalar_flux_and_volumes( + double total_active_distance_per_iteration) +{ + float normalization_factor = 1.0 / total_active_distance_per_iteration; + double volume_normalization_factor = + 1.0 / (total_active_distance_per_iteration * simulation::current_batch); + +// Normalize scalar flux to total distance travelled by all rays this iteration +#pragma omp parallel for + for (int64_t e = 0; e < scalar_flux_new_.size(); e++) { + scalar_flux_new_[e] *= normalization_factor; + } + +// Accumulate cell-wise ray length tallies collected this iteration, then +// update the simulation-averaged cell-wise volume estimates +#pragma omp parallel for + for (int64_t sr = 0; sr < n_source_regions_; sr++) { + volume_t_[sr] += volume_[sr]; + volume_[sr] = volume_t_[sr] * volume_normalization_factor; + } +} + +// Combine transport flux contributions and flat source contributions from the +// previous iteration to generate this iteration's estimate of scalar flux. +int64_t FlatSourceDomain::add_source_to_scalar_flux() +{ + int64_t n_hits = 0; + + // Temperature and angle indices, if using multiple temperature + // data sets and/or anisotropic data sets. + // TODO: Currently assumes we are only using single temp/single + // angle data. + const int t = 0; + const int a = 0; + +#pragma omp parallel for reduction(+ : n_hits) + for (int sr = 0; sr < n_source_regions_; sr++) { + + // Check if this cell was hit this iteration + int was_cell_hit = was_hit_[sr]; + if (was_cell_hit) { + n_hits++; + } + + double volume = volume_[sr]; + int material = material_[sr]; + for (int g = 0; g < negroups_; g++) { + int64_t idx = (sr * negroups_) + g; + + // There are three scenarios we need to consider: + if (was_cell_hit) { + // 1. If the FSR was hit this iteration, then the new flux is equal to + // the flat source from the previous iteration plus the contributions + // from rays passing through the source region (computed during the + // transport sweep) + float sigma_t = data::mg.macro_xs_[material].get_xs( + MgxsType::TOTAL, g, nullptr, nullptr, nullptr, t, a); + scalar_flux_new_[idx] /= (sigma_t * volume); + scalar_flux_new_[idx] += source_[idx]; + } else if (volume > 0.0) { + // 2. If the FSR was not hit this iteration, but has been hit some + // previous iteration, then we simply set the new scalar flux to be + // equal to the contribution from the flat source alone. + scalar_flux_new_[idx] = source_[idx]; + } else { + // If the FSR was not hit this iteration, and it has never been hit in + // any iteration (i.e., volume is zero), then we want to set this to 0 + // to avoid dividing anything by a zero volume. + scalar_flux_new_[idx] = 0.0f; + } + } + } + + // Return the number of source regions that were hit this iteration + return n_hits; +} + +// Generates new estimate of k_eff based on the differences between this +// iteration's estimate of the scalar flux and the last iteration's estimate. +double FlatSourceDomain::compute_k_eff(double k_eff_old) const +{ + double fission_rate_old = 0; + double fission_rate_new = 0; + + // Temperature and angle indices, if using multiple temperature + // data sets and/or anisotropic data sets. + // TODO: Currently assumes we are only using single temp/single + // angle data. + const int t = 0; + const int a = 0; + +#pragma omp parallel for reduction(+ : fission_rate_old, fission_rate_new) + for (int sr = 0; sr < n_source_regions_; sr++) { + + // If simulation averaged volume is zero, don't include this cell + double volume = volume_[sr]; + if (volume == 0.0) { + continue; + } + + int material = material_[sr]; + + double sr_fission_source_old = 0; + double sr_fission_source_new = 0; + + for (int g = 0; g < negroups_; g++) { + int64_t idx = (sr * negroups_) + g; + double nu_sigma_f = data::mg.macro_xs_[material].get_xs( + MgxsType::NU_FISSION, g, nullptr, nullptr, nullptr, t, a); + sr_fission_source_old += nu_sigma_f * scalar_flux_old_[idx]; + sr_fission_source_new += nu_sigma_f * scalar_flux_new_[idx]; + } + + fission_rate_old += sr_fission_source_old * volume; + fission_rate_new += sr_fission_source_new * volume; + } + + double k_eff_new = k_eff_old * (fission_rate_new / fission_rate_old); + + return k_eff_new; +} + +// This function is responsible for generating a mapping between random +// ray flat source regions (cell instances) and tally bins. The mapping +// takes the form of a "TallyTask" object, which accounts for one single +// score being applied to a single tally. Thus, a single source region +// may have anywhere from zero to many tally tasks associated with it --- +// meaning that the global "tally_task" data structure is in 2D. The outer +// dimension corresponds to the source element (i.e., each entry corresponds +// to a specific energy group within a specific source region), and the +// inner dimension corresponds to the tallying task itself. Mechanically, +// the mapping between FSRs and spatial filters is done by considering +// the location of a single known ray midpoint that passed through the +// FSR. I.e., during transport, the first ray to pass through a given FSR +// will write down its midpoint for use with this function. This is a cheap +// and easy way of mapping FSRs to spatial tally filters, but comes with +// the downside of adding the restriction that spatial tally filters must +// share boundaries with the physical geometry of the simulation (so as +// not to subdivide any FSR). It is acceptable for a spatial tally region +// to contain multiple FSRs, but not the other way around. + +// TODO: In future work, it would be preferable to offer a more general +// (but perhaps slightly more expensive) option for handling arbitrary +// spatial tallies that would be allowed to subdivide FSRs. + +// Besides generating the mapping structure, this function also keeps track +// of whether or not all flat source regions have been hit yet. This is +// required, as there is no guarantee that all flat source regions will +// be hit every iteration, such that in the first few iterations some FSRs +// may not have a known position within them yet to facilitate mapping to +// spatial tally filters. However, after several iterations, if all FSRs +// have been hit and have had a tally map generated, then this status will +// be passed back to the caller to alert them that this function doesn't +// need to be called for the remainder of the simulation. + +void FlatSourceDomain::convert_source_regions_to_tallies() +{ + openmc::simulation::time_tallies.start(); + + // Tracks if we've generated a mapping yet for all source regions. + bool all_source_regions_mapped = true; + +// Attempt to generate mapping for all source regions +#pragma omp parallel for + for (int sr = 0; sr < n_source_regions_; sr++) { + + // If this source region has not been hit by a ray yet, then + // we aren't going to be able to map it, so skip it. + if (!position_recorded_[sr]) { + all_source_regions_mapped = false; + continue; + } + + // A particle located at the recorded midpoint of a ray + // crossing through this source region is used to estabilish + // the spatial location of the source region + Particle p; + p.r() = position_[sr]; + p.r_last() = position_[sr]; + bool found = exhaustive_find_cell(p); + + // Loop over energy groups (so as to support energy filters) + for (int g = 0; g < negroups_; g++) { + + // Set particle to the current energy + p.g() = g; + p.g_last() = g; + p.E() = data::mg.energy_bin_avg_[p.g()]; + p.E_last() = p.E(); + + int64_t source_element = sr * negroups_ + g; + + // If this task has already been populated, we don't need to do + // it again. + if (tally_task_[source_element].size() > 0) { + continue; + } + + // Loop over all active tallies. This logic is essentially identical + // to what happens when scanning for applicable tallies during + // MC transport. + for (auto i_tally : model::active_tallies) { + Tally& tally {*model::tallies[i_tally]}; + + // Initialize an iterator over valid filter bin combinations. + // If there are no valid combinations, use a continue statement + // to ensure we skip the assume_separate break below. + auto filter_iter = FilterBinIter(tally, p); + auto end = FilterBinIter(tally, true, &p.filter_matches()); + if (filter_iter == end) + continue; + + // Loop over filter bins. + for (; filter_iter != end; ++filter_iter) { + auto filter_index = filter_iter.index_; + auto filter_weight = filter_iter.weight_; + + // Loop over scores + for (auto score_index = 0; score_index < tally.scores_.size(); + score_index++) { + auto score_bin = tally.scores_[score_index]; + // If a valid tally, filter, and score cobination has been found, + // then add it to the list of tally tasks for this source element. + tally_task_[source_element].emplace_back( + i_tally, filter_index, score_index, score_bin); + } + } + } + // Reset all the filter matches for the next tally event. + for (auto& match : p.filter_matches()) + match.bins_present_ = false; + } + } + openmc::simulation::time_tallies.stop(); + + mapped_all_tallies_ = all_source_regions_mapped; +} + +// Tallying in random ray is not done directly during transport, rather, +// it is done only once after each power iteration. This is made possible +// by way of a mapping data structure that relates spatial source regions +// (FSRs) to tally/filter/score combinations. The mechanism by which the +// mapping is done (and the limitations incurred) is documented in the +// "convert_source_regions_to_tallies()" function comments above. The present +// tally function simply traverses the mapping data structure and executes +// the scoring operations to OpenMC's native tally result arrays. + +void FlatSourceDomain::random_ray_tally() const +{ + openmc::simulation::time_tallies.start(); + + // Temperature and angle indices, if using multiple temperature + // data sets and/or anisotropic data sets. + // TODO: Currently assumes we are only using single temp/single + // angle data. + const int t = 0; + const int a = 0; + +// We loop over all source regions and energy groups. For each +// element, we check if there are any scores needed and apply +// them. +#pragma omp parallel for + for (int sr = 0; sr < n_source_regions_; sr++) { + double volume = volume_[sr]; + double material = material_[sr]; + for (int g = 0; g < negroups_; g++) { + int idx = sr * negroups_ + g; + double flux = scalar_flux_new_[idx] * volume; + for (auto& task : tally_task_[idx]) { + double score; + switch (task.score_type) { + + case SCORE_FLUX: + score = flux; + break; + + case SCORE_TOTAL: + score = flux * data::mg.macro_xs_[material].get_xs( + MgxsType::TOTAL, g, NULL, NULL, NULL, t, a); + break; + + case SCORE_FISSION: + score = flux * data::mg.macro_xs_[material].get_xs( + MgxsType::FISSION, g, NULL, NULL, NULL, t, a); + break; + + case SCORE_NU_FISSION: + score = flux * data::mg.macro_xs_[material].get_xs( + MgxsType::NU_FISSION, g, NULL, NULL, NULL, t, a); + break; + + case SCORE_EVENTS: + score = 1.0; + break; + + default: + fatal_error("Invalid score specified in tallies.xml. Only flux, " + "total, fission, nu-fission, and events are supported in " + "random ray mode."); + break; + } + Tally& tally {*model::tallies[task.tally_idx]}; +#pragma omp atomic + tally.results_(task.filter_idx, task.score_idx, TallyResult::VALUE) += + score; + } + } + } +} + +void FlatSourceDomain::all_reduce_replicated_source_regions() +{ +#ifdef OPENMC_MPI + + // If we only have 1 MPI rank, no need + // to reduce anything. + if (mpi::n_procs <= 1) + return; + + simulation::time_bank_sendrecv.start(); + + // The "position_recorded" variable needs to be allreduced (and maxed), + // as whether or not a cell was hit will affect some decisions in how the + // source is calculated in the next iteration so as to avoid dividing + // by zero. We take the max rather than the sum as the hit values are + // expected to be zero or 1. + MPI_Allreduce(MPI_IN_PLACE, position_recorded_.data(), n_source_regions_, + MPI_INT, MPI_MAX, mpi::intracomm); + + // The position variable is more complicated to reduce than the others, + // as we do not want the sum of all positions in each cell, rather, we + // want to just pick any single valid position. Thus, we perform a gather + // and then pick the first valid position we find for all source regions + // that have had a position recorded. This operation does not need to + // be broadcast back to other ranks, as this value is only used for the + // tally conversion operation, which is only performed on the master rank. + // While this is expensive, it only needs to be done for active batches, + // and only if we have not mapped all the tallies yet. Once tallies are + // fully mapped, then the position vector is fully populated, so this + // operation can be skipped. + + // First, we broadcast the fully mapped tally status variable so that + // all ranks are on the same page + int mapped_all_tallies_i = static_cast(mapped_all_tallies_); + MPI_Bcast(&mapped_all_tallies_i, 1, MPI_INT, 0, mpi::intracomm); + + // Then, we perform the gather of position data, if needed + if (simulation::current_batch > settings::n_inactive && + !mapped_all_tallies_i) { + + // Master rank will gather results and pick valid positions + if (mpi::master) { + // Initialize temporary vector for receiving positions + vector> all_position; + all_position.resize(mpi::n_procs); + for (int i = 0; i < mpi::n_procs; i++) { + all_position[i].resize(n_source_regions_); + } + + // Copy master rank data into gathered vector for convenience + all_position[0] = position_; + + // Receive all data into gather vector + for (int i = 1; i < mpi::n_procs; i++) { + MPI_Recv(all_position[i].data(), n_source_regions_ * 3, MPI_DOUBLE, i, + 0, mpi::intracomm, MPI_STATUS_IGNORE); + } + + // Scan through gathered data and pick first valid cell posiiton + for (int sr = 0; sr < n_source_regions_; sr++) { + if (position_recorded_[sr] == 1) { + for (int i = 0; i < mpi::n_procs; i++) { + if (all_position[i][sr].x != 0.0 || all_position[i][sr].y != 0.0 || + all_position[i][sr].z != 0.0) { + position_[sr] = all_position[i][sr]; + break; + } + } + } + } + } else { + // Other ranks just send in their data + MPI_Send(position_.data(), n_source_regions_ * 3, MPI_DOUBLE, 0, 0, + mpi::intracomm); + } + } + + // For the rest of the source region data, we simply perform an all reduce, + // as these values will be needed on all ranks for transport during the + // next iteration. + MPI_Allreduce(MPI_IN_PLACE, volume_.data(), n_source_regions_, MPI_DOUBLE, + MPI_SUM, mpi::intracomm); + + MPI_Allreduce(MPI_IN_PLACE, was_hit_.data(), n_source_regions_, MPI_INT, + MPI_SUM, mpi::intracomm); + + MPI_Allreduce(MPI_IN_PLACE, scalar_flux_new_.data(), n_source_elements_, + MPI_FLOAT, MPI_SUM, mpi::intracomm); + + simulation::time_bank_sendrecv.stop(); +#endif +} + +// Outputs all basic material, FSR ID, multigroup flux, and +// fission source data to .vtk file that can be directly +// loaded and displayed by Paraview. Note that .vtk binary +// files require big endian byte ordering, so endianness +// is checked and flipped if necessary. +void FlatSourceDomain::output_to_vtk() const +{ + // Rename .h5 plot filename(s) to .vtk filenames + for (int p = 0; p < model::plots.size(); p++) { + PlottableInterface* plot = model::plots[p].get(); + plot->path_plot() = + plot->path_plot().substr(0, plot->path_plot().find_last_of('.')) + ".vtk"; + } + + // Print header information + print_plot(); + + // Outer loop over plots + for (int p = 0; p < model::plots.size(); p++) { + + // Get handle to OpenMC plot object and extract params + Plot* openmc_plot = dynamic_cast(model::plots[p].get()); + + // Random ray plots only support voxel plots + if (!openmc_plot) { + warning(fmt::format("Plot {} is invalid plot type -- only voxel plotting " + "is allowed in random ray mode.", + p)); + continue; + } else if (openmc_plot->type_ != Plot::PlotType::voxel) { + warning(fmt::format("Plot {} is invalid plot type -- only voxel plotting " + "is allowed in random ray mode.", + p)); + continue; + } + + int Nx = openmc_plot->pixels_[0]; + int Ny = openmc_plot->pixels_[1]; + int Nz = openmc_plot->pixels_[2]; + Position origin = openmc_plot->origin_; + Position width = openmc_plot->width_; + Position ll = origin - width / 2.0; + double x_delta = width.x / Nx; + double y_delta = width.y / Ny; + double z_delta = width.z / Nz; + std::string filename = openmc_plot->path_plot(); + + // Perform sanity checks on file size + uint64_t bytes = Nx * Ny * Nz * (negroups_ + 1 + 1 + 1) * sizeof(float); + write_message(5, "Processing plot {}: {}... (Estimated size is {} MB)", + openmc_plot->id(), filename, bytes / 1.0e6); + if (bytes / 1.0e9 > 1.0) { + warning("Voxel plot specification is very large (>1 GB). Plotting may be " + "slow."); + } else if (bytes / 1.0e9 > 100.0) { + fatal_error("Voxel plot specification is too large (>100 GB). Exiting."); + } + + // Relate voxel spatial locations to random ray source regions + vector voxel_indices(Nx * Ny * Nz); + +#pragma omp parallel for collapse(3) + for (int z = 0; z < Nz; z++) { + for (int y = 0; y < Ny; y++) { + for (int x = 0; x < Nx; x++) { + Position sample; + sample.z = ll.z + z_delta / 2.0 + z * z_delta; + sample.y = ll.y + y_delta / 2.0 + y * y_delta; + sample.x = ll.x + x_delta / 2.0 + x * x_delta; + Particle p; + p.r() = sample; + bool found = exhaustive_find_cell(p); + int i_cell = p.lowest_coord().cell; + int64_t source_region_idx = + source_region_offsets_[i_cell] + p.cell_instance(); + voxel_indices[z * Ny * Nx + y * Nx + x] = source_region_idx; + } + } + } + + // Open file for writing + std::FILE* plot = std::fopen(filename.c_str(), "wb"); + + // Write vtk metadata + std::fprintf(plot, "# vtk DataFile Version 2.0\n"); + std::fprintf(plot, "Dataset File\n"); + std::fprintf(plot, "BINARY\n"); + std::fprintf(plot, "DATASET STRUCTURED_POINTS\n"); + std::fprintf(plot, "DIMENSIONS %d %d %d\n", Nx, Ny, Nz); + std::fprintf(plot, "ORIGIN 0 0 0\n"); + std::fprintf(plot, "SPACING %lf %lf %lf\n", x_delta, y_delta, z_delta); + std::fprintf(plot, "POINT_DATA %d\n", Nx * Ny * Nz); + + // Plot multigroup flux data + for (int g = 0; g < negroups_; g++) { + std::fprintf(plot, "SCALARS flux_group_%d float\n", g); + std::fprintf(plot, "LOOKUP_TABLE default\n"); + for (int fsr : voxel_indices) { + int64_t source_element = fsr * negroups_ + g; + float flux = scalar_flux_final_[source_element]; + flux /= (settings::n_batches - settings::n_inactive); + flux = convert_to_big_endian(flux); + std::fwrite(&flux, sizeof(float), 1, plot); + } + } + + // Plot FSRs + std::fprintf(plot, "SCALARS FSRs float\n"); + std::fprintf(plot, "LOOKUP_TABLE default\n"); + for (int fsr : voxel_indices) { + float value = future_prn(10, fsr); + value = convert_to_big_endian(value); + std::fwrite(&value, sizeof(float), 1, plot); + } + + // Plot Materials + std::fprintf(plot, "SCALARS Materials int\n"); + std::fprintf(plot, "LOOKUP_TABLE default\n"); + for (int fsr : voxel_indices) { + int mat = material_[fsr]; + mat = convert_to_big_endian(mat); + std::fwrite(&mat, sizeof(int), 1, plot); + } + + // Plot fission source + std::fprintf(plot, "SCALARS total_fission_source float\n"); + std::fprintf(plot, "LOOKUP_TABLE default\n"); + for (int fsr : voxel_indices) { + float total_fission = 0.0; + int mat = material_[fsr]; + for (int g = 0; g < negroups_; g++) { + int64_t source_element = fsr * negroups_ + g; + float flux = scalar_flux_final_[source_element]; + flux /= (settings::n_batches - settings::n_inactive); + float Sigma_f = data::mg.macro_xs_[mat].get_xs( + MgxsType::FISSION, g, nullptr, nullptr, nullptr, 0, 0); + total_fission += Sigma_f * flux; + } + total_fission = convert_to_big_endian(total_fission); + std::fwrite(&total_fission, sizeof(float), 1, plot); + } + + std::fclose(plot); + } +} + +} // namespace openmc diff --git a/src/random_ray/random_ray.cpp b/src/random_ray/random_ray.cpp new file mode 100644 index 0000000000..f471c75517 --- /dev/null +++ b/src/random_ray/random_ray.cpp @@ -0,0 +1,289 @@ +#include "openmc/random_ray/random_ray.h" + +#include "openmc/geometry.h" +#include "openmc/message_passing.h" +#include "openmc/mgxs_interface.h" +#include "openmc/random_ray/flat_source_domain.h" +#include "openmc/search.h" +#include "openmc/settings.h" +#include "openmc/simulation.h" +#include "openmc/source.h" + +namespace openmc { + +//============================================================================== +// Non-method functions +//============================================================================== + +// returns 1 - exp(-tau) +// Equivalent to -(_expm1f(-tau)), but faster +// Written by Colin Josey. +float cjosey_exponential(float tau) +{ + constexpr float c1n = -1.0000013559236386308f; + constexpr float c2n = 0.23151368626911062025f; + constexpr float c3n = -0.061481916409314966140f; + constexpr float c4n = 0.0098619906458127653020f; + constexpr float c5n = -0.0012629460503540849940f; + constexpr float c6n = 0.00010360973791574984608f; + constexpr float c7n = -0.000013276571933735820960f; + + constexpr float c0d = 1.0f; + constexpr float c1d = -0.73151337729389001396f; + constexpr float c2d = 0.26058381273536471371f; + constexpr float c3d = -0.059892419041316836940f; + constexpr float c4d = 0.0099070188241094279067f; + constexpr float c5d = -0.0012623388962473160860f; + constexpr float c6d = 0.00010361277635498731388f; + constexpr float c7d = -0.000013276569500666698498f; + + float x = -tau; + + float den = c7d; + den = den * x + c6d; + den = den * x + c5d; + den = den * x + c4d; + den = den * x + c3d; + den = den * x + c2d; + den = den * x + c1d; + den = den * x + c0d; + + float num = c7n; + num = num * x + c6n; + num = num * x + c5n; + num = num * x + c4n; + num = num * x + c3n; + num = num * x + c2n; + num = num * x + c1n; + num = num * x; + + return num / den; +} + +//============================================================================== +// RandomRay implementation +//============================================================================== + +// Static Variable Declarations +double RandomRay::distance_inactive_; +double RandomRay::distance_active_; +unique_ptr RandomRay::ray_source_; + +RandomRay::RandomRay() + : negroups_(data::mg.num_energy_groups_), + angular_flux_(data::mg.num_energy_groups_), + delta_psi_(data::mg.num_energy_groups_) +{} + +RandomRay::RandomRay(uint64_t ray_id, FlatSourceDomain* domain) : RandomRay() +{ + initialize_ray(ray_id, domain); +} + +// Transports ray until termination criteria are met +uint64_t RandomRay::transport_history_based_single_ray() +{ + using namespace openmc; + while (alive()) { + event_advance_ray(); + if (!alive()) + break; + event_cross_surface(); + } + + return n_event(); +} + +// Transports ray across a single source region +void RandomRay::event_advance_ray() +{ + // Find the distance to the nearest boundary + boundary() = distance_to_boundary(*this); + double distance = boundary().distance; + + if (distance <= 0.0) { + mark_as_lost("Negative transport distance detected for particle " + + std::to_string(id())); + return; + } + + if (is_active_) { + // If the ray is in the active length, need to check if it has + // reached its maximum termination distance. If so, reduce + // the ray traced length so that the ray does not overrun the + // maximum numerical length (so as to avoid numerical bias). + if (distance_travelled_ + distance >= distance_active_) { + distance = distance_active_ - distance_travelled_; + wgt() = 0.0; + } + + distance_travelled_ += distance; + attenuate_flux(distance, true); + } else { + // If the ray is still in the dead zone, need to check if it + // has entered the active phase. If so, split into two segments (one + // representing the final part of the dead zone, the other representing the + // first part of the active length) and attenuate each. Otherwise, if the + // full length of the segment is within the dead zone, attenuate as normal. + if (distance_travelled_ + distance >= distance_inactive_) { + is_active_ = true; + double distance_dead = distance_inactive_ - distance_travelled_; + attenuate_flux(distance_dead, false); + + double distance_alive = distance - distance_dead; + + // Ensure we haven't travelled past the active phase as well + if (distance_alive > distance_active_) { + distance_alive = distance_active_; + wgt() = 0.0; + } + + attenuate_flux(distance_alive, true); + distance_travelled_ = distance_alive; + } else { + distance_travelled_ += distance; + attenuate_flux(distance, false); + } + } + + // Advance particle + for (int j = 0; j < n_coord(); ++j) { + coord(j).r += distance * coord(j).u; + } +} + +// This function forms the inner loop of the random ray transport process. +// It is responsible for several tasks. Based on the incoming angular flux +// of the ray and the source term in the region, the outgoing angular flux +// is computed. The delta psi between the incoming and outgoing fluxes is +// contributed to the estimate of the total scalar flux in the source region. +// Additionally, the contribution of the ray path to the stochastically +// estimated volume is also kept track of. All tasks involving writing +// to the data for the source region are done with a lock over the entire +// source region. Locks are used instead of atomics as all energy groups +// must be written, such that locking once is typically much more efficient +// than use of many atomic operations corresponding to each energy group +// individually (at least on CPU). Several other bookkeeping tasks are also +// performed when inside the lock. +void RandomRay::attenuate_flux(double distance, bool is_active) +{ + // The number of geometric intersections is counted for reporting purposes + n_event()++; + + // Determine source region index etc. + int i_cell = lowest_coord().cell; + + // The source region is the spatial region index + int64_t source_region = + domain_->source_region_offsets_[i_cell] + cell_instance(); + + // The source element is the energy-specific region index + int64_t source_element = source_region * negroups_; + int material = this->material(); + + // Temperature and angle indices, if using multiple temperature + // data sets and/or anisotropic data sets. + // TODO: Currently assumes we are only using single temp/single + // angle data. + const int t = 0; + const int a = 0; + + // MOC incoming flux attenuation + source contribution/attenuation equation + for (int g = 0; g < negroups_; g++) { + float sigma_t = data::mg.macro_xs_[material].get_xs( + MgxsType::TOTAL, g, NULL, NULL, NULL, t, a); + float tau = sigma_t * distance; + float exponential = cjosey_exponential(tau); // exponential = 1 - exp(-tau) + float new_delta_psi = + (angular_flux_[g] - domain_->source_[source_element + g]) * exponential; + delta_psi_[g] = new_delta_psi; + angular_flux_[g] -= new_delta_psi; + } + + // If ray is in the active phase (not in dead zone), make contributions to + // source region bookkeeping + if (is_active) { + + // Aquire lock for source region + domain_->lock_[source_region].lock(); + + // Accumulate delta psi into new estimate of source region flux for + // this iteration + for (int g = 0; g < negroups_; g++) { + domain_->scalar_flux_new_[source_element + g] += delta_psi_[g]; + } + + // If the source region hasn't been hit yet this iteration, + // indicate that it now has + if (domain_->was_hit_[source_region] == 0) { + domain_->was_hit_[source_region] = 1; + } + + // Accomulate volume (ray distance) into this iteration's estimate + // of the source region's volume + domain_->volume_[source_region] += distance; + + // Tally valid position inside the source region (e.g., midpoint of + // the ray) if not done already + if (!domain_->position_recorded_[source_region]) { + Position midpoint = r() + u() * (distance / 2.0); + domain_->position_[source_region] = midpoint; + domain_->position_recorded_[source_region] = 1; + } + + // Release lock + domain_->lock_[source_region].unlock(); + } +} + +void RandomRay::initialize_ray(uint64_t ray_id, FlatSourceDomain* domain) +{ + domain_ = domain; + + // Reset particle event counter + n_event() = 0; + + is_active_ = (distance_inactive_ <= 0.0); + + wgt() = 1.0; + + // set identifier for particle + id() = simulation::work_index[mpi::rank] + ray_id; + + // set random number seed + int64_t particle_seed = + (simulation::current_batch - 1) * settings::n_particles + id(); + init_particle_seeds(particle_seed, seeds()); + stream() = STREAM_TRACKING; + + // Sample from ray source distribution + SourceSite site {ray_source_->sample(current_seed())}; + site.E = lower_bound_index( + data::mg.rev_energy_bins_.begin(), data::mg.rev_energy_bins_.end(), site.E); + site.E = negroups_ - site.E - 1.; + this->from_source(&site); + + // Locate ray + if (lowest_coord().cell == C_NONE) { + if (!exhaustive_find_cell(*this)) { + this->mark_as_lost( + "Could not find the cell containing particle " + std::to_string(id())); + } + + // Set birth cell attribute + if (cell_born() == C_NONE) + cell_born() = lowest_coord().cell; + } + + // Initialize ray's starting angular flux to starting location's isotropic + // source + int i_cell = lowest_coord().cell; + int64_t source_region_idx = + domain_->source_region_offsets_[i_cell] + cell_instance(); + + for (int g = 0; g < negroups_; g++) { + angular_flux_[g] = domain_->source_[source_region_idx * negroups_ + g]; + } +} + +} // namespace openmc diff --git a/src/random_ray/random_ray_simulation.cpp b/src/random_ray/random_ray_simulation.cpp new file mode 100644 index 0000000000..b9fd93a48f --- /dev/null +++ b/src/random_ray/random_ray_simulation.cpp @@ -0,0 +1,397 @@ +#include "openmc/random_ray/random_ray_simulation.h" + +#include "openmc/eigenvalue.h" +#include "openmc/geometry.h" +#include "openmc/message_passing.h" +#include "openmc/mgxs_interface.h" +#include "openmc/output.h" +#include "openmc/plot.h" +#include "openmc/random_ray/random_ray.h" +#include "openmc/simulation.h" +#include "openmc/source.h" +#include "openmc/tallies/filter.h" +#include "openmc/tallies/tally.h" +#include "openmc/tallies/tally_scoring.h" +#include "openmc/timer.h" + +namespace openmc { + +//============================================================================== +// Non-member functions +//============================================================================== + +void openmc_run_random_ray() +{ + // Initialize OpenMC general data structures + openmc_simulation_init(); + + // Validate that inputs meet requirements for random ray mode + if (mpi::master) + validate_random_ray_inputs(); + + // Initialize Random Ray Simulation Object + RandomRaySimulation sim; + + // Begin main simulation timer + simulation::time_total.start(); + + // Execute random ray simulation + sim.simulate(); + + // End main simulation timer + openmc::simulation::time_total.stop(); + + // Finalize OpenMC + openmc_simulation_finalize(); + + // Reduce variables across MPI ranks + sim.reduce_simulation_statistics(); + + // Output all simulation results + sim.output_simulation_results(); +} + +// Enforces restrictions on inputs in random ray mode. While there are +// many features that don't make sense in random ray mode, and are therefore +// unsupported, we limit our testing/enforcement operations only to inputs +// that may cause erroneous/misleading output or crashes from the solver. +void validate_random_ray_inputs() +{ + // Validate tallies + /////////////////////////////////////////////////////////////////// + for (auto& tally : model::tallies) { + + // Validate score types + for (auto score_bin : tally->scores_) { + switch (score_bin) { + case SCORE_FLUX: + case SCORE_TOTAL: + case SCORE_FISSION: + case SCORE_NU_FISSION: + case SCORE_EVENTS: + break; + default: + fatal_error( + "Invalid score specified. Only flux, total, fission, nu-fission, and " + "event scores are supported in random ray mode."); + } + } + + // Validate filter types + for (auto f : tally->filters()) { + auto& filter = *model::tally_filters[f]; + + switch (filter.type()) { + case FilterType::CELL: + case FilterType::CELL_INSTANCE: + case FilterType::DISTRIBCELL: + case FilterType::ENERGY: + case FilterType::MATERIAL: + case FilterType::MESH: + case FilterType::UNIVERSE: + break; + default: + fatal_error("Invalid filter specified. Only cell, cell_instance, " + "distribcell, energy, material, mesh, and universe filters " + "are supported in random ray mode."); + } + } + } + + // Validate MGXS data + /////////////////////////////////////////////////////////////////// + for (auto& material : data::mg.macro_xs_) { + if (!material.is_isotropic) { + fatal_error("Anisotropic MGXS detected. Only isotropic XS data sets " + "supported in random ray mode."); + } + if (material.get_xsdata().size() > 1) { + fatal_error("Non-isothermal MGXS detected. Only isothermal XS data sets " + "supported in random ray mode."); + } + } + + // Validate solver mode + /////////////////////////////////////////////////////////////////// + if (settings::run_mode == RunMode::FIXED_SOURCE) { + fatal_error( + "Invalid run mode. Fixed source not yet supported in random ray mode."); + } + + // Validate ray source + /////////////////////////////////////////////////////////////////// + + // Check for independent source + IndependentSource* is = + dynamic_cast(RandomRay::ray_source_.get()); + if (!is) { + fatal_error( + "Invalid ray source definition. Ray source must be IndependentSource."); + } + + // Check for box source + SpatialDistribution* space_dist = is->space(); + SpatialBox* sb = dynamic_cast(space_dist); + if (!sb) { + fatal_error( + "Invalid source definition -- only box sources are allowed in random " + "ray " + "mode. If no source is specified, OpenMC default is an isotropic point " + "source at the origin, which is invalid in random ray mode."); + } + + // Check that box source is not restricted to fissionable areas + if (sb->only_fissionable()) { + fatal_error("Invalid source definition -- fissionable spatial distribution " + "not allowed for random ray source."); + } + + // Check for isotropic source + UnitSphereDistribution* angle_dist = is->angle(); + Isotropic* id = dynamic_cast(angle_dist); + if (!id) { + fatal_error("Invalid source definition -- only isotropic sources are " + "allowed for random ray source."); + } + + // Validate plotting files + /////////////////////////////////////////////////////////////////// + for (int p = 0; p < model::plots.size(); p++) { + + // Get handle to OpenMC plot object + Plot* openmc_plot = dynamic_cast(model::plots[p].get()); + + // Random ray plots only support voxel plots + if (!openmc_plot) { + warning(fmt::format( + "Plot {} will not be used for end of simulation data plotting -- only " + "voxel plotting is allowed in random ray mode.", + p)); + continue; + } else if (openmc_plot->type_ != Plot::PlotType::voxel) { + warning(fmt::format( + "Plot {} will not be used for end of simulation data plotting -- only " + "voxel plotting is allowed in random ray mode.", + p)); + continue; + } + } + + // Warn about slow MPI domain replication, if detected + /////////////////////////////////////////////////////////////////// +#ifdef OPENMC_MPI + if (mpi::n_procs > 1) { + warning( + "Domain replication in random ray is supported, but suffers from poor " + "scaling of source all-reduce operations. Performance may severely " + "degrade beyond just a few MPI ranks. Domain decomposition may be " + "implemented in the future to provide efficient scaling."); + } +#endif +} + +//============================================================================== +// RandomRaySimulation implementation +//============================================================================== + +RandomRaySimulation::RandomRaySimulation() + : negroups_(data::mg.num_energy_groups_) +{ + // There are no source sites in random ray mode, so be sure to disable to + // ensure we don't attempt to write source sites to statepoint + settings::source_write = false; + + // Random ray mode does not have an inner loop over generations within a + // batch, so set the current gen to 1 + simulation::current_gen = 1; +} + +void RandomRaySimulation::simulate() +{ + // Random ray power iteration loop + while (simulation::current_batch < settings::n_batches) { + + // Initialize the current batch + initialize_batch(); + initialize_generation(); + + // Reset total starting particle weight used for normalizing tallies + simulation::total_weight = 1.0; + + // Update source term (scattering + fission) + domain_.update_neutron_source(k_eff_); + + // Reset scalar fluxes, iteration volume tallies, and region hit flags to + // zero + domain_.batch_reset(); + + // Start timer for transport + simulation::time_transport.start(); + +// Transport sweep over all random rays for the iteration +#pragma omp parallel for schedule(dynamic) \ + reduction(+ : total_geometric_intersections_) + for (int i = 0; i < simulation::work_per_rank; i++) { + RandomRay ray(i, &domain_); + total_geometric_intersections_ += + ray.transport_history_based_single_ray(); + } + + simulation::time_transport.stop(); + + // If using multiple MPI ranks, perform all reduce on all transport results + domain_.all_reduce_replicated_source_regions(); + + // Normalize scalar flux and update volumes + domain_.normalize_scalar_flux_and_volumes( + settings::n_particles * RandomRay::distance_active_); + + // Add source to scalar flux, compute number of FSR hits + int64_t n_hits = domain_.add_source_to_scalar_flux(); + + // Compute random ray k-eff + k_eff_ = domain_.compute_k_eff(k_eff_); + + // Store random ray k-eff into OpenMC's native k-eff variable + global_tally_tracklength = k_eff_; + + // Execute all tallying tasks, if this is an active batch + if (simulation::current_batch > settings::n_inactive && mpi::master) { + + // Generate mapping between source regions and tallies + if (!domain_.mapped_all_tallies_) { + domain_.convert_source_regions_to_tallies(); + } + + // Use above mapping to contribute FSR flux data to appropriate tallies + domain_.random_ray_tally(); + + // Add this iteration's scalar flux estimate to final accumulated estimate + domain_.accumulate_iteration_flux(); + } + + // Set phi_old = phi_new + domain_.scalar_flux_old_.swap(domain_.scalar_flux_new_); + + // Check for any obvious insabilities/nans/infs + instability_check(n_hits, k_eff_, avg_miss_rate_); + + // Finalize the current batch + finalize_generation(); + finalize_batch(); + } // End random ray power iteration loop +} + +void RandomRaySimulation::reduce_simulation_statistics() +{ + // Reduce number of intersections +#ifdef OPENMC_MPI + if (mpi::n_procs > 1) { + uint64_t total_geometric_intersections_reduced = 0; + MPI_Reduce(&total_geometric_intersections_, + &total_geometric_intersections_reduced, 1, MPI_UNSIGNED_LONG, MPI_SUM, 0, + mpi::intracomm); + total_geometric_intersections_ = total_geometric_intersections_reduced; + } +#endif +} + +void RandomRaySimulation::output_simulation_results() const +{ + // Print random ray results + if (mpi::master) { + print_results_random_ray(total_geometric_intersections_, + avg_miss_rate_ / settings::n_batches, negroups_, + domain_.n_source_regions_); + if (model::plots.size() > 0) { + domain_.output_to_vtk(); + } + } +} + +// Apply a few sanity checks to catch obvious cases of numerical instability. +// Instability typically only occurs if ray density is extremely low. +void RandomRaySimulation::instability_check( + int64_t n_hits, double k_eff, double& avg_miss_rate) const +{ + double percent_missed = ((domain_.n_source_regions_ - n_hits) / + static_cast(domain_.n_source_regions_)) * + 100.0; + avg_miss_rate += percent_missed; + + if (percent_missed > 10.0) { + warning(fmt::format( + "Very high FSR miss rate detected ({:.3f}%). Instability may occur. " + "Increase ray density by adding more rays and/or active distance.", + percent_missed)); + } else if (percent_missed > 0.01) { + warning(fmt::format("Elevated FSR miss rate detected ({:.3f}%). Increasing " + "ray density by adding more rays and/or active " + "distance may improve simulation efficiency.", + percent_missed)); + } + + if (k_eff > 10.0 || k_eff < 0.01 || !(std::isfinite(k_eff))) { + fatal_error("Instability detected"); + } +} + +// Print random ray simulation results +void RandomRaySimulation::print_results_random_ray( + uint64_t total_geometric_intersections, double avg_miss_rate, int negroups, + int64_t n_source_regions) const +{ + using namespace simulation; + + if (settings::verbosity >= 6) { + double total_integrations = total_geometric_intersections * negroups; + double time_per_integration = + simulation::time_transport.elapsed() / total_integrations; + double misc_time = time_total.elapsed() - time_update_src.elapsed() - + time_transport.elapsed() - time_tallies.elapsed() - + time_bank_sendrecv.elapsed(); + + header("Simulation Statistics", 4); + fmt::print( + " Total Iterations = {}\n", settings::n_batches); + fmt::print(" Flat Source Regions (FSRs) = {}\n", n_source_regions); + fmt::print(" Total Geometric Intersections = {:.4e}\n", + static_cast(total_geometric_intersections)); + fmt::print(" Avg per Iteration = {:.4e}\n", + static_cast(total_geometric_intersections) / settings::n_batches); + fmt::print(" Avg per Iteration per FSR = {:.2f}\n", + static_cast(total_geometric_intersections) / + static_cast(settings::n_batches) / n_source_regions); + fmt::print(" Avg FSR Miss Rate per Iteration = {:.4f}%\n", avg_miss_rate); + fmt::print(" Energy Groups = {}\n", negroups); + fmt::print( + " Total Integrations = {:.4e}\n", total_integrations); + fmt::print(" Avg per Iteration = {:.4e}\n", + total_integrations / settings::n_batches); + + header("Timing Statistics", 4); + show_time("Total time for initialization", time_initialize.elapsed()); + show_time("Reading cross sections", time_read_xs.elapsed(), 1); + show_time("Total simulation time", time_total.elapsed()); + show_time("Transport sweep only", time_transport.elapsed(), 1); + show_time("Source update only", time_update_src.elapsed(), 1); + show_time("Tally conversion only", time_tallies.elapsed(), 1); + show_time("MPI source reductions only", time_bank_sendrecv.elapsed(), 1); + show_time("Other iteration routines", misc_time, 1); + if (settings::run_mode == RunMode::EIGENVALUE) { + show_time("Time in inactive batches", time_inactive.elapsed()); + } + show_time("Time in active batches", time_active.elapsed()); + show_time("Time writing statepoints", time_statepoint.elapsed()); + show_time("Total time for finalization", time_finalize.elapsed()); + show_time("Time per integration", time_per_integration); + } + + if (settings::verbosity >= 4) { + header("Results", 4); + fmt::print(" k-effective = {:.5f} +/- {:.5f}\n", + simulation::keff, simulation::keff_std); + } +} + +} // namespace openmc diff --git a/src/settings.cpp b/src/settings.cpp index 9f183a6c16..6790f2cc0f 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -24,6 +24,7 @@ #include "openmc/output.h" #include "openmc/plot.h" #include "openmc/random_lcg.h" +#include "openmc/random_ray/random_ray.h" #include "openmc/simulation.h" #include "openmc/source.h" #include "openmc/string_utils.h" @@ -113,6 +114,7 @@ double res_scat_energy_min {0.01}; double res_scat_energy_max {1000.0}; vector res_scat_nuclides; RunMode run_mode {RunMode::UNSET}; +SolverType solver_type {SolverType::MONTE_CARLO}; std::unordered_set sourcepoint_batch; std::unordered_set statepoint_batch; std::unordered_set source_write_surf_id; @@ -233,6 +235,38 @@ void get_run_parameters(pugi::xml_node node_base) } } } + + // Random ray variables + if (solver_type == SolverType::RANDOM_RAY) { + xml_node random_ray_node = node_base.child("random_ray"); + if (check_for_node(random_ray_node, "distance_active")) { + RandomRay::distance_active_ = + std::stod(get_node_value(random_ray_node, "distance_active")); + if (RandomRay::distance_active_ <= 0.0) { + fatal_error("Random ray active distance must be greater than 0"); + } + } else { + fatal_error("Specify random ray active distance in settings XML"); + } + if (check_for_node(random_ray_node, "distance_inactive")) { + RandomRay::distance_inactive_ = + std::stod(get_node_value(random_ray_node, "distance_inactive")); + if (RandomRay::distance_inactive_ < 0) { + fatal_error( + "Random ray inactive distance must be greater than or equal to 0"); + } + } else { + fatal_error("Specify random ray inactive distance in settings XML"); + } + if (check_for_node(random_ray_node, "source")) { + xml_node source_node = random_ray_node.child("source"); + // Get point to list of elements and make sure there is at least + // one + RandomRay::ray_source_ = Source::create(source_node); + } else { + fatal_error("Specify random ray source in settings XML"); + } + } } void read_settings_xml() @@ -389,6 +423,14 @@ void read_settings_xml(pugi::xml_node root) } } + // Check solver type + if (check_for_node(root, "random_ray")) { + solver_type = SolverType::RANDOM_RAY; + if (run_CE) + fatal_error("multi-group energy mode must be specified in settings XML " + "when using the random ray solver."); + } + if (run_mode == RunMode::EIGENVALUE || run_mode == RunMode::FIXED_SOURCE) { // Read run parameters get_run_parameters(node_mode); diff --git a/src/simulation.cpp b/src/simulation.cpp index a7aea69424..1cf34a820d 100644 --- a/src/simulation.cpp +++ b/src/simulation.cpp @@ -122,7 +122,8 @@ int openmc_simulation_init() write_message("Resuming simulation...", 6); } else { // Only initialize primary source bank for eigenvalue simulations - if (settings::run_mode == RunMode::EIGENVALUE) { + if (settings::run_mode == RunMode::EIGENVALUE && + settings::solver_type == SolverType::MONTE_CARLO) { initialize_source(); } } @@ -132,7 +133,11 @@ int openmc_simulation_init() if (settings::run_mode == RunMode::FIXED_SOURCE) { header("FIXED SOURCE TRANSPORT SIMULATION", 3); } else if (settings::run_mode == RunMode::EIGENVALUE) { - header("K EIGENVALUE SIMULATION", 3); + if (settings::solver_type == SolverType::MONTE_CARLO) { + header("K EIGENVALUE SIMULATION", 3); + } else if (settings::solver_type == SolverType::RANDOM_RAY) { + header("K EIGENVALUE SIMULATION (RANDOM RAY SOLVER)", 3); + } if (settings::verbosity >= 7) print_columns(); } @@ -196,10 +201,12 @@ int openmc_simulation_finalize() simulation::time_finalize.stop(); simulation::time_total.stop(); if (mpi::master) { - if (settings::verbosity >= 6) - print_runtime(); - if (settings::verbosity >= 4) - print_results(); + if (settings::solver_type != SolverType::RANDOM_RAY) { + if (settings::verbosity >= 6) + print_runtime(); + if (settings::verbosity >= 4) + print_results(); + } } if (settings::check_overlaps) print_overlap_check(); @@ -309,7 +316,8 @@ vector work_index; void allocate_banks() { - if (settings::run_mode == RunMode::EIGENVALUE) { + if (settings::run_mode == RunMode::EIGENVALUE && + settings::solver_type == SolverType::MONTE_CARLO) { // Allocate source bank simulation::source_bank.resize(simulation::work_per_rank); @@ -493,7 +501,8 @@ void finalize_generation() } global_tally_leakage = 0.0; - if (settings::run_mode == RunMode::EIGENVALUE) { + if (settings::run_mode == RunMode::EIGENVALUE && + settings::solver_type == SolverType::MONTE_CARLO) { // If using shared memory, stable sort the fission bank (by parent IDs) // so as to allow for reproducibility regardless of which order particles // are run in. @@ -501,6 +510,9 @@ void finalize_generation() // Distribute fission bank across processors evenly synchronize_bank(); + } + + if (settings::run_mode == RunMode::EIGENVALUE) { // Calculate shannon entropy if (settings::entropy_on) diff --git a/src/source.cpp b/src/source.cpp index 778962667b..5ddac7f428 100644 --- a/src/source.cpp +++ b/src/source.cpp @@ -243,36 +243,40 @@ SourceSite IndependentSource::sample(uint64_t* seed) const // Sample angle site.u = angle_->sample(seed); - // Check for monoenergetic source above maximum particle energy - auto p = static_cast(particle_); - auto energy_ptr = dynamic_cast(energy_.get()); - if (energy_ptr) { - auto energies = xt::adapt(energy_ptr->x()); - if (xt::any(energies > data::energy_max[p])) { - fatal_error("Source energy above range of energies of at least " - "one cross section table"); + // Sample energy and time for neutron and photon sources + if (settings::solver_type != SolverType::RANDOM_RAY) { + // Check for monoenergetic source above maximum particle energy + auto p = static_cast(particle_); + auto energy_ptr = dynamic_cast(energy_.get()); + if (energy_ptr) { + auto energies = xt::adapt(energy_ptr->x()); + if (xt::any(energies > data::energy_max[p])) { + fatal_error("Source energy above range of energies of at least " + "one cross section table"); + } } - } - while (true) { - // Sample energy spectrum - site.E = energy_->sample(seed); + while (true) { + // Sample energy spectrum + site.E = energy_->sample(seed); - // Resample if energy falls above maximum particle energy - if (site.E < data::energy_max[p]) - break; + // Resample if energy falls above maximum particle energy + if (site.E < data::energy_max[p]) + break; - n_reject++; - if (n_reject >= EXTSRC_REJECT_THRESHOLD && - static_cast(n_accept) / n_reject <= EXTSRC_REJECT_FRACTION) { - fatal_error("More than 95% of external source sites sampled were " - "rejected. Please check your external source energy spectrum " - "definition."); + n_reject++; + if (n_reject >= EXTSRC_REJECT_THRESHOLD && + static_cast(n_accept) / n_reject <= EXTSRC_REJECT_FRACTION) { + fatal_error( + "More than 95% of external source sites sampled were " + "rejected. Please check your external source energy spectrum " + "definition."); + } } - } - // Sample particle creation time - site.time = time_->sample(seed); + // Sample particle creation time + site.time = time_->sample(seed); + } // Increment number of accepted samples ++n_accept; diff --git a/src/tallies/tally.cpp b/src/tallies/tally.cpp index df1ec5ea79..2e77bcad25 100644 --- a/src/tallies/tally.cpp +++ b/src/tallies/tally.cpp @@ -757,6 +757,10 @@ void Tally::accumulate() double norm = total_source / (settings::n_particles * settings::gen_per_batch); + if (settings::solver_type == SolverType::RANDOM_RAY) { + norm = 1.0; + } + // Accumulate each result #pragma omp parallel for for (int i = 0; i < results_.shape()[0]; ++i) { @@ -953,8 +957,9 @@ void accumulate_tallies() { #ifdef OPENMC_MPI // Combine tally results onto master process - if (mpi::n_procs > 1) + if (mpi::n_procs > 1 && settings::solver_type == SolverType::MONTE_CARLO) { reduce_tally_results(); + } #endif // Increase number of realizations (only used for global tallies) diff --git a/src/timer.cpp b/src/timer.cpp index 86436758a3..6d692d4fbf 100644 --- a/src/timer.cpp +++ b/src/timer.cpp @@ -26,6 +26,7 @@ Timer time_event_advance_particle; Timer time_event_surface_crossing; Timer time_event_collision; Timer time_event_death; +Timer time_update_src; } // namespace simulation @@ -85,6 +86,7 @@ void reset_timers() simulation::time_event_surface_crossing.reset(); simulation::time_event_collision.reset(); simulation::time_event_death.reset(); + simulation::time_update_src.reset(); } } // namespace openmc diff --git a/tests/regression_tests/random_ray_basic/__init__.py b/tests/regression_tests/random_ray_basic/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/regression_tests/random_ray_basic/inputs_true.dat b/tests/regression_tests/random_ray_basic/inputs_true.dat new file mode 100644 index 0000000000..97b7906f7b --- /dev/null +++ b/tests/regression_tests/random_ray_basic/inputs_true.dat @@ -0,0 +1,108 @@ + + + + mgxs.h5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.126 0.126 + 10 10 + -0.63 -0.63 + +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 + + + 1.26 1.26 + 2 2 + -1.26 -1.26 + +2 2 +2 5 + + + + + + + + + + + + + + + + + + + + + eigenvalue + 100 + 10 + 5 + multi-group + + 100.0 + 20.0 + + + -1.26 -1.26 -1 1.26 1.26 1 + + + + + + + 2 2 + -1.26 -1.26 + 1.26 1.26 + + + 1 + + + 1e-05 0.0635 10.0 100.0 1000.0 500000.0 1000000.0 20000000.0 + + + 1 2 + flux fission nu-fission + analog + + + diff --git a/tests/regression_tests/random_ray_basic/results_true.dat b/tests/regression_tests/random_ray_basic/results_true.dat new file mode 100644 index 0000000000..802e78a828 --- /dev/null +++ b/tests/regression_tests/random_ray_basic/results_true.dat @@ -0,0 +1,171 @@ +k-combined: +8.400322E-01 8.023349E-03 +tally 1: +1.260220E+00 +3.179889E-01 +1.484289E-01 +4.411066E-03 +3.612463E-01 +2.612843E-02 +7.086707E-01 +1.006119E-01 +3.342483E-02 +2.238499E-04 +8.134936E-02 +1.325949E-03 +4.194328E-01 +3.558669E-02 +4.287776E-03 +3.717447E-06 +1.043559E-02 +2.201986E-05 +5.878720E-01 +7.045887E-02 +6.147757E-03 +7.701173E-06 +1.496241E-02 +4.561699E-05 +1.768113E+00 +6.356917E-01 +6.513486E-03 +8.628535E-06 +1.585272E-02 +5.111136E-05 +5.063704E+00 +5.152401E+00 +2.440293E-03 +1.196869E-06 +6.038334E-03 +7.328193E-06 +3.253717E+00 +2.117655E+00 +1.389120E-02 +3.859385E-05 +3.863767E-02 +2.985798E-04 +1.876994E+00 +7.046366E-01 +0.000000E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +8.390875E-01 +1.408791E-01 +0.000000E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +4.513839E-01 +4.139640E-02 +0.000000E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +6.682186E-01 +9.116003E-02 +0.000000E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +1.849034E+00 +6.944337E-01 +0.000000E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +4.523425E+00 +4.112118E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +2.821432E+00 +1.592568E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +1.159618E+00 +2.694138E-01 +1.354028E-01 +3.672094E-03 +3.295432E-01 +2.175122E-02 +6.880334E-01 +9.491215E-02 +3.234611E-02 +2.097782E-04 +7.872396E-02 +1.242596E-03 +4.184841E-01 +3.536436E-02 +4.274305E-03 +3.687209E-06 +1.040280E-02 +2.184074E-05 +5.810180E-01 +6.872944E-02 +6.060273E-03 +7.476500E-06 +1.474949E-02 +4.428617E-05 +1.782580E+00 +6.457892E-01 +6.552384E-03 +8.730345E-06 +1.594739E-02 +5.171444E-05 +5.278155E+00 +5.596601E+00 +2.546878E-03 +1.303010E-06 +6.302072E-03 +7.978072E-06 +3.420419E+00 +2.340454E+00 +1.465798E-02 +4.299061E-05 +4.077042E-02 +3.325951E-04 +1.279417E+00 +3.278133E-01 +1.509073E-01 +4.561836E-03 +3.672782E-01 +2.702150E-02 +7.212777E-01 +1.042487E-01 +3.411552E-02 +2.332877E-04 +8.303035E-02 +1.381852E-03 +4.269473E-01 +3.685202E-02 +4.378540E-03 +3.872997E-06 +1.065649E-02 +2.294124E-05 +5.973530E-01 +7.266946E-02 +6.260881E-03 +7.976490E-06 +1.523773E-02 +4.724780E-05 +1.795373E+00 +6.547440E-01 +6.635941E-03 +8.945067E-06 +1.615075E-02 +5.298634E-05 +5.161876E+00 +5.353441E+00 +2.505311E-03 +1.261399E-06 +6.199218E-03 +7.723296E-06 +3.344042E+00 +2.236603E+00 +1.443089E-02 +4.166228E-05 +4.013879E-02 +3.223186E-04 diff --git a/tests/regression_tests/random_ray_basic/test.py b/tests/regression_tests/random_ray_basic/test.py new file mode 100644 index 0000000000..1727a63716 --- /dev/null +++ b/tests/regression_tests/random_ray_basic/test.py @@ -0,0 +1,232 @@ +import os + +import numpy as np +import openmc + +from tests.testing_harness import TolerantPyAPITestHarness + + +class MGXSTestHarness(TolerantPyAPITestHarness): + def _cleanup(self): + super()._cleanup() + f = 'mgxs.h5' + if os.path.exists(f): + os.remove(f) + + +def random_ray_model() -> openmc.Model: + ############################################################################### + # Create multigroup data + + # Instantiate the energy group data + group_edges = [1e-5, 0.0635, 10.0, 1.0e2, 1.0e3, 0.5e6, 1.0e6, 20.0e6] + groups = openmc.mgxs.EnergyGroups(group_edges) + + # Instantiate the 7-group (C5G7) cross section data + uo2_xsdata = openmc.XSdata('UO2', groups) + uo2_xsdata.order = 0 + uo2_xsdata.set_total( + [0.1779492, 0.3298048, 0.4803882, 0.5543674, 0.3118013, 0.3951678, + 0.5644058]) + uo2_xsdata.set_absorption([8.0248e-03, 3.7174e-03, 2.6769e-02, 9.6236e-02, + 3.0020e-02, 1.1126e-01, 2.8278e-01]) + scatter_matrix = np.array( + [[[0.1275370, 0.0423780, 0.0000094, 0.0000000, 0.0000000, 0.0000000, 0.0000000], + [0.0000000, 0.3244560, 0.0016314, 0.0000000, 0.0000000, 0.0000000, 0.0000000], + [0.0000000, 0.0000000, 0.4509400, 0.0026792, 0.0000000, 0.0000000, 0.0000000], + [0.0000000, 0.0000000, 0.0000000, 0.4525650, 0.0055664, 0.0000000, 0.0000000], + [0.0000000, 0.0000000, 0.0000000, 0.0001253, 0.2714010, 0.0102550, 0.0000000], + [0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0012968, 0.2658020, 0.0168090], + [0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0085458, 0.2730800]]]) + scatter_matrix = np.rollaxis(scatter_matrix, 0, 3) + uo2_xsdata.set_scatter_matrix(scatter_matrix) + uo2_xsdata.set_fission([7.21206e-03, 8.19301e-04, 6.45320e-03, + 1.85648e-02, 1.78084e-02, 8.30348e-02, + 2.16004e-01]) + uo2_xsdata.set_nu_fission([2.005998e-02, 2.027303e-03, 1.570599e-02, + 4.518301e-02, 4.334208e-02, 2.020901e-01, + 5.257105e-01]) + uo2_xsdata.set_chi([5.8791e-01, 4.1176e-01, 3.3906e-04, 1.1761e-07, 0.0000e+00, + 0.0000e+00, 0.0000e+00]) + + h2o_xsdata = openmc.XSdata('LWTR', groups) + h2o_xsdata.order = 0 + h2o_xsdata.set_total([0.15920605, 0.412969593, 0.59030986, 0.58435, + 0.718, 1.2544497, 2.650379]) + h2o_xsdata.set_absorption([6.0105e-04, 1.5793e-05, 3.3716e-04, + 1.9406e-03, 5.7416e-03, 1.5001e-02, + 3.7239e-02]) + scatter_matrix = np.array( + [[[0.0444777, 0.1134000, 0.0007235, 0.0000037, 0.0000001, 0.0000000, 0.0000000], + [0.0000000, 0.2823340, 0.1299400, 0.0006234, 0.0000480, 0.0000074, 0.0000010], + [0.0000000, 0.0000000, 0.3452560, 0.2245700, 0.0169990, 0.0026443, 0.0005034], + [0.0000000, 0.0000000, 0.0000000, 0.0910284, 0.4155100, 0.0637320, 0.0121390], + [0.0000000, 0.0000000, 0.0000000, 0.0000714, 0.1391380, 0.5118200, 0.0612290], + [0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0022157, 0.6999130, 0.5373200], + [0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.1324400, 2.4807000]]]) + scatter_matrix = np.rollaxis(scatter_matrix, 0, 3) + h2o_xsdata.set_scatter_matrix(scatter_matrix) + + mg_cross_sections = openmc.MGXSLibrary(groups) + mg_cross_sections.add_xsdatas([uo2_xsdata, h2o_xsdata]) + mg_cross_sections.export_to_hdf5() + + ############################################################################### + # Create materials for the problem + + # Instantiate some Materials and register the appropriate macroscopic data + uo2 = openmc.Material(name='UO2 fuel') + uo2.set_density('macro', 1.0) + uo2.add_macroscopic('UO2') + + water = openmc.Material(name='Water') + water.set_density('macro', 1.0) + water.add_macroscopic('LWTR') + + # Instantiate a Materials collection and export to XML + materials = openmc.Materials([uo2, water]) + materials.cross_sections = "mgxs.h5" + + ############################################################################### + # Define problem geometry + + ######################################## + # Define an unbounded pincell universe + + pitch = 1.26 + + # Create a surface for the fuel outer radius + fuel_or = openmc.ZCylinder(r=0.54, name='Fuel OR') + inner_ring_a = openmc.ZCylinder(r=0.33, name='inner ring a') + inner_ring_b = openmc.ZCylinder(r=0.45, name='inner ring b') + outer_ring_a = openmc.ZCylinder(r=0.60, name='outer ring a') + outer_ring_b = openmc.ZCylinder(r=0.69, name='outer ring b') + + # Instantiate Cells + fuel_a = openmc.Cell(fill=uo2, region=-inner_ring_a, name='fuel inner a') + fuel_b = openmc.Cell(fill=uo2, region=+inner_ring_a & -inner_ring_b, name='fuel inner b') + fuel_c = openmc.Cell(fill=uo2, region=+inner_ring_b & -fuel_or, name='fuel inner c') + moderator_a = openmc.Cell(fill=water, region=+fuel_or & -outer_ring_a, name='moderator inner a') + moderator_b = openmc.Cell(fill=water, region=+outer_ring_a & -outer_ring_b, name='moderator outer b') + moderator_c = openmc.Cell(fill=water, region=+outer_ring_b, name='moderator outer c') + + # Create pincell universe + pincell_base = openmc.Universe() + + # Register Cells with Universe + pincell_base.add_cells([fuel_a, fuel_b, fuel_c, moderator_a, moderator_b, moderator_c]) + + # Create planes for azimuthal sectors + azimuthal_planes = [] + for i in range(8): + angle = 2 * i * openmc.pi / 8 + normal_vector = (-openmc.sin(angle), openmc.cos(angle), 0) + azimuthal_planes.append(openmc.Plane(a=normal_vector[0], b=normal_vector[1], c=normal_vector[2], d=0)) + + # Create a cell for each azimuthal sector + azimuthal_cells = [] + for i in range(8): + azimuthal_cell = openmc.Cell(name=f'azimuthal_cell_{i}') + azimuthal_cell.fill = pincell_base + azimuthal_cell.region = +azimuthal_planes[i] & -azimuthal_planes[(i+1) % 8] + azimuthal_cells.append(azimuthal_cell) + + # Create a geometry with the azimuthal universes + pincell = openmc.Universe(cells=azimuthal_cells) + + ######################################## + # Define a moderator lattice universe + + moderator_infinite = openmc.Cell(fill=water, name='moderator infinite') + mu = openmc.Universe() + mu.add_cells([moderator_infinite]) + + lattice = openmc.RectLattice() + lattice.lower_left = [-pitch/2.0, -pitch/2.0] + lattice.pitch = [pitch/10.0, pitch/10.0] + lattice.universes = np.full((10, 10), mu) + + mod_lattice_cell = openmc.Cell(fill=lattice) + + mod_lattice_uni = openmc.Universe() + + mod_lattice_uni.add_cells([mod_lattice_cell]) + + ######################################## + # Define 2x2 outer lattice + lattice2x2 = openmc.RectLattice() + lattice2x2.lower_left = (-pitch, -pitch) + lattice2x2.pitch = (pitch, pitch) + lattice2x2.universes = [ + [pincell, pincell], + [pincell, mod_lattice_uni] + ] + + ######################################## + # Define cell containing lattice and other stuff + box = openmc.model.RectangularPrism(pitch*2, pitch*2, boundary_type='reflective') + + assembly = openmc.Cell(fill=lattice2x2, region=-box, name='assembly') + + # Create a geometry with the top-level cell + geometry = openmc.Geometry([assembly]) + + ############################################################################### + # Define problem settings + + # Instantiate a Settings object, set all runtime parameters, and export to XML + settings = openmc.Settings() + settings.energy_mode = "multi-group" + settings.batches = 10 + settings.inactive = 5 + settings.particles = 100 + + # Create an initial uniform spatial source distribution over fissionable zones + lower_left = (-pitch, -pitch, -1) + upper_right = (pitch, pitch, 1) + uniform_dist = openmc.stats.Box(lower_left, upper_right) + rr_source = openmc.IndependentSource(space=uniform_dist) + + settings.random_ray['distance_active'] = 100.0 + settings.random_ray['distance_inactive'] = 20.0 + settings.random_ray['ray_source'] = rr_source + + ############################################################################### + # Define tallies + + # Create a mesh that will be used for tallying + mesh = openmc.RegularMesh() + mesh.dimension = (2, 2) + mesh.lower_left = (-pitch, -pitch) + mesh.upper_right = (pitch, pitch) + + # Create a mesh filter that can be used in a tally + mesh_filter = openmc.MeshFilter(mesh) + + # Create an energy group filter as well + energy_filter = openmc.EnergyFilter(group_edges) + + # Now use the mesh filter in a tally and indicate what scores are desired + tally = openmc.Tally(name="Mesh tally") + tally.filters = [mesh_filter, energy_filter] + tally.scores = ['flux', 'fission', 'nu-fission'] + tally.estimator = 'analog' + + # Instantiate a Tallies collection and export to XML + tallies = openmc.Tallies([tally]) + + ############################################################################### + # Exporting to OpenMC model + ############################################################################### + + model = openmc.Model() + model.geometry = geometry + model.materials = materials + model.settings = settings + model.tallies = tallies + return model + + +def test_random_ray_basic(): + harness = MGXSTestHarness('statepoint.10.h5', random_ray_model()) + harness.main() diff --git a/tests/regression_tests/random_ray_vacuum/__init__.py b/tests/regression_tests/random_ray_vacuum/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/regression_tests/random_ray_vacuum/inputs_true.dat b/tests/regression_tests/random_ray_vacuum/inputs_true.dat new file mode 100644 index 0000000000..4ef1094200 --- /dev/null +++ b/tests/regression_tests/random_ray_vacuum/inputs_true.dat @@ -0,0 +1,108 @@ + + + + mgxs.h5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.126 0.126 + 10 10 + -0.63 -0.63 + +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 +3 3 3 3 3 3 3 3 3 3 + + + 1.26 1.26 + 2 2 + -1.26 -1.26 + +2 2 +2 5 + + + + + + + + + + + + + + + + + + + + + eigenvalue + 100 + 10 + 5 + multi-group + + 100.0 + 20.0 + + + -1.26 -1.26 -1 1.26 1.26 1 + + + + + + + 2 2 + -1.26 -1.26 + 1.26 1.26 + + + 1 + + + 1e-05 0.0635 10.0 100.0 1000.0 500000.0 1000000.0 20000000.0 + + + 1 2 + flux fission nu-fission + analog + + + diff --git a/tests/regression_tests/random_ray_vacuum/results_true.dat b/tests/regression_tests/random_ray_vacuum/results_true.dat new file mode 100644 index 0000000000..744ce6cef2 --- /dev/null +++ b/tests/regression_tests/random_ray_vacuum/results_true.dat @@ -0,0 +1,171 @@ +k-combined: +1.010455E-01 1.585558E-02 +tally 1: +1.849176E-01 +7.634332E-03 +2.181815E-02 +1.062861E-04 +5.310100E-02 +6.295730E-04 +4.048251E-02 +3.851890E-04 +1.893676E-03 +8.448769E-07 +4.608828E-03 +5.004529E-06 +4.063643E-03 +4.022442E-06 +4.112970E-05 +4.186661E-10 +1.001015E-04 +2.479919E-09 +7.467029E-03 +1.178864E-05 +7.688748E-05 +1.266903E-09 +1.871288E-04 +7.504350E-09 +3.870644E-02 +3.010745E-04 +1.375240E-04 +3.807356E-09 +3.347099E-04 +2.255298E-08 +4.524967E-01 +4.098857E-02 +2.437418E-04 +1.190325E-08 +6.031220E-04 +7.288126E-08 +4.989226E-01 +4.993728E-02 +2.374296E-03 +1.135824E-06 +6.603983E-03 +8.787258E-06 +3.899991E-01 +3.308783E-02 +0.000000E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +7.108982E-02 +1.144390E-03 +0.000000E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +5.295259E-03 +6.352159E-06 +0.000000E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +9.852001E-03 +1.984406E-05 +0.000000E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +4.414391E-02 +3.905201E-04 +0.000000E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +2.571668E-01 +1.323140E-02 +0.000000E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +2.752932E-01 +1.517930E-02 +0.000000E+00 +0.000000E+00 +0.000000E+00 +0.000000E+00 +1.465446E-01 +4.901884E-03 +1.700791E-02 +6.587674E-05 +4.139385E-02 +3.902131E-04 +3.424032E-02 +2.841338E-04 +1.598985E-03 +6.216312E-07 +3.891610E-03 +3.682159E-06 +4.067582E-03 +4.209468E-06 +4.152829E-05 +4.494649E-10 +1.010715E-04 +2.662352E-09 +7.526712E-03 +1.225032E-05 +7.769969E-05 +1.328443E-09 +1.891055E-04 +7.868877E-09 +4.008649E-02 +3.246821E-04 +1.417944E-04 +4.070719E-09 +3.451035E-04 +2.411301E-08 +4.859902E-01 +4.747592E-02 +2.606214E-04 +1.369749E-08 +6.448895E-04 +8.386705E-08 +5.475198E-01 +6.061269E-02 +2.625477E-03 +1.405458E-06 +7.302631E-03 +1.087327E-05 +1.909660E-01 +8.147906E-03 +2.269063E-02 +1.149570E-04 +5.522446E-02 +6.809342E-04 +4.196583E-02 +4.141620E-04 +1.980406E-03 +9.227119E-07 +4.819913E-03 +5.465576E-06 +4.247004E-03 +4.420116E-06 +4.341806E-05 +4.691518E-10 +1.056709E-04 +2.778965E-09 +7.742814E-03 +1.272112E-05 +8.039606E-05 +1.389209E-09 +1.956679E-04 +8.228817E-09 +3.982370E-02 +3.190931E-04 +1.427171E-04 +4.103942E-09 +3.473492E-04 +2.430981E-08 +4.849535E-01 +4.707014E-02 +2.678327E-04 +1.438540E-08 +6.627333E-04 +8.807897E-08 +5.493457E-01 +6.069440E-02 +2.717400E-03 +1.501450E-06 +7.558312E-03 +1.161591E-05 diff --git a/tests/regression_tests/random_ray_vacuum/test.py b/tests/regression_tests/random_ray_vacuum/test.py new file mode 100644 index 0000000000..e9ca225214 --- /dev/null +++ b/tests/regression_tests/random_ray_vacuum/test.py @@ -0,0 +1,235 @@ +import os + +import numpy as np +import openmc + +from tests.testing_harness import TolerantPyAPITestHarness + + +class MGXSTestHarness(TolerantPyAPITestHarness): + def _cleanup(self): + super()._cleanup() + f = 'mgxs.h5' + if os.path.exists(f): + os.remove(f) + + +def random_ray_model() -> openmc.Model: + ############################################################################### + # Create multigroup data + + # Instantiate the energy group data + group_edges = [1e-5, 0.0635, 10.0, 1.0e2, 1.0e3, 0.5e6, 1.0e6, 20.0e6] + groups = openmc.mgxs.EnergyGroups(group_edges) + + # Instantiate the 7-group (C5G7) cross section data + uo2_xsdata = openmc.XSdata('UO2', groups) + uo2_xsdata.order = 0 + uo2_xsdata.set_total( + [0.1779492, 0.3298048, 0.4803882, 0.5543674, 0.3118013, 0.3951678, + 0.5644058]) + uo2_xsdata.set_absorption([8.0248e-03, 3.7174e-03, 2.6769e-02, 9.6236e-02, + 3.0020e-02, 1.1126e-01, 2.8278e-01]) + scatter_matrix = np.array( + [[[0.1275370, 0.0423780, 0.0000094, 0.0000000, 0.0000000, 0.0000000, 0.0000000], + [0.0000000, 0.3244560, 0.0016314, 0.0000000, 0.0000000, 0.0000000, 0.0000000], + [0.0000000, 0.0000000, 0.4509400, 0.0026792, 0.0000000, 0.0000000, 0.0000000], + [0.0000000, 0.0000000, 0.0000000, 0.4525650, 0.0055664, 0.0000000, 0.0000000], + [0.0000000, 0.0000000, 0.0000000, 0.0001253, 0.2714010, 0.0102550, 0.0000000], + [0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0012968, 0.2658020, 0.0168090], + [0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0085458, 0.2730800]]]) + scatter_matrix = np.rollaxis(scatter_matrix, 0, 3) + uo2_xsdata.set_scatter_matrix(scatter_matrix) + uo2_xsdata.set_fission([7.21206e-03, 8.19301e-04, 6.45320e-03, + 1.85648e-02, 1.78084e-02, 8.30348e-02, + 2.16004e-01]) + uo2_xsdata.set_nu_fission([2.005998e-02, 2.027303e-03, 1.570599e-02, + 4.518301e-02, 4.334208e-02, 2.020901e-01, + 5.257105e-01]) + uo2_xsdata.set_chi([5.8791e-01, 4.1176e-01, 3.3906e-04, 1.1761e-07, 0.0000e+00, + 0.0000e+00, 0.0000e+00]) + + h2o_xsdata = openmc.XSdata('LWTR', groups) + h2o_xsdata.order = 0 + h2o_xsdata.set_total([0.15920605, 0.412969593, 0.59030986, 0.58435, + 0.718, 1.2544497, 2.650379]) + h2o_xsdata.set_absorption([6.0105e-04, 1.5793e-05, 3.3716e-04, + 1.9406e-03, 5.7416e-03, 1.5001e-02, + 3.7239e-02]) + scatter_matrix = np.array( + [[[0.0444777, 0.1134000, 0.0007235, 0.0000037, 0.0000001, 0.0000000, 0.0000000], + [0.0000000, 0.2823340, 0.1299400, 0.0006234, 0.0000480, 0.0000074, 0.0000010], + [0.0000000, 0.0000000, 0.3452560, 0.2245700, 0.0169990, 0.0026443, 0.0005034], + [0.0000000, 0.0000000, 0.0000000, 0.0910284, 0.4155100, 0.0637320, 0.0121390], + [0.0000000, 0.0000000, 0.0000000, 0.0000714, 0.1391380, 0.5118200, 0.0612290], + [0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0022157, 0.6999130, 0.5373200], + [0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.0000000, 0.1324400, 2.4807000]]]) + scatter_matrix = np.rollaxis(scatter_matrix, 0, 3) + h2o_xsdata.set_scatter_matrix(scatter_matrix) + + mg_cross_sections = openmc.MGXSLibrary(groups) + mg_cross_sections.add_xsdatas([uo2_xsdata, h2o_xsdata]) + mg_cross_sections.export_to_hdf5() + + ############################################################################### + # Create materials for the problem + + # Instantiate some Materials and register the appropriate Macroscopic objects + uo2 = openmc.Material(name='UO2 fuel') + uo2.set_density('macro', 1.0) + uo2.add_macroscopic('UO2') + + water = openmc.Material(name='Water') + water.set_density('macro', 1.0) + water.add_macroscopic('LWTR') + + # Instantiate a Materials collection and export to XML + materials = openmc.Materials([uo2, water]) + materials.cross_sections = "mgxs.h5" + + ############################################################################### + # Define problem geometry + + ######################################## + # Define an unbounded pincell universe + + pitch = 1.26 + + # Create a surface for the fuel outer radius + fuel_or = openmc.ZCylinder(r=0.54, name='Fuel OR') + inner_ring_a = openmc.ZCylinder(r=0.33, name='inner ring a') + inner_ring_b = openmc.ZCylinder(r=0.45, name='inner ring b') + outer_ring_a = openmc.ZCylinder(r=0.60, name='outer ring a') + outer_ring_b = openmc.ZCylinder(r=0.69, name='outer ring b') + + # Instantiate Cells + fuel_a = openmc.Cell(fill=uo2, region=-inner_ring_a, name='fuel inner a') + fuel_b = openmc.Cell(fill=uo2, region=+inner_ring_a & -inner_ring_b, name='fuel inner b') + fuel_c = openmc.Cell(fill=uo2, region=+inner_ring_b & -fuel_or, name='fuel inner c') + moderator_a = openmc.Cell(fill=water, region=+fuel_or & -outer_ring_a, name='moderator inner a') + moderator_b = openmc.Cell(fill=water, region=+outer_ring_a & -outer_ring_b, name='moderator outer b') + moderator_c = openmc.Cell(fill=water, region=+outer_ring_b, name='moderator outer c') + + # Create pincell universe + pincell_base = openmc.Universe() + + # Register Cells with Universe + pincell_base.add_cells([fuel_a, fuel_b, fuel_c, moderator_a, moderator_b, moderator_c]) + + # Create planes for azimuthal sectors + azimuthal_planes = [] + for i in range(8): + angle = 2 * i * openmc.pi / 8 + normal_vector = (-openmc.sin(angle), openmc.cos(angle), 0) + azimuthal_planes.append(openmc.Plane(a=normal_vector[0], b=normal_vector[1], c=normal_vector[2], d=0)) + + # Create a cell for each azimuthal sector + azimuthal_cells = [] + for i in range(8): + azimuthal_cell = openmc.Cell(name=f'azimuthal_cell_{i}') + azimuthal_cell.fill = pincell_base + azimuthal_cell.region = +azimuthal_planes[i] & -azimuthal_planes[(i+1) % 8] + azimuthal_cells.append(azimuthal_cell) + + # Create a geometry with the azimuthal universes + pincell = openmc.Universe(cells=azimuthal_cells) + + ######################################## + # Define a moderator lattice universe + + moderator_infinite = openmc.Cell(fill=water, name='moderator infinite') + mu = openmc.Universe() + mu.add_cells([moderator_infinite]) + + lattice = openmc.RectLattice() + lattice.lower_left = [-pitch/2.0, -pitch/2.0] + lattice.pitch = [pitch/10.0, pitch/10.0] + lattice.universes = np.full((10, 10), mu) + + mod_lattice_cell = openmc.Cell(fill=lattice) + + mod_lattice_uni = openmc.Universe() + + mod_lattice_uni.add_cells([mod_lattice_cell]) + + ######################################## + # Define 2x2 outer lattice + lattice2x2 = openmc.RectLattice() + lattice2x2.lower_left = [-pitch, -pitch] + lattice2x2.pitch = [pitch, pitch] + lattice2x2.universes = [ + [pincell, pincell], + [pincell, mod_lattice_uni] + ] + + ######################################## + # Define cell containing lattice and other stuff + box = openmc.model.RectangularPrism(pitch*2, pitch*2, boundary_type='vacuum') + + assembly = openmc.Cell(fill=lattice2x2, region=-box, name='assembly') + + root = openmc.Universe(name='root universe') + root.add_cell(assembly) + + # Create a geometry with the two cells and export to XML + geometry = openmc.Geometry(root) + + ############################################################################### + # Define problem settings + + # Instantiate a Settings object, set all runtime parameters, and export to XML + settings = openmc.Settings() + settings.energy_mode = "multi-group" + settings.batches = 10 + settings.inactive = 5 + settings.particles = 100 + + # Create an initial uniform spatial source distribution over fissionable zones + lower_left = (-pitch, -pitch, -1) + upper_right = (pitch, pitch, 1) + uniform_dist = openmc.stats.Box(lower_left, upper_right) + rr_source = openmc.IndependentSource(space=uniform_dist) + + settings.random_ray['distance_active'] = 100.0 + settings.random_ray['distance_inactive'] = 20.0 + settings.random_ray['ray_source'] = rr_source + + ############################################################################### + # Define tallies + + # Create a mesh that will be used for tallying + mesh = openmc.RegularMesh() + mesh.dimension = (2, 2) + mesh.lower_left = (-pitch, -pitch) + mesh.upper_right = (pitch, pitch) + + # Create a mesh filter that can be used in a tally + mesh_filter = openmc.MeshFilter(mesh) + + # Create an energy group filter as well + energy_filter = openmc.EnergyFilter(group_edges) + + # Now use the mesh filter in a tally and indicate what scores are desired + tally = openmc.Tally(name="Mesh tally") + tally.filters = [mesh_filter, energy_filter] + tally.scores = ['flux', 'fission', 'nu-fission'] + tally.estimator = 'analog' + + # Instantiate a Tallies collection and export to XML + tallies = openmc.Tallies([tally]) + + ############################################################################### + # Exporting to OpenMC model + ############################################################################### + + model = openmc.Model() + model.geometry = geometry + model.materials = materials + model.settings = settings + model.tallies = tallies + return model + + +def test_random_ray_vacuum(): + harness = MGXSTestHarness('statepoint.10.h5', random_ray_model()) + harness.main() diff --git a/tests/testing_harness.py b/tests/testing_harness.py index 40c9d37d80..81527452b8 100644 --- a/tests/testing_harness.py +++ b/tests/testing_harness.py @@ -384,6 +384,63 @@ class HashedPyAPITestHarness(PyAPITestHarness): return super()._get_results(True) +class TolerantPyAPITestHarness(PyAPITestHarness): + """Specialized harness for running tests that involve significant levels + of floating point non-associativity when using shared memory parallelism + due to single precision usage (e.g., as in the random ray solver). + + """ + def _are_files_equal(self, actual_path, expected_path, tolerance): + def isfloat(value): + try: + float(value) + return True + except ValueError: + return False + + def tokenize(line): + return line.strip().split() + + def compare_tokens(token1, token2): + if isfloat(token1) and isfloat(token2): + float1, float2 = float(token1), float(token2) + return abs(float1 - float2) <= tolerance * max(abs(float1), abs(float2)) + else: + return token1 == token2 + + expected = open(expected_path).readlines() + actual = open(actual_path).readlines() + + if len(expected) != len(actual): + return False + + for line1, line2 in zip(expected, actual): + tokens1 = tokenize(line1) + tokens2 = tokenize(line2) + + if len(tokens1) != len(tokens2): + return False + + for token1, token2 in zip(tokens1, tokens2): + if not compare_tokens(token1, token2): + return False + + return True + + def _compare_results(self): + """Make sure the current results agree with the reference.""" + compare = self._are_files_equal('results_test.dat', 'results_true.dat', 1e-6) + if not compare: + expected = open('results_true.dat').readlines() + actual = open('results_test.dat').readlines() + diff = unified_diff(expected, actual, 'results_true.dat', + 'results_test.dat') + print('Result differences:') + print(''.join(colorize(diff))) + os.rename('results_test.dat', 'results_error.dat') + assert compare, 'Results do not agree' + + class PlotTestHarness(TestHarness): """Specialized TestHarness for running OpenMC plotting tests.""" def __init__(self, plot_names, voxel_convert_checks=[]): diff --git a/tests/unit_tests/test_settings.py b/tests/unit_tests/test_settings.py index 30930ecdff..650bfd1868 100644 --- a/tests/unit_tests/test_settings.py +++ b/tests/unit_tests/test_settings.py @@ -58,6 +58,13 @@ def test_export_to_xml(run_in_tmpdir): s.electron_treatment = 'led' s.write_initial_source = True s.weight_window_checkpoints = {'surface': True, 'collision': False} + s.random_ray = { + 'distance_inactive': 10.0, + 'distance_active': 100.0, + 'ray_source': openmc.IndependentSource( + space=openmc.stats.Box((-1., -1., -1.), (1., 1., 1.)) + ) + } s.max_particle_events = 100 @@ -131,3 +138,7 @@ def test_export_to_xml(run_in_tmpdir): assert vol.upper_right == (10., 10., 10.) assert s.weight_window_checkpoints == {'surface': True, 'collision': False} assert s.max_particle_events == 100 + assert s.random_ray['distance_inactive'] == 10.0 + assert s.random_ray['distance_active'] == 100.0 + assert s.random_ray['ray_source'].space.lower_left == [-1., -1., -1.] + assert s.random_ray['ray_source'].space.upper_right == [1., 1., 1.] From b53b601edc325a0458bb7348a078d4014146de32 Mon Sep 17 00:00:00 2001 From: Gavin Ridley Date: Sat, 20 Apr 2024 00:44:40 -0500 Subject: [PATCH 24/48] Tiny updates from experience building on Mac (#2894) Co-authored-by: Paul Romano --- CMakeLists.txt | 21 ++++--- cmake/OpenMCConfig.cmake.in | 4 ++ docs/source/quickinstall.rst | 63 +++++++++++++------ docs/source/usersguide/install.rst | 8 +-- examples/custom_source/CMakeLists.txt | 2 +- .../CMakeLists.txt | 2 +- openmc/data/multipole.py | 9 +-- openmc/mgxs/mdgxs.py | 2 +- src/nuclide.cpp | 27 +++++--- src/tallies/filter_energy.cpp | 2 +- tests/regression_tests/cpp_driver/test.py | 2 +- tests/regression_tests/dagmc/external/test.py | 2 +- tests/regression_tests/external_moab/test.py | 2 +- tests/regression_tests/source_dlopen/test.py | 2 +- .../source_parameterized_dlopen/test.py | 2 +- 15 files changed, 95 insertions(+), 55 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3f87cbd7ec..49e987f221 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.10 FATAL_ERROR) -project(openmc C CXX) +project(openmc CXX) # Set version numbers set(OPENMC_VERSION_MAJOR 0) @@ -80,6 +80,14 @@ if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "Choose the type of build" FORCE) endif() +#=============================================================================== +# OpenMP for shared-memory parallelism (and GPU support some day!) +#=============================================================================== + +if(OPENMC_USE_OPENMP) + find_package(OpenMP REQUIRED) +endif() + #=============================================================================== # MPI for distributed-memory parallelism #=============================================================================== @@ -192,13 +200,6 @@ endif() # Skip for Visual Studio which has its own configurations through GUI if(NOT MSVC) -if(OPENMC_USE_OPENMP) - find_package(OpenMP REQUIRED) - # In CMake 3.9+, can use the OpenMP::OpenMP_CXX imported target - list(APPEND cxxflags ${OpenMP_CXX_FLAGS}) - list(APPEND ldflags ${OpenMP_CXX_FLAGS}) -endif() - set(CMAKE_POSITION_INDEPENDENT_CODE ON) if(OPENMC_ENABLE_PROFILE) @@ -522,6 +523,10 @@ if (PNG_FOUND) target_link_libraries(libopenmc PNG::PNG) endif() +if (OPENMC_USE_OPENMP) + target_link_libraries(libopenmc OpenMP::OpenMP_CXX) +endif() + if (OPENMC_USE_MPI) target_link_libraries(libopenmc MPI::MPI_CXX) endif() diff --git a/cmake/OpenMCConfig.cmake.in b/cmake/OpenMCConfig.cmake.in index 756fe26dc0..1305ad3edf 100644 --- a/cmake/OpenMCConfig.cmake.in +++ b/cmake/OpenMCConfig.cmake.in @@ -31,6 +31,10 @@ if(@OPENMC_USE_MPI@) find_package(MPI REQUIRED) endif() +if(@OPENMC_USE_OPENMP@) + find_package(OpenMP REQUIRED) +endif() + if(@OPENMC_USE_MCPL@) find_package(MCPL REQUIRED) endif() diff --git a/docs/source/quickinstall.rst b/docs/source/quickinstall.rst index b25b02fb86..7f222f77cb 100644 --- a/docs/source/quickinstall.rst +++ b/docs/source/quickinstall.rst @@ -107,31 +107,54 @@ can be used to access the installed packages. .. _Spack: https://spack.readthedocs.io/en/latest/ .. _setup guide: https://spack.readthedocs.io/en/latest/getting_started.html --------------------------------- -Installing from Source on Ubuntu --------------------------------- +------------------------------- +Manually Installing from Source +------------------------------- -To build OpenMC from source, several :ref:`prerequisites ` are -needed. If you are using Ubuntu or higher, all prerequisites can be installed -directly from the package manager: +Obtaining prerequisites on Ubuntu +--------------------------------- + +When building OpenMC from source, all :ref:`prerequisites ` can +be installed using the package manager: .. code-block:: sh sudo apt install g++ cmake libhdf5-dev libpng-dev -After the packages have been installed, follow the instructions below for -building and installing OpenMC from source. +After the packages have been installed, follow the instructions to build from +source below. -------------------------------------------- -Installing from Source on Linux or Mac OS X -------------------------------------------- +Obtaining prerequisites on macOS +-------------------------------- + +For an OpenMC build with multithreading enabled, a package manager like +`Homebrew `_ should first be installed. Then, the following +packages should be installed, for example in Homebrew via: + +.. code-block:: sh + + brew install llvm cmake xtensor hdf5 python libomp libpng + +The compiler provided by the above LLVM package should be used in place of the +one provisioned by XCode, which does not support the multithreading library used +by OpenMC. Consequently, the C++ compiler should explicitly be set before +proceeding: + +.. code-block:: sh + + export CXX=/opt/homebrew/opt/llvm/bin/clang++ + +After the packages have been installed, follow the instructions to build from +source below. + +Building Source on Linux or macOS +--------------------------------- All OpenMC source code is hosted on `GitHub `_. If you have `git -`_, the `gcc `_ compiler suite, -`CMake `_, and `HDF5 -`_ installed, you can download and -install OpenMC be entering the following commands in a terminal: +`_, a modern C++ compiler, `CMake `_, +and `HDF5 `_ installed, you can +download and install OpenMC by entering the following commands in a terminal: .. code-block:: sh @@ -151,14 +174,14 @@ should specify an installation directory where you have write access, e.g. cmake -DCMAKE_INSTALL_PREFIX=$HOME/.local .. The :mod:`openmc` Python package must be installed separately. The easiest way -to install it is using `pip `_, which is -included by default in Python 3.4+. From the root directory of the OpenMC -distribution/repository, run: +to install it is using `pip `_. +From the root directory of the OpenMC repository, run: .. code-block:: sh python -m pip install . -If you want to build a parallel version of OpenMC (using OpenMP or MPI), -directions can be found in the :ref:`detailed installation instructions +By default, OpenMC will be built with multithreading support. To build +distributed-memory parallel versions of OpenMC using MPI or to configure other +options, directions can be found in the :ref:`detailed installation instructions `. diff --git a/docs/source/usersguide/install.rst b/docs/source/usersguide/install.rst index 25329a77fd..130e96c0ae 100644 --- a/docs/source/usersguide/install.rst +++ b/docs/source/usersguide/install.rst @@ -464,11 +464,11 @@ can typically be set for a single command, i.e. .. _compile_linux: -Compiling on Linux and Mac OS X -------------------------------- +Compiling on Linux and macOS +---------------------------- -To compile OpenMC on Linux or Max OS X, run the following commands from within -the root directory of the source code: +To compile OpenMC on Linux or macOS, run the following commands from within the +root directory of the source code: .. code-block:: sh diff --git a/examples/custom_source/CMakeLists.txt b/examples/custom_source/CMakeLists.txt index 9498176944..21463ed51e 100644 --- a/examples/custom_source/CMakeLists.txt +++ b/examples/custom_source/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.3 FATAL_ERROR) +cmake_minimum_required(VERSION 3.10 FATAL_ERROR) project(openmc_sources CXX) add_library(source SHARED source_ring.cpp) find_package(OpenMC REQUIRED) diff --git a/examples/parameterized_custom_source/CMakeLists.txt b/examples/parameterized_custom_source/CMakeLists.txt index 3024e90cff..8232f3b546 100644 --- a/examples/parameterized_custom_source/CMakeLists.txt +++ b/examples/parameterized_custom_source/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.3 FATAL_ERROR) +cmake_minimum_required(VERSION 3.10 FATAL_ERROR) project(openmc_sources CXX) add_library(parameterized_source SHARED parameterized_source_ring.cpp) find_package(OpenMC REQUIRED) diff --git a/openmc/data/multipole.py b/openmc/data/multipole.py index d45d2beb6f..5c9df39c29 100644 --- a/openmc/data/multipole.py +++ b/openmc/data/multipole.py @@ -1169,10 +1169,11 @@ class WindowedMultipole(EqualityMixin): sqrtE = sqrt(E) invE = 1.0 / E - # Locate us. The i_window calc omits a + 1 present in F90 because of - # the 1-based vs. 0-based indexing. Similarly startw needs to be - # decreased by 1. endw does not need to be decreased because - # range(startw, endw) does not include endw. + # Locate us. The i_window calc omits a + 1 present from the legacy + # Fortran version of OpenMC because of the 1-based vs. 0-based + # indexing. Similarly startw needs to be decreased by 1. endw does + # not need to be decreased because range(startw, endw) does not include + # endw. i_window = min(self.n_windows - 1, int(np.floor((sqrtE - sqrt(self.E_min)) / self.spacing))) startw = self.windows[i_window, 0] - 1 diff --git a/openmc/mgxs/mdgxs.py b/openmc/mgxs/mdgxs.py index 58f6a2d2f7..45c559a220 100644 --- a/openmc/mgxs/mdgxs.py +++ b/openmc/mgxs/mdgxs.py @@ -20,7 +20,7 @@ MDGXS_TYPES = ( 'delayed-nu-fission matrix' ) -# Maximum number of delayed groups, from src/constants.F90 +# Maximum number of delayed groups, from include/openmc/constants.h MAX_DELAYED_GROUPS = 8 diff --git a/src/nuclide.cpp b/src/nuclide.cpp index 14d21bb090..91adc07779 100644 --- a/src/nuclide.cpp +++ b/src/nuclide.cpp @@ -634,16 +634,23 @@ void Nuclide::calculate_xs( } } - // Ensure these values are set - // Note, the only time either is used is in one of 4 places: - // 1. physics.cpp - scatter - For inelastic scatter. - // 2. physics.cpp - sample_fission - For partial fissions. - // 3. tally.F90 - score_general - For tallying on MTxxx reactions. - // 4. nuclide.cpp - calculate_urr_xs - For unresolved purposes. - // It is worth noting that none of these occur in the resolved - // resonance range, so the value here does not matter. index_temp is - // set to -1 to force a segfault in case a developer messes up and tries - // to use it with multipole. + /* + * index_temp, index_grid, and interp_factor are used only in the + * following places: + * 1. physics.cpp - scatter - For inelastic scatter. + * 2. physics.cpp - sample_fission - For partial fissions. + * 3. tallies/tally_scoring.cpp - score_general - + * For tallying on MTxxx reactions. + * 4. nuclide.cpp - calculate_urr_xs - For unresolved purposes. + * It is worth noting that none of these occur in the resolved resonance + * range, so the value here does not matter. index_temp is set to -1 to + * force a segfault in case a developer messes up and tries to use it with + * multipole. + * + * However, a segfault is not necessarily guaranteed with an out-of-bounds + * access, so this technique should be replaced by something more robust + * in the future. + */ micro.index_temp = -1; micro.index_grid = -1; micro.interp_factor = 0.0; diff --git a/src/tallies/filter_energy.cpp b/src/tallies/filter_energy.cpp index 825dd2ee57..4767dd175f 100644 --- a/src/tallies/filter_energy.cpp +++ b/src/tallies/filter_energy.cpp @@ -3,7 +3,7 @@ #include #include "openmc/capi.h" -#include "openmc/constants.h" // For F90_NONE +#include "openmc/constants.h" // For C_NONE #include "openmc/mgxs_interface.h" #include "openmc/search.h" #include "openmc/settings.h" diff --git a/tests/regression_tests/cpp_driver/test.py b/tests/regression_tests/cpp_driver/test.py index 79c30967cc..b80e82ee0e 100644 --- a/tests/regression_tests/cpp_driver/test.py +++ b/tests/regression_tests/cpp_driver/test.py @@ -20,7 +20,7 @@ def cpp_driver(request): openmc_dir = Path(str(request.config.rootdir)) / 'build' with open('CMakeLists.txt', 'w') as f: f.write(textwrap.dedent(""" - cmake_minimum_required(VERSION 3.3 FATAL_ERROR) + cmake_minimum_required(VERSION 3.10 FATAL_ERROR) project(openmc_cpp_driver CXX) add_executable(cpp_driver driver.cpp) find_package(OpenMC REQUIRED HINTS {}) diff --git a/tests/regression_tests/dagmc/external/test.py b/tests/regression_tests/dagmc/external/test.py index 7993772e8b..57bc9ea7fd 100644 --- a/tests/regression_tests/dagmc/external/test.py +++ b/tests/regression_tests/dagmc/external/test.py @@ -25,7 +25,7 @@ def cpp_driver(request): openmc_dir = Path(str(request.config.rootdir)) / 'build' with open('CMakeLists.txt', 'w') as f: f.write(textwrap.dedent(""" - cmake_minimum_required(VERSION 3.3 FATAL_ERROR) + cmake_minimum_required(VERSION 3.10 FATAL_ERROR) project(openmc_cpp_driver CXX) add_executable(main main.cpp) find_package(OpenMC REQUIRED HINTS {}) diff --git a/tests/regression_tests/external_moab/test.py b/tests/regression_tests/external_moab/test.py index 90bff69ed0..ce4e78a2c2 100644 --- a/tests/regression_tests/external_moab/test.py +++ b/tests/regression_tests/external_moab/test.py @@ -32,7 +32,7 @@ def cpp_driver(request): openmc_dir = Path(str(request.config.rootdir)) / 'build' with open('CMakeLists.txt', 'w') as f: f.write(textwrap.dedent(""" - cmake_minimum_required(VERSION 3.3 FATAL_ERROR) + cmake_minimum_required(VERSION 3.10 FATAL_ERROR) project(openmc_cpp_driver CXX) add_executable(main main.cpp) find_package(OpenMC REQUIRED HINTS {}) diff --git a/tests/regression_tests/source_dlopen/test.py b/tests/regression_tests/source_dlopen/test.py index efe942933d..88ff9dd850 100644 --- a/tests/regression_tests/source_dlopen/test.py +++ b/tests/regression_tests/source_dlopen/test.py @@ -18,7 +18,7 @@ def compile_source(request): openmc_dir = Path(str(request.config.rootdir)) / 'build' with open('CMakeLists.txt', 'w') as f: f.write(textwrap.dedent(""" - cmake_minimum_required(VERSION 3.3 FATAL_ERROR) + cmake_minimum_required(VERSION 3.10 FATAL_ERROR) project(openmc_sources CXX) add_library(source SHARED source_sampling.cpp) find_package(OpenMC REQUIRED HINTS {}) diff --git a/tests/regression_tests/source_parameterized_dlopen/test.py b/tests/regression_tests/source_parameterized_dlopen/test.py index c7c5d06b1f..1cc253528c 100644 --- a/tests/regression_tests/source_parameterized_dlopen/test.py +++ b/tests/regression_tests/source_parameterized_dlopen/test.py @@ -18,7 +18,7 @@ def compile_source(request): openmc_dir = Path(str(request.config.rootdir)) / 'build' with open('CMakeLists.txt', 'w') as f: f.write(textwrap.dedent(""" - cmake_minimum_required(VERSION 3.3 FATAL_ERROR) + cmake_minimum_required(VERSION 3.10 FATAL_ERROR) project(openmc_sources CXX) add_library(source SHARED parameterized_source_sampling.cpp) find_package(OpenMC REQUIRED HINTS {}) From cddb3be13944bad6a5e6762e9f2af3f689af14ad Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Sat, 20 Apr 2024 10:45:16 -0500 Subject: [PATCH 25/48] Add C to list of cmake project languages (#2969) --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 49e987f221..9b08914ac6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.10 FATAL_ERROR) -project(openmc CXX) +project(openmc C CXX) # Set version numbers set(OPENMC_VERSION_MAJOR 0) From b54b1e975c6696e14f274fd2fa5950d54d883882 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Wed, 24 Apr 2024 08:02:05 -0500 Subject: [PATCH 26/48] Update bounding_box docstrings (#2972) --- openmc/cell.py | 3 +-- openmc/region.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/openmc/cell.py b/openmc/cell.py index 6de8eadec8..94fac84135 100644 --- a/openmc/cell.py +++ b/openmc/cell.py @@ -343,8 +343,7 @@ class Cell(IDManagerMixin): if self.region is not None: return self.region.bounding_box else: - return BoundingBox(np.array([-np.inf, -np.inf, -np.inf]), - np.array([np.inf, np.inf, np.inf])) + return BoundingBox.infinite() @property def num_instances(self): diff --git a/openmc/region.py b/openmc/region.py index d3c03b8983..f679129c1f 100644 --- a/openmc/region.py +++ b/openmc/region.py @@ -18,6 +18,11 @@ class Region(ABC): respective classes are typically not instantiated directly but rather are created through operators of the Surface and Region classes. + Attributes + ---------- + bounding_box : openmc.BoundingBox + Axis-aligned bounding box of the region + """ def __and__(self, other): return Intersection((self, other)) @@ -415,7 +420,7 @@ class Intersection(Region, MutableSequence): Attributes ---------- bounding_box : openmc.BoundingBox - Lower-left and upper-right coordinates of an axis-aligned bounding box + Axis-aligned bounding box of the region """ @@ -503,7 +508,7 @@ class Union(Region, MutableSequence): Attributes ---------- bounding_box : openmc.BoundingBox - Lower-left and upper-right coordinates of an axis-aligned bounding box + Axis-aligned bounding box of the region """ @@ -594,7 +599,7 @@ class Complement(Region): node : openmc.Region Regions to take the complement of bounding_box : openmc.BoundingBox - Lower-left and upper-right coordinates of an axis-aligned bounding box + Axis-aligned bounding box of the region """ From 6b08f75065840ab7e3ab14347c7f447451b8dc60 Mon Sep 17 00:00:00 2001 From: Luke Labrie-Cleary Date: Wed, 24 Apr 2024 12:05:11 -0400 Subject: [PATCH 27/48] make uwuw optional (#2965) --- CMakeLists.txt | 17 ++++-- cmake/OpenMCConfig.cmake.in | 4 ++ include/openmc/dagmc.h | 9 +++- openmc/lib/__init__.py | 3 ++ src/dagmc.cpp | 66 ++++++++++++++++------- src/output.cpp | 5 ++ tests/regression_tests/dagmc/refl/test.py | 4 +- tests/regression_tests/dagmc/uwuw/test.py | 4 +- 8 files changed, 86 insertions(+), 26 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9b08914ac6..1841251ff5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,6 +39,7 @@ option(OPENMC_USE_LIBMESH "Enable support for libMesh unstructured mesh tall option(OPENMC_USE_MPI "Enable MPI" OFF) option(OPENMC_USE_MCPL "Enable MCPL" OFF) option(OPENMC_USE_NCRYSTAL "Enable support for NCrystal scattering" OFF) +option(OPENMC_USE_UWUW "Enable UWUW" OFF) # Warnings for deprecated options foreach(OLD_OPT IN ITEMS "openmp" "profile" "coverage" "dagmc" "libmesh") @@ -125,10 +126,15 @@ endif() if(OPENMC_USE_DAGMC) find_package(DAGMC REQUIRED PATH_SUFFIXES lib/cmake) if (${DAGMC_VERSION} VERSION_LESS 3.2.0) - message(FATAL_ERROR "Discovered DAGMC Version: ${DAGMC_VERSION}. \ - Please update DAGMC to version 3.2.0 or greater.") + message(FATAL_ERROR "Discovered DAGMC Version: ${DAGMC_VERSION}." + "Please update DAGMC to version 3.2.0 or greater.") endif() message(STATUS "Found DAGMC: ${DAGMC_DIR} (version ${DAGMC_VERSION})") + + # Check if UWUW is needed and available + if(OPENMC_USE_UWUW AND NOT DAGMC_BUILD_UWUW) + message(FATAL_ERROR "UWUW is enabled but DAGMC was not configured with UWUW.") + endif() endif() #=============================================================================== @@ -510,7 +516,7 @@ endif() if(OPENMC_USE_DAGMC) target_compile_definitions(libopenmc PRIVATE DAGMC) - target_link_libraries(libopenmc dagmc-shared uwuw-shared) + target_link_libraries(libopenmc dagmc-shared) endif() if(OPENMC_USE_LIBMESH) @@ -547,6 +553,11 @@ if(OPENMC_USE_NCRYSTAL) target_link_libraries(libopenmc NCrystal::NCrystal) endif() +if (OPENMC_USE_UWUW) + target_compile_definitions(libopenmc PRIVATE UWUW) + target_link_libraries(libopenmc uwuw-shared) +endif() + #=============================================================================== # Log build info that this executable can report later #=============================================================================== diff --git a/cmake/OpenMCConfig.cmake.in b/cmake/OpenMCConfig.cmake.in index 1305ad3edf..44a5e0d5a3 100644 --- a/cmake/OpenMCConfig.cmake.in +++ b/cmake/OpenMCConfig.cmake.in @@ -38,3 +38,7 @@ endif() if(@OPENMC_USE_MCPL@) find_package(MCPL REQUIRED) endif() + +if(@OPENMC_USE_UWUW@) + find_package(UWUW REQUIRED) +endif() diff --git a/include/openmc/dagmc.h b/include/openmc/dagmc.h index 0b23e567ab..2facf4fc05 100644 --- a/include/openmc/dagmc.h +++ b/include/openmc/dagmc.h @@ -3,7 +3,8 @@ namespace openmc { extern "C" const bool DAGMC_ENABLED; -} +extern "C" const bool UWUW_ENABLED; +} // namespace openmc // always include the XML interface header #include "openmc/xml_interface.h" @@ -120,6 +121,12 @@ public: void write_uwuw_materials_xml( const std::string& outfile = "uwuw_materials.xml") const; + //! Assign a material to a cell from uwuw material library + //! \param[in] vol_handle The DAGMC material assignment string + //! \param[in] c The OpenMC cell to which the material is assigned + void uwuw_assign_material( + moab::EntityHandle vol_handle, std::unique_ptr& c) const; + //! Assign a material to a cell based //! \param[in] mat_string The DAGMC material assignment string //! \param[in] c The OpenMC cell to which the material is assigned diff --git a/openmc/lib/__init__.py b/openmc/lib/__init__.py index 0e5ad92feb..dc5bd7d9b4 100644 --- a/openmc/lib/__init__.py +++ b/openmc/lib/__init__.py @@ -54,6 +54,9 @@ def _libmesh_enabled(): def _mcpl_enabled(): return c_bool.in_dll(_dll, "MCPL_ENABLED").value +def _uwuw_enabled(): + return c_bool.in_dll(_dll, "UWUW_ENABLED").value + from .error import * from .core import * diff --git a/src/dagmc.cpp b/src/dagmc.cpp index 6a4865c391..2f2502f6ea 100644 --- a/src/dagmc.cpp +++ b/src/dagmc.cpp @@ -11,7 +11,7 @@ #include "openmc/settings.h" #include "openmc/string_utils.h" -#ifdef DAGMC +#ifdef UWUW #include "uwuw.hpp" #endif #include @@ -29,6 +29,12 @@ const bool DAGMC_ENABLED = true; const bool DAGMC_ENABLED = false; #endif +#ifdef UWUW +const bool UWUW_ENABLED = true; +#else +const bool UWUW_ENABLED = false; +#endif + } // namespace openmc #ifdef DAGMC @@ -85,7 +91,6 @@ DAGUniverse::DAGUniverse(std::shared_ptr dagmc_ptr, { set_id(); init_metadata(); - read_uwuw_materials(); init_geometry(); } @@ -111,8 +116,6 @@ void DAGUniverse::initialize() init_metadata(); - read_uwuw_materials(); - init_geometry(); } @@ -209,20 +212,7 @@ void DAGUniverse::init_geometry() c->material_.push_back(MATERIAL_VOID); } else { if (uses_uwuw()) { - // lookup material in uwuw if present - std::string uwuw_mat = - dmd_ptr->volume_material_property_data_eh[vol_handle]; - if (uwuw_->material_library.count(uwuw_mat) != 0) { - // Note: material numbers are set by UWUW - int mat_number = uwuw_->material_library.get_material(uwuw_mat) - .metadata["mat_number"] - .asInt(); - c->material_.push_back(mat_number); - } else { - fatal_error(fmt::format("Material with value '{}' not found in the " - "UWUW material library", - mat_str)); - } + uwuw_assign_material(vol_handle, c); } else { legacy_assign_material(mat_str, c); } @@ -439,11 +429,16 @@ void DAGUniverse::to_hdf5(hid_t universes_group) const bool DAGUniverse::uses_uwuw() const { +#ifdef UWUW return uwuw_ && !uwuw_->material_library.empty(); +#else + return false; +#endif // UWUW } std::string DAGUniverse::get_uwuw_materials_xml() const { +#ifdef UWUW if (!uses_uwuw()) { throw std::runtime_error("This DAGMC Universe does not use UWUW materials"); } @@ -461,10 +456,14 @@ std::string DAGUniverse::get_uwuw_materials_xml() const ss << ""; return ss.str(); +#else + fatal_error("DAGMC was not configured with UWUW."); +#endif // UWUW } void DAGUniverse::write_uwuw_materials_xml(const std::string& outfile) const { +#ifdef UWUW if (!uses_uwuw()) { throw std::runtime_error( "This DAGMC universe does not use UWUW materials."); @@ -475,6 +474,9 @@ void DAGUniverse::write_uwuw_materials_xml(const std::string& outfile) const std::ofstream mats_xml(outfile); mats_xml << xml_str; mats_xml.close(); +#else + fatal_error("DAGMC was not configured with UWUW."); +#endif } void DAGUniverse::legacy_assign_material( @@ -536,6 +538,7 @@ void DAGUniverse::legacy_assign_material( void DAGUniverse::read_uwuw_materials() { +#ifdef UWUW // If no filename was provided, don't read UWUW materials if (filename_ == "") return; @@ -573,8 +576,35 @@ void DAGUniverse::read_uwuw_materials() for (pugi::xml_node material_node : root.children("material")) { model::materials.push_back(std::make_unique(material_node)); } +#else + fatal_error("DAGMC was not configured with UWUW."); +#endif } +void DAGUniverse::uwuw_assign_material( + moab::EntityHandle vol_handle, std::unique_ptr& c) const +{ +#ifdef UWUW + // read materials from uwuw material file + read_uwuw_materials(); + + // lookup material in uwuw if present + std::string uwuw_mat = dmd_ptr->volume_material_property_data_eh[vol_handle]; + if (uwuw_->material_library.count(uwuw_mat) != 0) { + // Note: material numbers are set by UWUW + int mat_number = uwuw_->material_library.get_material(uwuw_mat) + .metadata["mat_number"] + .asInt(); + c->material_.push_back(mat_number); + } else { + fatal_error(fmt::format("Material with value '{}' not found in the " + "UWUW material library", + mat_str)); + } +#else + fatal_error("DAGMC was not configured with UWUW."); +#endif +} //============================================================================== // DAGMC Cell implementation //============================================================================== diff --git a/src/output.cpp b/src/output.cpp index b1b963f7d3..5fdbea1304 100644 --- a/src/output.cpp +++ b/src/output.cpp @@ -318,6 +318,7 @@ void print_build_info() std::string coverage(n); std::string mcpl(n); std::string ncrystal(n); + std::string uwuw(n); #ifdef PHDF5 phdf5 = y; @@ -346,6 +347,9 @@ void print_build_info() #ifdef COVERAGEBUILD coverage = y; #endif +#ifdef UWUW + uwuw = y; +#endif // Wraps macro variables in quotes #define STRINGIFY(x) STRINGIFY2(x) @@ -364,6 +368,7 @@ void print_build_info() fmt::print("NCrystal support: {}\n", ncrystal); fmt::print("Coverage testing: {}\n", coverage); fmt::print("Profiling flags: {}\n", profiling); + fmt::print("UWUW support: {}\n", uwuw); } } diff --git a/tests/regression_tests/dagmc/refl/test.py b/tests/regression_tests/dagmc/refl/test.py index 03c1c407bb..a13acc0256 100644 --- a/tests/regression_tests/dagmc/refl/test.py +++ b/tests/regression_tests/dagmc/refl/test.py @@ -6,8 +6,8 @@ import pytest from tests.testing_harness import PyAPITestHarness pytestmark = pytest.mark.skipif( - not openmc.lib._dagmc_enabled(), - reason="DAGMC CAD geometry is not enabled.") + not openmc.lib._uwuw_enabled(), + reason="UWUW is not enabled.") class UWUWTest(PyAPITestHarness): def __init__(self, *args, **kwargs): diff --git a/tests/regression_tests/dagmc/uwuw/test.py b/tests/regression_tests/dagmc/uwuw/test.py index 38c335a5ed..bea464cfab 100644 --- a/tests/regression_tests/dagmc/uwuw/test.py +++ b/tests/regression_tests/dagmc/uwuw/test.py @@ -6,8 +6,8 @@ import pytest from tests.testing_harness import PyAPITestHarness pytestmark = pytest.mark.skipif( - not openmc.lib._dagmc_enabled(), - reason="DAGMC CAD geometry is not enabled.") + not openmc.lib._uwuw_enabled(), + reason="UWUW is not enabled.") class UWUWTest(PyAPITestHarness): def __init__(self, *args, **kwargs): From 1d56adbc3deb83145cab5539723f3c92bd30ce17 Mon Sep 17 00:00:00 2001 From: Jonathan Shimwell Date: Wed, 24 Apr 2024 19:07:36 +0100 Subject: [PATCH 28/48] added damage-energy as optional reaction for micro (#2903) --- openmc/deplete/microxs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openmc/deplete/microxs.py b/openmc/deplete/microxs.py index c1c4cb7acf..d80c52baa9 100644 --- a/openmc/deplete/microxs.py +++ b/openmc/deplete/microxs.py @@ -24,6 +24,7 @@ import openmc.lib _valid_rxns = list(REACTIONS) _valid_rxns.append('fission') +_valid_rxns.append('damage-energy') def _resolve_chain_file_path(chain_file: str): From 3370ce1978ebea3dc70de24ffbbe47d41383c282 Mon Sep 17 00:00:00 2001 From: Catherine Yu <76599141+cxtherineyu@users.noreply.github.com> Date: Wed, 24 Apr 2024 17:05:10 -0400 Subject: [PATCH 29/48] Print warning if no natural isotopes when using add_element and wrote unit test (#2938) Co-authored-by: Catherine Yu Co-authored-by: Paul Romano --- openmc/element.py | 5 +++++ tests/unit_tests/test_element.py | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/openmc/element.py b/openmc/element.py index f9cf102f7c..082bee5226 100644 --- a/openmc/element.py +++ b/openmc/element.py @@ -1,4 +1,5 @@ import re +import warnings import lxml.etree as ET @@ -123,6 +124,10 @@ class Element(str): # Get the nuclides present in nature natural_nuclides = {name for name, abundance in natural_isotopes(self)} + # Issue warning if no existing nuclides + if len(natural_nuclides) == 0: + warnings.warn(f"No naturally occurring isotopes found for {self}.") + # Create dict to store the expanded nuclides and abundances abundances = {} diff --git a/tests/unit_tests/test_element.py b/tests/unit_tests/test_element.py index bacb988b9a..d3555701e2 100644 --- a/tests/unit_tests/test_element.py +++ b/tests/unit_tests/test_element.py @@ -1,5 +1,5 @@ import openmc -from pytest import approx, raises +from pytest import approx, raises, warns from openmc.data import NATURAL_ABUNDANCE, atomic_mass @@ -37,6 +37,13 @@ def test_expand_enrichment(): assert isotope[1] == approx(ref[isotope[0]]) +def test_expand_no_isotopes(): + """Test that correct warning is raised for elements with no isotopes""" + with warns(UserWarning, match='No naturally occurring'): + element = openmc.Element('Tc') + element.expand(100.0, 'ao') + + def test_expand_exceptions(): """ Test that correct exceptions are raised for invalid input """ From f543c007a3662226c65a7dd71a6c5e93937c4468 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Wed, 24 Apr 2024 17:09:56 -0500 Subject: [PATCH 30/48] Statepoint file loading refactor and CAPI function (#2886) Co-authored-by: Paul Romano --- docs/source/pythonapi/capi.rst | 1 + docs/source/usersguide/settings.rst | 34 +++++++++++++++ include/openmc/capi.h | 1 + openmc/lib/core.py | 18 ++++++++ src/simulation.cpp | 8 +++- src/state_point.cpp | 42 ++++++++++++------- .../statepoint_restart/test.py | 24 +++++++---- 7 files changed, 104 insertions(+), 24 deletions(-) diff --git a/docs/source/pythonapi/capi.rst b/docs/source/pythonapi/capi.rst index 3135431996..995ad97fa7 100644 --- a/docs/source/pythonapi/capi.rst +++ b/docs/source/pythonapi/capi.rst @@ -43,6 +43,7 @@ Functions simulation_finalize simulation_init source_bank + statepoint_load statepoint_write Classes diff --git a/docs/source/usersguide/settings.rst b/docs/source/usersguide/settings.rst index 81fa78991a..e966423f0e 100644 --- a/docs/source/usersguide/settings.rst +++ b/docs/source/usersguide/settings.rst @@ -628,3 +628,37 @@ instance, whereas the :meth:`openmc.Track.filter` method returns a new .. code-block:: sh openmc-track-combine tracks_p*.h5 --out tracks.h5 + +----------------------- +Restarting a Simulation +----------------------- + +OpenMC can be run in a mode where it reads in a statepoint file and continues a +simulation from the ending point of the statepoint file. A restart simulation +can be performed by passing the path to the statepoint file to the OpenMC +executable: + +.. code-block:: sh + + openmc -r statepoint.100.h5 + +From the Python API, the `restart_file` argument provides the same behavior: + +.. code-block:: python + + openmc.run(restart_file='statepoint.100.h5') + +or if using the :class:`~openmc.Model` class: + +.. code-block:: python + + model.run(restart_file='statepoint.100.h5') + +The restart simulation will execute until the number of batches specified in the +:class:`~openmc.Settings` object on a model (or in the :ref:`settings XML file +`) is satisfied. Note that if the number of batches in the +statepoint file is the same as that specified in the settings object (i.e., if +the inputs were not modified before the restart run), no particles will be +transported and OpenMC will exit immediately. + +.. note:: A statepoint file must match the input model to be successfully used in a restart simulation. diff --git a/include/openmc/capi.h b/include/openmc/capi.h index 5f98152cd6..9401156a64 100644 --- a/include/openmc/capi.h +++ b/include/openmc/capi.h @@ -150,6 +150,7 @@ int openmc_sphharm_filter_get_cosine(int32_t index, char cosine[]); int openmc_sphharm_filter_set_order(int32_t index, int order); int openmc_sphharm_filter_set_cosine(int32_t index, const char cosine[]); int openmc_statepoint_write(const char* filename, bool* write_source); +int openmc_statepoint_load(const char* filename); int openmc_tally_allocate(int32_t index, const char* type); int openmc_tally_get_active(int32_t index, bool* active); int openmc_tally_get_estimator(int32_t index, int* estimator); diff --git a/openmc/lib/core.py b/openmc/lib/core.py index 4ea1c86d0b..d8e0bfdb5f 100644 --- a/openmc/lib/core.py +++ b/openmc/lib/core.py @@ -95,6 +95,11 @@ _dll.openmc_simulation_finalize.errcheck = _error_handler _dll.openmc_statepoint_write.argtypes = [c_char_p, POINTER(c_bool)] _dll.openmc_statepoint_write.restype = c_int _dll.openmc_statepoint_write.errcheck = _error_handler +_dll.openmc_statepoint_load.argtypes = [c_char_p] +_dll.openmc_statepoint_load.restype = c_int +_dll.openmc_statepoint_load.errcheck = _error_handler +_dll.openmc_statepoint_write.restype = c_int +_dll.openmc_statepoint_write.errcheck = _error_handler _dll.openmc_global_bounding_box.argtypes = [POINTER(c_double), POINTER(c_double)] _dll.openmc_global_bounding_box.restype = c_int @@ -568,6 +573,19 @@ def statepoint_write(filename=None, write_source=True): _dll.openmc_statepoint_write(filename, c_bool(write_source)) +def statepoint_load(filename: PathLike): + """Load a statepoint file. + + Parameters + ---------- + filename : path-like + Path to the statepoint to load. + + """ + filename = c_char_p(str(filename).encode()) + _dll.openmc_statepoint_load(filename) + + @contextmanager def run_in_memory(**kwargs): """Provides context manager for calling OpenMC shared library functions. diff --git a/src/simulation.cpp b/src/simulation.cpp index 1cf34a820d..43d9206066 100644 --- a/src/simulation.cpp +++ b/src/simulation.cpp @@ -54,8 +54,14 @@ int openmc_run() openmc::simulation::time_total.start(); openmc_simulation_init(); - int err = 0; + // Ensure that a batch isn't executed in the case that the maximum number of + // batches has already been run in a restart statepoint file int status = 0; + if (openmc::simulation::current_batch >= openmc::settings::n_max_batches) { + status = openmc::STATUS_EXIT_MAX_BATCH; + } + + int err = 0; while (status == 0 && err == 0) { err = openmc_next_batch(&status); } diff --git a/src/state_point.cpp b/src/state_point.cpp index bff6213206..c7b7d6ad85 100644 --- a/src/state_point.cpp +++ b/src/state_point.cpp @@ -364,11 +364,27 @@ void restart_set_keff() void load_state_point() { - // Write message - write_message("Loading state point " + settings::path_statepoint + "...", 5); + write_message( + fmt::format("Loading state point {}...", settings::path_statepoint_c), 5); + openmc_statepoint_load(settings::path_statepoint.c_str()); +} +void statepoint_version_check(hid_t file_id) +{ + // Read revision number for state point file and make sure it matches with + // current version + array version_array; + read_attribute(file_id, "version", version_array); + if (version_array != VERSION_STATEPOINT) { + fatal_error( + "State point version does not match current version in OpenMC."); + } +} + +extern "C" int openmc_statepoint_load(const char* filename) +{ // Open file for reading - hid_t file_id = file_open(settings::path_statepoint.c_str(), 'r', true); + hid_t file_id = file_open(filename, 'r', true); // Read filetype std::string word; @@ -377,14 +393,7 @@ void load_state_point() fatal_error("OpenMC tried to restart from a non-statepoint file."); } - // Read revision number for state point file and make sure it matches with - // current version - array array; - read_attribute(file_id, "version", array); - if (array != VERSION_STATEPOINT) { - fatal_error( - "State point version does not match current version in OpenMC."); - } + statepoint_version_check(file_id); // Read and overwrite random number seed int64_t seed; @@ -421,9 +430,10 @@ void load_state_point() read_dataset(file_id, "current_batch", simulation::restart_batch); if (simulation::restart_batch >= settings::n_max_batches) { - fatal_error(fmt::format( - "The number of batches specified for simulation ({}) is smaller" - " than the number of batches in the restart statepoint file ({})", + warning(fmt::format( + "The number of batches specified for simulation ({}) is smaller " + "than or equal to the number of batches in the restart statepoint file " + "({})", settings::n_max_batches, simulation::restart_batch)); } @@ -489,7 +499,6 @@ void load_state_point() if (internal) { tally->writable_ = false; } else { - auto& results = tally->results_; read_tally_results(tally_group, results.shape()[0], results.shape()[1], results.data()); @@ -497,7 +506,6 @@ void load_state_point() close_group(tally_group); } } - close_group(tallies_group); } } @@ -525,6 +533,8 @@ void load_state_point() // Close file file_close(file_id); + + return 0; } hid_t h5banktype() diff --git a/tests/regression_tests/statepoint_restart/test.py b/tests/regression_tests/statepoint_restart/test.py index 1e98bc480b..82e514da87 100644 --- a/tests/regression_tests/statepoint_restart/test.py +++ b/tests/regression_tests/statepoint_restart/test.py @@ -1,7 +1,6 @@ from pathlib import Path import openmc -import pytest from tests.testing_harness import TestHarness from tests.regression_tests import config @@ -61,24 +60,35 @@ def test_statepoint_restart(): harness.main() -def test_batch_check(request): +def test_batch_check(request, capsys): xmls = list(request.path.parent.glob('*.xml')) with cdtemp(xmls): model = openmc.Model.from_xml() model.settings.particles = 100 + # run the model - sp_file = model.run() + sp_file = model.run(export_model_xml=False) + assert sp_file is not None # run a restart with the resulting statepoint # and the settings unchanged - with pytest.raises(RuntimeError, match='is smaller than the number of batches'): - model.run(restart_file=sp_file) + model.settings.batches = 6 + # ensure we capture output only from the next run + capsys.readouterr() + sp_file = model.run(export_model_xml=False, restart_file=sp_file) + # indicates that a new statepoint file was not created + assert sp_file is None - # update the number of batches and run again + output = capsys.readouterr().out + assert "WARNING" in output + assert "The number of batches specified for simulation" in output + + # update the number of batches and run again, + # this restart run should be successful model.settings.batches = 15 model.settings.statepoint = {} - sp_file = model.run(restart_file=sp_file) + sp_file = model.run(export_model_xml=False, restart_file=sp_file) sp = openmc.StatePoint(sp_file) assert sp.n_batches == 15 From 7936b8a59c578e4a2ea221f1da5dbea2ef7f95ee Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Wed, 24 Apr 2024 17:14:17 -0500 Subject: [PATCH 31/48] Support track file writing for particle restart runs. (#2957) Co-authored-by: Paul Romano --- src/particle_restart.cpp | 8 +++++++- tests/unit_tests/test_tracks.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/particle_restart.cpp b/src/particle_restart.cpp index 32d187dd82..6b7778211a 100644 --- a/src/particle_restart.cpp +++ b/src/particle_restart.cpp @@ -87,8 +87,10 @@ void run_particle_restart() read_particle_restart(p, previous_run_mode); // write track if that was requested on command line - if (settings::write_all_tracks) + if (settings::write_all_tracks) { + open_track_file(); p.write_track() = true; + } // Set all tallies to 0 for now (just tracking errors) model::tallies.clear(); @@ -123,6 +125,10 @@ void run_particle_restart() // Write output if particle made it print_particle(p); + + if (settings::write_all_tracks) { + close_track_file(); + } } } // namespace openmc diff --git a/tests/unit_tests/test_tracks.py b/tests/unit_tests/test_tracks.py index 3a01701556..3951a72c63 100644 --- a/tests/unit_tests/test_tracks.py +++ b/tests/unit_tests/test_tracks.py @@ -1,5 +1,6 @@ from pathlib import Path +import h5py import numpy as np import openmc import pytest @@ -157,3 +158,35 @@ def test_write_to_vtk(sphere_model): assert isinstance(polydata, vtk.vtkPolyData) assert Path('tracks.vtp').is_file() + + +def test_restart_track(run_in_tmpdir, sphere_model): + # cut the sphere model in half with an improper boundary condition + plane = openmc.XPlane(x0=-1.0) + for cell in sphere_model.geometry.get_all_cells().values(): + cell.region &= +plane + + # generate lost particle files + with pytest.raises(RuntimeError, match='Maximum number of lost particles has been reached.'): + sphere_model.run(output=False) + + lost_particle_files = list(Path.cwd().glob('particle_*.h5')) + assert len(lost_particle_files) > 0 + particle_file = lost_particle_files[0] + # restart the lost particle with tracks enabled + sphere_model.run(tracks=True, restart_file=particle_file) + tracks_file = Path('tracks.h5') + assert tracks_file.is_file() + + # check that the last track of the file matches the lost particle file + tracks = openmc.Tracks(tracks_file) + initial_state = tracks[0].particle_tracks[0].states[0] + restart_r = np.array(initial_state['r']) + restart_u = np.array(initial_state['u']) + + with h5py.File(particle_file, 'r') as lost_particle_file: + lost_r = np.array(lost_particle_file['xyz'][()]) + lost_u = np.array(lost_particle_file['uvw'][()]) + + pytest.approx(restart_r, lost_r) + pytest.approx(restart_u, lost_u) From ff50afb19ffe9cffb5707118a7796b1ba534f280 Mon Sep 17 00:00:00 2001 From: Gavin Ridley Date: Wed, 24 Apr 2024 17:14:37 -0500 Subject: [PATCH 32/48] Allow pure decay IndependentOperator (#2966) Co-authored-by: Paul Romano --- openmc/deplete/abc.py | 2 +- openmc/deplete/independent_operator.py | 9 +++-- ...ecay_products.py => test_deplete_decay.py} | 36 +++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) rename tests/unit_tests/{test_deplete_decay_products.py => test_deplete_decay.py} (55%) diff --git a/openmc/deplete/abc.py b/openmc/deplete/abc.py index a574d48550..fa07ad12a6 100644 --- a/openmc/deplete/abc.py +++ b/openmc/deplete/abc.py @@ -848,7 +848,7 @@ class Integrator(ABC): print(f"[openmc.deplete] t={t} (final operator evaluation)") res_list = [self.operator(n, source_rate if final_step else 0.0)] StepResult.save(self.operator, [n], res_list, [t, t], - source_rate, self._i_res + len(self), proc_time) + source_rate, self._i_res + len(self), proc_time, path) self.operator.write_bos_data(len(self) + self._i_res) self.operator.finalize() diff --git a/openmc/deplete/independent_operator.py b/openmc/deplete/independent_operator.py index 5469b28c7c..2328ca7612 100644 --- a/openmc/deplete/independent_operator.py +++ b/openmc/deplete/independent_operator.py @@ -241,7 +241,6 @@ class IndependentOperator(OpenMCOperator): reduce_chain_level=reduce_chain_level, fission_yield_opts=fission_yield_opts) - @staticmethod def _consolidate_nuclides_to_material(nuclides, nuc_units, volume): """Puts nuclide list into an openmc.Materials object. @@ -270,7 +269,6 @@ class IndependentOperator(OpenMCOperator): self.prev_res[-1].transfer_volumes(model) self.materials = model.materials - # Store previous results in operator # Distribute reaction rates according to those tracked # on this process @@ -282,7 +280,6 @@ class IndependentOperator(OpenMCOperator): new_res = res_obj.distribute(self.local_mats, mat_indexes) self.prev_res.append(new_res) - def _get_nuclides_with_data(self, cross_sections: List[MicroXS]) -> Set[str]: """Finds nuclides with cross section data""" return set(cross_sections[0].nuclides) @@ -412,6 +409,12 @@ class IndependentOperator(OpenMCOperator): self._update_materials_and_nuclides(vec) + # If the source rate is zero, return zero reaction rates + if source_rate == 0.0: + rates = self.reaction_rates.copy() + rates.fill(0.0) + return OperatorResult(ufloat(0.0, 0.0), rates) + rates = self._calculate_reaction_rates(source_rate) keff = self._keff diff --git a/tests/unit_tests/test_deplete_decay_products.py b/tests/unit_tests/test_deplete_decay.py similarity index 55% rename from tests/unit_tests/test_deplete_decay_products.py rename to tests/unit_tests/test_deplete_decay.py index 751a96ca6e..aca812560c 100644 --- a/tests/unit_tests/test_deplete_decay_products.py +++ b/tests/unit_tests/test_deplete_decay.py @@ -1,3 +1,5 @@ +from pathlib import Path + import openmc.deplete import numpy as np import pytest @@ -45,3 +47,37 @@ def test_deplete_decay_products(run_in_tmpdir): # H1 and He4 assert h1[1] == pytest.approx(1e24) assert he4[1] == pytest.approx(1e24) + + +def test_deplete_decay_step_fissionable(run_in_tmpdir): + """Ensures that power is not computed in zero power cases with + fissionable material present. This tests decay calculations without + power, although this specific example does not exhibit any decay. + + Proves github issue #2963 is fixed + """ + + # Set up a pure decay operator + micro_xs = openmc.deplete.MicroXS(np.empty((0, 0)), [], []) + mat = openmc.Material() + mat.name = 'I do not decay.' + mat.add_nuclide('U238', 1.0, 'ao') + mat.volume = 10.0 + mat.set_density('g/cc', 1.0) + original_atoms = mat.get_nuclide_atoms()['U238'] + + mats = openmc.Materials([mat]) + op = openmc.deplete.IndependentOperator( + mats, [1.0], [micro_xs], Path(__file__).parents[1] / "chain_simple.xml") + + # Create time integrator and integrate + integrator = openmc.deplete.PredictorIntegrator( + op, [1.0], power=[0.0], timestep_units='s' + ) + integrator.integrate() + + # Get concentration of U238. It should be unchanged since this chain has no U238 decay. + results = openmc.deplete.Results('depletion_results.h5') + _, u238 = results.get_atoms("1", "U238") + + assert u238[1] == pytest.approx(original_atoms) From 95b15f9522aa11b8d1eca6f8270c519ef85575ca Mon Sep 17 00:00:00 2001 From: Jonathan Shimwell Date: Thu, 25 Apr 2024 01:39:51 +0100 Subject: [PATCH 33/48] moved apt get to optional ci parts (#2970) Co-authored-by: Paul Romano --- .github/workflows/ci.yml | 14 ++++++++++++-- tools/ci/gha-install-vectfit.sh | 2 -- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5accc5e0f..7aaaf1da4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,16 +111,26 @@ jobs: run: | sudo apt -y update sudo apt install -y libpng-dev \ - libmpich-dev \ libnetcdf-dev \ libpnetcdf-dev \ libhdf5-serial-dev \ - libhdf5-mpich-dev \ libeigen3-dev + + - name: Optional apt dependencies for MPI + shell: bash + if: ${{ matrix.mpi == 'y' }} + run: | + sudo apt install -y libhdf5-mpich-dev \ + libmpich-dev sudo update-alternatives --set mpi /usr/bin/mpicc.mpich sudo update-alternatives --set mpirun /usr/bin/mpirun.mpich sudo update-alternatives --set mpi-x86_64-linux-gnu /usr/include/x86_64-linux-gnu/mpich + - name: Optional apt dependencies for vectfit + shell: bash + if: ${{ matrix.vectfit == 'y' }} + run: sudo apt install -y libblas-dev liblapack-dev + - name: install shell: bash run: | diff --git a/tools/ci/gha-install-vectfit.sh b/tools/ci/gha-install-vectfit.sh index 8444c3036b..bd38e1ea8c 100755 --- a/tools/ci/gha-install-vectfit.sh +++ b/tools/ci/gha-install-vectfit.sh @@ -16,8 +16,6 @@ XTENSOR_PYTHON_REPO='https://github.com/xtensor-stack/xtensor-python' XTENSOR_BLAS_BRANCH='0.17.1' XTENSOR_BLAS_REPO='https://github.com/xtensor-stack/xtensor-blas' -sudo apt-get install -y libblas-dev liblapack-dev - cd $HOME git clone -b $PYBIND_BRANCH $PYBIND_REPO cd pybind11 && mkdir build && cd build && cmake .. && sudo make install From d1d37a5b99c90ea8cf5cb150c33cfaa6c84910c1 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Fri, 26 Apr 2024 05:57:02 -0500 Subject: [PATCH 34/48] Allow MOAB k-d tree to be configured (#2976) --- docs/source/io_formats/statepoint.rst | 2 ++ docs/source/io_formats/tallies.rst | 4 +++ include/openmc/mesh.h | 10 +++--- openmc/filter.py | 8 ++--- openmc/mesh.py | 36 +++++++++++++++++-- src/mesh.cpp | 36 ++++++++++++++----- .../unstructured_mesh/inputs_true10.dat | 2 +- .../unstructured_mesh/inputs_true11.dat | 2 +- .../unstructured_mesh/inputs_true12.dat | 2 +- .../unstructured_mesh/inputs_true13.dat | 2 +- .../unstructured_mesh/inputs_true14.dat | 2 +- .../unstructured_mesh/inputs_true15.dat | 2 +- .../unstructured_mesh/inputs_true8.dat | 2 +- .../unstructured_mesh/inputs_true9.dat | 2 +- .../unstructured_mesh/test.py | 2 ++ 15 files changed, 85 insertions(+), 29 deletions(-) diff --git a/docs/source/io_formats/statepoint.rst b/docs/source/io_formats/statepoint.rst index edff686b1b..f61e967ca8 100644 --- a/docs/source/io_formats/statepoint.rst +++ b/docs/source/io_formats/statepoint.rst @@ -96,6 +96,8 @@ The current version of the statepoint file format is 18.1. - **library** (*char[]*) -- Mesh library used to represent the mesh ("moab" or "libmesh"). - **length_multiplier** (*double*) Scaling factor applied to the mesh. + - **options** (*char[]*) -- Special options that control spatial + search data structures used. - **volumes** (*double[]*) -- Volume of each mesh cell. - **vertices** (*double[]*) -- x, y, z values of the mesh vertices. - **connectivity** (*int[]*) -- Connectivity array for the mesh diff --git a/docs/source/io_formats/tallies.rst b/docs/source/io_formats/tallies.rst index b88877b838..c28db988b9 100644 --- a/docs/source/io_formats/tallies.rst +++ b/docs/source/io_formats/tallies.rst @@ -364,6 +364,10 @@ attributes/sub-elements: The mesh library used to represent an unstructured mesh. This can be either "moab" or "libmesh". (For unstructured mesh only.) + :options: + Special options that control spatial search data structures used. (For + unstructured mesh using MOAB only) + :filename: The name of the mesh file to be loaded at runtime. (For unstructured mesh only.) diff --git a/include/openmc/mesh.h b/include/openmc/mesh.h index 37b7718324..3917e6368a 100644 --- a/include/openmc/mesh.h +++ b/include/openmc/mesh.h @@ -659,11 +659,6 @@ protected: //! Set the length multiplier to apply to each point in the mesh void set_length_multiplier(const double length_multiplier); - // Data members - double length_multiplier_ { - 1.0}; //!< Constant multiplication factor to apply to mesh coordinates - bool specified_length_multiplier_ {false}; - //! Sample barycentric coordinates given a seed and the vertex positions and //! return the sampled position // @@ -672,6 +667,11 @@ protected: //! \return Sampled position within the tetrahedron Position sample_tet(std::array coords, uint64_t* seed) const; + // Data members + double length_multiplier_ { + -1.0}; //!< Multiplicative factor applied to mesh coordinates + std::string options_; //!< Options for search data structures + private: //! Setup method for the mesh. Builds data structures, //! sets up element mapping, creates bounding boxes, etc. diff --git a/openmc/filter.py b/openmc/filter.py index 66d832c694..e005140eeb 100644 --- a/openmc/filter.py +++ b/openmc/filter.py @@ -1,3 +1,4 @@ +from __future__ import annotations from abc import ABCMeta from collections.abc import Iterable import hashlib @@ -804,8 +805,8 @@ class MeshFilter(Filter): id : int Unique identifier for the filter translation : Iterable of float - This array specifies a vector that is used to translate (shift) - the mesh for this filter + This array specifies a vector that is used to translate (shift) the mesh + for this filter bins : list of tuple A list of mesh indices for each filter bin, e.g. [(1, 1, 1), (2, 1, 1), ...] @@ -846,7 +847,6 @@ class MeshFilter(Filter): mesh_obj = kwargs['meshes'][mesh_id] filter_id = int(group.name.split('/')[-1].lstrip('filter ')) - out = cls(mesh_obj, filter_id=filter_id) translation = group.get('translation') @@ -972,7 +972,7 @@ class MeshFilter(Filter): return element @classmethod - def from_xml_element(cls, elem, **kwargs): + def from_xml_element(cls, elem: ET.Element, **kwargs) -> MeshFilter: mesh_id = int(get_text(elem, 'bins')) mesh_obj = kwargs['meshes'][mesh_id] filter_id = int(elem.get('id')) diff --git a/openmc/mesh.py b/openmc/mesh.py index 280f447fab..1f36524d2d 100644 --- a/openmc/mesh.py +++ b/openmc/mesh.py @@ -1952,6 +1952,11 @@ class UnstructuredMesh(MeshBase): Name of the mesh length_multiplier: float Constant multiplier to apply to mesh coordinates + options : str, optional + Special options that control spatial search data structures used. This + is currently only used to set `parameters + `_ for MOAB's AdaptiveKDTree. If + None, OpenMC internally uses a default of "MAX_DEPTH=20;PLANE_SET=2;". Attributes ---------- @@ -1965,6 +1970,11 @@ class UnstructuredMesh(MeshBase): Multiplicative factor to apply to mesh coordinates library : {'moab', 'libmesh'} Mesh library used for the unstructured mesh tally + options : str + Special options that control spatial search data structures used. This + is currently only used to set `parameters + `_ for MOAB's AdaptiveKDTree. If + None, OpenMC internally uses a default of "MAX_DEPTH=20;PLANE_SET=2;". output : bool Indicates whether or not automatic tally output should be generated for this mesh @@ -1998,7 +2008,8 @@ class UnstructuredMesh(MeshBase): _LINEAR_HEX = 1 def __init__(self, filename: PathLike, library: str, mesh_id: Optional[int] = None, - name: str = '', length_multiplier: float = 1.0): + name: str = '', length_multiplier: float = 1.0, + options: Optional[str] = None): super().__init__(mesh_id, name) self.filename = filename self._volumes = None @@ -2008,6 +2019,7 @@ class UnstructuredMesh(MeshBase): self.library = library self._output = False self.length_multiplier = length_multiplier + self.options = options self._has_statepoint_data = False @property @@ -2028,6 +2040,15 @@ class UnstructuredMesh(MeshBase): cv.check_value('Unstructured mesh library', lib, ('moab', 'libmesh')) self._library = lib + @property + def options(self) -> Optional[str]: + return self._options + + @options.setter + def options(self, options: Optional[str]): + cv.check_type('options', options, (str, type(None))) + self._options = options + @property @require_statepoint_data def size(self): @@ -2139,6 +2160,8 @@ class UnstructuredMesh(MeshBase): if self.length_multiplier != 1.0: string += '{: <16}=\t{}\n'.format('\tLength multiplier', self.length_multiplier) + if self.options is not None: + string += '{: <16}=\t{}\n'.format('\tOptions', self.options) return string @property @@ -2294,8 +2317,12 @@ class UnstructuredMesh(MeshBase): mesh_id = int(group.name.split('/')[-1].lstrip('mesh ')) filename = group['filename'][()].decode() library = group['library'][()].decode() + if 'options' in group.attrs: + options = group.attrs['options'].decode() + else: + options = None - mesh = cls(filename=filename, library=library, mesh_id=mesh_id) + mesh = cls(filename=filename, library=library, mesh_id=mesh_id, options=options) mesh._has_statepoint_data = True vol_data = group['volumes'][()] mesh.volumes = np.reshape(vol_data, (vol_data.shape[0],)) @@ -2326,6 +2353,8 @@ class UnstructuredMesh(MeshBase): element.set("id", str(self._id)) element.set("type", "unstructured") element.set("library", self._library) + if self.options is not None: + element.set('options', self.options) subelement = ET.SubElement(element, "filename") subelement.text = str(self.filename) @@ -2352,8 +2381,9 @@ class UnstructuredMesh(MeshBase): filename = get_text(elem, 'filename') library = get_text(elem, 'library') length_multiplier = float(get_text(elem, 'length_multiplier', 1.0)) + options = elem.get('options') - return cls(filename, library, mesh_id, '', length_multiplier) + return cls(filename, library, mesh_id, '', length_multiplier, options) def _read_meshes(elem): diff --git a/src/mesh.cpp b/src/mesh.cpp index c6e421b429..c57dd5dcc3 100644 --- a/src/mesh.cpp +++ b/src/mesh.cpp @@ -44,6 +44,10 @@ #include "libmesh/numeric_vector.h" #endif +#ifdef DAGMC +#include "moab/FileOptions.hpp" +#endif + namespace openmc { //============================================================================== @@ -291,7 +295,6 @@ UnstructuredMesh::UnstructuredMesh(pugi::xml_node node) : Mesh(node) // check if a length unit multiplier was specified if (check_for_node(node, "length_multiplier")) { length_multiplier_ = std::stod(get_node_value(node, "length_multiplier")); - specified_length_multiplier_ = true; } // get the filename of the unstructured mesh to load @@ -305,6 +308,10 @@ UnstructuredMesh::UnstructuredMesh(pugi::xml_node node) : Mesh(node) "No filename supplied for unstructured mesh with ID: {}", id_)); } + if (check_for_node(node, "options")) { + options_ = get_node_value(node, "options"); + } + // check if mesh tally data should be written with // statepoint files if (check_for_node(node, "output")) { @@ -367,8 +374,11 @@ void UnstructuredMesh::to_hdf5(hid_t group) const write_dataset(mesh_group, "type", mesh_type); write_dataset(mesh_group, "filename", filename_); write_dataset(mesh_group, "library", this->library()); + if (!options_.empty()) { + write_attribute(mesh_group, "options", options_); + } - if (specified_length_multiplier_) + if (length_multiplier_ > 0.0) write_dataset(mesh_group, "length_multiplier", length_multiplier_); // write vertex coordinates @@ -428,9 +438,6 @@ void UnstructuredMesh::to_hdf5(hid_t group) const void UnstructuredMesh::set_length_multiplier(double length_multiplier) { length_multiplier_ = length_multiplier; - - if (length_multiplier_ != 1.0) - specified_length_multiplier_ = true; } ElementType UnstructuredMesh::element_type(int bin) const @@ -2231,7 +2238,7 @@ void MOABMesh::initialize() fatal_error("Failed to add tetrahedra to an entity set."); } - if (specified_length_multiplier_) { + if (length_multiplier_ > 0.0) { // get the connectivity of all tets moab::Range adj; rval = mbi_->get_adjacencies(ehs_, 0, true, adj, moab::Interface::UNION); @@ -2291,6 +2298,7 @@ void MOABMesh::build_kdtree(const moab::Range& all_tets) { moab::Range all_tris; int adj_dim = 2; + write_message("Getting tet adjacencies...", 7); moab::ErrorCode rval = mbi_->get_adjacencies( all_tets, adj_dim, true, all_tris, moab::Interface::UNION); if (rval != moab::MB_SUCCESS) { @@ -2309,10 +2317,20 @@ void MOABMesh::build_kdtree(const moab::Range& all_tets) all_tets_and_tris.merge(all_tris); // create a kd-tree instance + write_message("Building adaptive k-d tree for tet mesh...", 7); kdtree_ = make_unique(mbi_.get()); - // build the tree - rval = kdtree_->build_tree(all_tets_and_tris, &kdtree_root_); + // Determine what options to use + std::ostringstream options_stream; + if (options_.empty()) { + options_stream << "MAX_DEPTH=20;PLANE_SET=2;"; + } else { + options_stream << options_; + } + moab::FileOptions file_opts(options_stream.str().c_str()); + + // Build the k-d tree + rval = kdtree_->build_tree(all_tets_and_tris, &kdtree_root_, &file_opts); if (rval != moab::MB_SUCCESS) { fatal_error("Failed to construct KDTree for the " "unstructured mesh file: " + @@ -2899,7 +2917,7 @@ void LibMesh::initialize() // assuming that unstructured meshes used in OpenMC are 3D n_dimension_ = 3; - if (specified_length_multiplier_) { + if (length_multiplier_ > 0.0) { libMesh::MeshTools::Modification::scale(*m_, length_multiplier_); } // if OpenMC is managing the libMesh::MeshBase instance, prepare the mesh. diff --git a/tests/regression_tests/unstructured_mesh/inputs_true10.dat b/tests/regression_tests/unstructured_mesh/inputs_true10.dat index ebc5548f54..81dfbdc52d 100644 --- a/tests/regression_tests/unstructured_mesh/inputs_true10.dat +++ b/tests/regression_tests/unstructured_mesh/inputs_true10.dat @@ -67,7 +67,7 @@ -10.0 -10.0 -10.0 10.0 10.0 10.0 - + test_mesh_tets_w_holes.e diff --git a/tests/regression_tests/unstructured_mesh/inputs_true11.dat b/tests/regression_tests/unstructured_mesh/inputs_true11.dat index e30a5ccfc1..b224a3ebc3 100644 --- a/tests/regression_tests/unstructured_mesh/inputs_true11.dat +++ b/tests/regression_tests/unstructured_mesh/inputs_true11.dat @@ -67,7 +67,7 @@ -10.0 -10.0 -10.0 10.0 10.0 10.0 - + test_mesh_tets.e diff --git a/tests/regression_tests/unstructured_mesh/inputs_true12.dat b/tests/regression_tests/unstructured_mesh/inputs_true12.dat index a60b70eab2..a18aee627c 100644 --- a/tests/regression_tests/unstructured_mesh/inputs_true12.dat +++ b/tests/regression_tests/unstructured_mesh/inputs_true12.dat @@ -67,7 +67,7 @@ -10.0 -10.0 -10.0 10.0 10.0 10.0 - + test_mesh_tets_w_holes.e diff --git a/tests/regression_tests/unstructured_mesh/inputs_true13.dat b/tests/regression_tests/unstructured_mesh/inputs_true13.dat index 1b1aa19c31..0e9123c903 100644 --- a/tests/regression_tests/unstructured_mesh/inputs_true13.dat +++ b/tests/regression_tests/unstructured_mesh/inputs_true13.dat @@ -67,7 +67,7 @@ -10.0 -10.0 -10.0 10.0 10.0 10.0 - + test_mesh_tets.e diff --git a/tests/regression_tests/unstructured_mesh/inputs_true14.dat b/tests/regression_tests/unstructured_mesh/inputs_true14.dat index d07504b9d2..9d4db49045 100644 --- a/tests/regression_tests/unstructured_mesh/inputs_true14.dat +++ b/tests/regression_tests/unstructured_mesh/inputs_true14.dat @@ -67,7 +67,7 @@ -10.0 -10.0 -10.0 10.0 10.0 10.0 - + test_mesh_tets_w_holes.e diff --git a/tests/regression_tests/unstructured_mesh/inputs_true15.dat b/tests/regression_tests/unstructured_mesh/inputs_true15.dat index 8cabbb38d0..eab97b9522 100644 --- a/tests/regression_tests/unstructured_mesh/inputs_true15.dat +++ b/tests/regression_tests/unstructured_mesh/inputs_true15.dat @@ -67,7 +67,7 @@ -10.0 -10.0 -10.0 10.0 10.0 10.0 - + test_mesh_tets.e diff --git a/tests/regression_tests/unstructured_mesh/inputs_true8.dat b/tests/regression_tests/unstructured_mesh/inputs_true8.dat index 5f14aa691b..7994e1add7 100644 --- a/tests/regression_tests/unstructured_mesh/inputs_true8.dat +++ b/tests/regression_tests/unstructured_mesh/inputs_true8.dat @@ -67,7 +67,7 @@ -10.0 -10.0 -10.0 10.0 10.0 10.0 - + test_mesh_tets_w_holes.e diff --git a/tests/regression_tests/unstructured_mesh/inputs_true9.dat b/tests/regression_tests/unstructured_mesh/inputs_true9.dat index 92354c75cf..4fff123d7d 100644 --- a/tests/regression_tests/unstructured_mesh/inputs_true9.dat +++ b/tests/regression_tests/unstructured_mesh/inputs_true9.dat @@ -67,7 +67,7 @@ -10.0 -10.0 -10.0 10.0 10.0 10.0 - + test_mesh_tets.e diff --git a/tests/regression_tests/unstructured_mesh/test.py b/tests/regression_tests/unstructured_mesh/test.py index f3ba46e37a..0082198dde 100644 --- a/tests/regression_tests/unstructured_mesh/test.py +++ b/tests/regression_tests/unstructured_mesh/test.py @@ -277,6 +277,8 @@ def test_unstructured_mesh_tets(model, test_opts): # add analagous unstructured mesh tally uscd_mesh = openmc.UnstructuredMesh(mesh_filename, test_opts['library']) + if test_opts['library'] == 'moab': + uscd_mesh.options = 'MAX_DEPTH=15;PLANE_SET=2' uscd_filter = openmc.MeshFilter(mesh=uscd_mesh) # create tallies From e8ae7063afc20c5c33dbe49476819fb00b7e89d0 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Fri, 26 Apr 2024 17:54:38 -0500 Subject: [PATCH 35/48] Update CODEOWNERS file (#2974) --- CODEOWNERS | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 1b5130e0b6..6d452e3665 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -5,9 +5,9 @@ openmc/data/ @paulromano openmc/lib/ @paulromano # Depletion -openmc/deplete/ @drewejohnson -tests/regression_tests/deplete/ @drewejohnson -tests/unit_tests/test_deplete_*.py @drewejohnson +openmc/deplete/ @paulromano +tests/regression_tests/deplete/ @paulromano +tests/unit_tests/test_deplete_*.py @paulromano # MG-related functionality openmc/mgxs_library.py @nelsonag @@ -26,6 +26,12 @@ src/dagmc.cpp @pshriwise tests/regression_tests/dagmc/ @pshriwise tests/unit_tests/dagmc/ @pshriwise +# Weight windows +openmc/weight_windows.py @pshriwise +openmc/lib/weight_windows.py @pshriwise +src/weight_windows.py @pshriwise +tests/unit_tests/weightwindows/ @pshriwise + # Photon transport openmc/data/BREMX.DAT @amandalund openmc/data/compton_profiles.h5 @amandalund @@ -49,3 +55,12 @@ openmc/data/resonance_covariance.py @icmeyer # Docker Dockerfile @shimwell + +# Random ray +src/random_ray/ @jtramm + +# NCrystal interface +src/ncrystal_interface.cpp @marquezj + +# MCPL interface +src/mcpl_interface.cpp @ebknudsen From 5d2b352025e7dcfdb8772dc0415d6eef3000d335 Mon Sep 17 00:00:00 2001 From: Jonathan Shimwell Date: Mon, 29 Apr 2024 22:45:37 +0100 Subject: [PATCH 36/48] f strings instead of .format for string editing (#2987) --- openmc/arithmetic.py | 5 ++- openmc/cmfd.py | 17 +++++----- openmc/data/ace.py | 19 +++++------ openmc/data/angle_energy.py | 3 +- openmc/data/decay.py | 11 +++---- openmc/data/effective_dose/dose.py | 2 +- openmc/data/endf.py | 3 +- openmc/data/energy_distribution.py | 3 +- openmc/data/library.py | 3 +- openmc/data/multipole.py | 53 ++++++++++++++---------------- openmc/data/neutron.py | 16 ++++----- openmc/data/njoy.py | 36 ++++++++++---------- openmc/data/photon.py | 17 +++++----- openmc/data/product.py | 4 +-- openmc/data/reaction.py | 31 +++++++++-------- openmc/data/resonance.py | 4 +-- openmc/data/thermal.py | 8 ++--- openmc/deplete/abc.py | 13 +++----- openmc/deplete/chain.py | 20 +++++------ openmc/deplete/coupled_operator.py | 2 +- openmc/deplete/nuclide.py | 9 +++-- openmc/lattice.py | 4 +-- openmc/lib/__init__.py | 2 +- openmc/lib/cell.py | 2 +- openmc/lib/core.py | 4 +-- openmc/lib/error.py | 2 +- openmc/lib/plot.py | 31 +++++++++-------- openmc/lib/settings.py | 2 +- openmc/material.py | 25 ++++++-------- openmc/mgxs/library.py | 4 +-- openmc/mgxs/mdgxs.py | 10 +++--- openmc/mgxs/mgxs.py | 31 +++++++++-------- openmc/model/funcs.py | 3 +- openmc/model/surface_composite.py | 2 +- openmc/plots.py | 3 +- openmc/stats/univariate.py | 6 ++-- openmc/surface.py | 3 +- openmc/tallies.py | 13 ++++---- 38 files changed, 197 insertions(+), 229 deletions(-) diff --git a/openmc/arithmetic.py b/openmc/arithmetic.py index c66efe5b81..4520553daf 100644 --- a/openmc/arithmetic.py +++ b/openmc/arithmetic.py @@ -54,8 +54,7 @@ class CrossScore: return str(other) == str(self) def __repr__(self): - return '({} {} {})'.format(self.left_score, self.binary_op, - self.right_score) + return f'({self.left_score} {self.binary_op} {self.right_score})' @property def left_score(self): @@ -271,7 +270,7 @@ class CrossFilter: def type(self): left_type = self.left_filter.type right_type = self.right_filter.type - return '({} {} {})'.format(left_type, self.binary_op, right_type) + return f'({left_type} {self.binary_op} {right_type})' @property def bins(self): diff --git a/openmc/cmfd.py b/openmc/cmfd.py index 9b0b4ca83c..eff6a151fb 100644 --- a/openmc/cmfd.py +++ b/openmc/cmfd.py @@ -135,7 +135,7 @@ class CMFDMesh: return outstr def _get_repr(self, list_var, label): - outstr = "\t{:<11} = ".format(label) + outstr = f"\t{label:<11} = " if list(list_var): outstr += ", ".join(str(i) for i in list_var) return outstr @@ -242,9 +242,9 @@ class CMFDMesh: check_length('CMFD mesh grid', grid, grid_length) for i in range(grid_length): - check_type('CMFD mesh {}-grid'.format(dims[i]), grid[i], Iterable, + check_type(f'CMFD mesh {dims[i]}-grid', grid[i], Iterable, Real) - check_greater_than('CMFD mesh {}-grid length'.format(dims[i]), + check_greater_than(f'CMFD mesh {dims[i]}-grid length', len(grid[i]), 1) self._grid = [np.array(g) for g in grid] self._display_mesh_warning('rectilinear', 'CMFD mesh grid') @@ -612,7 +612,7 @@ class CMFDRun: for key, value in display.items(): check_value('display key', key, ('balance', 'entropy', 'dominance', 'source')) - check_type("display['{}']".format(key), value, bool) + check_type(f"display['{key}']", value, bool) self._display[key] = value @downscatter.setter @@ -928,7 +928,7 @@ class CMFDRun: with h5py.File(filename, 'a') as f: if 'cmfd' not in f: if openmc.lib.settings.verbosity >= 5: - print(' Writing CMFD data to {}...'.format(filename)) + print(f' Writing CMFD data to {filename}...') sys.stdout.flush() cmfd_group = f.create_group("cmfd") cmfd_group.attrs['cmfd_on'] = self._cmfd_on @@ -1134,12 +1134,12 @@ class CMFDRun: with h5py.File(filename, 'r') as f: if 'cmfd' not in f: raise OpenMCError('Could not find CMFD parameters in ', - 'file {}'.format(filename)) + f'file {filename}') else: # Overwrite CMFD values from statepoint if (openmc.lib.master() and openmc.lib.settings.verbosity >= 5): - print(' Loading CMFD data from {}...'.format(filename)) + print(f' Loading CMFD data from {filename}...') sys.stdout.flush() cmfd_group = f['cmfd'] @@ -1409,8 +1409,7 @@ class CMFDRun: # Get all data entries for particular row in matrix data = matrix.data[matrix.indptr[row]:matrix.indptr[row+1]] for i in range(len(cols)): - fh.write('{:3d}, {:3d}, {:0.8f}\n'.format( - row, cols[i], data[i])) + fh.write(f'{row:3d}, {cols[i]:3d}, {data[i]:0.8f}\n') # Save matrix in scipy format sparse.save_npz(base_filename, matrix) diff --git a/openmc/data/ace.py b/openmc/data/ace.py index 7c348bd176..1247593a80 100644 --- a/openmc/data/ace.py +++ b/openmc/data/ace.py @@ -143,7 +143,7 @@ def ascii_to_binary(ascii_file, binary_file): # that XSS will start at the second record nxs = [int(x) for x in ' '.join(lines[idx + 6:idx + 8]).split()] jxs = [int(x) for x in ' '.join(lines[idx + 8:idx + 12]).split()] - binary_file.write(struct.pack(str('=16i32i{}x'.format(record_length - 500)), + binary_file.write(struct.pack(str(f'=16i32i{record_length - 500}x'), *(nxs + jxs))) # Read/write XSS array. Null bytes are added to form a complete record @@ -152,8 +152,7 @@ def ascii_to_binary(ascii_file, binary_file): start = idx + _ACE_HEADER_SIZE xss = np.fromstring(' '.join(lines[start:start + n_lines]), sep=' ') extra_bytes = record_length - ((len(xss)*8 - 1) % record_length + 1) - binary_file.write(struct.pack(str('={}d{}x'.format( - nxs[0], extra_bytes)), *xss)) + binary_file.write(struct.pack(str(f'={nxs[0]}d{extra_bytes}x'), *xss)) # Advance to next table in file idx += _ACE_HEADER_SIZE + n_lines @@ -184,8 +183,7 @@ def get_table(filename, name=None): if lib.tables: return lib.tables[0] else: - raise ValueError('Could not find ACE table with name: {}' - .format(name)) + raise ValueError(f'Could not find ACE table with name: {name}') # The beginning of an ASCII ACE file consists of 12 lines that include the name, @@ -295,14 +293,14 @@ class Library(EqualityMixin): if verbose: kelvin = round(temperature * EV_PER_MEV / K_BOLTZMANN) - print("Loading nuclide {} at {} K".format(name, kelvin)) + print(f"Loading nuclide {name} at {kelvin} K") # Read JXS jxs = list(struct.unpack(str('=32i'), ace_file.read(128))) # Read XSS ace_file.seek(start_position + recl_length) - xss = list(struct.unpack(str('={}d'.format(length)), + xss = list(struct.unpack(str(f'={length}d'), ace_file.read(length*8))) # Insert zeros at beginning of NXS, JXS, and XSS arrays so that the @@ -393,7 +391,7 @@ class Library(EqualityMixin): if verbose: kelvin = round(temperature * EV_PER_MEV / K_BOLTZMANN) - print("Loading nuclide {} at {} K".format(name, kelvin)) + print(f"Loading nuclide {name} at {kelvin} K") # Insert zeros at beginning of NXS, JXS, and XSS arrays so that the # indexing will be the same as Fortran. This makes it easier to @@ -455,8 +453,7 @@ class TableType(enum.Enum): for member in cls: if suffix.endswith(member.value): return member - raise ValueError("Suffix '{}' has no corresponding ACE table type." - .format(suffix)) + raise ValueError(f"Suffix '{suffix}' has no corresponding ACE table type.") class Table(EqualityMixin): @@ -507,7 +504,7 @@ class Table(EqualityMixin): return TableType.from_suffix(xs[-1]) def __repr__(self): - return "".format(self.name) + return f"" def get_libraries_from_xsdir(path): diff --git a/openmc/data/angle_energy.py b/openmc/data/angle_energy.py index b8fde54787..71ca47587d 100644 --- a/openmc/data/angle_energy.py +++ b/openmc/data/angle_energy.py @@ -112,7 +112,6 @@ class AngleEnergy(EqualityMixin, ABC): distribution = openmc.data.NBodyPhaseSpace.from_ace( ace, idx, rx.q_value) else: - raise ValueError("Unsupported ACE secondary energy " - "distribution law {}".format(law)) + raise ValueError(f"Unsupported ACE secondary energy distribution law {law}") return distribution diff --git a/openmc/data/decay.py b/openmc/data/decay.py index 7b8993a289..be3dab77ab 100644 --- a/openmc/data/decay.py +++ b/openmc/data/decay.py @@ -128,7 +128,7 @@ class FissionProductYields(EqualityMixin): isomeric_state = int(values[4*j + 1]) name = ATOMIC_SYMBOL[Z] + str(A) if isomeric_state > 0: - name += '_m{}'.format(isomeric_state) + name += f'_m{isomeric_state}' yield_j = ufloat(values[4*j + 2], values[4*j + 3]) yields[name] = yield_j @@ -257,9 +257,9 @@ class DecayMode(EqualityMixin): Z += delta_Z if self._daughter_state > 0: - return '{}{}_m{}'.format(ATOMIC_SYMBOL[Z], A, self._daughter_state) + return f'{ATOMIC_SYMBOL[Z]}{A}_m{self._daughter_state}' else: - return '{}{}'.format(ATOMIC_SYMBOL[Z], A) + return f'{ATOMIC_SYMBOL[Z]}{A}' @property def parent(self): @@ -350,10 +350,9 @@ class Decay(EqualityMixin): self.nuclide['mass_number'] = A self.nuclide['isomeric_state'] = metastable if metastable > 0: - self.nuclide['name'] = '{}{}_m{}'.format(ATOMIC_SYMBOL[Z], A, - metastable) + self.nuclide['name'] = f'{ATOMIC_SYMBOL[Z]}{A}_m{metastable}' else: - self.nuclide['name'] = '{}{}'.format(ATOMIC_SYMBOL[Z], A) + self.nuclide['name'] = f'{ATOMIC_SYMBOL[Z]}{A}' self.nuclide['mass'] = items[1] # AWR self.nuclide['excited_state'] = items[2] # State of the original nuclide self.nuclide['stable'] = (items[4] == 1) # Nucleus stability flag diff --git a/openmc/data/effective_dose/dose.py b/openmc/data/effective_dose/dose.py index eb8fe9f356..ae981ee7dc 100644 --- a/openmc/data/effective_dose/dose.py +++ b/openmc/data/effective_dose/dose.py @@ -60,7 +60,7 @@ def dose_coefficients(particle, geometry='AP'): # Get all data for selected particle data = _DOSE_ICRP116.get(particle) if data is None: - raise ValueError("{} has no effective dose data".format(particle)) + raise ValueError(f"{particle} has no effective dose data") # Determine index for selected geometry if particle in ('neutron', 'photon', 'proton', 'photon kerma'): diff --git a/openmc/data/endf.py b/openmc/data/endf.py index d526bc53f5..73299723a8 100644 --- a/openmc/data/endf.py +++ b/openmc/data/endf.py @@ -449,8 +449,7 @@ class Evaluation: def __repr__(self): name = self.target['zsymam'].replace(' ', '') - return '<{} for {} {}>'.format(self.info['sublibrary'], name, - self.info['library']) + return f"<{self.info['sublibrary']} for {name} {self.info['library']}>" def _read_header(self): file_obj = io.StringIO(self.section[1, 451]) diff --git a/openmc/data/energy_distribution.py b/openmc/data/energy_distribution.py index b3566e9990..069ab1b9be 100644 --- a/openmc/data/energy_distribution.py +++ b/openmc/data/energy_distribution.py @@ -53,8 +53,7 @@ class EnergyDistribution(EqualityMixin, ABC): elif energy_type == 'continuous': return ContinuousTabular.from_hdf5(group) else: - raise ValueError("Unknown energy distribution type: {}" - .format(energy_type)) + raise ValueError(f"Unknown energy distribution type: {energy_type}") @staticmethod def from_endf(file_obj, params): diff --git a/openmc/data/library.py b/openmc/data/library.py index de9de27443..a6ce1bbd32 100644 --- a/openmc/data/library.py +++ b/openmc/data/library.py @@ -93,8 +93,7 @@ class DataLibrary(list): materials = list(h5file) else: raise ValueError( - "File type {} not supported by {}" - .format(path.name, self.__class__.__name__)) + f"File type {path.name} not supported by {self.__class__.__name__}") library = {'path': str(path), 'type': filetype, 'materials': materials} self.append(library) diff --git a/openmc/data/multipole.py b/openmc/data/multipole.py index 5c9df39c29..dd14e0d194 100644 --- a/openmc/data/multipole.py +++ b/openmc/data/multipole.py @@ -194,9 +194,8 @@ def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None, test_xs_ref[i] = np.interp(test_energy, energy, ce_xs[i]) if log: - print(" energy: {:.3e} to {:.3e} eV ({} points)".format( - energy[0], energy[-1], ne)) - print(" error tolerance: rtol={}, atol={}".format(rtol, atol)) + print(f" energy: {energy[0]:.3e} to {energy[-1]:.3e} eV ({ne} points)") + print(f" error tolerance: rtol={rtol}, atol={atol}") # transform xs (sigma) and energy (E) to f (sigma*E) and s (sqrt(E)) to be # compatible with the multipole representation @@ -230,8 +229,8 @@ def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None, orders = list(range(lowest_order, highest_order + 1, 2)) if log: - print("Found {} peaks".format(n_peaks)) - print("Fitting orders from {} to {}".format(orders[0], orders[-1])) + print(f"Found {n_peaks} peaks") + print(f"Fitting orders from {orders[0]} to {orders[-1]}") # perform VF with increasing orders found_ideal = False @@ -239,7 +238,7 @@ def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None, best_quality = best_ratio = -np.inf for i, order in enumerate(orders): if log: - print("Order={}({}/{})".format(order, i, len(orders))) + print(f"Order={order}({i}/{len(orders)})") # initial guessed poles poles_r = np.linspace(s[0], s[-1], order//2) poles = poles_r + poles_r*0.01j @@ -249,7 +248,7 @@ def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None, # fitting iteration for i_vf in range(n_vf_iter): if log >= DETAILED_LOGGING: - print("VF iteration {}/{}".format(i_vf + 1, n_vf_iter)) + print(f"VF iteration {i_vf + 1}/{n_vf_iter}") # call vf poles, residues, cf, f_fit, rms = vf.vectfit(f, s, poles, weight) @@ -268,7 +267,7 @@ def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None, # re-calculate residues if poles changed if n_real_poles > 0: if log >= DETAILED_LOGGING: - print(" # real poles: {}".format(n_real_poles)) + print(f" # real poles: {n_real_poles}") new_poles, residues, cf, f_fit, rms = \ vf.vectfit(f, s, new_poles, weight, skip_pole=True) @@ -296,10 +295,10 @@ def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None, quality = -np.inf if log >= DETAILED_LOGGING: - print(" # poles: {}".format(new_poles.size)) - print(" Max relative error: {:.3f}%".format(maxre*100)) - print(" Satisfaction: {:.1f}%, {:.1f}%".format(ratio*100, ratio2*100)) - print(" Quality: {:.2f}".format(quality)) + print(f" # poles: {new_poles.size}") + print(f" Max relative error: {maxre * 100:.3f}%") + print(f" Satisfaction: {ratio * 100:.1f}%, {ratio2 * 100:.1f}%") + print(f" Quality: {quality:.2f}") if quality > best_quality: if log >= DETAILED_LOGGING: @@ -354,7 +353,7 @@ def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None, mp_residues = np.concatenate((best_residues[:, real_idx], best_residues[:, conj_idx]*2), axis=1)/1j if log: - print("Final number of poles: {}".format(mp_poles.size)) + print(f"Final number of poles: {mp_poles.size}") if path_out: if not os.path.exists(path_out): @@ -378,14 +377,14 @@ def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None, ax2.set_ylabel('relative error', color='r') ax2.tick_params('y', colors='r') - plt.title("MT {} vector fitted with {} poles".format(mt, mp_poles.size)) + plt.title(f"MT {mt} vector fitted with {mp_poles.size} poles") fig.tight_layout() fig_file = os.path.join(path_out, "{:.0f}-{:.0f}_MT{}.png".format( energy[0], energy[-1], mt)) plt.savefig(fig_file) plt.close() if log: - print("Saved figure: {}".format(fig_file)) + print(f"Saved figure: {fig_file}") return (mp_poles, mp_residues) @@ -423,7 +422,7 @@ def vectfit_nuclide(endf_file, njoy_error=5e-4, vf_pieces=None, # make 0K ACE data using njoy if log: - print("Running NJOY to get 0K point-wise data (error={})...".format(njoy_error)) + print(f"Running NJOY to get 0K point-wise data (error={njoy_error})...") nuc_ce = IncidentNeutron.from_njoy(endf_file, temperatures=[0.0], error=njoy_error, broadr=False, heatr=False, purr=False) @@ -477,9 +476,8 @@ def vectfit_nuclide(endf_file, njoy_error=5e-4, vf_pieces=None, mts = [2, 27] if log: - print(" MTs: {}".format(mts)) - print(" Energy range: {:.3e} to {:.3e} eV ({} points)".format( - E_min, E_max, n_points)) + print(f" MTs: {mts}") + print(f" Energy range: {E_min:.3e} to {E_max:.3e} eV ({n_points} points)") # ====================================================================== # PERFORM VECTOR FITTING @@ -500,7 +498,7 @@ def vectfit_nuclide(endf_file, njoy_error=5e-4, vf_pieces=None, # VF piece by piece for i_piece in range(vf_pieces): if log: - print("Vector fitting piece {}/{}...".format(i_piece + 1, vf_pieces)) + print(f"Vector fitting piece {i_piece + 1}/{vf_pieces}...") # start E of this piece e_bound = (sqrt(E_min) + piece_width*(i_piece-0.5))**2 if i_piece == 0 or sqrt(alpha*e_bound) < 4.0: @@ -534,12 +532,12 @@ def vectfit_nuclide(endf_file, njoy_error=5e-4, vf_pieces=None, if not os.path.exists(path_out): os.makedirs(path_out) if not mp_filename: - mp_filename = "{}_mp.pickle".format(nuc_ce.name) + mp_filename = f"{nuc_ce.name}_mp.pickle" mp_filename = os.path.join(path_out, mp_filename) with open(mp_filename, 'wb') as f: pickle.dump(mp_data, f) if log: - print("Dumped multipole data to file: {}".format(mp_filename)) + print(f"Dumped multipole data to file: {mp_filename}") return mp_data @@ -605,9 +603,8 @@ def _windowing(mp_data, n_cf, rtol=1e-3, atol=1e-5, n_win=None, spacing=None, if log: print("Windowing:") - print(" config: # windows={}, spacing={}, CF order={}".format( - n_win, spacing, n_cf)) - print(" error tolerance: rtol={}, atol={}".format(rtol, atol)) + print(f" config: # windows={n_win}, spacing={spacing}, CF order={n_cf}") + print(f" error tolerance: rtol={rtol}, atol={atol}") # sort poles (and residues) by the real component of the pole for ip in range(n_pieces): @@ -623,7 +620,7 @@ def _windowing(mp_data, n_cf, rtol=1e-3, atol=1e-5, n_win=None, spacing=None, win_data = [] for iw in range(n_win): if log >= DETAILED_LOGGING: - print("Processing window {}/{}...".format(iw + 1, n_win)) + print(f"Processing window {iw + 1}/{n_win}...") # inner window boundaries inbegin = sqrt(E_min) + spacing * iw @@ -658,7 +655,7 @@ def _windowing(mp_data, n_cf, rtol=1e-3, atol=1e-5, n_win=None, spacing=None, lp = rp = center_pole_ind while True: if log >= DETAILED_LOGGING: - print("Trying poles {} to {}".format(lp, rp)) + print(f"Trying poles {lp} to {rp}") # calculate the cross sections contributed by the windowed poles if rp > lp: @@ -1108,7 +1105,7 @@ class WindowedMultipole(EqualityMixin): for n_w in np.unique(np.linspace(n_win_min, n_win_max, 20, dtype=int)): for n_cf in range(10, 1, -1): if log: - print("Testing N_win={} N_cf={}".format(n_w, n_cf)) + print(f"Testing N_win={n_w} N_cf={n_cf}") # update arguments dictionary kwargs.update(n_win=n_w, n_cf=n_cf) diff --git a/openmc/data/neutron.py b/openmc/data/neutron.py index 94833d4b34..894be18717 100644 --- a/openmc/data/neutron.py +++ b/openmc/data/neutron.py @@ -122,10 +122,10 @@ class IncidentNeutron(EqualityMixin): if len(mts) > 0: return self._get_redundant_reaction(mt, mts) else: - raise KeyError('No reaction with MT={}.'.format(mt)) + raise KeyError(f'No reaction with MT={mt}.') def __repr__(self): - return "".format(self.name) + return f"" def __iter__(self): return iter(self.reactions.values()) @@ -231,7 +231,7 @@ class IncidentNeutron(EqualityMixin): @property def temperatures(self): - return ["{}K".format(int(round(kT / K_BOLTZMANN))) for kT in self.kTs] + return [f"{int(round(kT / K_BOLTZMANN))}K" for kT in self.kTs] @property def atomic_symbol(self): @@ -261,7 +261,7 @@ class IncidentNeutron(EqualityMixin): # Check if temprature already exists strT = data.temperatures[0] if strT in self.temperatures: - warn('Cross sections at T={} already exist.'.format(strT)) + warn(f'Cross sections at T={strT} already exist.') return # Check that name matches @@ -461,7 +461,7 @@ class IncidentNeutron(EqualityMixin): if not (photon_rx or rx.mt in keep_mts): continue - rx_group = rxs_group.create_group('reaction_{:03}'.format(rx.mt)) + rx_group = rxs_group.create_group(f'reaction_{rx.mt:03}') rx.to_hdf5(rx_group) # Write total nu data if available @@ -593,7 +593,7 @@ class IncidentNeutron(EqualityMixin): zaid, xs = ace.name.split('.') if not xs.endswith('c'): raise TypeError( - "{} is not a continuous-energy neutron ACE table.".format(ace)) + f"{ace} is not a continuous-energy neutron ACE table.") name, element, Z, mass_number, metastable = \ get_metadata(int(zaid), metastable_scheme) @@ -732,9 +732,9 @@ class IncidentNeutron(EqualityMixin): # Determine name element = ATOMIC_SYMBOL[atomic_number] if metastable > 0: - name = '{}{}_m{}'.format(element, mass_number, metastable) + name = f'{element}{mass_number}_m{metastable}' else: - name = '{}{}'.format(element, mass_number) + name = f'{element}{mass_number}' # Instantiate incident neutron data data = cls(name, atomic_number, mass_number, metastable, diff --git a/openmc/data/njoy.py b/openmc/data/njoy.py index 9c2055cc48..c1dcbab17f 100644 --- a/openmc/data/njoy.py +++ b/openmc/data/njoy.py @@ -188,7 +188,7 @@ def run(commands, tapein, tapeout, input_filename=None, stdout=False, with tempfile.TemporaryDirectory() as tmpdir: # Copy evaluations to appropriates 'tapes' for tape_num, filename in tapein.items(): - tmpfilename = os.path.join(tmpdir, 'tape{}'.format(tape_num)) + tmpfilename = os.path.join(tmpdir, f'tape{tape_num}') shutil.copy(str(filename), tmpfilename) # Start up NJOY process @@ -216,7 +216,7 @@ def run(commands, tapein, tapeout, input_filename=None, stdout=False, # Copy output files back to original directory for tape_num, filename in tapeout.items(): - tmpfilename = os.path.join(tmpdir, 'tape{}'.format(tape_num)) + tmpfilename = os.path.join(tmpdir, f'tape{tape_num}') if os.path.isfile(tmpfilename): shutil.move(tmpfilename, str(filename)) @@ -317,7 +317,7 @@ def make_ace(filename, temperatures=None, acer=True, xsdir=None, else: output_dir = Path(output_dir) if not output_dir.is_dir(): - raise IOError("{} is not a directory".format(output_dir)) + raise IOError(f"{output_dir} is not a directory") ev = evaluation if evaluation is not None else endf.Evaluation(filename) mat = ev.material @@ -389,12 +389,12 @@ def make_ace(filename, temperatures=None, acer=True, xsdir=None, # Extend input with an ACER run for each temperature nace = nacer_in + 1 + 2*i ndir = nace + 1 - ext = '{:02}'.format(i + 1) + ext = f'{i + 1:02}' commands += _TEMPLATE_ACER.format(**locals()) # Indicate tapes to save for each ACER run - tapeout[nace] = output_dir / "ace_{:.1f}".format(temperature) - tapeout[ndir] = output_dir / "xsdir_{:.1f}".format(temperature) + tapeout[nace] = output_dir / f"ace_{temperature:.1f}" + tapeout[ndir] = output_dir / f"xsdir_{temperature:.1f}" commands += 'stop\n' run(commands, tapein, tapeout, **kwargs) @@ -404,7 +404,7 @@ def make_ace(filename, temperatures=None, acer=True, xsdir=None, with ace.open('w') as ace_file, xsdir.open('w') as xsdir_file: for temperature in temperatures: # Get contents of ACE file - text = (output_dir / "ace_{:.1f}".format(temperature)).read_text() + text = (output_dir / f"ace_{temperature:.1f}").read_text() # If the target is metastable, make sure that ZAID in the ACE # file reflects this by adding 400 @@ -417,13 +417,13 @@ def make_ace(filename, temperatures=None, acer=True, xsdir=None, ace_file.write(text) # Concatenate into destination xsdir file - xsdir_in = output_dir / "xsdir_{:.1f}".format(temperature) + xsdir_in = output_dir / f"xsdir_{temperature:.1f}" xsdir_file.write(xsdir_in.read_text()) # Remove ACE/xsdir files for each temperature for temperature in temperatures: - (output_dir / "ace_{:.1f}".format(temperature)).unlink() - (output_dir / "xsdir_{:.1f}".format(temperature)).unlink() + (output_dir / f"ace_{temperature:.1f}").unlink() + (output_dir / f"xsdir_{temperature:.1f}").unlink() def make_ace_thermal(filename, filename_thermal, temperatures=None, @@ -480,7 +480,7 @@ def make_ace_thermal(filename, filename_thermal, temperatures=None, else: output_dir = Path(output_dir) if not output_dir.is_dir(): - raise IOError("{} is not a directory".format(output_dir)) + raise IOError(f"{output_dir} is not a directory") ev = evaluation if evaluation is not None else endf.Evaluation(filename) mat = ev.material @@ -581,12 +581,12 @@ def make_ace_thermal(filename, filename_thermal, temperatures=None, # Extend input with an ACER run for each temperature nace = nthermal_acer_in + 1 + 2*i ndir = nace + 1 - ext = '{:02}'.format(i + 1) + ext = f'{i + 1:02}' commands += _THERMAL_TEMPLATE_ACER.format(**locals()) # Indicate tapes to save for each ACER run - tapeout[nace] = output_dir / "ace_{:.1f}".format(temperature) - tapeout[ndir] = output_dir / "xsdir_{:.1f}".format(temperature) + tapeout[nace] = output_dir / f"ace_{temperature:.1f}" + tapeout[ndir] = output_dir / f"xsdir_{temperature:.1f}" commands += 'stop\n' run(commands, tapein, tapeout, **kwargs) @@ -595,13 +595,13 @@ def make_ace_thermal(filename, filename_thermal, temperatures=None, with ace.open('w') as ace_file, xsdir.open('w') as xsdir_file: # Concatenate ACE and xsdir files together for temperature in temperatures: - ace_in = output_dir / "ace_{:.1f}".format(temperature) + ace_in = output_dir / f"ace_{temperature:.1f}" ace_file.write(ace_in.read_text()) - xsdir_in = output_dir / "xsdir_{:.1f}".format(temperature) + xsdir_in = output_dir / f"xsdir_{temperature:.1f}" xsdir_file.write(xsdir_in.read_text()) # Remove ACE/xsdir files for each temperature for temperature in temperatures: - (output_dir / "ace_{:.1f}".format(temperature)).unlink() - (output_dir / "xsdir_{:.1f}".format(temperature)).unlink() + (output_dir / f"ace_{temperature:.1f}").unlink() + (output_dir / f"xsdir_{temperature:.1f}").unlink() diff --git a/openmc/data/photon.py b/openmc/data/photon.py index ba4bf5c88b..c9d51f0623 100644 --- a/openmc/data/photon.py +++ b/openmc/data/photon.py @@ -451,10 +451,10 @@ class IncidentPhoton(EqualityMixin): if mt in self.reactions: return self.reactions[mt] else: - raise KeyError('No reaction with MT={}.'.format(mt)) + raise KeyError(f'No reaction with MT={mt}.') def __repr__(self): - return "".format(self.name) + return f"" def __iter__(self): return iter(self.reactions.values()) @@ -508,7 +508,7 @@ class IncidentPhoton(EqualityMixin): # Get atomic number based on name of ACE table zaid, xs = ace.name.split('.') if not xs.endswith('p'): - raise TypeError("{} is not a photoatomic transport ACE table.".format(ace)) + raise TypeError(f"{ace} is not a photoatomic transport ACE table.") Z = get_metadata(int(zaid))[2] # Read each reaction @@ -638,7 +638,7 @@ class IncidentPhoton(EqualityMixin): with h5py.File(filename, 'r') as f: _COMPTON_PROFILES['pz'] = f['pz'][()] for i in range(1, 101): - group = f['{:03}'.format(i)] + group = f[f'{i:03}'] num_electrons = group['num_electrons'][()] binding_energy = group['binding_energy'][()]*EV_PER_MEV J = group['J'][()] @@ -713,7 +713,7 @@ class IncidentPhoton(EqualityMixin): # Check for necessary reactions for mt in (502, 504, 522): - assert mt in data, "Reaction {} not found".format(mt) + assert mt in data, f"Reaction {mt} not found" # Read atomic relaxation data.atomic_relaxation = AtomicRelaxation.from_hdf5(group['subshells']) @@ -836,7 +836,7 @@ class IncidentPhoton(EqualityMixin): filename = os.path.join(os.path.dirname(__file__), 'density_effect.h5') with h5py.File(filename, 'r') as f: for i in range(1, 101): - group = f['{:03}'.format(i)] + group = f[f'{i:03}'] _BREMSSTRAHLUNG[i] = { 'I': group.attrs['I'], 'num_electrons': group['num_electrons'][()], @@ -924,10 +924,9 @@ class PhotonReaction(EqualityMixin): def __repr__(self): if self.mt in _REACTION_NAME: - return "".format( - self.mt, _REACTION_NAME[self.mt][0]) + return f"" else: - return "".format(self.mt) + return f"" @property def anomalous_real(self): diff --git a/openmc/data/product.py b/openmc/data/product.py index 4a9b07aee2..88c83b81fd 100644 --- a/openmc/data/product.py +++ b/openmc/data/product.py @@ -135,7 +135,7 @@ class Product(EqualityMixin): # Write applicability/distribution group.attrs['n_distribution'] = len(self.distribution) for i, d in enumerate(self.distribution): - dgroup = group.create_group('distribution_{}'.format(i)) + dgroup = group.create_group(f'distribution_{i}') if self.applicability: self.applicability[i].to_hdf5(dgroup, 'applicability') d.to_hdf5(dgroup) @@ -170,7 +170,7 @@ class Product(EqualityMixin): distribution = [] applicability = [] for i in range(n_distribution): - dgroup = group['distribution_{}'.format(i)] + dgroup = group[f'distribution_{i}'] if 'applicability' in dgroup: applicability.append(Tabulated1D.from_hdf5( dgroup['applicability'])) diff --git a/openmc/data/reaction.py b/openmc/data/reaction.py index 8ba4787826..8f90d2de47 100644 --- a/openmc/data/reaction.py +++ b/openmc/data/reaction.py @@ -56,13 +56,13 @@ REACTION_NAME = {1: '(n,total)', 2: '(n,elastic)', 4: '(n,level)', 301: 'heating', 444: 'damage-energy', 649: '(n,pc)', 699: '(n,dc)', 749: '(n,tc)', 799: '(n,3Hec)', 849: '(n,ac)', 891: '(n,2nc)', 901: 'heating-local'} -REACTION_NAME.update({i: '(n,n{})'.format(i - 50) for i in range(51, 91)}) -REACTION_NAME.update({i: '(n,p{})'.format(i - 600) for i in range(600, 649)}) -REACTION_NAME.update({i: '(n,d{})'.format(i - 650) for i in range(650, 699)}) -REACTION_NAME.update({i: '(n,t{})'.format(i - 700) for i in range(700, 749)}) -REACTION_NAME.update({i: '(n,3He{})'.format(i - 750) for i in range(750, 799)}) -REACTION_NAME.update({i: '(n,a{})'.format(i - 800) for i in range(800, 849)}) -REACTION_NAME.update({i: '(n,2n{})'.format(i - 875) for i in range(875, 891)}) +REACTION_NAME.update({i: f'(n,n{i - 50})' for i in range(51, 91)}) +REACTION_NAME.update({i: f'(n,p{i - 600})' for i in range(600, 649)}) +REACTION_NAME.update({i: f'(n,d{i - 650})' for i in range(650, 699)}) +REACTION_NAME.update({i: f'(n,t{i - 700})' for i in range(700, 749)}) +REACTION_NAME.update({i: f'(n,3He{i - 750})' for i in range(750, 799)}) +REACTION_NAME.update({i: f'(n,a{i - 800})' for i in range(800, 849)}) +REACTION_NAME.update({i: f'(n,2n{i - 875})' for i in range(875, 891)}) REACTION_MT = {name: mt for mt, name in REACTION_NAME.items()} REACTION_MT['fission'] = 18 @@ -119,7 +119,7 @@ def _get_products(ev, mt): p = Product('electron') else: Z, A = divmod(za, 1000) - p = Product('{}{}'.format(ATOMIC_SYMBOL[Z], A)) + p = Product(f'{ATOMIC_SYMBOL[Z]}{A}') p.yield_ = yield_ @@ -557,9 +557,9 @@ def _get_activation_products(ev, rx): # Get GNDS name for product symbol = ATOMIC_SYMBOL[Z] if excited_state > 0: - name = '{}{}_e{}'.format(symbol, A, excited_state) + name = f'{symbol}{A}_e{excited_state}' else: - name = '{}{}'.format(symbol, A) + name = f'{symbol}{A}' p = Product(name) if mf == 9: @@ -656,8 +656,7 @@ def _get_photon_products_ace(ace, rx): photon.yield_ = Tabulated1D(energy, yield_) else: - raise ValueError("MFTYPE must be 12, 13, 16. Got {0}".format( - mftype)) + raise ValueError(f"MFTYPE must be 12, 13, 16. Got {mftype}") # ================================================================== # Photon energy distribution @@ -846,9 +845,9 @@ class Reaction(EqualityMixin): def __repr__(self): if self.mt in REACTION_NAME: - return "".format(self.mt, REACTION_NAME[self.mt]) + return f"" else: - return "".format(self.mt) + return f"" @property def center_of_mass(self): @@ -933,7 +932,7 @@ class Reaction(EqualityMixin): threshold_idx = getattr(self.xs[T], '_threshold_idx', 0) dset.attrs['threshold_idx'] = threshold_idx for i, p in enumerate(self.products): - pgroup = group.create_group('product_{}'.format(i)) + pgroup = group.create_group(f'product_{i}') p.to_hdf5(pgroup) @classmethod @@ -985,7 +984,7 @@ class Reaction(EqualityMixin): # Read reaction products for i in range(n_product): - pgroup = group['product_{}'.format(i)] + pgroup = group[f'product_{i}'] rx.products.append(Product.from_hdf5(pgroup)) return rx diff --git a/openmc/data/resonance.py b/openmc/data/resonance.py index 340d73a186..31e230df58 100644 --- a/openmc/data/resonance.py +++ b/openmc/data/resonance.py @@ -830,7 +830,7 @@ class RMatrixLimited(ResonanceRange): elif mt == 102: columns.append('captureWidth') else: - columns.append('width (MT={})'.format(mt)) + columns.append(f'width (MT={mt})') # Create Pandas dataframe with resonance parameters parameters = pd.DataFrame.from_records(records, columns=columns) @@ -896,7 +896,7 @@ class SpinGroup: self.parameters = parameters def __repr__(self): - return ''.format(self.spin, self.parity) + return f'' class Unresolved(ResonanceRange): diff --git a/openmc/data/thermal.py b/openmc/data/thermal.py index f8dd43ed9a..f5841d9c90 100644 --- a/openmc/data/thermal.py +++ b/openmc/data/thermal.py @@ -90,7 +90,7 @@ _THERMAL_NAMES = { def _temperature_str(T): # round() normally returns an int when called with a single argument, but # numpy floats overload rounding to return another float - return "{}K".format(int(round(T))) + return f"{int(round(T))}K" def get_thermal_name(name): @@ -439,7 +439,7 @@ class ThermalScattering(EqualityMixin): def __repr__(self): if hasattr(self, 'name'): - return "".format(self.name) + return f"" else: return "" @@ -506,7 +506,7 @@ class ThermalScattering(EqualityMixin): # Check if temprature already exists strT = data.temperatures[0] if strT in self.temperatures: - warn('S(a,b) data at T={} already exists.'.format(strT)) + warn(f'S(a,b) data at T={strT} already exists.') return # Check that name matches @@ -614,7 +614,7 @@ class ThermalScattering(EqualityMixin): # Get new name that is GND-consistent ace_name, xs = ace.name.split('.') if not xs.endswith('t'): - raise TypeError("{} is not a thermal scattering ACE table.".format(ace)) + raise TypeError(f"{ace} is not a thermal scattering ACE table.") if name is None: name = get_thermal_name(ace_name) diff --git a/openmc/deplete/abc.py b/openmc/deplete/abc.py index fa07ad12a6..49bb3e6c72 100644 --- a/openmc/deplete/abc.py +++ b/openmc/deplete/abc.py @@ -647,7 +647,7 @@ class Integrator(ABC): days = watt_days_per_kg * kilograms / rate seconds.append(days*_SECONDS_PER_DAY) else: - raise ValueError("Invalid timestep unit '{}'".format(unit)) + raise ValueError(f"Invalid timestep unit '{unit}'") self.timesteps = np.asarray(seconds) self.source_rates = np.asarray(source_rates) @@ -664,8 +664,7 @@ class Integrator(ABC): self._solver = CRAM16 else: raise ValueError( - "Solver {} not understood. Expected 'cram48' or " - "'cram16'".format(solver)) + f"Solver {solver} not understood. Expected 'cram48' or 'cram16'") else: self.solver = solver @@ -677,14 +676,13 @@ class Integrator(ABC): def solver(self, func): if not isinstance(func, Callable): raise TypeError( - "Solver must be callable, not {}".format(type(func))) + f"Solver must be callable, not {type(func)}") try: sig = signature(func) except ValueError: # Guard against callables that aren't introspectable, e.g. # fortran functions wrapped by F2PY - warn("Could not determine arguments to {}. Proceeding " - "anyways".format(func)) + warn(f"Could not determine arguments to {func}. Proceeding anyways") self._solver = func return @@ -696,8 +694,7 @@ class Integrator(ABC): for ix, param in enumerate(sig.parameters.values()): if param.kind in {param.KEYWORD_ONLY, param.VAR_KEYWORD}: raise ValueError( - "Keyword arguments like {} at position {} are not " - "allowed".format(ix, param)) + f"Keyword arguments like {ix} at position {param} are not allowed") self._solver = func diff --git a/openmc/deplete/chain.py b/openmc/deplete/chain.py index e15b127d08..1d38349805 100644 --- a/openmc/deplete/chain.py +++ b/openmc/deplete/chain.py @@ -141,7 +141,7 @@ def replace_missing(product, decay_data): # First check if ground state is available if state: - product = '{}{}'.format(symbol, A) + product = f'{symbol}{A}' # Find isotope with longest half-life half_life = 0.0 @@ -172,7 +172,7 @@ def replace_missing(product, decay_data): Z += 1 else: Z -= 1 - product = '{}{}'.format(openmc.data.ATOMIC_SYMBOL[Z], A) + product = f'{openmc.data.ATOMIC_SYMBOL[Z]}{A}' return product @@ -417,7 +417,7 @@ class Chain: if mts & reactions_available: A = data.nuclide['mass_number'] + delta_A Z = data.nuclide['atomic_number'] + delta_Z - daughter = '{}{}'.format(openmc.data.ATOMIC_SYMBOL[Z], A) + daughter = f'{openmc.data.ATOMIC_SYMBOL[Z]}{A}' if daughter not in decay_data: daughter = replace_missing(daughter, decay_data) @@ -483,7 +483,7 @@ class Chain: if missing_daughter: print('The following decay modes have daughters with no decay data:') for mode in missing_daughter: - print(' {}'.format(mode)) + print(f' {mode}') print('') if missing_rx_product: @@ -495,7 +495,7 @@ class Chain: if missing_fpy: print('The following fissionable nuclides have no fission product yields:') for parent, replacement in missing_fpy: - print(' {}, replaced with {}'.format(parent, replacement)) + print(f' {parent}, replaced with {replacement}') print('') if missing_fp: @@ -873,8 +873,7 @@ class Chain: if len(indexes) == 0: if strict: raise AttributeError( - "Nuclide {} does not have {} reactions".format( - parent, reaction)) + f"Nuclide {parent} does not have {reaction} reactions") missing_reaction.add(parent) continue @@ -896,8 +895,7 @@ class Chain: if len(rxn_ix_map) == 0: raise IndexError( - "No {} reactions found in this {}".format( - reaction, self.__class__.__name__)) + f"No {reaction} reactions found in this {self.__class__.__name__}") if len(missing_parents) > 0: warn("The following nuclides were not found in {}: {}".format( @@ -908,14 +906,14 @@ class Chain: "{}".format(reaction, ", ".join(sorted(missing_reaction)))) if len(missing_products) > 0: - tail = ("{} -> {}".format(k, v) + tail = (f"{k} -> {v}" for k, v in sorted(missing_products.items())) warn("The following products were not found in the {} and " "parents were unmodified: \n{}".format( self.__class__.__name__, ", ".join(tail))) if len(bad_sums) > 0: - tail = ("{}: {:5.3f}".format(k, s) + tail = (f"{k}: {s:5.3f}" for k, s in sorted(bad_sums.items())) warn("The following parent nuclides were given {} branch ratios " "with a sum outside tolerance of 1 +/- {:5.3e}:\n{}".format( diff --git a/openmc/deplete/coupled_operator.py b/openmc/deplete/coupled_operator.py index 8d43e2b2ce..acdcf467c5 100644 --- a/openmc/deplete/coupled_operator.py +++ b/openmc/deplete/coupled_operator.py @@ -518,7 +518,7 @@ class CoupledOperator(OpenMCOperator): """ openmc.lib.statepoint_write( - "openmc_simulation_n{}.h5".format(step), + f"openmc_simulation_n{step}.h5", write_source=False) def finalize(self): diff --git a/openmc/deplete/nuclide.py b/openmc/deplete/nuclide.py index d055c2e375..60e3e5317f 100644 --- a/openmc/deplete/nuclide.py +++ b/openmc/deplete/nuclide.py @@ -275,7 +275,7 @@ class Nuclide: if parent is not None: assert root is not None fpy_elem = root.find( - './/nuclide[@name="{}"]/neutron_fission_yields'.format(parent) + f'.//nuclide[@name="{parent}"]/neutron_fission_yields' ) if fpy_elem is None: raise ValueError( @@ -413,7 +413,7 @@ class Nuclide: continue msg = msg_func( name=self.name, actual=sum_br, expected=1.0, tol=tolerance, - prop="{} reaction branch ratios".format(rxn_type)) + prop=f"{rxn_type} reaction branch ratios") if strict: raise ValueError(msg) elif quiet: @@ -430,7 +430,7 @@ class Nuclide: msg = msg_func( name=self.name, actual=sum_yield, expected=2.0, tol=tolerance, - prop="fission yields (E = {:7.4e} eV)".format(energy)) + prop=f"fission yields (E = {energy:7.4e} eV)") if strict: raise ValueError(msg) elif quiet: @@ -695,8 +695,7 @@ class FissionYield(Mapping): return self * scalar def __repr__(self): - return "<{} containing {} products and yields>".format( - self.__class__.__name__, len(self)) + return f"<{self.__class__.__name__} containing {len(self)} products and yields>" def __deepcopy__(self, memo): result = FissionYield(self.products, self.yields.copy()) diff --git a/openmc/lattice.py b/openmc/lattice.py index d281f6e769..40b97f6cf5 100644 --- a/openmc/lattice.py +++ b/openmc/lattice.py @@ -1851,7 +1851,7 @@ class HexLattice(Lattice): largest_index = 6*(num_rings - 1) n_digits_index = len(str(largest_index)) n_digits_ring = len(str(num_rings - 1)) - str_form = '({{:{}}},{{:{}}})'.format(n_digits_ring, n_digits_index) + str_form = f'({{:{n_digits_ring}}},{{:{n_digits_index}}})' pad = ' '*(n_digits_index + n_digits_ring + 3) # Initialize the list for each row. @@ -1956,7 +1956,7 @@ class HexLattice(Lattice): largest_index = 6*(num_rings - 1) n_digits_index = len(str(largest_index)) n_digits_ring = len(str(num_rings - 1)) - str_form = '({{:{}}},{{:{}}})'.format(n_digits_ring, n_digits_index) + str_form = f'({{:{n_digits_ring}}},{{:{n_digits_index}}})' pad = ' '*(n_digits_index + n_digits_ring + 3) # Initialize the list for each row. diff --git a/openmc/lib/__init__.py b/openmc/lib/__init__.py index dc5bd7d9b4..42d7794414 100644 --- a/openmc/lib/__init__.py +++ b/openmc/lib/__init__.py @@ -28,7 +28,7 @@ else: if os.environ.get('READTHEDOCS', None) != 'True': # Open shared library _filename = pkg_resources.resource_filename( - __name__, 'libopenmc.{}'.format(_suffix)) + __name__, f'libopenmc.{_suffix}') _dll = CDLL(_filename) else: # For documentation builds, we don't actually have the shared library diff --git a/openmc/lib/cell.py b/openmc/lib/cell.py index 807fe73c77..971a24cba9 100644 --- a/openmc/lib/cell.py +++ b/openmc/lib/cell.py @@ -268,7 +268,7 @@ class Cell(_FortranObjectWithID): return rotation_data[9:] else: raise ValueError( - 'Invalid size of rotation matrix: {}'.format(rot_size)) + f'Invalid size of rotation matrix: {rot_size}') @rotation.setter def rotation(self, rotation_data): diff --git a/openmc/lib/core.py b/openmc/lib/core.py index d8e0bfdb5f..a9a549fa05 100644 --- a/openmc/lib/core.py +++ b/openmc/lib/core.py @@ -629,7 +629,7 @@ class _DLLGlobal: class _FortranObject: def __repr__(self): - return "<{}(index={})>".format(type(self).__name__, self._index) + return f"<{type(self).__name__}(index={self._index})>" class _FortranObjectWithID(_FortranObject): @@ -641,7 +641,7 @@ class _FortranObjectWithID(_FortranObject): self.id def __repr__(self): - return "<{}(id={})>".format(type(self).__name__, self.id) + return f"<{type(self).__name__}(id={self.id})>" @contextmanager diff --git a/openmc/lib/error.py b/openmc/lib/error.py index 89e7b6e390..dbe08e1ef8 100644 --- a/openmc/lib/error.py +++ b/openmc/lib/error.py @@ -37,5 +37,5 @@ def _error_handler(err, func, args): warn(msg) elif err < 0: if not msg: - msg = "Unknown error encountered (code {}).".format(err) + msg = f"Unknown error encountered (code {err})." raise exc.OpenMCError(msg) diff --git a/openmc/lib/plot.py b/openmc/lib/plot.py index 9f294a42c0..d986366764 100644 --- a/openmc/lib/plot.py +++ b/openmc/lib/plot.py @@ -31,7 +31,7 @@ class _Position(Structure): elif idx == 2: return self.z else: - raise IndexError("{} index is invalid for _Position".format(idx)) + raise IndexError(f"{idx} index is invalid for _Position") def __setitem__(self, idx, val): if idx == 0: @@ -41,10 +41,10 @@ class _Position(Structure): elif idx == 2: self.z = val else: - raise IndexError("{} index is invalid for _Position".format(idx)) + raise IndexError(f"{idx} index is invalid for _Position") def __repr__(self): - return "({}, {}, {})".format(self.x, self.y, self.z) + return f"({self.x}, {self.y}, {self.z})" class _PlotBase(Structure): @@ -127,7 +127,7 @@ class _PlotBase(Structure): elif self.basis_ == 3: return 'yz' - raise ValueError("Plot basis {} is invalid".format(self.basis_)) + raise ValueError(f"Plot basis {self.basis_} is invalid") @basis.setter def basis(self, basis): @@ -135,7 +135,7 @@ class _PlotBase(Structure): valid_bases = ('xy', 'xz', 'yz') basis = basis.lower() if basis not in valid_bases: - raise ValueError("{} is not a valid plot basis.".format(basis)) + raise ValueError(f"{basis} is not a valid plot basis.") if basis == 'xy': self.basis_ = 1 @@ -148,12 +148,11 @@ class _PlotBase(Structure): if isinstance(basis, int): valid_bases = (1, 2, 3) if basis not in valid_bases: - raise ValueError("{} is not a valid plot basis.".format(basis)) + raise ValueError(f"{basis} is not a valid plot basis.") self.basis_ = basis return - raise ValueError("{} of type {} is an" - " invalid plot basis".format(basis, type(basis))) + raise ValueError(f"{basis} of type {type(basis)} is an invalid plot basis") @property def h_res(self): @@ -199,14 +198,14 @@ class _PlotBase(Structure): out_str = ["-----", "Plot:", "-----", - "Origin: {}".format(self.origin), - "Width: {}".format(self.width), - "Height: {}".format(self.height), - "Basis: {}".format(self.basis), - "HRes: {}".format(self.h_res), - "VRes: {}".format(self.v_res), - "Color Overlaps: {}".format(self.color_overlaps), - "Level: {}".format(self.level)] + f"Origin: {self.origin}", + f"Width: {self.width}", + f"Height: {self.height}", + f"Basis: {self.basis}", + f"HRes: {self.h_res}", + f"VRes: {self.v_res}", + f"Color Overlaps: {self.color_overlaps}", + f"Level: {self.level}"] return '\n'.join(out_str) diff --git a/openmc/lib/settings.py b/openmc/lib/settings.py index 6a7e5aa124..062670ef84 100644 --- a/openmc/lib/settings.py +++ b/openmc/lib/settings.py @@ -53,7 +53,7 @@ class _Settings: current_idx.value = idx break else: - raise ValueError('Invalid run mode: {}'.format(mode)) + raise ValueError(f'Invalid run mode: {mode}') @property def path_statepoint(self): diff --git a/openmc/material.py b/openmc/material.py index c31862a18a..6edc372161 100644 --- a/openmc/material.py +++ b/openmc/material.py @@ -152,7 +152,7 @@ class Material(IDManagerMixin): for nuclide, percent, percent_type in self._nuclides: string += '{: <16}'.format('\t{}'.format(nuclide)) - string += '=\t{: <12} [{}]\n'.format(percent, percent_type) + string += f'=\t{percent: <12} [{percent_type}]\n' if self._macroscopic is not None: string += '{: <16}\n'.format('\tMacroscopic Data') @@ -469,8 +469,7 @@ class Material(IDManagerMixin): raise ValueError('No volume information found for material ID={}.' .format(self.id)) else: - raise ValueError('No volume information found for material ID={}.' - .format(self.id)) + raise ValueError(f'No volume information found for material ID={self.id}.') def set_density(self, units: str, density: Optional[float] = None): """Set the density of the material @@ -500,7 +499,7 @@ class Material(IDManagerMixin): '"sum" unit'.format(self.id) raise ValueError(msg) - cv.check_type('the density for Material ID="{}"'.format(self.id), + cv.check_type(f'the density for Material ID="{self.id}"', density, Real) self._density = density @@ -743,20 +742,18 @@ class Material(IDManagerMixin): el = element.lower() element = openmc.data.ELEMENT_SYMBOL.get(el) if element is None: - msg = 'Element name "{}" not recognised'.format(el) + msg = f'Element name "{el}" not recognised' raise ValueError(msg) else: if element[0].islower(): - msg = 'Element name "{}" should start with an uppercase ' \ - 'letter'.format(element) + msg = f'Element name "{element}" should start with an uppercase letter' raise ValueError(msg) if len(element) == 2 and element[1].isupper(): - msg = 'Element name "{}" should end with a lowercase ' \ - 'letter'.format(element) + msg = f'Element name "{element}" should end with a lowercase letter' raise ValueError(msg) # skips the first entry of ATOMIC_SYMBOL which is n for neutron if element not in list(openmc.data.ATOMIC_SYMBOL.values())[1:]: - msg = 'Element name "{}" not recognised'.format(element) + msg = f'Element name "{element}" not recognised' raise ValueError(msg) if self._macroscopic is not None: @@ -847,8 +844,7 @@ class Material(IDManagerMixin): for token in row: if token.isalpha(): if token == "n" or token not in openmc.data.ATOMIC_NUMBER: - msg = 'Formula entry {} not an element symbol.' \ - .format(token) + msg = f'Formula entry {token} not an element symbol.' raise ValueError(msg) elif token not in ['(', ')', ''] and not token.isdigit(): msg = 'Formula must be made from a sequence of ' \ @@ -1373,8 +1369,7 @@ class Material(IDManagerMixin): subelement.set("value", str(self._density)) subelement.set("units", self._density_units) else: - raise ValueError('Density has not been set for material {}!' - .format(self.id)) + raise ValueError(f'Density has not been set for material {self.id}!') if self._macroscopic is None: # Create nuclide XML subelements @@ -1480,7 +1475,7 @@ class Material(IDManagerMixin): # Create the new material with the desired name if name is None: - name = '-'.join(['{}({})'.format(m.name, f) for m, f in + name = '-'.join([f'{m.name}({f})' for m, f in zip(materials, fracs)]) new_mat = openmc.Material(name=name) diff --git a/openmc/mgxs/library.py b/openmc/mgxs/library.py index 2fa2e0f056..12a4630bdc 100644 --- a/openmc/mgxs/library.py +++ b/openmc/mgxs/library.py @@ -685,7 +685,7 @@ class Library: # Check that requested domain is included in library if mgxs_type not in self.mgxs_types: - msg = 'Unable to find MGXS type "{0}"'.format(mgxs_type) + msg = f'Unable to find MGXS type "{mgxs_type}"' raise ValueError(msg) return self.all_mgxs[domain_id][mgxs_type] @@ -901,7 +901,7 @@ class Library: if not os.path.exists(directory): os.makedirs(directory) - full_filename = os.path.join(directory, '{}.pkl'.format(filename)) + full_filename = os.path.join(directory, f'{filename}.pkl') full_filename = full_filename.replace(' ', '-') # Load and return pickled Library object diff --git a/openmc/mgxs/mdgxs.py b/openmc/mgxs/mdgxs.py index 45c559a220..b95a4fbc0e 100644 --- a/openmc/mgxs/mdgxs.py +++ b/openmc/mgxs/mdgxs.py @@ -612,7 +612,7 @@ class MDGXS(MGXS): string += '{0: <16}=\t{1}\n'.format('\tDomain ID', self.domain.id) # Generate the header for an individual XS - xs_header = '\tCross Sections [{0}]:'.format(self.get_units(xs_type)) + xs_header = f'\tCross Sections [{self.get_units(xs_type)}]:' # If cross section data has not been computed, only print string header if self.tallies is None: @@ -641,7 +641,7 @@ class MDGXS(MGXS): string += '{0: <16}=\t{1}\n'.format('\tNuclide', nuclide) # Add the cross section header - string += '{0: <16}\n'.format(xs_header) + string += f'{xs_header: <16}\n' for delayed_group in self.delayed_groups: @@ -875,7 +875,7 @@ class MDGXS(MGXS): # Sort the dataframe by domain type id (e.g., distribcell id) and # energy groups such that data is from fast to thermal if self.domain_type == 'mesh': - mesh_str = 'mesh {0}'.format(self.domain.id) + mesh_str = f'mesh {self.domain.id}' df.sort_values(by=[(mesh_str, 'x'), (mesh_str, 'y'), (mesh_str, 'z')] + columns, inplace=True) else: @@ -2496,7 +2496,7 @@ class MatrixMDGXS(MDGXS): string += '{0: <16}=\t{1}\n'.format('\tDomain ID', self.domain.id) # Generate the header for an individual XS - xs_header = '\tCross Sections [{0}]:'.format(self.get_units(xs_type)) + xs_header = f'\tCross Sections [{self.get_units(xs_type)}]:' # If cross section data has not been computed, only print string header if self.tallies is None: @@ -2532,7 +2532,7 @@ class MatrixMDGXS(MDGXS): string += '{: <16}=\t{}\n'.format('\tNuclide', nuclide) # Build header for cross section type - string += '{: <16}\n'.format(xs_header) + string += f'{xs_header: <16}\n' if self.delayed_groups is not None: diff --git a/openmc/mgxs/mgxs.py b/openmc/mgxs/mgxs.py index 2eb7f27564..588b6f64e2 100644 --- a/openmc/mgxs/mgxs.py +++ b/openmc/mgxs/mgxs.py @@ -1427,7 +1427,7 @@ class MGXS: filter_bins=subdomains) avg_xs.tallies[tally_type] = tally_avg - avg_xs._domain_type = 'sum({0})'.format(self.domain_type) + avg_xs._domain_type = f'sum({self.domain_type})' avg_xs.sparse = self.sparse return avg_xs @@ -1478,7 +1478,7 @@ class MGXS: # Clone this MGXS to initialize the homogenized version homogenized_mgxs = copy.deepcopy(self) homogenized_mgxs._derived = True - name = 'hom({}, '.format(self.domain.name) + name = f'hom({self.domain.name}, ' # Get the domain filter filter_type = _DOMAIN_TO_FILTER[self.domain_type] @@ -1505,7 +1505,7 @@ class MGXS: denom_tally += other_denom_tally # Update the name for the homogenzied MGXS - name += '{}, '.format(mgxs.domain.name) + name += f'{mgxs.domain.name}, ' # Set the properties of the homogenized MGXS homogenized_mgxs._rxn_rate_tally = rxn_rate_tally @@ -1745,7 +1745,7 @@ class MGXS: string += '{0: <16}=\t{1}\n'.format('\tDomain ID', self.domain.id) # Generate the header for an individual XS - xs_header = '\tCross Sections [{0}]:'.format(self.get_units(xs_type)) + xs_header = f'\tCross Sections [{self.get_units(xs_type)}]:' # If cross section data has not been computed, only print string header if self.tallies is None: @@ -1773,7 +1773,7 @@ class MGXS: string += '{0: <16}=\t{1}\n'.format('\tNuclide', nuclide) # Build header for cross section type - string += '{0: <16}\n'.format(xs_header) + string += f'{xs_header: <16}\n' template = '{0: <12}Group {1} [{2: <10} - {3: <10}eV]:\t' average_xs = self.get_xs(nuclides=[nuclide], @@ -2131,7 +2131,7 @@ class MGXS: # Sort the dataframe by domain type id (e.g., distribcell id) and # energy groups such that data is from fast to thermal if self.domain_type == 'mesh': - mesh_str = 'mesh {0}'.format(self.domain.id) + mesh_str = f'mesh {self.domain.id}' df.sort_values(by=[(mesh_str, 'x'), (mesh_str, 'y'), (mesh_str, 'z')] + columns, inplace=True) else: @@ -2472,7 +2472,7 @@ class MatrixMGXS(MGXS): string += '{0: <16}=\t{1}\n'.format('\tDomain ID', self.domain.id) # Generate the header for an individual XS - xs_header = '\tCross Sections [{0}]:'.format(self.get_units(xs_type)) + xs_header = f'\tCross Sections [{self.get_units(xs_type)}]:' # If cross section data has not been computed, only print string header if self.tallies is None: @@ -2508,7 +2508,7 @@ class MatrixMGXS(MGXS): string += '{0: <16}=\t{1}\n'.format('\tNuclide', nuclide) # Build header for cross section type - string += '{0: <16}\n'.format(xs_header) + string += f'{xs_header: <16}\n' template = '{0: <12}Group {1} -> Group {2}:\t\t' average_xs = self.get_xs(nuclides=[nuclide], @@ -4476,7 +4476,7 @@ class ScatterMatrixXS(MatrixMGXS): slice_xs.legendre_order = legendre_order # Slice the scattering tally - filter_bins = [tuple(['P{}'.format(i) + filter_bins = [tuple([f'P{i}' for i in range(self.legendre_order + 1)])] slice_xs.tallies[self.rxn_type] = \ slice_xs.tallies[self.rxn_type].get_slice( @@ -4613,7 +4613,7 @@ class ScatterMatrixXS(MatrixMGXS): cv.check_less_than( 'moment', moment, self.legendre_order, equality=True) filters.append(openmc.LegendreFilter) - filter_bins.append(('P{}'.format(moment),)) + filter_bins.append((f'P{moment}',)) num_angle_bins = 1 else: num_angle_bins = self.legendre_order + 1 @@ -4804,7 +4804,7 @@ class ScatterMatrixXS(MatrixMGXS): cv.check_value('xs_type', xs_type, ['macro', 'micro']) if self.correction != 'P0' and self.scatter_format == SCATTER_LEGENDRE: - rxn_type = '{0} (P{1})'.format(self.mgxs_type, moment) + rxn_type = f'{self.mgxs_type} (P{moment})' else: rxn_type = self.mgxs_type @@ -4815,7 +4815,7 @@ class ScatterMatrixXS(MatrixMGXS): string += '{0: <16}=\t{1}\n'.format('\tDomain ID', self.domain.id) # Generate the header for an individual XS - xs_header = '\tCross Sections [{0}]:'.format(self.get_units(xs_type)) + xs_header = f'\tCross Sections [{self.get_units(xs_type)}]:' # If cross section data has not been computed, only print string header if self.tallies is None: @@ -4851,7 +4851,7 @@ class ScatterMatrixXS(MatrixMGXS): string += '{0: <16}=\t{1}\n'.format('\tNuclide', nuclide) # Build header for cross section type - string += '{0: <16}\n'.format(xs_header) + string += f'{xs_header: <16}\n' average_xs = self.get_xs(nuclides=[nuclide], subdomains=[subdomain], @@ -4903,8 +4903,7 @@ class ScatterMatrixXS(MatrixMGXS): for azi in range(len(azi_bins) - 1): azi_low, azi_high = azi_bins[azi: azi + 2] string += \ - '\t\tPolar Angle: [{0:5f} - {1:5f}]'.format( - pol_low, pol_high) + \ + f'\t\tPolar Angle: [{pol_low:5f} - {pol_high:5f}]' + \ '\tAzimuthal Angle: [{0:5f} - {1:5f}]'.format( azi_low, azi_high) + '\n' string += print_groups_and_histogram( @@ -6226,7 +6225,7 @@ class MeshSurfaceMGXS(MGXS): if 'group out' in df: df = df[df['group out'].isin(groups)] - mesh_str = 'mesh {0}'.format(self.domain.id) + mesh_str = f'mesh {self.domain.id}' col_key = (mesh_str, 'surf') surfaces = df.pop(col_key) df.insert(len(self.domain.dimension), col_key, surfaces) diff --git a/openmc/model/funcs.py b/openmc/model/funcs.py index 815d038294..41aa920eae 100644 --- a/openmc/model/funcs.py +++ b/openmc/model/funcs.py @@ -221,8 +221,7 @@ def pin(surfaces, items, subdivisions=None, divide_vols=True, center_getter = attrgetter("z0", "y0") else: raise TypeError( - "Not configured to interpret {} surfaces".format( - surf_type.__name__)) + f"Not configured to interpret {surf_type.__name__} surfaces") centers = set() prev_rad = 0 diff --git a/openmc/model/surface_composite.py b/openmc/model/surface_composite.py index 5543a88e82..6f9123311f 100644 --- a/openmc/model/surface_composite.py +++ b/openmc/model/surface_composite.py @@ -46,7 +46,7 @@ class CompositeSurface(ABC): getattr(self, name).boundary_type = boundary_type def __repr__(self): - return "<{} at 0x{:x}>".format(type(self).__name__, id(self)) + return f"<{type(self).__name__} at 0x{id(self):x}>" @property @abstractmethod diff --git a/openmc/plots.py b/openmc/plots.py index 5552b18a85..df4a766331 100644 --- a/openmc/plots.py +++ b/openmc/plots.py @@ -634,8 +634,7 @@ class Plot(PlotBase): raise ValueError(msg) elif meshlines['type'] not in ['tally', 'entropy', 'ufs', 'cmfd']: - msg = 'Unable to set the meshlines with ' \ - 'type "{}"'.format(meshlines['type']) + msg = f"Unable to set the meshlines with type \"{meshlines['type']}\"" raise ValueError(msg) if 'id' in meshlines: diff --git a/openmc/stats/univariate.py b/openmc/stats/univariate.py index 3d5f647ace..a0e86c3f8f 100644 --- a/openmc/stats/univariate.py +++ b/openmc/stats/univariate.py @@ -383,7 +383,7 @@ class Uniform(Univariate): """ element = ET.Element(element_name) element.set("type", "uniform") - element.set("parameters", '{} {}'.format(self.a, self.b)) + element.set("parameters", f'{self.a} {self.b}') return element @classmethod @@ -672,7 +672,7 @@ class Watt(Univariate): """ element = ET.Element(element_name) element.set("type", "watt") - element.set("parameters", '{} {}'.format(self.a, self.b)) + element.set("parameters", f'{self.a} {self.b}') return element @classmethod @@ -762,7 +762,7 @@ class Normal(Univariate): """ element = ET.Element(element_name) element.set("type", "normal") - element.set("parameters", '{} {}'.format(self.mean_value, self.std_dev)) + element.set("parameters", f'{self.mean_value} {self.std_dev}') return element @classmethod diff --git a/openmc/surface.py b/openmc/surface.py index bc10f7e8a4..806331024e 100644 --- a/openmc/surface.py +++ b/openmc/surface.py @@ -187,8 +187,7 @@ class Surface(IDManagerMixin, ABC): coefficients = '{0: <20}'.format('\tCoefficients') + '\n' for coeff in self._coefficients: - coefficients += '{0: <20}{1}{2}\n'.format( - coeff, '=\t', self._coefficients[coeff]) + coefficients += f'{coeff: <20}=\t{self._coefficients[coeff]}\n' string += coefficients diff --git a/openmc/tallies.py b/openmc/tallies.py index ddc5d68ee6..04ae00b6a3 100644 --- a/openmc/tallies.py +++ b/openmc/tallies.py @@ -2149,7 +2149,7 @@ class Tally(IDManagerMixin): new_tally.sparse = self.sparse else: - msg = 'Unable to add "{}" to Tally ID="{}"'.format(other, self.id) + msg = f'Unable to add "{other}" to Tally ID="{self.id}"' raise ValueError(msg) return new_tally @@ -2220,7 +2220,7 @@ class Tally(IDManagerMixin): new_tally.sparse = self.sparse else: - msg = 'Unable to subtract "{}" from Tally ID="{}"'.format(other, self.id) + msg = f'Unable to subtract "{other}" from Tally ID="{self.id}"' raise ValueError(msg) return new_tally @@ -2291,7 +2291,7 @@ class Tally(IDManagerMixin): new_tally.sparse = self.sparse else: - msg = 'Unable to multiply Tally ID="{}" by "{}"'.format(self.id, other) + msg = f'Unable to multiply Tally ID="{self.id}" by "{other}"' raise ValueError(msg) return new_tally @@ -2362,7 +2362,7 @@ class Tally(IDManagerMixin): new_tally.sparse = self.sparse else: - msg = 'Unable to divide Tally ID="{}" by "{}"'.format(self.id, other) + msg = f'Unable to divide Tally ID="{self.id}" by "{other}"' raise ValueError(msg) return new_tally @@ -2437,7 +2437,7 @@ class Tally(IDManagerMixin): new_tally.sparse = self.sparse else: - msg = 'Unable to raise Tally ID="{}" to power "{}"'.format(self.id, power) + msg = f'Unable to raise Tally ID="{self.id}" to power "{power}"' raise ValueError(msg) return new_tally @@ -3105,8 +3105,7 @@ class Tallies(cv.CheckedList): """ if not isinstance(tally, Tally): - msg = 'Unable to add a non-Tally "{}" to the ' \ - 'Tallies instance'.format(tally) + msg = f'Unable to add a non-Tally "{tally}" to the Tallies instance' raise TypeError(msg) if merge: From d1366c05450a6bb65ec3566012f2b7a221d94b55 Mon Sep 17 00:00:00 2001 From: Gavin Ridley Date: Thu, 2 May 2024 06:52:39 -0500 Subject: [PATCH 37/48] Update random_dist.h comment to be less specific (#2991) --- include/openmc/random_dist.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/include/openmc/random_dist.h b/include/openmc/random_dist.h index 4c9ab3c677..0fb186edca 100644 --- a/include/openmc/random_dist.h +++ b/include/openmc/random_dist.h @@ -48,9 +48,9 @@ extern "C" double maxwell_spectrum(double T, uint64_t* seed); extern "C" double watt_spectrum(double a, double b, uint64_t* seed); //============================================================================== -//! Samples an energy from the Gaussian energy-dependent fission distribution. +//! Samples an energy from the Gaussian distribution. //! -//! Samples from a Normal distribution with a given mean and standard deviation +//! Samples from a normal distribution with a given mean and standard deviation //! The PDF is defined as s(x) = (1/2*sigma*sqrt(2) * e-((mu-x)/2*sigma)^2 //! Its sampled according to //! http://www-pdg.lbl.gov/2009/reviews/rpp2009-rev-monte-carlo-techniques.pdf From c976653abfba28ce4ac3c464d3e1d22b685d93d1 Mon Sep 17 00:00:00 2001 From: Travis L Date: Thu, 2 May 2024 10:00:22 -0400 Subject: [PATCH 38/48] Allow zero bins in tally triggers (#2928) Co-authored-by: Paul Romano --- docs/source/io_formats/tallies.rst | 12 +++++++++ include/openmc/tallies/trigger.h | 1 + openmc/trigger.py | 28 +++++++++++++++++++-- src/tallies/tally.cpp | 10 ++++++-- src/tallies/trigger.cpp | 6 ++--- tests/unit_tests/test_triggers.py | 40 ++++++++++++++++++++++++++++++ 6 files changed, 90 insertions(+), 7 deletions(-) diff --git a/docs/source/io_formats/tallies.rst b/docs/source/io_formats/tallies.rst index c28db988b9..78101ab668 100644 --- a/docs/source/io_formats/tallies.rst +++ b/docs/source/io_formats/tallies.rst @@ -100,6 +100,18 @@ The ```` element accepts the following sub-elements: *Default*: None + :ignore_zeros: + Whether to allow zero tally bins to be ignored when assessing the + convergece of the precision trigger. If True, only nonzero tally scores + will be compared to the trigger's threshold. + + .. note:: The ``ignore_zeros`` option can cause the tally trigger to fire + prematurely if there are no hits in any bins at the first + evalulation. It is the user's responsibility to specify enough + particles per batch to get a nonzero score in at least one bin. + + *Default*: False + :scores: The score(s) in this tally to which the trigger should be applied. diff --git a/include/openmc/tallies/trigger.h b/include/openmc/tallies/trigger.h index 9fe159b9d9..7feed5e8ad 100644 --- a/include/openmc/tallies/trigger.h +++ b/include/openmc/tallies/trigger.h @@ -23,6 +23,7 @@ enum class TriggerMetric { struct Trigger { TriggerMetric metric; //!< The type of uncertainty (e.g. std dev) measured double threshold; //!< Uncertainty value below which trigger is satisfied + bool ignore_zeros; //!< Whether to allow zero tally bins to be ignored int score_index; //!< Index of the relevant score in the tally's arrays }; diff --git a/openmc/trigger.py b/openmc/trigger.py index c7d9e9240b..79f5ea5af8 100644 --- a/openmc/trigger.py +++ b/openmc/trigger.py @@ -17,6 +17,12 @@ class Trigger(EqualityMixin): relative error of scores. threshold : float The threshold for the trigger type. + ignore_zeros : bool + Whether to allow zero tally bins to be ignored. Note that this option + can cause the trigger to fire prematurely if there are zero scores in + any bin at the first evaluation. + + .. versionadded:: 0.14.1 Attributes ---------- @@ -27,18 +33,22 @@ class Trigger(EqualityMixin): The threshold for the trigger type. scores : list of str Scores which should be checked against the trigger + ignore_zeros : bool + Whether to allow zero tally bins to be ignored. """ - def __init__(self, trigger_type: str, threshold: float): + def __init__(self, trigger_type: str, threshold: float, ignore_zeros: bool = False): self.trigger_type = trigger_type self.threshold = threshold + self.ignore_zeros = ignore_zeros self._scores = [] def __repr__(self): string = 'Trigger\n' string += '{: <16}=\t{}\n'.format('\tType', self._trigger_type) string += '{: <16}=\t{}\n'.format('\tThreshold', self._threshold) + string += '{: <16}=\t{}\n'.format('\tIgnore Zeros', self._ignore_zeros) string += '{: <16}=\t{}\n'.format('\tScores', self._scores) return string @@ -61,6 +71,15 @@ class Trigger(EqualityMixin): cv.check_type('tally trigger threshold', threshold, Real) self._threshold = threshold + @property + def ignore_zeros(self): + return self._ignore_zeros + + @ignore_zeros.setter + def ignore_zeros(self, ignore_zeros): + cv.check_type('tally trigger ignores zeros', ignore_zeros, bool) + self._ignore_zeros = ignore_zeros + @property def scores(self): return self._scores @@ -88,6 +107,8 @@ class Trigger(EqualityMixin): element = ET.Element("trigger") element.set("type", self._trigger_type) element.set("threshold", str(self._threshold)) + if self._ignore_zeros: + element.set("ignore_zeros", "true") if len(self._scores) != 0: element.set("scores", ' '.join(self._scores)) return element @@ -110,7 +131,10 @@ class Trigger(EqualityMixin): # Generate trigger object trigger_type = elem.get("type") threshold = float(elem.get("threshold")) - trigger = cls(trigger_type, threshold) + ignore_zeros = str(elem.get("ignore_zeros", "false")).lower() + # Try to convert to bool. Let Trigger error out on instantiation. + ignore_zeros = ignore_zeros in ('true', '1') + trigger = cls(trigger_type, threshold, ignore_zeros) # Add scores if present scores = elem.get("scores") diff --git a/src/tallies/tally.cpp b/src/tallies/tally.cpp index 2e77bcad25..674987b8f1 100644 --- a/src/tallies/tally.cpp +++ b/src/tallies/tally.cpp @@ -690,6 +690,12 @@ void Tally::init_triggers(pugi::xml_node node) "Must specify trigger threshold for tally {} in tally XML file", id_)); } + // Read whether to allow zero-tally bins to be ignored. + bool ignore_zeros = false; + if (check_for_node(trigger_node, "ignore_zeros")) { + ignore_zeros = get_node_value_bool(trigger_node, "ignore_zeros"); + } + // Read the trigger scores. vector trigger_scores; if (check_for_node(trigger_node, "scores")) { @@ -703,7 +709,7 @@ void Tally::init_triggers(pugi::xml_node node) if (score_str == "all") { triggers_.reserve(triggers_.size() + this->scores_.size()); for (auto i_score = 0; i_score < this->scores_.size(); ++i_score) { - triggers_.push_back({metric, threshold, i_score}); + triggers_.push_back({metric, threshold, ignore_zeros, i_score}); } } else { int i_score = 0; @@ -717,7 +723,7 @@ void Tally::init_triggers(pugi::xml_node node) "{} but it was listed in a trigger on that tally", score_str, id_)); } - triggers_.push_back({metric, threshold, i_score}); + triggers_.push_back({metric, threshold, ignore_zeros, i_score}); } } } diff --git a/src/tallies/trigger.cpp b/src/tallies/trigger.cpp index 87f298baa1..f1f83e2982 100644 --- a/src/tallies/trigger.cpp +++ b/src/tallies/trigger.cpp @@ -77,9 +77,9 @@ void check_tally_triggers(double& ratio, int& tally_id, int& score) auto uncert_pair = get_tally_uncertainty(i_tally, trigger.score_index, filter_index); - // if there is a score without contributions, set ratio to inf and - // exit early - if (uncert_pair.first == -1) { + // If there is a score without contributions, set ratio to inf and + // exit early, unless zero scores are ignored for this trigger. + if (uncert_pair.first == -1 && !trigger.ignore_zeros) { ratio = INFINITY; score = t.scores_[trigger.score_index]; tally_id = t.id_; diff --git a/tests/unit_tests/test_triggers.py b/tests/unit_tests/test_triggers.py index 4fe6e044ab..6b9e54eeb7 100644 --- a/tests/unit_tests/test_triggers.py +++ b/tests/unit_tests/test_triggers.py @@ -73,3 +73,43 @@ def test_tally_trigger_null_score(run_in_tmpdir): total_batches = sp.n_realizations + sp.n_inactive assert total_batches == pincell.settings.trigger_max_batches + +def test_tally_trigger_zero_ignored(run_in_tmpdir): + pincell = openmc.examples.pwr_pin_cell() + + # create an energy filter below and around the O-16(n,p) threshold (1.02e7 eV) + e_filter = openmc.EnergyFilter([0.0, 1e7, 2e7]) + + # create a tally with triggers applied + tally = openmc.Tally() + tally.filters = [e_filter] + tally.scores = ['(n,p)'] + tally.nuclides = ["O16"] + + # 100% relative error: should be immediately satisfied in nonzero bin + trigger = openmc.Trigger('rel_err', 1.0) + trigger.scores = ['(n,p)'] + trigger.ignore_zeros = True + + tally.triggers = [trigger] + + pincell.tallies = [tally] + + pincell.settings.particles = 1000 # we need a few more particles for this + pincell.settings.trigger_active = True + pincell.settings.trigger_max_batches = 50 + pincell.settings.trigger_batch_interval = 20 + + sp_file = pincell.run() + + with openmc.StatePoint(sp_file) as sp: + # verify that the first bin is zero and the second is nonzero + tally_out = sp.get_tally(id=tally.id) + below, above = tally_out.mean.squeeze() + assert below == 0.0, "Tally events observed below expected threshold" + assert above > 0, "No tally events observed. Test with more particles." + + # we expect that the trigger fires before max batches are hit + total_batches = sp.n_realizations + sp.n_inactive + assert total_batches < pincell.settings.trigger_max_batches + From 6e57f1dc7265ad7761886115408e2ec53afc165e Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Thu, 2 May 2024 12:47:10 -0500 Subject: [PATCH 39/48] Compute homogenized materials over mesh elements (#2971) --- openmc/deplete/abc.py | 22 +----- openmc/deplete/independent_operator.py | 1 - openmc/deplete/microxs.py | 59 ++++++++--------- openmc/mesh.py | 92 +++++++++++++++++++++++++- openmc/model/model.py | 22 ++---- openmc/utility_funcs.py | 38 +++++++++++ tests/unit_tests/test_mesh.py | 48 ++++++++++++++ 7 files changed, 212 insertions(+), 70 deletions(-) create mode 100644 openmc/utility_funcs.py diff --git a/openmc/deplete/abc.py b/openmc/deplete/abc.py index 49bb3e6c72..5d9b8a2403 100644 --- a/openmc/deplete/abc.py +++ b/openmc/deplete/abc.py @@ -10,8 +10,6 @@ from collections.abc import Iterable, Callable from copy import deepcopy from inspect import signature from numbers import Real, Integral -from contextlib import contextmanager -import os from pathlib import Path import time from typing import Optional, Union, Sequence @@ -22,6 +20,7 @@ from uncertainties import ufloat from openmc.checkvalue import check_type, check_greater_than, PathLike from openmc.mpi import comm +from openmc.utility_funcs import change_directory from openmc import Material from .stepresult import StepResult from .chain import Chain @@ -61,25 +60,6 @@ except AttributeError: # Can't set __doc__ on properties on Python 3.4 pass -@contextmanager -def change_directory(output_dir): - """ - Helper function for managing the current directory. - - Parameters - ---------- - output_dir : pathlib.Path - Directory to switch to. - """ - orig_dir = os.getcwd() - output_dir = Path(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - try: - os.chdir(output_dir) - yield - finally: - os.chdir(orig_dir) - class TransportOperator(ABC): """Abstract class defining a transport operator diff --git a/openmc/deplete/independent_operator.py b/openmc/deplete/independent_operator.py index 2328ca7612..250b94deb4 100644 --- a/openmc/deplete/independent_operator.py +++ b/openmc/deplete/independent_operator.py @@ -130,7 +130,6 @@ class IndependentOperator(OpenMCOperator): # Validate micro-xs parameters check_type('materials', materials, openmc.Materials) check_type('micros', micros, Iterable, MicroXS) - check_type('fluxes', fluxes, Iterable, float) if not (len(fluxes) == len(micros) == len(materials)): msg = (f'The length of fluxes ({len(fluxes)}) should be equal to ' diff --git a/openmc/deplete/microxs.py b/openmc/deplete/microxs.py index d80c52baa9..497a5284a1 100644 --- a/openmc/deplete/microxs.py +++ b/openmc/deplete/microxs.py @@ -13,11 +13,11 @@ import numpy as np from openmc.checkvalue import check_type, check_value, check_iterable_type, PathLike from openmc.exceptions import DataError +from openmc.utility_funcs import change_directory from openmc import StatePoint from openmc.mgxs import GROUP_STRUCTURES from openmc.data import REACTION_MT import openmc -from .abc import change_directory from .chain import Chain, REACTIONS from .coupled_operator import _find_cross_sections, _get_nuclides_with_data import openmc.lib @@ -284,38 +284,37 @@ class MicroXS: # Create 2D array for microscopic cross sections microxs_arr = np.zeros((len(nuclides), len(mts))) - with TemporaryDirectory() as tmpdir: - # Create a material with all nuclides - mat_all_nucs = openmc.Material() - for nuc in nuclides: - if nuc in nuclides_with_data: - mat_all_nucs.add_nuclide(nuc, 1.0) - mat_all_nucs.set_density("atom/b-cm", 1.0) + # Create a material with all nuclides + mat_all_nucs = openmc.Material() + for nuc in nuclides: + if nuc in nuclides_with_data: + mat_all_nucs.add_nuclide(nuc, 1.0) + mat_all_nucs.set_density("atom/b-cm", 1.0) - # Create simple model containing the above material - surf1 = openmc.Sphere(boundary_type="vacuum") - surf1_cell = openmc.Cell(fill=mat_all_nucs, region=-surf1) - model = openmc.Model() - model.geometry = openmc.Geometry([surf1_cell]) - model.settings = openmc.Settings( - particles=1, batches=1, output={'summary': False}) + # Create simple model containing the above material + surf1 = openmc.Sphere(boundary_type="vacuum") + surf1_cell = openmc.Cell(fill=mat_all_nucs, region=-surf1) + model = openmc.Model() + model.geometry = openmc.Geometry([surf1_cell]) + model.settings = openmc.Settings( + particles=1, batches=1, output={'summary': False}) - with change_directory(tmpdir): - # Export model within temporary directory - model.export_to_model_xml() + with change_directory(tmpdir=True): + # Export model within temporary directory + model.export_to_model_xml() - with openmc.lib.run_in_memory(**init_kwargs): - # For each nuclide and reaction, compute the flux-averaged - # cross section - for nuc_index, nuc in enumerate(nuclides): - if nuc not in nuclides_with_data: - continue - lib_nuc = openmc.lib.nuclides[nuc] - for mt_index, mt in enumerate(mts): - xs = lib_nuc.collapse_rate( - mt, temperature, energies, multigroup_flux - ) - microxs_arr[nuc_index, mt_index] = xs + with openmc.lib.run_in_memory(**init_kwargs): + # For each nuclide and reaction, compute the flux-averaged + # cross section + for nuc_index, nuc in enumerate(nuclides): + if nuc not in nuclides_with_data: + continue + lib_nuc = openmc.lib.nuclides[nuc] + for mt_index, mt in enumerate(mts): + xs = lib_nuc.collapse_rate( + mt, temperature, energies, multigroup_flux + ) + microxs_arr[nuc_index, mt_index] = xs return cls(microxs_arr, nuclides, reactions) diff --git a/openmc/mesh.py b/openmc/mesh.py index 1f36524d2d..8777da3255 100644 --- a/openmc/mesh.py +++ b/openmc/mesh.py @@ -7,7 +7,8 @@ from functools import wraps from math import pi, sqrt, atan2 from numbers import Integral, Real from pathlib import Path -from typing import Optional, Sequence, Tuple +import tempfile +from typing import Optional, Sequence, Tuple, List import h5py import lxml.etree as ET @@ -16,6 +17,7 @@ import numpy as np import openmc import openmc.checkvalue as cv from openmc.checkvalue import PathLike +from openmc.utility_funcs import change_directory from ._xml import get_text from .mixin import IDManagerMixin from .surface import _BOUNDARY_TYPES @@ -145,6 +147,94 @@ class MeshBase(IDManagerMixin, ABC): else: raise ValueError(f'Unrecognized mesh type "{mesh_type}" found.') + def get_homogenized_materials( + self, + model: openmc.Model, + n_samples: int = 10_000, + prn_seed: Optional[int] = None, + **kwargs + ) -> List[openmc.Material]: + """Generate homogenized materials over each element in a mesh. + + .. versionadded:: 0.14.1 + + Parameters + ---------- + model : openmc.Model + Model containing materials to be homogenized and the associated + geometry. + n_samples : int + Number of samples in each mesh element. + prn_seed : int, optional + Pseudorandom number generator (PRNG) seed; if None, one will be + generated randomly. + **kwargs + Keyword-arguments passed to :func:`openmc.lib.init`. + + Returns + ------- + list of openmc.Material + Homogenized material in each mesh element + + """ + import openmc.lib + + with change_directory(tmpdir=True): + # In order to get mesh into model, we temporarily replace the + # tallies with a single mesh tally using the current mesh + original_tallies = model.tallies + new_tally = openmc.Tally() + new_tally.filters = [openmc.MeshFilter(self)] + new_tally.scores = ['flux'] + model.tallies = [new_tally] + + # Export model to XML + model.export_to_model_xml() + + # Get material volume fractions + openmc.lib.init(**kwargs) + mesh = openmc.lib.tallies[new_tally.id].filters[0].mesh + mat_volume_by_element = [ + [ + (mat.id if mat is not None else None, volume) + for mat, volume in mat_volume_list + ] + for mat_volume_list in mesh.material_volumes(n_samples, prn_seed) + ] + openmc.lib.finalize() + + # Restore original tallies + model.tallies = original_tallies + + # Create homogenized material for each element + materials = model.geometry.get_all_materials() + homogenized_materials = [] + for mat_volume_list in mat_volume_by_element: + material_ids, volumes = [list(x) for x in zip(*mat_volume_list)] + total_volume = sum(volumes) + + # Check for void material and remove + try: + index_void = material_ids.index(None) + except ValueError: + pass + else: + material_ids.pop(index_void) + volumes.pop(index_void) + + # Compute volume fractions + volume_fracs = np.array(volumes) / total_volume + + # Get list of materials and mix 'em up! + mats = [materials[uid] for uid in material_ids] + homogenized_mat = openmc.Material.mix_materials( + mats, volume_fracs, 'vo' + ) + homogenized_mat.volume = total_volume + homogenized_materials.append(homogenized_mat) + + return homogenized_materials + class StructuredMesh(MeshBase): """A base class for structured mesh functionality diff --git a/openmc/model/model.py b/openmc/model/model.py index 2be6fcefa3..2160c97e6c 100644 --- a/openmc/model/model.py +++ b/openmc/model/model.py @@ -1,6 +1,5 @@ from __future__ import annotations from collections.abc import Iterable -from contextlib import contextmanager from functools import lru_cache import os from pathlib import Path @@ -18,18 +17,7 @@ from openmc.dummy_comm import DummyCommunicator from openmc.executor import _process_CLI_arguments from openmc.checkvalue import check_type, check_value, PathLike from openmc.exceptions import InvalidIDError - - -@contextmanager -def _change_directory(working_dir): - """A context manager for executing in a provided working directory""" - start_dir = Path.cwd() - Path.mkdir(working_dir, parents=True, exist_ok=True) - os.chdir(working_dir) - try: - yield - finally: - os.chdir(start_dir) +from openmc.utility_funcs import change_directory class Model: @@ -395,7 +383,7 @@ class Model: # Store whether or not the library was initialized when we started started_initialized = self.is_initialized - with _change_directory(Path(directory)): + with change_directory(directory): with openmc.lib.quiet_dll(output): # TODO: Support use of IndependentOperator too depletion_operator = dep.CoupledOperator(self, **op_kwargs) @@ -677,7 +665,7 @@ class Model: last_statepoint = None # Operate in the provided working directory - with _change_directory(Path(cwd)): + with change_directory(cwd): if self.is_initialized: # Handle the run options as applicable # First dont allow ones that must be set via init @@ -767,7 +755,7 @@ class Model: raise ValueError("The Settings.volume_calculations attribute must" " be specified before executing this method!") - with _change_directory(Path(cwd)): + with change_directory(cwd): if self.is_initialized: if threads is not None: msg = "Threads must be set via Model.is_initialized(...)" @@ -829,7 +817,7 @@ class Model: raise ValueError("The Model.plots attribute must be specified " "before executing this method!") - with _change_directory(Path(cwd)): + with change_directory(cwd): if self.is_initialized: # Compute the volumes openmc.lib.plot_geometry(output) diff --git a/openmc/utility_funcs.py b/openmc/utility_funcs.py new file mode 100644 index 0000000000..4eb307c930 --- /dev/null +++ b/openmc/utility_funcs.py @@ -0,0 +1,38 @@ +from contextlib import contextmanager +import os +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Optional + +from .checkvalue import PathLike + +@contextmanager +def change_directory(working_dir: Optional[PathLike] = None, *, tmpdir: bool = False): + """Context manager for executing in a provided working directory + + Parameters + ---------- + working_dir : path-like + Directory to switch to. + tmpdir : bool + Whether to use a temporary directory instead of a specific working directory + + """ + orig_dir = Path.cwd() + + # Set up temporary directory if requested + if tmpdir: + tmp = TemporaryDirectory() + working_dir = tmp.name + elif working_dir is None: + raise ValueError('Must pass working_dir argument or specify tmpdir=True.') + + working_dir = Path(working_dir) + working_dir.mkdir(parents=True, exist_ok=True) + os.chdir(working_dir) + try: + yield + finally: + os.chdir(orig_dir) + if tmpdir: + tmp.cleanup() diff --git a/tests/unit_tests/test_mesh.py b/tests/unit_tests/test_mesh.py index d50d2968e0..c499306506 100644 --- a/tests/unit_tests/test_mesh.py +++ b/tests/unit_tests/test_mesh.py @@ -299,6 +299,7 @@ def test_CylindricalMesh_get_indices_at_coords(): assert mesh.get_indices_at_coords([98, 199.9, 299]) == (0, 2, 0) # third angle quadrant assert mesh.get_indices_at_coords([102, 199.1, 299]) == (0, 3, 0) # forth angle quadrant + def test_umesh_roundtrip(run_in_tmpdir, request): umesh = openmc.UnstructuredMesh(request.path.parent / 'test_mesh_tets.e', 'moab') umesh.output = True @@ -317,3 +318,50 @@ def test_umesh_roundtrip(run_in_tmpdir, request): xml_mesh = xml_tally.filters[0].mesh assert umesh.id == xml_mesh.id + + +def test_mesh_get_homogenized_materials(): + """Test the get_homogenized_materials method""" + # Simple model with 1 cm of Fe56 next to 1 cm of H1 + fe = openmc.Material() + fe.add_nuclide('Fe56', 1.0) + fe.set_density('g/cm3', 5.0) + h = openmc.Material() + h.add_nuclide('H1', 1.0) + h.set_density('g/cm3', 1.0) + + x0 = openmc.XPlane(-1.0, boundary_type='vacuum') + x1 = openmc.XPlane(0.0) + x2 = openmc.XPlane(1.0) + x3 = openmc.XPlane(2.0, boundary_type='vacuum') + cell1 = openmc.Cell(fill=fe, region=+x0 & -x1) + cell2 = openmc.Cell(fill=h, region=+x1 & -x2) + cell_empty = openmc.Cell(region=+x2 & -x3) + model = openmc.Model(geometry=openmc.Geometry([cell1, cell2, cell_empty])) + model.settings.particles = 1000 + model.settings.batches = 10 + + mesh = openmc.RegularMesh() + mesh.lower_left = (-1., -1., -1.) + mesh.upper_right = (1., 1., 1.) + mesh.dimension = (3, 1, 1) + m1, m2, m3 = mesh.get_homogenized_materials(model, n_samples=1_000_000) + + # Left mesh element should be only Fe56 + assert m1.get_mass_density('Fe56') == pytest.approx(5.0) + + # Middle mesh element should be 50% Fe56 and 50% H1 + assert m2.get_mass_density('Fe56') == pytest.approx(2.5, rel=1e-2) + assert m2.get_mass_density('H1') == pytest.approx(0.5, rel=1e-2) + + # Right mesh element should be only H1 + assert m3.get_mass_density('H1') == pytest.approx(1.0) + + mesh_void = openmc.RegularMesh() + mesh_void.lower_left = (0.5, 0.5, -1.) + mesh_void.upper_right = (1.5, 1.5, 1.) + mesh_void.dimension = (1, 1, 1) + m4, = mesh_void.get_homogenized_materials(model, n_samples=1_000_000) + + # Mesh element that overlaps void should have half density + assert m4.get_mass_density('H1') == pytest.approx(0.5, rel=1e-2) From cfe210da22367ce5cf928482e7b74e48c909a7ee Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Fri, 3 May 2024 13:30:49 -0500 Subject: [PATCH 40/48] Allow MeshSource to take a 1D array of sources (#2980) --- openmc/source.py | 51 ++++++++++++++-------------- tests/unit_tests/test_source_mesh.py | 9 ++--- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/openmc/source.py b/openmc/source.py index a77ca8b04c..5deb6b05f5 100644 --- a/openmc/source.py +++ b/openmc/source.py @@ -399,20 +399,19 @@ class MeshSource(SourceBase): ---------- mesh : openmc.MeshBase The mesh over which source sites will be generated. - sources : iterable of openmc.SourceBase - Sources for each element in the mesh. If spatial distributions are set - on any of the source objects, they will be ignored during source site - sampling. + sources : sequence of openmc.SourceBase + Sources for each element in the mesh. Sources must be specified as + either a 1-D array in the order of the mesh indices or a + multidimensional array whose shape matches the mesh shape. If spatial + distributions are set on any of the source objects, they will be ignored + during source site sampling. Attributes ---------- mesh : openmc.MeshBase The mesh over which source sites will be generated. - sources : numpy.ndarray or iterable of openmc.SourceBase - The set of sources to apply to each element. The shape of this array - must match the shape of the mesh with and exception in the case of - unstructured mesh, which allows for application of 1-D array or - iterable. + sources : numpy.ndarray of openmc.SourceBase + Sources to apply to each element strength : float Strength of the source type : str @@ -433,7 +432,7 @@ class MeshSource(SourceBase): @property def strength(self) -> float: - return sum(s.strength for s in self.sources.flat) + return sum(s.strength for s in self.sources) @property def sources(self) -> np.ndarray: @@ -450,16 +449,23 @@ class MeshSource(SourceBase): s = np.asarray(s) - if isinstance(self.mesh, StructuredMesh) and s.shape != self.mesh.dimension: - raise ValueError('The shape of the source array' - f'({s.shape}) does not match the ' - f'dimensions of the structured mesh ({self.mesh.dimension})') + if isinstance(self.mesh, StructuredMesh): + if s.size != self.mesh.num_mesh_cells: + raise ValueError( + f'The length of the source array ({s.size}) does not match ' + f'the number of mesh elements ({self.mesh.num_mesh_cells}).') + + # If user gave a multidimensional array, flatten in the order + # of the mesh indices + if s.ndim > 1: + s = s.ravel(order='F') + elif isinstance(self.mesh, UnstructuredMesh): - if len(s.shape) > 1: + if s.ndim > 1: raise ValueError('Sources must be a 1-D array for unstructured mesh') self._sources = s - for src in self._sources.flat: + for src in self._sources: if isinstance(src, IndependentSource) and src.space is not None: warnings.warn('Some sources on the mesh have spatial ' 'distributions that will be ignored at runtime.') @@ -481,7 +487,7 @@ class MeshSource(SourceBase): """ current_strength = self.strength if self.strength != 0.0 else 1.0 - for s in self.sources.flat: + for s in self.sources: s.strength *= strength / current_strength def normalize_source_strengths(self): @@ -500,13 +506,8 @@ class MeshSource(SourceBase): elem.set("mesh", str(self.mesh.id)) # write in the order of mesh indices - if isinstance(self.mesh, openmc.UnstructuredMesh): - for s in self.sources: - elem.append(s.to_xml_element()) - else: - for idx in self.mesh.indices: - idx = tuple(i - 1 for i in idx) - elem.append(self.sources[idx].to_xml_element()) + for s in self.sources: + elem.append(s.to_xml_element()) @classmethod def from_xml_element(cls, elem: ET.Element, meshes) -> openmc.MeshSource: @@ -527,11 +528,9 @@ class MeshSource(SourceBase): MeshSource generated from the XML element """ mesh_id = int(get_text(elem, 'mesh')) - mesh = meshes[mesh_id] sources = [SourceBase.from_xml_element(e) for e in elem.iterchildren('source')] - sources = np.asarray(sources).reshape(mesh.dimension, order='F') return cls(mesh, sources) diff --git a/tests/unit_tests/test_source_mesh.py b/tests/unit_tests/test_source_mesh.py index f0813d2f9e..43bb1678c4 100644 --- a/tests/unit_tests/test_source_mesh.py +++ b/tests/unit_tests/test_source_mesh.py @@ -276,12 +276,12 @@ def test_mesh_source_independent(run_in_tmpdir, void_model, mesh_type): # for each element, set a single-non zero source with particles # traveling out of the mesh (and geometry) w/o crossing any other # mesh elements - for i, j, k in mesh.indices: + for flat_index, (i, j, k) in enumerate(mesh.indices): ijk = (i-1, j-1, k-1) # zero-out all source strengths and set the strength # on the element of interest mesh_source.strength = 0.0 - mesh_source.sources[ijk].strength = 1.0 + mesh_source.sources[flat_index].strength = 1.0 sp_file = model.run() @@ -375,10 +375,7 @@ def test_mesh_source_file(run_in_tmpdir): mesh.upper_right = (2, 3, 4) mesh.dimension = (1, 1, 1) - mesh_source_arr = np.asarray([file_source]).reshape(mesh.dimension) - source = openmc.MeshSource(mesh, mesh_source_arr) - - model.settings.source = source + model.settings.source = openmc.MeshSource(mesh, [file_source]) model.export_to_model_xml() From a7d6939c11a2d905d0aab3c57a97cd82e7018b3a Mon Sep 17 00:00:00 2001 From: April Novak Date: Tue, 14 May 2024 01:47:11 -0500 Subject: [PATCH 41/48] Fixes reordering in random ray. Refs #2997 (#2998) Co-authored-by: Paul Romano --- include/openmc/random_ray/random_ray.h | 6 +++--- src/random_ray/random_ray.cpp | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/include/openmc/random_ray/random_ray.h b/include/openmc/random_ray/random_ray.h index c114007c63..5ee64574ec 100644 --- a/include/openmc/random_ray/random_ray.h +++ b/include/openmc/random_ray/random_ray.h @@ -38,14 +38,14 @@ public: // Public data members vector angular_flux_; +private: //---------------------------------------------------------------------------- // Private data members -private: + vector delta_psi_; + int negroups_; FlatSourceDomain* domain_ {nullptr}; // pointer to domain that has flat source // data needed for ray transport - vector delta_psi_; double distance_travelled_ {0}; - int negroups_; bool is_active_ {false}; bool is_alive_ {true}; }; // class RandomRay diff --git a/src/random_ray/random_ray.cpp b/src/random_ray/random_ray.cpp index f471c75517..63d728cce8 100644 --- a/src/random_ray/random_ray.cpp +++ b/src/random_ray/random_ray.cpp @@ -70,9 +70,9 @@ double RandomRay::distance_active_; unique_ptr RandomRay::ray_source_; RandomRay::RandomRay() - : negroups_(data::mg.num_energy_groups_), - angular_flux_(data::mg.num_energy_groups_), - delta_psi_(data::mg.num_energy_groups_) + : angular_flux_(data::mg.num_energy_groups_), + delta_psi_(data::mg.num_energy_groups_), + negroups_(data::mg.num_energy_groups_) {} RandomRay::RandomRay(uint64_t ray_id, FlatSourceDomain* domain) : RandomRay() From 1702b4554bfb1a7f19cc2b9f1318edeb0f7ad0d2 Mon Sep 17 00:00:00 2001 From: Erik B Knudsen Date: Wed, 15 May 2024 22:49:04 +0200 Subject: [PATCH 42/48] Restricted file source (#2916) Co-authored-by: church89 Co-authored-by: Paul Romano --- docs/source/io_formats/settings.rst | 38 +- docs/source/usersguide/settings.rst | 48 +++ examples/assembly/assembly.py | 9 +- .../pincell_depletion/restart_depletion.py | 5 +- examples/pincell_depletion/run_depletion.py | 5 +- include/openmc/distribution_spatial.h | 5 + include/openmc/source.h | 90 ++++- openmc/examples.py | 12 +- openmc/source.py | 328 +++++++++++++----- openmc/stats/multivariate.py | 11 + src/distribution_spatial.cpp | 7 +- src/simulation.cpp | 4 +- src/source.cpp | 304 +++++++++++----- .../asymmetric_lattice/inputs_true.dat | 5 +- .../asymmetric_lattice/test.py | 6 +- .../mg_temperature_multi/inputs_true.dat | 5 +- .../mg_temperature_multi/test.py | 5 +- .../mgxs_library_ce_to_mg/inputs_true.dat | 5 +- .../inputs_true.dat | 5 +- .../mgxs_library_condense/inputs_true.dat | 5 +- .../mgxs_library_correction/inputs_true.dat | 5 +- .../mgxs_library_distribcell/inputs_true.dat | 5 +- .../mgxs_library_hdf5/inputs_true.dat | 5 +- .../mgxs_library_histogram/inputs_true.dat | 5 +- .../mgxs_library_no_nuclides/inputs_true.dat | 5 +- .../mgxs_library_nuclides/inputs_true.dat | 5 +- .../inputs_true.dat | 5 +- .../surface_tally/inputs_true.dat | 5 +- tests/regression_tests/surface_tally/test.py | 6 +- tests/unit_tests/test_lib.py | 6 +- tests/unit_tests/test_model.py | 6 +- tests/unit_tests/test_source.py | 103 +++++- 32 files changed, 823 insertions(+), 240 deletions(-) diff --git a/docs/source/io_formats/settings.rst b/docs/source/io_formats/settings.rst index 6318a99160..ae3266dabe 100644 --- a/docs/source/io_formats/settings.rst +++ b/docs/source/io_formats/settings.rst @@ -534,8 +534,9 @@ attributes/sub-elements: *Default*: 1.0 :type: - Indicator of source type. One of ``independent``, ``file``, ``compiled``, or ``mesh``. - The type of the source will be determined by this attribute if it is present. + Indicator of source type. One of ``independent``, ``file``, ``compiled``, or + ``mesh``. The type of the source will be determined by this attribute if it + is present. :particle: The source particle type, either ``neutron`` or ``photon``. @@ -716,6 +717,39 @@ attributes/sub-elements: mesh element and follows the format for :ref:`source_element`. The number of ```` sub-elements should correspond to the number of mesh elements. + :constraints: + This sub-element indicates the presence of constraints on sampled source + sites (see :ref:`usersguide_source_constraints` for details). It may have + the following sub-elements: + + :domain_ids: + The unique IDs of domains for which source sites must be within. + + *Default*: None + + :domain_type: + The type of each domain for source rejection ("cell", "material", or + "universe"). + + *Default*: None + + :fissionable: + A boolean indicating whether source sites must be sampled within a + material that is fissionable in order to be accepted. + + :time_bounds: + A pair of times in [s] indicating the lower and upper bound for a time + interval that source particles must be within. + + :energy_bounds: + A pair of energies in [eV] indicating the lower and upper bound for an + energy interval that source particles must be within. + + :rejection_strategy: + Either "resample", indicating that source sites should be resampled when + one is rejected, or "kill", indicating that a rejected source site is + assigned zero weight. + .. _univariate: Univariate Probability Distributions diff --git a/docs/source/usersguide/settings.rst b/docs/source/usersguide/settings.rst index e966423f0e..eb02f654c3 100644 --- a/docs/source/usersguide/settings.rst +++ b/docs/source/usersguide/settings.rst @@ -420,6 +420,54 @@ string, which gets passed down to the ``openmc_create_source()`` function:: settings.source = openmc.CompiledSource('libsource.so', '3.5e6') +.. _usersguide_source_constraints: + +Source Constraints +------------------ + +All source classes in OpenMC have the ability to apply a set of "constraints" +that limit which sampled source sites are actually used for transport. The most +common use case is to sample source sites over some simple spatial distribution +(e.g., uniform over a box) and then only accept those that appear in a given +cell or material. This can be done with a domain constraint, which can be +specified as follows:: + + source_cell = openmc.Cell(...) + ... + + spatial_dist = openmc.stats.Box((-10., -10., -10.), (10., 10., 10.)) + source = openmc.IndependentSource( + space=spatial_dist, + constraints={'domains': [source_cell]} + ) + +For k-eigenvalue problems, a convenient constraint is available that limits +source sites to those sampled in a fissionable material:: + + source = openmc.IndependentSource( + space=spatial_dist, constraints={'fissionable': True} + ) + +Constraints can also be placed on a range of energies or times:: + + # Only use source sites between 500 keV and 1 MeV and with times under 1 sec + source = openmc.FileSource( + 'source.h5', + constraints={'energy_bounds': [500.0e3, 1.0e6], 'time_bounds': [0.0, 1.0]} + ) + +Normally, when a source site is rejected, a new one will be resampled until one +is found that meets the constraints. However, the rejection strategy can be +changed so that a rejected site will just not be simulated by specifying:: + + source = openmc.IndependentSource( + space=spatial_dist, + constraints={'domains': [cell], 'rejection_strategy': 'kill'} + ) + +In this case, the actual number of particles simulated may be less than what you +specified in :attr:`Settings.particles`. + .. _usersguide_entropy: --------------- diff --git a/examples/assembly/assembly.py b/examples/assembly/assembly.py index a370a36114..d94cb69bfc 100644 --- a/examples/assembly/assembly.py +++ b/examples/assembly/assembly.py @@ -112,11 +112,10 @@ def assembly_model(): model.settings.batches = 150 model.settings.inactive = 50 model.settings.particles = 1000 - model.settings.source = openmc.IndependentSource(space=openmc.stats.Box( - (-pitch/2, -pitch/2, -1), - (pitch/2, pitch/2, 1), - only_fissionable=True - )) + model.settings.source = openmc.IndependentSource( + space=openmc.stats.Box((-pitch/2, -pitch/2, -1), (pitch/2, pitch/2, 1)), + constraints={'fissionable': True} + ) # NOTE: We never actually created a Materials object. When you export/run # using the Model object, if no materials were assigned it will look through diff --git a/examples/pincell_depletion/restart_depletion.py b/examples/pincell_depletion/restart_depletion.py index ac065d3402..de9fc16cb1 100644 --- a/examples/pincell_depletion/restart_depletion.py +++ b/examples/pincell_depletion/restart_depletion.py @@ -26,8 +26,9 @@ settings.particles = 10000 # Create an initial uniform spatial source distribution over fissionable zones bounds = [-0.62992, -0.62992, -1, 0.62992, 0.62992, 1] -uniform_dist = openmc.stats.Box(bounds[:3], bounds[3:], only_fissionable=True) -settings.source = openmc.IndependentSource(space=uniform_dist) +uniform_dist = openmc.stats.Box(bounds[:3], bounds[3:]) +settings.source = openmc.IndependentSource( + space=uniform_dist, constraints={'fissionable': True}) entropy_mesh = openmc.RegularMesh() entropy_mesh.lower_left = [-0.39218, -0.39218, -1.e50] diff --git a/examples/pincell_depletion/run_depletion.py b/examples/pincell_depletion/run_depletion.py index e1959938f0..013c83c86a 100644 --- a/examples/pincell_depletion/run_depletion.py +++ b/examples/pincell_depletion/run_depletion.py @@ -71,8 +71,9 @@ settings.particles = 1000 # Create an initial uniform spatial source distribution over fissionable zones bounds = [-0.62992, -0.62992, -1, 0.62992, 0.62992, 1] -uniform_dist = openmc.stats.Box(bounds[:3], bounds[3:], only_fissionable=True) -settings.source = openmc.IndependentSource(space=uniform_dist) +uniform_dist = openmc.stats.Box(bounds[:3], bounds[3:]) +settings.source = openmc.IndependentSource( + space=uniform_dist, constraints={'fissionable': True}) entropy_mesh = openmc.RegularMesh() entropy_mesh.lower_left = [-0.39218, -0.39218, -1.e50] diff --git a/include/openmc/distribution_spatial.h b/include/openmc/distribution_spatial.h index bd10a29960..28568c890d 100644 --- a/include/openmc/distribution_spatial.h +++ b/include/openmc/distribution_spatial.h @@ -116,6 +116,11 @@ public: //! \return Sampled element index and position within that element std::pair sample_mesh(uint64_t* seed) const; + //! Sample a mesh element + //! \param seed Pseudorandom number seed pointer + //! \return Sampled element index + int32_t sample_element_index(uint64_t* seed) const; + //! For unstructured meshes, ensure that elements are all linear tetrahedra void check_element_types() const; diff --git a/include/openmc/source.h b/include/openmc/source.h index d5ea9bd1d5..9ef8b92512 100644 --- a/include/openmc/source.h +++ b/include/openmc/source.h @@ -4,6 +4,7 @@ #ifndef OPENMC_SOURCE_H #define OPENMC_SOURCE_H +#include #include #include "pugixml.hpp" @@ -39,19 +40,72 @@ extern vector> external_sources; //============================================================================== //! Abstract source interface +// +//! The Source class provides the interface that must be implemented by derived +//! classes, namely the sample() method that returns a sampled source site. From +//! this base class, source rejection is handled within the +//! sample_with_constraints() method. However, note that some classes directly +//! check for constraints for efficiency reasons (like IndependentSource), in +//! which case the constraints_applied() method indicates that constraints +//! should not be checked a second time from the base class. //============================================================================== class Source { public: + // Constructors, destructors + Source() = default; + explicit Source(pugi::xml_node node); virtual ~Source() = default; - // Methods that must be implemented + // Methods that can be overridden + virtual double strength() const { return strength_; } + + //! Sample a source site and apply constraints + // + //! \param[inout] seed Pseudorandom seed pointer + //! \return Sampled site + SourceSite sample_with_constraints(uint64_t* seed) const; + + //! Sample a source site (without applying constraints) + // + //! Sample from the external source distribution + //! \param[inout] seed Pseudorandom seed pointer + //! \return Sampled site virtual SourceSite sample(uint64_t* seed) const = 0; - // Methods that can be overridden - virtual double strength() const { return 1.0; } - static unique_ptr create(pugi::xml_node node); + +protected: + // Domain types + enum class DomainType { UNIVERSE, MATERIAL, CELL }; + + // Strategy used for rejecting sites when constraints are applied. KILL means + // that sites are always accepted but if they don't satisfy constraints, they + // are given weight 0. RESAMPLE means that a new source site will be sampled + // until constraints are met. + enum class RejectionStrategy { KILL, RESAMPLE }; + + // Indicates whether derived class already handles constraints + virtual bool constraints_applied() const { return false; } + + // Methods for constraints + void read_constraints(pugi::xml_node node); + bool satisfies_spatial_constraints(Position r) const; + bool satisfies_energy_constraints(double E) const; + bool satisfies_time_constraints(double time) const; + + // Data members + double strength_ {1.0}; //!< Source strength + std::unordered_set domain_ids_; //!< Domains to reject from + DomainType domain_type_; //!< Domain type for rejection + std::pair time_bounds_ {-std::numeric_limits::max(), + std::numeric_limits::max()}; //!< time limits + std::pair energy_bounds_ { + 0, std::numeric_limits::max()}; //!< energy limits + bool only_fissionable_ { + false}; //!< Whether site must be in fissionable material + RejectionStrategy rejection_strategy_ { + RejectionStrategy::RESAMPLE}; //!< Procedure for rejecting }; //============================================================================== @@ -73,7 +127,6 @@ public: // Properties ParticleType particle_type() const { return particle_; } - double strength() const override { return strength_; } // Make observing pointers available SpatialDistribution* space() const { return space_.get(); } @@ -81,19 +134,17 @@ public: Distribution* energy() const { return energy_.get(); } Distribution* time() const { return time_.get(); } -private: - // Domain types - enum class DomainType { UNIVERSE, MATERIAL, CELL }; +protected: + // Indicates whether derived class already handles constraints + bool constraints_applied() const override { return true; } +private: // Data members ParticleType particle_ {ParticleType::neutron}; //!< Type of particle emitted - double strength_ {1.0}; //!< Source strength UPtrSpace space_; //!< Spatial distribution UPtrAngle angle_; //!< Angular distribution UPtrDist energy_; //!< Energy distribution UPtrDist time_; //!< Time distribution - DomainType domain_type_; //!< Domain type for rejection - std::unordered_set domain_ids_; //!< Domains to reject from }; //============================================================================== @@ -107,9 +158,12 @@ public: explicit FileSource(const std::string& path); // Methods - SourceSite sample(uint64_t* seed) const override; void load_sites_from_file( const std::string& path); //!< Load source sites from file + +protected: + SourceSite sample(uint64_t* seed) const override; + private: vector sites_; //!< Source sites from a file }; @@ -124,16 +178,17 @@ public: CompiledSourceWrapper(pugi::xml_node node); ~CompiledSourceWrapper(); + double strength() const override { return compiled_source_->strength(); } + + void setup(const std::string& path, const std::string& parameters); + +protected: // Defer implementation to custom source library SourceSite sample(uint64_t* seed) const override { return compiled_source_->sample(seed); } - double strength() const override { return compiled_source_->strength(); } - - void setup(const std::string& path, const std::string& parameters); - private: void* shared_library_; //!< library from dlopen unique_ptr compiled_source_; @@ -164,6 +219,9 @@ public: return sources_.size() == 1 ? sources_[0] : sources_[i]; } +protected: + bool constraints_applied() const override { return true; } + private: // Data members unique_ptr space_; //!< Mesh spatial diff --git a/openmc/examples.py b/openmc/examples.py index cef86a47a8..fc94b8b528 100644 --- a/openmc/examples.py +++ b/openmc/examples.py @@ -76,8 +76,10 @@ def pwr_pin_cell(): model.settings.batches = 10 model.settings.inactive = 5 model.settings.particles = 100 - model.settings.source = openmc.IndependentSource(space=openmc.stats.Box( - [-pitch/2, -pitch/2, -1], [pitch/2, pitch/2, 1], only_fissionable=True)) + model.settings.source = openmc.IndependentSource( + space=openmc.stats.Box([-pitch/2, -pitch/2, -1], [pitch/2, pitch/2, 1]), + constraints={'fissionable': True} + ) plot = openmc.Plot.from_geometry(model.geometry) plot.pixels = (300, 300) @@ -527,8 +529,10 @@ def pwr_assembly(): model.settings.batches = 10 model.settings.inactive = 5 model.settings.particles = 100 - model.settings.source = openmc.IndependentSource(space=openmc.stats.Box( - [-pitch/2, -pitch/2, -1], [pitch/2, pitch/2, 1], only_fissionable=True)) + model.settings.source = openmc.IndependentSource( + space=openmc.stats.Box([-pitch/2, -pitch/2, -1], [pitch/2, pitch/2, 1]), + constraints={'fissionable': True} + ) plot = openmc.Plot() plot.origin = (0.0, 0.0, 0) diff --git a/openmc/source.py b/openmc/source.py index 5deb6b05f5..91318f8e9a 100644 --- a/openmc/source.py +++ b/openmc/source.py @@ -6,7 +6,7 @@ from numbers import Real import warnings import typing # imported separately as py3.8 requires typing.Iterable # also required to prevent typing.Union namespace overwriting Union -from typing import Optional, Sequence +from typing import Optional, Sequence, Dict, Any import lxml.etree as ET import numpy as np @@ -28,6 +28,19 @@ class SourceBase(ABC): ---------- strength : float Strength of the source + constraints : dict + Constraints on sampled source particles. Valid keys include 'domains', + 'time_bounds', 'energy_bounds', 'fissionable', and 'rejection_strategy'. + For 'domains', the corresponding value is an iterable of + :class:`openmc.Cell`, :class:`openmc.Material`, or + :class:`openmc.Universe` for which sampled sites must be within. For + 'time_bounds' and 'energy_bounds', the corresponding value is a sequence + of floats giving the lower and upper bounds on time in [s] or energy in + [eV] that the sampled particle must be within. For 'fissionable', the + value is a bool indicating that only sites in fissionable material + should be accepted. The 'rejection_strategy' indicates what should + happen when a source particle is rejected: either 'resample' (pick a new + particle) or 'kill' (accept and terminate). Attributes ---------- @@ -35,11 +48,20 @@ class SourceBase(ABC): Indicator of source type. strength : float Strength of the source + constraints : dict + Constraints on sampled source particles. Valid keys include + 'domain_type', 'domain_ids', 'time_bounds', 'energy_bounds', + 'fissionable', and 'rejection_strategy'. """ - def __init__(self, strength=1.0): + def __init__( + self, + strength: Optional[float] = 1.0, + constraints: Optional[Dict[str, Any]] = None + ): self.strength = strength + self.constraints = constraints @property def strength(self): @@ -47,10 +69,47 @@ class SourceBase(ABC): @strength.setter def strength(self, strength): - cv.check_type('source strength', strength, Real) - cv.check_greater_than('source strength', strength, 0.0, True) + cv.check_type('source strength', strength, Real, none_ok=True) + if strength is not None: + cv.check_greater_than('source strength', strength, 0.0, True) self._strength = strength + @property + def constraints(self) -> Dict[str, Any]: + return self._constraints + + @constraints.setter + def constraints(self, constraints: Optional[Dict[str, Any]]): + self._constraints = {} + if constraints is None: + return + + for key, value in constraints.items(): + if key == 'domains': + cv.check_type('domains', value, Iterable, + (openmc.Cell, openmc.Material, openmc.Universe)) + if isinstance(value[0], openmc.Cell): + self._constraints['domain_type'] = 'cell' + elif isinstance(value[0], openmc.Material): + self._constraints['domain_type'] = 'material' + elif isinstance(value[0], openmc.Universe): + self._constraints['domain_type'] = 'universe' + self._constraints['domain_ids'] = [d.id for d in value] + elif key == 'time_bounds': + cv.check_type('time bounds', value, Iterable, Real) + self._constraints['time_bounds'] = tuple(value) + elif key == 'energy_bounds': + cv.check_type('energy bounds', value, Iterable, Real) + self._constraints['energy_bounds'] = tuple(value) + elif key == 'fissionable': + cv.check_type('fissionable', value, bool) + self._constraints['fissionable'] = value + elif key == 'rejection_strategy': + cv.check_value('rejection strategy', value, ('resample', 'kill')) + self._constraints['rejection_strategy'] = value + else: + raise ValueError('Unknown key in constraints dictionary: {key}') + @abstractmethod def populate_xml_element(self, element): """Add necessary source information to an XML element @@ -73,8 +132,30 @@ class SourceBase(ABC): """ element = ET.Element("source") element.set("type", self.type) - element.set("strength", str(self.strength)) + if self.strength is not None: + element.set("strength", str(self.strength)) self.populate_xml_element(element) + constraints = self.constraints + if constraints: + constraints_elem = ET.SubElement(element, "constraints") + if "domain_ids" in constraints: + dt_elem = ET.SubElement(constraints_elem, "domain_type") + dt_elem.text = constraints["domain_type"] + id_elem = ET.SubElement(constraints_elem, "domain_ids") + id_elem.text = ' '.join(str(uid) for uid in constraints["domain_ids"]) + if "time_bounds" in constraints: + dt_elem = ET.SubElement(constraints_elem, "time_bounds") + dt_elem.text = ' '.join(str(t) for t in constraints["time_bounds"]) + if "energy_bounds" in constraints: + dt_elem = ET.SubElement(constraints_elem, "energy_bounds") + dt_elem.text = ' '.join(str(E) for E in constraints["energy_bounds"]) + if "fissionable" in constraints: + dt_elem = ET.SubElement(constraints_elem, "fissionable") + dt_elem.text = str(constraints["fissionable"]).lower() + if "rejection_strategy" in constraints: + dt_elem = ET.SubElement(constraints_elem, "rejection_strategy") + dt_elem.text = constraints["rejection_strategy"] + return element @classmethod @@ -118,6 +199,47 @@ class SourceBase(ABC): else: raise ValueError(f'Source type {source_type} is not recognized') + @staticmethod + def _get_constraints(elem: ET.Element) -> Dict[str, Any]: + # Find element containing constraints + constraints_elem = elem.find("constraints") + elem = constraints_elem if constraints_elem is not None else elem + + constraints = {} + domain_type = get_text(elem, "domain_type") + if domain_type is not None: + domain_ids = [int(x) for x in get_text(elem, "domain_ids").split()] + + # Instantiate some throw-away domains that are used by the + # constructor to assign IDs + with warnings.catch_warnings(): + warnings.simplefilter('ignore', openmc.IDWarning) + if domain_type == 'cell': + domains = [openmc.Cell(uid) for uid in domain_ids] + elif domain_type == 'material': + domains = [openmc.Material(uid) for uid in domain_ids] + elif domain_type == 'universe': + domains = [openmc.Universe(uid) for uid in domain_ids] + constraints['domains'] = domains + + time_bounds = get_text(elem, "time_bounds") + if time_bounds is not None: + constraints['time_bounds'] = [float(x) for x in time_bounds.split()] + + energy_bounds = get_text(elem, "energy_bounds") + if energy_bounds is not None: + constraints['energy_bounds'] = [float(x) for x in energy_bounds.split()] + + fissionable = get_text(elem, "fissionable") + if fissionable is not None: + constraints['fissionable'] = fissionable in ('true', '1') + + rejection_strategy = get_text(elem, "rejection_strategy") + if rejection_strategy is not None: + constraints['rejection_strategy'] = rejection_strategy + + return constraints + class IndependentSource(SourceBase): """Distribution of phase space coordinates for source sites. @@ -142,6 +264,22 @@ class IndependentSource(SourceBase): Domains to reject based on, i.e., if a sampled spatial location is not within one of these domains, it will be rejected. + .. deprecated:: 0.14.1 + Use the `constraints` argument instead. + constraints : dict + Constraints on sampled source particles. Valid keys include 'domains', + 'time_bounds', 'energy_bounds', 'fissionable', and 'rejection_strategy'. + For 'domains', the corresponding value is an iterable of + :class:`openmc.Cell`, :class:`openmc.Material`, or + :class:`openmc.Universe` for which sampled sites must be within. For + 'time_bounds' and 'energy_bounds', the corresponding value is a sequence + of floats giving the lower and upper bounds on time in [s] or energy in + [eV] that the sampled particle must be within. For 'fissionable', the + value is a bool indicating that only sites in fissionable material + should be accepted. The 'rejection_strategy' indicates what should + happen when a source particle is rejected: either 'resample' (pick a new + particle) or 'kill' (accept and terminate). + Attributes ---------- space : openmc.stats.Spatial or None @@ -161,10 +299,10 @@ class IndependentSource(SourceBase): particle : {'neutron', 'photon'} Source particle type - ids : Iterable of int - IDs of domains to use for rejection - domain_type : {'cell', 'material', 'universe'} - Type of domain to use for rejection + constraints : dict + Constraints on sampled source particles. Valid keys include + 'domain_type', 'domain_ids', 'time_bounds', 'energy_bounds', + 'fissionable', and 'rejection_strategy'. """ @@ -176,9 +314,15 @@ class IndependentSource(SourceBase): time: Optional[openmc.stats.Univariate] = None, strength: float = 1.0, particle: str = 'neutron', - domains: Optional[Sequence[typing.Union[openmc.Cell, openmc.Material, openmc.Universe]]] = None + domains: Optional[Sequence[typing.Union[openmc.Cell, openmc.Material, openmc.Universe]]] = None, + constraints: Optional[Dict[str, Any]] = None ): - super().__init__(strength) + if domains is not None: + warnings.warn("The 'domains' arguments has been replaced by the " + "'constraints' argument.", FutureWarning) + constraints = {'domains': domains} + + super().__init__(strength=strength, constraints=constraints) self._space = None self._angle = None @@ -193,20 +337,8 @@ class IndependentSource(SourceBase): self.energy = energy if time is not None: self.time = time - self.strength = strength self.particle = particle - self._domain_ids = [] - self._domain_type = None - if domains is not None: - if isinstance(domains[0], openmc.Cell): - self.domain_type = 'cell' - elif isinstance(domains[0], openmc.Material): - self.domain_type = 'material' - elif isinstance(domains[0], openmc.Universe): - self.domain_type = 'universe' - self.domain_ids = [d.id for d in domains] - @property def type(self) -> str: return 'independent' @@ -273,24 +405,6 @@ class IndependentSource(SourceBase): cv.check_value('source particle', particle, ['neutron', 'photon']) self._particle = particle - @property - def domain_ids(self): - return self._domain_ids - - @domain_ids.setter - def domain_ids(self, ids): - cv.check_type('domain IDs', ids, Iterable, Real) - self._domain_ids = ids - - @property - def domain_type(self): - return self._domain_type - - @domain_type.setter - def domain_type(self, domain_type): - cv.check_value('domain type', domain_type, ('cell', 'material', 'universe')) - self._domain_type = domain_type - def populate_xml_element(self, element): """Add necessary source information to an XML element @@ -309,11 +423,6 @@ class IndependentSource(SourceBase): element.append(self.energy.to_xml_element('energy')) if self.time is not None: element.append(self.time.to_xml_element('time')) - if self.domain_ids: - dt_elem = ET.SubElement(element, "domain_type") - dt_elem.text = self.domain_type - id_elem = ET.SubElement(element, "domain_ids") - id_elem.text = ' '.join(str(uid) for uid in self.domain_ids) @classmethod def from_xml_element(cls, elem: ET.Element, meshes=None) -> SourceBase: @@ -333,24 +442,8 @@ class IndependentSource(SourceBase): Source generated from XML element """ - domain_type = get_text(elem, "domain_type") - if domain_type is not None: - domain_ids = [int(x) for x in get_text(elem, "domain_ids").split()] - - # Instantiate some throw-away domains that are used by the - # constructor to assign IDs - with warnings.catch_warnings(): - warnings.simplefilter('ignore', openmc.IDWarning) - if domain_type == 'cell': - domains = [openmc.Cell(uid) for uid in domain_ids] - elif domain_type == 'material': - domains = [openmc.Material(uid) for uid in domain_ids] - elif domain_type == 'universe': - domains = [openmc.Universe(uid) for uid in domain_ids] - else: - domains = None - - source = cls(domains=domains) + constraints = cls._get_constraints(elem) + source = cls(constraints=constraints) strength = get_text(elem, 'strength') if strength is not None: @@ -360,10 +453,6 @@ class IndependentSource(SourceBase): if particle is not None: source.particle = particle - filename = get_text(elem, 'file') - if filename is not None: - source.file = filename - space = elem.find('space') if space is not None: source.space = Spatial.from_xml_element(space, meshes) @@ -405,6 +494,19 @@ class MeshSource(SourceBase): multidimensional array whose shape matches the mesh shape. If spatial distributions are set on any of the source objects, they will be ignored during source site sampling. + constraints : dict + Constraints on sampled source particles. Valid keys include 'domains', + 'time_bounds', 'energy_bounds', 'fissionable', and 'rejection_strategy'. + For 'domains', the corresponding value is an iterable of + :class:`openmc.Cell`, :class:`openmc.Material`, or + :class:`openmc.Universe` for which sampled sites must be within. For + 'time_bounds' and 'energy_bounds', the corresponding value is a sequence + of floats giving the lower and upper bounds on time in [s] or energy in + [eV] that the sampled particle must be within. For 'fissionable', the + value is a bool indicating that only sites in fissionable material + should be accepted. The 'rejection_strategy' indicates what should + happen when a source particle is rejected: either 'resample' (pick a new + particle) or 'kill' (accept and terminate). Attributes ---------- @@ -416,9 +518,19 @@ class MeshSource(SourceBase): Strength of the source type : str Indicator of source type: 'mesh' + constraints : dict + Constraints on sampled source particles. Valid keys include + 'domain_type', 'domain_ids', 'time_bounds', 'energy_bounds', + 'fissionable', and 'rejection_strategy'. """ - def __init__(self, mesh: MeshBase, sources: Sequence[SourceBase]): + def __init__( + self, + mesh: MeshBase, + sources: Sequence[SourceBase], + constraints: Optional[Dict[str, Any]] = None, + ): + super().__init__(strength=None, constraints=constraints) self.mesh = mesh self.sources = sources @@ -473,8 +585,9 @@ class MeshSource(SourceBase): @strength.setter def strength(self, val): - cv.check_type('mesh source strength', val, Real) - self.set_total_strength(val) + if val is not None: + cv.check_type('mesh source strength', val, Real) + self.set_total_strength(val) def set_total_strength(self, strength: float): """Scales the element source strengths based on a desired total strength. @@ -531,7 +644,8 @@ class MeshSource(SourceBase): mesh = meshes[mesh_id] sources = [SourceBase.from_xml_element(e) for e in elem.iterchildren('source')] - return cls(mesh, sources) + constraints = cls._get_constraints(elem) + return cls(mesh, sources, constraints=constraints) def Source(*args, **kwargs): @@ -556,6 +670,19 @@ class CompiledSource(SourceBase): Parameters to be provided to the compiled shared library function strength : float Strength of the source + constraints : dict + Constraints on sampled source particles. Valid keys include 'domains', + 'time_bounds', 'energy_bounds', 'fissionable', and 'rejection_strategy'. + For 'domains', the corresponding value is an iterable of + :class:`openmc.Cell`, :class:`openmc.Material`, or + :class:`openmc.Universe` for which sampled sites must be within. For + 'time_bounds' and 'energy_bounds', the corresponding value is a sequence + of floats giving the lower and upper bounds on time in [s] or energy in + [eV] that the sampled particle must be within. For 'fissionable', the + value is a bool indicating that only sites in fissionable material + should be accepted. The 'rejection_strategy' indicates what should + happen when a source particle is rejected: either 'resample' (pick a new + particle) or 'kill' (accept and terminate). Attributes ---------- @@ -567,10 +694,20 @@ class CompiledSource(SourceBase): Strength of the source type : str Indicator of source type: 'compiled' + constraints : dict + Constraints on sampled source particles. Valid keys include + 'domain_type', 'domain_ids', 'time_bounds', 'energy_bounds', + 'fissionable', and 'rejection_strategy'. """ - def __init__(self, library: Optional[str] = None, parameters: Optional[str] = None, strength=1.0) -> None: - super().__init__(strength=strength) + def __init__( + self, + library: Optional[str] = None, + parameters: Optional[str] = None, + strength: float = 1.0, + constraints: Optional[Dict[str, Any]] = None + ) -> None: + super().__init__(strength=strength, constraints=constraints) self._library = None if library is not None: @@ -634,9 +771,10 @@ class CompiledSource(SourceBase): Source generated from XML element """ - library = get_text(elem, 'library') + kwargs = {'constraints': cls._get_constraints(elem)} + kwargs['library'] = get_text(elem, 'library') - source = cls(library) + source = cls(**kwargs) strength = get_text(elem, 'strength') if strength is not None: @@ -660,6 +798,19 @@ class FileSource(SourceBase): Path to the source file from which sites should be sampled strength : float Strength of the source (default is 1.0) + constraints : dict + Constraints on sampled source particles. Valid keys include 'domains', + 'time_bounds', 'energy_bounds', 'fissionable', and 'rejection_strategy'. + For 'domains', the corresponding value is an iterable of + :class:`openmc.Cell`, :class:`openmc.Material`, or + :class:`openmc.Universe` for which sampled sites must be within. For + 'time_bounds' and 'energy_bounds', the corresponding value is a sequence + of floats giving the lower and upper bounds on time in [s] or energy in + [eV] that the sampled particle must be within. For 'fissionable', the + value is a bool indicating that only sites in fissionable material + should be accepted. The 'rejection_strategy' indicates what should + happen when a source particle is rejected: either 'resample' (pick a new + particle) or 'kill' (accept and terminate). Attributes ---------- @@ -669,13 +820,21 @@ class FileSource(SourceBase): Strength of the source type : str Indicator of source type: 'file' + constraints : dict + Constraints on sampled source particles. Valid keys include + 'domain_type', 'domain_ids', 'time_bounds', 'energy_bounds', + 'fissionable', and 'rejection_strategy'. """ - def __init__(self, path: Optional[PathLike] = None, strength=1.0) -> None: - super().__init__(strength=strength) + def __init__( + self, + path: Optional[PathLike] = None, + strength: float = 1.0, + constraints: Optional[Dict[str, Any]] = None + ): + super().__init__(strength=strength, constraints=constraints) self._path = None - if path is not None: self.path = path @@ -722,16 +881,13 @@ class FileSource(SourceBase): Source generated from XML element """ - - filename = get_text(elem, 'file') - - source = cls(filename) - + kwargs = {'constraints': cls._get_constraints(elem)} + kwargs['path'] = get_text(elem, 'file') strength = get_text(elem, 'strength') if strength is not None: - source.strength = float(strength) + kwargs['strength'] = float(strength) - return source + return cls(**kwargs) class ParticleType(IntEnum): diff --git a/openmc/stats/multivariate.py b/openmc/stats/multivariate.py index 3922d601aa..212083c3cf 100644 --- a/openmc/stats/multivariate.py +++ b/openmc/stats/multivariate.py @@ -4,6 +4,7 @@ from abc import ABC, abstractmethod from collections.abc import Iterable from math import cos, pi from numbers import Real +from warnings import warn import lxml.etree as ET import numpy as np @@ -768,6 +769,9 @@ class Box(Spatial): Whether spatial sites should only be accepted if they occur in fissionable materials + .. deprecated:: 0.14.1 + Use the `constraints` argument when defining a source object instead. + Attributes ---------- lower_left : Iterable of float @@ -778,6 +782,9 @@ class Box(Spatial): Whether spatial sites should only be accepted if they occur in fissionable materials + .. deprecated:: 0.14.1 + Use the `constraints` argument when defining a source object instead. + """ def __init__( @@ -818,6 +825,10 @@ class Box(Spatial): def only_fissionable(self, only_fissionable): cv.check_type('only fissionable', only_fissionable, bool) self._only_fissionable = only_fissionable + if only_fissionable: + warn("The 'only_fissionable' has been deprecated. Use the " + "'constraints' argument when defining a source instead.", + FutureWarning) def to_xml_element(self): """Return XML representation of the box distribution diff --git a/src/distribution_spatial.cpp b/src/distribution_spatial.cpp index 8f34ea6b93..8dbd7d8870 100644 --- a/src/distribution_spatial.cpp +++ b/src/distribution_spatial.cpp @@ -281,10 +281,15 @@ void MeshSpatial::check_element_types() const } } +int32_t MeshSpatial::sample_element_index(uint64_t* seed) const +{ + return elem_idx_dist_.sample(seed); +} + std::pair MeshSpatial::sample_mesh(uint64_t* seed) const { // Sample the CDF defined in initialization above - int32_t elem_idx = elem_idx_dist_.sample(seed); + int32_t elem_idx = this->sample_element_index(seed); return {elem_idx, mesh()->sample_element(elem_idx, seed)}; } diff --git a/src/simulation.cpp b/src/simulation.cpp index 43d9206066..527d98db4f 100644 --- a/src/simulation.cpp +++ b/src/simulation.cpp @@ -750,7 +750,7 @@ void free_memory_simulation() void transport_history_based_single_particle(Particle& p) { - while (true) { + while (p.alive()) { p.event_calculate_xs(); if (!p.alive()) break; @@ -761,8 +761,6 @@ void transport_history_based_single_particle(Particle& p) p.event_collide(); } p.event_revive_from_secondary(); - if (!p.alive()) - break; } p.event_death(); } diff --git a/src/source.cpp b/src/source.cpp index 5ddac7f428..405de998c8 100644 --- a/src/source.cpp +++ b/src/source.cpp @@ -47,9 +47,23 @@ vector> external_sources; } //============================================================================== -// Source create implementation +// Source implementation //============================================================================== +Source::Source(pugi::xml_node node) +{ + // Check for source strength + if (check_for_node(node, "strength")) { + strength_ = std::stod(get_node_value(node, "strength")); + if (strength_ < 0.0) { + fatal_error("Source strength is negative."); + } + } + + // Check for additional defined constraints + read_constraints(node); +} + unique_ptr Source::create(pugi::xml_node node) { // if the source type is present, use it to determine the type @@ -79,6 +93,163 @@ unique_ptr Source::create(pugi::xml_node node) } } +void Source::read_constraints(pugi::xml_node node) +{ + // Check for constraints node. For backwards compatibility, if no constraints + // node is given, still try searching for domain constraints from top-level + // node. + pugi::xml_node constraints_node = node.child("constraints"); + if (constraints_node) { + node = constraints_node; + } + + // Check for domains to reject from + if (check_for_node(node, "domain_type")) { + std::string domain_type = get_node_value(node, "domain_type"); + if (domain_type == "cell") { + domain_type_ = DomainType::CELL; + } else if (domain_type == "material") { + domain_type_ = DomainType::MATERIAL; + } else if (domain_type == "universe") { + domain_type_ = DomainType::UNIVERSE; + } else { + fatal_error( + std::string("Unrecognized domain type for constraint: " + domain_type)); + } + + auto ids = get_node_array(node, "domain_ids"); + domain_ids_.insert(ids.begin(), ids.end()); + } + + if (check_for_node(node, "time_bounds")) { + auto ids = get_node_array(node, "time_bounds"); + if (ids.size() != 2) { + fatal_error("Time bounds must be represented by two numbers."); + } + time_bounds_ = std::make_pair(ids[0], ids[1]); + } + if (check_for_node(node, "energy_bounds")) { + auto ids = get_node_array(node, "energy_bounds"); + if (ids.size() != 2) { + fatal_error("Energy bounds must be represented by two numbers."); + } + energy_bounds_ = std::make_pair(ids[0], ids[1]); + } + + if (check_for_node(node, "fissionable")) { + only_fissionable_ = get_node_value_bool(node, "fissionable"); + } + + // Check for how to handle rejected particles + if (check_for_node(node, "rejection_strategy")) { + std::string rejection_strategy = get_node_value(node, "rejection_strategy"); + if (rejection_strategy == "kill") { + rejection_strategy_ = RejectionStrategy::KILL; + } else if (rejection_strategy == "resample") { + rejection_strategy_ = RejectionStrategy::RESAMPLE; + } else { + fatal_error(std::string( + "Unrecognized strategy source rejection: " + rejection_strategy)); + } + } +} + +SourceSite Source::sample_with_constraints(uint64_t* seed) const +{ + bool accepted = false; + static int n_reject = 0; + static int n_accept = 0; + SourceSite site; + + while (!accepted) { + // Sample a source site without considering constraints yet + site = this->sample(seed); + + if (constraints_applied()) { + accepted = true; + } else { + // Check whether sampled site satisfies constraints + accepted = satisfies_spatial_constraints(site.r) && + satisfies_energy_constraints(site.E) && + satisfies_time_constraints(site.time); + if (!accepted) { + ++n_reject; + if (n_reject >= EXTSRC_REJECT_THRESHOLD && + static_cast(n_accept) / n_reject <= + EXTSRC_REJECT_FRACTION) { + fatal_error("More than 95% of external source sites sampled were " + "rejected. Please check your source definition."); + } + + // For the "kill" strategy, accept particle but set weight to 0 so that + // it is terminated immediately + if (rejection_strategy_ == RejectionStrategy::KILL) { + accepted = true; + site.wgt = 0.0; + } + } + } + } + + // Increment number of accepted samples + ++n_accept; + + return site; +} + +bool Source::satisfies_energy_constraints(double E) const +{ + return E > energy_bounds_.first && E < energy_bounds_.second; +} + +bool Source::satisfies_time_constraints(double time) const +{ + return time > time_bounds_.first && time < time_bounds_.second; +} + +bool Source::satisfies_spatial_constraints(Position r) const +{ + GeometryState geom_state; + geom_state.r() = r; + + // Reject particle if it's not in the geometry at all + bool found = exhaustive_find_cell(geom_state); + if (!found) + return false; + + // Check the geometry state against specified domains + bool accepted = true; + if (!domain_ids_.empty()) { + if (domain_type_ == DomainType::MATERIAL) { + auto mat_index = geom_state.material(); + if (mat_index != MATERIAL_VOID) { + accepted = contains(domain_ids_, model::materials[mat_index]->id()); + } + } else { + for (int i = 0; i < geom_state.n_coord(); i++) { + auto id = (domain_type_ == DomainType::CELL) + ? model::cells[geom_state.coord(i).cell]->id_ + : model::universes[geom_state.coord(i).universe]->id_; + if ((accepted = contains(domain_ids_, id))) + break; + } + } + } + + // Check if spatial site is in fissionable material + if (accepted && only_fissionable_) { + // Determine material + auto mat_index = geom_state.material(); + if (mat_index == MATERIAL_VOID) { + accepted = false; + } else { + accepted = model::materials[mat_index]->fissionable(); + } + } + + return accepted; +} + //============================================================================== // IndependentSource implementation //============================================================================== @@ -89,7 +260,7 @@ IndependentSource::IndependentSource( energy_ {std::move(energy)}, time_ {std::move(time)} {} -IndependentSource::IndependentSource(pugi::xml_node node) +IndependentSource::IndependentSource(pugi::xml_node node) : Source(node) { // Check for particle type if (check_for_node(node, "particle")) { @@ -104,11 +275,6 @@ IndependentSource::IndependentSource(pugi::xml_node node) } } - // Check for source strength - if (check_for_node(node, "strength")) { - strength_ = std::stod(get_node_value(node, "strength")); - } - // Check for external source file if (check_for_node(node, "file")) { @@ -122,6 +288,15 @@ IndependentSource::IndependentSource(pugi::xml_node node) space_ = UPtrSpace {new SpatialPoint()}; } + // For backwards compatibility, check for only fissionable setting on box + // source + auto space_box = dynamic_cast(space_.get()); + if (space_box) { + if (!only_fissionable_) { + only_fissionable_ = space_box->only_fissionable(); + } + } + // Determine external source angular distribution if (check_for_node(node, "angle")) { angle_ = UnitSphereDistribution::create(node.child("angle")); @@ -148,24 +323,6 @@ IndependentSource::IndependentSource(pugi::xml_node node) double p[] {1.0}; time_ = UPtrDist {new Discrete {T, p, 1}}; } - - // Check for domains to reject from - if (check_for_node(node, "domain_type")) { - std::string domain_type = get_node_value(node, "domain_type"); - if (domain_type == "cell") { - domain_type_ = DomainType::CELL; - } else if (domain_type == "material") { - domain_type_ = DomainType::MATERIAL; - } else if (domain_type == "universe") { - domain_type_ = DomainType::UNIVERSE; - } else { - fatal_error(std::string( - "Unrecognized domain type for source rejection: " + domain_type)); - } - - auto ids = get_node_array(node, "domain_ids"); - domain_ids_.insert(ids.begin(), ids.end()); - } } } @@ -174,60 +331,21 @@ SourceSite IndependentSource::sample(uint64_t* seed) const SourceSite site; site.particle = particle_; - // Repeat sampling source location until a good site has been found - bool found = false; - int n_reject = 0; + // Repeat sampling source location until a good site has been accepted + bool accepted = false; + static int n_reject = 0; static int n_accept = 0; - while (!found) { - // Set particle type - Particle p; - p.type() = particle_; - p.u() = {0.0, 0.0, 1.0}; + while (!accepted) { // Sample spatial distribution - p.r() = space_->sample(seed); + site.r = space_->sample(seed); - // Now search to see if location exists in geometry - found = exhaustive_find_cell(p); - - // Check if spatial site is in fissionable material - if (found) { - auto space_box = dynamic_cast(space_.get()); - if (space_box) { - if (space_box->only_fissionable()) { - // Determine material - auto mat_index = p.material(); - if (mat_index == MATERIAL_VOID) { - found = false; - } else { - found = model::materials[mat_index]->fissionable(); - } - } - } - - // Rejection based on cells/materials/universes - if (!domain_ids_.empty()) { - found = false; - if (domain_type_ == DomainType::MATERIAL) { - auto mat_index = p.material(); - if (mat_index != MATERIAL_VOID) { - found = contains(domain_ids_, model::materials[mat_index]->id()); - } - } else { - for (int i = 0; i < p.n_coord(); i++) { - auto id = (domain_type_ == DomainType::CELL) - ? model::cells[p.coord(i).cell]->id_ - : model::universes[p.coord(i).universe]->id_; - if ((found = contains(domain_ids_, id))) - break; - } - } - } - } + // Check if sampled position satisfies spatial constraints + accepted = satisfies_spatial_constraints(site.r); // Check for rejection - if (!found) { + if (!accepted) { ++n_reject; if (n_reject >= EXTSRC_REJECT_THRESHOLD && static_cast(n_accept) / n_reject <= EXTSRC_REJECT_FRACTION) { @@ -236,8 +354,6 @@ SourceSite IndependentSource::sample(uint64_t* seed) const "definition."); } } - - site.r = p.r(); } // Sample angle @@ -261,7 +377,8 @@ SourceSite IndependentSource::sample(uint64_t* seed) const site.E = energy_->sample(seed); // Resample if energy falls above maximum particle energy - if (site.E < data::energy_max[p]) + if (site.E < data::energy_max[p] and + (satisfies_energy_constraints(site.E))) break; n_reject++; @@ -287,7 +404,8 @@ SourceSite IndependentSource::sample(uint64_t* seed) const //============================================================================== // FileSource implementation //============================================================================== -FileSource::FileSource(pugi::xml_node node) + +FileSource::FileSource(pugi::xml_node node) : Source(node) { auto path = get_node_value(node, "file", false, true); if (ends_with(path, ".mcpl") || ends_with(path, ".mcpl.gz")) { @@ -332,6 +450,7 @@ void FileSource::load_sites_from_file(const std::string& path) SourceSite FileSource::sample(uint64_t* seed) const { + // Sample a particle randomly from list size_t i_site = sites_.size() * prn(seed); return sites_[i_site]; } @@ -339,7 +458,8 @@ SourceSite FileSource::sample(uint64_t* seed) const //============================================================================== // CompiledSourceWrapper implementation //============================================================================== -CompiledSourceWrapper::CompiledSourceWrapper(pugi::xml_node node) + +CompiledSourceWrapper::CompiledSourceWrapper(pugi::xml_node node) : Source(node) { // Get shared library path and parameters auto path = get_node_value(node, "library", false, true); @@ -403,7 +523,7 @@ CompiledSourceWrapper::~CompiledSourceWrapper() // MeshSource implementation //============================================================================== -MeshSource::MeshSource(pugi::xml_node node) +MeshSource::MeshSource(pugi::xml_node node) : Source(node) { int32_t mesh_id = stoi(get_node_value(node, "mesh")); int32_t mesh_idx = model::mesh_map.at(mesh_id); @@ -430,15 +550,27 @@ MeshSource::MeshSource(pugi::xml_node node) SourceSite MeshSource::sample(uint64_t* seed) const { - // sample location and element from mesh - auto mesh_location = space_->sample_mesh(seed); + // Sample the CDF defined in initialization above + int32_t element = space_->sample_element_index(seed); - // Sample source for the chosen element - int32_t element = mesh_location.first; - SourceSite site = source(element)->sample(seed); + // Sample position and apply rejection on spatial domains + Position r; + do { + r = space_->mesh()->sample_element(element, seed); + } while (!this->satisfies_spatial_constraints(r)); - // Replace spatial position with the one already sampled - site.r = mesh_location.second; + SourceSite site; + while (true) { + // Sample source for the chosen element and replace the position + site = source(element)->sample_with_constraints(seed); + site.r = r; + + // Apply other rejections + if (satisfies_energy_constraints(site.E) && + satisfies_time_constraints(site.time)) { + break; + } + } return site; } @@ -493,7 +625,7 @@ SourceSite sample_external_source(uint64_t* seed) } // Sample source site from i-th source distribution - SourceSite site {model::external_sources[i]->sample(seed)}; + SourceSite site {model::external_sources[i]->sample_with_constraints(seed)}; // If running in MG, convert site.E to group if (!settings::run_CE) { diff --git a/tests/regression_tests/asymmetric_lattice/inputs_true.dat b/tests/regression_tests/asymmetric_lattice/inputs_true.dat index 3977220da6..302ef24c20 100644 --- a/tests/regression_tests/asymmetric_lattice/inputs_true.dat +++ b/tests/regression_tests/asymmetric_lattice/inputs_true.dat @@ -209,9 +209,12 @@ 10 5 - + -32 -32 0 32 32 32 + + true + diff --git a/tests/regression_tests/asymmetric_lattice/test.py b/tests/regression_tests/asymmetric_lattice/test.py index 0d17a6c98e..fadf272ffd 100644 --- a/tests/regression_tests/asymmetric_lattice/test.py +++ b/tests/regression_tests/asymmetric_lattice/test.py @@ -54,8 +54,10 @@ class AsymmetricLatticeTestHarness(PyAPITestHarness): self._model.tallies.append(tally) # Specify summary output and correct source sampling box - self._model.settings.source = openmc.IndependentSource(space=openmc.stats.Box( - [-32, -32, 0], [32, 32, 32], only_fissionable = True)) + self._model.settings.source = openmc.IndependentSource( + space=openmc.stats.Box([-32, -32, 0], [32, 32, 32]), + constraints={'fissionable': True} + ) def _get_results(self, hash_output=True): """Digest info in statepoint and summary and return as a string.""" diff --git a/tests/regression_tests/mg_temperature_multi/inputs_true.dat b/tests/regression_tests/mg_temperature_multi/inputs_true.dat index b2ad5063c2..0c452cd520 100644 --- a/tests/regression_tests/mg_temperature_multi/inputs_true.dat +++ b/tests/regression_tests/mg_temperature_multi/inputs_true.dat @@ -28,9 +28,12 @@ 10 5 - + -0.63 -0.63 -1 0.63 0.63 1 + + true + multi-group diff --git a/tests/regression_tests/mg_temperature_multi/test.py b/tests/regression_tests/mg_temperature_multi/test.py index 404c8c0cd0..3117e29ba0 100755 --- a/tests/regression_tests/mg_temperature_multi/test.py +++ b/tests/regression_tests/mg_temperature_multi/test.py @@ -133,8 +133,9 @@ def test_mg_temperature_multi(): # Create an initial uniform spatial source distribution over fissionable zones lower_left = (-pitch/2, -pitch/2, -1) upper_right = (pitch/2, pitch/2, 1) - uniform_dist = openmc.stats.Box(lower_left, upper_right, only_fissionable=True) - settings.source = openmc.IndependentSource(space=uniform_dist) + uniform_dist = openmc.stats.Box(lower_left, upper_right) + settings.source = openmc.IndependentSource( + space=uniform_dist, constraints={'fissionable': True}) ############################################################################### # Define tallies diff --git a/tests/regression_tests/mgxs_library_ce_to_mg/inputs_true.dat b/tests/regression_tests/mgxs_library_ce_to_mg/inputs_true.dat index d9a3c52c84..6c071263f3 100644 --- a/tests/regression_tests/mgxs_library_ce_to_mg/inputs_true.dat +++ b/tests/regression_tests/mgxs_library_ce_to_mg/inputs_true.dat @@ -42,9 +42,12 @@ 10 5 - + -0.63 -0.63 -1 0.63 0.63 1 + + true + diff --git a/tests/regression_tests/mgxs_library_ce_to_mg_nuclides/inputs_true.dat b/tests/regression_tests/mgxs_library_ce_to_mg_nuclides/inputs_true.dat index f84adc3296..ad3d50974b 100644 --- a/tests/regression_tests/mgxs_library_ce_to_mg_nuclides/inputs_true.dat +++ b/tests/regression_tests/mgxs_library_ce_to_mg_nuclides/inputs_true.dat @@ -42,9 +42,12 @@ 10 5 - + -0.63 -0.63 -1 0.63 0.63 1 + + true + diff --git a/tests/regression_tests/mgxs_library_condense/inputs_true.dat b/tests/regression_tests/mgxs_library_condense/inputs_true.dat index 93519cd2a2..8606e9fdda 100644 --- a/tests/regression_tests/mgxs_library_condense/inputs_true.dat +++ b/tests/regression_tests/mgxs_library_condense/inputs_true.dat @@ -42,9 +42,12 @@ 10 5 - + -0.63 -0.63 -1 0.63 0.63 1 + + true + diff --git a/tests/regression_tests/mgxs_library_correction/inputs_true.dat b/tests/regression_tests/mgxs_library_correction/inputs_true.dat index 80cbc4bb56..ea2d1b714a 100644 --- a/tests/regression_tests/mgxs_library_correction/inputs_true.dat +++ b/tests/regression_tests/mgxs_library_correction/inputs_true.dat @@ -42,9 +42,12 @@ 10 5 - + -0.63 -0.63 -1 0.63 0.63 1 + + true + diff --git a/tests/regression_tests/mgxs_library_distribcell/inputs_true.dat b/tests/regression_tests/mgxs_library_distribcell/inputs_true.dat index fc2c06d1cf..f1a6053349 100644 --- a/tests/regression_tests/mgxs_library_distribcell/inputs_true.dat +++ b/tests/regression_tests/mgxs_library_distribcell/inputs_true.dat @@ -69,9 +69,12 @@ 10 5 - + -10.71 -10.71 -1 10.71 10.71 1 + + true + diff --git a/tests/regression_tests/mgxs_library_hdf5/inputs_true.dat b/tests/regression_tests/mgxs_library_hdf5/inputs_true.dat index 93519cd2a2..8606e9fdda 100644 --- a/tests/regression_tests/mgxs_library_hdf5/inputs_true.dat +++ b/tests/regression_tests/mgxs_library_hdf5/inputs_true.dat @@ -42,9 +42,12 @@ 10 5 - + -0.63 -0.63 -1 0.63 0.63 1 + + true + diff --git a/tests/regression_tests/mgxs_library_histogram/inputs_true.dat b/tests/regression_tests/mgxs_library_histogram/inputs_true.dat index 178a44464e..013fcd0fa5 100644 --- a/tests/regression_tests/mgxs_library_histogram/inputs_true.dat +++ b/tests/regression_tests/mgxs_library_histogram/inputs_true.dat @@ -42,9 +42,12 @@ 10 5 - + -0.63 -0.63 -1 0.63 0.63 1 + + true + diff --git a/tests/regression_tests/mgxs_library_no_nuclides/inputs_true.dat b/tests/regression_tests/mgxs_library_no_nuclides/inputs_true.dat index 0003413acb..5cad36b7ea 100644 --- a/tests/regression_tests/mgxs_library_no_nuclides/inputs_true.dat +++ b/tests/regression_tests/mgxs_library_no_nuclides/inputs_true.dat @@ -42,9 +42,12 @@ 10 5 - + -0.63 -0.63 -1 0.63 0.63 1 + + true + diff --git a/tests/regression_tests/mgxs_library_nuclides/inputs_true.dat b/tests/regression_tests/mgxs_library_nuclides/inputs_true.dat index 97e227177e..9b3a22510b 100644 --- a/tests/regression_tests/mgxs_library_nuclides/inputs_true.dat +++ b/tests/regression_tests/mgxs_library_nuclides/inputs_true.dat @@ -42,9 +42,12 @@ 10 5 - + -0.63 -0.63 -1 0.63 0.63 1 + + true + diff --git a/tests/regression_tests/mgxs_library_specific_nuclides/inputs_true.dat b/tests/regression_tests/mgxs_library_specific_nuclides/inputs_true.dat index b73ed179e5..1372741c94 100644 --- a/tests/regression_tests/mgxs_library_specific_nuclides/inputs_true.dat +++ b/tests/regression_tests/mgxs_library_specific_nuclides/inputs_true.dat @@ -42,9 +42,12 @@ 10 5 - + -0.63 -0.63 -1 0.63 0.63 1 + + true + diff --git a/tests/regression_tests/surface_tally/inputs_true.dat b/tests/regression_tests/surface_tally/inputs_true.dat index abd01266d4..192c2e89f4 100644 --- a/tests/regression_tests/surface_tally/inputs_true.dat +++ b/tests/regression_tests/surface_tally/inputs_true.dat @@ -30,9 +30,12 @@ 10 0 - + -0.62992 -0.62992 -1 0.62992 0.62992 1 + + true + diff --git a/tests/regression_tests/surface_tally/test.py b/tests/regression_tests/surface_tally/test.py index 8968db9b6f..e496ac0f65 100644 --- a/tests/regression_tests/surface_tally/test.py +++ b/tests/regression_tests/surface_tally/test.py @@ -72,9 +72,9 @@ class SurfaceTallyTestHarness(PyAPITestHarness): # Create an initial uniform spatial source distribution bounds = [-0.62992, -0.62992, -1, 0.62992, 0.62992, 1] - uniform_dist = openmc.stats.Box(bounds[:3], bounds[3:],\ - only_fissionable=True) - settings_file.source = openmc.IndependentSource(space=uniform_dist) + uniform_dist = openmc.stats.Box(bounds[:3], bounds[3:],) + settings_file.source = openmc.IndependentSource( + space=uniform_dist, constraints={'fissionable': True}) self._model.settings = settings_file # Tallies file diff --git a/tests/unit_tests/test_lib.py b/tests/unit_tests/test_lib.py index 1310a6a7fa..009a909683 100644 --- a/tests/unit_tests/test_lib.py +++ b/tests/unit_tests/test_lib.py @@ -67,8 +67,10 @@ def uo2_trigger_model(): model.settings.batches = 10 model.settings.inactive = 5 model.settings.particles = 100 - model.settings.source = openmc.IndependentSource(space=openmc.stats.Box( - [-0.5, -0.5, -1], [0.5, 0.5, 1], only_fissionable=True)) + model.settings.source = openmc.IndependentSource( + space=openmc.stats.Box([-0.5, -0.5, -1], [0.5, 0.5, 1]), + constraints={'fissionable': True}, + ) model.settings.verbosity = 1 model.settings.keff_trigger = {'type': 'std_dev', 'threshold': 0.001} model.settings.trigger_active = True diff --git a/tests/unit_tests/test_model.py b/tests/unit_tests/test_model.py index 16fa18d453..404235bef1 100644 --- a/tests/unit_tests/test_model.py +++ b/tests/unit_tests/test_model.py @@ -57,9 +57,9 @@ def pin_model_attributes(): # Create a uniform spatial source distribution over fissionable zones bounds = [-0.62992, -0.62992, -1, 0.62992, 0.62992, 1] - uniform_dist = openmc.stats.Box( - bounds[:3], bounds[3:], only_fissionable=True) - settings.source = openmc.IndependentSource(space=uniform_dist) + uniform_dist = openmc.stats.Box(bounds[:3], bounds[3:]) + settings.source = openmc.IndependentSource( + space=uniform_dist, constraints={'fissionable': True}) entropy_mesh = openmc.RegularMesh() entropy_mesh.lower_left = [-0.39218, -0.39218, -1.e50] diff --git a/tests/unit_tests/test_source.py b/tests/unit_tests/test_source.py index 4783ff8f11..9a19f6f24d 100644 --- a/tests/unit_tests/test_source.py +++ b/tests/unit_tests/test_source.py @@ -7,6 +7,8 @@ import numpy as np import pytest from pytest import approx +from tests.regression_tests import config + def test_source(): space = openmc.stats.Point() @@ -100,7 +102,8 @@ def test_source_xml_roundtrip(): assert new_src.strength == approx(src.strength) -def test_rejection(run_in_tmpdir): +@pytest.fixture +def sphere_box_model(): # Model with two spheres inside a box mat = openmc.Material() mat.add_nuclide('H1', 1.0) @@ -119,24 +122,108 @@ def test_rejection(run_in_tmpdir): model.settings.batches = 10 model.settings.run_mode = 'fixed source' + return model, cell1, cell2, cell3 + + +def test_constraints_independent(sphere_box_model, run_in_tmpdir): + model, cell1, cell2, cell3 = sphere_box_model + # Set up a box source with rejection on the spherical cell - space = openmc.stats.Box(*cell3.bounding_box) - model.settings.source = openmc.IndependentSource(space=space, domains=[cell1, cell2]) + space = openmc.stats.Box((-4., -1., -1.), (4., 1., 1.)) + model.settings.source = openmc.IndependentSource( + space=space, constraints={'domains': [cell1, cell2]} + ) # Load up model via openmc.lib and sample source - model.export_to_xml() + model.export_to_model_xml() openmc.lib.init() particles = openmc.lib.sample_external_source(1000) # Make sure that all sampled sources are within one of the spheres - joint_region = cell1.region | cell2.region for p in particles: - assert p.r in joint_region - assert p.r not in non_source_region + assert p.r in (cell1.region | cell2.region) + assert p.r not in cell3.region openmc.lib.finalize() +def test_constraints_mesh(sphere_box_model, run_in_tmpdir): + model, cell1, cell2, cell3 = sphere_box_model + + bbox = cell3.bounding_box + mesh = openmc.RegularMesh() + mesh.lower_left = bbox.lower_left + mesh.upper_right = bbox.upper_right + mesh.dimension = (2, 1, 1) + + left_source = openmc.IndependentSource() + right_source = openmc.IndependentSource() + model.settings.source = openmc.MeshSource( + mesh, [left_source, right_source], constraints={'domains': [cell1, cell2]} + ) + + # Load up model via openmc.lib and sample source + model.export_to_model_xml() + openmc.lib.init() + particles = openmc.lib.sample_external_source(1000) + + # Make sure that all sampled sources are within one of the spheres + for p in particles: + assert p.r in (cell1.region | cell2.region) + assert p.r not in cell3.region + + openmc.lib.finalize() + + +def test_constraints_file(sphere_box_model, run_in_tmpdir): + model = sphere_box_model[0] + + # Create source file with randomly sampled source sites + rng = np.random.default_rng() + energy = rng.uniform(0., 1e6, 10_000) + time = rng.uniform(0., 1., 10_000) + particles = [openmc.SourceParticle(E=e, time=t) for e, t in zip(energy, time)] + openmc.write_source_file(particles, 'uniform_source.h5') + + # Use source file + model.settings.source = openmc.FileSource( + 'uniform_source.h5', + constraints={ + 'time_bounds': [0.25, 0.75], + 'energy_bounds': [500.e3, 1.0e6], + } + ) + + # Load up model via openmc.lib and sample source + model.export_to_model_xml() + openmc.lib.init() + particles = openmc.lib.sample_external_source(1000) + + # Make sure that all sampled sources are within energy/time bounds + for p in particles: + assert 0.25 <= p.time <= 0.75 + assert 500.e3 <= p.E <= 1.0e6 + + openmc.lib.finalize() + + +@pytest.mark.skipif(config['mpi'], reason='Not compatible with MPI') +def test_rejection_limit(sphere_box_model, run_in_tmpdir): + model, cell1 = sphere_box_model[:2] + + # Define a point source that will get rejected 100% of the time + model.settings.source = openmc.IndependentSource( + space=openmc.stats.Point((-3., 0., 0.)), + constraints={'domains': [cell1]} + ) + + # Confirm that OpenMC doesn't run in an infinite loop. Note that this may + # work when running with MPI since it won't necessarily capture the error + # message correctly + with pytest.raises(RuntimeError, match="rejected"): + model.run(openmc_exec=config['exe']) + + def test_exceptions(): with pytest.raises(AttributeError, match=r'Please use the FileSource class'): @@ -156,4 +243,4 @@ def test_exceptions(): with pytest.raises(AttributeError, match=r'has no attribute \'frisbee\''): s = openmc.IndependentSource() - s.frisbee \ No newline at end of file + s.frisbee From 25e47dea9bc1857272bb7a69642b868f4c27fe08 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Fri, 24 May 2024 18:56:20 -0500 Subject: [PATCH 43/48] Apply memoization in `get_all_universes` (#2995) Co-authored-by: Paul Romano --- openmc/cell.py | 33 ++++++++++++++++++--------------- openmc/geometry.py | 6 +++--- openmc/lattice.py | 36 +++++++++++++++++++++++------------- openmc/tallies.py | 1 + openmc/universe.py | 42 +++++++++++++++++++++++++++--------------- 5 files changed, 72 insertions(+), 46 deletions(-) diff --git a/openmc/cell.py b/openmc/cell.py index 94fac84135..fe3939bbe3 100644 --- a/openmc/cell.py +++ b/openmc/cell.py @@ -426,15 +426,13 @@ class Cell(IDManagerMixin): instances """ + if memo is None: + memo = set() + elif self in memo: + return {} + memo.add(self) cells = {} - - if memo and self in memo: - return cells - - if memo is not None: - memo.add(self) - if self.fill_type in ('universe', 'lattice'): cells.update(self.fill.get_all_cells(memo)) @@ -465,7 +463,7 @@ class Cell(IDManagerMixin): return materials - def get_all_universes(self): + def get_all_universes(self, memo=None): """Return all universes that are contained within this one if any of its cells are filled with a universe or lattice. @@ -476,18 +474,22 @@ class Cell(IDManagerMixin): :class:`Universe` instances """ + if memo is None: + memo = set() + if self in memo: + return {} + memo.add(self) universes = {} - if self.fill_type == 'universe': universes[self.fill.id] = self.fill - universes.update(self.fill.get_all_universes()) + universes.update(self.fill.get_all_universes(memo)) elif self.fill_type == 'lattice': - universes.update(self.fill.get_all_universes()) + universes.update(self.fill.get_all_universes(memo)) return universes - def clone(self, clone_materials=True, clone_regions=True, memo=None): + def clone(self, clone_materials=True, clone_regions=True, memo=None): """Create a copy of this cell with a new unique ID, and clones the cell's region and fill. @@ -678,10 +680,11 @@ class Cell(IDManagerMixin): # thus far. def create_surface_elements(node, element, memo=None): if isinstance(node, Halfspace): - if memo and node.surface in memo: + if memo is None: + memo = set() + elif node.surface in memo: return - if memo is not None: - memo.add(node.surface) + memo.add(node.surface) xml_element.append(node.surface.to_xml_element()) elif isinstance(node, Complement): diff --git a/openmc/geometry.py b/openmc/geometry.py index 175cef2bf3..db927c46ec 100644 --- a/openmc/geometry.py +++ b/openmc/geometry.py @@ -134,7 +134,7 @@ class Geometry: # Create XML representation element = ET.Element("geometry") - self.root_universe.create_xml_subelement(element, memo=set()) + self.root_universe.create_xml_subelement(element) # Sort the elements in the file element[:] = sorted(element, key=lambda x: ( @@ -373,7 +373,7 @@ class Geometry: """ if self.root_universe is not None: - return self.root_universe.get_all_cells(memo=set()) + return self.root_universe.get_all_cells() else: return {} @@ -417,7 +417,7 @@ class Geometry: """ if self.root_universe is not None: - return self.root_universe.get_all_materials(memo=set()) + return self.root_universe.get_all_materials() else: return {} diff --git a/openmc/lattice.py b/openmc/lattice.py index 40b97f6cf5..6282cf21c1 100644 --- a/openmc/lattice.py +++ b/openmc/lattice.py @@ -170,11 +170,11 @@ class Lattice(IDManagerMixin, ABC): """ cells = {} - if memo and self in memo: + if memo is None: + memo = set() + elif self in memo: return cells - - if memo is not None: - memo.add(self) + memo.add(self) unique_universes = self.get_unique_universes() @@ -194,6 +194,9 @@ class Lattice(IDManagerMixin, ABC): """ + if memo is None: + memo = set() + materials = {} # Append all Cells in each Cell in the Universe to the dictionary @@ -203,7 +206,7 @@ class Lattice(IDManagerMixin, ABC): return materials - def get_all_universes(self): + def get_all_universes(self, memo=None): """Return all universes that are contained within the lattice Returns @@ -213,11 +216,16 @@ class Lattice(IDManagerMixin, ABC): :class:`Universe` instances """ - # Initialize a dictionary of all Universes contained by the Lattice # in each nested Universe level all_universes = {} + if memo is None: + memo = set() + elif self in memo: + return all_universes + memo.add(self) + # Get all unique Universes contained in each of the lattice cells unique_universes = self.get_unique_universes() @@ -226,7 +234,7 @@ class Lattice(IDManagerMixin, ABC): # Append all Universes containing each cell to the dictionary for universe in unique_universes.values(): - all_universes.update(universe.get_all_universes()) + all_universes.update(universe.get_all_universes(memo)) return all_universes @@ -846,10 +854,11 @@ class RectLattice(Lattice): """ # If the element already contains the Lattice subelement, then return - if memo and self in memo: + if memo is None: + memo = set() + elif self in memo: return - if memo is not None: - memo.add(self) + memo.add(self) # Make sure universes have been assigned if self.universes is None: @@ -1417,10 +1426,11 @@ class HexLattice(Lattice): def create_xml_subelement(self, xml_element, memo=None): # If this subelement has already been written, return - if memo and self in memo: + if memo is None: + memo = set() + elif self in memo: return - if memo is not None: - memo.add(self) + memo.add(self) lattice_subelement = ET.Element("hex_lattice") lattice_subelement.set("id", str(self._id)) diff --git a/openmc/tallies.py b/openmc/tallies.py index 04ae00b6a3..f39ffa0789 100644 --- a/openmc/tallies.py +++ b/openmc/tallies.py @@ -3209,6 +3209,7 @@ class Tallies(cv.CheckedList): def to_xml_element(self, memo=None): """Creates a 'tallies' element to be written to an XML file. """ + memo = memo if memo is not None else set() element = ET.Element("tallies") self._create_mesh_subelements(element, memo) self._create_filter_subelements(element) diff --git a/openmc/universe.py b/openmc/universe.py index 2e86ab05a9..9fab9ae51b 100644 --- a/openmc/universe.py +++ b/openmc/universe.py @@ -90,7 +90,7 @@ class UniverseBase(ABC, IDManagerMixin): else: raise ValueError('No volume information found for this universe.') - def get_all_universes(self): + def get_all_universes(self, memo=None): """Return all universes that are contained within this one. Returns @@ -100,10 +100,16 @@ class UniverseBase(ABC, IDManagerMixin): :class:`Universe` instances """ + if memo is None: + memo = set() + elif self in memo: + return {} + memo.add(self) + # Append all Universes within each Cell to the dictionary universes = {} for cell in self.get_all_cells().values(): - universes.update(cell.get_all_universes()) + universes.update(cell.get_all_universes(memo)) return universes @@ -639,15 +645,14 @@ class Universe(UniverseBase): """ - cells = {} - - if memo and self in memo: - return cells - - if memo is not None: - memo.add(self) + if memo is None: + memo = set() + elif self in memo: + return {} + memo.add(self) # Add this Universe's cells to the dictionary + cells = {} cells.update(self._cells) # Append all Cells in each Cell in the Universe to the dictionary @@ -667,6 +672,9 @@ class Universe(UniverseBase): """ + if memo is None: + memo = set() + materials = {} # Append all Cells in each Cell in the Universe to the dictionary @@ -677,15 +685,17 @@ class Universe(UniverseBase): return materials def create_xml_subelement(self, xml_element, memo=None): + if memo is None: + memo = set() + # Iterate over all Cells for cell in self._cells.values(): # If the cell was already written, move on - if memo and cell in memo: + if cell in memo: continue - if memo is not None: - memo.add(cell) + memo.add(cell) # Create XML subelement for this Cell cell_element = cell.create_xml_subelement(xml_element, memo) @@ -928,11 +938,13 @@ class DAGMCUniverse(UniverseBase): return self._n_geom_elements('surface') def create_xml_subelement(self, xml_element, memo=None): - if memo and self in memo: + if memo is None: + memo = set() + + if self in memo: return - if memo is not None: - memo.add(self) + memo.add(self) # Set xml element values dagmc_element = ET.Element('dagmc_universe') From 12ecc17997a65e925f9ee378c4e83e7462931796 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Fri, 24 May 2024 19:50:59 -0500 Subject: [PATCH 44/48] Hexagonal Lattice Roundtrip (#3003) Co-authored-by: Paul Romano --- openmc/lattice.py | 2 ++ tests/unit_tests/test_lattice.py | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/openmc/lattice.py b/openmc/lattice.py index 6282cf21c1..f7f9da8ea8 100644 --- a/openmc/lattice.py +++ b/openmc/lattice.py @@ -1574,6 +1574,7 @@ class HexLattice(Lattice): alpha -= 1 if not lat.is_valid_index((x, alpha, z)): # Reached the bottom + j += 1 break j += 1 else: @@ -1593,6 +1594,7 @@ class HexLattice(Lattice): # Check if we've reached the bottom if y == -n_rings: + j += 1 break while not lat.is_valid_index((alpha, y, z)): diff --git a/tests/unit_tests/test_lattice.py b/tests/unit_tests/test_lattice.py index 6fa32760e6..d72d9c5c3e 100644 --- a/tests/unit_tests/test_lattice.py +++ b/tests/unit_tests/test_lattice.py @@ -377,3 +377,46 @@ def test_unset_universes(): hex_lattice.pitch = (1.,) with pytest.raises(ValueError): hex_lattice.create_xml_subelement(elem) + + +@pytest.mark.parametrize("orientation", ['x', 'y']) +def test_hex_lattice_roundtrip(orientation): + openmc.reset_auto_ids() + + # ensure that the lattice universes are the same on all axial levels + def check_lattice_universes(og_lattice, xml_lattice): + for axial_og, axial_rt in zip(og_lattice.universes, xml_lattice.universes): + for ring_og, ring_rt in zip(axial_og, axial_rt): + assert [u.id for u in ring_og] == [u.id for u in ring_rt] + + latt = openmc.HexLattice() + latt.pitch = (1.0, 1.0) + latt.center = (0.0, 0.0, 0.0) + latt.orientation = orientation + + # fill the lattice with universes in increasing order and repeat for + # the second actial level + lvl_one_univs = [openmc.Universe(cells=[openmc.Cell()]) for _ in range(19)] + lvl_one_univs = [lvl_one_univs[-12:], lvl_one_univs[1:7], lvl_one_univs[:1]] + latt.universes = [lvl_one_univs, lvl_one_univs] + + geom = openmc.Geometry([openmc.Cell(fill=latt)]) + geom.export_to_xml() + + xml_geom = openmc.Geometry.from_xml(materials=openmc.Materials()) + + xml_latt = xml_geom.get_all_lattices()[latt.id] + + check_lattice_universes(latt, xml_latt) + + # same test but with unique universes for each axial level + lvl_two_univs = [openmc.Universe(cells=[openmc.Cell()]) for _ in range(19)] + lvl_two_univs = [lvl_two_univs[-12:], lvl_two_univs[1:7], lvl_two_univs[:1]] + latt.universes = [lvl_one_univs, lvl_two_univs] + + geom.export_to_xml() + + xml_geom = openmc.Geometry.from_xml(materials=openmc.Materials()) + xml_latt = xml_geom.get_all_lattices()[latt.id] + + check_lattice_universes(latt, xml_latt) From 18cd81a6aad6a6aabc633571f0fd7030dbfcf89d Mon Sep 17 00:00:00 2001 From: Chris Wagner <72230352+chrwagne@users.noreply.github.com> Date: Fri, 24 May 2024 23:27:37 -0400 Subject: [PATCH 45/48] added extra error checking on spherical mesh creation (#2973) Co-authored-by: Jonathan Shimwell Co-authored-by: Paul Romano --- openmc/checkvalue.py | 23 +++++++++++++++++++++++ openmc/mesh.py | 6 ++++++ tests/unit_tests/test_mesh.py | 21 +++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/openmc/checkvalue.py b/openmc/checkvalue.py index 840306486f..42df3e5efb 100644 --- a/openmc/checkvalue.py +++ b/openmc/checkvalue.py @@ -171,6 +171,29 @@ def check_length(name, value, length_min, length_max=None): raise ValueError(msg) +def check_increasing(name: str, value, equality: bool = False): + """Ensure that a list's elements are strictly or loosely increasing. + + Parameters + ---------- + name : str + Description of value being checked + value : iterable + Object to check if increasing + equality : bool, optional + Whether equality is allowed. Defaults to False. + + """ + if equality: + if not np.all(np.diff(value) >= 0.0): + raise ValueError(f'Unable to set "{name}" to "{value}" since its ' + 'elements must be increasing.') + elif not equality: + if not np.all(np.diff(value) > 0.0): + raise ValueError(f'Unable to set "{name}" to "{value}" since its ' + 'elements must be strictly increasing.') + + def check_value(name, value, accepted_values): """Ensure that an object's value is contained in a set of acceptable values. diff --git a/openmc/mesh.py b/openmc/mesh.py index 8777da3255..48263c2055 100644 --- a/openmc/mesh.py +++ b/openmc/mesh.py @@ -1820,6 +1820,8 @@ class SphericalMesh(StructuredMesh): @r_grid.setter def r_grid(self, grid): cv.check_type('mesh r_grid', grid, Iterable, Real) + cv.check_length('mesh r_grid', grid, 2) + cv.check_increasing('mesh r_grid', grid) self._r_grid = np.asarray(grid, dtype=float) @property @@ -1829,6 +1831,8 @@ class SphericalMesh(StructuredMesh): @theta_grid.setter def theta_grid(self, grid): cv.check_type('mesh theta_grid', grid, Iterable, Real) + cv.check_length('mesh theta_grid', grid, 2) + cv.check_increasing('mesh theta_grid', grid) self._theta_grid = np.asarray(grid, dtype=float) @property @@ -1838,6 +1842,8 @@ class SphericalMesh(StructuredMesh): @phi_grid.setter def phi_grid(self, grid): cv.check_type('mesh phi_grid', grid, Iterable, Real) + cv.check_length('mesh phi_grid', grid, 2) + cv.check_increasing('mesh phi_grid', grid) self._phi_grid = np.asarray(grid, dtype=float) @property diff --git a/tests/unit_tests/test_mesh.py b/tests/unit_tests/test_mesh.py index c499306506..5bacd5fc69 100644 --- a/tests/unit_tests/test_mesh.py +++ b/tests/unit_tests/test_mesh.py @@ -132,6 +132,27 @@ def test_SphericalMesh_initiation(): mesh.r_grid = (0, 11) assert (mesh.r_grid == np.array([0., 11.])).all() + # test invalid r_grid values + with pytest.raises(ValueError): + openmc.SphericalMesh(r_grid=[1, 1]) + + with pytest.raises(ValueError): + openmc.SphericalMesh(r_grid=[0]) + + # test invalid theta_grid values + with pytest.raises(ValueError): + openmc.SphericalMesh(r_grid=[1, 2], theta_grid=[1, 1]) + + with pytest.raises(ValueError): + openmc.SphericalMesh(r_grid=[1, 2], theta_grid=[0]) + + # test invalid phi_grid values + with pytest.raises(ValueError): + openmc.SphericalMesh(r_grid=[1, 2], phi_grid=[1, 1]) + + with pytest.raises(ValueError): + openmc.SphericalMesh(r_grid=[1, 2], phi_grid=[0]) + # waffles and pancakes are unfortunately not valid radii with pytest.raises(TypeError): openmc.SphericalMesh(('🧇', '🥞')) From 0b686e365edffc9034802a1cfe1e433e18b6ecb2 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Sat, 25 May 2024 14:11:21 -0500 Subject: [PATCH 46/48] Correction for histogram interpolation of Tabular distributions (#2981) Co-authored-by: Paul Romano --- openmc/data/thermal.py | 4 +-- openmc/stats/univariate.py | 60 ++++++++++++++++++---------------- tests/unit_tests/test_stats.py | 23 +++++++++++-- 3 files changed, 54 insertions(+), 33 deletions(-) diff --git a/openmc/data/thermal.py b/openmc/data/thermal.py index f5841d9c90..6df171d3a4 100644 --- a/openmc/data/thermal.py +++ b/openmc/data/thermal.py @@ -698,8 +698,8 @@ class ThermalScattering(EqualityMixin): # add an outgoing energy 0 eV that has a PDF of 0 (and of # course, a CDF of 0 as well). if eout_i.c[0] > 0.: - eout_i.x = np.insert(eout_i.x, 0, 0.) - eout_i.p = np.insert(eout_i.p, 0, 0.) + eout_i._x = np.insert(eout_i.x, 0, 0.) + eout_i._p = np.insert(eout_i.p, 0, 0.) eout_i.c = np.insert(eout_i.c, 0, 0.) # For this added outgoing energy (of 0 eV) we add a set of diff --git a/openmc/stats/univariate.py b/openmc/stats/univariate.py index a0e86c3f8f..f44bce67ce 100644 --- a/openmc/stats/univariate.py +++ b/openmc/stats/univariate.py @@ -837,10 +837,11 @@ class Tabular(Univariate): x : Iterable of float Tabulated values of the random variable p : Iterable of float - Tabulated probabilities + Tabulated probabilities. For histogram interpolation, if the length of + `p` is the same as `x`, the last value is ignored. interpolation : {'histogram', 'linear-linear', 'linear-log', 'log-linear', 'log-log'}, optional - Indicate whether the density function is constant between tabulated - points or linearly-interpolated. Defaults to 'linear-linear'. + Indicates how the density function is interpolated between tabulated + points. Defaults to 'linear-linear'. ignore_negative : bool Ignore negative probabilities @@ -850,9 +851,9 @@ class Tabular(Univariate): Tabulated values of the random variable p : numpy.ndarray Tabulated probabilities - interpolation : {'histogram', 'linear-linear', 'linear-log', 'log-linear', 'log-log'}, optional - Indicate whether the density function is constant between tabulated - points or linearly-interpolated. + interpolation : {'histogram', 'linear-linear', 'linear-log', 'log-linear', 'log-log'} + Indicates how the density function is interpolated between tabulated + points. Defaults to 'linear-linear'. """ @@ -863,35 +864,38 @@ class Tabular(Univariate): interpolation: str = 'linear-linear', ignore_negative: bool = False ): - self._ignore_negative = ignore_negative - self.x = x - self.p = p self.interpolation = interpolation + cv.check_type('tabulated values', x, Iterable, Real) + cv.check_type('tabulated probabilities', p, Iterable, Real) + + x = np.array(x, dtype=float) + p = np.array(p, dtype=float) + + if p.size > x.size: + raise ValueError('Number of probabilities exceeds number of table values.') + if self.interpolation != 'histogram' and x.size != p.size: + raise ValueError(f'Tabulated values ({x.size}) and probabilities ' + f'({p.size}) should have the same length') + + if not ignore_negative: + for pk in p: + cv.check_greater_than('tabulated probability', pk, 0.0, True) + + self._x = x + self._p = p + def __len__(self): - return len(self.x) + return self.p.size @property def x(self): return self._x - @x.setter - def x(self, x): - cv.check_type('tabulated values', x, Iterable, Real) - self._x = np.array(x, dtype=float) - @property def p(self): return self._p - @p.setter - def p(self, p): - cv.check_type('tabulated probabilities', p, Iterable, Real) - if not self._ignore_negative: - for pk in p: - cv.check_greater_than('tabulated probability', pk, 0.0, True) - self._p = np.array(p, dtype=float) - @property def interpolation(self): return self._interpolation @@ -907,7 +911,7 @@ class Tabular(Univariate): p = self.p if self.interpolation == 'histogram': - c[1:] = p[:-1] * np.diff(x) + c[1:] = p[:x.size-1] * np.diff(x) elif self.interpolation == 'linear-linear': c[1:] = 0.5 * (p[:-1] + p[1:]) * np.diff(x) else: @@ -938,7 +942,7 @@ class Tabular(Univariate): elif self.interpolation == 'histogram': x_l = self.x[:-1] x_r = self.x[1:] - p_l = self.p[:-1] + p_l = self.p[:self.x.size-1] mean = (0.5 * (x_l + x_r) * (x_r - x_l) * p_l).sum() else: raise NotImplementedError('Can only compute mean for tabular ' @@ -952,7 +956,7 @@ class Tabular(Univariate): def normalize(self): """Normalize the probabilities stored on the distribution""" - self.p /= self.cdf().max() + self._p /= self.cdf().max() def sample(self, n_samples: int = 1, seed: typing.Optional[int] = None): rng = np.random.RandomState(seed) @@ -1070,7 +1074,7 @@ class Tabular(Univariate): Integral of tabular distrbution """ if self.interpolation == 'histogram': - return np.sum(np.diff(self.x) * self.p[:-1]) + return np.sum(np.diff(self.x) * self.p[:self.x.size-1]) elif self.interpolation == 'linear-linear': return trapezoid(self.p, self.x) else: @@ -1346,7 +1350,7 @@ def combine_distributions( # Apply probabilites to continuous distributions for i in cont_index: dist = dist_list[i] - dist.p *= probs[i] + dist._p *= probs[i] if discrete_index: # Create combined discrete distribution diff --git a/tests/unit_tests/test_stats.py b/tests/unit_tests/test_stats.py index 761f26ab3d..f5b467d038 100644 --- a/tests/unit_tests/test_stats.py +++ b/tests/unit_tests/test_stats.py @@ -92,7 +92,7 @@ def test_clip_discrete(): with pytest.raises(ValueError): d.clip(-1.) - + with pytest.raises(ValueError): d.clip(5) @@ -188,8 +188,8 @@ def test_watt(): def test_tabular(): - x = np.array([0.0, 5.0, 7.0]) - p = np.array([10.0, 20.0, 5.0]) + x = np.array([0.0, 5.0, 7.0, 10.0]) + p = np.array([10.0, 20.0, 5.0, 6.0]) d = openmc.stats.Tabular(x, p, 'linear-linear') elem = d.to_xml_element('distribution') @@ -217,6 +217,23 @@ def test_tabular(): d.normalize() assert d.integral() == pytest.approx(1.0) + # ensure that passing a set of probabilities shorter than x works + # for histogram interpolation + d = openmc.stats.Tabular(x, p[:-1], interpolation='histogram') + d.cdf() + d.mean() + assert_sample_mean(d.sample(n_samples), d.mean()) + + # passing a shorter probability set should raise an error for linear-linear + with pytest.raises(ValueError): + d = openmc.stats.Tabular(x, p[:-1], interpolation='linear-linear') + d.cdf() + + # Use probabilities of correct length for linear-linear interpolation and + # call the CDF method + d = openmc.stats.Tabular(x, p, interpolation='linear-linear') + d.cdf() + def test_legendre(): # Pu239 elastic scattering at 100 keV From 342019971839c7195101af9ac1b169de3726742a Mon Sep 17 00:00:00 2001 From: Olek <45364492+yardasol@users.noreply.github.com> Date: Tue, 28 May 2024 12:14:38 -0400 Subject: [PATCH 47/48] Fix `CylinderSector` and `IsogonalOctagon` translations (#3018) Co-authored-by: Paul Romano --- openmc/model/surface_composite.py | 56 ++++++++++++++-------- tests/unit_tests/test_surface_composite.py | 37 ++++++++------ 2 files changed, 59 insertions(+), 34 deletions(-) diff --git a/openmc/model/surface_composite.py b/openmc/model/surface_composite.py index 6f9123311f..1fa4234fbf 100644 --- a/openmc/model/surface_composite.py +++ b/openmc/model/surface_composite.py @@ -90,7 +90,7 @@ class CylinderSector(CompositeSurface): counterclockwise direction with respect to the first basis axis (+y, +z, or +x). Must be greater than :attr:`theta1`. center : iterable of float - Coordinate for central axes of cylinders in the (y, z), (z, x), or (x, y) + Coordinate for central axes of cylinders in the (y, z), (x, z), or (x, y) basis. Defaults to (0,0). axis : {'x', 'y', 'z'} Central axis of the cylinders defining the inner and outer surfaces of @@ -133,13 +133,13 @@ class CylinderSector(CompositeSurface): phi2 = pi / 180 * theta2 # Coords for axis-perpendicular planes - p1 = np.array([0., 0., 1.]) + p1 = np.array([center[0], center[1], 1.]) - p2_plane1 = np.array([r1 * cos(phi1), r1 * sin(phi1), 0.]) - p3_plane1 = np.array([r2 * cos(phi1), r2 * sin(phi1), 0.]) + p2_plane1 = np.array([r1 * cos(phi1) + center[0], r1 * sin(phi1) + center[1], 0.]) + p3_plane1 = np.array([r2 * cos(phi1) + center[0], r2 * sin(phi1) + center[1], 0.]) - p2_plane2 = np.array([r1 * cos(phi2), r1 * sin(phi2), 0.]) - p3_plane2 = np.array([r2 * cos(phi2), r2 * sin(phi2), 0.]) + p2_plane2 = np.array([r1 * cos(phi2) + center[0], r1 * sin(phi2)+ center[1], 0.]) + p3_plane2 = np.array([r2 * cos(phi2) + center[0], r2 * sin(phi2)+ center[1], 0.]) points = [p1, p2_plane1, p3_plane1, p2_plane2, p3_plane2] if axis == 'z': @@ -147,7 +147,7 @@ class CylinderSector(CompositeSurface): self.inner_cyl = openmc.ZCylinder(*center, r1, **kwargs) self.outer_cyl = openmc.ZCylinder(*center, r2, **kwargs) elif axis == 'y': - coord_map = [1, 2, 0] + coord_map = [0, 2, 1] self.inner_cyl = openmc.YCylinder(*center, r1, **kwargs) self.outer_cyl = openmc.YCylinder(*center, r2, **kwargs) elif axis == 'x': @@ -155,6 +155,7 @@ class CylinderSector(CompositeSurface): self.inner_cyl = openmc.XCylinder(*center, r1, **kwargs) self.outer_cyl = openmc.XCylinder(*center, r2, **kwargs) + # Reorder the points to correspond to the correct central axis for p in points: p[:] = p[coord_map] @@ -192,8 +193,8 @@ class CylinderSector(CompositeSurface): with respect to the first basis axis (+y, +z, or +x). Note that negative values translate to an offset in the clockwise direction. center : iterable of float - Coordinate for central axes of cylinders in the (y, z), (z, x), or (x, y) - basis. Defaults to (0,0). + Coordinate for central axes of cylinders in the (y, z), (x, z), or + (x, y) basis. Defaults to (0,0). axis : {'x', 'y', 'z'} Central axis of the cylinders defining the inner and outer surfaces of the sector. Defaults to 'z'. @@ -216,10 +217,16 @@ class CylinderSector(CompositeSurface): return cls(r1, r2, theta1, theta2, center=center, axis=axis, **kwargs) def __neg__(self): - return -self.outer_cyl & +self.inner_cyl & -self.plane1 & +self.plane2 + if isinstance(self.inner_cyl, openmc.YCylinder): + return -self.outer_cyl & +self.inner_cyl & +self.plane1 & -self.plane2 + else: + return -self.outer_cyl & +self.inner_cyl & -self.plane1 & +self.plane2 def __pos__(self): - return +self.outer_cyl | -self.inner_cyl | +self.plane1 | -self.plane2 + if isinstance(self.inner_cyl, openmc.YCylinder): + return +self.outer_cyl | -self.inner_cyl | -self.plane1 | +self.plane2 + else: + return +self.outer_cyl | -self.inner_cyl | +self.plane1 | -self.plane2 class IsogonalOctagon(CompositeSurface): @@ -284,11 +291,11 @@ class IsogonalOctagon(CompositeSurface): c1, c2 = center # Coords for axis-perpendicular planes - ctop = c1 + r1 - cbottom = c1 - r1 + cright = c1 + r1 + cleft = c1 - r1 - cright = c2 + r1 - cleft = c2 - r1 + ctop = c2 + r1 + cbottom = c2 - r1 # Side lengths if r2 > r1 * sqrt(2): @@ -309,7 +316,16 @@ class IsogonalOctagon(CompositeSurface): p2_lr = np.array([L_basis_ax, -r1, 0.]) p3_lr = np.array([L_basis_ax, -r1, 1.]) - points = [p1_ur, p2_ur, p3_ur, p1_lr, p2_lr, p3_lr] + p1_ll = -p1_ur + p2_ll = -p2_ur + p3_ll = -p3_ur + + p1_ul = -p1_lr + p2_ul = -p2_lr + p3_ul = -p3_lr + + points = [p1_ur, p2_ur, p3_ur, p1_lr, p2_lr, p3_lr, + p1_ll, p2_ll, p3_ll, p1_ul, p2_ul, p3_ul] # Orientation specific variables if axis == 'z': @@ -331,17 +347,19 @@ class IsogonalOctagon(CompositeSurface): self.right = openmc.YPlane(cright, **kwargs) self.left = openmc.YPlane(cleft, **kwargs) - # Put our coordinates in (x,y,z) order + # Put our coordinates in (x,y,z) order and add the offset for p in points: + p[0] += c1 + p[1] += c2 p[:] = p[coord_map] self.upper_right = openmc.Plane.from_points(p1_ur, p2_ur, p3_ur, **kwargs) self.lower_right = openmc.Plane.from_points(p1_lr, p2_lr, p3_lr, **kwargs) - self.lower_left = openmc.Plane.from_points(-p1_ur, -p2_ur, -p3_ur, + self.lower_left = openmc.Plane.from_points(p1_ll, p2_ll, p3_ll, **kwargs) - self.upper_left = openmc.Plane.from_points(-p1_lr, -p2_lr, -p3_lr, + self.upper_left = openmc.Plane.from_points(p1_ul, p2_ul, p3_ul, **kwargs) def __neg__(self): diff --git a/tests/unit_tests/test_surface_composite.py b/tests/unit_tests/test_surface_composite.py index 19221212e0..62ec18b326 100644 --- a/tests/unit_tests/test_surface_composite.py +++ b/tests/unit_tests/test_surface_composite.py @@ -158,18 +158,23 @@ def test_cone_one_sided(axis, point_pos, point_neg, ll_true): @pytest.mark.parametrize( - "axis, indices", [ - ("X", [0, 1, 2]), - ("Y", [1, 2, 0]), - ("Z", [2, 0, 1]), + "axis, indices, center", [ + ("X", [2, 0, 1], (0., 0.)), + ("Y", [0, 2, 1], (0., 0.)), + ("Z", [0, 1, 2], (0., 0.)), + ("X", [2, 0, 1], (10., 5.)), + ("Y", [0, 2, 1], (10., 5.)), + ("Z", [0, 1, 2], (10., 5.)), + ] ) -def test_cylinder_sector(axis, indices): +def test_cylinder_sector(axis, indices, center): + c1, c2 = center r1, r2 = 0.5, 1.5 d = (r2 - r1) / 2 phi1 = -60. phi2 = 60 - s = openmc.model.CylinderSector(r1, r2, phi1, phi2, + s = openmc.model.CylinderSector(r1, r2, phi1, phi2, center=center, axis=axis.lower()) assert isinstance(s.outer_cyl, getattr(openmc, axis + "Cylinder")) assert isinstance(s.inner_cyl, getattr(openmc, axis + "Cylinder")) @@ -189,16 +194,18 @@ def test_cylinder_sector(axis, indices): assert np.all(np.isinf(ll)) assert np.all(np.isinf(ur)) ll, ur = (-s).bounding_box - assert ll == pytest.approx(np.roll([-np.inf, -r2, -r2], indices[0])) - assert ur == pytest.approx(np.roll([np.inf, r2, r2], indices[0])) + test_point_ll = np.array([-r2 + c1, -r2 + c2, -np.inf]) + assert ll == pytest.approx(test_point_ll[indices]) + test_point_ur = np.array([r2 + c1, r2 + c2, np.inf]) + assert ur == pytest.approx(test_point_ur[indices]) # __contains__ on associated half-spaces - point_pos = np.roll([0, r2 + 1, 0], indices[0]) - assert point_pos in +s - assert point_pos not in -s - point_neg = np.roll([0, r1 + d, r1 + d], indices[0]) - assert point_neg in -s - assert point_neg not in +s + point_pos = np.array([0 + c1, r2 + 1 + c2, 0]) + assert point_pos[indices] in +s + assert point_pos[indices] not in -s + point_neg = np.array([r1 + d + c1, r1 + d + c2, 0]) + assert point_neg[indices] in -s + assert point_neg[indices] not in +s # translate method t = uniform(-5.0, 5.0) @@ -399,7 +406,7 @@ def test_polygon(): with pytest.raises(ValueError): openmc.model.Polygon(rz_points) - # Test "M" shaped polygon + # Test "M" shaped polygon points = np.array([[8.5151581, -17.988337], [10.381711000000001, -17.988337], [12.744357, -24.288728000000003], From 2a53aba1c171773e11305a841e8b714d81c5e186 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Mon, 3 Jun 2024 11:34:37 -0500 Subject: [PATCH 48/48] Make sure direction is set when checking source spatial constraints (#3022) --- src/source.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/source.cpp b/src/source.cpp index 405de998c8..15fe8433ba 100644 --- a/src/source.cpp +++ b/src/source.cpp @@ -211,6 +211,7 @@ bool Source::satisfies_spatial_constraints(Position r) const { GeometryState geom_state; geom_state.r() = r; + geom_state.u() = {0.0, 0.0, 1.0}; // Reject particle if it's not in the geometry at all bool found = exhaustive_find_cell(geom_state);