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

openmc-dev / openmc / 19058781736

04 Nov 2025 05:26AM UTC coverage: 82.008% (-3.1%) from 85.155%
19058781736

Pull #3252

github

web-flow
Merge b8a72730f into bd76fc056
Pull Request #3252: Adding vtkhdf option to write vtk data

16714 of 23236 branches covered (71.93%)

Branch coverage included in aggregate %.

61 of 66 new or added lines in 1 file covered. (92.42%)

3175 existing lines in 103 files now uncovered.

54243 of 63288 relevant lines covered (85.71%)

43393337.77 hits per line

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

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

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

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

29

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

35

36
class Model:
11✔
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__(
11✔
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
11✔
88
        self.materials = openmc.Materials() if materials is None else materials
11✔
89
        self.settings = openmc.Settings() if settings is None else settings
11✔
90
        self.tallies = openmc.Tallies() if tallies is None else tallies
11✔
91
        self.plots = openmc.Plots() if plots is None else plots
11✔
92

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

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

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

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

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

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

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

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

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

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

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

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

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

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

188
    @property
11✔
189
    @cache
11✔
190
    def _cells_by_name(self) -> dict[int, openmc.Cell]:
11✔
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 = {}
11✔
195
        for cell in self.geometry.get_all_cells().values():
11✔
196
            if cell.name not in result:
11✔
197
                result[cell.name] = set()
11✔
198
            result[cell.name].add(cell)
11✔
199
        return result
11✔
200

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

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

236
        return materials
11✔
237

238
    def add_kinetics_parameters_tallies(self, num_groups: int | None = None):
11✔
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):
11✔
255
            gen_time_tally = openmc.Tally(name='IFP time numerator')
11✔
256
            gen_time_tally.scores = ['ifp-time-numerator']
11✔
257
            self.tallies.append(gen_time_tally)
11✔
258
        if not any('ifp-beta-numerator' in t.scores for t in self.tallies):
11✔
259
            beta_tally = openmc.Tally(name='IFP beta numerator')
11✔
260
            beta_tally.scores = ['ifp-beta-numerator']
11✔
261
            if num_groups is not None:
11✔
262
                beta_tally.filters = [openmc.DelayedGroupFilter(list(range(1, num_groups + 1)))]
11✔
263
            self.tallies.append(beta_tally)
11✔
264
        if not any('ifp-denominator' in t.scores for t in self.tallies):
11✔
265
            denom_tally = openmc.Tally(name='IFP denominator')
11✔
266
            denom_tally.scores = ['ifp-denominator']
11✔
267
            self.tallies.append(denom_tally)
11✔
268

269
    @classmethod
11✔
270
    def from_xml(
11✔
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)
11✔
304
        geometry = openmc.Geometry.from_xml(geometry, materials)
11✔
305
        settings = openmc.Settings.from_xml(settings)
11✔
306
        tallies = openmc.Tallies.from_xml(
11✔
307
            tallies) if Path(tallies).exists() else None
308
        plots = openmc.Plots.from_xml(plots) if Path(plots).exists() else None
11✔
309
        return cls(geometry, materials, settings, tallies, plots)
11✔
310

311
    @classmethod
11✔
312
    def from_model_xml(cls, path: PathLike = "model.xml") -> Model:
11✔
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)
11✔
323
        tree = ET.parse(path, parser=parser)
11✔
324
        root = tree.getroot()
11✔
325

326
        model = cls()
11✔
327

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

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

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

343
        return model
11✔
344

345
    def init_lib(
11✔
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
11✔
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(
11✔
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:]
11✔
402

403
        self.finalize_lib()
11✔
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:
11✔
408
            self._intracomm = intracomm
5✔
409
        else:
410
            self._intracomm = DummyCommunicator()
11✔
411

412
        if self._intracomm.rank == 0:
11✔
413
            if directory is not None:
11✔
414
                self.export_to_xml(directory=directory)
1✔
415
            else:
416
                self.export_to_xml()
11✔
417
        self._intracomm.barrier()
11✔
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)
11✔
423

424
    def sync_dagmc_universes(self):
11✔
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
        """
434
        if self.is_initialized:
1✔
435
            if self.materials:
1✔
436
                materials = self.materials
1✔
437
            else:
UNCOV
438
                materials = list(self.geometry.get_all_materials().values())
×
439
            for univ in self.geometry.get_all_universes().values():
1✔
440
                if isinstance(univ, openmc.DAGMCUniverse):
1✔
441
                    univ.sync_dagmc_cells(materials)
1✔
442
        else:
UNCOV
443
            raise ValueError("The model must be initialized before calling "
×
444
                             "this method")
445

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

449
        .. versionadded:: 0.13.0
450

451
        """
452

453
        import openmc.lib
11✔
454

455
        openmc.lib.finalize()
11✔
456

457
    def deplete(
11✔
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:
11✔
UNCOV
500
            op_kwargs = {}
×
501
        elif isinstance(operator_kwargs, dict):
11✔
502
            op_kwargs = operator_kwargs
11✔
503
        else:
UNCOV
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
11✔
509

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

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

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

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

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

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

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

549
    def export_to_xml(self, directory: PathLike = '.', remove_surfs: bool = False,
11✔
550
                      nuclides_to_ignore: Iterable[str] | None = None):
551
        """Export model to separate XML files.
552

553
        Parameters
554
        ----------
555
        directory : PathLike
556
            Directory to write XML files to. If it doesn't exist already, it
557
            will be created.
558
        remove_surfs : bool
559
            Whether or not to remove redundant surfaces from the geometry when
560
            exporting.
561

562
            .. versionadded:: 0.13.1
563
        nuclides_to_ignore : list of str
564
            Nuclides to ignore when exporting to XML.
565

566
        """
567
        # Create directory if required
568
        d = Path(directory)
11✔
569
        if not d.is_dir():
11✔
570
            d.mkdir(parents=True, exist_ok=True)
11✔
571

572
        self.settings.export_to_xml(d)
11✔
573
        self.geometry.export_to_xml(d, remove_surfs=remove_surfs)
11✔
574

575
        # If a materials collection was specified, export it. Otherwise, look
576
        # for all materials in the geometry and use that to automatically build
577
        # a collection.
578
        if self.materials:
11✔
579
            self.materials.export_to_xml(d, nuclides_to_ignore=nuclides_to_ignore)
11✔
580
        else:
581
            materials = openmc.Materials(self.geometry.get_all_materials()
11✔
582
                                         .values())
583
            materials.export_to_xml(d, nuclides_to_ignore=nuclides_to_ignore)
11✔
584

585
        if self.tallies:
11✔
586
            self.tallies.export_to_xml(d)
11✔
587
        if self.plots:
11✔
588
            self.plots.export_to_xml(d)
11✔
589

590
    def export_to_model_xml(self, path: PathLike = 'model.xml', remove_surfs: bool = False,
11✔
591
                            nuclides_to_ignore: Iterable[str] | None = None):
592
        """Export model to a single XML file.
593

594
        .. versionadded:: 0.13.3
595

596
        Parameters
597
        ----------
598
        path : str or PathLike
599
            Location of the XML file to write (default is 'model.xml'). Can be a
600
            directory or file path.
601
        remove_surfs : bool
602
            Whether or not to remove redundant surfaces from the geometry when
603
            exporting.
604
        nuclides_to_ignore : list of str
605
            Nuclides to ignore when exporting to XML.
606

607
        """
608
        xml_path = Path(path)
11✔
609
        # if the provided path doesn't end with the XML extension, assume the
610
        # input path is meant to be a directory. If the directory does not
611
        # exist, create it and place a 'model.xml' file there.
612
        if not str(xml_path).endswith('.xml'):
11✔
613
            if not xml_path.exists():
11✔
UNCOV
614
                xml_path.mkdir(parents=True, exist_ok=True)
×
615
            elif not xml_path.is_dir():
11✔
UNCOV
616
                raise FileExistsError(f"File exists and is not a directory: '{xml_path}'")
×
617
            xml_path /= 'model.xml'
11✔
618
        # if this is an XML file location and the file's parent directory does
619
        # not exist, create it before continuing
620
        elif not xml_path.parent.exists():
11✔
UNCOV
621
            xml_path.parent.mkdir(parents=True, exist_ok=True)
×
622

623
        if remove_surfs:
11✔
UNCOV
624
            warnings.warn("remove_surfs kwarg will be deprecated soon, please "
×
625
                          "set the Geometry.merge_surfaces attribute instead.")
UNCOV
626
            self.geometry.merge_surfaces = True
×
627

628
        # provide a memo to track which meshes have been written
629
        mesh_memo = set()
11✔
630
        settings_element = self.settings.to_xml_element(mesh_memo)
11✔
631
        geometry_element = self.geometry.to_xml_element()
11✔
632

633
        xml.clean_indentation(geometry_element, level=1)
11✔
634
        xml.clean_indentation(settings_element, level=1)
11✔
635

636
        # If a materials collection was specified, export it. Otherwise, look
637
        # for all materials in the geometry and use that to automatically build
638
        # a collection.
639
        if self.materials:
11✔
640
            materials = self.materials
11✔
641
        else:
642
            materials = openmc.Materials(self.geometry.get_all_materials()
11✔
643
                                         .values())
644

645
        with open(xml_path, 'w', encoding='utf-8', errors='xmlcharrefreplace') as fh:
11✔
646
            # write the XML header
647
            fh.write("<?xml version='1.0' encoding='utf-8'?>\n")
11✔
648
            fh.write("<model>\n")
11✔
649
            # Write the materials collection to the open XML file first.
650
            # This will write the XML header also
651
            materials._write_xml(fh, False, level=1,
11✔
652
                                 nuclides_to_ignore=nuclides_to_ignore)
653
            # Write remaining elements as a tree
654
            fh.write(ET.tostring(geometry_element, encoding="unicode"))
11✔
655
            fh.write(ET.tostring(settings_element, encoding="unicode"))
11✔
656

657
            if self.tallies:
11✔
658
                tallies_element = self.tallies.to_xml_element(mesh_memo)
11✔
659
                xml.clean_indentation(
11✔
660
                    tallies_element, level=1, trailing_indent=self.plots)
661
                fh.write(ET.tostring(tallies_element, encoding="unicode"))
11✔
662
            if self.plots:
11✔
663
                plots_element = self.plots.to_xml_element()
11✔
664
                xml.clean_indentation(
11✔
665
                    plots_element, level=1, trailing_indent=False)
666
                fh.write(ET.tostring(plots_element, encoding="unicode"))
11✔
667
            fh.write("</model>\n")
11✔
668

669
    def import_properties(self, filename: PathLike):
11✔
670
        """Import physical properties
671

672
        .. versionchanged:: 0.13.0
673
            This method now updates values as loaded in memory with the C API
674

675
        Parameters
676
        ----------
677
        filename : PathLike
678
            Path to properties HDF5 file
679

680
        See Also
681
        --------
682
        openmc.lib.export_properties
683

684
        """
685
        import openmc.lib
11✔
686

687
        cells = self.geometry.get_all_cells()
11✔
688
        materials = self.geometry.get_all_materials()
11✔
689

690
        with h5py.File(filename, 'r') as fh:
11✔
691
            cells_group = fh['geometry/cells']
11✔
692

693
            # Make sure number of cells matches
694
            n_cells = fh['geometry'].attrs['n_cells']
11✔
695
            if n_cells != len(cells):
11✔
UNCOV
696
                raise ValueError("Number of cells in properties file doesn't "
×
697
                                 "match current model.")
698

699
            # Update temperatures and densities for cells filled with materials
700
            for name, group in cells_group.items():
11✔
701
                cell_id = int(name.split()[1])
11✔
702
                cell = cells[cell_id]
11✔
703
                if cell.fill_type in ('material', 'distribmat'):
11✔
704
                    temperature = group['temperature'][()]
11✔
705
                    cell.temperature = temperature
11✔
706
                    if self.is_initialized:
11✔
707
                        lib_cell = openmc.lib.cells[cell_id]
11✔
708
                        if temperature.size > 1:
11✔
UNCOV
709
                            for i, T in enumerate(temperature):
×
UNCOV
710
                                lib_cell.set_temperature(T, i)
×
711
                        else:
712
                            lib_cell.set_temperature(temperature[0])
11✔
713

714
                    if group['density']:
11✔
715
                      density = group['density'][()]
11✔
716
                      if density.size > 1:
11✔
UNCOV
717
                          cell.density = [rho for rho in density]
×
718
                      else:
719
                          cell.density = density
11✔
720
                      if self.is_initialized:
11✔
721
                          lib_cell = openmc.lib.cells[cell_id]
11✔
722
                          if density.size > 1:
11✔
UNCOV
723
                              for i, rho in enumerate(density):
×
UNCOV
724
                                  lib_cell.set_density(rho, i)
×
725
                          else:
726
                              lib_cell.set_density(density[0])
11✔
727

728
            # Make sure number of materials matches
729
            mats_group = fh['materials']
11✔
730
            n_cells = mats_group.attrs['n_materials']
11✔
731
            if n_cells != len(materials):
11✔
UNCOV
732
                raise ValueError("Number of materials in properties file "
×
733
                                 "doesn't match current model.")
734

735
            # Update material densities
736
            for name, group in mats_group.items():
11✔
737
                mat_id = int(name.split()[1])
11✔
738
                atom_density = group.attrs['atom_density']
11✔
739
                materials[mat_id].set_density('atom/b-cm', atom_density)
11✔
740
                if self.is_initialized:
11✔
741
                    C_mat = openmc.lib.materials[mat_id]
11✔
742
                    C_mat.set_density(atom_density, 'atom/b-cm')
11✔
743

744
    def run(
11✔
745
        self,
746
        particles: int | None = None,
747
        threads: int | None = None,
748
        geometry_debug: bool = False,
749
        restart_file: PathLike | None = None,
750
        tracks: bool = False,
751
        output: bool = True,
752
        cwd: PathLike = ".",
753
        openmc_exec: PathLike = "openmc",
754
        mpi_args: Iterable[str] = None,
755
        event_based: bool | None = None,
756
        export_model_xml: bool = True,
757
        apply_tally_results: bool = False,
758
        **export_kwargs,
759
    ) -> Path:
760
        """Run OpenMC
761

762
        If the C API has been initialized, then the C API is used, otherwise,
763
        this method creates the XML files and runs OpenMC via a system call. In
764
        both cases this method returns the path to the last statepoint file
765
        generated.
766

767
        .. versionchanged:: 0.12
768
            Instead of returning the final k-effective value, this function now
769
            returns the path to the final statepoint written.
770

771
        .. versionchanged:: 0.13.0
772
            This method can utilize the C API for execution
773

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

808
            .. versionadded:: 0.13.3
809
        apply_tally_results : bool
810
            Whether to apply results of the final statepoint file to the
811
            model's tally objects.
812

813
            .. versionadded:: 0.15.1
814
        **export_kwargs
815
            Keyword arguments passed to either :meth:`Model.export_to_model_xml`
816
            or :meth:`Model.export_to_xml`.
817

818
        Returns
819
        -------
820
        Path
821
            Path to the last statepoint written by this run (None if no
822
            statepoint was written)
823

824
        """
825

826
        # Setting tstart here ensures we don't pick up any pre-existing
827
        # statepoint files in the output directory -- just in case there are
828
        # differences between the system clock and the filesystem, we get the
829
        # time of a just-created temporary file
830
        with NamedTemporaryFile() as fp:
11✔
831
            tstart = Path(fp.name).stat().st_mtime
11✔
832
        last_statepoint = None
11✔
833

834
        # Operate in the provided working directory
835
        with change_directory(cwd):
11✔
836
            if self.is_initialized:
11✔
837
                # Handle the run options as applicable
838
                # First dont allow ones that must be set via init
839
                for arg_name, arg, default in zip(
11✔
840
                    ['threads', 'geometry_debug', 'restart_file', 'tracks'],
841
                    [threads, geometry_debug, restart_file, tracks],
842
                    [None, False, None, False]
843
                ):
844
                    if arg != default:
11✔
845
                        msg = f"{arg_name} must be set via Model.is_initialized(...)"
11✔
846
                        raise ValueError(msg)
11✔
847

848
                init_particles = openmc.lib.settings.particles
11✔
849
                if particles is not None:
11✔
UNCOV
850
                    if isinstance(particles, Integral) and particles > 0:
×
UNCOV
851
                        openmc.lib.settings.particles = particles
×
852

853
                init_event_based = openmc.lib.settings.event_based
11✔
854
                if event_based is not None:
11✔
UNCOV
855
                    openmc.lib.settings.event_based = event_based
×
856

857
                # Then run using the C API
858
                openmc.lib.run(output)
11✔
859

860
                # Reset changes for the openmc.run kwargs handling
861
                openmc.lib.settings.particles = init_particles
11✔
862
                openmc.lib.settings.event_based = init_event_based
11✔
863

864
            else:
865
                # Then run via the command line
866
                if export_model_xml:
11✔
867
                    self.export_to_model_xml(**export_kwargs)
11✔
868
                else:
869
                    self.export_to_xml(**export_kwargs)
11✔
870
                path_input = export_kwargs.get("path", None)
11✔
871
                openmc.run(particles, threads, geometry_debug, restart_file,
11✔
872
                           tracks, output, Path('.'), openmc_exec, mpi_args,
873
                           event_based, path_input)
874

875
            # Get output directory and return the last statepoint written
876
            if self.settings.output and 'path' in self.settings.output:
11✔
UNCOV
877
                output_dir = Path(self.settings.output['path'])
×
878
            else:
879
                output_dir = Path.cwd()
11✔
880
            for sp in output_dir.glob('statepoint.*.h5'):
11✔
881
                mtime = sp.stat().st_mtime
11✔
882
                if mtime >= tstart:  # >= allows for poor clock resolution
11✔
883
                    tstart = mtime
11✔
884
                    last_statepoint = sp
11✔
885

886
        if apply_tally_results:
11✔
887
            self.apply_tally_results(last_statepoint)
11✔
888

889
        return last_statepoint
11✔
890

891
    def calculate_volumes(
11✔
892
        self,
893
        threads: int | None = None,
894
        output: bool = True,
895
        cwd: PathLike = ".",
896
        openmc_exec: PathLike = "openmc",
897
        mpi_args: list[str] | None = None,
898
        apply_volumes: bool = True,
899
        export_model_xml: bool = True,
900
        **export_kwargs,
901
    ):
902
        """Runs an OpenMC stochastic volume calculation and, if requested,
903
        applies volumes to the model
904

905
        .. versionadded:: 0.13.0
906

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

937
        """
938

939
        if len(self.settings.volume_calculations) == 0:
11✔
940
            # Then there is no volume calculation specified
941
            raise ValueError("The Settings.volume_calculations attribute must"
11✔
942
                             " be specified before executing this method!")
943

944
        with change_directory(cwd):
11✔
945
            if self.is_initialized:
11✔
946
                if threads is not None:
11✔
UNCOV
947
                    msg = "Threads must be set via Model.is_initialized(...)"
×
UNCOV
948
                    raise ValueError(msg)
×
949
                if mpi_args is not None:
11✔
UNCOV
950
                    msg = "The MPI environment must be set otherwise such as" \
×
951
                        "with the call to mpi4py"
UNCOV
952
                    raise ValueError(msg)
×
953

954
                # Compute the volumes
955
                openmc.lib.calculate_volumes(output)
11✔
956

957
            else:
958
                if export_model_xml:
11✔
959
                    self.export_to_model_xml(**export_kwargs)
11✔
960
                else:
UNCOV
961
                    self.export_to_xml(**export_kwargs)
×
962
                path_input = export_kwargs.get("path", None)
11✔
963
                openmc.calculate_volumes(
11✔
964
                    threads=threads, output=output, openmc_exec=openmc_exec,
965
                    mpi_args=mpi_args, path_input=path_input
966
                )
967

968
            # Now we apply the volumes
969
            if apply_volumes:
11✔
970
                # Load the results and add them to the model
971
                for i, vol_calc in enumerate(self.settings.volume_calculations):
11✔
972
                    vol_calc.load_results(f"volume_{i + 1}.h5")
11✔
973
                    # First add them to the Python side
974
                    if vol_calc.domain_type == "material" and self.materials:
11✔
975
                        for material in self.materials:
11✔
976
                            if material.id in vol_calc.volumes:
11✔
977
                                material.add_volume_information(vol_calc)
11✔
978
                    else:
979
                        self.geometry.add_volume_information(vol_calc)
11✔
980

981
                    # And now repeat for the C API
982
                    if self.is_initialized and vol_calc.domain_type == 'material':
11✔
983
                        # Then we can do this in the C API
984
                        for domain_id in vol_calc.ids:
11✔
985
                            openmc.lib.materials[domain_id].volume = \
11✔
986
                                vol_calc.volumes[domain_id].n
987

988

989
    def _set_plot_defaults(
11✔
990
        self,
991
        origin: Sequence[float] | None,
992
        width: Sequence[float] | None,
993
        pixels: int | Sequence[int],
994
        basis: str
995
    ):
996
        x, y, _ = _BASIS_INDICES[basis]
11✔
997

998
        bb = self.bounding_box
11✔
999
        # checks to see if bounding box contains -inf or inf values
1000
        if np.isinf(bb.extent[basis]).any():
11✔
1001
            if origin is None:
11✔
1002
                origin = (0, 0, 0)
11✔
1003
            if width is None:
11✔
1004
                width = (10, 10)
11✔
1005
        else:
1006
            if origin is None:
11✔
1007
                # if nan values in the bb.center they get replaced with 0.0
1008
                # this happens when the bounding_box contains inf values
1009
                with warnings.catch_warnings():
11✔
1010
                    warnings.simplefilter("ignore", RuntimeWarning)
11✔
1011
                    origin = np.nan_to_num(bb.center)
11✔
1012
            if width is None:
11✔
1013
                bb_width = bb.width
11✔
1014
                width = (bb_width[x], bb_width[y])
11✔
1015

1016
        if isinstance(pixels, int):
11✔
1017
            aspect_ratio = width[0] / width[1]
11✔
1018
            pixels_y = math.sqrt(pixels / aspect_ratio)
11✔
1019
            pixels = (int(pixels / pixels_y), int(pixels_y))
11✔
1020

1021
        return origin, width, pixels
11✔
1022

1023
    def id_map(
11✔
1024
        self,
1025
        origin: Sequence[float] | None = None,
1026
        width: Sequence[float] | None = None,
1027
        pixels: int | Sequence[int] = 40000,
1028
        basis: str = 'xy',
1029
        **init_kwargs
1030
    ) -> np.ndarray:
1031
        """Generate an ID map for domains based on the plot parameters
1032

1033
        If the model is not yet initialized, it will be initialized with
1034
        openmc.lib. If the model is initialized, the model will remain
1035
        initialized after this method call exits.
1036

1037
        .. versionadded:: 0.15.3
1038

1039
        Parameters
1040
        ----------
1041
        origin : Sequence[float], optional
1042
            Origin of the plot. If unspecified, this argument defaults to the
1043
            center of the bounding box if the bounding box does not contain inf
1044
            values for the provided basis, otherwise (0.0, 0.0, 0.0).
1045
        width : Sequence[float], optional
1046
            Width of the plot. If unspecified, this argument defaults to the
1047
            width of the bounding box if the bounding box does not contain inf
1048
            values for the provided basis, otherwise (10.0, 10.0).
1049
        pixels : int | Sequence[int], optional
1050
            If an iterable of ints is provided then this directly sets the
1051
            number of pixels to use in each basis direction. If a single int is
1052
            provided then this sets the total number of pixels in the plot and
1053
            the number of pixels in each basis direction is calculated from this
1054
            total and the image aspect ratio based on the width argument.
1055
        basis : {'xy', 'yz', 'xz'}, optional
1056
            Basis of the plot.
1057
        **init_kwargs
1058
            Keyword arguments passed to :meth:`Model.init_lib`.
1059

1060
        Returns
1061
        -------
1062
        id_map : numpy.ndarray
1063
            A NumPy array with shape (vertical pixels, horizontal pixels, 3) of
1064
            OpenMC property IDs with dtype int32. The last dimension of the
1065
            array contains cell IDs, cell instances, and material IDs (in that
1066
            order).
1067
        """
1068
        import openmc.lib
11✔
1069

1070
        origin, width, pixels = self._set_plot_defaults(
11✔
1071
            origin, width, pixels, basis)
1072

1073
        # initialize the openmc.lib.plot._PlotBase object
1074
        plot_obj = openmc.lib.plot._PlotBase()
11✔
1075
        plot_obj.origin = origin
11✔
1076
        plot_obj.width = width[0]
11✔
1077
        plot_obj.height = width[1]
11✔
1078
        plot_obj.h_res = pixels[0]
11✔
1079
        plot_obj.v_res = pixels[1]
11✔
1080
        plot_obj.basis = basis
11✔
1081

1082
        # Silence output by default. Also set arguments to start in volume
1083
        # calculation mode to avoid loading cross sections
1084
        init_kwargs.setdefault('output', False)
11✔
1085
        init_kwargs.setdefault('args', ['-c'])
11✔
1086

1087
        with openmc.lib.TemporarySession(self, **init_kwargs):
11✔
1088
            return openmc.lib.id_map(plot_obj)
11✔
1089

1090
    @add_plot_params
11✔
1091
    def plot(
11✔
1092
        self,
1093
        origin: Sequence[float] | None = None,
1094
        width: Sequence[float] | None = None,
1095
        pixels: int | Sequence[int] = 40000,
1096
        basis: str = 'xy',
1097
        color_by: str = 'cell',
1098
        colors: dict | None = None,
1099
        seed: int | None = None,
1100
        openmc_exec: PathLike = 'openmc',
1101
        axes=None,
1102
        legend: bool = False,
1103
        axis_units: str = 'cm',
1104
        outline: bool | str = False,
1105
        show_overlaps: bool = False,
1106
        overlap_color: Sequence[int] | str | None = None,
1107
        n_samples: int | None = None,
1108
        plane_tolerance: float = 1.,
1109
        legend_kwargs: dict | None = None,
1110
        source_kwargs: dict | None = None,
1111
        contour_kwargs: dict | None = None,
1112
        **kwargs,
1113
    ):
1114
        """Display a slice plot of the model.
1115

1116
        .. versionadded:: 0.15.1
1117
        """
1118
        import matplotlib.image as mpimg
11✔
1119
        import matplotlib.patches as mpatches
11✔
1120
        import matplotlib.pyplot as plt
11✔
1121

1122
        check_type('n_samples', n_samples, int | None)
11✔
1123
        check_type('plane_tolerance', plane_tolerance, Real)
11✔
1124
        if legend_kwargs is None:
11✔
1125
            legend_kwargs = {}
11✔
1126
        legend_kwargs.setdefault('bbox_to_anchor', (1.05, 1))
11✔
1127
        legend_kwargs.setdefault('loc', 2)
11✔
1128
        legend_kwargs.setdefault('borderaxespad', 0.0)
11✔
1129
        if source_kwargs is None:
11✔
1130
            source_kwargs = {}
11✔
1131
        source_kwargs.setdefault('marker', 'x')
11✔
1132

1133
        # Set indices using basis and create axis labels
1134
        x, y, z = _BASIS_INDICES[basis]
11✔
1135
        xlabel, ylabel = f'{basis[0]} [{axis_units}]', f'{basis[1]} [{axis_units}]'
11✔
1136

1137
        # Determine extents of plot
1138
        origin, width, pixels = self._set_plot_defaults(
11✔
1139
            origin, width, pixels, basis)
1140

1141
        axis_scaling_factor = {'km': 0.00001, 'm': 0.01, 'cm': 1, 'mm': 10}
11✔
1142

1143
        x_min = (origin[x] - 0.5*width[0]) * axis_scaling_factor[axis_units]
11✔
1144
        x_max = (origin[x] + 0.5*width[0]) * axis_scaling_factor[axis_units]
11✔
1145
        y_min = (origin[y] - 0.5*width[1]) * axis_scaling_factor[axis_units]
11✔
1146
        y_max = (origin[y] + 0.5*width[1]) * axis_scaling_factor[axis_units]
11✔
1147

1148
        # Determine whether any materials contains macroscopic data and if so,
1149
        # set energy mode accordingly
1150
        _energy_mode = self.settings._energy_mode
11✔
1151
        for mat in self.geometry.get_all_materials().values():
11✔
1152
            if mat._macroscopic is not None:
11✔
1153
                self.settings.energy_mode = 'multi-group'
×
UNCOV
1154
                break
×
1155

1156
        with TemporaryDirectory() as tmpdir:
11✔
1157
            _plot_seed = self.settings.plot_seed
11✔
1158
            if seed is not None:
11✔
UNCOV
1159
                self.settings.plot_seed = seed
×
1160

1161
            # Create plot object matching passed arguments
1162
            plot = openmc.Plot()
11✔
1163
            plot.origin = origin
11✔
1164
            plot.width = width
11✔
1165
            plot.pixels = pixels
11✔
1166
            plot.basis = basis
11✔
1167
            plot.color_by = color_by
11✔
1168
            plot.show_overlaps = show_overlaps
11✔
1169
            if overlap_color is not None:
11✔
UNCOV
1170
                plot.overlap_color = overlap_color
×
1171
            if colors is not None:
11✔
1172
                plot.colors = colors
11✔
1173
            self.plots.append(plot)
11✔
1174

1175
            # Run OpenMC in geometry plotting mode
1176
            self.plot_geometry(False, cwd=tmpdir, openmc_exec=openmc_exec)
11✔
1177

1178
            # Undo changes to model
1179
            self.plots.pop()
11✔
1180
            self.settings._plot_seed = _plot_seed
11✔
1181
            self.settings._energy_mode = _energy_mode
11✔
1182

1183
            # Read image from file
1184
            img_path = Path(tmpdir) / f'plot_{plot.id}.png'
11✔
1185
            if not img_path.is_file():
11✔
UNCOV
1186
                img_path = img_path.with_suffix('.ppm')
×
1187
            img = mpimg.imread(str(img_path))
11✔
1188

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

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

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

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

1222
            # add legend showing which colors represent which material
1223
            # or cell if that was requested
1224
            if legend:
11✔
1225
                if plot.colors == {}:
11✔
1226
                    raise ValueError("Must pass 'colors' dictionary if you "
11✔
1227
                                     "are adding a legend via legend=True.")
1228

1229
                if color_by == "cell":
11✔
UNCOV
1230
                    expected_key_type = openmc.Cell
×
1231
                else:
1232
                    expected_key_type = openmc.Material
11✔
1233

1234
                patches = []
11✔
1235
                for key, color in plot.colors.items():
11✔
1236

1237
                    if isinstance(key, int):
11✔
UNCOV
1238
                        raise TypeError(
×
1239
                            "Cannot use IDs in colors dict for auto legend.")
1240
                    elif not isinstance(key, expected_key_type):
11✔
UNCOV
1241
                        raise TypeError(
×
1242
                            "Color dict key type does not match color_by")
1243

1244
                    # this works whether we're doing cells or materials
1245
                    label = key.name if key.name != '' else key.id
11✔
1246

1247
                    # matplotlib takes RGB on 0-1 scale rather than 0-255. at
1248
                    # this point PlotBase has already checked that 3-tuple
1249
                    # based colors are already valid, so if the length is three
1250
                    # then we know it just needs to be converted to the 0-1
1251
                    # format.
1252
                    if len(color) == 3 and not isinstance(color, str):
11✔
1253
                        scaled_color = (
11✔
1254
                            color[0]/255, color[1]/255, color[2]/255)
1255
                    else:
1256
                        scaled_color = color
11✔
1257

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

1261
                axes.legend(handles=patches, **legend_kwargs)
11✔
1262

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

1267

1268
        if n_samples:
11✔
1269
            # Sample external source particles
1270
            particles = self.sample_external_source(n_samples)
11✔
1271

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

1283
        return axes
11✔
1284

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

1293
        .. versionadded:: 0.15.1
1294

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

1305
        Returns
1306
        -------
1307
        openmc.ParticleList
1308
            List of samples source particles
1309
        """
1310
        import openmc.lib
11✔
1311

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

1317
        with openmc.lib.TemporarySession(self, **init_kwargs):
11✔
1318
            return openmc.lib.sample_external_source(
11✔
1319
                n_samples=n_samples, prn_seed=prn_seed
1320
            )
1321

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

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

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

1342
        .. versionadded:: 0.13.0
1343

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

1361
        """
1362

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

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

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

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

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

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

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

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

1476
        .. versionadded:: 0.13.0
1477

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

1487
        """
1488

1489
        self._change_py_lib_attribs(names_or_ids, vector, 'cell', 'rotation')
11✔
1490

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

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

1500
        .. versionadded:: 0.13.0
1501

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

1511
        """
1512

1513
        self._change_py_lib_attribs(names_or_ids, vector, 'cell',
11✔
1514
                                    'translation')
1515

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

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

1527
        .. versionadded:: 0.13.0
1528

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

1539
        """
1540

1541
        self._change_py_lib_attribs(names_or_ids, density, 'material',
11✔
1542
                                    'density', density_units)
1543

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

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

1552
        .. versionadded:: 0.13.0
1553

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

1562
        """
1563

1564
        self._change_py_lib_attribs(names_or_ids, temperature, 'cell',
11✔
1565
                                    'temperature')
1566

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

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

1575
        .. versionadded:: 0.13.0
1576

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

1585
        """
1586

1587
        self._change_py_lib_attribs(names_or_ids, volume, 'material', 'volume')
11✔
1588

1589
    def differentiate_depletable_mats(self, diff_volume_method: str = None):
11✔
1590
        """Assign distribmats for each depletable material
1591

1592
        .. versionadded:: 0.14.0
1593

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

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

1609
    def differentiate_mats(self, diff_volume_method: str = None, depletable_only: bool = True):
11✔
1610
        """Assign distribmats for each material
1611

1612
        .. versionadded:: 0.15.1
1613

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

1627
        # Count the number of instances for each cell and material
1628
        self.geometry.determine_paths(instances_only=True)
11✔
1629

1630
        # Get list of materials
1631
        if self.materials:
11✔
1632
            materials = self.materials
11✔
1633
        else:
UNCOV
1634
            materials = list(self.geometry.get_all_materials().values())
×
1635

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

1663
        if not distribmats:
11✔
UNCOV
1664
            return
×
1665

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

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

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

1685
        if self.materials is not None:
11✔
1686
            self.materials = openmc.Materials(
11✔
1687
                self.geometry.get_all_materials().values()
1688
            )
1689

1690
    def _generate_infinite_medium_mgxs(
11✔
1691
        self,
1692
        groups: openmc.mgxs.EnergyGroups,
1693
        nparticles: int,
1694
        mgxs_path: PathLike,
1695
        correction: str | None,
1696
        directory: PathLike,
1697
    ):
1698
        """Generate a MGXS library by running multiple OpenMC simulations, each
1699
        representing an infinite medium simulation of a single isolated
1700
        material. A discrete source is used to sample particles, with an equal
1701
        strength spread across each of the energy groups. This is a highly naive
1702
        method that ignores all spatial self shielding effects and all resonance
1703
        shielding effects between materials.
1704

1705
        Parameters
1706
        ----------
1707
        groups : openmc.mgxs.EnergyGroups
1708
            Energy group structure for the MGXS.
1709
        nparticles : int
1710
            Number of particles to simulate per batch when generating MGXS.
1711
        mgxs_path : str
1712
            Filename for the MGXS HDF5 file.
1713
        correction : str
1714
            Transport correction to apply to the MGXS. Options are None and
1715
            "P0".
1716
        directory : str
1717
            Directory to run the simulation in, so as to contain XML files.
1718
        """
1719
        warnings.warn("The infinite medium method of generating MGXS may hang "
11✔
1720
                      "if a material has a k-infinity > 1.0.")
1721
        mgxs_sets = []
11✔
1722
        for material in self.materials:
11✔
1723
            model = openmc.Model()
11✔
1724

1725
            # Set materials on the model
1726
            model.materials = [material]
11✔
1727

1728
            # Settings
1729
            model.settings.batches = 100
11✔
1730
            model.settings.particles = nparticles
11✔
1731
            model.settings.run_mode = 'fixed source'
11✔
1732

1733
            # Make a discrete source that is uniform over the bins of the group structure
1734
            n_groups = groups.num_groups
11✔
1735
            midpoints = []
11✔
1736
            strengths = []
11✔
1737
            for i in range(n_groups):
11✔
1738
                bounds = groups.get_group_bounds(i+1)
11✔
1739
                midpoints.append((bounds[0] + bounds[1]) / 2.0)
11✔
1740
                strengths.append(1.0)
11✔
1741

1742
            energy_distribution = openmc.stats.Discrete(x=midpoints, p=strengths)
11✔
1743
            model.settings.source = openmc.IndependentSource(
11✔
1744
                space=openmc.stats.Point(), energy=energy_distribution)
1745
            model.settings.output = {'summary': True, 'tallies': False}
11✔
1746

1747
            # Geometry
1748
            box = openmc.model.RectangularPrism(
11✔
1749
                100000.0, 100000.0, boundary_type='reflective')
1750
            name = material.name
11✔
1751
            infinite_cell = openmc.Cell(name=name, fill=material, region=-box)
11✔
1752
            infinite_universe = openmc.Universe(name=name, cells=[infinite_cell])
11✔
1753
            model.geometry.root_universe = infinite_universe
11✔
1754

1755
            # Add MGXS Tallies
1756

1757
            # Initialize MGXS library with a finished OpenMC geometry object
1758
            mgxs_lib = openmc.mgxs.Library(model.geometry)
11✔
1759

1760
            # Pick energy group structure
1761
            mgxs_lib.energy_groups = groups
11✔
1762

1763
            # Disable transport correction
1764
            mgxs_lib.correction = correction
11✔
1765

1766
            # Specify needed cross sections for random ray
1767
            if correction == 'P0':
11✔
UNCOV
1768
                mgxs_lib.mgxs_types = [
×
1769
                    'nu-transport', 'absorption', 'nu-fission', 'fission',
1770
                    'consistent nu-scatter matrix', 'multiplicity matrix', 'chi'
1771
                ]
1772
            elif correction is None:
11✔
1773
                mgxs_lib.mgxs_types = [
11✔
1774
                    'total', 'absorption', 'nu-fission', 'fission',
1775
                    'consistent nu-scatter matrix', 'multiplicity matrix', 'chi'
1776
                ]
1777

1778
            # Specify a "cell" domain type for the cross section tally filters
1779
            mgxs_lib.domain_type = "material"
11✔
1780

1781
            # Specify the cell domains over which to compute multi-group cross sections
1782
            mgxs_lib.domains = model.geometry.get_all_materials().values()
11✔
1783

1784
            # Do not compute cross sections on a nuclide-by-nuclide basis
1785
            mgxs_lib.by_nuclide = False
11✔
1786

1787
            # Check the library - if no errors are raised, then the library is satisfactory.
1788
            mgxs_lib.check_library_for_openmc_mgxs()
11✔
1789

1790
            # Construct all tallies needed for the multi-group cross section library
1791
            mgxs_lib.build_library()
11✔
1792

1793
            # Create a "tallies.xml" file for the MGXS Library
1794
            mgxs_lib.add_to_tallies_file(model.tallies, merge=True)
11✔
1795

1796
            # Run
1797
            statepoint_filename = model.run(cwd=directory)
11✔
1798

1799
            # Load MGXS
1800
            with openmc.StatePoint(statepoint_filename) as sp:
11✔
1801
                mgxs_lib.load_from_statepoint(sp)
11✔
1802

1803
            # Create a MGXS File which can then be written to disk
1804
            mgxs_set = mgxs_lib.get_xsdata(domain=material, xsdata_name=name)
11✔
1805
            mgxs_sets.append(mgxs_set)
11✔
1806

1807
        # Write the file to disk
1808
        mgxs_file = openmc.MGXSLibrary(energy_groups=groups)
11✔
1809
        for mgxs_set in mgxs_sets:
11✔
1810
            mgxs_file.add_xsdata(mgxs_set)
11✔
1811
        mgxs_file.export_to_hdf5(mgxs_path)
11✔
1812

1813
    @staticmethod
11✔
1814
    def _create_stochastic_slab_geometry(
11✔
1815
        materials: Sequence[openmc.Material],
1816
        cell_thickness: float = 1.0,
1817
        num_repeats: int = 100,
1818
    ) -> tuple[openmc.Geometry, openmc.stats.Box]:
1819
        """Create a geometry representing a stochastic "sandwich" of materials in a
1820
        layered slab geometry. To reduce the impact of the order of materials in
1821
        the slab, the materials are applied to 'num_repeats' different randomly
1822
        positioned layers of 'cell_thickness' each.
1823

1824
        Parameters
1825
        ----------
1826
        materials : list of openmc.Material
1827
            List of materials to assign. Each material will appear exactly num_repeats times,
1828
            then the ordering is randomly shuffled.
1829
        cell_thickness : float, optional
1830
            Thickness of each lattice cell in x (default 1.0 cm).
1831
        num_repeats : int, optional
1832
            Number of repeats for each material (default 100).
1833

1834
        Returns
1835
        -------
1836
        geometry : openmc.Geometry
1837
            The constructed geometry.
1838
        box : openmc.stats.Box
1839
            A spatial sampling distribution covering the full slab domain.
1840
        """
1841
        if not materials:
11✔
UNCOV
1842
            raise ValueError("At least one material must be provided.")
×
1843

1844
        num_materials = len(materials)
11✔
1845
        total_cells = num_materials * num_repeats
11✔
1846
        total_width = total_cells * cell_thickness
11✔
1847

1848
        # Generate an infinite cell/universe for each material
1849
        universes = []
11✔
1850
        for i in range(num_materials):
11✔
1851
            cell = openmc.Cell(fill=materials[i])
11✔
1852
            universes.append(openmc.Universe(cells=[cell]))
11✔
1853

1854
        # Make a list of randomized material idx assignments for the stochastic slab
1855
        assignments = list(range(num_materials)) * num_repeats
11✔
1856
        random.seed(42)
11✔
1857
        random.shuffle(assignments)
11✔
1858

1859
        # Create a list of the (randomized) universe assignments to be used
1860
        # when defining the problem lattice.
1861
        lattice_entries = [universes[m] for m in assignments]
11✔
1862

1863
        # Create the RectLattice for the 1D material variation in x.
1864
        lattice = openmc.RectLattice()
11✔
1865
        lattice.pitch = (cell_thickness, total_width, total_width)
11✔
1866
        lattice.lower_left = (0.0, 0.0, 0.0)
11✔
1867
        lattice.universes = [[lattice_entries]]
11✔
1868
        lattice.outer = universes[0]
11✔
1869

1870
        # Define the six outer surfaces with reflective boundary conditions
1871
        rpp = openmc.model.RectangularParallelepiped(
11✔
1872
            0.0, total_width, 0.0, total_width, 0.0, total_width,
1873
            boundary_type='reflective'
1874
        )
1875

1876
        # Create an outer cell that fills with the lattice.
1877
        outer_cell = openmc.Cell(fill=lattice, region=-rpp)
11✔
1878

1879
        # Build the geometry
1880
        geometry = openmc.Geometry([outer_cell])
11✔
1881

1882
        # Define the spatial distribution that covers the full cubic domain
1883
        box = openmc.stats.Box(*outer_cell.bounding_box)
11✔
1884

1885
        return geometry, box
11✔
1886

1887
    def _generate_stochastic_slab_mgxs(
11✔
1888
        self,
1889
        groups: openmc.mgxs.EnergyGroups,
1890
        nparticles: int,
1891
        mgxs_path: PathLike,
1892
        correction: str | None,
1893
        directory: PathLike,
1894
    ) -> None:
1895
        """Generate MGXS assuming a stochastic "sandwich" of materials in a layered
1896
        slab geometry. While geometry-specific spatial shielding effects are not
1897
        captured, this method can be useful when the geometry has materials only
1898
        found far from the source region that the "material_wise" method would
1899
        not be capable of generating cross sections for. Conversely, this method
1900
        will generate cross sections for all materials in the problem regardless
1901
        of type. If this is a fixed source problem, a discrete source is used to
1902
        sample particles, with an equal strength spread across each of the
1903
        energy groups.
1904

1905
        Parameters
1906
        ----------
1907
        groups : openmc.mgxs.EnergyGroups
1908
            Energy group structure for the MGXS.
1909
        nparticles : int
1910
            Number of particles to simulate per batch when generating MGXS.
1911
        mgxs_path : str
1912
            Filename for the MGXS HDF5 file.
1913
        correction : str
1914
            Transport correction to apply to the MGXS. Options are None and
1915
            "P0".
1916
        directory : str
1917
            Directory to run the simulation in, so as to contain XML files.
1918
        """
1919
        model = openmc.Model()
11✔
1920
        model.materials = self.materials
11✔
1921

1922
        # Settings
1923
        model.settings.batches = 200
11✔
1924
        model.settings.inactive = 100
11✔
1925
        model.settings.particles = nparticles
11✔
1926
        model.settings.output = {'summary': True, 'tallies': False}
11✔
1927
        model.settings.run_mode = self.settings.run_mode
11✔
1928

1929
        # Stochastic slab geometry
1930
        model.geometry, spatial_distribution = Model._create_stochastic_slab_geometry(
11✔
1931
            model.materials)
1932

1933
        # Make a discrete source that is uniform over the bins of the group structure
1934
        n_groups = groups.num_groups
11✔
1935
        midpoints = []
11✔
1936
        strengths = []
11✔
1937
        for i in range(n_groups):
11✔
1938
            bounds = groups.get_group_bounds(i+1)
11✔
1939
            midpoints.append((bounds[0] + bounds[1]) / 2.0)
11✔
1940
            strengths.append(1.0)
11✔
1941

1942
        energy_distribution = openmc.stats.Discrete(x=midpoints, p=strengths)
11✔
1943
        model.settings.source = [openmc.IndependentSource(
11✔
1944
            space=spatial_distribution, energy=energy_distribution, strength=1.0)]
1945

1946
        model.settings.output = {'summary': True, 'tallies': False}
11✔
1947

1948
        # Add MGXS Tallies
1949

1950
        # Initialize MGXS library with a finished OpenMC geometry object
1951
        mgxs_lib = openmc.mgxs.Library(model.geometry)
11✔
1952

1953
        # Pick energy group structure
1954
        mgxs_lib.energy_groups = groups
11✔
1955

1956
        # Disable transport correction
1957
        mgxs_lib.correction = correction
11✔
1958

1959
       # Specify needed cross sections for random ray
1960
        if correction == 'P0':
11✔
UNCOV
1961
            mgxs_lib.mgxs_types = ['nu-transport', 'absorption', 'nu-fission', 'fission',
×
1962
                                   'consistent nu-scatter matrix', 'multiplicity matrix', 'chi']
1963
        elif correction is None:
11✔
1964
            mgxs_lib.mgxs_types = ['total', 'absorption', 'nu-fission', 'fission',
11✔
1965
                                   'consistent nu-scatter matrix', 'multiplicity matrix', 'chi']
1966

1967
        # Specify a "cell" domain type for the cross section tally filters
1968
        mgxs_lib.domain_type = "material"
11✔
1969

1970
        # Specify the cell domains over which to compute multi-group cross sections
1971
        mgxs_lib.domains = model.geometry.get_all_materials().values()
11✔
1972

1973
        # Do not compute cross sections on a nuclide-by-nuclide basis
1974
        mgxs_lib.by_nuclide = False
11✔
1975

1976
        # Check the library - if no errors are raised, then the library is satisfactory.
1977
        mgxs_lib.check_library_for_openmc_mgxs()
11✔
1978

1979
        # Construct all tallies needed for the multi-group cross section library
1980
        mgxs_lib.build_library()
11✔
1981

1982
        # Create a "tallies.xml" file for the MGXS Library
1983
        mgxs_lib.add_to_tallies_file(model.tallies, merge=True)
11✔
1984

1985
        # Run
1986
        statepoint_filename = model.run(cwd=directory)
11✔
1987

1988
        # Load MGXS
1989
        with openmc.StatePoint(statepoint_filename) as sp:
11✔
1990
            mgxs_lib.load_from_statepoint(sp)
11✔
1991

1992
        names = [mat.name for mat in mgxs_lib.domains]
11✔
1993

1994
        # Create a MGXS File which can then be written to disk
1995
        mgxs_file = mgxs_lib.create_mg_library(xs_type='macro', xsdata_names=names)
11✔
1996
        mgxs_file.export_to_hdf5(mgxs_path)
11✔
1997

1998
    def _generate_material_wise_mgxs(
11✔
1999
        self,
2000
        groups: openmc.mgxs.EnergyGroups,
2001
        nparticles: int,
2002
        mgxs_path: PathLike,
2003
        correction: str | None,
2004
        directory: PathLike,
2005
    ) -> None:
2006
        """Generate a material-wise MGXS library for the model by running the
2007
        original continuous energy OpenMC simulation of the full material
2008
        geometry and source, and tally MGXS data for each material. This method
2009
        accurately conserves reaction rates totaled over the entire simulation
2010
        domain. However, when the geometry has materials only found far from the
2011
        source region, it is possible the Monte Carlo solver may not be able to
2012
        score any tallies to these material types, thus resulting in zero cross
2013
        section values for these materials. For such cases, the "stochastic
2014
        slab" method may be more appropriate.
2015

2016
        Parameters
2017
        ----------
2018
        groups : openmc.mgxs.EnergyGroups
2019
            Energy group structure for the MGXS.
2020
        nparticles : int
2021
            Number of particles to simulate per batch when generating MGXS.
2022
        mgxs_path : PathLike
2023
            Filename for the MGXS HDF5 file.
2024
        correction : str
2025
            Transport correction to apply to the MGXS. Options are None and
2026
            "P0".
2027
        directory : PathLike
2028
            Directory to run the simulation in, so as to contain XML files.
2029
        """
2030
        model = copy.deepcopy(self)
11✔
2031
        model.tallies = openmc.Tallies()
11✔
2032

2033
        # Settings
2034
        model.settings.batches = 200
11✔
2035
        model.settings.inactive = 100
11✔
2036
        model.settings.particles = nparticles
11✔
2037
        model.settings.output = {'summary': True, 'tallies': False}
11✔
2038

2039
        # Add MGXS Tallies
2040

2041
        # Initialize MGXS library with a finished OpenMC geometry object
2042
        mgxs_lib = openmc.mgxs.Library(model.geometry)
11✔
2043

2044
        # Pick energy group structure
2045
        mgxs_lib.energy_groups = groups
11✔
2046

2047
        # Disable transport correction
2048
        mgxs_lib.correction = correction
11✔
2049

2050
        # Specify needed cross sections for random ray
2051
        if correction == 'P0':
11✔
2052
            mgxs_lib.mgxs_types = [
11✔
2053
                'nu-transport', 'absorption', 'nu-fission', 'fission',
2054
                'consistent nu-scatter matrix', 'multiplicity matrix', 'chi'
2055
            ]
2056
        elif correction is None:
11✔
2057
            mgxs_lib.mgxs_types = [
11✔
2058
                'total', 'absorption', 'nu-fission', 'fission',
2059
                'consistent nu-scatter matrix', 'multiplicity matrix', 'chi'
2060
            ]
2061

2062
        # Specify a "cell" domain type for the cross section tally filters
2063
        mgxs_lib.domain_type = "material"
11✔
2064

2065
        # Specify the cell domains over which to compute multi-group cross sections
2066
        mgxs_lib.domains = model.geometry.get_all_materials().values()
11✔
2067

2068
        # Do not compute cross sections on a nuclide-by-nuclide basis
2069
        mgxs_lib.by_nuclide = False
11✔
2070

2071
        # Check the library - if no errors are raised, then the library is satisfactory.
2072
        mgxs_lib.check_library_for_openmc_mgxs()
11✔
2073

2074
        # Construct all tallies needed for the multi-group cross section library
2075
        mgxs_lib.build_library()
11✔
2076

2077
        # Create a "tallies.xml" file for the MGXS Library
2078
        mgxs_lib.add_to_tallies_file(model.tallies, merge=True)
11✔
2079

2080
        # Run
2081
        statepoint_filename = model.run(cwd=directory)
11✔
2082

2083
        # Load MGXS
2084
        with openmc.StatePoint(statepoint_filename) as sp:
11✔
2085
            mgxs_lib.load_from_statepoint(sp)
11✔
2086

2087
        names = [mat.name for mat in mgxs_lib.domains]
11✔
2088

2089
        # Create a MGXS File which can then be written to disk
2090
        mgxs_file = mgxs_lib.create_mg_library(
11✔
2091
            xs_type='macro', xsdata_names=names)
2092
        mgxs_file.export_to_hdf5(mgxs_path)
11✔
2093

2094
    def convert_to_multigroup(
11✔
2095
        self,
2096
        method: str = "material_wise",
2097
        groups: str = "CASMO-2",
2098
        nparticles: int = 2000,
2099
        overwrite_mgxs_library: bool = False,
2100
        mgxs_path: PathLike = "mgxs.h5",
2101
        correction: str | None = None,
2102
    ):
2103
        """Convert all materials from continuous energy to multigroup.
2104

2105
        If no MGXS data library file is found, generate one using one or more
2106
        continuous energy Monte Carlo simulations.
2107

2108
        Parameters
2109
        ----------
2110
        method : {"material_wise", "stochastic_slab", "infinite_medium"}, optional
2111
            Method to generate the MGXS.
2112
        groups : openmc.mgxs.EnergyGroups or str, optional
2113
            Energy group structure for the MGXS or the name of the group
2114
            structure (based on keys from openmc.mgxs.GROUP_STRUCTURES).
2115
        mgxs_path : str, optional
2116
            Filename of the mgxs.h5 library file.
2117
        correction : str, optional
2118
            Transport correction to apply to the MGXS. Options are None and
2119
            "P0".
2120
        """
2121
        if isinstance(groups, str):
11✔
2122
            groups = openmc.mgxs.EnergyGroups(groups)
11✔
2123

2124
        # Do all work (including MGXS generation) in a temporary directory
2125
        # to avoid polluting the working directory with residual XML files
2126
        with TemporaryDirectory() as tmpdir:
11✔
2127

2128
            # Determine if there are DAGMC universes in the model. If so, we need to synchronize
2129
            # the dagmc materials with cells.
2130
            # TODO: Can this be done without having to init/finalize?
2131
            for univ in self.geometry.get_all_universes().values():
11✔
2132
                if isinstance(univ, openmc.DAGMCUniverse):
11✔
2133
                    self.init_lib(directory=tmpdir)
1✔
2134
                    self.sync_dagmc_universes()
1✔
2135
                    self.finalize_lib()
1✔
2136
                    break
1✔
2137

2138
            # Make sure all materials have a name, and that the name is a valid HDF5
2139
            # dataset name
2140
            for material in self.materials:
11✔
2141
                if not material.name or not material.name.strip():
11✔
UNCOV
2142
                    material.name = f"material {material.id}"
×
2143
                material.name = re.sub(r'[^a-zA-Z0-9]', '_', material.name)
11✔
2144

2145
            # If needed, generate the needed MGXS data library file
2146
            if not Path(mgxs_path).is_file() or overwrite_mgxs_library:
11✔
2147
                if method == "infinite_medium":
11✔
2148
                    self._generate_infinite_medium_mgxs(
11✔
2149
                        groups, nparticles, mgxs_path, correction, tmpdir)
2150
                elif method == "material_wise":
11✔
2151
                    self._generate_material_wise_mgxs(
11✔
2152
                        groups, nparticles, mgxs_path, correction, tmpdir)
2153
                elif method == "stochastic_slab":
11✔
2154
                    self._generate_stochastic_slab_mgxs(
11✔
2155
                        groups, nparticles, mgxs_path, correction, tmpdir)
2156
                else:
UNCOV
2157
                    raise ValueError(
×
2158
                        f'MGXS generation method "{method}" not recognized')
2159
            else:
UNCOV
2160
                print(f'Existing MGXS library file "{mgxs_path}" will be used')
×
2161

2162
            # Convert all continuous energy materials to multigroup
2163
            self.materials.cross_sections = mgxs_path
11✔
2164
            for material in self.materials:
11✔
2165
                material.set_density('macro', 1.0)
11✔
2166
                material._nuclides = []
11✔
2167
                material._sab = []
11✔
2168
                material.add_macroscopic(material.name)
11✔
2169

2170
            self.settings.energy_mode = 'multi-group'
11✔
2171

2172
    def convert_to_random_ray(self):
11✔
2173
        """Convert a multigroup model to use random ray.
2174

2175
        This method determines values for the needed settings and adds them to
2176
        the settings.random_ray dictionary so as to enable random ray mode. The
2177
        settings that are populated are:
2178

2179
        - 'ray_source' (openmc.IndependentSource): Where random ray starting
2180
          points are sampled from.
2181
        - 'distance_inactive' (float): The "dead zone" distance at the beginning
2182
          of the ray.
2183
        - 'distance_active' (float): The "active" distance of the ray
2184
        - 'particles' (int): Number of rays to simulate
2185

2186
        The method will determine reasonable defaults for each of the above
2187
        variables based on analysis of the model's geometry. The function will
2188
        have no effect if the random ray dictionary is already defined in the
2189
        model settings.
2190
        """
2191
        # If the random ray dictionary is already set, don't overwrite it
2192
        if self.settings.random_ray:
11✔
UNCOV
2193
            warnings.warn("Random ray conversion skipped as "
×
2194
                          "settings.random_ray dictionary is already set.")
UNCOV
2195
            return
×
2196

2197
        if self.settings.energy_mode != 'multi-group':
11✔
UNCOV
2198
            raise ValueError(
×
2199
                "Random ray conversion failed: energy mode must be "
2200
                "'multi-group'. Use convert_to_multigroup() first."
2201
            )
2202

2203
        # Helper function for detecting infinity
2204
        def _replace_infinity(value):
11✔
2205
            if np.isinf(value):
11✔
2206
                return 1.0 if value > 0 else -1.0
11✔
2207
            return value
11✔
2208

2209
        # Get a bounding box for sampling rays. We can utilize the geometry's bounding box
2210
        # though for 2D problems we need to detect the infinities and replace them with an
2211
        # arbitrary finite value.
2212
        bounding_box = self.geometry.bounding_box
11✔
2213
        lower_left = [_replace_infinity(v) for v in bounding_box.lower_left]
11✔
2214
        upper_right = [_replace_infinity(v) for v in bounding_box.upper_right]
11✔
2215
        uniform_dist_ray = openmc.stats.Box(lower_left, upper_right)
11✔
2216
        rr_source = openmc.IndependentSource(space=uniform_dist_ray)
11✔
2217
        self.settings.random_ray['ray_source'] = rr_source
11✔
2218

2219
        # For the dead zone and active length, a reasonable guess is the larger of either:
2220
        # 1) The maximum chord length through the geometry (as defined by its bounding box)
2221
        # 2) 30 cm
2222
        # Then, set the active length to be 5x longer than the dead zone length, for the sake of efficiency.
2223
        chord_length = np.array(upper_right) - np.array(lower_left)
11✔
2224
        max_length = max(np.linalg.norm(chord_length), 30.0)
11✔
2225

2226
        self.settings.random_ray['distance_inactive'] = max_length
11✔
2227
        self.settings.random_ray['distance_active'] = 5 * max_length
11✔
2228

2229
        # Take a wild guess as to how many rays are needed
2230
        self.settings.particles = 2 * int(max_length)
11✔
2231

2232
    def keff_search(
11✔
2233
        self,
2234
        func: ModelModifier,
2235
        x0: float,
2236
        x1: float,
2237
        target: float = 1.0,
2238
        k_tol: float = 1e-4,
2239
        sigma_final: float = 3e-4,
2240
        p: float = 0.5,
2241
        q: float = 0.95,
2242
        memory: int = 4,
2243
        x_min: float | None = None,
2244
        x_max: float | None = None,
2245
        b0: int | None = None,
2246
        b_min: int = 20,
2247
        b_max: int | None = None,
2248
        maxiter: int = 50,
2249
        output: bool = False,
2250
        func_kwargs: dict[str, Any] | None = None,
2251
        run_kwargs: dict[str, Any] | None = None,
2252
    ) -> SearchResult:
2253
        r"""Perform a keff search on a model parametrized by a single variable.
2254

2255
        This method uses the GRsecant method described in a paper by `Price and
2256
        Roskoff <https://doi.org/10.1016/j.pnucene.2023.104731>`_. The GRsecant
2257
        method is a modification of the secant method that accounts for
2258
        uncertainties in the function evaluations. The method uses a weighted
2259
        linear fit of the most recent function evaluations to predict the next
2260
        point to evaluate. It also adaptively changes the number of batches to
2261
        meet the target uncertainty value at each iteration.
2262

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

2266
        .. math::
2267
            \sigma_{i+1} = q \sigma_\text{final} \left ( \frac{ \min \left \{
2268
            \left\lvert k_i - k_\text{target} \right\rvert : k=0,1,\dots,n
2269
            \right \} }{k_\text{tol}} \right )^p
2270

2271
        where :math:`q` is a multiplicative factor less than 1, given as the
2272
        ``sigma_factor`` parameter below.
2273

2274
        Parameters
2275
        ----------
2276
        func : ModelModifier
2277
            Function that takes the parameter to be searched and makes a
2278
            modification to the model.
2279
        x0 : float
2280
            First guess for the parameter passed to `func`
2281
        x1 : float
2282
            Second guess for the parameter passed to `func`
2283
        target : float, optional
2284
            keff value to search for
2285
        k_tol : float, optional
2286
            Stopping criterion on the function value; the absolute value must be
2287
            within ``k_tol`` of zero to be accepted.
2288
        sigma_final : float, optional
2289
            Maximum accepted k-effective uncertainty for the stopping criterion.
2290
        p : float, optional
2291
            Exponent used in the stopping criterion.
2292
        q : float, optional
2293
            Multiplicative factor used in the stopping criterion.
2294
        memory : int, optional
2295
            Number of most-recent points used in the weighted linear fit of
2296
            ``f(x) = a + b x`` to predict the next point.
2297
        x_min : float, optional
2298
            Minimum allowed value for the parameter ``x``.
2299
        x_max : float, optional
2300
            Maximum allowed value for the parameter ``x``.
2301
        b0 : int, optional
2302
            Number of active batches to use for the initial function
2303
            evaluations. If None, uses the model's current setting.
2304
        b_min : int, optional
2305
            Minimum number of active batches to use in a function evaluation.
2306
        b_max : int, optional
2307
            Maximum number of active batches to use in a function evaluation.
2308
        maxiter : int, optional
2309
            Maximum number of iterations to perform.
2310
        output : bool, optional
2311
            Whether or not to display output showing iteration progress.
2312
        func_kwargs : dict, optional
2313
            Keyword-based arguments to pass to the `func` function.
2314
        run_kwargs : dict, optional
2315
            Keyword arguments to pass to :meth:`openmc.Model.run` or
2316
            :meth:`openmc.lib.run`.
2317

2318
        Returns
2319
        -------
2320
        SearchResult
2321
            Result object containing the estimated root (parameter value) and
2322
            evaluation history (parameters, means, standard deviations, and
2323
            batches), plus convergence status and termination reason.
2324

2325
        """
2326
        import openmc.lib
11✔
2327

2328
        check_type('model modifier', func, Callable)
11✔
2329
        check_type('target', target, Real)
11✔
2330
        if memory < 2:
11✔
UNCOV
2331
            raise ValueError("memory must be ≥ 2")
×
2332
        func_kwargs = {} if func_kwargs is None else dict(func_kwargs)
11✔
2333
        run_kwargs = {} if run_kwargs is None else dict(run_kwargs)
11✔
2334
        run_kwargs.setdefault('output', False)
11✔
2335

2336
        # Create lists to store the history of evaluations
2337
        xs: list[float] = []
11✔
2338
        fs: list[float] = []
11✔
2339
        ss: list[float] = []
11✔
2340
        gs: list[int] = []
11✔
2341
        count = 0
11✔
2342

2343
        # Helper function to evaluate f and store results
2344
        def eval_at(x: float, batches: int) -> tuple[float, float]:
11✔
2345
            # Modify the model with the current guess
2346
            func(x, **func_kwargs)
11✔
2347

2348
            # Change the number of batches and run the model
2349
            batches += self.settings.inactive
11✔
2350
            if openmc.lib.is_initialized:
11✔
UNCOV
2351
                openmc.lib.settings.set_batches(batches)
×
UNCOV
2352
                openmc.lib.reset()
×
UNCOV
2353
                openmc.lib.run(**run_kwargs)
×
UNCOV
2354
                sp_filepath = f'statepoint.{batches}.h5'
×
2355
            else:
2356
                self.settings.batches = batches
11✔
2357
                sp_filepath = self.run(**run_kwargs)
11✔
2358

2359
            # Extract keff and its uncertainty
2360
            with openmc.StatePoint(sp_filepath) as sp:
11✔
2361
                keff = sp.keff
11✔
2362

2363
            if output:
11✔
2364
                nonlocal count
2365
                count += 1
11✔
2366
                print(f'Iteration {count}: {batches=}, {x=:.6g}, {keff=:.5f}')
11✔
2367

2368
            xs.append(float(x))
11✔
2369
            fs.append(float(keff.n - target))
11✔
2370
            ss.append(float(keff.s))
11✔
2371
            gs.append(int(batches))
11✔
2372
            return fs[-1], ss[-1]
11✔
2373

2374
        # Default b0 to current model settings if not explicitly provided
2375
        if b0 is None:
11✔
2376
            b0 = self.settings.batches - self.settings.inactive
11✔
2377

2378
        # Perform the search (inlined GRsecant) in a temporary directory
2379
        with TemporaryDirectory() as tmpdir:
11✔
2380
            if not openmc.lib.is_initialized:
11✔
2381
                run_kwargs.setdefault('cwd', tmpdir)
11✔
2382

2383
            # ---- Seed with two evaluations
2384
            f0, s0 = eval_at(x0, b0)
11✔
2385
            if abs(f0) <= k_tol and s0 <= sigma_final:
11✔
UNCOV
2386
                return SearchResult(x0, xs, fs, ss, gs, True, "converged")
×
2387
            f1, s1 = eval_at(x1, b0)
11✔
2388
            if abs(f1) <= k_tol and s1 <= sigma_final:
11✔
UNCOV
2389
                return SearchResult(x1, xs, fs, ss, gs, True, "converged")
×
2390

2391
            for _ in range(maxiter - 2):
11✔
2392
                # ------ Step 1: propose next x via GRsecant
2393
                m = min(memory, len(xs))
11✔
2394

2395
                # Perform a curve fit on f(x) = a + bx accounting for
2396
                # uncertainties. This is equivalent to minimizing the function
2397
                # in Equation (A.14)
2398
                (a, b), _ = curve_fit(
11✔
2399
                    lambda x, a, b: a + b*x,
2400
                    xs[-m:], fs[-m:], sigma=ss[-m:], absolute_sigma=True
2401
                )
2402
                x_new = float(-a / b)
11✔
2403

2404
                # Clamp x_new to the bounds if provided
2405
                if x_min is not None:
11✔
UNCOV
2406
                    x_new = max(x_new, x_min)
×
2407
                if x_max is not None:
11✔
UNCOV
2408
                    x_new = min(x_new, x_max)
×
2409

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

2412
                min_abs_f = float(np.min(np.abs(fs)))
11✔
2413
                base = q * sigma_final
11✔
2414
                ratio = min_abs_f / k_tol if k_tol > 0 else 1.0
11✔
2415
                sig = base * (ratio ** p)
11✔
2416
                sig_target = max(sig, base)
11✔
2417

2418
                # ------ Step 3: choose generations to hit σ_target (Appendix C)
2419

2420
                # Use at least two past points for regression
2421
                if len(gs) >= 2 and np.var(np.log(gs)) > 0.0:
11✔
2422
                    # Perform a curve fit based on Eq. (C.3) to solve for ln(k).
2423
                    # Note that unlike in the paper, we do not leave r as an
2424
                    # undetermined parameter and choose r=0.5.
2425
                    (ln_k,), _ = curve_fit(
11✔
2426
                        lambda ln_b, ln_k: ln_k - 0.5*ln_b,
2427
                        np.log(gs[-4:]), np.log(ss[-4:]),
2428
                    )
2429
                    k = float(np.exp(ln_k))
11✔
2430
                else:
2431
                    k = float(ss[-1] * math.sqrt(gs[-1]))
11✔
2432

2433
                b_new = (k / sig_target) ** 2
11✔
2434

2435
                # Clamp and round up to integer
2436
                b_new = max(b_min, math.ceil(b_new))
11✔
2437
                if b_max is not None:
11✔
UNCOV
2438
                    b_new = min(b_new, b_max)
×
2439

2440
                # Evaluate at proposed x with batches determined above
2441
                f_new, s_new = eval_at(x_new, b_new)
11✔
2442

2443
                # Termination based on both criteria (|f| and σ)
2444
                if abs(f_new) <= k_tol and s_new <= sigma_final:
11✔
2445
                    return SearchResult(x_new, xs, fs, ss, gs, True, "converged")
11✔
2446

UNCOV
2447
            return SearchResult(xs[-1], xs, fs, ss, gs, False, "maxiter")
×
2448

2449

2450
@dataclass
11✔
2451
class SearchResult:
11✔
2452
    """Result of a GRsecant keff search.
2453

2454
    Attributes
2455
    ----------
2456
    root : float
2457
        Estimated parameter value where f(x) = 0 at termination.
2458
    parameters : list[float]
2459
        Parameter values (x) evaluated during the search, in order.
2460
    keffs : list[float]
2461
        Estimated keff values for each evaluation.
2462
    stdevs : list[float]
2463
        One-sigma uncertainties of keff for each evaluation.
2464
    batches : list[int]
2465
        Number of active batches used for each evaluation.
2466
    converged : bool
2467
        Whether both |f| <= k_tol and sigma <= sigma_final were met.
2468
    flag : str
2469
        Reason for termination (e.g., "converged", "maxiter").
2470
    """
2471
    root: float
11✔
2472
    parameters: list[float] = field(repr=False)
11✔
2473
    means: list[float] = field(repr=False)
11✔
2474
    stdevs: list[float] = field(repr=False)
11✔
2475
    batches: list[int] = field(repr=False)
11✔
2476
    converged: bool
11✔
2477
    flag: str
11✔
2478

2479
    @property
11✔
2480
    def function_calls(self) -> int:
11✔
2481
        """Number of function evaluations performed."""
2482
        return len(self.parameters)
11✔
2483

2484
    @property
11✔
2485
    def total_batches(self) -> int:
11✔
2486
        """Total number of active batches used across all evaluations."""
2487
        return sum(self.batches)
11✔
2488

2489

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