• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

openmc-dev / openmc / 21489819490

29 Jan 2026 06:21PM UTC coverage: 80.077% (-1.9%) from 81.953%
21489819490

Pull #3757

github

web-flow
Merge d08626053 into f7a734189
Pull Request #3757: Testing point detectors

16004 of 22621 branches covered (70.75%)

Branch coverage included in aggregate %.

94 of 518 new or added lines in 26 files covered. (18.15%)

1021 existing lines in 52 files now uncovered.

53779 of 64524 relevant lines covered (83.35%)

8016833.26 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.16
/openmc/model/model.py
1
from __future__ import annotations
1✔
2
from collections.abc import Callable, Iterable, Sequence
1✔
3
import copy
1✔
4
from dataclasses import dataclass, field
1✔
5
from functools import cache
1✔
6
from pathlib import Path
1✔
7
import math
1✔
8
from numbers import Integral, Real
1✔
9
import random
1✔
10
import re
1✔
11
from tempfile import NamedTemporaryFile, TemporaryDirectory
1✔
12
from typing import Any, Protocol
1✔
13
import warnings
1✔
14

15
import h5py
1✔
16
import lxml.etree as ET
1✔
17
import numpy as np
1✔
18
from scipy.optimize import curve_fit
1✔
19

20
import openmc
1✔
21
import openmc._xml as xml
1✔
22
from openmc.dummy_comm import DummyCommunicator
1✔
23
from openmc.executor import _process_CLI_arguments
1✔
24
from openmc.checkvalue import check_type, check_value, PathLike
1✔
25
from openmc.exceptions import InvalidIDError
1✔
26
from openmc.plots import add_plot_params, _BASIS_INDICES, id_map_to_rgb
1✔
27
from openmc.utility_funcs import change_directory
1✔
28

29

30
# Protocol for a function that is passed to search_keff
31
class ModelModifier(Protocol):
1✔
32
    def __call__(self, val: float, **kwargs: Any) -> None:
33
        ...
34

35

36
class Model:
1✔
37
    """Model container.
38

39
    This class can be used to store instances of :class:`openmc.Geometry`,
40
    :class:`openmc.Materials`, :class:`openmc.Settings`,
41
    :class:`openmc.Tallies`, and :class:`openmc.Plots`, thus making a complete
42
    model. The :meth:`Model.export_to_xml` method will export XML files for all
43
    attributes that have been set. If the :attr:`Model.materials` attribute is
44
    not set, it will attempt to create a ``materials.xml`` file based on all
45
    materials appearing in the geometry.
46

47
    .. versionchanged:: 0.13.0
48
        The model information can now be loaded in to OpenMC directly via
49
        openmc.lib
50

51
    Parameters
52
    ----------
53
    geometry : openmc.Geometry, optional
54
        Geometry information
55
    materials : openmc.Materials, optional
56
        Materials information
57
    settings : openmc.Settings, optional
58
        Settings information
59
    tallies : openmc.Tallies, optional
60
        Tallies information
61
    plots : openmc.Plots, optional
62
        Plot information
63

64
    Attributes
65
    ----------
66
    geometry : openmc.Geometry
67
        Geometry information
68
    materials : openmc.Materials
69
        Materials information
70
    settings : openmc.Settings
71
        Settings information
72
    tallies : openmc.Tallies
73
        Tallies information
74
    plots : openmc.Plots
75
        Plot information
76

77
    """
78

79
    def __init__(
1✔
80
        self,
81
        geometry: openmc.Geometry | None = None,
82
        materials: openmc.Materials | None = None,
83
        settings: openmc.Settings | None = None,
84
        tallies: openmc.Tallies | None = None,
85
        plots: openmc.Plots | None = None,
86
    ):
87
        self.geometry = openmc.Geometry() if geometry is None else geometry
1✔
88
        self.materials = openmc.Materials() if materials is None else materials
1✔
89
        self.settings = openmc.Settings() if settings is None else settings
1✔
90
        self.tallies = openmc.Tallies() if tallies is None else tallies
1✔
91
        self.plots = openmc.Plots() if plots is None else plots
1✔
92

93
    @property
1✔
94
    def geometry(self) -> openmc.Geometry:
1✔
95
        return self._geometry
1✔
96

97
    @geometry.setter
1✔
98
    def geometry(self, geometry):
1✔
99
        check_type('geometry', geometry, openmc.Geometry)
1✔
100
        self._geometry = geometry
1✔
101

102
    @property
1✔
103
    def materials(self) -> openmc.Materials:
1✔
104
        return self._materials
1✔
105

106
    @materials.setter
1✔
107
    def materials(self, materials):
1✔
108
        check_type('materials', materials, Iterable, openmc.Material)
1✔
109
        if isinstance(materials, openmc.Materials):
1✔
110
            self._materials = materials
1✔
111
        else:
112
            if not hasattr(self, '_materials'):
1✔
113
                self._materials = openmc.Materials()
1✔
114
            del self._materials[:]
1✔
115
            for mat in materials:
1✔
116
                self._materials.append(mat)
1✔
117

118
    @property
1✔
119
    def settings(self) -> openmc.Settings:
1✔
120
        return self._settings
1✔
121

122
    @settings.setter
1✔
123
    def settings(self, settings):
1✔
124
        check_type('settings', settings, openmc.Settings)
1✔
125
        self._settings = settings
1✔
126

127
    @property
1✔
128
    def tallies(self) -> openmc.Tallies:
1✔
129
        return self._tallies
1✔
130

131
    @tallies.setter
1✔
132
    def tallies(self, tallies):
1✔
133
        check_type('tallies', tallies, Iterable, openmc.Tally)
1✔
134
        if isinstance(tallies, openmc.Tallies):
1✔
135
            self._tallies = tallies
1✔
136
        else:
137
            if not hasattr(self, '_tallies'):
1✔
138
                self._tallies = openmc.Tallies()
1✔
139
            del self._tallies[:]
1✔
140
            for tally in tallies:
1✔
141
                self._tallies.append(tally)
1✔
142

143
    @property
1✔
144
    def plots(self) -> openmc.Plots:
1✔
145
        return self._plots
1✔
146

147
    @plots.setter
1✔
148
    def plots(self, plots):
1✔
149
        check_type('plots', plots, Iterable, openmc.PlotBase)
1✔
150
        if isinstance(plots, openmc.Plots):
1✔
151
            self._plots = plots
1✔
152
        else:
153
            if not hasattr(self, '_plots'):
1✔
154
                self._plots = openmc.Plots()
1✔
155
            del self._plots[:]
1✔
156
            for plot in plots:
1✔
157
                self._plots.append(plot)
1✔
158

159
    @property
1✔
160
    def bounding_box(self) -> openmc.BoundingBox:
1✔
161
        return self.geometry.bounding_box
1✔
162

163
    @property
1✔
164
    def is_initialized(self) -> bool:
1✔
165
        try:
1✔
166
            import openmc.lib
1✔
167
            return openmc.lib.is_initialized
1✔
168
        except ImportError:
×
169
            return False
×
170

171
    @property
1✔
172
    @cache
1✔
173
    def _materials_by_id(self) -> dict:
1✔
174
        """Dictionary mapping material ID --> material"""
175
        if self.materials:
1✔
176
            mats = self.materials
1✔
177
        else:
178
            mats = self.geometry.get_all_materials().values()
1✔
179
        return {mat.id: mat for mat in mats}
1✔
180

181
    @property
1✔
182
    @cache
1✔
183
    def _cells_by_id(self) -> dict:
1✔
184
        """Dictionary mapping cell ID --> cell"""
185
        cells = self.geometry.get_all_cells()
1✔
186
        return {cell.id: cell for cell in cells.values()}
1✔
187

188
    @property
1✔
189
    @cache
1✔
190
    def _cells_by_name(self) -> dict[int, openmc.Cell]:
1✔
191
        # Get the names maps, but since names are not unique, store a set for
192
        # each name key. In this way when the user requests a change by a name,
193
        # the change will be applied to all of the same name.
194
        result = {}
1✔
195
        for cell in self.geometry.get_all_cells().values():
1✔
196
            if cell.name not in result:
1✔
197
                result[cell.name] = set()
1✔
198
            result[cell.name].add(cell)
1✔
199
        return result
1✔
200

201
    @property
1✔
202
    @cache
1✔
203
    def _materials_by_name(self) -> dict[int, openmc.Material]:
1✔
204
        if self.materials is None:
1✔
205
            mats = self.geometry.get_all_materials().values()
×
206
        else:
207
            mats = self.materials
1✔
208
        result = {}
1✔
209
        for mat in mats:
1✔
210
            if mat.name not in result:
1✔
211
                result[mat.name] = set()
1✔
212
            result[mat.name].add(mat)
1✔
213
        return result
1✔
214

215
    # TODO: This should really get incorporated in lower-level calls to
216
    # get_all_materials, but right now it requires information from the Model object
217
    def _get_all_materials(self) -> dict[int, openmc.Material]:
1✔
218
        """Get all materials including those in DAGMC universes
219

220
        Returns
221
        -------
222
        dict
223
            Dictionary mapping material ID to material instances
224
        """
225
        # Get all materials from the Geometry object
226
        materials = self.geometry.get_all_materials()
1✔
227

228
        # Account for materials in DAGMC universes
229
        for cell in self.geometry.get_all_cells().values():
1✔
230
            if isinstance(cell.fill, openmc.DAGMCUniverse):
1✔
231
                names = cell.fill.material_names
×
232
                materials.update({
×
233
                    mat.id: mat for mat in self.materials if mat.name in names
234
                })
235

236
        return materials
1✔
237

238
    def add_kinetics_parameters_tallies(self, num_groups: int | None = None):
1✔
239
        """Add tallies for calculating kinetics parameters using the IFP method.
240

241
        This method adds tallies to the model for calculating two kinetics
242
        parameters, the generation time and the effective delayed neutron
243
        fraction (beta effective). After a model is run, these parameters can be
244
        determined through the :meth:`openmc.StatePoint.ifp_results` method.
245

246
        Parameters
247
        ----------
248
        num_groups : int, optional
249
            Number of precursor groups to filter the delayed neutron fraction.
250
            If None, only the total effective delayed neutron fraction is
251
            tallied.
252

253
        """
254
        if not any('ifp-time-numerator' in t.scores for t in self.tallies):
1✔
255
            gen_time_tally = openmc.Tally(name='IFP time numerator')
1✔
256
            gen_time_tally.scores = ['ifp-time-numerator']
1✔
257
            self.tallies.append(gen_time_tally)
1✔
258
        if not any('ifp-beta-numerator' in t.scores for t in self.tallies):
1✔
259
            beta_tally = openmc.Tally(name='IFP beta numerator')
1✔
260
            beta_tally.scores = ['ifp-beta-numerator']
1✔
261
            if num_groups is not None:
1✔
262
                beta_tally.filters = [openmc.DelayedGroupFilter(list(range(1, num_groups + 1)))]
1✔
263
            self.tallies.append(beta_tally)
1✔
264
        if not any('ifp-denominator' in t.scores for t in self.tallies):
1✔
265
            denom_tally = openmc.Tally(name='IFP denominator')
1✔
266
            denom_tally.scores = ['ifp-denominator']
1✔
267
            self.tallies.append(denom_tally)
1✔
268

269
    @classmethod
1✔
270
    def from_xml(
1✔
271
        cls,
272
        geometry: PathLike = "geometry.xml",
273
        materials: PathLike = "materials.xml",
274
        settings: PathLike = "settings.xml",
275
        tallies: PathLike = "tallies.xml",
276
        plots: PathLike = "plots.xml",
277
    ) -> Model:
278
        """Create model from existing XML files
279

280
        Parameters
281
        ----------
282
        geometry : PathLike
283
            Path to geometry.xml file
284
        materials : PathLike
285
            Path to materials.xml file
286
        settings : PathLike
287
            Path to settings.xml file
288
        tallies : PathLike
289
            Path to tallies.xml file
290

291
            .. versionadded:: 0.13.0
292
        plots : PathLike
293
            Path to plots.xml file
294

295
            .. versionadded:: 0.13.0
296

297
        Returns
298
        -------
299
        openmc.model.Model
300
            Model created from XML files
301

302
        """
303
        materials = openmc.Materials.from_xml(materials)
1✔
304
        geometry = openmc.Geometry.from_xml(geometry, materials)
1✔
305
        settings = openmc.Settings.from_xml(settings)
1✔
306
        tallies = openmc.Tallies.from_xml(
1✔
307
            tallies) if Path(tallies).exists() else None
308
        plots = openmc.Plots.from_xml(plots) if Path(plots).exists() else None
1✔
309
        return cls(geometry, materials, settings, tallies, plots)
1✔
310

311
    @classmethod
1✔
312
    def from_model_xml(cls, path: PathLike = "model.xml") -> Model:
1✔
313
        """Create model from single XML file
314

315
        .. versionadded:: 0.13.3
316

317
        Parameters
318
        ----------
319
        path : PathLike
320
            Path to model.xml file
321
        """
322
        parser = ET.XMLParser(huge_tree=True)
1✔
323
        tree = ET.parse(path, parser=parser)
1✔
324
        root = tree.getroot()
1✔
325

326
        model = cls()
1✔
327

328
        meshes = {}
1✔
329
        model.settings = openmc.Settings.from_xml_element(
1✔
330
            root.find('settings'), meshes)
331
        model.materials = openmc.Materials.from_xml_element(
1✔
332
            root.find('materials'))
333
        model.geometry = openmc.Geometry.from_xml_element(
1✔
334
            root.find('geometry'), model.materials)
335

336
        if root.find('tallies') is not None:
1✔
337
            model.tallies = openmc.Tallies.from_xml_element(
1✔
338
                root.find('tallies'), meshes)
339

340
        if root.find('plots') is not None:
1✔
341
            model.plots = openmc.Plots.from_xml_element(root.find('plots'))
1✔
342

343
        return model
1✔
344

345
    def init_lib(
1✔
346
        self,
347
        threads: int | None = None,
348
        geometry_debug: bool = False,
349
        restart_file: PathLike | None = None,
350
        tracks: bool = False,
351
        output: bool = True,
352
        event_based: bool | None = None,
353
        intracomm=None,
354
        directory: PathLike | None = None,
355
    ):
356
        """Initializes the model in memory via the C API
357

358
        .. versionadded:: 0.13.0
359

360
        Parameters
361
        ----------
362
        threads : int, optional
363
            Number of OpenMP threads. If OpenMC is compiled with OpenMP
364
            threading enabled, the default is implementation-dependent but is
365
            usually equal to the number of hardware threads available
366
            (or a value set by the :envvar:`OMP_NUM_THREADS` environment
367
            variable).
368
        geometry_debug : bool, optional
369
            Turn on geometry debugging during simulation. Defaults to False.
370
        restart_file : PathLike, optional
371
            Path to restart file to use
372
        tracks : bool, optional
373
            Enables the writing of particles tracks. The number of particle
374
            tracks written to tracks.h5 is limited to 1000 unless
375
            Settings.max_tracks is set. Defaults to False.
376
        output : bool
377
            Capture OpenMC output from standard out
378
        event_based : None or bool, optional
379
            Turns on event-based parallelism if True. If None, the value in
380
            the Settings will be used.
381
        intracomm : mpi4py.MPI.Intracomm or None, optional
382
            MPI intracommunicator
383
        directory : PathLike or None, optional
384
            Directory to write XML files to. Defaults to None.
385
        """
386

387
        import openmc.lib
1✔
388

389
        # TODO: right now the only way to set most of the above parameters via
390
        # the C API are at initialization time despite use-cases existing to
391
        # set them for individual runs. For now this functionality is exposed
392
        # where it exists (here in init), but in the future the functionality
393
        # should be exposed so that it can be accessed via model.run(...)
394

395
        args = _process_CLI_arguments(
1✔
396
            volume=False, geometry_debug=geometry_debug,
397
            restart_file=restart_file, threads=threads, tracks=tracks,
398
            event_based=event_based, path_input=directory)
399

400
        # Args adds the openmc_exec command in the first entry; remove it
401
        args = args[1:]
1✔
402

403
        self.finalize_lib()
1✔
404

405
        # The Model object needs to be aware of the communicator so it can
406
        # use it in certain cases, therefore lets store the communicator
407
        if intracomm is not None:
1✔
UNCOV
408
            self._intracomm = intracomm
×
409
        else:
410
            self._intracomm = DummyCommunicator()
1✔
411

412
        if self._intracomm.rank == 0:
1✔
413
            if directory is not None:
1✔
UNCOV
414
                self.export_to_xml(directory=directory)
×
415
            else:
416
                self.export_to_xml()
1✔
417
        self._intracomm.barrier()
1✔
418

419
        # We cannot pass DummyCommunicator to openmc.lib.init so pass instead
420
        # the user-provided intracomm which will either be None or an mpi4py
421
        # communicator
422
        openmc.lib.init(args=args, intracomm=intracomm, output=output)
1✔
423

424
    def sync_dagmc_universes(self):
1✔
425
        """Synchronize all DAGMC universes in the current geometry.
426

427
        This method iterates over all DAGMC universes in the geometry and
428
        synchronizes their cells with the current material assignments. Requires
429
        that the model has been initialized via :meth:`Model.init_lib`.
430

431
        .. versionadded:: 0.15.1
432

433
        """
UNCOV
434
        if self.is_initialized:
×
UNCOV
435
            if self.materials:
×
UNCOV
436
                materials = self.materials
×
437
            else:
438
                materials = list(self.geometry.get_all_materials().values())
×
UNCOV
439
            for univ in self.geometry.get_all_universes().values():
×
UNCOV
440
                if isinstance(univ, openmc.DAGMCUniverse):
×
UNCOV
441
                    univ.sync_dagmc_cells(materials)
×
442
        else:
443
            raise ValueError("The model must be initialized before calling "
×
444
                             "this method")
445

446
    def finalize_lib(self):
1✔
447
        """Finalize simulation and free memory allocated for the C API
448

449
        .. versionadded:: 0.13.0
450

451
        """
452

453
        import openmc.lib
1✔
454

455
        openmc.lib.finalize()
1✔
456

457
    def deplete(
1✔
458
        self,
459
        method: str = "cecm",
460
        final_step: bool = True,
461
        operator_kwargs: dict | None = None,
462
        directory: PathLike = ".",
463
        output: bool = True,
464
        **integrator_kwargs,
465
    ):
466
        """Deplete model using specified timesteps/power
467

468
        .. versionchanged:: 0.13.0
469
            The *final_step*, *operator_kwargs*, *directory*, and *output*
470
            arguments were added.
471

472
        Parameters
473
        ----------
474
        timesteps : iterable of float or iterable of tuple
475
            Array of timesteps. Note that values are not cumulative. The units are
476
            specified by the `timestep_units` argument when `timesteps` is an
477
            iterable of float. Alternatively, units can be specified for each step
478
            by passing an iterable of (value, unit) tuples.
479
        method : str
480
             Integration method used for depletion (e.g., 'cecm', 'predictor').
481
             Defaults to 'cecm'.
482
        final_step : bool, optional
483
            Indicate whether or not a transport solve should be run at the end
484
            of the last timestep. Defaults to running this transport solve.
485
        operator_kwargs : dict
486
            Keyword arguments passed to the depletion operator initializer
487
            (e.g., :func:`openmc.deplete.Operator`)
488
        directory : PathLike, optional
489
            Directory to write XML files to. If it doesn't exist already, it
490
            will be created. Defaults to the current working directory
491
        output : bool
492
            Capture OpenMC output from standard out
493
        integrator_kwargs : dict
494
            Remaining keyword arguments passed to the depletion integrator
495
            (e.g., :class:`openmc.deplete.CECMIntegrator`).
496

497
        """
498

499
        if operator_kwargs is None:
1✔
500
            op_kwargs = {}
×
501
        elif isinstance(operator_kwargs, dict):
1✔
502
            op_kwargs = operator_kwargs
1✔
503
        else:
504
            raise ValueError("operator_kwargs must be a dict or None")
×
505

506
        # Import openmc.deplete here so the Model can be used even if the
507
        # shared library is unavailable.
508
        import openmc.deplete as dep
1✔
509

510
        # Store whether or not the library was initialized when we started
511
        started_initialized = self.is_initialized
1✔
512

513
        with change_directory(directory):
1✔
514
            with openmc.lib.quiet_dll(output):
1✔
515
                # TODO: Support use of IndependentOperator too
516
                depletion_operator = dep.CoupledOperator(self, **op_kwargs)
1✔
517

518
            # Tell depletion_operator.finalize NOT to clear C API memory when
519
            # it is done
520
            depletion_operator.cleanup_when_done = False
1✔
521

522
            # Set up the integrator
523
            check_value('method', method,
1✔
524
                        dep.integrators.integrator_by_name.keys())
525
            integrator_class = dep.integrators.integrator_by_name[method]
1✔
526
            integrator = integrator_class(depletion_operator, **integrator_kwargs)
1✔
527

528
            # Now perform the depletion
529
            with openmc.lib.quiet_dll(output):
1✔
530
                integrator.integrate(final_step)
1✔
531

532
            # Now make the python Materials match the C API material data
533
            for mat_id, mat in self._materials_by_id.items():
1✔
534
                if mat.depletable:
1✔
535
                    # Get the C data
536
                    c_mat = openmc.lib.materials[mat_id]
1✔
537
                    nuclides, densities = c_mat._get_densities()
1✔
538
                    # And now we can remove isotopes and add these ones in
539
                    mat.nuclides.clear()
1✔
540
                    for nuc, density in zip(nuclides, densities):
1✔
541
                        mat.add_nuclide(nuc, density)
1✔
542
                    mat.set_density('atom/b-cm', sum(densities))
1✔
543

544
            # If we didnt start intialized, we should cleanup after ourselves
545
            if not started_initialized:
1✔
546
                depletion_operator.cleanup_when_done = True
1✔
547
                depletion_operator.finalize()
1✔
548

549
    def _link_geometry_to_filters(self):
1✔
550
        """Establishes a link between distribcell filters and the geometry"""
551
        for tally in self.tallies:
1✔
552
            for f in tally.filters:
1✔
553
                if isinstance(f, openmc.DistribcellFilter):
1✔
554
                    f._geometry = self.geometry
1✔
555

556
    def export_to_xml(self, directory: PathLike = '.', remove_surfs: bool = False,
1✔
557
                      nuclides_to_ignore: Iterable[str] | None = None):
558
        """Export model to separate XML files.
559

560
        Parameters
561
        ----------
562
        directory : PathLike
563
            Directory to write XML files to. If it doesn't exist already, it
564
            will be created.
565
        remove_surfs : bool
566
            Whether or not to remove redundant surfaces from the geometry when
567
            exporting.
568

569
            .. versionadded:: 0.13.1
570
        nuclides_to_ignore : list of str
571
            Nuclides to ignore when exporting to XML.
572

573
        """
574
        # Create directory if required
575
        d = Path(directory)
1✔
576
        if not d.is_dir():
1✔
577
            d.mkdir(parents=True, exist_ok=True)
1✔
578

579
        self.settings.export_to_xml(d)
1✔
580
        self.geometry.export_to_xml(d, remove_surfs=remove_surfs)
1✔
581

582
        # If a materials collection was specified, export it. Otherwise, look
583
        # for all materials in the geometry and use that to automatically build
584
        # a collection.
585
        if self.materials:
1✔
586
            self.materials.export_to_xml(d, nuclides_to_ignore=nuclides_to_ignore)
1✔
587
        else:
588
            materials = openmc.Materials(self.geometry.get_all_materials()
1✔
589
                                         .values())
590
            materials.export_to_xml(d, nuclides_to_ignore=nuclides_to_ignore)
1✔
591

592
        if self.tallies:
1✔
593
            self.tallies.export_to_xml(d)
1✔
594
        if self.plots:
1✔
595
            self.plots.export_to_xml(d)
1✔
596

597
        self._link_geometry_to_filters()
1✔
598

599
    def export_to_model_xml(self, path: PathLike = 'model.xml', remove_surfs: bool = False,
1✔
600
                            nuclides_to_ignore: Iterable[str] | None = None):
601
        """Export model to a single XML file.
602

603
        .. versionadded:: 0.13.3
604

605
        Parameters
606
        ----------
607
        path : str or PathLike
608
            Location of the XML file to write (default is 'model.xml'). Can be a
609
            directory or file path.
610
        remove_surfs : bool
611
            Whether or not to remove redundant surfaces from the geometry when
612
            exporting.
613
        nuclides_to_ignore : list of str
614
            Nuclides to ignore when exporting to XML.
615

616
        """
617
        xml_path = Path(path)
1✔
618
        # if the provided path doesn't end with the XML extension, assume the
619
        # input path is meant to be a directory. If the directory does not
620
        # exist, create it and place a 'model.xml' file there.
621
        if not str(xml_path).endswith('.xml'):
1✔
622
            if not xml_path.exists():
1✔
623
                xml_path.mkdir(parents=True, exist_ok=True)
×
624
            elif not xml_path.is_dir():
1✔
625
                raise FileExistsError(f"File exists and is not a directory: '{xml_path}'")
×
626
            xml_path /= 'model.xml'
1✔
627
        # if this is an XML file location and the file's parent directory does
628
        # not exist, create it before continuing
629
        elif not xml_path.parent.exists():
1✔
630
            xml_path.parent.mkdir(parents=True, exist_ok=True)
×
631

632
        if remove_surfs:
1✔
633
            warnings.warn("remove_surfs kwarg will be deprecated soon, please "
×
634
                          "set the Geometry.merge_surfaces attribute instead.")
635
            self.geometry.merge_surfaces = True
×
636

637
        # provide a memo to track which meshes have been written
638
        mesh_memo = set()
1✔
639
        settings_element = self.settings.to_xml_element(mesh_memo)
1✔
640
        geometry_element = self.geometry.to_xml_element()
1✔
641

642
        xml.clean_indentation(geometry_element, level=1)
1✔
643
        xml.clean_indentation(settings_element, level=1)
1✔
644

645
        # If a materials collection was specified, export it. Otherwise, look
646
        # for all materials in the geometry and use that to automatically build
647
        # a collection.
648
        if self.materials:
1✔
649
            materials = self.materials
1✔
650
        else:
651
            materials = openmc.Materials(self.geometry.get_all_materials()
1✔
652
                                         .values())
653

654
        with open(xml_path, 'w', encoding='utf-8', errors='xmlcharrefreplace') as fh:
1✔
655
            # write the XML header
656
            fh.write("<?xml version='1.0' encoding='utf-8'?>\n")
1✔
657
            fh.write("<model>\n")
1✔
658
            # Write the materials collection to the open XML file first.
659
            # This will write the XML header also
660
            materials._write_xml(fh, False, level=1,
1✔
661
                                 nuclides_to_ignore=nuclides_to_ignore)
662
            # Write remaining elements as a tree
663
            fh.write(ET.tostring(geometry_element, encoding="unicode"))
1✔
664
            fh.write(ET.tostring(settings_element, encoding="unicode"))
1✔
665

666
            if self.tallies:
1✔
667
                tallies_element = self.tallies.to_xml_element(mesh_memo)
1✔
668
                xml.clean_indentation(
1✔
669
                    tallies_element, level=1, trailing_indent=self.plots)
670
                fh.write(ET.tostring(tallies_element, encoding="unicode"))
1✔
671
            if self.plots:
1✔
672
                plots_element = self.plots.to_xml_element()
1✔
673
                xml.clean_indentation(
1✔
674
                    plots_element, level=1, trailing_indent=False)
675
                fh.write(ET.tostring(plots_element, encoding="unicode"))
1✔
676
            fh.write("</model>\n")
1✔
677

678
        self._link_geometry_to_filters()
1✔
679

680
    def import_properties(self, filename: PathLike):
1✔
681
        """Import physical properties
682

683
        .. versionchanged:: 0.13.0
684
            This method now updates values as loaded in memory with the C API
685

686
        Parameters
687
        ----------
688
        filename : PathLike
689
            Path to properties HDF5 file
690

691
        See Also
692
        --------
693
        openmc.lib.export_properties
694

695
        """
696
        import openmc.lib
1✔
697

698
        cells = self.geometry.get_all_cells()
1✔
699
        materials = self.geometry.get_all_materials()
1✔
700

701
        with h5py.File(filename, 'r') as fh:
1✔
702
            cells_group = fh['geometry/cells']
1✔
703

704
            # Make sure number of cells matches
705
            n_cells = fh['geometry'].attrs['n_cells']
1✔
706
            if n_cells != len(cells):
1✔
707
                raise ValueError("Number of cells in properties file doesn't "
×
708
                                 "match current model.")
709

710
            # Update temperatures and densities for cells filled with materials
711
            for name, group in cells_group.items():
1✔
712
                cell_id = int(name.split()[1])
1✔
713
                cell = cells[cell_id]
1✔
714
                if cell.fill_type in ('material', 'distribmat'):
1✔
715
                    temperature = group['temperature'][()]
1✔
716
                    cell.temperature = temperature
1✔
717
                    if self.is_initialized:
1✔
718
                        lib_cell = openmc.lib.cells[cell_id]
1✔
719
                        if temperature.size > 1:
1✔
720
                            for i, T in enumerate(temperature):
×
721
                                lib_cell.set_temperature(T, i)
×
722
                        else:
723
                            lib_cell.set_temperature(temperature[0])
1✔
724

725
                    if group['density']:
1✔
726
                      density = group['density'][()]
1✔
727
                      if density.size > 1:
1✔
728
                          cell.density = [rho for rho in density]
×
729
                      else:
730
                          cell.density = density
1✔
731
                      if self.is_initialized:
1✔
732
                          lib_cell = openmc.lib.cells[cell_id]
1✔
733
                          if density.size > 1:
1✔
734
                              for i, rho in enumerate(density):
×
735
                                  lib_cell.set_density(rho, i)
×
736
                          else:
737
                              lib_cell.set_density(density[0])
1✔
738

739
            # Make sure number of materials matches
740
            mats_group = fh['materials']
1✔
741
            n_cells = mats_group.attrs['n_materials']
1✔
742
            if n_cells != len(materials):
1✔
743
                raise ValueError("Number of materials in properties file "
×
744
                                 "doesn't match current model.")
745

746
            # Update material densities
747
            for name, group in mats_group.items():
1✔
748
                mat_id = int(name.split()[1])
1✔
749
                atom_density = group.attrs['atom_density']
1✔
750
                materials[mat_id].set_density('atom/b-cm', atom_density)
1✔
751
                if self.is_initialized:
1✔
752
                    C_mat = openmc.lib.materials[mat_id]
1✔
753
                    C_mat.set_density(atom_density, 'atom/b-cm')
1✔
754

755
    def run(
1✔
756
        self,
757
        particles: int | None = None,
758
        threads: int | None = None,
759
        geometry_debug: bool = False,
760
        restart_file: PathLike | None = None,
761
        tracks: bool = False,
762
        output: bool = True,
763
        cwd: PathLike = ".",
764
        openmc_exec: PathLike = "openmc",
765
        mpi_args: Iterable[str] = None,
766
        event_based: bool | None = None,
767
        export_model_xml: bool = True,
768
        apply_tally_results: bool = False,
769
        **export_kwargs,
770
    ) -> Path:
771
        """Run OpenMC
772

773
        If the C API has been initialized, then the C API is used, otherwise,
774
        this method creates the XML files and runs OpenMC via a system call. In
775
        both cases this method returns the path to the last statepoint file
776
        generated.
777

778
        .. versionchanged:: 0.12
779
            Instead of returning the final k-effective value, this function now
780
            returns the path to the final statepoint written.
781

782
        .. versionchanged:: 0.13.0
783
            This method can utilize the C API for execution
784

785
        Parameters
786
        ----------
787
        particles : int, optional
788
            Number of particles to simulate per generation.
789
        threads : int, optional
790
            Number of OpenMP threads. If OpenMC is compiled with OpenMP
791
            threading enabled, the default is implementation-dependent but is
792
            usually equal to the number of hardware threads available (or a
793
            value set by the :envvar:`OMP_NUM_THREADS` environment variable).
794
        geometry_debug : bool, optional
795
            Turn on geometry debugging during simulation. Defaults to False.
796
        restart_file : str or PathLike
797
            Path to restart file to use
798
        tracks : bool, optional
799
            Enables the writing of particles tracks. The number of particle
800
            tracks written to tracks.h5 is limited to 1000 unless
801
            Settings.max_tracks is set. Defaults to False.
802
        output : bool, optional
803
            Capture OpenMC output from standard out
804
        cwd : PathLike, optional
805
            Path to working directory to run in. Defaults to the current working
806
            directory.
807
        openmc_exec : str, optional
808
            Path to OpenMC executable. Defaults to 'openmc'.
809
        mpi_args : list of str, optional
810
            MPI execute command and any additional MPI arguments to pass, e.g.
811
            ['mpiexec', '-n', '8'].
812
        event_based : None or bool, optional
813
            Turns on event-based parallelism if True. If None, the value in the
814
            Settings will be used.
815
        export_model_xml : bool, optional
816
            Exports a single model.xml file rather than separate files. Defaults
817
            to True.
818

819
            .. versionadded:: 0.13.3
820
        apply_tally_results : bool
821
            Whether to apply results of the final statepoint file to the
822
            model's tally objects.
823

824
            .. versionadded:: 0.15.1
825
        **export_kwargs
826
            Keyword arguments passed to either :meth:`Model.export_to_model_xml`
827
            or :meth:`Model.export_to_xml`.
828

829
        Returns
830
        -------
831
        Path
832
            Path to the last statepoint written by this run (None if no
833
            statepoint was written)
834

835
        """
836

837
        # Setting tstart here ensures we don't pick up any pre-existing
838
        # statepoint files in the output directory -- just in case there are
839
        # differences between the system clock and the filesystem, we get the
840
        # time of a just-created temporary file
841
        with NamedTemporaryFile() as fp:
1✔
842
            tstart = Path(fp.name).stat().st_mtime
1✔
843
        last_statepoint = None
1✔
844

845
        # Operate in the provided working directory
846
        with change_directory(cwd):
1✔
847
            if self.is_initialized:
1✔
848
                # Handle the run options as applicable
849
                # First dont allow ones that must be set via init
850
                for arg_name, arg, default in zip(
1✔
851
                    ['threads', 'geometry_debug', 'restart_file', 'tracks'],
852
                    [threads, geometry_debug, restart_file, tracks],
853
                    [None, False, None, False]
854
                ):
855
                    if arg != default:
1✔
856
                        msg = f"{arg_name} must be set via Model.is_initialized(...)"
1✔
857
                        raise ValueError(msg)
1✔
858

859
                init_particles = openmc.lib.settings.particles
1✔
860
                if particles is not None:
1✔
861
                    if isinstance(particles, Integral) and particles > 0:
×
862
                        openmc.lib.settings.particles = particles
×
863

864
                init_event_based = openmc.lib.settings.event_based
1✔
865
                if event_based is not None:
1✔
866
                    openmc.lib.settings.event_based = event_based
×
867

868
                # Then run using the C API
869
                openmc.lib.run(output)
1✔
870

871
                # Reset changes for the openmc.run kwargs handling
872
                openmc.lib.settings.particles = init_particles
1✔
873
                openmc.lib.settings.event_based = init_event_based
1✔
874

875
            else:
876
                # Then run via the command line
877
                if export_model_xml:
1✔
878
                    self.export_to_model_xml(**export_kwargs)
1✔
879
                else:
880
                    self.export_to_xml(**export_kwargs)
1✔
881
                path_input = export_kwargs.get("path", None)
1✔
882
                openmc.run(particles, threads, geometry_debug, restart_file,
1✔
883
                           tracks, output, Path('.'), openmc_exec, mpi_args,
884
                           event_based, path_input)
885

886
            # Get output directory and return the last statepoint written
887
            if self.settings.output and 'path' in self.settings.output:
1✔
888
                output_dir = Path(self.settings.output['path'])
×
889
            else:
890
                output_dir = Path.cwd()
1✔
891
            for sp in output_dir.glob('statepoint.*.h5'):
1✔
892
                mtime = sp.stat().st_mtime
1✔
893
                if mtime >= tstart:  # >= allows for poor clock resolution
1✔
894
                    tstart = mtime
1✔
895
                    last_statepoint = sp
1✔
896

897
        if apply_tally_results:
1✔
898
            self.apply_tally_results(last_statepoint)
1✔
899

900
        return last_statepoint
1✔
901

902
    def calculate_volumes(
1✔
903
        self,
904
        threads: int | None = None,
905
        output: bool = True,
906
        cwd: PathLike = ".",
907
        openmc_exec: PathLike = "openmc",
908
        mpi_args: list[str] | None = None,
909
        apply_volumes: bool = True,
910
        export_model_xml: bool = True,
911
        **export_kwargs,
912
    ):
913
        """Runs an OpenMC stochastic volume calculation and, if requested,
914
        applies volumes to the model
915

916
        .. versionadded:: 0.13.0
917

918
        Parameters
919
        ----------
920
        threads : int, optional
921
            Number of OpenMP threads. If OpenMC is compiled with OpenMP
922
            threading enabled, the default is implementation-dependent but is
923
            usually equal to the number of hardware threads available (or a
924
            value set by the :envvar:`OMP_NUM_THREADS` environment variable).
925
            This currenty only applies to the case when not using the C API.
926
        output : bool, optional
927
            Capture OpenMC output from standard out
928
        openmc_exec : str, optional
929
            Path to OpenMC executable. Defaults to 'openmc'.
930
            This only applies to the case when not using the C API.
931
        mpi_args : list of str, optional
932
            MPI execute command and any additional MPI arguments to pass,
933
            e.g. ['mpiexec', '-n', '8'].
934
            This only applies to the case when not using the C API.
935
        cwd : str, optional
936
            Path to working directory to run in. Defaults to the current
937
            working directory.
938
        apply_volumes : bool, optional
939
            Whether apply the volume calculation results from this calculation
940
            to the model. Defaults to applying the volumes.
941
        export_model_xml : bool, optional
942
            Exports a single model.xml file rather than separate files. Defaults
943
            to True.
944
        **export_kwargs
945
            Keyword arguments passed to either :meth:`Model.export_to_model_xml`
946
            or :meth:`Model.export_to_xml`.
947

948
        """
949

950
        if len(self.settings.volume_calculations) == 0:
1✔
951
            # Then there is no volume calculation specified
952
            raise ValueError("The Settings.volume_calculations attribute must"
1✔
953
                             " be specified before executing this method!")
954

955
        with change_directory(cwd):
1✔
956
            if self.is_initialized:
1✔
957
                if threads is not None:
1✔
958
                    msg = "Threads must be set via Model.is_initialized(...)"
×
959
                    raise ValueError(msg)
×
960
                if mpi_args is not None:
1✔
961
                    msg = "The MPI environment must be set otherwise such as" \
×
962
                        "with the call to mpi4py"
963
                    raise ValueError(msg)
×
964

965
                # Compute the volumes
966
                openmc.lib.calculate_volumes(output)
1✔
967

968
            else:
969
                if export_model_xml:
1✔
970
                    self.export_to_model_xml(**export_kwargs)
1✔
971
                else:
972
                    self.export_to_xml(**export_kwargs)
×
973
                path_input = export_kwargs.get("path", None)
1✔
974
                openmc.calculate_volumes(
1✔
975
                    threads=threads, output=output, openmc_exec=openmc_exec,
976
                    mpi_args=mpi_args, path_input=path_input
977
                )
978

979
            # Now we apply the volumes
980
            if apply_volumes:
1✔
981
                # Load the results and add them to the model
982
                for i, vol_calc in enumerate(self.settings.volume_calculations):
1✔
983
                    vol_calc.load_results(f"volume_{i + 1}.h5")
1✔
984
                    # First add them to the Python side
985
                    if vol_calc.domain_type == "material" and self.materials:
1✔
986
                        for material in self.materials:
1✔
987
                            if material.id in vol_calc.volumes:
1✔
988
                                material.add_volume_information(vol_calc)
1✔
989
                    else:
990
                        self.geometry.add_volume_information(vol_calc)
1✔
991

992
                    # And now repeat for the C API
993
                    if self.is_initialized and vol_calc.domain_type == 'material':
1✔
994
                        # Then we can do this in the C API
995
                        for domain_id in vol_calc.ids:
1✔
996
                            openmc.lib.materials[domain_id].volume = \
1✔
997
                                vol_calc.volumes[domain_id].n
998

999

1000
    def _set_plot_defaults(
1✔
1001
        self,
1002
        origin: Sequence[float] | None,
1003
        width: Sequence[float] | None,
1004
        pixels: int | Sequence[int],
1005
        basis: str
1006
    ):
1007
        x, y, _ = _BASIS_INDICES[basis]
1✔
1008

1009
        bb = self.bounding_box
1✔
1010
        # checks to see if bounding box contains -inf or inf values
1011
        if np.isinf(bb.extent[basis]).any():
1✔
1012
            if origin is None:
1✔
1013
                origin = (0, 0, 0)
1✔
1014
            if width is None:
1✔
1015
                width = (10, 10)
1✔
1016
        else:
1017
            if origin is None:
1✔
1018
                # if nan values in the bb.center they get replaced with 0.0
1019
                # this happens when the bounding_box contains inf values
1020
                with warnings.catch_warnings():
1✔
1021
                    warnings.simplefilter("ignore", RuntimeWarning)
1✔
1022
                    origin = np.nan_to_num(bb.center)
1✔
1023
            if width is None:
1✔
1024
                bb_width = bb.width
1✔
1025
                width = (bb_width[x], bb_width[y])
1✔
1026

1027
        if isinstance(pixels, int):
1✔
1028
            aspect_ratio = width[0] / width[1]
1✔
1029
            pixels_y = math.sqrt(pixels / aspect_ratio)
1✔
1030
            pixels = (int(pixels / pixels_y), int(pixels_y))
1✔
1031

1032
        return origin, width, pixels
1✔
1033

1034
    def id_map(
1✔
1035
        self,
1036
        origin: Sequence[float] | None = None,
1037
        width: Sequence[float] | None = None,
1038
        pixels: int | Sequence[int] = 40000,
1039
        basis: str = 'xy',
1040
        color_overlaps: bool = False,
1041
        **init_kwargs
1042
    ) -> np.ndarray:
1043
        """Generate an ID map for domains based on the plot parameters
1044

1045
        If the model is not yet initialized, it will be initialized with
1046
        openmc.lib. If the model is initialized, the model will remain
1047
        initialized after this method call exits.
1048

1049
        .. versionadded:: 0.15.3
1050

1051
        Parameters
1052
        ----------
1053
        origin : Sequence[float], optional
1054
            Origin of the plot. If unspecified, this argument defaults to the
1055
            center of the bounding box if the bounding box does not contain inf
1056
            values for the provided basis, otherwise (0.0, 0.0, 0.0).
1057
        width : Sequence[float], optional
1058
            Width of the plot. If unspecified, this argument defaults to the
1059
            width of the bounding box if the bounding box does not contain inf
1060
            values for the provided basis, otherwise (10.0, 10.0).
1061
        pixels : int | Sequence[int], optional
1062
            If an iterable of ints is provided then this directly sets the
1063
            number of pixels to use in each basis direction. If a single int is
1064
            provided then this sets the total number of pixels in the plot and
1065
            the number of pixels in each basis direction is calculated from this
1066
            total and the image aspect ratio based on the width argument.
1067
        basis : {'xy', 'yz', 'xz'}, optional
1068
            Basis of the plot.
1069
        color_overlaps : bool, optional
1070
            Whether to assign unique IDs (-3) to overlapping regions. If False,
1071
            overlapping regions will be assigned the ID of the lowest-numbered
1072
            cell that occupies that region. Defaults to False.
1073
        **init_kwargs
1074
            Keyword arguments passed to :meth:`Model.init_lib`.
1075

1076
        Returns
1077
        -------
1078
        id_map : numpy.ndarray
1079
            A NumPy array with shape (vertical pixels, horizontal pixels, 3) of
1080
            OpenMC property IDs with dtype int32. The last dimension of the
1081
            array contains cell IDs, cell instances, and material IDs (in that
1082
            order).
1083
        """
1084
        import openmc.lib
1✔
1085

1086
        origin, width, pixels = self._set_plot_defaults(
1✔
1087
            origin, width, pixels, basis)
1088

1089
        # initialize the openmc.lib.plot._PlotBase object
1090
        plot_obj = openmc.lib.plot._PlotBase()
1✔
1091
        plot_obj.origin = origin
1✔
1092
        plot_obj.width = width[0]
1✔
1093
        plot_obj.height = width[1]
1✔
1094
        plot_obj.h_res = pixels[0]
1✔
1095
        plot_obj.v_res = pixels[1]
1✔
1096
        plot_obj.basis = basis
1✔
1097
        plot_obj.color_overlaps = color_overlaps
1✔
1098

1099
        # Silence output by default. Also set arguments to start in volume
1100
        # calculation mode to avoid loading cross sections
1101
        init_kwargs.setdefault('output', False)
1✔
1102
        init_kwargs.setdefault('args', ['-c'])
1✔
1103

1104
        with openmc.lib.TemporarySession(self, **init_kwargs):
1✔
1105
            return openmc.lib.id_map(plot_obj)
1✔
1106

1107
    @add_plot_params
1✔
1108
    def plot(
1✔
1109
        self,
1110
        origin: Sequence[float] | None = None,
1111
        width: Sequence[float] | None = None,
1112
        pixels: int | Sequence[int] = 40000,
1113
        basis: str = 'xy',
1114
        color_by: str = 'cell',
1115
        colors: dict | None = None,
1116
        seed: int | None = None,
1117
        axes=None,
1118
        legend: bool = False,
1119
        axis_units: str = 'cm',
1120
        outline: bool | str = False,
1121
        show_overlaps: bool = False,
1122
        overlap_color: Sequence[int] | str = (255, 0, 0),
1123
        n_samples: int | None = None,
1124
        plane_tolerance: float = 1.,
1125
        legend_kwargs: dict | None = None,
1126
        source_kwargs: dict | None = None,
1127
        contour_kwargs: dict | None = None,
1128
        **kwargs,
1129
    ):
1130
        """Display a slice plot of the model.
1131

1132
        .. versionadded:: 0.15.1
1133
        """
1134
        import matplotlib.patches as mpatches
1✔
1135
        import matplotlib.pyplot as plt
1✔
1136

1137
        check_type('n_samples', n_samples, int | None)
1✔
1138
        check_type('plane_tolerance', plane_tolerance, Real)
1✔
1139
        if legend_kwargs is None:
1✔
1140
            legend_kwargs = {}
1✔
1141
        legend_kwargs.setdefault('bbox_to_anchor', (1.05, 1))
1✔
1142
        legend_kwargs.setdefault('loc', 2)
1✔
1143
        legend_kwargs.setdefault('borderaxespad', 0.0)
1✔
1144
        if source_kwargs is None:
1✔
1145
            source_kwargs = {}
1✔
1146
        source_kwargs.setdefault('marker', 'x')
1✔
1147

1148
        # Set indices using basis and create axis labels
1149
        x, y, z = _BASIS_INDICES[basis]
1✔
1150
        xlabel, ylabel = f'{basis[0]} [{axis_units}]', f'{basis[1]} [{axis_units}]'
1✔
1151

1152
        # Determine extents of plot
1153
        origin, width, pixels = self._set_plot_defaults(
1✔
1154
            origin, width, pixels, basis)
1155

1156
        axis_scaling_factor = {'km': 0.00001, 'm': 0.01, 'cm': 1, 'mm': 10}
1✔
1157

1158
        x_min = (origin[x] - 0.5*width[0]) * axis_scaling_factor[axis_units]
1✔
1159
        x_max = (origin[x] + 0.5*width[0]) * axis_scaling_factor[axis_units]
1✔
1160
        y_min = (origin[y] - 0.5*width[1]) * axis_scaling_factor[axis_units]
1✔
1161
        y_max = (origin[y] + 0.5*width[1]) * axis_scaling_factor[axis_units]
1✔
1162

1163
        # Get ID map from the C API
1164
        id_map = self.id_map(
1✔
1165
            origin=origin,
1166
            width=width,
1167
            pixels=pixels,
1168
            basis=basis,
1169
            color_overlaps=show_overlaps
1170
        )
1171

1172
        # Generate colors if not provided
1173
        if colors is None and seed is not None:
1✔
1174
            # Use the colorize method to generate random colors
1175
            plot = openmc.SlicePlot()
×
1176
            plot.color_by = color_by
×
1177
            plot.colorize(self.geometry, seed=seed)
×
1178
            colors = plot.colors
×
1179

1180
        # Convert ID map to RGB image
1181
        img = id_map_to_rgb(
1✔
1182
            id_map=id_map,
1183
            color_by=color_by,
1184
            colors=colors,
1185
            overlap_color=overlap_color
1186
        )
1187

1188
        # Create a figure sized such that the size of the axes within
1189
        # exactly matches the number of pixels specified
1190
        if axes is None:
1✔
1191
            px = 1/plt.rcParams['figure.dpi']
1✔
1192
            fig, axes = plt.subplots()
1✔
1193
            axes.set_xlabel(xlabel)
1✔
1194
            axes.set_ylabel(ylabel)
1✔
1195
            params = fig.subplotpars
1✔
1196
            width_px = pixels[0]*px/(params.right - params.left)
1✔
1197
            height_px = pixels[1]*px/(params.top - params.bottom)
1✔
1198
            fig.set_size_inches(width_px, height_px)
1✔
1199

1200
        if outline:
1✔
1201
            # Combine R, G, B values into a single int for contour detection
1202
            rgb = (img * 256).astype(int)
1✔
1203
            image_value = (rgb[..., 0] << 16) + \
1✔
1204
                (rgb[..., 1] << 8) + (rgb[..., 2])
1205

1206
            # Set default arguments for contour()
1207
            if contour_kwargs is None:
1✔
1208
                contour_kwargs = {}
1✔
1209
            contour_kwargs.setdefault('colors', 'k')
1✔
1210
            contour_kwargs.setdefault('linestyles', 'solid')
1✔
1211
            contour_kwargs.setdefault('algorithm', 'serial')
1✔
1212

1213
            axes.contour(
1✔
1214
                image_value,
1215
                origin="upper",
1216
                levels=np.unique(image_value),
1217
                extent=(x_min, x_max, y_min, y_max),
1218
                **contour_kwargs
1219
            )
1220

1221
            # If only showing outline, set the axis limits and aspect explicitly
1222
            if outline == 'only':
1✔
1223
                axes.set_xlim(x_min, x_max)
×
1224
                axes.set_ylim(y_min, y_max)
×
1225
                axes.set_aspect('equal')
×
1226

1227
        # Add legend showing which colors represent which material or cell
1228
        if legend:
1✔
1229
            if colors is None or len(colors) == 0:
1✔
1230
                raise ValueError("Must pass 'colors' dictionary if you "
1✔
1231
                                 "are adding a legend via legend=True.")
1232

1233
            if color_by == "cell":
1✔
1234
                expected_key_type = openmc.Cell
×
1235
            else:
1236
                expected_key_type = openmc.Material
1✔
1237

1238
            patches = []
1✔
1239
            for key, color in colors.items():
1✔
1240
                if isinstance(key, int):
1✔
1241
                    raise TypeError(
×
1242
                        "Cannot use IDs in colors dict for auto legend.")
1243
                elif not isinstance(key, expected_key_type):
1✔
1244
                    raise TypeError(
×
1245
                        "Color dict key type does not match color_by")
1246

1247
                # this works whether we're doing cells or materials
1248
                label = key.name if key.name != '' else key.id
1✔
1249

1250
                # matplotlib takes RGB on 0-1 scale rather than 0-255
1251
                if len(color) == 3 and not isinstance(color, str):
1✔
1252
                    scaled_color = (
1✔
1253
                        color[0]/255, color[1]/255, color[2]/255)
1254
                else:
1255
                    scaled_color = color
1✔
1256

1257
                key_patch = mpatches.Patch(color=scaled_color, label=label)
1✔
1258
                patches.append(key_patch)
1✔
1259

1260
            axes.legend(handles=patches, **legend_kwargs)
1✔
1261

1262
        # Plot image and return the axes
1263
        if outline != 'only':
1✔
1264
            axes.imshow(img, extent=(x_min, x_max, y_min, y_max), **kwargs)
1✔
1265

1266
        if n_samples:
1✔
1267
            # Sample external source particles
1268
            particles = self.sample_external_source(n_samples)
1✔
1269

1270
            # Get points within tolerance of the slice plane
1271
            slice_value = origin[z]
1✔
1272
            xs = []
1✔
1273
            ys = []
1✔
1274
            tol = plane_tolerance
1✔
1275
            for particle in particles:
1✔
1276
                if (slice_value - tol < particle.r[z] < slice_value + tol):
1✔
1277
                    xs.append(particle.r[x] * axis_scaling_factor[axis_units])
1✔
1278
                    ys.append(particle.r[y] * axis_scaling_factor[axis_units])
1✔
1279
            axes.scatter(xs, ys, **source_kwargs)
1✔
1280

1281
        return axes
1✔
1282

1283
    def sample_external_source(
1✔
1284
        self,
1285
        n_samples: int = 1000,
1286
        prn_seed: int | None = None,
1287
        **init_kwargs
1288
    ) -> openmc.ParticleList:
1289
        """Sample external source and return source particles.
1290

1291
        .. versionadded:: 0.15.1
1292

1293
        Parameters
1294
        ----------
1295
        n_samples : int
1296
            Number of samples
1297
        prn_seed : int
1298
            Pseudorandom number generator (PRNG) seed; if None, one will be
1299
            generated randomly.
1300
        **init_kwargs
1301
            Keyword arguments passed to :func:`openmc.lib.init`
1302

1303
        Returns
1304
        -------
1305
        openmc.ParticleList
1306
            List of samples source particles
1307
        """
1308
        import openmc.lib
1✔
1309

1310
        # Silence output by default. Also set arguments to start in volume
1311
        # calculation mode to avoid loading cross sections
1312
        init_kwargs.setdefault('output', False)
1✔
1313
        init_kwargs.setdefault('args', ['-c'])
1✔
1314

1315
        with openmc.lib.TemporarySession(self, **init_kwargs):
1✔
1316
            return openmc.lib.sample_external_source(
1✔
1317
                n_samples=n_samples, prn_seed=prn_seed
1318
            )
1319

1320
    def apply_tally_results(self, statepoint: PathLike | openmc.StatePoint):
1✔
1321
        """Apply results from a statepoint to tally objects on the Model
1322

1323
        Parameters
1324
        ----------
1325
        statepoint : PathLike or openmc.StatePoint
1326
            Statepoint file used to update tally results
1327
        """
1328
        self.tallies.add_results(statepoint)
1✔
1329

1330
    def plot_geometry(
1✔
1331
        self,
1332
        output: bool = True,
1333
        cwd: PathLike = ".",
1334
        openmc_exec: PathLike = "openmc",
1335
        export_model_xml: bool = True,
1336
        **export_kwargs,
1337
    ):
1338
        """Creates plot images as specified by the Model.plots attribute
1339

1340
        .. versionadded:: 0.13.0
1341

1342
        Parameters
1343
        ----------
1344
        output : bool, optional
1345
            Capture OpenMC output from standard out
1346
        cwd : PathLike, optional
1347
            Path to working directory to run in. Defaults to the current
1348
            working directory.
1349
        openmc_exec : PathLike, optional
1350
            Path to OpenMC executable. Defaults to 'openmc'.
1351
            This only applies to the case when not using the C API.
1352
        export_model_xml : bool, optional
1353
            Exports a single model.xml file rather than separate files. Defaults
1354
            to True.
1355
        **export_kwargs
1356
            Keyword arguments passed to either :meth:`Model.export_to_model_xml`
1357
            or :meth:`Model.export_to_xml`.
1358

1359
        """
1360

1361
        if len(self.plots) == 0:
1✔
1362
            # Then there is no volume calculation specified
1363
            raise ValueError("The Model.plots attribute must be specified "
×
1364
                             "before executing this method!")
1365

1366
        with change_directory(cwd):
1✔
1367
            if self.is_initialized:
1✔
1368
                # Compute the volumes
1369
                openmc.lib.plot_geometry(output)
1✔
1370
            else:
1371
                if export_model_xml:
1✔
1372
                    self.export_to_model_xml(**export_kwargs)
1✔
1373
                else:
1374
                    self.export_to_xml(**export_kwargs)
×
1375
                path_input = export_kwargs.get("path", None)
1✔
1376
                openmc.plot_geometry(output=output, openmc_exec=openmc_exec,
1✔
1377
                                     path_input=path_input)
1378

1379
    def _change_py_lib_attribs(
1✔
1380
        self,
1381
        names_or_ids: Iterable[str] | Iterable[int],
1382
        value: float | Iterable[float],
1383
        obj_type: str,
1384
        attrib_name: str,
1385
        density_units: str = "atom/b-cm",
1386
    ):
1387
        # Method to do the same work whether it is a cell or material and
1388
        # a temperature or volume
1389
        check_type('names_or_ids', names_or_ids, Iterable, (Integral, str))
1✔
1390
        check_type('obj_type', obj_type, str)
1✔
1391
        obj_type = obj_type.lower()
1✔
1392
        check_value('obj_type', obj_type, ('material', 'cell'))
1✔
1393
        check_value('attrib_name', attrib_name,
1✔
1394
                    ('temperature', 'volume', 'density', 'rotation',
1395
                     'translation'))
1396
        # The C API only allows setting density units of atom/b-cm and g/cm3
1397
        check_value('density_units', density_units, ('atom/b-cm', 'g/cm3'))
1✔
1398
        # The C API has no way to set cell volume or material temperature
1399
        # so lets raise exceptions as needed
1400
        if obj_type == 'cell' and attrib_name == 'volume':
1✔
1401
            raise NotImplementedError(
×
1402
                'Setting a Cell volume is not supported!')
1403
        if obj_type == 'material' and attrib_name == 'temperature':
1✔
1404
            raise NotImplementedError(
×
1405
                'Setting a material temperature is not supported!')
1406

1407
        # And some items just dont make sense
1408
        if obj_type == 'cell' and attrib_name == 'density':
1✔
1409
            raise ValueError('Cannot set a Cell density!')
×
1410
        if obj_type == 'material' and attrib_name in ('rotation',
1✔
1411
                                                      'translation'):
1412
            raise ValueError('Cannot set a material rotation/translation!')
×
1413

1414
        # Set the
1415
        if obj_type == 'cell':
1✔
1416
            by_name = self._cells_by_name
1✔
1417
            by_id = self._cells_by_id
1✔
1418
            if self.is_initialized:
1✔
1419
                obj_by_id = openmc.lib.cells
1✔
1420
        else:
1421
            by_name = self._materials_by_name
1✔
1422
            by_id = self._materials_by_id
1✔
1423
            if self.is_initialized:
1✔
1424
                obj_by_id = openmc.lib.materials
1✔
1425
        # Get the list of ids to use if converting from names and accepting
1426
        # only values that have actual ids
1427
        ids = []
1✔
1428
        for name_or_id in names_or_ids:
1✔
1429
            if isinstance(name_or_id, Integral):
1✔
1430
                if name_or_id in by_id:
1✔
1431
                    ids.append(int(name_or_id))
1✔
1432
                else:
1433
                    cap_obj = obj_type.capitalize()
1✔
1434
                    msg = f'{cap_obj} ID {name_or_id} " \
1✔
1435
                        "is not present in the model!'
1436
                    raise InvalidIDError(msg)
1✔
1437
            elif isinstance(name_or_id, str):
1✔
1438
                if name_or_id in by_name:
1✔
1439
                    # Then by_name[name_or_id] is a list so we need to add all
1440
                    # entries
1441
                    ids.extend([obj.id for obj in by_name[name_or_id]])
1✔
1442
                else:
1443
                    cap_obj = obj_type.capitalize()
1✔
1444
                    msg = f'{cap_obj} {name_or_id} " \
1✔
1445
                        "is not present in the model!'
1446
                    raise InvalidIDError(msg)
1✔
1447

1448
        # Now perform the change to both python and C API
1449
        for id_ in ids:
1✔
1450
            obj = by_id[id_]
1✔
1451
            if attrib_name == 'density':
1✔
1452
                obj.set_density(density_units, value)
1✔
1453
            else:
1454
                setattr(obj, attrib_name, value)
1✔
1455
            # Next lets keep what is in C API memory up to date as well
1456
            if self.is_initialized:
1✔
1457
                lib_obj = obj_by_id[id_]
1✔
1458
                if attrib_name == 'density':
1✔
1459
                    lib_obj.set_density(value, density_units)
1✔
1460
                elif attrib_name == 'temperature':
1✔
1461
                    lib_obj.set_temperature(value)
1✔
1462
                else:
1463
                    setattr(lib_obj, attrib_name, value)
1✔
1464

1465
    def rotate_cells(
1✔
1466
        self, names_or_ids: Iterable[str] | Iterable[int], vector: Iterable[float]
1467
    ):
1468
        """Rotate the identified cell(s) by the specified rotation vector.
1469
        The rotation is only applied to cells filled with a universe.
1470

1471
        .. note:: If applying this change to a name that is not unique, then
1472
                  the change will be applied to all objects of that name.
1473

1474
        .. versionadded:: 0.13.0
1475

1476
        Parameters
1477
        ----------
1478
        names_or_ids : Iterable of str or int
1479
            The cell names (if str) or id (if int) that are to be translated
1480
            or rotated. This parameter can include a mix of names and ids.
1481
        vector : Iterable of float
1482
            The rotation vector of length 3 to apply. This array specifies the
1483
            angles in degrees about the x, y, and z axes, respectively.
1484

1485
        """
1486

1487
        self._change_py_lib_attribs(names_or_ids, vector, 'cell', 'rotation')
1✔
1488

1489
    def translate_cells(
1✔
1490
        self, names_or_ids: Iterable[str] | Iterable[int], vector: Iterable[float]
1491
    ):
1492
        """Translate the identified cell(s) by the specified translation vector.
1493
        The translation is only applied to cells filled with a universe.
1494

1495
        .. note:: If applying this change to a name that is not unique, then
1496
                  the change will be applied to all objects of that name.
1497

1498
        .. versionadded:: 0.13.0
1499

1500
        Parameters
1501
        ----------
1502
        names_or_ids : Iterable of str or int
1503
            The cell names (if str) or id (if int) that are to be translated
1504
            or rotated. This parameter can include a mix of names and ids.
1505
        vector : Iterable of float
1506
            The translation vector of length 3 to apply. This array specifies
1507
            the x, y, and z dimensions of the translation.
1508

1509
        """
1510

1511
        self._change_py_lib_attribs(names_or_ids, vector, 'cell',
1✔
1512
                                    'translation')
1513

1514
    def update_densities(
1✔
1515
        self,
1516
        names_or_ids: Iterable[str] | Iterable[int],
1517
        density: float,
1518
        density_units: str = "atom/b-cm",
1519
    ):
1520
        """Update the density of a given set of materials to a new value
1521

1522
        .. note:: If applying this change to a name that is not unique, then
1523
                  the change will be applied to all objects of that name.
1524

1525
        .. versionadded:: 0.13.0
1526

1527
        Parameters
1528
        ----------
1529
        names_or_ids : Iterable of str or int
1530
            The material names (if str) or id (if int) that are to be updated.
1531
            This parameter can include a mix of names and ids.
1532
        density : float
1533
            The density to apply in the units specified by `density_units`
1534
        density_units : {'atom/b-cm', 'g/cm3'}, optional
1535
            Units for `density`. Defaults to 'atom/b-cm'
1536

1537
        """
1538

1539
        self._change_py_lib_attribs(names_or_ids, density, 'material',
1✔
1540
                                    'density', density_units)
1541

1542
    def update_cell_temperatures(
1✔
1543
        self, names_or_ids: Iterable[str] | Iterable[int], temperature: float
1544
    ):
1545
        """Update the temperature of a set of cells to the given value
1546

1547
        .. note:: If applying this change to a name that is not unique, then
1548
                  the change will be applied to all objects of that name.
1549

1550
        .. versionadded:: 0.13.0
1551

1552
        Parameters
1553
        ----------
1554
        names_or_ids : Iterable of str or int
1555
            The cell names (if str) or id (if int) that are to be updated.
1556
            This parameter can include a mix of names and ids.
1557
        temperature : float
1558
            The temperature to apply in units of Kelvin
1559

1560
        """
1561

1562
        self._change_py_lib_attribs(names_or_ids, temperature, 'cell',
1✔
1563
                                    'temperature')
1564

1565
    def update_material_volumes(
1✔
1566
        self, names_or_ids: Iterable[str] | Iterable[int], volume: float
1567
    ):
1568
        """Update the volume of a set of materials to the given value
1569

1570
        .. note:: If applying this change to a name that is not unique, then
1571
                  the change will be applied to all objects of that name.
1572

1573
        .. versionadded:: 0.13.0
1574

1575
        Parameters
1576
        ----------
1577
        names_or_ids : Iterable of str or int
1578
            The material names (if str) or id (if int) that are to be updated.
1579
            This parameter can include a mix of names and ids.
1580
        volume : float
1581
            The volume to apply in units of cm^3
1582

1583
        """
1584

1585
        self._change_py_lib_attribs(names_or_ids, volume, 'material', 'volume')
1✔
1586

1587
    def differentiate_depletable_mats(self, diff_volume_method: str = None):
1✔
1588
        """Assign distribmats for each depletable material
1589

1590
        .. versionadded:: 0.14.0
1591

1592
        .. versionchanged:: 0.15.1
1593
            diff_volume_method default is None, do not set volumes on the new
1594
            material ovjects. Is now a convenience method for
1595
            differentiate_mats(diff_volume_method, depletable_only=True)
1596

1597
        Parameters
1598
        ----------
1599
        diff_volume_method : str
1600
            Specifies how the volumes of the new materials should be found.
1601
            - None: Do not assign volumes to the new materials (Default)
1602
            - 'divide equally': Divide the original material volume equally between the new materials
1603
            - 'match cell': Set the volume of the material to the volume of the cell they fill
1604
        """
1605
        self.differentiate_mats(diff_volume_method, depletable_only=True)
1✔
1606

1607
    def differentiate_mats(self, diff_volume_method: str = None, depletable_only: bool = True):
1✔
1608
        """Assign distribmats for each material
1609

1610
        .. versionadded:: 0.15.1
1611

1612
        Parameters
1613
        ----------
1614
        diff_volume_method : str
1615
            Specifies how the volumes of the new materials should be found.
1616
            - None: Do not assign volumes to the new materials (Default)
1617
            - 'divide equally': Divide the original material volume equally between the new materials
1618
            - 'match cell': Set the volume of the material to the volume of the cell they fill
1619
        depletable_only : bool
1620
            Default is True, only depletable materials will be differentiated. If False, all materials will be
1621
            differentiated.
1622
        """
1623
        check_value('volume differentiation method', diff_volume_method, ("divide equally", "match cell", None))
1✔
1624

1625
        # Count the number of instances for each cell and material
1626
        self.geometry.determine_paths(instances_only=True)
1✔
1627

1628
        # Get list of materials
1629
        if self.materials:
1✔
1630
            materials = self.materials
1✔
1631
        else:
1632
            materials = list(self.geometry.get_all_materials().values())
×
1633

1634
        # Find all or depletable_only materials which have multiple instance
1635
        distribmats = set()
1✔
1636
        for mat in materials:
1✔
1637
            # Differentiate all materials with multiple instances
1638
            diff_mat = mat.num_instances > 1
1✔
1639
            # If depletable_only is True, differentiate only depletable materials
1640
            if depletable_only:
1✔
1641
                diff_mat = diff_mat and mat.depletable
1✔
1642
            if diff_mat:
1✔
1643
                # Assign volumes to the materials according to requirements
1644
                if diff_volume_method == "divide equally":
1✔
1645
                    if mat.volume is None:
1✔
1646
                        raise RuntimeError(
×
1647
                            "Volume not specified for "
1648
                            f"material with ID={mat.id}.")
1649
                    else:
1650
                        mat.volume /= mat.num_instances
1✔
1651
                elif diff_volume_method == "match cell":
1✔
1652
                    for cell in self.geometry.get_all_material_cells().values():
1✔
1653
                        if cell.fill == mat:
1✔
1654
                            if not cell.volume:
1✔
1655
                                raise ValueError(
×
1656
                                    f"Volume of cell ID={cell.id} not specified. "
1657
                                    "Set volumes of cells prior to using "
1658
                                    "diff_volume_method='match cell'.")
1659
                distribmats.add(mat)
1✔
1660

1661
        if not distribmats:
1✔
1662
            return
×
1663

1664
        # Assign distribmats to cells
1665
        for cell in self.geometry.get_all_material_cells().values():
1✔
1666
            if cell.fill in distribmats:
1✔
1667
                mat = cell.fill
1✔
1668

1669
                # Clone materials
1670
                if cell.num_instances > 1:
1✔
1671
                    cell.fill = [mat.clone() for _ in range(cell.num_instances)]
1✔
1672
                else:
1673
                    cell.fill = mat.clone()
1✔
1674

1675
                # For 'match cell', assign volumes based on the cells
1676
                if diff_volume_method == 'match cell':
1✔
1677
                    if cell.fill_type == 'distribmat':
1✔
1678
                        for clone_mat in cell.fill:
×
1679
                            clone_mat.volume = cell.volume
×
1680
                    else:
1681
                        cell.fill.volume = cell.volume
1✔
1682

1683
        if self.materials is not None:
1✔
1684
            self.materials = openmc.Materials(
1✔
1685
                self.geometry.get_all_materials().values()
1686
            )
1687

1688
    def _auto_generate_mgxs_lib(
1✔
1689
        self,
1690
        model: openmc.model.model,
1691
        groups: openmc.mgxs.EnergyGroups,
1692
        correction: str | none,
1693
        directory: pathlike,
1694
    ) -> openmc.mgxs.Library:
1695
        """
1696
        Automatically generate a multi-group cross section libray from a model
1697
        with the specified group structure.
1698

1699
        Parameters
1700
        ----------
1701
        groups : openmc.mgxs.EnergyGroups
1702
            Energy group structure for the MGXS.
1703
        nparticles : int
1704
            Number of particles to simulate per batch when generating MGXS.
1705
        mgxs_path : str
1706
            Filename for the MGXS HDF5 file.
1707
        correction : str
1708
            Transport correction to apply to the MGXS. Options are None and
1709
            "P0".
1710
        directory : str
1711
            Directory to run the simulation in, so as to contain XML files.
1712

1713
        Returns
1714
        -------
1715
        mgxs_lib : openmc.mgxs.Library
1716
            OpenMC MGXS Library object
1717
        """
1718

1719
        # Initialize MGXS library with a finished OpenMC geometry object
1720
        mgxs_lib = openmc.mgxs.Library(model.geometry)
1✔
1721

1722
        # Pick energy group structure
1723
        mgxs_lib.energy_groups = groups
1✔
1724

1725
        # Disable transport correction
1726
        mgxs_lib.correction = correction
1✔
1727

1728
        # Specify needed cross sections for random ray
1729
        if correction == 'P0':
1✔
1730
            mgxs_lib.mgxs_types = [
1✔
1731
                'nu-transport', 'absorption', 'nu-fission', 'fission',
1732
                'consistent nu-scatter matrix', 'multiplicity matrix', 'chi',
1733
                'kappa-fission'
1734
            ]
1735
        elif correction is None:
1✔
1736
            mgxs_lib.mgxs_types = [
1✔
1737
                'total', 'absorption', 'nu-fission', 'fission',
1738
                'consistent nu-scatter matrix', 'multiplicity matrix', 'chi',
1739
                'kappa-fission'
1740
            ]
1741

1742
        # Specify a "material" domain type for the cross section tally filters
1743
        mgxs_lib.domain_type = "material"
1✔
1744

1745
        # Specify the domains over which to compute multi-group cross sections
1746
        mgxs_lib.domains = model.geometry.get_all_materials().values()
1✔
1747

1748
        # Do not compute cross sections on a nuclide-by-nuclide basis
1749
        mgxs_lib.by_nuclide = False
1✔
1750

1751
        # Check the library - if no errors are raised, then the library is satisfactory.
1752
        mgxs_lib.check_library_for_openmc_mgxs()
1✔
1753

1754
        # Construct all tallies needed for the multi-group cross section library
1755
        mgxs_lib.build_library()
1✔
1756

1757
        # Create a "tallies.xml" file for the MGXS Library
1758
        mgxs_lib.add_to_tallies(model.tallies, merge=True)
1✔
1759

1760
        # Run
1761
        statepoint_filename = model.run(cwd=directory)
1✔
1762

1763
        # Load MGXS
1764
        with openmc.StatePoint(statepoint_filename) as sp:
1✔
1765
            mgxs_lib.load_from_statepoint(sp)
1✔
1766

1767
        return mgxs_lib
1✔
1768

1769
    def _create_mgxs_sources(
1✔
1770
        self,
1771
        groups: openmc.mgxs.EnergyGroups,
1772
        spatial_dist: openmc.stats.Spatial,
1773
        source_energy: openmc.stats.Univariate | None = None,
1774
    ) -> list[openmc.IndependentSource]:
1775
        """Create a list of independent sources to use with MGXS generation.
1776

1777
        Note that in all cases, a discrete source that is uniform over all
1778
        energy groups is created (strength = 0.01) to ensure that total cross
1779
        sections are generated for all energy groups. In the case that the user
1780
        has provided a source_energy distribution as an argument, an additional
1781
        source (strength = 0.99) is created using that energy distribution. If
1782
        the user has not provided a source_energy distribution, but the model
1783
        has sources defined, and all of those sources are of IndependentSource
1784
        type, then additional sources are created based on the model's existing
1785
        sources, keeping their energy distributions but replacing their
1786
        spatial/angular distributions, with their combined strength being 0.99.
1787
        If the user has not provided a source_energy distribution and no sources
1788
        are defined on the model and the run mode is 'eigenvalue', then a
1789
        default Watt spectrum source (strength = 0.99) is added.
1790

1791
        Parameters
1792
        ----------
1793
        groups : openmc.mgxs.EnergyGroups
1794
            Energy group structure for the MGXS.
1795
        spatial_dist : openmc.stats.Spatial
1796
            Spatial distribution to use for all sources.
1797
        source_energy : openmc.stats.Univariate, optional
1798
            Energy distribution to use when generating MGXS data, replacing any
1799
            existing sources in the model.
1800

1801
        Returns
1802
        -------
1803
        list[openmc.IndependentSource]
1804
            A list of independent sources to use for MGXS generation.
1805
        """
1806
        # Make a discrete source that is uniform over the bins of the group structure
1807
        midpoints = []
1✔
1808
        strengths = []
1✔
1809
        for i in range(groups.num_groups):
1✔
1810
            bounds = groups.get_group_bounds(i+1)
1✔
1811
            midpoints.append((bounds[0] + bounds[1]) / 2.0)
1✔
1812
            strengths.append(1.0)
1✔
1813

1814
        uniform_energy = openmc.stats.Discrete(x=midpoints, p=strengths)
1✔
1815
        uniform_distribution = openmc.IndependentSource(spatial_dist, energy=uniform_energy, strength=0.01)
1✔
1816
        sources = [uniform_distribution]
1✔
1817

1818
        # If the user provided an energy distribution, use that
1819
        if source_energy is not None:
1✔
1820
            user_energy = openmc.IndependentSource(
1✔
1821
                space=spatial_dist, energy=source_energy, strength=0.99)
1822
            sources.append(user_energy)
1✔
1823

1824
        # If the user did not provide an energy distribution, create sources
1825
        # based on what is in their model, keeping the energy spectrum but
1826
        # replacing the spatial/angular distributions. We only do this if ALL
1827
        # sources are of IndependentSource type, as we can't pull the energy
1828
        # distribution from e.g. CompiledSource or FileSource types.
1829
        else:
1830
            if self.settings.source is not None:
1✔
1831
                for src in self.settings.source:
1✔
1832
                    if not isinstance(src, openmc.IndependentSource):
1✔
1833
                        break
×
1834
                else:
1835
                    n_user_sources = len(self.settings.source)
1✔
1836
                    for src in self.settings.source:
1✔
1837
                        # Create a new IndependentSource with adjusted strength, space, and angle
1838
                        user_source = openmc.IndependentSource(
1✔
1839
                            space=spatial_dist,
1840
                            energy=src.energy,
1841
                            strength=0.99 / n_user_sources
1842
                        )
1843
                        sources.append(user_source)
1✔
1844
            else:
1845
                # No user sources defined. If we are in eigenvalue mode, then use the default Watt spectrum.
1846
                if self.settings.run_mode == 'eigenvalue':
×
1847
                    watt_energy = openmc.stats.Watt()
×
1848
                    watt_source = openmc.IndependentSource(
×
1849
                        space=spatial_dist, energy=watt_energy, strength=0.99)
1850
                    sources.append(watt_source)
×
1851

1852
        return sources
1✔
1853

1854
    def _generate_infinite_medium_mgxs(
1✔
1855
        self,
1856
        groups: openmc.mgxs.EnergyGroups,
1857
        nparticles: int,
1858
        mgxs_path: PathLike,
1859
        correction: str | None,
1860
        directory: PathLike,
1861
        source_energy: openmc.stats.Univariate | None = None,
1862
    ):
1863
        """Generate a MGXS library by running multiple OpenMC simulations, each
1864
        representing an infinite medium simulation of a single isolated
1865
        material. A discrete source is used to sample particles, with an equal
1866
        strength spread across each of the energy groups. This is a highly naive
1867
        method that ignores all spatial self shielding effects and all resonance
1868
        shielding effects between materials.
1869

1870
        Note that in all cases, a discrete source that is uniform over all
1871
        energy groups is created (strength = 0.01) to ensure that total cross
1872
        sections are generated for all energy groups. In the case that the user
1873
        has provided a source_energy distribution as an argument, an additional
1874
        source (strength = 0.99) is created using that energy distribution. If
1875
        the user has not provided a source_energy distribution, but the model
1876
        has sources defined, and all of those sources are of IndependentSource
1877
        type, then additional sources are created based on the model's existing
1878
        sources, keeping their energy distributions but replacing their
1879
        spatial/angular distributions, with their combined strength being 0.99.
1880
        If the user has not provided a source_energy distribution and no sources
1881
        are defined on the model and the run mode is 'eigenvalue', then a
1882
        default Watt spectrum source (strength = 0.99) is added.
1883

1884
        Parameters
1885
        ----------
1886
        groups : openmc.mgxs.EnergyGroups
1887
            Energy group structure for the MGXS.
1888
        nparticles : int
1889
            Number of particles to simulate per batch when generating MGXS.
1890
        mgxs_path : str
1891
            Filename for the MGXS HDF5 file.
1892
        correction : str
1893
            Transport correction to apply to the MGXS. Options are None and
1894
            "P0".
1895
        directory : str
1896
            Directory to run the simulation in, so as to contain XML files.
1897
        source_energy : openmc.stats.Univariate, optional
1898
            Energy distribution to use when generating MGXS data, replacing any
1899
            existing sources in the model.
1900
        """
1901
        mgxs_sets = []
1✔
1902
        for material in self.materials:
1✔
1903
            model = openmc.Model()
1✔
1904

1905
            # Set materials on the model
1906
            model.materials = [material]
1✔
1907

1908
            # Settings
1909
            model.settings.batches = 100
1✔
1910
            model.settings.particles = nparticles
1✔
1911

1912
            model.settings.source = self._create_mgxs_sources(
1✔
1913
                groups,
1914
                spatial_dist=openmc.stats.Point(),
1915
                source_energy=source_energy
1916
            )
1917

1918
            model.settings.run_mode = 'fixed source'
1✔
1919
            model.settings.create_fission_neutrons = False
1✔
1920

1921
            model.settings.output = {'summary': True, 'tallies': False}
1✔
1922

1923
            # Geometry
1924
            box = openmc.model.RectangularPrism(
1✔
1925
                100000.0, 100000.0, boundary_type='reflective')
1926
            name = material.name
1✔
1927
            infinite_cell = openmc.Cell(name=name, fill=material, region=-box)
1✔
1928
            infinite_universe = openmc.Universe(name=name, cells=[infinite_cell])
1✔
1929
            model.geometry.root_universe = infinite_universe
1✔
1930

1931
            # Add MGXS Tallies
1932
            mgxs_lib = self._auto_generate_mgxs_lib(
1✔
1933
                model, groups, correction, directory)
1934

1935
            # Create a MGXS File which can then be written to disk
1936
            mgxs_set = mgxs_lib.get_xsdata(domain=material, xsdata_name=name)
1✔
1937
            mgxs_sets.append(mgxs_set)
1✔
1938

1939
        # Write the file to disk
1940
        mgxs_file = openmc.MGXSLibrary(energy_groups=groups)
1✔
1941
        for mgxs_set in mgxs_sets:
1✔
1942
            mgxs_file.add_xsdata(mgxs_set)
1✔
1943
        mgxs_file.export_to_hdf5(mgxs_path)
1✔
1944

1945
    @staticmethod
1✔
1946
    def _create_stochastic_slab_geometry(
1✔
1947
        materials: Sequence[openmc.Material],
1948
        cell_thickness: float = 1.0,
1949
        num_repeats: int = 100,
1950
    ) -> tuple[openmc.Geometry, openmc.stats.Box]:
1951
        """Create a geometry representing a stochastic "sandwich" of materials in a
1952
        layered slab geometry. To reduce the impact of the order of materials in
1953
        the slab, the materials are applied to 'num_repeats' different randomly
1954
        positioned layers of 'cell_thickness' each.
1955

1956
        Parameters
1957
        ----------
1958
        materials : list of openmc.Material
1959
            List of materials to assign. Each material will appear exactly num_repeats times,
1960
            then the ordering is randomly shuffled.
1961
        cell_thickness : float, optional
1962
            Thickness of each lattice cell in x (default 1.0 cm).
1963
        num_repeats : int, optional
1964
            Number of repeats for each material (default 100).
1965

1966
        Returns
1967
        -------
1968
        geometry : openmc.Geometry
1969
            The constructed geometry.
1970
        box : openmc.stats.Box
1971
            A spatial sampling distribution covering the full slab domain.
1972
        """
1973
        if not materials:
1✔
1974
            raise ValueError("At least one material must be provided.")
×
1975

1976
        num_materials = len(materials)
1✔
1977
        total_cells = num_materials * num_repeats
1✔
1978
        total_width = total_cells * cell_thickness
1✔
1979

1980
        # Generate an infinite cell/universe for each material
1981
        universes = []
1✔
1982
        for i in range(num_materials):
1✔
1983
            cell = openmc.Cell(fill=materials[i])
1✔
1984
            universes.append(openmc.Universe(cells=[cell]))
1✔
1985

1986
        # Make a list of randomized material idx assignments for the stochastic slab
1987
        assignments = list(range(num_materials)) * num_repeats
1✔
1988
        random.seed(42)
1✔
1989
        random.shuffle(assignments)
1✔
1990

1991
        # Create a list of the (randomized) universe assignments to be used
1992
        # when defining the problem lattice.
1993
        lattice_entries = [universes[m] for m in assignments]
1✔
1994

1995
        # Create the RectLattice for the 1D material variation in x.
1996
        lattice = openmc.RectLattice()
1✔
1997
        lattice.pitch = (cell_thickness, total_width, total_width)
1✔
1998
        lattice.lower_left = (0.0, 0.0, 0.0)
1✔
1999
        lattice.universes = [[lattice_entries]]
1✔
2000
        lattice.outer = universes[0]
1✔
2001

2002
        # Define the six outer surfaces with reflective boundary conditions
2003
        rpp = openmc.model.RectangularParallelepiped(
1✔
2004
            0.0, total_width, 0.0, total_width, 0.0, total_width,
2005
            boundary_type='reflective'
2006
        )
2007

2008
        # Create an outer cell that fills with the lattice.
2009
        outer_cell = openmc.Cell(fill=lattice, region=-rpp)
1✔
2010

2011
        # Build the geometry
2012
        geometry = openmc.Geometry([outer_cell])
1✔
2013

2014
        # Define the spatial distribution that covers the full cubic domain
2015
        box = openmc.stats.Box(*outer_cell.bounding_box)
1✔
2016

2017
        return geometry, box
1✔
2018

2019
    def _generate_stochastic_slab_mgxs(
1✔
2020
        self,
2021
        groups: openmc.mgxs.EnergyGroups,
2022
        nparticles: int,
2023
        mgxs_path: PathLike,
2024
        correction: str | None,
2025
        directory: PathLike,
2026
        source_energy: openmc.stats.Univariate | None = None,
2027
    ) -> None:
2028
        """Generate MGXS assuming a stochastic "sandwich" of materials in a layered
2029
        slab geometry. While geometry-specific spatial shielding effects are not
2030
        captured, this method can be useful when the geometry has materials only
2031
        found far from the source region that the "material_wise" method would
2032
        not be capable of generating cross sections for. Conversely, this method
2033
        will generate cross sections for all materials in the problem regardless
2034
        of type. If this is a fixed source problem, a discrete source is used to
2035
        sample particles, with an equal strength spread across each of the
2036
        energy groups.
2037

2038
        Parameters
2039
        ----------
2040
        groups : openmc.mgxs.EnergyGroups
2041
            Energy group structure for the MGXS.
2042
        nparticles : int
2043
            Number of particles to simulate per batch when generating MGXS.
2044
        mgxs_path : str
2045
            Filename for the MGXS HDF5 file.
2046
        correction : str
2047
            Transport correction to apply to the MGXS. Options are None and
2048
            "P0".
2049
        directory : str
2050
            Directory to run the simulation in, so as to contain XML files.
2051
        source_energy : openmc.stats.Univariate, optional
2052
            Energy distribution to use when generating MGXS data, replacing any
2053
            existing sources in the model. In all cases, a discrete source that
2054
            is uniform over all energy groups is created (strength = 0.01) to
2055
            ensure that total cross sections are generated for all energy
2056
            groups. In the case that the user has provided a source_energy
2057
            distribution as an argument, an additional source (strength = 0.99)
2058
            is created using that energy distribution. If the user has not
2059
            provided a source_energy distribution, but the model has sources
2060
            defined, and all of those sources are of IndependentSource type,
2061
            then additional sources are created based on the model's existing
2062
            sources, keeping their energy distributions but replacing their
2063
            spatial/angular distributions, with their combined strength being
2064
            0.99. If the user has not provided a source_energy distribution and
2065
            no sources are defined on the model and the run mode is
2066
            'eigenvalue', then a default Watt spectrum source (strength = 0.99)
2067
            is added.
2068
        """
2069
        model = openmc.Model()
1✔
2070
        model.materials = self.materials
1✔
2071

2072
        # Settings
2073
        model.settings.batches = 200
1✔
2074
        model.settings.inactive = 100
1✔
2075
        model.settings.particles = nparticles
1✔
2076
        model.settings.output = {'summary': True, 'tallies': False}
1✔
2077

2078
        # Stochastic slab geometry
2079
        model.geometry, spatial_distribution = Model._create_stochastic_slab_geometry(
1✔
2080
            model.materials)
2081

2082
        # Define the sources
2083
        model.settings.source = self._create_mgxs_sources(
1✔
2084
            groups,
2085
            spatial_dist=spatial_distribution,
2086
            source_energy=source_energy
2087
        )
2088

2089
        model.settings.run_mode = 'fixed source'
1✔
2090
        model.settings.create_fission_neutrons = False
1✔
2091

2092
        model.settings.output = {'summary': True, 'tallies': False}
1✔
2093

2094
        # Add MGXS Tallies
2095
        mgxs_lib = self._auto_generate_mgxs_lib(
1✔
2096
                model, groups, correction, directory)
2097

2098
        names = [mat.name for mat in mgxs_lib.domains]
1✔
2099

2100
        # Create a MGXS File which can then be written to disk
2101
        mgxs_file = mgxs_lib.create_mg_library(xs_type='macro', xsdata_names=names)
1✔
2102
        mgxs_file.export_to_hdf5(mgxs_path)
1✔
2103

2104
    def _generate_material_wise_mgxs(
1✔
2105
        self,
2106
        groups: openmc.mgxs.EnergyGroups,
2107
        nparticles: int,
2108
        mgxs_path: PathLike,
2109
        correction: str | None,
2110
        directory: PathLike,
2111
    ) -> None:
2112
        """Generate a material-wise MGXS library for the model by running the
2113
        original continuous energy OpenMC simulation of the full material
2114
        geometry and source, and tally MGXS data for each material. This method
2115
        accurately conserves reaction rates totaled over the entire simulation
2116
        domain. However, when the geometry has materials only found far from the
2117
        source region, it is possible the Monte Carlo solver may not be able to
2118
        score any tallies to these material types, thus resulting in zero cross
2119
        section values for these materials. For such cases, the "stochastic
2120
        slab" method may be more appropriate.
2121

2122
        Parameters
2123
        ----------
2124
        groups : openmc.mgxs.EnergyGroups
2125
            Energy group structure for the MGXS.
2126
        nparticles : int
2127
            Number of particles to simulate per batch when generating MGXS.
2128
        mgxs_path : PathLike
2129
            Filename for the MGXS HDF5 file.
2130
        correction : str
2131
            Transport correction to apply to the MGXS. Options are None and
2132
            "P0".
2133
        directory : PathLike
2134
            Directory to run the simulation in, so as to contain XML files.
2135
        """
2136
        model = copy.deepcopy(self)
1✔
2137
        model.tallies = openmc.Tallies()
1✔
2138

2139
        # Settings
2140
        model.settings.batches = 200
1✔
2141
        model.settings.inactive = 100
1✔
2142
        model.settings.particles = nparticles
1✔
2143
        model.settings.output = {'summary': True, 'tallies': False}
1✔
2144

2145
        # Add MGXS Tallies
2146
        mgxs_lib = self._auto_generate_mgxs_lib(
1✔
2147
                model, groups, correction, directory)
2148

2149
        names = [mat.name for mat in mgxs_lib.domains]
1✔
2150

2151
        # Create a MGXS File which can then be written to disk
2152
        mgxs_file = mgxs_lib.create_mg_library(
1✔
2153
            xs_type='macro', xsdata_names=names)
2154
        mgxs_file.export_to_hdf5(mgxs_path)
1✔
2155

2156
    def convert_to_multigroup(
1✔
2157
        self,
2158
        method: str = "material_wise",
2159
        groups: str = "CASMO-2",
2160
        nparticles: int = 2000,
2161
        overwrite_mgxs_library: bool = False,
2162
        mgxs_path: PathLike = "mgxs.h5",
2163
        correction: str | None = None,
2164
        source_energy: openmc.stats.Univariate | None = None,
2165
    ):
2166
        """Convert all materials from continuous energy to multigroup.
2167

2168
        If no MGXS data library file is found, generate one using one or more
2169
        continuous energy Monte Carlo simulations.
2170

2171
        Parameters
2172
        ----------
2173
        method : {"material_wise", "stochastic_slab", "infinite_medium"}, optional
2174
            Method to generate the MGXS.
2175
        groups : openmc.mgxs.EnergyGroups or str, optional
2176
            Energy group structure for the MGXS or the name of the group
2177
            structure (based on keys from openmc.mgxs.GROUP_STRUCTURES).
2178
        nparticles : int, optional
2179
            Number of particles to simulate per batch when generating MGXS.
2180
        overwrite_mgxs_library : bool, optional
2181
            Whether to overwrite an existing MGXS library file.
2182
        mgxs_path : str, optional
2183
            Path to the mgxs.h5 library file.
2184
        correction : str, optional
2185
            Transport correction to apply to the MGXS. Options are None and
2186
            "P0".
2187
        source_energy : openmc.stats.Univariate, optional
2188
            Energy distribution to use when generating MGXS data, replacing any
2189
            existing sources in the model. In all cases, a discrete source that
2190
            is uniform over all energy groups is created (strength = 0.01) to
2191
            ensure that total cross sections are generated for all energy
2192
            groups. In the case that the user has provided a source_energy
2193
            distribution as an argument, an additional source (strength = 0.99)
2194
            is created using that energy distribution. If the user has not
2195
            provided a source_energy distribution, but the model has sources
2196
            defined, and all of those sources are of IndependentSource type,
2197
            then additional sources are created based on the model's existing
2198
            sources, keeping their energy distributions but replacing their
2199
            spatial/angular distributions, with their combined strength being
2200
            0.99. If the user has not provided a source_energy distribution and
2201
            no sources are defined on the model and the run mode is
2202
            'eigenvalue', then a default Watt spectrum source (strength = 0.99)
2203
            is added. Note that this argument is only used when using the
2204
            "stochastic_slab" or "infinite_medium" MGXS generation methods.
2205
        """
2206
        if isinstance(groups, str):
1✔
2207
            groups = openmc.mgxs.EnergyGroups(groups)
1✔
2208

2209
        # Do all work (including MGXS generation) in a temporary directory
2210
        # to avoid polluting the working directory with residual XML files
2211
        with TemporaryDirectory() as tmpdir:
1✔
2212

2213
            # Determine if there are DAGMC universes in the model. If so, we need to synchronize
2214
            # the dagmc materials with cells.
2215
            # TODO: Can this be done without having to init/finalize?
2216
            for univ in self.geometry.get_all_universes().values():
1✔
2217
                if isinstance(univ, openmc.DAGMCUniverse):
1✔
UNCOV
2218
                    self.init_lib(directory=tmpdir)
×
UNCOV
2219
                    self.sync_dagmc_universes()
×
UNCOV
2220
                    self.finalize_lib()
×
UNCOV
2221
                    break
×
2222

2223
            # Make sure all materials have a name, and that the name is a valid HDF5
2224
            # dataset name
2225
            for material in self.materials:
1✔
2226
                if not material.name or not material.name.strip():
1✔
2227
                    material.name = f"material {material.id}"
×
2228
                material.name = re.sub(r'[^a-zA-Z0-9]', '_', material.name)
1✔
2229

2230
            # If needed, generate the needed MGXS data library file
2231
            if not Path(mgxs_path).is_file() or overwrite_mgxs_library:
1✔
2232
                if method == "infinite_medium":
1✔
2233
                    self._generate_infinite_medium_mgxs(
1✔
2234
                        groups, nparticles, mgxs_path, correction, tmpdir, source_energy)
2235
                elif method == "material_wise":
1✔
2236
                    self._generate_material_wise_mgxs(
1✔
2237
                        groups, nparticles, mgxs_path, correction, tmpdir)
2238
                elif method == "stochastic_slab":
1✔
2239
                    self._generate_stochastic_slab_mgxs(
1✔
2240
                        groups, nparticles, mgxs_path, correction, tmpdir, source_energy)
2241
                else:
2242
                    raise ValueError(
×
2243
                        f'MGXS generation method "{method}" not recognized')
2244
            else:
2245
                print(f'Existing MGXS library file "{mgxs_path}" will be used')
×
2246

2247
            # Convert all continuous energy materials to multigroup
2248
            self.materials.cross_sections = mgxs_path
1✔
2249
            for material in self.materials:
1✔
2250
                material.set_density('macro', 1.0)
1✔
2251
                material._nuclides = []
1✔
2252
                material._sab = []
1✔
2253
                material.add_macroscopic(material.name)
1✔
2254

2255
            self.settings.energy_mode = 'multi-group'
1✔
2256

2257
    def convert_to_random_ray(self):
1✔
2258
        """Convert a multigroup model to use random ray.
2259

2260
        This method determines values for the needed settings and adds them to
2261
        the settings.random_ray dictionary so as to enable random ray mode. The
2262
        settings that are populated are:
2263

2264
        - 'ray_source' (openmc.IndependentSource): Where random ray starting
2265
          points are sampled from.
2266
        - 'distance_inactive' (float): The "dead zone" distance at the beginning
2267
          of the ray.
2268
        - 'distance_active' (float): The "active" distance of the ray
2269
        - 'particles' (int): Number of rays to simulate
2270

2271
        The method will determine reasonable defaults for each of the above
2272
        variables based on analysis of the model's geometry. The function will
2273
        have no effect if the random ray dictionary is already defined in the
2274
        model settings.
2275
        """
2276
        # If the random ray dictionary is already set, don't overwrite it
2277
        if self.settings.random_ray:
1✔
2278
            warnings.warn("Random ray conversion skipped as "
×
2279
                          "settings.random_ray dictionary is already set.")
2280
            return
×
2281

2282
        if self.settings.energy_mode != 'multi-group':
1✔
2283
            raise ValueError(
×
2284
                "Random ray conversion failed: energy mode must be "
2285
                "'multi-group'. Use convert_to_multigroup() first."
2286
            )
2287

2288
        # Helper function for detecting infinity
2289
        def _replace_infinity(value):
1✔
2290
            if np.isinf(value):
1✔
2291
                return 1.0 if value > 0 else -1.0
1✔
2292
            return value
1✔
2293

2294
        # Get a bounding box for sampling rays. We can utilize the geometry's bounding box
2295
        # though for 2D problems we need to detect the infinities and replace them with an
2296
        # arbitrary finite value.
2297
        bounding_box = self.geometry.bounding_box
1✔
2298
        lower_left = [_replace_infinity(v) for v in bounding_box.lower_left]
1✔
2299
        upper_right = [_replace_infinity(v) for v in bounding_box.upper_right]
1✔
2300
        uniform_dist_ray = openmc.stats.Box(lower_left, upper_right)
1✔
2301
        rr_source = openmc.IndependentSource(space=uniform_dist_ray)
1✔
2302
        self.settings.random_ray['ray_source'] = rr_source
1✔
2303

2304
        # For the dead zone and active length, a reasonable guess is the larger of either:
2305
        # 1) The maximum chord length through the geometry (as defined by its bounding box)
2306
        # 2) 30 cm
2307
        # Then, set the active length to be 5x longer than the dead zone length, for the sake of efficiency.
2308
        chord_length = np.array(upper_right) - np.array(lower_left)
1✔
2309
        max_length = max(np.linalg.norm(chord_length), 30.0)
1✔
2310

2311
        self.settings.random_ray['distance_inactive'] = max_length
1✔
2312
        self.settings.random_ray['distance_active'] = 5 * max_length
1✔
2313

2314
        # Take a wild guess as to how many rays are needed
2315
        self.settings.particles = 2 * int(max_length)
1✔
2316

2317
    def keff_search(
1✔
2318
        self,
2319
        func: ModelModifier,
2320
        x0: float,
2321
        x1: float,
2322
        target: float = 1.0,
2323
        k_tol: float = 1e-4,
2324
        sigma_final: float = 3e-4,
2325
        p: float = 0.5,
2326
        q: float = 0.95,
2327
        memory: int = 4,
2328
        x_min: float | None = None,
2329
        x_max: float | None = None,
2330
        b0: int | None = None,
2331
        b_min: int = 20,
2332
        b_max: int | None = None,
2333
        maxiter: int = 50,
2334
        output: bool = False,
2335
        func_kwargs: dict[str, Any] | None = None,
2336
        run_kwargs: dict[str, Any] | None = None,
2337
    ) -> SearchResult:
2338
        r"""Perform a keff search on a model parametrized by a single variable.
2339

2340
        This method uses the GRsecant method described in a paper by `Price and
2341
        Roskoff <https://doi.org/10.1016/j.pnucene.2023.104731>`_. The GRsecant
2342
        method is a modification of the secant method that accounts for
2343
        uncertainties in the function evaluations. The method uses a weighted
2344
        linear fit of the most recent function evaluations to predict the next
2345
        point to evaluate. It also adaptively changes the number of batches to
2346
        meet the target uncertainty value at each iteration.
2347

2348
        The target uncertainty for iteration :math:`n+1` is determined by the
2349
        following equation (following Eq. (8) in the paper):
2350

2351
        .. math::
2352
            \sigma_{i+1} = q \sigma_\text{final} \left ( \frac{ \min \left \{
2353
            \left\lvert k_i - k_\text{target} \right\rvert : k=0,1,\dots,n
2354
            \right \} }{k_\text{tol}} \right )^p
2355

2356
        where :math:`q` is a multiplicative factor less than 1, given as the
2357
        ``sigma_factor`` parameter below.
2358

2359
        Parameters
2360
        ----------
2361
        func : ModelModifier
2362
            Function that takes the parameter to be searched and makes a
2363
            modification to the model.
2364
        x0 : float
2365
            First guess for the parameter passed to `func`
2366
        x1 : float
2367
            Second guess for the parameter passed to `func`
2368
        target : float, optional
2369
            keff value to search for
2370
        k_tol : float, optional
2371
            Stopping criterion on the function value; the absolute value must be
2372
            within ``k_tol`` of zero to be accepted.
2373
        sigma_final : float, optional
2374
            Maximum accepted k-effective uncertainty for the stopping criterion.
2375
        p : float, optional
2376
            Exponent used in the stopping criterion.
2377
        q : float, optional
2378
            Multiplicative factor used in the stopping criterion.
2379
        memory : int, optional
2380
            Number of most-recent points used in the weighted linear fit of
2381
            ``f(x) = a + b x`` to predict the next point.
2382
        x_min : float, optional
2383
            Minimum allowed value for the parameter ``x``.
2384
        x_max : float, optional
2385
            Maximum allowed value for the parameter ``x``.
2386
        b0 : int, optional
2387
            Number of active batches to use for the initial function
2388
            evaluations. If None, uses the model's current setting.
2389
        b_min : int, optional
2390
            Minimum number of active batches to use in a function evaluation.
2391
        b_max : int, optional
2392
            Maximum number of active batches to use in a function evaluation.
2393
        maxiter : int, optional
2394
            Maximum number of iterations to perform.
2395
        output : bool, optional
2396
            Whether or not to display output showing iteration progress.
2397
        func_kwargs : dict, optional
2398
            Keyword-based arguments to pass to the `func` function.
2399
        run_kwargs : dict, optional
2400
            Keyword arguments to pass to :meth:`openmc.Model.run` or
2401
            :meth:`openmc.lib.run`.
2402

2403
        Returns
2404
        -------
2405
        SearchResult
2406
            Result object containing the estimated root (parameter value) and
2407
            evaluation history (parameters, means, standard deviations, and
2408
            batches), plus convergence status and termination reason.
2409

2410
        """
2411
        import openmc.lib
1✔
2412

2413
        check_type('model modifier', func, Callable)
1✔
2414
        check_type('target', target, Real)
1✔
2415
        if memory < 2:
1✔
2416
            raise ValueError("memory must be ≥ 2")
×
2417
        func_kwargs = {} if func_kwargs is None else dict(func_kwargs)
1✔
2418
        run_kwargs = {} if run_kwargs is None else dict(run_kwargs)
1✔
2419
        run_kwargs.setdefault('output', False)
1✔
2420

2421
        # Create lists to store the history of evaluations
2422
        xs: list[float] = []
1✔
2423
        fs: list[float] = []
1✔
2424
        ss: list[float] = []
1✔
2425
        gs: list[int] = []
1✔
2426
        count = 0
1✔
2427

2428
        # Helper function to evaluate f and store results
2429
        def eval_at(x: float, batches: int) -> tuple[float, float]:
1✔
2430
            # Modify the model with the current guess
2431
            func(x, **func_kwargs)
1✔
2432

2433
            # Change the number of batches and run the model
2434
            batches += self.settings.inactive
1✔
2435
            if openmc.lib.is_initialized:
1✔
2436
                openmc.lib.settings.set_batches(batches)
×
2437
                openmc.lib.reset()
×
2438
                openmc.lib.run(**run_kwargs)
×
2439
                sp_filepath = f'statepoint.{batches}.h5'
×
2440
            else:
2441
                self.settings.batches = batches
1✔
2442
                sp_filepath = self.run(**run_kwargs)
1✔
2443

2444
            # Extract keff and its uncertainty
2445
            with openmc.StatePoint(sp_filepath) as sp:
1✔
2446
                keff = sp.keff
1✔
2447

2448
            if output:
1✔
2449
                nonlocal count
2450
                count += 1
1✔
2451
                print(f'Iteration {count}: {batches=}, {x=:.6g}, {keff=:.5f}')
1✔
2452

2453
            xs.append(float(x))
1✔
2454
            fs.append(float(keff.n - target))
1✔
2455
            ss.append(float(keff.s))
1✔
2456
            gs.append(int(batches))
1✔
2457
            return fs[-1], ss[-1]
1✔
2458

2459
        # Default b0 to current model settings if not explicitly provided
2460
        if b0 is None:
1✔
2461
            b0 = self.settings.batches - self.settings.inactive
1✔
2462

2463
        # Perform the search (inlined GRsecant) in a temporary directory
2464
        with TemporaryDirectory() as tmpdir:
1✔
2465
            if not openmc.lib.is_initialized:
1✔
2466
                run_kwargs.setdefault('cwd', tmpdir)
1✔
2467

2468
            # ---- Seed with two evaluations
2469
            f0, s0 = eval_at(x0, b0)
1✔
2470
            if abs(f0) <= k_tol and s0 <= sigma_final:
1✔
2471
                return SearchResult(x0, xs, fs, ss, gs, True, "converged")
×
2472
            f1, s1 = eval_at(x1, b0)
1✔
2473
            if abs(f1) <= k_tol and s1 <= sigma_final:
1✔
2474
                return SearchResult(x1, xs, fs, ss, gs, True, "converged")
×
2475

2476
            for _ in range(maxiter - 2):
1✔
2477
                # ------ Step 1: propose next x via GRsecant
2478
                m = min(memory, len(xs))
1✔
2479

2480
                # Perform a curve fit on f(x) = a + bx accounting for
2481
                # uncertainties. This is equivalent to minimizing the function
2482
                # in Equation (A.14)
2483
                (a, b), _ = curve_fit(
1✔
2484
                    lambda x, a, b: a + b*x,
2485
                    xs[-m:], fs[-m:], sigma=ss[-m:], absolute_sigma=True
2486
                )
2487
                x_new = float(-a / b)
1✔
2488

2489
                # Clamp x_new to the bounds if provided
2490
                if x_min is not None:
1✔
2491
                    x_new = max(x_new, x_min)
×
2492
                if x_max is not None:
1✔
2493
                    x_new = min(x_new, x_max)
×
2494

2495
                # ------ Step 2: choose target σ for next run (Eq. 8 + clamp)
2496

2497
                min_abs_f = float(np.min(np.abs(fs)))
1✔
2498
                base = q * sigma_final
1✔
2499
                ratio = min_abs_f / k_tol if k_tol > 0 else 1.0
1✔
2500
                sig = base * (ratio ** p)
1✔
2501
                sig_target = max(sig, base)
1✔
2502

2503
                # ------ Step 3: choose generations to hit σ_target (Appendix C)
2504

2505
                # Use at least two past points for regression
2506
                if len(gs) >= 2 and np.var(np.log(gs)) > 0.0:
1✔
2507
                    # Perform a curve fit based on Eq. (C.3) to solve for ln(k).
2508
                    # Note that unlike in the paper, we do not leave r as an
2509
                    # undetermined parameter and choose r=0.5.
2510
                    (ln_k,), _ = curve_fit(
1✔
2511
                        lambda ln_b, ln_k: ln_k - 0.5*ln_b,
2512
                        np.log(gs[-4:]), np.log(ss[-4:]),
2513
                    )
2514
                    k = float(np.exp(ln_k))
1✔
2515
                else:
2516
                    k = float(ss[-1] * math.sqrt(gs[-1]))
1✔
2517

2518
                b_new = (k / sig_target) ** 2
1✔
2519

2520
                # Clamp and round up to integer
2521
                b_new = max(b_min, math.ceil(b_new))
1✔
2522
                if b_max is not None:
1✔
2523
                    b_new = min(b_new, b_max)
×
2524

2525
                # Evaluate at proposed x with batches determined above
2526
                f_new, s_new = eval_at(x_new, b_new)
1✔
2527

2528
                # Termination based on both criteria (|f| and σ)
2529
                if abs(f_new) <= k_tol and s_new <= sigma_final:
1✔
2530
                    return SearchResult(x_new, xs, fs, ss, gs, True, "converged")
1✔
2531

2532
            return SearchResult(xs[-1], xs, fs, ss, gs, False, "maxiter")
×
2533

2534

2535
@dataclass
1✔
2536
class SearchResult:
1✔
2537
    """Result of a GRsecant keff search.
2538

2539
    Attributes
2540
    ----------
2541
    root : float
2542
        Estimated parameter value where f(x) = 0 at termination.
2543
    parameters : list[float]
2544
        Parameter values (x) evaluated during the search, in order.
2545
    keffs : list[float]
2546
        Estimated keff values for each evaluation.
2547
    stdevs : list[float]
2548
        One-sigma uncertainties of keff for each evaluation.
2549
    batches : list[int]
2550
        Number of active batches used for each evaluation.
2551
    converged : bool
2552
        Whether both |f| <= k_tol and sigma <= sigma_final were met.
2553
    flag : str
2554
        Reason for termination (e.g., "converged", "maxiter").
2555
    """
2556
    root: float
1✔
2557
    parameters: list[float] = field(repr=False)
1✔
2558
    means: list[float] = field(repr=False)
1✔
2559
    stdevs: list[float] = field(repr=False)
1✔
2560
    batches: list[int] = field(repr=False)
1✔
2561
    converged: bool
1✔
2562
    flag: str
1✔
2563

2564
    @property
1✔
2565
    def function_calls(self) -> int:
1✔
2566
        """Number of function evaluations performed."""
2567
        return len(self.parameters)
1✔
2568

2569
    @property
1✔
2570
    def total_batches(self) -> int:
1✔
2571
        """Total number of active batches used across all evaluations."""
2572
        return sum(self.batches)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc