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

openmc-dev / openmc / 21979737004

13 Feb 2026 08:19AM UTC coverage: 81.451% (-0.4%) from 81.851%
21979737004

Pull #3801

github

web-flow
Merge a4595d510 into bcb939520
Pull Request #3801: avoid need to set particles and batches for dagmc models when calling convert_to_multigroup

16960 of 23616 branches covered (71.82%)

Branch coverage included in aggregate %.

0 of 1 new or added line in 1 file covered. (0.0%)

908 existing lines in 38 files now uncovered.

55950 of 65898 relevant lines covered (84.9%)

36199990.53 hits per line

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

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

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

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

29

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

35

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

236
        return materials
7✔
237

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

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

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

326
        model = cls()
7✔
327

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

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

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

343
        return model
7✔
344

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

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

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

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

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

431
        .. versionadded:: 0.15.1
432

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

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

449
        .. versionadded:: 0.13.0
450

451
        """
452

453
        import openmc.lib
7✔
454

455
        openmc.lib.finalize()
7✔
456

457
    def deplete(
7✔
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:
7✔
500
            op_kwargs = {}
×
501
        elif isinstance(operator_kwargs, dict):
7✔
502
            op_kwargs = operator_kwargs
7✔
503
        else:
504
            raise ValueError("operator_kwargs must be a dict or None")
×
505

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

597
        self._link_geometry_to_filters()
7✔
598

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

603
        .. versionadded:: 0.13.3
604

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

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

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

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

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

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

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

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

678
        self._link_geometry_to_filters()
7✔
679

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

835
        """
836

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

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

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

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

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

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

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

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

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

900
        return last_statepoint
7✔
901

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

916
        .. versionadded:: 0.13.0
917

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

948
        """
949

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

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

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

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

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

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

999

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

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

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

1032
        return origin, width, pixels
7✔
1033

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

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

1049
        .. versionadded:: 0.15.3
1050

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

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

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

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

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

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

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

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

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

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

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

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

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

1163
        # Determine whether any materials contains macroscopic data and if so,
1164
        # set energy mode accordingly and check that mg cross sections path is accessible
1165
        for mat in self.geometry.get_all_materials().values():
7✔
1166
            if mat._macroscopic is not None:
7✔
1167
                self.settings.energy_mode = 'multi-group'
7✔
1168
                if 'mg_cross_sections' not in openmc.config:
7✔
1169
                    raise RuntimeError("'mg_cross_sections' path must be set in "
7✔
1170
                                       "openmc.config before plotting.")
1171
                break
7✔
1172

1173
        # Get ID map from the C API
1174
        id_map = self.id_map(
7✔
1175
            origin=origin,
1176
            width=width,
1177
            pixels=pixels,
1178
            basis=basis,
1179
            color_overlaps=show_overlaps
1180
        )
1181

1182
        # Generate colors if not provided
1183
        if colors is None and seed is not None:
7✔
1184
            # Use the colorize method to generate random colors
1185
            plot = openmc.SlicePlot()
×
1186
            plot.color_by = color_by
×
1187
            plot.colorize(self.geometry, seed=seed)
×
1188
            colors = plot.colors
×
1189

1190
        # Convert ID map to RGB image
1191
        img = id_map_to_rgb(
7✔
1192
            id_map=id_map,
1193
            color_by=color_by,
1194
            colors=colors,
1195
            overlap_color=overlap_color
1196
        )
1197

1198
        # Create a figure sized such that the size of the axes within
1199
        # exactly matches the number of pixels specified
1200
        if axes is None:
7✔
1201
            px = 1/plt.rcParams['figure.dpi']
7✔
1202
            fig, axes = plt.subplots()
7✔
1203
            axes.set_xlabel(xlabel)
7✔
1204
            axes.set_ylabel(ylabel)
7✔
1205
            params = fig.subplotpars
7✔
1206
            width_px = pixels[0]*px/(params.right - params.left)
7✔
1207
            height_px = pixels[1]*px/(params.top - params.bottom)
7✔
1208
            fig.set_size_inches(width_px, height_px)
7✔
1209

1210
        if outline:
7✔
1211
            # Combine R, G, B values into a single int for contour detection
1212
            rgb = (img * 256).astype(int)
7✔
1213
            image_value = (rgb[..., 0] << 16) + \
7✔
1214
                (rgb[..., 1] << 8) + (rgb[..., 2])
1215

1216
            # Set default arguments for contour()
1217
            if contour_kwargs is None:
7✔
1218
                contour_kwargs = {}
7✔
1219
            contour_kwargs.setdefault('colors', 'k')
7✔
1220
            contour_kwargs.setdefault('linestyles', 'solid')
7✔
1221
            contour_kwargs.setdefault('algorithm', 'serial')
7✔
1222

1223
            axes.contour(
7✔
1224
                image_value,
1225
                origin="upper",
1226
                levels=np.unique(image_value),
1227
                extent=(x_min, x_max, y_min, y_max),
1228
                **contour_kwargs
1229
            )
1230

1231
            # If only showing outline, set the axis limits and aspect explicitly
1232
            if outline == 'only':
7✔
1233
                axes.set_xlim(x_min, x_max)
×
1234
                axes.set_ylim(y_min, y_max)
×
1235
                axes.set_aspect('equal')
×
1236

1237
        # Add legend showing which colors represent which material or cell
1238
        if legend:
7✔
1239
            if colors is None or len(colors) == 0:
7✔
1240
                raise ValueError("Must pass 'colors' dictionary if you "
7✔
1241
                                 "are adding a legend via legend=True.")
1242

1243
            if color_by == "cell":
7✔
1244
                expected_key_type = openmc.Cell
×
1245
            else:
1246
                expected_key_type = openmc.Material
7✔
1247

1248
            patches = []
7✔
1249
            for key, color in colors.items():
7✔
1250
                if isinstance(key, int):
7✔
1251
                    raise TypeError(
×
1252
                        "Cannot use IDs in colors dict for auto legend.")
1253
                elif not isinstance(key, expected_key_type):
7✔
1254
                    raise TypeError(
×
1255
                        "Color dict key type does not match color_by")
1256

1257
                # this works whether we're doing cells or materials
1258
                label = key.name if key.name != '' else key.id
7✔
1259

1260
                # matplotlib takes RGB on 0-1 scale rather than 0-255
1261
                if len(color) == 3 and not isinstance(color, str):
7✔
1262
                    scaled_color = (
7✔
1263
                        color[0]/255, color[1]/255, color[2]/255)
1264
                else:
1265
                    scaled_color = color
7✔
1266

1267
                key_patch = mpatches.Patch(color=scaled_color, label=label)
7✔
1268
                patches.append(key_patch)
7✔
1269

1270
            axes.legend(handles=patches, **legend_kwargs)
7✔
1271

1272
        # Plot image and return the axes
1273
        if outline != 'only':
7✔
1274
            axes.imshow(img, extent=(x_min, x_max, y_min, y_max), **kwargs)
7✔
1275

1276
        if n_samples:
7✔
1277
            # Sample external source particles
1278
            particles = self.sample_external_source(n_samples)
7✔
1279

1280
            # Get points within tolerance of the slice plane
1281
            slice_value = origin[z]
7✔
1282
            xs = []
7✔
1283
            ys = []
7✔
1284
            tol = plane_tolerance
7✔
1285
            for particle in particles:
7✔
1286
                if (slice_value - tol < particle.r[z] < slice_value + tol):
7✔
1287
                    xs.append(particle.r[x] * axis_scaling_factor[axis_units])
7✔
1288
                    ys.append(particle.r[y] * axis_scaling_factor[axis_units])
7✔
1289
            axes.scatter(xs, ys, **source_kwargs)
7✔
1290

1291
        return axes
7✔
1292

1293
    def sample_external_source(
7✔
1294
        self,
1295
        n_samples: int = 1000,
1296
        prn_seed: int | None = None,
1297
        **init_kwargs
1298
    ) -> openmc.ParticleList:
1299
        """Sample external source and return source particles.
1300

1301
        .. versionadded:: 0.15.1
1302

1303
        Parameters
1304
        ----------
1305
        n_samples : int
1306
            Number of samples
1307
        prn_seed : int
1308
            Pseudorandom number generator (PRNG) seed; if None, one will be
1309
            generated randomly.
1310
        **init_kwargs
1311
            Keyword arguments passed to :func:`openmc.lib.init`
1312

1313
        Returns
1314
        -------
1315
        openmc.ParticleList
1316
            List of samples source particles
1317
        """
1318
        import openmc.lib
7✔
1319

1320
        # Silence output by default. Also set arguments to start in volume
1321
        # calculation mode to avoid loading cross sections
1322
        init_kwargs.setdefault('output', False)
7✔
1323
        init_kwargs.setdefault('args', ['-c'])
7✔
1324

1325
        with openmc.lib.TemporarySession(self, **init_kwargs):
7✔
1326
            return openmc.lib.sample_external_source(
7✔
1327
                n_samples=n_samples, prn_seed=prn_seed
1328
            )
1329

1330
    def apply_tally_results(self, statepoint: PathLike | openmc.StatePoint):
7✔
1331
        """Apply results from a statepoint to tally objects on the Model
1332

1333
        Parameters
1334
        ----------
1335
        statepoint : PathLike or openmc.StatePoint
1336
            Statepoint file used to update tally results
1337
        """
1338
        self.tallies.add_results(statepoint)
7✔
1339

1340
    def plot_geometry(
7✔
1341
        self,
1342
        output: bool = True,
1343
        cwd: PathLike = ".",
1344
        openmc_exec: PathLike = "openmc",
1345
        export_model_xml: bool = True,
1346
        **export_kwargs,
1347
    ):
1348
        """Creates plot images as specified by the Model.plots attribute
1349

1350
        .. versionadded:: 0.13.0
1351

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

1369
        """
1370

1371
        if len(self.plots) == 0:
7✔
1372
            # Then there is no volume calculation specified
1373
            raise ValueError("The Model.plots attribute must be specified "
×
1374
                             "before executing this method!")
1375

1376
        with change_directory(cwd):
7✔
1377
            if self.is_initialized:
7✔
1378
                # Compute the volumes
1379
                openmc.lib.plot_geometry(output)
7✔
1380
            else:
1381
                if export_model_xml:
7✔
1382
                    self.export_to_model_xml(**export_kwargs)
7✔
1383
                else:
1384
                    self.export_to_xml(**export_kwargs)
×
1385
                path_input = export_kwargs.get("path", None)
7✔
1386
                openmc.plot_geometry(output=output, openmc_exec=openmc_exec,
7✔
1387
                                     path_input=path_input)
1388

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

1417
        # And some items just dont make sense
1418
        if obj_type == 'cell' and attrib_name == 'density':
7✔
1419
            raise ValueError('Cannot set a Cell density!')
×
1420
        if obj_type == 'material' and attrib_name in ('rotation',
7✔
1421
                                                      'translation'):
1422
            raise ValueError('Cannot set a material rotation/translation!')
×
1423

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

1458
        # Now perform the change to both python and C API
1459
        for id_ in ids:
7✔
1460
            obj = by_id[id_]
7✔
1461
            if attrib_name == 'density':
7✔
1462
                obj.set_density(density_units, value)
7✔
1463
            else:
1464
                setattr(obj, attrib_name, value)
7✔
1465
            # Next lets keep what is in C API memory up to date as well
1466
            if self.is_initialized:
7✔
1467
                lib_obj = obj_by_id[id_]
7✔
1468
                if attrib_name == 'density':
7✔
1469
                    lib_obj.set_density(value, density_units)
7✔
1470
                elif attrib_name == 'temperature':
7✔
1471
                    lib_obj.set_temperature(value)
7✔
1472
                else:
1473
                    setattr(lib_obj, attrib_name, value)
7✔
1474

1475
    def rotate_cells(
7✔
1476
        self, names_or_ids: Iterable[str] | Iterable[int], vector: Iterable[float]
1477
    ):
1478
        """Rotate the identified cell(s) by the specified rotation vector.
1479
        The rotation is only applied to cells filled with a universe.
1480

1481
        .. note:: If applying this change to a name that is not unique, then
1482
                  the change will be applied to all objects of that name.
1483

1484
        .. versionadded:: 0.13.0
1485

1486
        Parameters
1487
        ----------
1488
        names_or_ids : Iterable of str or int
1489
            The cell names (if str) or id (if int) that are to be translated
1490
            or rotated. This parameter can include a mix of names and ids.
1491
        vector : Iterable of float
1492
            The rotation vector of length 3 to apply. This array specifies the
1493
            angles in degrees about the x, y, and z axes, respectively.
1494

1495
        """
1496

1497
        self._change_py_lib_attribs(names_or_ids, vector, 'cell', 'rotation')
7✔
1498

1499
    def translate_cells(
7✔
1500
        self, names_or_ids: Iterable[str] | Iterable[int], vector: Iterable[float]
1501
    ):
1502
        """Translate the identified cell(s) by the specified translation vector.
1503
        The translation is only applied to cells filled with a universe.
1504

1505
        .. note:: If applying this change to a name that is not unique, then
1506
                  the change will be applied to all objects of that name.
1507

1508
        .. versionadded:: 0.13.0
1509

1510
        Parameters
1511
        ----------
1512
        names_or_ids : Iterable of str or int
1513
            The cell names (if str) or id (if int) that are to be translated
1514
            or rotated. This parameter can include a mix of names and ids.
1515
        vector : Iterable of float
1516
            The translation vector of length 3 to apply. This array specifies
1517
            the x, y, and z dimensions of the translation.
1518

1519
        """
1520

1521
        self._change_py_lib_attribs(names_or_ids, vector, 'cell',
7✔
1522
                                    'translation')
1523

1524
    def update_densities(
7✔
1525
        self,
1526
        names_or_ids: Iterable[str] | Iterable[int],
1527
        density: float,
1528
        density_units: str = "atom/b-cm",
1529
    ):
1530
        """Update the density of a given set of materials to a new value
1531

1532
        .. note:: If applying this change to a name that is not unique, then
1533
                  the change will be applied to all objects of that name.
1534

1535
        .. versionadded:: 0.13.0
1536

1537
        Parameters
1538
        ----------
1539
        names_or_ids : Iterable of str or int
1540
            The material names (if str) or id (if int) that are to be updated.
1541
            This parameter can include a mix of names and ids.
1542
        density : float
1543
            The density to apply in the units specified by `density_units`
1544
        density_units : {'atom/b-cm', 'g/cm3'}, optional
1545
            Units for `density`. Defaults to 'atom/b-cm'
1546

1547
        """
1548

1549
        self._change_py_lib_attribs(names_or_ids, density, 'material',
7✔
1550
                                    'density', density_units)
1551

1552
    def update_cell_temperatures(
7✔
1553
        self, names_or_ids: Iterable[str] | Iterable[int], temperature: float
1554
    ):
1555
        """Update the temperature of a set of cells to the given value
1556

1557
        .. note:: If applying this change to a name that is not unique, then
1558
                  the change will be applied to all objects of that name.
1559

1560
        .. versionadded:: 0.13.0
1561

1562
        Parameters
1563
        ----------
1564
        names_or_ids : Iterable of str or int
1565
            The cell names (if str) or id (if int) that are to be updated.
1566
            This parameter can include a mix of names and ids.
1567
        temperature : float
1568
            The temperature to apply in units of Kelvin
1569

1570
        """
1571

1572
        self._change_py_lib_attribs(names_or_ids, temperature, 'cell',
7✔
1573
                                    'temperature')
1574

1575
    def update_material_volumes(
7✔
1576
        self, names_or_ids: Iterable[str] | Iterable[int], volume: float
1577
    ):
1578
        """Update the volume of a set of materials to the given value
1579

1580
        .. note:: If applying this change to a name that is not unique, then
1581
                  the change will be applied to all objects of that name.
1582

1583
        .. versionadded:: 0.13.0
1584

1585
        Parameters
1586
        ----------
1587
        names_or_ids : Iterable of str or int
1588
            The material names (if str) or id (if int) that are to be updated.
1589
            This parameter can include a mix of names and ids.
1590
        volume : float
1591
            The volume to apply in units of cm^3
1592

1593
        """
1594

1595
        self._change_py_lib_attribs(names_or_ids, volume, 'material', 'volume')
7✔
1596

1597
    def differentiate_depletable_mats(self, diff_volume_method: str = None):
7✔
1598
        """Assign distribmats for each depletable material
1599

1600
        .. versionadded:: 0.14.0
1601

1602
        .. versionchanged:: 0.15.1
1603
            diff_volume_method default is None, do not set volumes on the new
1604
            material ovjects. Is now a convenience method for
1605
            differentiate_mats(diff_volume_method, depletable_only=True)
1606

1607
        Parameters
1608
        ----------
1609
        diff_volume_method : str
1610
            Specifies how the volumes of the new materials should be found.
1611
            - None: Do not assign volumes to the new materials (Default)
1612
            - 'divide equally': Divide the original material volume equally between the new materials
1613
            - 'match cell': Set the volume of the material to the volume of the cell they fill
1614
        """
1615
        self.differentiate_mats(diff_volume_method, depletable_only=True)
7✔
1616

1617
    def differentiate_mats(self, diff_volume_method: str = None, depletable_only: bool = True):
7✔
1618
        """Assign distribmats for each material
1619

1620
        .. versionadded:: 0.15.1
1621

1622
        Parameters
1623
        ----------
1624
        diff_volume_method : str
1625
            Specifies how the volumes of the new materials should be found.
1626
            - None: Do not assign volumes to the new materials (Default)
1627
            - 'divide equally': Divide the original material volume equally between the new materials
1628
            - 'match cell': Set the volume of the material to the volume of the cell they fill
1629
        depletable_only : bool
1630
            Default is True, only depletable materials will be differentiated. If False, all materials will be
1631
            differentiated.
1632
        """
1633
        check_value('volume differentiation method', diff_volume_method, ("divide equally", "match cell", None))
7✔
1634

1635
        # Count the number of instances for each cell and material
1636
        self.geometry.determine_paths(instances_only=True)
7✔
1637

1638
        # Get list of materials
1639
        if self.materials:
7✔
1640
            materials = self.materials
7✔
1641
        else:
1642
            materials = list(self.geometry.get_all_materials().values())
×
1643

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

1671
        if not distribmats:
7✔
1672
            return
×
1673

1674
        # Assign distribmats to cells
1675
        for cell in self.geometry.get_all_material_cells().values():
7✔
1676
            if cell.fill in distribmats:
7✔
1677
                mat = cell.fill
7✔
1678

1679
                # Clone materials
1680
                if cell.num_instances > 1:
7✔
1681
                    cell.fill = [mat.clone() for _ in range(cell.num_instances)]
7✔
1682
                else:
1683
                    cell.fill = mat.clone()
7✔
1684

1685
                # For 'match cell', assign volumes based on the cells
1686
                if diff_volume_method == 'match cell':
7✔
1687
                    if cell.fill_type == 'distribmat':
7✔
1688
                        for clone_mat in cell.fill:
×
1689
                            clone_mat.volume = cell.volume
×
1690
                    else:
1691
                        cell.fill.volume = cell.volume
7✔
1692

1693
        if self.materials is not None:
7✔
1694
            self.materials = openmc.Materials(
7✔
1695
                self.geometry.get_all_materials().values()
1696
            )
1697

1698
    @staticmethod
7✔
1699
    def _auto_generate_mgxs_lib(
7✔
1700
        model: openmc.model.model,
1701
        groups: openmc.mgxs.EnergyGroups,
1702
        correction: str | none,
1703
        directory: pathlike,
1704
    ) -> openmc.mgxs.Library:
1705
        """
1706
        Automatically generate a multi-group cross section libray from a model
1707
        with the specified group structure.
1708

1709
        Parameters
1710
        ----------
1711
        groups : openmc.mgxs.EnergyGroups
1712
            Energy group structure for the MGXS.
1713
        nparticles : int
1714
            Number of particles to simulate per batch when generating MGXS.
1715
        mgxs_path : str
1716
            Filename for the MGXS HDF5 file.
1717
        correction : str
1718
            Transport correction to apply to the MGXS. Options are None and
1719
            "P0".
1720
        directory : str
1721
            Directory to run the simulation in, so as to contain XML files.
1722

1723
        Returns
1724
        -------
1725
        mgxs_lib : openmc.mgxs.Library
1726
            OpenMC MGXS Library object
1727
        """
1728

1729
        # Initialize MGXS library with a finished OpenMC geometry object
1730
        mgxs_lib = openmc.mgxs.Library(model.geometry)
7✔
1731

1732
        # Pick energy group structure
1733
        mgxs_lib.energy_groups = groups
7✔
1734

1735
        # Disable transport correction
1736
        mgxs_lib.correction = correction
7✔
1737

1738
        # Specify needed cross sections for random ray
1739
        if correction == 'P0':
7✔
1740
            mgxs_lib.mgxs_types = [
7✔
1741
                'nu-transport', 'absorption', 'nu-fission', 'fission',
1742
                'consistent nu-scatter matrix', 'multiplicity matrix', 'chi',
1743
                'kappa-fission'
1744
            ]
1745
        elif correction is None:
7✔
1746
            mgxs_lib.mgxs_types = [
7✔
1747
                'total', 'absorption', 'nu-fission', 'fission',
1748
                'consistent nu-scatter matrix', 'multiplicity matrix', 'chi',
1749
                'kappa-fission'
1750
            ]
1751

1752
        # Specify a "material" domain type for the cross section tally filters
1753
        mgxs_lib.domain_type = "material"
7✔
1754

1755
        # Specify the domains over which to compute multi-group cross sections
1756
        mgxs_lib.domains = model.geometry.get_all_materials().values()
7✔
1757

1758
        # Do not compute cross sections on a nuclide-by-nuclide basis
1759
        mgxs_lib.by_nuclide = False
7✔
1760

1761
        # Check the library - if no errors are raised, then the library is satisfactory.
1762
        mgxs_lib.check_library_for_openmc_mgxs()
7✔
1763

1764
        # Construct all tallies needed for the multi-group cross section library
1765
        mgxs_lib.build_library()
7✔
1766

1767
        # Create a "tallies.xml" file for the MGXS Library
1768
        mgxs_lib.add_to_tallies(model.tallies, merge=True)
7✔
1769

1770
        # Run
1771
        statepoint_filename = model.run(cwd=directory)
7✔
1772

1773
        # Load MGXS
1774
        with openmc.StatePoint(statepoint_filename) as sp:
7✔
1775
            mgxs_lib.load_from_statepoint(sp)
7✔
1776

1777
        return mgxs_lib
7✔
1778

1779
    def _create_mgxs_sources(
7✔
1780
        self,
1781
        groups: openmc.mgxs.EnergyGroups,
1782
        spatial_dist: openmc.stats.Spatial,
1783
        source_energy: openmc.stats.Univariate | None = None,
1784
    ) -> list[openmc.IndependentSource]:
1785
        """Create a list of independent sources to use with MGXS generation.
1786

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

1801
        Parameters
1802
        ----------
1803
        groups : openmc.mgxs.EnergyGroups
1804
            Energy group structure for the MGXS.
1805
        spatial_dist : openmc.stats.Spatial
1806
            Spatial distribution to use for all sources.
1807
        source_energy : openmc.stats.Univariate, optional
1808
            Energy distribution to use when generating MGXS data, replacing any
1809
            existing sources in the model.
1810

1811
        Returns
1812
        -------
1813
        list[openmc.IndependentSource]
1814
            A list of independent sources to use for MGXS generation.
1815
        """
1816
        # Make a discrete source that is uniform over the bins of the group structure
1817
        midpoints = []
7✔
1818
        strengths = []
7✔
1819
        for i in range(groups.num_groups):
7✔
1820
            bounds = groups.get_group_bounds(i+1)
7✔
1821
            midpoints.append((bounds[0] + bounds[1]) / 2.0)
7✔
1822
            strengths.append(1.0)
7✔
1823

1824
        uniform_energy = openmc.stats.Discrete(x=midpoints, p=strengths)
7✔
1825
        uniform_distribution = openmc.IndependentSource(spatial_dist, energy=uniform_energy, strength=0.01)
7✔
1826
        sources = [uniform_distribution]
7✔
1827

1828
        # If the user provided an energy distribution, use that
1829
        if source_energy is not None:
7✔
1830
            user_energy = openmc.IndependentSource(
7✔
1831
                space=spatial_dist, energy=source_energy, strength=0.99)
1832
            sources.append(user_energy)
7✔
1833

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

1862
        return sources
7✔
1863

1864
    @staticmethod
7✔
1865
    def _isothermal_infinite_media_mgxs(
7✔
1866
        material: openmc.Material,
1867
        groups: openmc.mgxs.EnergyGroups,
1868
        nparticles: int,
1869
        correction: str | None,
1870
        directory: PathLike,
1871
        source: openmc.IndependentSource,
1872
        temperature_settings: dict,
1873
        temperature: float | None = None,
1874
    ) -> openmc.XSdata:
1875
        """Generate a single MGXS set for one material, where the geometry is an
1876
        infinite medium composed of that material at an isothermal temperature value.
1877

1878
        Parameters
1879
        ----------
1880
        material : openmc.Material
1881
            The material to generate MGXS for
1882
        groups : openmc.mgxs.EnergyGroups
1883
            Energy group structure for the MGXS.
1884
        nparticles : int
1885
            Number of particles to simulate per batch when generating MGXS.
1886
        correction : str
1887
            Transport correction to apply to the MGXS. Options are None and
1888
            "P0".
1889
        directory : str
1890
            Directory to run the simulation in, so as to contain XML files.
1891
        source : openmc.IndependentSource
1892
            Source to use when generating MGXS.
1893
        temperature_settings : dict
1894
            A dictionary of temperature settings to use when generating MGXS.
1895
            Valid entries for temperature_settings are the same as the valid
1896
            entries in openmc.Settings.temperature_settings.
1897
        temperature : float, optional
1898
            The isothermal temperature value to apply to the material. If not specified,
1899
            defaults to the temperature in the material.
1900

1901
        Returns
1902
        -------
1903
        data : openmc.XSdata
1904
            The material MGXS for the given temperature isotherm.
1905
        """
1906
        model = openmc.Model()
7✔
1907

1908
        # Set materials on the model
1909
        model.materials = [material]
7✔
1910
        if temperature != None:
7✔
1911
          model.materials[-1].temperature = temperature
7✔
1912

1913
        # Settings
1914
        model.settings.batches = 100
7✔
1915
        model.settings.particles = nparticles
7✔
1916

1917
        model.settings.source = source
7✔
1918

1919
        model.settings.run_mode = 'fixed source'
7✔
1920
        model.settings.create_fission_neutrons = False
7✔
1921

1922
        model.settings.output = {'summary': True, 'tallies': False}
7✔
1923
        model.settings.temperature = temperature_settings
7✔
1924

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

1933
        # Generate MGXS
1934
        mgxs_lib = Model._auto_generate_mgxs_lib(
7✔
1935
                model, groups, correction, directory)
1936

1937
        if temperature != None:
7✔
1938
            return mgxs_lib.get_xsdata(domain=material, xsdata_name=name,
7✔
1939
                                       temperature=temperature)
1940
        else:
1941
            return mgxs_lib.get_xsdata(domain=material, xsdata_name=name)
7✔
1942

1943
    def _generate_infinite_medium_mgxs(
7✔
1944
        self,
1945
        groups: openmc.mgxs.EnergyGroups,
1946
        nparticles: int,
1947
        mgxs_path: PathLike,
1948
        correction: str | None,
1949
        directory: PathLike,
1950
        source_energy: openmc.stats.Univariate | None = None,
1951
        temperatures: Sequence[float] | None = None,
1952
        temperature_settings: dict | None = None,
1953
    ) -> None:
1954
        """Generate a MGXS library by running multiple OpenMC simulations, each
1955
        representing an infinite medium simulation of a single isolated
1956
        material. A discrete source is used to sample particles, with an equal
1957
        strength spread across each of the energy groups. This is a highly naive
1958
        method that ignores all spatial self shielding effects and all resonance
1959
        shielding effects between materials. If temperature data points are provided,
1960
        isothermal cross sections are generated at each temperature point for
1961
        each material to build a temperature interpolation table.
1962

1963
        Note that in all cases, a discrete source that is uniform over all
1964
        energy groups is created (strength = 0.01) to ensure that total cross
1965
        sections are generated for all energy groups. In the case that the user
1966
        has provided a source_energy distribution as an argument, an additional
1967
        source (strength = 0.99) is created using that energy distribution. If
1968
        the user has not provided a source_energy distribution, but the model
1969
        has sources defined, and all of those sources are of IndependentSource
1970
        type, then additional sources are created based on the model's existing
1971
        sources, keeping their energy distributions but replacing their
1972
        spatial/angular distributions, with their combined strength being 0.99.
1973
        If the user has not provided a source_energy distribution and no sources
1974
        are defined on the model and the run mode is 'eigenvalue', then a
1975
        default Watt spectrum source (strength = 0.99) is added.
1976

1977
        Parameters
1978
        ----------
1979
        groups : openmc.mgxs.EnergyGroups
1980
            Energy group structure for the MGXS.
1981
        nparticles : int
1982
            Number of particles to simulate per batch when generating MGXS.
1983
        mgxs_path : str
1984
            Filename for the MGXS HDF5 file.
1985
        correction : str
1986
            Transport correction to apply to the MGXS. Options are None and
1987
            "P0".
1988
        directory : str
1989
            Directory to run the simulation in, so as to contain XML files.
1990
        source_energy : openmc.stats.Univariate, optional
1991
            Energy distribution to use when generating MGXS data, replacing any
1992
            existing sources in the model.
1993
        temperatures : Sequence[float], optional
1994
            A list of temperatures to generate MGXS at. Each infinite material region
1995
            is isothermal at a given temperature data point for cross
1996
            section generation.
1997
        temperature_settings : dict, optional
1998
            A dictionary of temperature settings to use when generating MGXS.
1999
            Valid entries for temperature_settings are the same as the valid
2000
            entries in openmc.Settings.temperature_settings.
2001
        """
2002

2003
        src = self._create_mgxs_sources(
7✔
2004
            groups,
2005
            spatial_dist=openmc.stats.Point(),
2006
            source_energy=source_energy
2007
        )
2008

2009
        temp_settings = {}
7✔
2010
        if temperature_settings == None:
7✔
2011
            temp_settings = self.settings.temperature
7✔
2012
        else:
2013
            temp_settings = temperature_settings
7✔
2014

2015
        if temperatures == None:
7✔
2016
            mgxs_sets = []
7✔
2017
            for material in self.materials:
7✔
2018
                xs_data = Model._isothermal_infinite_media_mgxs(
7✔
2019
                    material,
2020
                    groups,
2021
                    nparticles,
2022
                    correction,
2023
                    directory,
2024
                    src,
2025
                    temp_settings
2026
                )
2027
                mgxs_sets.append(xs_data)
7✔
2028

2029
            # Write the file to disk.
2030
            mgxs_file = openmc.MGXSLibrary(energy_groups=groups)
7✔
2031
            for mgxs_set in mgxs_sets:
7✔
2032
                mgxs_file.add_xsdata(mgxs_set)
7✔
2033
            mgxs_file.export_to_hdf5(mgxs_path)
7✔
2034
        else:
2035
            # Build a series of XSData objects, one for each isothermal temperature value.
2036
            raw_mgxs_sets = {}
7✔
2037
            for temperature in temperatures:
7✔
2038
                raw_mgxs_sets[temperature] = []
7✔
2039
                for material in self.materials:
7✔
2040
                    xs_data = Model._isothermal_infinite_media_mgxs(
7✔
2041
                        material,
2042
                        groups,
2043
                        nparticles,
2044
                        correction,
2045
                        directory,
2046
                        src,
2047
                        temp_settings,
2048
                        temperature
2049
                    )
2050
                    raw_mgxs_sets[temperature].append(xs_data)
7✔
2051

2052
            # Unpack the isothermal XSData objects and build a single XSData object per material.
2053
            mgxs_sets = []
7✔
2054
            for m in range(len(self.materials)):
7✔
2055
                mgxs_sets.append(openmc.XSdata(self.materials[m].name, groups))
7✔
2056
                mgxs_sets[-1].order = 0
7✔
2057
                for temperature in temperatures:
7✔
2058
                    mgxs_sets[-1].add_temperature_data(raw_mgxs_sets[temperature][m])
7✔
2059

2060
            # Write the file to disk.
2061
            mgxs_file = openmc.MGXSLibrary(energy_groups=groups)
7✔
2062
            for mgxs_set in mgxs_sets:
7✔
2063
                mgxs_file.add_xsdata(mgxs_set)
7✔
2064
            mgxs_file.export_to_hdf5(mgxs_path)
7✔
2065

2066
    @staticmethod
7✔
2067
    def _create_stochastic_slab_geometry(
7✔
2068
        materials: Sequence[openmc.Material],
2069
        cell_thickness: float = 1.0,
2070
        num_repeats: int = 100,
2071
    ) -> tuple[openmc.Geometry, openmc.stats.Box]:
2072
        """Create a geometry representing a stochastic "sandwich" of materials in a
2073
        layered slab geometry. To reduce the impact of the order of materials in
2074
        the slab, the materials are applied to 'num_repeats' different randomly
2075
        positioned layers of 'cell_thickness' each.
2076

2077
        Parameters
2078
        ----------
2079
        materials : list of openmc.Material
2080
            List of materials to assign. Each material will appear exactly num_repeats times,
2081
            then the ordering is randomly shuffled.
2082
        cell_thickness : float, optional
2083
            Thickness of each lattice cell in x (default 1.0 cm).
2084
        num_repeats : int, optional
2085
            Number of repeats for each material (default 100).
2086

2087
        Returns
2088
        -------
2089
        geometry : openmc.Geometry
2090
            The constructed geometry.
2091
        box : openmc.stats.Box
2092
            A spatial sampling distribution covering the full slab domain.
2093
        """
2094
        if not materials:
7✔
2095
            raise ValueError("At least one material must be provided.")
×
2096

2097
        num_materials = len(materials)
7✔
2098
        total_cells = num_materials * num_repeats
7✔
2099
        total_width = total_cells * cell_thickness
7✔
2100

2101
        # Generate an infinite cell/universe for each material
2102
        universes = []
7✔
2103
        for i in range(num_materials):
7✔
2104
            cell = openmc.Cell(fill=materials[i])
7✔
2105
            universes.append(openmc.Universe(cells=[cell]))
7✔
2106

2107
        # Make a list of randomized material idx assignments for the stochastic slab
2108
        assignments = list(range(num_materials)) * num_repeats
7✔
2109
        random.seed(42)
7✔
2110
        random.shuffle(assignments)
7✔
2111

2112
        # Create a list of the (randomized) universe assignments to be used
2113
        # when defining the problem lattice.
2114
        lattice_entries = [universes[m] for m in assignments]
7✔
2115

2116
        # Create the RectLattice for the 1D material variation in x.
2117
        lattice = openmc.RectLattice()
7✔
2118
        lattice.pitch = (cell_thickness, total_width, total_width)
7✔
2119
        lattice.lower_left = (0.0, 0.0, 0.0)
7✔
2120
        lattice.universes = [[lattice_entries]]
7✔
2121
        lattice.outer = universes[0]
7✔
2122

2123
        # Define the six outer surfaces with reflective boundary conditions
2124
        rpp = openmc.model.RectangularParallelepiped(
7✔
2125
            0.0, total_width, 0.0, total_width, 0.0, total_width,
2126
            boundary_type='reflective'
2127
        )
2128

2129
        # Create an outer cell that fills with the lattice.
2130
        outer_cell = openmc.Cell(fill=lattice, region=-rpp)
7✔
2131

2132
        # Build the geometry
2133
        geometry = openmc.Geometry([outer_cell])
7✔
2134

2135
        # Define the spatial distribution that covers the full cubic domain
2136
        box = openmc.stats.Box(*outer_cell.bounding_box)
7✔
2137

2138
        return geometry, box
7✔
2139

2140
    @staticmethod
7✔
2141
    def _isothermal_stochastic_slab_mgxs(
7✔
2142
        stoch_geom: openmc.Geometry,
2143
        groups: openmc.mgxs.EnergyGroups,
2144
        nparticles: int,
2145
        correction: str | None,
2146
        directory: PathLike,
2147
        source: openmc.IndependentSource,
2148
        temperature_settings: dict,
2149
        temperature: float | None = None,
2150
    ) -> dict[str, openmc.XSdata]:
2151
        """Generate MGXS assuming a stochastic "sandwich" of materials in a layered
2152
        slab geometry. If a temperature is specified, all materials in the slab have
2153
        their temperatures set to be isothermal at this temperature.
2154

2155
        Parameters
2156
        ----------
2157
        stoch_geom : openmc.Geometry
2158
            The stochastic slab geometry.
2159
        groups : openmc.mgxs.EnergyGroups
2160
            Energy group structure for the MGXS.
2161
        nparticles : int
2162
            Number of particles to simulate per batch when generating MGXS.
2163
        correction : str
2164
            Transport correction to apply to the MGXS. Options are None and
2165
            "P0".
2166
        directory : str
2167
            Directory to run the simulation in, so as to contain XML files.
2168
        source : openmc.IndependentSource
2169
            Source to use when generating MGXS.
2170
        temperature_settings : dict
2171
            A dictionary of temperature settings to use when generating MGXS.
2172
            Valid entries for temperature_settings are the same as the valid
2173
            entries in openmc.Settings.temperature_settings.
2174
        temperature : float, optional
2175
            The isothermal temperature value to apply to the materials in the
2176
            slab. If not specified, defaults to the temperature in the materials.
2177

2178
        Returns
2179
        -------
2180
        data : dict[str, openmc.XSdata]
2181
            A dictionary where the key is the name of the material and the value is the isothermal MGXS.
2182
        """
2183

2184
        model = openmc.Model()
7✔
2185
        model.geometry = stoch_geom
7✔
2186

2187
        if temperature != None:
7✔
2188
            for material in model.geometry.get_all_materials().values():
7✔
2189
                material.temperature = temperature
7✔
2190

2191
        # Settings
2192
        model.settings.batches = 200
7✔
2193
        model.settings.inactive = 100
7✔
2194
        model.settings.particles = nparticles
7✔
2195
        model.settings.output = {'summary': True, 'tallies': False}
7✔
2196
        model.settings.temperature = temperature_settings
7✔
2197

2198
        # Define the sources
2199
        model.settings.source = source
7✔
2200

2201
        model.settings.run_mode = 'fixed source'
7✔
2202
        model.settings.create_fission_neutrons = False
7✔
2203

2204
        model.settings.output = {'summary': True, 'tallies': False}
7✔
2205

2206
        # Generate MGXS
2207
        mgxs_lib = Model._auto_generate_mgxs_lib(
7✔
2208
                model, groups, correction, directory)
2209

2210
        # Fetch all of the isothermal results.
2211
        if temperature != None:
7✔
2212
            return {
7✔
2213
                mat.name : mgxs_lib.get_xsdata(domain=mat, xsdata_name=mat.name,
2214
                                               temperature=temperature)
2215
                    for mat in mgxs_lib.domains
2216
            }
2217
        else:
2218
            return {
7✔
2219
                mat.name : mgxs_lib.get_xsdata(domain=mat, xsdata_name=mat.name)
2220
                    for mat in mgxs_lib.domains
2221
            }
2222

2223
    def _generate_stochastic_slab_mgxs(
7✔
2224
        self,
2225
        groups: openmc.mgxs.EnergyGroups,
2226
        nparticles: int,
2227
        mgxs_path: PathLike,
2228
        correction: str | None,
2229
        directory: PathLike,
2230
        source_energy: openmc.stats.Univariate | None = None,
2231
        temperatures: Sequence[float] | None = None,
2232
        temperature_settings: dict | None = None,
2233
    ) -> None:
2234
        """Generate MGXS assuming a stochastic "sandwich" of materials in a layered
2235
        slab geometry. While geometry-specific spatial shielding effects are not
2236
        captured, this method can be useful when the geometry has materials only
2237
        found far from the source region that the "material_wise" method would
2238
        not be capable of generating cross sections for. Conversely, this method
2239
        will generate cross sections for all materials in the problem regardless
2240
        of type. If this is a fixed source problem, a discrete source is used to
2241
        sample particles, with an equal strength spread across each of the
2242
        energy groups. If temperature data points are provided,
2243
        isothermal cross sections are generated at each temperature point for
2244
        the stochastic slab to build a temperature interpolation table.
2245

2246
        Parameters
2247
        ----------
2248
        groups : openmc.mgxs.EnergyGroups
2249
            Energy group structure for the MGXS.
2250
        nparticles : int
2251
            Number of particles to simulate per batch when generating MGXS.
2252
        mgxs_path : str
2253
            Filename for the MGXS HDF5 file.
2254
        correction : str
2255
            Transport correction to apply to the MGXS. Options are None and
2256
            "P0".
2257
        directory : str
2258
            Directory to run the simulation in, so as to contain XML files.
2259
        source_energy : openmc.stats.Univariate, optional
2260
            Energy distribution to use when generating MGXS data, replacing any
2261
            existing sources in the model. In all cases, a discrete source that
2262
            is uniform over all energy groups is created (strength = 0.01) to
2263
            ensure that total cross sections are generated for all energy
2264
            groups. In the case that the user has provided a source_energy
2265
            distribution as an argument, an additional source (strength = 0.99)
2266
            is created using that energy distribution. If the user has not
2267
            provided a source_energy distribution, but the model has sources
2268
            defined, and all of those sources are of IndependentSource type,
2269
            then additional sources are created based on the model's existing
2270
            sources, keeping their energy distributions but replacing their
2271
            spatial/angular distributions, with their combined strength being
2272
            0.99. If the user has not provided a source_energy distribution and
2273
            no sources are defined on the model and the run mode is
2274
            'eigenvalue', then a default Watt spectrum source (strength = 0.99)
2275
            is added.
2276
        temperatures : Sequence[float], optional
2277
            A list of temperatures to generate MGXS at. Each infinite material region
2278
            is isothermal at a given temperature data point for cross
2279
            section generation.
2280
        temperature_settings : dict, optional
2281
            A dictionary of temperature settings to use when generating MGXS.
2282
            Valid entries for temperature_settings are the same as the valid
2283
            entries in openmc.Settings.temperature_settings.
2284
        """
2285

2286
        # Stochastic slab geometry
2287
        geo, spatial_distribution = Model._create_stochastic_slab_geometry(
7✔
2288
            self.materials)
2289

2290
        src = self._create_mgxs_sources(
7✔
2291
            groups,
2292
            spatial_dist=spatial_distribution,
2293
            source_energy=source_energy
2294
        )
2295

2296
        temp_settings = {}
7✔
2297
        if temperature_settings == None:
7✔
2298
            temp_settings = self.settings.temperature
7✔
2299
        else:
2300
            temp_settings = temperature_settings
7✔
2301

2302
        if temperatures == None:
7✔
2303
            mgxs_sets = Model._isothermal_stochastic_slab_mgxs(
7✔
2304
                geo,
2305
                groups,
2306
                nparticles,
2307
                correction,
2308
                directory,
2309
                src,
2310
                temp_settings
2311
            ).values()
2312

2313
            # Write the file to disk.
2314
            mgxs_file = openmc.MGXSLibrary(energy_groups=groups)
7✔
2315
            for mgxs_set in mgxs_sets:
7✔
2316
                mgxs_file.add_xsdata(mgxs_set)
7✔
2317
            mgxs_file.export_to_hdf5(mgxs_path)
7✔
2318
        else:
2319
            # Build a series of XSData objects, one for each isothermal temperature value.
2320
            raw_mgxs_sets = {}
7✔
2321
            for temperature in temperatures:
7✔
2322
                raw_mgxs_sets[temperature] = Model._isothermal_stochastic_slab_mgxs(
7✔
2323
                    geo,
2324
                    groups,
2325
                    nparticles,
2326
                    correction,
2327
                    directory,
2328
                    src,
2329
                    temp_settings,
2330
                    temperature
2331
                )
2332

2333
            # Unpack the isothermal XSData objects and build a single XSData object per material.
2334
            mgxs_sets = []
7✔
2335
            for mat in self.materials:
7✔
2336
                mgxs_sets.append(openmc.XSdata(mat.name, groups))
7✔
2337
                mgxs_sets[-1].order = 0
7✔
2338
                for temperature in temperatures:
7✔
2339
                    mgxs_sets[-1].add_temperature_data(raw_mgxs_sets[temperature][mat.name])
7✔
2340

2341
            # Write the file to disk.
2342
            mgxs_file = openmc.MGXSLibrary(energy_groups=groups)
7✔
2343
            for mgxs_set in mgxs_sets:
7✔
2344
                mgxs_file.add_xsdata(mgxs_set)
7✔
2345
            mgxs_file.export_to_hdf5(mgxs_path)
7✔
2346

2347
    @staticmethod
7✔
2348
    def _isothermal_materialwise_mgxs(
7✔
2349
        input_model: openmc.Model,
2350
        groups: openmc.mgxs.EnergyGroups,
2351
        nparticles: int,
2352
        correction: str | None,
2353
        directory: PathLike,
2354
        temperature_settings: dict,
2355
        temperature: float | None = None,
2356
    ) -> dict[str, openmc.XSdata]:
2357
        """Generate a material-wise MGXS library for the model by running the
2358
        original continuous energy OpenMC simulation. If a temperature is
2359
        specified, each material in the input model is set to that temperature.
2360
        Otherwise, the original material temperatures are used. If temperature
2361
        data points are provided, isothermal cross sections are generated at
2362
        each temperature point for the whole model to build a temperature
2363
        interpolation table.
2364

2365
        Parameters
2366
        ----------
2367
        input_model : openmc.Model
2368
            The model to use when computing material-wise MGXS.
2369
        groups : openmc.mgxs.EnergyGroups
2370
            Energy group structure for the MGXS.
2371
        nparticles : int
2372
            Number of particles to simulate per batch when generating MGXS.
2373
        correction : str
2374
            Transport correction to apply to the MGXS. Options are None and
2375
            "P0".
2376
        directory : str
2377
            Directory to run the simulation in, so as to contain XML files.
2378
        temperature_settings : dict
2379
            A dictionary of temperature settings to use when generating MGXS.
2380
            Valid entries for temperature_settings are the same as the valid
2381
            entries in openmc.Settings.temperature_settings.
2382
        temperature : float, optional
2383
            The isothermal temperature value to apply to the materials in the
2384
            input model. If not specified, defaults to the temperatures in the
2385
            materials.
2386

2387
        Returns
2388
        -------
2389
        data : dict[str, openmc.XSdata]
2390
            A dictionary where the key is the name of the material and the value is the isothermal MGXS.
2391
        """
2392
        model = copy.deepcopy(input_model)
7✔
2393
        model.tallies = openmc.Tallies()
7✔
2394

2395
        if temperature != None:
7✔
2396
            for material in model.geometry.get_all_materials().values():
7✔
2397
                material.temperature = temperature
7✔
2398

2399
        # Settings
2400
        model.settings.batches = 200
7✔
2401
        model.settings.inactive = 100
7✔
2402
        model.settings.particles = nparticles
7✔
2403
        model.settings.output = {'summary': True, 'tallies': False}
7✔
2404
        model.settings.temperature = temperature_settings
7✔
2405

2406
        # Generate MGXS
2407
        mgxs_lib = Model._auto_generate_mgxs_lib(
7✔
2408
                model, groups, correction, directory)
2409

2410
        # Fetch all of the isothermal results.
2411
        if temperature != None:
7✔
2412
            return {
7✔
2413
                mat.name : mgxs_lib.get_xsdata(domain=mat, xsdata_name=mat.name,
2414
                                               temperature=temperature)
2415
                    for mat in mgxs_lib.domains
2416
            }
2417
        else:
2418
            return {
7✔
2419
                mat.name : mgxs_lib.get_xsdata(domain=mat, xsdata_name=mat.name)
2420
                    for mat in mgxs_lib.domains
2421
            }
2422

2423
    def _generate_material_wise_mgxs(
7✔
2424
        self,
2425
        groups: openmc.mgxs.EnergyGroups,
2426
        nparticles: int,
2427
        mgxs_path: PathLike,
2428
        correction: str | None,
2429
        directory: PathLike,
2430
        temperatures: Sequence[float] | None = None,
2431
        temperature_settings: dict | None = None,
2432
    ) -> None:
2433
        """Generate a material-wise MGXS library for the model by running the
2434
        original continuous energy OpenMC simulation of the full material
2435
        geometry and source, and tally MGXS data for each material. This method
2436
        accurately conserves reaction rates totaled over the entire simulation
2437
        domain. However, when the geometry has materials only found far from the
2438
        source region, it is possible the Monte Carlo solver may not be able to
2439
        score any tallies to these material types, thus resulting in zero cross
2440
        section values for these materials. For such cases, the "stochastic
2441
        slab" method may be more appropriate.
2442

2443
        Parameters
2444
        ----------
2445
        groups : openmc.mgxs.EnergyGroups
2446
            Energy group structure for the MGXS.
2447
        nparticles : int
2448
            Number of particles to simulate per batch when generating MGXS.
2449
        mgxs_path : PathLike
2450
            Filename for the MGXS HDF5 file.
2451
        correction : str
2452
            Transport correction to apply to the MGXS. Options are None and
2453
            "P0".
2454
        directory : PathLike
2455
            Directory to run the simulation in, so as to contain XML files.
2456
        temperatures : Sequence[float], optional
2457
            A list of temperatures to generate MGXS at. Each infinite material region
2458
            is isothermal at a given temperature data point for cross
2459
            section generation.
2460
        temperature_settings : dict, optional
2461
            A dictionary of temperature settings to use when generating MGXS.
2462
            Valid entries for temperature_settings are the same as the valid
2463
            entries in openmc.Settings.temperature_settings.
2464
        """
2465
        temp_settings = {}
7✔
2466
        if temperature_settings == None:
7✔
2467
            temp_settings = self.settings.temperature
7✔
2468
        else:
2469
            temp_settings = temperature_settings
7✔
2470

2471
        if temperatures == None:
7✔
2472
            mgxs_sets = Model._isothermal_materialwise_mgxs(
7✔
2473
                self,
2474
                groups,
2475
                nparticles,
2476
                correction,
2477
                directory,
2478
                temp_settings
2479
            ).values()
2480

2481
            # Write the file to disk.
2482
            mgxs_file = openmc.MGXSLibrary(energy_groups=groups)
7✔
2483
            for mgxs_set in mgxs_sets:
7✔
2484
                mgxs_file.add_xsdata(mgxs_set)
7✔
2485
            mgxs_file.export_to_hdf5(mgxs_path)
7✔
2486
        else:
2487
            # Build a series of XSData objects, one for each isothermal temperature value.
2488
            raw_mgxs_sets = {}
7✔
2489
            for temperature in temperatures:
7✔
2490
                raw_mgxs_sets[temperature] = Model._isothermal_materialwise_mgxs(
7✔
2491
                    self,
2492
                    groups,
2493
                    nparticles,
2494
                    correction,
2495
                    directory,
2496
                    temp_settings,
2497
                    temperature
2498
                )
2499

2500
            # Unpack the isothermal XSData objects and build a single XSData object per material.
2501
            mgxs_sets = []
7✔
2502
            for mat in self.materials:
7✔
2503
                mgxs_sets.append(openmc.XSdata(mat.name, groups))
7✔
2504
                mgxs_sets[-1].order = 0
7✔
2505
                for temperature in temperatures:
7✔
2506
                    mgxs_sets[-1].add_temperature_data(raw_mgxs_sets[temperature][mat.name])
7✔
2507

2508
            # Write the file to disk.
2509
            mgxs_file = openmc.MGXSLibrary(energy_groups=groups)
7✔
2510
            for mgxs_set in mgxs_sets:
7✔
2511
                mgxs_file.add_xsdata(mgxs_set)
7✔
2512
            mgxs_file.export_to_hdf5(mgxs_path)
7✔
2513

2514
    def convert_to_multigroup(
7✔
2515
        self,
2516
        method: str = "material_wise",
2517
        groups: str = "CASMO-2",
2518
        nparticles: int = 2000,
2519
        overwrite_mgxs_library: bool = False,
2520
        mgxs_path: PathLike = "mgxs.h5",
2521
        correction: str | None = None,
2522
        source_energy: openmc.stats.Univariate | None = None,
2523
        temperatures: Sequence[float] | None = None,
2524
        temperature_settings: dict | None = None,
2525
    ):
2526
        """Convert all materials from continuous energy to multigroup.
2527

2528
        If no MGXS data library file is found, generate one using one or more
2529
        continuous energy Monte Carlo simulations.
2530

2531
        Parameters
2532
        ----------
2533
        method : {"material_wise", "stochastic_slab", "infinite_medium"}, optional
2534
            Method to generate the MGXS.
2535
        groups : openmc.mgxs.EnergyGroups or str, optional
2536
            Energy group structure for the MGXS or the name of the group
2537
            structure (based on keys from openmc.mgxs.GROUP_STRUCTURES).
2538
        nparticles : int, optional
2539
            Number of particles to simulate per batch when generating MGXS.
2540
        overwrite_mgxs_library : bool, optional
2541
            Whether to overwrite an existing MGXS library file.
2542
        mgxs_path : str, optional
2543
            Path to the mgxs.h5 library file.
2544
        correction : str, optional
2545
            Transport correction to apply to the MGXS. Options are None and
2546
            "P0".
2547
        source_energy : openmc.stats.Univariate, optional
2548
            Energy distribution to use when generating MGXS data, replacing any
2549
            existing sources in the model. In all cases, a discrete source that
2550
            is uniform over all energy groups is created (strength = 0.01) to
2551
            ensure that total cross sections are generated for all energy
2552
            groups. In the case that the user has provided a source_energy
2553
            distribution as an argument, an additional source (strength = 0.99)
2554
            is created using that energy distribution. If the user has not
2555
            provided a source_energy distribution, but the model has sources
2556
            defined, and all of those sources are of IndependentSource type,
2557
            then additional sources are created based on the model's existing
2558
            sources, keeping their energy distributions but replacing their
2559
            spatial/angular distributions, with their combined strength being
2560
            0.99. If the user has not provided a source_energy distribution and
2561
            no sources are defined on the model and the run mode is
2562
            'eigenvalue', then a default Watt spectrum source (strength = 0.99)
2563
            is added. Note that this argument is only used when using the
2564
            "stochastic_slab" or "infinite_medium" MGXS generation methods.
2565
        temperatures : Sequence[float], optional
2566
            A list of temperatures to generate MGXS at. Each infinite material region
2567
            is isothermal at a given temperature data point for cross
2568
            section generation.
2569
        temperature_settings : dict, optional
2570
            A dictionary of temperature settings to use when generating MGXS.
2571
            Valid entries for temperature_settings are the same as the valid
2572
            entries in openmc.Settings.temperature_settings.
2573
        """
2574
        if isinstance(groups, str):
7✔
2575
            groups = openmc.mgxs.EnergyGroups(groups)
7✔
2576

2577
        # Do all work (including MGXS generation) in a temporary directory
2578
        # to avoid polluting the working directory with residual XML files
2579
        with TemporaryDirectory() as tmpdir:
7✔
2580

2581
            # Determine if there are DAGMC universes in the model. If so, we need to synchronize
2582
            # the dagmc materials with cells.
2583
            # TODO: Can this be done without having to init/finalize?
2584
            for univ in self.geometry.get_all_universes().values():
7✔
2585
                if isinstance(univ, openmc.DAGMCUniverse):
7✔
2586
                    # Initialize in volume calculation mode (non-transport mode)
2587
                    # This avoids loading cross sections and doesn't require
2588
                    # valid transport settings like particles/batches
NEW
2589
                    self.init_lib(args=['-c'], directory=tmpdir)
×
UNCOV
2590
                    self.sync_dagmc_universes()
×
UNCOV
2591
                    self.finalize_lib()
×
UNCOV
2592
                    break
×
2593

2594
            # Make sure all materials have a name, and that the name is a valid HDF5
2595
            # dataset name
2596
            for material in self.materials:
7✔
2597
                if not material.name or not material.name.strip():
7✔
2598
                    material.name = f"material {material.id}"
×
2599
                material.name = re.sub(r'[^a-zA-Z0-9]', '_', material.name)
7✔
2600

2601
            # If needed, generate the needed MGXS data library file
2602
            if not Path(mgxs_path).is_file() or overwrite_mgxs_library:
7✔
2603
                if method == "infinite_medium":
7✔
2604
                    self._generate_infinite_medium_mgxs(
7✔
2605
                        groups, nparticles, mgxs_path, correction, tmpdir, source_energy,
2606
                        temperatures, temperature_settings)
2607
                elif method == "material_wise":
7✔
2608
                    self._generate_material_wise_mgxs(
7✔
2609
                        groups, nparticles, mgxs_path, correction, tmpdir,
2610
                        temperatures, temperature_settings)
2611
                elif method == "stochastic_slab":
7✔
2612
                    self._generate_stochastic_slab_mgxs(
7✔
2613
                        groups, nparticles, mgxs_path, correction, tmpdir, source_energy,
2614
                        temperatures, temperature_settings)
2615
                else:
2616
                    raise ValueError(
×
2617
                        f'MGXS generation method "{method}" not recognized')
2618
            else:
2619
                print(f'Existing MGXS library file "{mgxs_path}" will be used')
×
2620

2621
            # Convert all continuous energy materials to multigroup
2622
            self.materials.cross_sections = mgxs_path
7✔
2623
            for material in self.materials:
7✔
2624
                material.set_density('macro', 1.0)
7✔
2625
                material._nuclides = []
7✔
2626
                material._sab = []
7✔
2627
                material.add_macroscopic(material.name)
7✔
2628

2629
            self.settings.energy_mode = 'multi-group'
7✔
2630

2631
    def convert_to_random_ray(self):
7✔
2632
        """Convert a multigroup model to use random ray.
2633

2634
        This method determines values for the needed settings and adds them to
2635
        the settings.random_ray dictionary so as to enable random ray mode. The
2636
        settings that are populated are:
2637

2638
        - 'ray_source' (openmc.IndependentSource): Where random ray starting
2639
          points are sampled from.
2640
        - 'distance_inactive' (float): The "dead zone" distance at the beginning
2641
          of the ray.
2642
        - 'distance_active' (float): The "active" distance of the ray
2643
        - 'particles' (int): Number of rays to simulate
2644

2645
        The method will determine reasonable defaults for each of the above
2646
        variables based on analysis of the model's geometry. The function will
2647
        have no effect if the random ray dictionary is already defined in the
2648
        model settings.
2649
        """
2650
        # If the random ray dictionary is already set, don't overwrite it
2651
        if self.settings.random_ray:
7✔
2652
            warnings.warn("Random ray conversion skipped as "
×
2653
                          "settings.random_ray dictionary is already set.")
2654
            return
×
2655

2656
        if self.settings.energy_mode != 'multi-group':
7✔
2657
            raise ValueError(
×
2658
                "Random ray conversion failed: energy mode must be "
2659
                "'multi-group'. Use convert_to_multigroup() first."
2660
            )
2661

2662
        # Helper function for detecting infinity
2663
        def _replace_infinity(value):
7✔
2664
            if np.isinf(value):
7✔
2665
                return 1.0 if value > 0 else -1.0
7✔
2666
            return value
7✔
2667

2668
        # Get a bounding box for sampling rays. We can utilize the geometry's bounding box
2669
        # though for 2D problems we need to detect the infinities and replace them with an
2670
        # arbitrary finite value.
2671
        bounding_box = self.geometry.bounding_box
7✔
2672
        lower_left = [_replace_infinity(v) for v in bounding_box.lower_left]
7✔
2673
        upper_right = [_replace_infinity(v) for v in bounding_box.upper_right]
7✔
2674
        uniform_dist_ray = openmc.stats.Box(lower_left, upper_right)
7✔
2675
        rr_source = openmc.IndependentSource(space=uniform_dist_ray)
7✔
2676
        self.settings.random_ray['ray_source'] = rr_source
7✔
2677

2678
        # For the dead zone and active length, a reasonable guess is the larger of either:
2679
        # 1) The maximum chord length through the geometry (as defined by its bounding box)
2680
        # 2) 30 cm
2681
        # Then, set the active length to be 5x longer than the dead zone length, for the sake of efficiency.
2682
        chord_length = np.array(upper_right) - np.array(lower_left)
7✔
2683
        max_length = max(np.linalg.norm(chord_length), 30.0)
7✔
2684

2685
        self.settings.random_ray['distance_inactive'] = max_length
7✔
2686
        self.settings.random_ray['distance_active'] = 5 * max_length
7✔
2687

2688
        # Take a wild guess as to how many rays are needed
2689
        self.settings.particles = 2 * int(max_length)
7✔
2690

2691
    def keff_search(
7✔
2692
        self,
2693
        func: ModelModifier,
2694
        x0: float,
2695
        x1: float,
2696
        target: float = 1.0,
2697
        k_tol: float = 1e-4,
2698
        sigma_final: float = 3e-4,
2699
        p: float = 0.5,
2700
        q: float = 0.95,
2701
        memory: int = 4,
2702
        x_min: float | None = None,
2703
        x_max: float | None = None,
2704
        b0: int | None = None,
2705
        b_min: int = 20,
2706
        b_max: int | None = None,
2707
        maxiter: int = 50,
2708
        output: bool = False,
2709
        func_kwargs: dict[str, Any] | None = None,
2710
        run_kwargs: dict[str, Any] | None = None,
2711
    ) -> SearchResult:
2712
        r"""Perform a keff search on a model parametrized by a single variable.
2713

2714
        This method uses the GRsecant method described in a paper by `Price and
2715
        Roskoff <https://doi.org/10.1016/j.pnucene.2023.104731>`_. The GRsecant
2716
        method is a modification of the secant method that accounts for
2717
        uncertainties in the function evaluations. The method uses a weighted
2718
        linear fit of the most recent function evaluations to predict the next
2719
        point to evaluate. It also adaptively changes the number of batches to
2720
        meet the target uncertainty value at each iteration.
2721

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

2725
        .. math::
2726
            \sigma_{i+1} = q \sigma_\text{final} \left ( \frac{ \min \left \{
2727
            \left\lvert k_i - k_\text{target} \right\rvert : k=0,1,\dots,n
2728
            \right \} }{k_\text{tol}} \right )^p
2729

2730
        where :math:`q` is a multiplicative factor less than 1, given as the
2731
        ``sigma_factor`` parameter below.
2732

2733
        Parameters
2734
        ----------
2735
        func : ModelModifier
2736
            Function that takes the parameter to be searched and makes a
2737
            modification to the model.
2738
        x0 : float
2739
            First guess for the parameter passed to `func`
2740
        x1 : float
2741
            Second guess for the parameter passed to `func`
2742
        target : float, optional
2743
            keff value to search for
2744
        k_tol : float, optional
2745
            Stopping criterion on the function value; the absolute value must be
2746
            within ``k_tol`` of zero to be accepted.
2747
        sigma_final : float, optional
2748
            Maximum accepted k-effective uncertainty for the stopping criterion.
2749
        p : float, optional
2750
            Exponent used in the stopping criterion.
2751
        q : float, optional
2752
            Multiplicative factor used in the stopping criterion.
2753
        memory : int, optional
2754
            Number of most-recent points used in the weighted linear fit of
2755
            ``f(x) = a + b x`` to predict the next point.
2756
        x_min : float, optional
2757
            Minimum allowed value for the parameter ``x``.
2758
        x_max : float, optional
2759
            Maximum allowed value for the parameter ``x``.
2760
        b0 : int, optional
2761
            Number of active batches to use for the initial function
2762
            evaluations. If None, uses the model's current setting.
2763
        b_min : int, optional
2764
            Minimum number of active batches to use in a function evaluation.
2765
        b_max : int, optional
2766
            Maximum number of active batches to use in a function evaluation.
2767
        maxiter : int, optional
2768
            Maximum number of iterations to perform.
2769
        output : bool, optional
2770
            Whether or not to display output showing iteration progress.
2771
        func_kwargs : dict, optional
2772
            Keyword-based arguments to pass to the `func` function.
2773
        run_kwargs : dict, optional
2774
            Keyword arguments to pass to :meth:`openmc.Model.run` or
2775
            :meth:`openmc.lib.run`.
2776

2777
        Returns
2778
        -------
2779
        SearchResult
2780
            Result object containing the estimated root (parameter value) and
2781
            evaluation history (parameters, means, standard deviations, and
2782
            batches), plus convergence status and termination reason.
2783

2784
        """
2785
        import openmc.lib
7✔
2786

2787
        check_type('model modifier', func, Callable)
7✔
2788
        check_type('target', target, Real)
7✔
2789
        if memory < 2:
7✔
2790
            raise ValueError("memory must be ≥ 2")
×
2791
        func_kwargs = {} if func_kwargs is None else dict(func_kwargs)
7✔
2792
        run_kwargs = {} if run_kwargs is None else dict(run_kwargs)
7✔
2793
        run_kwargs.setdefault('output', False)
7✔
2794

2795
        # Create lists to store the history of evaluations
2796
        xs: list[float] = []
7✔
2797
        fs: list[float] = []
7✔
2798
        ss: list[float] = []
7✔
2799
        gs: list[int] = []
7✔
2800
        count = 0
7✔
2801

2802
        # Helper function to evaluate f and store results
2803
        def eval_at(x: float, batches: int) -> tuple[float, float]:
7✔
2804
            # Modify the model with the current guess
2805
            func(x, **func_kwargs)
7✔
2806

2807
            # Change the number of batches and run the model
2808
            batches += self.settings.inactive
7✔
2809
            if openmc.lib.is_initialized:
7✔
2810
                openmc.lib.settings.set_batches(batches)
×
2811
                openmc.lib.reset()
×
2812
                openmc.lib.run(**run_kwargs)
×
2813
                sp_filepath = f'statepoint.{batches}.h5'
×
2814
            else:
2815
                self.settings.batches = batches
7✔
2816
                sp_filepath = self.run(**run_kwargs)
7✔
2817

2818
            # Extract keff and its uncertainty
2819
            with openmc.StatePoint(sp_filepath) as sp:
7✔
2820
                keff = sp.keff
7✔
2821

2822
            if output:
7✔
2823
                nonlocal count
2824
                count += 1
7✔
2825
                print(f'Iteration {count}: {batches=}, {x=:.6g}, {keff=:.5f}')
7✔
2826

2827
            xs.append(float(x))
7✔
2828
            fs.append(float(keff.n - target))
7✔
2829
            ss.append(float(keff.s))
7✔
2830
            gs.append(int(batches))
7✔
2831
            return fs[-1], ss[-1]
7✔
2832

2833
        # Default b0 to current model settings if not explicitly provided
2834
        if b0 is None:
7✔
2835
            b0 = self.settings.batches - self.settings.inactive
7✔
2836

2837
        # Perform the search (inlined GRsecant) in a temporary directory
2838
        with TemporaryDirectory() as tmpdir:
7✔
2839
            if not openmc.lib.is_initialized:
7✔
2840
                run_kwargs.setdefault('cwd', tmpdir)
7✔
2841

2842
            # ---- Seed with two evaluations
2843
            f0, s0 = eval_at(x0, b0)
7✔
2844
            if abs(f0) <= k_tol and s0 <= sigma_final:
7✔
2845
                return SearchResult(x0, xs, fs, ss, gs, True, "converged")
×
2846
            f1, s1 = eval_at(x1, b0)
7✔
2847
            if abs(f1) <= k_tol and s1 <= sigma_final:
7✔
2848
                return SearchResult(x1, xs, fs, ss, gs, True, "converged")
×
2849

2850
            for _ in range(maxiter - 2):
7✔
2851
                # ------ Step 1: propose next x via GRsecant
2852
                m = min(memory, len(xs))
7✔
2853

2854
                # Perform a curve fit on f(x) = a + bx accounting for
2855
                # uncertainties. This is equivalent to minimizing the function
2856
                # in Equation (A.14)
2857
                (a, b), _ = curve_fit(
7✔
2858
                    lambda x, a, b: a + b*x,
2859
                    xs[-m:], fs[-m:], sigma=ss[-m:], absolute_sigma=True
2860
                )
2861
                x_new = float(-a / b)
7✔
2862

2863
                # Clamp x_new to the bounds if provided
2864
                if x_min is not None:
7✔
2865
                    x_new = max(x_new, x_min)
×
2866
                if x_max is not None:
7✔
2867
                    x_new = min(x_new, x_max)
×
2868

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

2871
                min_abs_f = float(np.min(np.abs(fs)))
7✔
2872
                base = q * sigma_final
7✔
2873
                ratio = min_abs_f / k_tol if k_tol > 0 else 1.0
7✔
2874
                sig = base * (ratio ** p)
7✔
2875
                sig_target = max(sig, base)
7✔
2876

2877
                # ------ Step 3: choose generations to hit σ_target (Appendix C)
2878

2879
                # Use at least two past points for regression
2880
                if len(gs) >= 2 and np.var(np.log(gs)) > 0.0:
7✔
2881
                    # Perform a curve fit based on Eq. (C.3) to solve for ln(k).
2882
                    # Note that unlike in the paper, we do not leave r as an
2883
                    # undetermined parameter and choose r=0.5.
2884
                    (ln_k,), _ = curve_fit(
7✔
2885
                        lambda ln_b, ln_k: ln_k - 0.5*ln_b,
2886
                        np.log(gs[-4:]), np.log(ss[-4:]),
2887
                    )
2888
                    k = float(np.exp(ln_k))
7✔
2889
                else:
2890
                    k = float(ss[-1] * math.sqrt(gs[-1]))
7✔
2891

2892
                b_new = (k / sig_target) ** 2
7✔
2893

2894
                # Clamp and round up to integer
2895
                b_new = max(b_min, math.ceil(b_new))
7✔
2896
                if b_max is not None:
7✔
2897
                    b_new = min(b_new, b_max)
×
2898

2899
                # Evaluate at proposed x with batches determined above
2900
                f_new, s_new = eval_at(x_new, b_new)
7✔
2901

2902
                # Termination based on both criteria (|f| and σ)
2903
                if abs(f_new) <= k_tol and s_new <= sigma_final:
7✔
2904
                    return SearchResult(x_new, xs, fs, ss, gs, True, "converged")
7✔
2905

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

2908

2909
@dataclass
7✔
2910
class SearchResult:
7✔
2911
    """Result of a GRsecant keff search.
2912

2913
    Attributes
2914
    ----------
2915
    root : float
2916
        Estimated parameter value where f(x) = 0 at termination.
2917
    parameters : list[float]
2918
        Parameter values (x) evaluated during the search, in order.
2919
    keffs : list[float]
2920
        Estimated keff values for each evaluation.
2921
    stdevs : list[float]
2922
        One-sigma uncertainties of keff for each evaluation.
2923
    batches : list[int]
2924
        Number of active batches used for each evaluation.
2925
    converged : bool
2926
        Whether both |f| <= k_tol and sigma <= sigma_final were met.
2927
    flag : str
2928
        Reason for termination (e.g., "converged", "maxiter").
2929
    """
2930
    root: float
7✔
2931
    parameters: list[float] = field(repr=False)
7✔
2932
    means: list[float] = field(repr=False)
7✔
2933
    stdevs: list[float] = field(repr=False)
7✔
2934
    batches: list[int] = field(repr=False)
7✔
2935
    converged: bool
7✔
2936
    flag: str
7✔
2937

2938
    @property
7✔
2939
    def function_calls(self) -> int:
7✔
2940
        """Number of function evaluations performed."""
2941
        return len(self.parameters)
7✔
2942

2943
    @property
7✔
2944
    def total_batches(self) -> int:
7✔
2945
        """Total number of active batches used across all evaluations."""
2946
        return sum(self.batches)
7✔
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