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

openmc-dev / openmc / 23561323664

25 Mar 2026 08:00PM UTC coverage: 81.332% (+0.006%) from 81.326%
23561323664

Pull #3717

github

web-flow
Merge 11ca70b1f into 6cd39073b
Pull Request #3717: Local adjoint source for Random Ray

17726 of 25626 branches covered (69.17%)

Branch coverage included in aggregate %.

366 of 400 new or added lines in 9 files covered. (91.5%)

2 existing lines in 2 files now uncovered.

58360 of 67924 relevant lines covered (85.92%)

44892866.35 hits per line

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

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

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

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

29

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

35

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

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

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

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

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

77
    """
78

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

228
        # Account for materials in DAGMC universes
229
        for cell in self.geometry.get_all_cells().values():
11✔
230
            if isinstance(cell.fill, openmc.DAGMCUniverse):
11✔
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
11✔
237

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

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

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

253
        """
254
        if not any('ifp-time-numerator' in t.scores for t in self.tallies):
11✔
255
            gen_time_tally = openmc.Tally(name='IFP time numerator')
11✔
256
            gen_time_tally.scores = ['ifp-time-numerator']
11✔
257
            self.tallies.append(gen_time_tally)
11✔
258
        if not any('ifp-beta-numerator' in t.scores for t in self.tallies):
11✔
259
            beta_tally = openmc.Tally(name='IFP beta numerator')
11✔
260
            beta_tally.scores = ['ifp-beta-numerator']
11✔
261
            if num_groups is not None:
11✔
262
                beta_tally.filters = [openmc.DelayedGroupFilter(list(range(1, num_groups + 1)))]
11✔
263
            self.tallies.append(beta_tally)
11✔
264
        if not any('ifp-denominator' in t.scores for t in self.tallies):
11✔
265
            denom_tally = openmc.Tally(name='IFP denominator')
11✔
266
            denom_tally.scores = ['ifp-denominator']
11✔
267
            self.tallies.append(denom_tally)
11✔
268
    
269
    # TODO: This should also be incorporated into lower-level calls in 
270
    # settings.py, but it requires information about the tallies currently
271
    # on the active Model
272
    def _assign_fw_cadis_tally_IDs(self):
11✔
273
        # Verify that all tallies assigned as targets on WeightWindowGenerators 
274
        # exist within model.tallies. If this is the case, convert the .targets 
275
        # attribute of each WeightWindowGenerator to a sequence of tally IDs.
276
        if len(self.settings.weight_window_generators) == 0:
11✔
277
            return
11✔
278
        
279
        # List of valid tally IDs
280
        reference_tally_ids = np.asarray([tal.id for tal in self.tallies])
11✔
281
        
282
        for wwg in self.settings.weight_window_generators:
11✔
283
            # Only proceeds if the "targets" attribute is an openmc.Tallies, 
284
            # which means it hasn't been checked against model.tallies.
285
            if isinstance(wwg.targets, openmc.Tallies):
11✔
286
                id_vec = []
11✔
287
                for tal in wwg.targets:
11✔
288
                    # check against model tallies for equivalence
289
                    id_next = None
11✔
290
                    for reference_tal in self.tallies:
11✔
291
                        if tal == reference_tal:
11✔
292
                            id_next = reference_tal.id
11✔
293
                            break
11✔
294
                    
295
                    if id_next == None:
11✔
NEW
296
                        raise RuntimeError(
×
297
                            f'Local FW-CADIS target tally {tal.id} not found on model.tallies!')
298
                    else:
299
                        id_vec.append(id_next)
11✔
300

301
                wwg.targets = id_vec
11✔
302

303
            elif isinstance(wwg.targets, np.ndarray):
11✔
NEW
304
                invalid = wwg.targets[~np.isin(wwg.targets, reference_tally_ids)]
×
NEW
305
                if len(invalid) > 0:
×
NEW
306
                    raise RuntimeError(
×
307
                        f'Local FW-CADIS target tally IDs {invalid} not found on model.tallies!')
308

309
    @classmethod
11✔
310
    def from_xml(
11✔
311
        cls,
312
        geometry: PathLike = "geometry.xml",
313
        materials: PathLike = "materials.xml",
314
        settings: PathLike = "settings.xml",
315
        tallies: PathLike = "tallies.xml",
316
        plots: PathLike = "plots.xml",
317
    ) -> Model:
318
        """Create model from existing XML files
319

320
        Parameters
321
        ----------
322
        geometry : PathLike
323
            Path to geometry.xml file
324
        materials : PathLike
325
            Path to materials.xml file
326
        settings : PathLike
327
            Path to settings.xml file
328
        tallies : PathLike
329
            Path to tallies.xml file
330

331
            .. versionadded:: 0.13.0
332
        plots : PathLike
333
            Path to plots.xml file
334

335
            .. versionadded:: 0.13.0
336

337
        Returns
338
        -------
339
        openmc.model.Model
340
            Model created from XML files
341

342
        """
343
        materials = openmc.Materials.from_xml(materials)
11✔
344
        geometry = openmc.Geometry.from_xml(geometry, materials)
11✔
345
        settings = openmc.Settings.from_xml(settings)
11✔
346
        tallies = openmc.Tallies.from_xml(
11✔
347
            tallies) if Path(tallies).exists() else None
348
        plots = openmc.Plots.from_xml(plots) if Path(plots).exists() else None
11✔
349
        return cls(geometry, materials, settings, tallies, plots)
11✔
350

351
    @classmethod
11✔
352
    def from_model_xml(cls, path: PathLike = "model.xml") -> Model:
11✔
353
        """Create model from single XML file
354

355
        .. versionadded:: 0.13.3
356

357
        Parameters
358
        ----------
359
        path : PathLike
360
            Path to model.xml file
361
        """
362
        parser = ET.XMLParser(huge_tree=True)
11✔
363
        tree = ET.parse(path, parser=parser)
11✔
364
        root = tree.getroot()
11✔
365

366
        model = cls()
11✔
367

368
        meshes = {}
11✔
369
        model.settings = openmc.Settings.from_xml_element(
11✔
370
            root.find('settings'), meshes)
371
        model.materials = openmc.Materials.from_xml_element(
11✔
372
            root.find('materials'))
373
        model.geometry = openmc.Geometry.from_xml_element(
11✔
374
            root.find('geometry'), model.materials)
375

376
        if root.find('tallies') is not None:
11✔
377
            model.tallies = openmc.Tallies.from_xml_element(
11✔
378
                root.find('tallies'), meshes)
379

380
        if root.find('plots') is not None:
11✔
381
            model.plots = openmc.Plots.from_xml_element(root.find('plots'))
11✔
382

383
        return model
11✔
384

385
    def init_lib(
11✔
386
        self,
387
        threads: int | None = None,
388
        geometry_debug: bool = False,
389
        restart_file: PathLike | None = None,
390
        tracks: bool = False,
391
        output: bool = True,
392
        event_based: bool | None = None,
393
        intracomm=None,
394
        directory: PathLike | None = None,
395
    ):
396
        """Initializes the model in memory via the C API
397

398
        .. versionadded:: 0.13.0
399

400
        Parameters
401
        ----------
402
        threads : int, optional
403
            Number of OpenMP threads. If OpenMC is compiled with OpenMP
404
            threading enabled, the default is implementation-dependent but is
405
            usually equal to the number of hardware threads available
406
            (or a value set by the :envvar:`OMP_NUM_THREADS` environment
407
            variable).
408
        geometry_debug : bool, optional
409
            Turn on geometry debugging during simulation. Defaults to False.
410
        restart_file : PathLike, optional
411
            Path to restart file to use
412
        tracks : bool, optional
413
            Enables the writing of particles tracks. The number of particle
414
            tracks written to tracks.h5 is limited to 1000 unless
415
            Settings.max_tracks is set. Defaults to False.
416
        output : bool
417
            Capture OpenMC output from standard out
418
        event_based : None or bool, optional
419
            Turns on event-based parallelism if True. If None, the value in
420
            the Settings will be used.
421
        intracomm : mpi4py.MPI.Intracomm or None, optional
422
            MPI intracommunicator
423
        directory : PathLike or None, optional
424
            Directory to write XML files to. Defaults to None.
425
        """
426

427
        import openmc.lib
11✔
428

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

435
        args = _process_CLI_arguments(
11✔
436
            volume=False, geometry_debug=geometry_debug,
437
            restart_file=restart_file, threads=threads, tracks=tracks,
438
            event_based=event_based, path_input=directory)
439

440
        # Args adds the openmc_exec command in the first entry; remove it
441
        args = args[1:]
11✔
442

443
        self.finalize_lib()
11✔
444

445
        # The Model object needs to be aware of the communicator so it can
446
        # use it in certain cases, therefore lets store the communicator
447
        if intracomm is not None:
11✔
448
            self._intracomm = intracomm
4✔
449
        else:
450
            self._intracomm = DummyCommunicator()
11✔
451

452
        if self._intracomm.rank == 0:
11✔
453
            if directory is not None:
11✔
454
                self.export_to_xml(directory=directory)
1✔
455
            else:
456
                self.export_to_xml()
11✔
457
        self._intracomm.barrier()
11✔
458

459
        # We cannot pass DummyCommunicator to openmc.lib.init so pass instead
460
        # the user-provided intracomm which will either be None or an mpi4py
461
        # communicator
462
        openmc.lib.init(args=args, intracomm=intracomm, output=output)
11✔
463

464
    def sync_dagmc_universes(self):
11✔
465
        """Synchronize all DAGMC universes in the current geometry.
466

467
        This method iterates over all DAGMC universes in the geometry and
468
        synchronizes their cells with the current material assignments. Requires
469
        that the model has been initialized via :meth:`Model.init_lib`.
470

471
        .. versionadded:: 0.15.1
472

473
        """
474
        if self.is_initialized:
1✔
475
            if self.materials:
1✔
476
                materials = self.materials
1✔
477
            else:
478
                materials = list(self.geometry.get_all_materials().values())
×
479
            for univ in self.geometry.get_all_universes().values():
1✔
480
                if isinstance(univ, openmc.DAGMCUniverse):
1✔
481
                    univ.sync_dagmc_cells(materials)
1✔
482
        else:
483
            raise ValueError("The model must be initialized before calling "
×
484
                             "this method")
485

486
    def finalize_lib(self):
11✔
487
        """Finalize simulation and free memory allocated for the C API
488

489
        .. versionadded:: 0.13.0
490

491
        """
492

493
        import openmc.lib
11✔
494

495
        openmc.lib.finalize()
11✔
496

497
    def deplete(
11✔
498
        self,
499
        method: str = "cecm",
500
        final_step: bool = True,
501
        operator_kwargs: dict | None = None,
502
        directory: PathLike = ".",
503
        output: bool = True,
504
        **integrator_kwargs,
505
    ):
506
        """Deplete model using specified timesteps/power
507

508
        .. versionchanged:: 0.13.0
509
            The *final_step*, *operator_kwargs*, *directory*, and *output*
510
            arguments were added.
511

512
        Parameters
513
        ----------
514
        timesteps : iterable of float or iterable of tuple
515
            Array of timesteps. Note that values are not cumulative. The units are
516
            specified by the `timestep_units` argument when `timesteps` is an
517
            iterable of float. Alternatively, units can be specified for each step
518
            by passing an iterable of (value, unit) tuples.
519
        method : str
520
             Integration method used for depletion (e.g., 'cecm', 'predictor').
521
             Defaults to 'cecm'.
522
        final_step : bool, optional
523
            Indicate whether or not a transport solve should be run at the end
524
            of the last timestep. Defaults to running this transport solve.
525
        operator_kwargs : dict
526
            Keyword arguments passed to the depletion operator initializer
527
            (e.g., :func:`openmc.deplete.Operator`)
528
        directory : PathLike, optional
529
            Directory to write XML files to. If it doesn't exist already, it
530
            will be created. Defaults to the current working directory
531
        output : bool
532
            Capture OpenMC output from standard out
533
        integrator_kwargs : dict
534
            Remaining keyword arguments passed to the depletion integrator
535
            (e.g., :class:`openmc.deplete.CECMIntegrator`).
536

537
        """
538

539
        if operator_kwargs is None:
11✔
540
            op_kwargs = {}
×
541
        elif isinstance(operator_kwargs, dict):
11✔
542
            op_kwargs = operator_kwargs
11✔
543
        else:
544
            raise ValueError("operator_kwargs must be a dict or None")
×
545

546
        # Import openmc.deplete here so the Model can be used even if the
547
        # shared library is unavailable.
548
        import openmc.deplete as dep
11✔
549

550
        # Store whether or not the library was initialized when we started
551
        started_initialized = self.is_initialized
11✔
552

553
        with change_directory(directory):
11✔
554
            with openmc.lib.quiet_dll(output):
11✔
555
                # TODO: Support use of IndependentOperator too
556
                depletion_operator = dep.CoupledOperator(self, **op_kwargs)
11✔
557

558
            # Tell depletion_operator.finalize NOT to clear C API memory when
559
            # it is done
560
            depletion_operator.cleanup_when_done = False
11✔
561

562
            # Set up the integrator
563
            check_value('method', method,
11✔
564
                        dep.integrators.integrator_by_name.keys())
565
            integrator_class = dep.integrators.integrator_by_name[method]
11✔
566
            integrator = integrator_class(depletion_operator, **integrator_kwargs)
11✔
567

568
            # Now perform the depletion
569
            with openmc.lib.quiet_dll(output):
11✔
570
                integrator.integrate(final_step)
11✔
571

572
            # Now make the python Materials match the C API material data
573
            for mat_id, mat in self._materials_by_id.items():
11✔
574
                if mat.depletable:
11✔
575
                    # Get the C data
576
                    c_mat = openmc.lib.materials[mat_id]
11✔
577
                    nuclides, densities = c_mat._get_densities()
11✔
578
                    # And now we can remove isotopes and add these ones in
579
                    mat.nuclides.clear()
11✔
580
                    for nuc, density in zip(nuclides, densities):
11✔
581
                        mat.add_nuclide(nuc, density)
11✔
582
                    mat.set_density('atom/b-cm', sum(densities))
11✔
583

584
            # If we didnt start intialized, we should cleanup after ourselves
585
            if not started_initialized:
11✔
586
                depletion_operator.cleanup_when_done = True
11✔
587
                depletion_operator.finalize()
11✔
588

589
    def _link_geometry_to_filters(self):
11✔
590
        """Establishes a link between distribcell filters and the geometry"""
591
        for tally in self.tallies:
11✔
592
            for f in tally.filters:
11✔
593
                if isinstance(f, openmc.DistribcellFilter):
11✔
594
                    f._geometry = self.geometry
11✔
595

596
    def export_to_xml(self, directory: PathLike = '.', remove_surfs: bool = False,
11✔
597
                      nuclides_to_ignore: Iterable[str] | None = None):
598
        """Export model to separate XML files.
599

600
        Parameters
601
        ----------
602
        directory : PathLike
603
            Directory to write XML files to. If it doesn't exist already, it
604
            will be created.
605
        remove_surfs : bool
606
            Whether or not to remove redundant surfaces from the geometry when
607
            exporting.
608

609
            .. versionadded:: 0.13.1
610
        nuclides_to_ignore : list of str
611
            Nuclides to ignore when exporting to XML.
612

613
        """
614
        # Create directory if required
615
        d = Path(directory)
11✔
616
        if not d.is_dir():
11✔
617
            d.mkdir(parents=True, exist_ok=True)
11✔
618

619
        self._assign_fw_cadis_tally_IDs()
11✔
620
        self.settings.export_to_xml(d)
11✔
621
        self.geometry.export_to_xml(d, remove_surfs=remove_surfs)
11✔
622

623
        # If a materials collection was specified, export it. Otherwise, look
624
        # for all materials in the geometry and use that to automatically build
625
        # a collection.
626
        if self.materials:
11✔
627
            self.materials.export_to_xml(d, nuclides_to_ignore=nuclides_to_ignore)
11✔
628
        else:
629
            materials = openmc.Materials(self.geometry.get_all_materials()
11✔
630
                                         .values())
631
            materials.export_to_xml(d, nuclides_to_ignore=nuclides_to_ignore)
11✔
632

633
        if self.tallies:
11✔
634
            self.tallies.export_to_xml(d)
11✔
635
        if self.plots:
11✔
636
            self.plots.export_to_xml(d)
11✔
637

638
        self._link_geometry_to_filters()
11✔
639

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

644
        .. versionadded:: 0.13.3
645

646
        Parameters
647
        ----------
648
        path : str or PathLike
649
            Location of the XML file to write (default is 'model.xml'). Can be a
650
            directory or file path.
651
        remove_surfs : bool
652
            Whether or not to remove redundant surfaces from the geometry when
653
            exporting.
654
        nuclides_to_ignore : list of str
655
            Nuclides to ignore when exporting to XML.
656

657
        """
658
        xml_path = Path(path)
11✔
659
        # if the provided path doesn't end with the XML extension, assume the
660
        # input path is meant to be a directory. If the directory does not
661
        # exist, create it and place a 'model.xml' file there.
662
        if not str(xml_path).endswith('.xml'):
11✔
663
            if not xml_path.exists():
11✔
664
                xml_path.mkdir(parents=True, exist_ok=True)
×
665
            elif not xml_path.is_dir():
11✔
666
                raise FileExistsError(f"File exists and is not a directory: '{xml_path}'")
×
667
            xml_path /= 'model.xml'
11✔
668
        # if this is an XML file location and the file's parent directory does
669
        # not exist, create it before continuing
670
        elif not xml_path.parent.exists():
11✔
671
            xml_path.parent.mkdir(parents=True, exist_ok=True)
×
672

673
        if remove_surfs:
11✔
674
            warnings.warn("remove_surfs kwarg will be deprecated soon, please "
×
675
                          "set the Geometry.merge_surfaces attribute instead.")
676
            self.geometry.merge_surfaces = True
×
677

678
        # Link FW-CADIS WeightWindowGenerator target tallies, if present
679
        self._assign_fw_cadis_tally_IDs()
11✔
680

681
        # provide a memo to track which meshes have been written
682
        mesh_memo = set()
11✔
683
        settings_element = self.settings.to_xml_element(mesh_memo)
11✔
684
        geometry_element = self.geometry.to_xml_element()
11✔
685

686
        xml.clean_indentation(geometry_element, level=1)
11✔
687
        xml.clean_indentation(settings_element, level=1)
11✔
688

689
        # If a materials collection was specified, export it. Otherwise, look
690
        # for all materials in the geometry and use that to automatically build
691
        # a collection.
692
        if self.materials:
11✔
693
            materials = self.materials
11✔
694
        else:
695
            materials = openmc.Materials(self.geometry.get_all_materials()
11✔
696
                                         .values())
697

698
        with open(xml_path, 'w', encoding='utf-8', errors='xmlcharrefreplace') as fh:
11✔
699
            # write the XML header
700
            fh.write("<?xml version='1.0' encoding='utf-8'?>\n")
11✔
701
            fh.write("<model>\n")
11✔
702
            # Write the materials collection to the open XML file first.
703
            # This will write the XML header also
704
            materials._write_xml(fh, False, level=1,
11✔
705
                                 nuclides_to_ignore=nuclides_to_ignore)
706
            # Write remaining elements as a tree
707
            fh.write(ET.tostring(geometry_element, encoding="unicode"))
11✔
708
            fh.write(ET.tostring(settings_element, encoding="unicode"))
11✔
709

710
            if self.tallies:
11✔
711
                tallies_element = self.tallies.to_xml_element(mesh_memo)
11✔
712
                xml.clean_indentation(
11✔
713
                    tallies_element, level=1, trailing_indent=self.plots)
714
                fh.write(ET.tostring(tallies_element, encoding="unicode"))
11✔
715
            if self.plots:
11✔
716
                plots_element = self.plots.to_xml_element()
11✔
717
                xml.clean_indentation(
11✔
718
                    plots_element, level=1, trailing_indent=False)
719
                fh.write(ET.tostring(plots_element, encoding="unicode"))
11✔
720
            fh.write("</model>\n")
11✔
721

722
        self._link_geometry_to_filters()
11✔
723

724
    def import_properties(self, filename: PathLike):
11✔
725
        """Import physical properties
726

727
        .. versionchanged:: 0.13.0
728
            This method now updates values as loaded in memory with the C API
729

730
        Parameters
731
        ----------
732
        filename : PathLike
733
            Path to properties HDF5 file
734

735
        See Also
736
        --------
737
        openmc.lib.export_properties
738

739
        """
740
        import openmc.lib
11✔
741

742
        cells = self.geometry.get_all_cells()
11✔
743
        materials = self.geometry.get_all_materials()
11✔
744

745
        with h5py.File(filename, 'r') as fh:
11✔
746
            cells_group = fh['geometry/cells']
11✔
747

748
            # Make sure number of cells matches
749
            n_cells = fh['geometry'].attrs['n_cells']
11✔
750
            if n_cells != len(cells):
11✔
751
                raise ValueError("Number of cells in properties file doesn't "
×
752
                                 "match current model.")
753

754
            # Update temperatures and densities for cells filled with materials
755
            for name, group in cells_group.items():
11✔
756
                cell_id = int(name.split()[1])
11✔
757
                cell = cells[cell_id]
11✔
758
                if cell.fill_type in ('material', 'distribmat'):
11✔
759
                    temperature = group['temperature'][()]
11✔
760
                    cell.temperature = temperature
11✔
761
                    if self.is_initialized:
11✔
762
                        lib_cell = openmc.lib.cells[cell_id]
11✔
763
                        if temperature.size > 1:
11✔
764
                            for i, T in enumerate(temperature):
×
765
                                lib_cell.set_temperature(T, i)
×
766
                        else:
767
                            lib_cell.set_temperature(temperature[0])
11✔
768

769
                    if group['density']:
11✔
770
                      density = group['density'][()]
11✔
771
                      if density.size > 1:
11✔
772
                          cell.density = [rho for rho in density]
×
773
                      else:
774
                          cell.density = density
11✔
775
                      if self.is_initialized:
11✔
776
                          lib_cell = openmc.lib.cells[cell_id]
11✔
777
                          if density.size > 1:
11✔
778
                              for i, rho in enumerate(density):
×
779
                                  lib_cell.set_density(rho, i)
×
780
                          else:
781
                              lib_cell.set_density(density[0])
11✔
782

783
            # Make sure number of materials matches
784
            mats_group = fh['materials']
11✔
785
            n_cells = mats_group.attrs['n_materials']
11✔
786
            if n_cells != len(materials):
11✔
787
                raise ValueError("Number of materials in properties file "
×
788
                                 "doesn't match current model.")
789

790
            # Update material densities
791
            for name, group in mats_group.items():
11✔
792
                mat_id = int(name.split()[1])
11✔
793
                atom_density = group.attrs['atom_density']
11✔
794
                materials[mat_id].set_density('atom/b-cm', atom_density)
11✔
795
                if self.is_initialized:
11✔
796
                    C_mat = openmc.lib.materials[mat_id]
11✔
797
                    C_mat.set_density(atom_density, 'atom/b-cm')
11✔
798

799
    def run(
11✔
800
        self,
801
        particles: int | None = None,
802
        threads: int | None = None,
803
        geometry_debug: bool = False,
804
        restart_file: PathLike | None = None,
805
        tracks: bool = False,
806
        output: bool = True,
807
        cwd: PathLike = ".",
808
        openmc_exec: PathLike = "openmc",
809
        mpi_args: Iterable[str] = None,
810
        event_based: bool | None = None,
811
        export_model_xml: bool = True,
812
        apply_tally_results: bool = False,
813
        **export_kwargs,
814
    ) -> Path:
815
        """Run OpenMC
816

817
        If the C API has been initialized, then the C API is used, otherwise,
818
        this method creates the XML files and runs OpenMC via a system call. In
819
        both cases this method returns the path to the last statepoint file
820
        generated.
821

822
        .. versionchanged:: 0.12
823
            Instead of returning the final k-effective value, this function now
824
            returns the path to the final statepoint written.
825

826
        .. versionchanged:: 0.13.0
827
            This method can utilize the C API for execution
828

829
        Parameters
830
        ----------
831
        particles : int, optional
832
            Number of particles to simulate per generation.
833
        threads : int, optional
834
            Number of OpenMP threads. If OpenMC is compiled with OpenMP
835
            threading enabled, the default is implementation-dependent but is
836
            usually equal to the number of hardware threads available (or a
837
            value set by the :envvar:`OMP_NUM_THREADS` environment variable).
838
        geometry_debug : bool, optional
839
            Turn on geometry debugging during simulation. Defaults to False.
840
        restart_file : str or PathLike
841
            Path to restart file to use
842
        tracks : bool, optional
843
            Enables the writing of particles tracks. The number of particle
844
            tracks written to tracks.h5 is limited to 1000 unless
845
            Settings.max_tracks is set. Defaults to False.
846
        output : bool, optional
847
            Capture OpenMC output from standard out
848
        cwd : PathLike, optional
849
            Path to working directory to run in. Defaults to the current working
850
            directory.
851
        openmc_exec : str, optional
852
            Path to OpenMC executable. Defaults to 'openmc'.
853
        mpi_args : list of str, optional
854
            MPI execute command and any additional MPI arguments to pass, e.g.
855
            ['mpiexec', '-n', '8'].
856
        event_based : None or bool, optional
857
            Turns on event-based parallelism if True. If None, the value in the
858
            Settings will be used.
859
        export_model_xml : bool, optional
860
            Exports a single model.xml file rather than separate files. Defaults
861
            to True.
862

863
            .. versionadded:: 0.13.3
864
        apply_tally_results : bool
865
            Whether to apply results of the final statepoint file to the
866
            model's tally objects.
867

868
            .. versionadded:: 0.15.1
869
        **export_kwargs
870
            Keyword arguments passed to either :meth:`Model.export_to_model_xml`
871
            or :meth:`Model.export_to_xml`.
872

873
        Returns
874
        -------
875
        Path
876
            Path to the last statepoint written by this run (None if no
877
            statepoint was written)
878

879
        """
880

881
        # Setting tstart here ensures we don't pick up any pre-existing
882
        # statepoint files in the output directory -- just in case there are
883
        # differences between the system clock and the filesystem, we get the
884
        # time of a just-created temporary file
885
        with NamedTemporaryFile() as fp:
11✔
886
            tstart = Path(fp.name).stat().st_mtime
11✔
887
        last_statepoint = None
11✔
888

889
        # Operate in the provided working directory
890
        with change_directory(cwd):
11✔
891
            if self.is_initialized:
11✔
892
                # Handle the run options as applicable
893
                # First dont allow ones that must be set via init
894
                for arg_name, arg, default in zip(
11✔
895
                    ['threads', 'geometry_debug', 'restart_file', 'tracks'],
896
                    [threads, geometry_debug, restart_file, tracks],
897
                    [None, False, None, False]
898
                ):
899
                    if arg != default:
11✔
900
                        msg = f"{arg_name} must be set via Model.is_initialized(...)"
11✔
901
                        raise ValueError(msg)
11✔
902

903
                init_particles = openmc.lib.settings.particles
11✔
904
                if particles is not None:
11✔
905
                    if isinstance(particles, Integral) and particles > 0:
×
906
                        openmc.lib.settings.particles = particles
×
907

908
                init_event_based = openmc.lib.settings.event_based
11✔
909
                if event_based is not None:
11✔
910
                    openmc.lib.settings.event_based = event_based
×
911

912
                # Then run using the C API
913
                openmc.lib.run(output)
11✔
914

915
                # Reset changes for the openmc.run kwargs handling
916
                openmc.lib.settings.particles = init_particles
11✔
917
                openmc.lib.settings.event_based = init_event_based
11✔
918

919
            else:
920
                # Then run via the command line
921
                if export_model_xml:
11✔
922
                    self.export_to_model_xml(**export_kwargs)
11✔
923
                else:
924
                    self.export_to_xml(**export_kwargs)
11✔
925
                path_input = export_kwargs.get("path", None)
11✔
926
                openmc.run(particles, threads, geometry_debug, restart_file,
11✔
927
                           tracks, output, Path('.'), openmc_exec, mpi_args,
928
                           event_based, path_input)
929

930
            # Get output directory and return the last statepoint written
931
            if self.settings.output and 'path' in self.settings.output:
11✔
932
                output_dir = Path(self.settings.output['path'])
×
933
            else:
934
                output_dir = Path.cwd()
11✔
935
            for sp in output_dir.glob('statepoint.*.h5'):
11✔
936
                mtime = sp.stat().st_mtime
11✔
937
                if mtime >= tstart:  # >= allows for poor clock resolution
11✔
938
                    tstart = mtime
11✔
939
                    last_statepoint = sp
11✔
940

941
        if apply_tally_results:
11✔
942
            self.apply_tally_results(last_statepoint)
11✔
943

944
        return last_statepoint
11✔
945

946
    def calculate_volumes(
11✔
947
        self,
948
        threads: int | None = None,
949
        output: bool = True,
950
        cwd: PathLike = ".",
951
        openmc_exec: PathLike = "openmc",
952
        mpi_args: list[str] | None = None,
953
        apply_volumes: bool = True,
954
        export_model_xml: bool = True,
955
        **export_kwargs,
956
    ):
957
        """Runs an OpenMC stochastic volume calculation and, if requested,
958
        applies volumes to the model
959

960
        .. versionadded:: 0.13.0
961

962
        Parameters
963
        ----------
964
        threads : int, optional
965
            Number of OpenMP threads. If OpenMC is compiled with OpenMP
966
            threading enabled, the default is implementation-dependent but is
967
            usually equal to the number of hardware threads available (or a
968
            value set by the :envvar:`OMP_NUM_THREADS` environment variable).
969
            This currenty only applies to the case when not using the C API.
970
        output : bool, optional
971
            Capture OpenMC output from standard out
972
        openmc_exec : str, optional
973
            Path to OpenMC executable. Defaults to 'openmc'.
974
            This only applies to the case when not using the C API.
975
        mpi_args : list of str, optional
976
            MPI execute command and any additional MPI arguments to pass,
977
            e.g. ['mpiexec', '-n', '8'].
978
            This only applies to the case when not using the C API.
979
        cwd : str, optional
980
            Path to working directory to run in. Defaults to the current
981
            working directory.
982
        apply_volumes : bool, optional
983
            Whether apply the volume calculation results from this calculation
984
            to the model. Defaults to applying the volumes.
985
        export_model_xml : bool, optional
986
            Exports a single model.xml file rather than separate files. Defaults
987
            to True.
988
        **export_kwargs
989
            Keyword arguments passed to either :meth:`Model.export_to_model_xml`
990
            or :meth:`Model.export_to_xml`.
991

992
        """
993

994
        if len(self.settings.volume_calculations) == 0:
11✔
995
            # Then there is no volume calculation specified
996
            raise ValueError("The Settings.volume_calculations attribute must"
11✔
997
                             " be specified before executing this method!")
998

999
        with change_directory(cwd):
11✔
1000
            if self.is_initialized:
11✔
1001
                if threads is not None:
11✔
1002
                    msg = "Threads must be set via Model.is_initialized(...)"
×
1003
                    raise ValueError(msg)
×
1004
                if mpi_args is not None:
11✔
1005
                    msg = "The MPI environment must be set otherwise such as" \
×
1006
                        "with the call to mpi4py"
1007
                    raise ValueError(msg)
×
1008

1009
                # Compute the volumes
1010
                openmc.lib.calculate_volumes(output)
11✔
1011

1012
            else:
1013
                if export_model_xml:
11✔
1014
                    self.export_to_model_xml(**export_kwargs)
11✔
1015
                else:
1016
                    self.export_to_xml(**export_kwargs)
×
1017
                path_input = export_kwargs.get("path", None)
11✔
1018
                openmc.calculate_volumes(
11✔
1019
                    threads=threads, output=output, openmc_exec=openmc_exec,
1020
                    mpi_args=mpi_args, path_input=path_input
1021
                )
1022

1023
            # Now we apply the volumes
1024
            if apply_volumes:
11✔
1025
                # Load the results and add them to the model
1026
                for i, vol_calc in enumerate(self.settings.volume_calculations):
11✔
1027
                    vol_calc.load_results(f"volume_{i + 1}.h5")
11✔
1028
                    # First add them to the Python side
1029
                    if vol_calc.domain_type == "material" and self.materials:
11✔
1030
                        for material in self.materials:
11✔
1031
                            if material.id in vol_calc.volumes:
11✔
1032
                                material.add_volume_information(vol_calc)
11✔
1033
                    else:
1034
                        self.geometry.add_volume_information(vol_calc)
11✔
1035

1036
                    # And now repeat for the C API
1037
                    if self.is_initialized and vol_calc.domain_type == 'material':
11✔
1038
                        # Then we can do this in the C API
1039
                        for domain_id in vol_calc.ids:
11✔
1040
                            openmc.lib.materials[domain_id].volume = \
11✔
1041
                                vol_calc.volumes[domain_id].n
1042

1043

1044
    def _set_plot_defaults(
11✔
1045
        self,
1046
        origin: Sequence[float] | None,
1047
        width: Sequence[float] | None,
1048
        pixels: int | Sequence[int],
1049
        basis: str
1050
    ):
1051
        x, y, _ = _BASIS_INDICES[basis]
11✔
1052

1053
        bb = self.bounding_box
11✔
1054
        # checks to see if bounding box contains -inf or inf values
1055
        if np.isinf(bb.extent[basis]).any():
11✔
1056
            if origin is None:
11✔
1057
                origin = (0, 0, 0)
11✔
1058
            if width is None:
11✔
1059
                width = (10, 10)
11✔
1060
        else:
1061
            if origin is None:
11✔
1062
                # if nan values in the bb.center they get replaced with 0.0
1063
                # this happens when the bounding_box contains inf values
1064
                with warnings.catch_warnings():
11✔
1065
                    warnings.simplefilter("ignore", RuntimeWarning)
11✔
1066
                    origin = np.nan_to_num(bb.center)
11✔
1067
            if width is None:
11✔
1068
                bb_width = bb.width
11✔
1069
                width = (bb_width[x], bb_width[y])
11✔
1070

1071
        if isinstance(pixels, int):
11✔
1072
            aspect_ratio = width[0] / width[1]
11✔
1073
            pixels_y = math.sqrt(pixels / aspect_ratio)
11✔
1074
            pixels = (int(pixels / pixels_y), int(pixels_y))
11✔
1075

1076
        return origin, width, pixels
11✔
1077

1078
    def id_map(
11✔
1079
        self,
1080
        origin: Sequence[float] | None = None,
1081
        width: Sequence[float] | None = None,
1082
        pixels: int | Sequence[int] = 40000,
1083
        basis: str = 'xy',
1084
        color_overlaps: bool = False,
1085
        **init_kwargs
1086
    ) -> np.ndarray:
1087
        """Generate an ID map for domains based on the plot parameters
1088

1089
        If the model is not yet initialized, it will be initialized with
1090
        openmc.lib. If the model is initialized, the model will remain
1091
        initialized after this method call exits.
1092

1093
        .. versionadded:: 0.15.3
1094

1095
        Parameters
1096
        ----------
1097
        origin : Sequence[float], optional
1098
            Origin of the plot. If unspecified, this argument defaults to the
1099
            center of the bounding box if the bounding box does not contain inf
1100
            values for the provided basis, otherwise (0.0, 0.0, 0.0).
1101
        width : Sequence[float], optional
1102
            Width of the plot. If unspecified, this argument defaults to the
1103
            width of the bounding box if the bounding box does not contain inf
1104
            values for the provided basis, otherwise (10.0, 10.0).
1105
        pixels : int | Sequence[int], optional
1106
            If an iterable of ints is provided then this directly sets the
1107
            number of pixels to use in each basis direction. If a single int is
1108
            provided then this sets the total number of pixels in the plot and
1109
            the number of pixels in each basis direction is calculated from this
1110
            total and the image aspect ratio based on the width argument.
1111
        basis : {'xy', 'yz', 'xz'}, optional
1112
            Basis of the plot.
1113
        color_overlaps : bool, optional
1114
            Whether to assign unique IDs (-3) to overlapping regions. If False,
1115
            overlapping regions will be assigned the ID of the lowest-numbered
1116
            cell that occupies that region. Defaults to False.
1117
        **init_kwargs
1118
            Keyword arguments passed to :meth:`Model.init_lib`.
1119

1120
        Returns
1121
        -------
1122
        id_map : numpy.ndarray
1123
            A NumPy array with shape (vertical pixels, horizontal pixels, 3) of
1124
            OpenMC property IDs with dtype int32. The last dimension of the
1125
            array contains cell IDs, cell instances, and material IDs (in that
1126
            order).
1127
        """
1128
        import openmc.lib
11✔
1129

1130
        origin, width, pixels = self._set_plot_defaults(
11✔
1131
            origin, width, pixels, basis)
1132

1133
        # initialize the openmc.lib.plot._PlotBase object
1134
        plot_obj = openmc.lib.plot._PlotBase()
11✔
1135
        plot_obj.origin = origin
11✔
1136
        plot_obj.width = width[0]
11✔
1137
        plot_obj.height = width[1]
11✔
1138
        plot_obj.h_res = pixels[0]
11✔
1139
        plot_obj.v_res = pixels[1]
11✔
1140
        plot_obj.basis = basis
11✔
1141
        plot_obj.color_overlaps = color_overlaps
11✔
1142

1143
        # Silence output by default. Also set arguments to start in volume
1144
        # calculation mode to avoid loading cross sections
1145
        init_kwargs.setdefault('output', False)
11✔
1146
        init_kwargs.setdefault('args', ['-c'])
11✔
1147

1148
        with openmc.lib.TemporarySession(self, **init_kwargs):
11✔
1149
            return openmc.lib.id_map(plot_obj)
11✔
1150

1151
    @add_plot_params
11✔
1152
    def plot(
11✔
1153
        self,
1154
        origin: Sequence[float] | None = None,
1155
        width: Sequence[float] | None = None,
1156
        pixels: int | Sequence[int] = 40000,
1157
        basis: str = 'xy',
1158
        color_by: str = 'cell',
1159
        colors: dict | None = None,
1160
        seed: int | None = None,
1161
        axes=None,
1162
        legend: bool = False,
1163
        axis_units: str = 'cm',
1164
        outline: bool | str = False,
1165
        show_overlaps: bool = False,
1166
        overlap_color: Sequence[int] | str = (255, 0, 0),
1167
        n_samples: int | None = None,
1168
        plane_tolerance: float = 1.,
1169
        legend_kwargs: dict | None = None,
1170
        source_kwargs: dict | None = None,
1171
        contour_kwargs: dict | None = None,
1172
        **kwargs,
1173
    ):
1174
        """Display a slice plot of the model.
1175

1176
        .. versionadded:: 0.15.1
1177
        """
1178
        import matplotlib.patches as mpatches
11✔
1179
        import matplotlib.pyplot as plt
11✔
1180

1181
        check_type('n_samples', n_samples, int | None)
11✔
1182
        check_type('plane_tolerance', plane_tolerance, Real)
11✔
1183
        if legend_kwargs is None:
11✔
1184
            legend_kwargs = {}
11✔
1185
        legend_kwargs.setdefault('bbox_to_anchor', (1.05, 1))
11✔
1186
        legend_kwargs.setdefault('loc', 2)
11✔
1187
        legend_kwargs.setdefault('borderaxespad', 0.0)
11✔
1188
        if source_kwargs is None:
11✔
1189
            source_kwargs = {}
11✔
1190
        source_kwargs.setdefault('marker', 'x')
11✔
1191

1192
        # Set indices using basis and create axis labels
1193
        x, y, z = _BASIS_INDICES[basis]
11✔
1194
        xlabel, ylabel = f'{basis[0]} [{axis_units}]', f'{basis[1]} [{axis_units}]'
11✔
1195

1196
        # Determine extents of plot
1197
        origin, width, pixels = self._set_plot_defaults(
11✔
1198
            origin, width, pixels, basis)
1199

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

1202
        x_min = (origin[x] - 0.5*width[0]) * axis_scaling_factor[axis_units]
11✔
1203
        x_max = (origin[x] + 0.5*width[0]) * axis_scaling_factor[axis_units]
11✔
1204
        y_min = (origin[y] - 0.5*width[1]) * axis_scaling_factor[axis_units]
11✔
1205
        y_max = (origin[y] + 0.5*width[1]) * axis_scaling_factor[axis_units]
11✔
1206

1207
        # Determine whether any materials contains macroscopic data and if so,
1208
        # set energy mode accordingly and check that mg cross sections path is accessible
1209
        for mat in self.geometry.get_all_materials().values():
11✔
1210
            if mat._macroscopic is not None:
11✔
1211
                self.settings.energy_mode = 'multi-group'
11✔
1212
                if 'mg_cross_sections' not in openmc.config:
11✔
1213
                    raise RuntimeError("'mg_cross_sections' path must be set in "
11✔
1214
                                       "openmc.config before plotting.")
1215
                break
11✔
1216

1217
        # Get ID map from the C API
1218
        id_map = self.id_map(
11✔
1219
            origin=origin,
1220
            width=width,
1221
            pixels=pixels,
1222
            basis=basis,
1223
            color_overlaps=show_overlaps
1224
        )
1225

1226
        # Generate colors if not provided
1227
        if colors is None and seed is not None:
11✔
1228
            # Use the colorize method to generate random colors
1229
            plot = openmc.SlicePlot()
×
1230
            plot.color_by = color_by
×
1231
            plot.colorize(self.geometry, seed=seed)
×
1232
            colors = plot.colors
×
1233

1234
        # Convert ID map to RGB image
1235
        img = id_map_to_rgb(
11✔
1236
            id_map=id_map,
1237
            color_by=color_by,
1238
            colors=colors,
1239
            overlap_color=overlap_color
1240
        )
1241

1242
        # Create a figure sized such that the size of the axes within
1243
        # exactly matches the number of pixels specified
1244
        if axes is None:
11✔
1245
            px = 1/plt.rcParams['figure.dpi']
11✔
1246
            fig, axes = plt.subplots()
11✔
1247
            axes.set_xlabel(xlabel)
11✔
1248
            axes.set_ylabel(ylabel)
11✔
1249
            params = fig.subplotpars
11✔
1250
            width_px = pixels[0]*px/(params.right - params.left)
11✔
1251
            height_px = pixels[1]*px/(params.top - params.bottom)
11✔
1252
            fig.set_size_inches(width_px, height_px)
11✔
1253

1254
        if outline:
11✔
1255
            # Combine R, G, B values into a single int for contour detection
1256
            rgb = (img * 256).astype(int)
11✔
1257
            image_value = (rgb[..., 0] << 16) + \
11✔
1258
                (rgb[..., 1] << 8) + (rgb[..., 2])
1259

1260
            # Set default arguments for contour()
1261
            if contour_kwargs is None:
11✔
1262
                contour_kwargs = {}
11✔
1263
            contour_kwargs.setdefault('colors', 'k')
11✔
1264
            contour_kwargs.setdefault('linestyles', 'solid')
11✔
1265
            contour_kwargs.setdefault('algorithm', 'serial')
11✔
1266

1267
            axes.contour(
11✔
1268
                image_value,
1269
                origin="upper",
1270
                levels=np.unique(image_value),
1271
                extent=(x_min, x_max, y_min, y_max),
1272
                **contour_kwargs
1273
            )
1274

1275
            # If only showing outline, set the axis limits and aspect explicitly
1276
            if outline == 'only':
11✔
1277
                axes.set_xlim(x_min, x_max)
×
1278
                axes.set_ylim(y_min, y_max)
×
1279
                axes.set_aspect('equal')
×
1280

1281
        # Add legend showing which colors represent which material or cell
1282
        if legend:
11✔
1283
            if colors is None or len(colors) == 0:
11✔
1284
                raise ValueError("Must pass 'colors' dictionary if you "
11✔
1285
                                 "are adding a legend via legend=True.")
1286

1287
            if color_by == "cell":
11✔
1288
                expected_key_type = openmc.Cell
×
1289
            else:
1290
                expected_key_type = openmc.Material
11✔
1291

1292
            patches = []
11✔
1293
            for key, color in colors.items():
11✔
1294
                if isinstance(key, int):
11✔
1295
                    raise TypeError(
×
1296
                        "Cannot use IDs in colors dict for auto legend.")
1297
                elif not isinstance(key, expected_key_type):
11✔
1298
                    raise TypeError(
×
1299
                        "Color dict key type does not match color_by")
1300

1301
                # this works whether we're doing cells or materials
1302
                label = key.name if key.name != '' else key.id
11✔
1303

1304
                # matplotlib takes RGB on 0-1 scale rather than 0-255
1305
                if len(color) == 3 and not isinstance(color, str):
11✔
1306
                    scaled_color = (
11✔
1307
                        color[0]/255, color[1]/255, color[2]/255)
1308
                else:
1309
                    scaled_color = color
11✔
1310

1311
                key_patch = mpatches.Patch(color=scaled_color, label=label)
11✔
1312
                patches.append(key_patch)
11✔
1313

1314
            axes.legend(handles=patches, **legend_kwargs)
11✔
1315

1316
        # Plot image and return the axes
1317
        if outline != 'only':
11✔
1318
            axes.imshow(img, extent=(x_min, x_max, y_min, y_max), **kwargs)
11✔
1319

1320
        if n_samples:
11✔
1321
            # Sample external source particles
1322
            particles = self.sample_external_source(n_samples)
11✔
1323

1324
            # Get points within tolerance of the slice plane
1325
            slice_value = origin[z]
11✔
1326
            xs = []
11✔
1327
            ys = []
11✔
1328
            tol = plane_tolerance
11✔
1329
            for particle in particles:
11✔
1330
                if (slice_value - tol < particle.r[z] < slice_value + tol):
11✔
1331
                    xs.append(particle.r[x] * axis_scaling_factor[axis_units])
11✔
1332
                    ys.append(particle.r[y] * axis_scaling_factor[axis_units])
11✔
1333
            axes.scatter(xs, ys, **source_kwargs)
11✔
1334

1335
        return axes
11✔
1336

1337
    def sample_external_source(
11✔
1338
        self,
1339
        n_samples: int = 1000,
1340
        prn_seed: int | None = None,
1341
        as_array: bool = False,
1342
        **init_kwargs
1343
    ) -> openmc.ParticleList | np.ndarray:
1344
        """Sample external source and return source particles.
1345

1346
        .. versionadded:: 0.15.1
1347

1348
        Parameters
1349
        ----------
1350
        n_samples : int
1351
            Number of samples
1352
        prn_seed : int
1353
            Pseudorandom number generator (PRNG) seed; if None, one will be
1354
            generated randomly.
1355
        as_array : bool
1356
            If True, return a numpy structured array instead of a
1357
            :class:`~openmc.ParticleList`.
1358
        **init_kwargs
1359
            Keyword arguments passed to :func:`openmc.lib.init`
1360

1361
        Returns
1362
        -------
1363
        openmc.ParticleList or numpy.ndarray
1364
            List of sampled source particles, or a structured array when
1365
            *as_array* is True.
1366
        """
1367
        import openmc.lib
11✔
1368

1369
        # Silence output by default. Also set arguments to start in volume
1370
        # calculation mode to avoid loading cross sections
1371
        init_kwargs.setdefault('output', False)
11✔
1372
        init_kwargs.setdefault('args', ['-c'])
11✔
1373

1374
        with openmc.lib.TemporarySession(self, **init_kwargs):
11✔
1375
            return openmc.lib.sample_external_source(
11✔
1376
                n_samples=n_samples, prn_seed=prn_seed, as_array=as_array
1377
            )
1378

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

1382
        Parameters
1383
        ----------
1384
        statepoint : PathLike or openmc.StatePoint
1385
            Statepoint file used to update tally results
1386
        """
1387
        self.tallies.add_results(statepoint)
11✔
1388

1389
    def plot_geometry(
11✔
1390
        self,
1391
        output: bool = True,
1392
        cwd: PathLike = ".",
1393
        openmc_exec: PathLike = "openmc",
1394
        export_model_xml: bool = True,
1395
        **export_kwargs,
1396
    ):
1397
        """Creates plot images as specified by the Model.plots attribute
1398

1399
        .. versionadded:: 0.13.0
1400

1401
        Parameters
1402
        ----------
1403
        output : bool, optional
1404
            Capture OpenMC output from standard out
1405
        cwd : PathLike, optional
1406
            Path to working directory to run in. Defaults to the current
1407
            working directory.
1408
        openmc_exec : PathLike, optional
1409
            Path to OpenMC executable. Defaults to 'openmc'.
1410
            This only applies to the case when not using the C API.
1411
        export_model_xml : bool, optional
1412
            Exports a single model.xml file rather than separate files. Defaults
1413
            to True.
1414
        **export_kwargs
1415
            Keyword arguments passed to either :meth:`Model.export_to_model_xml`
1416
            or :meth:`Model.export_to_xml`.
1417

1418
        """
1419

1420
        if len(self.plots) == 0:
11✔
1421
            # Then there is no volume calculation specified
1422
            raise ValueError("The Model.plots attribute must be specified "
×
1423
                             "before executing this method!")
1424

1425
        with change_directory(cwd):
11✔
1426
            if self.is_initialized:
11✔
1427
                # Compute the volumes
1428
                openmc.lib.plot_geometry(output)
11✔
1429
            else:
1430
                if export_model_xml:
11✔
1431
                    self.export_to_model_xml(**export_kwargs)
11✔
1432
                else:
1433
                    self.export_to_xml(**export_kwargs)
×
1434
                path_input = export_kwargs.get("path", None)
11✔
1435
                openmc.plot_geometry(output=output, openmc_exec=openmc_exec,
11✔
1436
                                     path_input=path_input)
1437

1438
    def _change_py_lib_attribs(
11✔
1439
        self,
1440
        names_or_ids: Iterable[str] | Iterable[int],
1441
        value: float | Iterable[float],
1442
        obj_type: str,
1443
        attrib_name: str,
1444
        density_units: str = "atom/b-cm",
1445
    ):
1446
        # Method to do the same work whether it is a cell or material and
1447
        # a temperature or volume
1448
        check_type('names_or_ids', names_or_ids, Iterable, (Integral, str))
11✔
1449
        check_type('obj_type', obj_type, str)
11✔
1450
        obj_type = obj_type.lower()
11✔
1451
        check_value('obj_type', obj_type, ('material', 'cell'))
11✔
1452
        check_value('attrib_name', attrib_name,
11✔
1453
                    ('temperature', 'volume', 'density', 'rotation',
1454
                     'translation'))
1455
        # The C API only allows setting density units of atom/b-cm and g/cm3
1456
        check_value('density_units', density_units, ('atom/b-cm', 'g/cm3'))
11✔
1457
        # The C API has no way to set cell volume or material temperature
1458
        # so lets raise exceptions as needed
1459
        if obj_type == 'cell' and attrib_name == 'volume':
11✔
1460
            raise NotImplementedError(
×
1461
                'Setting a Cell volume is not supported!')
1462
        if obj_type == 'material' and attrib_name == 'temperature':
11✔
1463
            raise NotImplementedError(
×
1464
                'Setting a material temperature is not supported!')
1465

1466
        # And some items just dont make sense
1467
        if obj_type == 'cell' and attrib_name == 'density':
11✔
1468
            raise ValueError('Cannot set a Cell density!')
×
1469
        if obj_type == 'material' and attrib_name in ('rotation',
11✔
1470
                                                      'translation'):
1471
            raise ValueError('Cannot set a material rotation/translation!')
×
1472

1473
        # Set the
1474
        if obj_type == 'cell':
11✔
1475
            by_name = self._cells_by_name
11✔
1476
            by_id = self._cells_by_id
11✔
1477
            if self.is_initialized:
11✔
1478
                obj_by_id = openmc.lib.cells
11✔
1479
        else:
1480
            by_name = self._materials_by_name
11✔
1481
            by_id = self._materials_by_id
11✔
1482
            if self.is_initialized:
11✔
1483
                obj_by_id = openmc.lib.materials
11✔
1484
        # Get the list of ids to use if converting from names and accepting
1485
        # only values that have actual ids
1486
        ids = []
11✔
1487
        for name_or_id in names_or_ids:
11✔
1488
            if isinstance(name_or_id, Integral):
11✔
1489
                if name_or_id in by_id:
11✔
1490
                    ids.append(int(name_or_id))
11✔
1491
                else:
1492
                    cap_obj = obj_type.capitalize()
11✔
1493
                    msg = f'{cap_obj} ID {name_or_id} " \
11✔
1494
                        "is not present in the model!'
1495
                    raise InvalidIDError(msg)
11✔
1496
            elif isinstance(name_or_id, str):
11✔
1497
                if name_or_id in by_name:
11✔
1498
                    # Then by_name[name_or_id] is a list so we need to add all
1499
                    # entries
1500
                    ids.extend([obj.id for obj in by_name[name_or_id]])
11✔
1501
                else:
1502
                    cap_obj = obj_type.capitalize()
11✔
1503
                    msg = f'{cap_obj} {name_or_id} " \
11✔
1504
                        "is not present in the model!'
1505
                    raise InvalidIDError(msg)
11✔
1506

1507
        # Now perform the change to both python and C API
1508
        for id_ in ids:
11✔
1509
            obj = by_id[id_]
11✔
1510
            if attrib_name == 'density':
11✔
1511
                obj.set_density(density_units, value)
11✔
1512
            else:
1513
                setattr(obj, attrib_name, value)
11✔
1514
            # Next lets keep what is in C API memory up to date as well
1515
            if self.is_initialized:
11✔
1516
                lib_obj = obj_by_id[id_]
11✔
1517
                if attrib_name == 'density':
11✔
1518
                    lib_obj.set_density(value, density_units)
11✔
1519
                elif attrib_name == 'temperature':
11✔
1520
                    lib_obj.set_temperature(value)
11✔
1521
                else:
1522
                    setattr(lib_obj, attrib_name, value)
11✔
1523

1524
    def rotate_cells(
11✔
1525
        self, names_or_ids: Iterable[str] | Iterable[int], vector: Iterable[float]
1526
    ):
1527
        """Rotate the identified cell(s) by the specified rotation vector.
1528
        The rotation is only applied to cells filled with a universe.
1529

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

1533
        .. versionadded:: 0.13.0
1534

1535
        Parameters
1536
        ----------
1537
        names_or_ids : Iterable of str or int
1538
            The cell names (if str) or id (if int) that are to be translated
1539
            or rotated. This parameter can include a mix of names and ids.
1540
        vector : Iterable of float
1541
            The rotation vector of length 3 to apply. This array specifies the
1542
            angles in degrees about the x, y, and z axes, respectively.
1543

1544
        """
1545

1546
        self._change_py_lib_attribs(names_or_ids, vector, 'cell', 'rotation')
11✔
1547

1548
    def translate_cells(
11✔
1549
        self, names_or_ids: Iterable[str] | Iterable[int], vector: Iterable[float]
1550
    ):
1551
        """Translate the identified cell(s) by the specified translation vector.
1552
        The translation is only applied to cells filled with a universe.
1553

1554
        .. note:: If applying this change to a name that is not unique, then
1555
                  the change will be applied to all objects of that name.
1556

1557
        .. versionadded:: 0.13.0
1558

1559
        Parameters
1560
        ----------
1561
        names_or_ids : Iterable of str or int
1562
            The cell names (if str) or id (if int) that are to be translated
1563
            or rotated. This parameter can include a mix of names and ids.
1564
        vector : Iterable of float
1565
            The translation vector of length 3 to apply. This array specifies
1566
            the x, y, and z dimensions of the translation.
1567

1568
        """
1569

1570
        self._change_py_lib_attribs(names_or_ids, vector, 'cell',
11✔
1571
                                    'translation')
1572

1573
    def update_densities(
11✔
1574
        self,
1575
        names_or_ids: Iterable[str] | Iterable[int],
1576
        density: float,
1577
        density_units: str = "atom/b-cm",
1578
    ):
1579
        """Update the density of a given set of materials to a new value
1580

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

1584
        .. versionadded:: 0.13.0
1585

1586
        Parameters
1587
        ----------
1588
        names_or_ids : Iterable of str or int
1589
            The material names (if str) or id (if int) that are to be updated.
1590
            This parameter can include a mix of names and ids.
1591
        density : float
1592
            The density to apply in the units specified by `density_units`
1593
        density_units : {'atom/b-cm', 'g/cm3'}, optional
1594
            Units for `density`. Defaults to 'atom/b-cm'
1595

1596
        """
1597

1598
        self._change_py_lib_attribs(names_or_ids, density, 'material',
11✔
1599
                                    'density', density_units)
1600

1601
    def update_cell_temperatures(
11✔
1602
        self, names_or_ids: Iterable[str] | Iterable[int], temperature: float
1603
    ):
1604
        """Update the temperature of a set of cells to the given value
1605

1606
        .. note:: If applying this change to a name that is not unique, then
1607
                  the change will be applied to all objects of that name.
1608

1609
        .. versionadded:: 0.13.0
1610

1611
        Parameters
1612
        ----------
1613
        names_or_ids : Iterable of str or int
1614
            The cell names (if str) or id (if int) that are to be updated.
1615
            This parameter can include a mix of names and ids.
1616
        temperature : float
1617
            The temperature to apply in units of Kelvin
1618

1619
        """
1620

1621
        self._change_py_lib_attribs(names_or_ids, temperature, 'cell',
11✔
1622
                                    'temperature')
1623

1624
    def update_material_volumes(
11✔
1625
        self, names_or_ids: Iterable[str] | Iterable[int], volume: float
1626
    ):
1627
        """Update the volume of a set of materials to the given value
1628

1629
        .. note:: If applying this change to a name that is not unique, then
1630
                  the change will be applied to all objects of that name.
1631

1632
        .. versionadded:: 0.13.0
1633

1634
        Parameters
1635
        ----------
1636
        names_or_ids : Iterable of str or int
1637
            The material names (if str) or id (if int) that are to be updated.
1638
            This parameter can include a mix of names and ids.
1639
        volume : float
1640
            The volume to apply in units of cm^3
1641

1642
        """
1643

1644
        self._change_py_lib_attribs(names_or_ids, volume, 'material', 'volume')
11✔
1645

1646
    def differentiate_depletable_mats(self, diff_volume_method: str = None):
11✔
1647
        """Assign distribmats for each depletable material
1648

1649
        .. versionadded:: 0.14.0
1650

1651
        .. versionchanged:: 0.15.1
1652
            diff_volume_method default is None, do not set volumes on the new
1653
            material ovjects. Is now a convenience method for
1654
            differentiate_mats(diff_volume_method, depletable_only=True)
1655

1656
        Parameters
1657
        ----------
1658
        diff_volume_method : str
1659
            Specifies how the volumes of the new materials should be found.
1660
            - None: Do not assign volumes to the new materials (Default)
1661
            - 'divide equally': Divide the original material volume equally between the new materials
1662
            - 'match cell': Set the volume of the material to the volume of the cell they fill
1663
        """
1664
        self.differentiate_mats(diff_volume_method, depletable_only=True)
11✔
1665

1666
    def differentiate_mats(self, diff_volume_method: str = None, depletable_only: bool = True):
11✔
1667
        """Assign distribmats for each material
1668

1669
        .. versionadded:: 0.15.1
1670

1671
        Parameters
1672
        ----------
1673
        diff_volume_method : str
1674
            Specifies how the volumes of the new materials should be found.
1675
            - None: Do not assign volumes to the new materials (Default)
1676
            - 'divide equally': Divide the original material volume equally between the new materials
1677
            - 'match cell': Set the volume of the material to the volume of the cell they fill
1678
        depletable_only : bool
1679
            Default is True, only depletable materials will be differentiated. If False, all materials will be
1680
            differentiated.
1681
        """
1682
        check_value('volume differentiation method', diff_volume_method, ("divide equally", "match cell", None))
11✔
1683

1684
        # Count the number of instances for each cell and material
1685
        self.geometry.determine_paths(instances_only=True)
11✔
1686

1687
        # Get list of materials
1688
        if self.materials:
11✔
1689
            materials = self.materials
11✔
1690
        else:
1691
            materials = list(self.geometry.get_all_materials().values())
×
1692

1693
        # Find all or depletable_only materials which have multiple instance
1694
        distribmats = set()
11✔
1695
        for mat in materials:
11✔
1696
            # Differentiate all materials with multiple instances
1697
            diff_mat = mat.num_instances > 1
11✔
1698
            # If depletable_only is True, differentiate only depletable materials
1699
            if depletable_only:
11✔
1700
                diff_mat = diff_mat and mat.depletable
11✔
1701
            if diff_mat:
11✔
1702
                # Assign volumes to the materials according to requirements
1703
                if diff_volume_method == "divide equally":
11✔
1704
                    if mat.volume is None:
11✔
1705
                        raise RuntimeError(
×
1706
                            "Volume not specified for "
1707
                            f"material with ID={mat.id}.")
1708
                    else:
1709
                        mat.volume /= mat.num_instances
11✔
1710
                elif diff_volume_method == "match cell":
11✔
1711
                    for cell in self.geometry.get_all_material_cells().values():
11✔
1712
                        if cell.fill == mat:
11✔
1713
                            if not cell.volume:
11✔
1714
                                raise ValueError(
×
1715
                                    f"Volume of cell ID={cell.id} not specified. "
1716
                                    "Set volumes of cells prior to using "
1717
                                    "diff_volume_method='match cell'.")
1718
                distribmats.add(mat)
11✔
1719

1720
        if not distribmats:
11✔
1721
            return
×
1722

1723
        # Assign distribmats to cells
1724
        for cell in self.geometry.get_all_material_cells().values():
11✔
1725
            if cell.fill in distribmats:
11✔
1726
                mat = cell.fill
11✔
1727

1728
                # Clone materials
1729
                if cell.num_instances > 1:
11✔
1730
                    cell.fill = [mat.clone() for _ in range(cell.num_instances)]
11✔
1731
                else:
1732
                    cell.fill = mat.clone()
11✔
1733

1734
                # For 'match cell', assign volumes based on the cells
1735
                if diff_volume_method == 'match cell':
11✔
1736
                    if cell.fill_type == 'distribmat':
11✔
1737
                        for clone_mat in cell.fill:
×
1738
                            clone_mat.volume = cell.volume
×
1739
                    else:
1740
                        cell.fill.volume = cell.volume
11✔
1741

1742
        if self.materials is not None:
11✔
1743
            self.materials = openmc.Materials(
11✔
1744
                self.geometry.get_all_materials().values()
1745
            )
1746

1747
    @staticmethod
11✔
1748
    def _auto_generate_mgxs_lib(
11✔
1749
        model: openmc.model.model,
1750
        groups: openmc.mgxs.EnergyGroups,
1751
        correction: str | none,
1752
        directory: pathlike,
1753
    ) -> openmc.mgxs.Library:
1754
        """
1755
        Automatically generate a multi-group cross section libray from a model
1756
        with the specified group structure.
1757

1758
        Parameters
1759
        ----------
1760
        groups : openmc.mgxs.EnergyGroups
1761
            Energy group structure for the MGXS.
1762
        nparticles : int
1763
            Number of particles to simulate per batch when generating MGXS.
1764
        mgxs_path : str
1765
            Filename for the MGXS HDF5 file.
1766
        correction : str
1767
            Transport correction to apply to the MGXS. Options are None and
1768
            "P0".
1769
        directory : str
1770
            Directory to run the simulation in, so as to contain XML files.
1771

1772
        Returns
1773
        -------
1774
        mgxs_lib : openmc.mgxs.Library
1775
            OpenMC MGXS Library object
1776
        """
1777

1778
        # Initialize MGXS library with a finished OpenMC geometry object
1779
        mgxs_lib = openmc.mgxs.Library(model.geometry)
11✔
1780

1781
        # Pick energy group structure
1782
        mgxs_lib.energy_groups = groups
11✔
1783

1784
        # Disable transport correction
1785
        mgxs_lib.correction = correction
11✔
1786

1787
        # Specify needed cross sections for random ray
1788
        if correction == 'P0':
11✔
1789
            mgxs_lib.mgxs_types = [
11✔
1790
                'nu-transport', 'absorption', 'nu-fission', 'fission',
1791
                'consistent nu-scatter matrix', 'multiplicity matrix', 'chi',
1792
                'kappa-fission'
1793
            ]
1794
        elif correction is None:
11✔
1795
            mgxs_lib.mgxs_types = [
11✔
1796
                'total', 'absorption', 'nu-fission', 'fission',
1797
                'consistent nu-scatter matrix', 'multiplicity matrix', 'chi',
1798
                'kappa-fission'
1799
            ]
1800

1801
        # Specify a "material" domain type for the cross section tally filters
1802
        mgxs_lib.domain_type = "material"
11✔
1803

1804
        # Specify the domains over which to compute multi-group cross sections
1805
        mgxs_lib.domains = model.geometry.get_all_materials().values()
11✔
1806

1807
        # Do not compute cross sections on a nuclide-by-nuclide basis
1808
        mgxs_lib.by_nuclide = False
11✔
1809

1810
        # Check the library - if no errors are raised, then the library is satisfactory.
1811
        mgxs_lib.check_library_for_openmc_mgxs()
11✔
1812

1813
        # Construct all tallies needed for the multi-group cross section library
1814
        mgxs_lib.build_library()
11✔
1815

1816
        # Create a "tallies.xml" file for the MGXS Library
1817
        mgxs_lib.add_to_tallies(model.tallies, merge=True)
11✔
1818

1819
        # Run
1820
        statepoint_filename = model.run(cwd=directory)
11✔
1821

1822
        # Load MGXS
1823
        with openmc.StatePoint(statepoint_filename) as sp:
11✔
1824
            mgxs_lib.load_from_statepoint(sp)
11✔
1825

1826
        return mgxs_lib
11✔
1827

1828
    def _create_mgxs_sources(
11✔
1829
        self,
1830
        groups: openmc.mgxs.EnergyGroups,
1831
        spatial_dist: openmc.stats.Spatial,
1832
        source_energy: openmc.stats.Univariate | None = None,
1833
    ) -> list[openmc.IndependentSource]:
1834
        """Create a list of independent sources to use with MGXS generation.
1835

1836
        Note that in all cases, a discrete source that is uniform over all
1837
        energy groups is created (strength = 0.01) to ensure that total cross
1838
        sections are generated for all energy groups. In the case that the user
1839
        has provided a source_energy distribution as an argument, an additional
1840
        source (strength = 0.99) is created using that energy distribution. If
1841
        the user has not provided a source_energy distribution, but the model
1842
        has sources defined, and all of those sources are of IndependentSource
1843
        type, then additional sources are created based on the model's existing
1844
        sources, keeping their energy distributions but replacing their
1845
        spatial/angular distributions, with their combined strength being 0.99.
1846
        If the user has not provided a source_energy distribution and no sources
1847
        are defined on the model and the run mode is 'eigenvalue', then a
1848
        default Watt spectrum source (strength = 0.99) is added.
1849

1850
        Parameters
1851
        ----------
1852
        groups : openmc.mgxs.EnergyGroups
1853
            Energy group structure for the MGXS.
1854
        spatial_dist : openmc.stats.Spatial
1855
            Spatial distribution to use for all sources.
1856
        source_energy : openmc.stats.Univariate, optional
1857
            Energy distribution to use when generating MGXS data, replacing any
1858
            existing sources in the model.
1859

1860
        Returns
1861
        -------
1862
        list[openmc.IndependentSource]
1863
            A list of independent sources to use for MGXS generation.
1864
        """
1865
        # Make a discrete source that is uniform over the bins of the group structure
1866
        midpoints = []
11✔
1867
        strengths = []
11✔
1868
        for i in range(groups.num_groups):
11✔
1869
            bounds = groups.get_group_bounds(i+1)
11✔
1870
            midpoints.append((bounds[0] + bounds[1]) / 2.0)
11✔
1871
            strengths.append(1.0)
11✔
1872

1873
        uniform_energy = openmc.stats.Discrete(x=midpoints, p=strengths)
11✔
1874
        uniform_distribution = openmc.IndependentSource(spatial_dist, energy=uniform_energy, strength=0.01)
11✔
1875
        sources = [uniform_distribution]
11✔
1876

1877
        # If the user provided an energy distribution, use that
1878
        if source_energy is not None:
11✔
1879
            user_energy = openmc.IndependentSource(
11✔
1880
                space=spatial_dist, energy=source_energy, strength=0.99)
1881
            sources.append(user_energy)
11✔
1882

1883
        # If the user did not provide an energy distribution, create sources
1884
        # based on what is in their model, keeping the energy spectrum but
1885
        # replacing the spatial/angular distributions. We only do this if ALL
1886
        # sources are of IndependentSource type, as we can't pull the energy
1887
        # distribution from e.g. CompiledSource or FileSource types.
1888
        else:
1889
            if self.settings.source is not None:
11✔
1890
                for src in self.settings.source:
11✔
1891
                    if not isinstance(src, openmc.IndependentSource):
11✔
1892
                        break
×
1893
                else:
1894
                    n_user_sources = len(self.settings.source)
11✔
1895
                    for src in self.settings.source:
11✔
1896
                        # Create a new IndependentSource with adjusted strength, space, and angle
1897
                        user_source = openmc.IndependentSource(
11✔
1898
                            space=spatial_dist,
1899
                            energy=src.energy,
1900
                            strength=0.99 / n_user_sources
1901
                        )
1902
                        sources.append(user_source)
11✔
1903
            else:
1904
                # No user sources defined. If we are in eigenvalue mode, then use the default Watt spectrum.
1905
                if self.settings.run_mode == 'eigenvalue':
×
1906
                    watt_energy = openmc.stats.Watt()
×
1907
                    watt_source = openmc.IndependentSource(
×
1908
                        space=spatial_dist, energy=watt_energy, strength=0.99)
1909
                    sources.append(watt_source)
×
1910

1911
        return sources
11✔
1912

1913
    @staticmethod
11✔
1914
    def _isothermal_infinite_media_mgxs(
11✔
1915
        material: openmc.Material,
1916
        groups: openmc.mgxs.EnergyGroups,
1917
        nparticles: int,
1918
        correction: str | None,
1919
        directory: PathLike,
1920
        source: openmc.IndependentSource,
1921
        temperature_settings: dict,
1922
        temperature: float | None = None,
1923
    ) -> openmc.XSdata:
1924
        """Generate a single MGXS set for one material, where the geometry is an
1925
        infinite medium composed of that material at an isothermal temperature value.
1926

1927
        Parameters
1928
        ----------
1929
        material : openmc.Material
1930
            The material to generate MGXS for
1931
        groups : openmc.mgxs.EnergyGroups
1932
            Energy group structure for the MGXS.
1933
        nparticles : int
1934
            Number of particles to simulate per batch when generating MGXS.
1935
        correction : str
1936
            Transport correction to apply to the MGXS. Options are None and
1937
            "P0".
1938
        directory : str
1939
            Directory to run the simulation in, so as to contain XML files.
1940
        source : openmc.IndependentSource
1941
            Source to use when generating MGXS.
1942
        temperature_settings : dict
1943
            A dictionary of temperature settings to use when generating MGXS.
1944
            Valid entries for temperature_settings are the same as the valid
1945
            entries in openmc.Settings.temperature_settings.
1946
        temperature : float, optional
1947
            The isothermal temperature value to apply to the material. If not specified,
1948
            defaults to the temperature in the material.
1949

1950
        Returns
1951
        -------
1952
        data : openmc.XSdata
1953
            The material MGXS for the given temperature isotherm.
1954
        """
1955
        model = openmc.Model()
11✔
1956

1957
        # Set materials on the model
1958
        model.materials = [material]
11✔
1959
        if temperature != None:
11✔
1960
          model.materials[-1].temperature = temperature
11✔
1961

1962
        # Settings
1963
        model.settings.batches = 100
11✔
1964
        model.settings.particles = nparticles
11✔
1965

1966
        model.settings.source = source
11✔
1967

1968
        model.settings.run_mode = 'fixed source'
11✔
1969
        model.settings.create_fission_neutrons = False
11✔
1970

1971
        model.settings.output = {'summary': True, 'tallies': False}
11✔
1972
        model.settings.temperature = temperature_settings
11✔
1973

1974
        # Geometry
1975
        box = openmc.model.RectangularPrism(
11✔
1976
            100000.0, 100000.0, boundary_type='reflective')
1977
        name = material.name
11✔
1978
        infinite_cell = openmc.Cell(name=name, fill=model.materials[-1], region=-box)
11✔
1979
        infinite_universe = openmc.Universe(name=name, cells=[infinite_cell])
11✔
1980
        model.geometry.root_universe = infinite_universe
11✔
1981

1982
        # Generate MGXS
1983
        mgxs_lib = Model._auto_generate_mgxs_lib(
11✔
1984
                model, groups, correction, directory)
1985

1986
        if temperature != None:
11✔
1987
            return mgxs_lib.get_xsdata(domain=material, xsdata_name=name,
11✔
1988
                                       temperature=temperature)
1989
        else:
1990
            return mgxs_lib.get_xsdata(domain=material, xsdata_name=name)
11✔
1991

1992
    def _generate_infinite_medium_mgxs(
11✔
1993
        self,
1994
        groups: openmc.mgxs.EnergyGroups,
1995
        nparticles: int,
1996
        mgxs_path: PathLike,
1997
        correction: str | None,
1998
        directory: PathLike,
1999
        source_energy: openmc.stats.Univariate | None = None,
2000
        temperatures: Sequence[float] | None = None,
2001
        temperature_settings: dict | None = None,
2002
    ) -> None:
2003
        """Generate a MGXS library by running multiple OpenMC simulations, each
2004
        representing an infinite medium simulation of a single isolated
2005
        material. A discrete source is used to sample particles, with an equal
2006
        strength spread across each of the energy groups. This is a highly naive
2007
        method that ignores all spatial self shielding effects and all resonance
2008
        shielding effects between materials. If temperature data points are provided,
2009
        isothermal cross sections are generated at each temperature point for
2010
        each material to build a temperature interpolation table.
2011

2012
        Note that in all cases, a discrete source that is uniform over all
2013
        energy groups is created (strength = 0.01) to ensure that total cross
2014
        sections are generated for all energy groups. In the case that the user
2015
        has provided a source_energy distribution as an argument, an additional
2016
        source (strength = 0.99) is created using that energy distribution. If
2017
        the user has not provided a source_energy distribution, but the model
2018
        has sources defined, and all of those sources are of IndependentSource
2019
        type, then additional sources are created based on the model's existing
2020
        sources, keeping their energy distributions but replacing their
2021
        spatial/angular distributions, with their combined strength being 0.99.
2022
        If the user has not provided a source_energy distribution and no sources
2023
        are defined on the model and the run mode is 'eigenvalue', then a
2024
        default Watt spectrum source (strength = 0.99) is added.
2025

2026
        Parameters
2027
        ----------
2028
        groups : openmc.mgxs.EnergyGroups
2029
            Energy group structure for the MGXS.
2030
        nparticles : int
2031
            Number of particles to simulate per batch when generating MGXS.
2032
        mgxs_path : str
2033
            Filename for the MGXS HDF5 file.
2034
        correction : str
2035
            Transport correction to apply to the MGXS. Options are None and
2036
            "P0".
2037
        directory : str
2038
            Directory to run the simulation in, so as to contain XML files.
2039
        source_energy : openmc.stats.Univariate, optional
2040
            Energy distribution to use when generating MGXS data, replacing any
2041
            existing sources in the model.
2042
        temperatures : Sequence[float], optional
2043
            A list of temperatures to generate MGXS at. Each infinite material region
2044
            is isothermal at a given temperature data point for cross
2045
            section generation.
2046
        temperature_settings : dict, optional
2047
            A dictionary of temperature settings to use when generating MGXS.
2048
            Valid entries for temperature_settings are the same as the valid
2049
            entries in openmc.Settings.temperature_settings.
2050
        """
2051

2052
        src = self._create_mgxs_sources(
11✔
2053
            groups,
2054
            spatial_dist=openmc.stats.Point(),
2055
            source_energy=source_energy
2056
        )
2057

2058
        temp_settings = {}
11✔
2059
        if temperature_settings == None:
11✔
2060
            temp_settings = self.settings.temperature
11✔
2061
        else:
2062
            temp_settings = temperature_settings
11✔
2063

2064
        if temperatures == None:
11✔
2065
            mgxs_sets = []
11✔
2066
            for material in self.materials:
11✔
2067
                xs_data = Model._isothermal_infinite_media_mgxs(
11✔
2068
                    material,
2069
                    groups,
2070
                    nparticles,
2071
                    correction,
2072
                    directory,
2073
                    src,
2074
                    temp_settings
2075
                )
2076
                mgxs_sets.append(xs_data)
11✔
2077

2078
            # Write the file to disk.
2079
            mgxs_file = openmc.MGXSLibrary(energy_groups=groups)
11✔
2080
            for mgxs_set in mgxs_sets:
11✔
2081
                mgxs_file.add_xsdata(mgxs_set)
11✔
2082
            mgxs_file.export_to_hdf5(mgxs_path)
11✔
2083
        else:
2084
            # Build a series of XSData objects, one for each isothermal temperature value.
2085
            raw_mgxs_sets = {}
11✔
2086
            for temperature in temperatures:
11✔
2087
                raw_mgxs_sets[temperature] = []
11✔
2088
                for material in self.materials:
11✔
2089
                    xs_data = Model._isothermal_infinite_media_mgxs(
11✔
2090
                        material,
2091
                        groups,
2092
                        nparticles,
2093
                        correction,
2094
                        directory,
2095
                        src,
2096
                        temp_settings,
2097
                        temperature
2098
                    )
2099
                    raw_mgxs_sets[temperature].append(xs_data)
11✔
2100

2101
            # Unpack the isothermal XSData objects and build a single XSData object per material.
2102
            mgxs_sets = []
11✔
2103
            for m in range(len(self.materials)):
11✔
2104
                mgxs_sets.append(openmc.XSdata(self.materials[m].name, groups,
11✔
2105
                                               temperatures=temperatures))
2106
                mgxs_sets[-1].order = 0
11✔
2107
                for temperature in temperatures:
11✔
2108
                    mgxs_sets[-1].add_temperature_data(raw_mgxs_sets[temperature][m])
11✔
2109

2110
            # Write the file to disk.
2111
            mgxs_file = openmc.MGXSLibrary(energy_groups=groups)
11✔
2112
            for mgxs_set in mgxs_sets:
11✔
2113
                mgxs_file.add_xsdata(mgxs_set)
11✔
2114
            mgxs_file.export_to_hdf5(mgxs_path)
11✔
2115

2116
    @staticmethod
11✔
2117
    def _create_stochastic_slab_geometry(
11✔
2118
        materials: Sequence[openmc.Material],
2119
        cell_thickness: float = 1.0,
2120
        num_repeats: int = 100,
2121
    ) -> tuple[openmc.Geometry, openmc.stats.Box]:
2122
        """Create a geometry representing a stochastic "sandwich" of materials in a
2123
        layered slab geometry. To reduce the impact of the order of materials in
2124
        the slab, the materials are applied to 'num_repeats' different randomly
2125
        positioned layers of 'cell_thickness' each.
2126

2127
        Parameters
2128
        ----------
2129
        materials : list of openmc.Material
2130
            List of materials to assign. Each material will appear exactly num_repeats times,
2131
            then the ordering is randomly shuffled.
2132
        cell_thickness : float, optional
2133
            Thickness of each lattice cell in x (default 1.0 cm).
2134
        num_repeats : int, optional
2135
            Number of repeats for each material (default 100).
2136

2137
        Returns
2138
        -------
2139
        geometry : openmc.Geometry
2140
            The constructed geometry.
2141
        box : openmc.stats.Box
2142
            A spatial sampling distribution covering the full slab domain.
2143
        """
2144
        if not materials:
11✔
2145
            raise ValueError("At least one material must be provided.")
×
2146

2147
        num_materials = len(materials)
11✔
2148
        total_cells = num_materials * num_repeats
11✔
2149
        total_width = total_cells * cell_thickness
11✔
2150

2151
        # Generate an infinite cell/universe for each material
2152
        universes = []
11✔
2153
        for i in range(num_materials):
11✔
2154
            cell = openmc.Cell(fill=materials[i])
11✔
2155
            universes.append(openmc.Universe(cells=[cell]))
11✔
2156

2157
        # Make a list of randomized material idx assignments for the stochastic slab
2158
        assignments = list(range(num_materials)) * num_repeats
11✔
2159
        random.seed(42)
11✔
2160
        random.shuffle(assignments)
11✔
2161

2162
        # Create a list of the (randomized) universe assignments to be used
2163
        # when defining the problem lattice.
2164
        lattice_entries = [universes[m] for m in assignments]
11✔
2165

2166
        # Create the RectLattice for the 1D material variation in x.
2167
        lattice = openmc.RectLattice()
11✔
2168
        lattice.pitch = (cell_thickness, total_width, total_width)
11✔
2169
        lattice.lower_left = (0.0, 0.0, 0.0)
11✔
2170
        lattice.universes = [[lattice_entries]]
11✔
2171
        lattice.outer = universes[0]
11✔
2172

2173
        # Define the six outer surfaces with reflective boundary conditions
2174
        rpp = openmc.model.RectangularParallelepiped(
11✔
2175
            0.0, total_width, 0.0, total_width, 0.0, total_width,
2176
            boundary_type='reflective'
2177
        )
2178

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

2182
        # Build the geometry
2183
        geometry = openmc.Geometry([outer_cell])
11✔
2184

2185
        # Define the spatial distribution that covers the full cubic domain
2186
        box = openmc.stats.Box(*outer_cell.bounding_box)
11✔
2187

2188
        return geometry, box
11✔
2189

2190
    @staticmethod
11✔
2191
    def _isothermal_stochastic_slab_mgxs(
11✔
2192
        stoch_geom: openmc.Geometry,
2193
        groups: openmc.mgxs.EnergyGroups,
2194
        nparticles: int,
2195
        correction: str | None,
2196
        directory: PathLike,
2197
        source: openmc.IndependentSource,
2198
        temperature_settings: dict,
2199
        temperature: float | None = None,
2200
    ) -> dict[str, openmc.XSdata]:
2201
        """Generate MGXS assuming a stochastic "sandwich" of materials in a layered
2202
        slab geometry. If a temperature is specified, all materials in the slab have
2203
        their temperatures set to be isothermal at this temperature.
2204

2205
        Parameters
2206
        ----------
2207
        stoch_geom : openmc.Geometry
2208
            The stochastic slab geometry.
2209
        groups : openmc.mgxs.EnergyGroups
2210
            Energy group structure for the MGXS.
2211
        nparticles : int
2212
            Number of particles to simulate per batch when generating MGXS.
2213
        correction : str
2214
            Transport correction to apply to the MGXS. Options are None and
2215
            "P0".
2216
        directory : str
2217
            Directory to run the simulation in, so as to contain XML files.
2218
        source : openmc.IndependentSource
2219
            Source to use when generating MGXS.
2220
        temperature_settings : dict
2221
            A dictionary of temperature settings to use when generating MGXS.
2222
            Valid entries for temperature_settings are the same as the valid
2223
            entries in openmc.Settings.temperature_settings.
2224
        temperature : float, optional
2225
            The isothermal temperature value to apply to the materials in the
2226
            slab. If not specified, defaults to the temperature in the materials.
2227

2228
        Returns
2229
        -------
2230
        data : dict[str, openmc.XSdata]
2231
            A dictionary where the key is the name of the material and the value is the isothermal MGXS.
2232
        """
2233

2234
        model = openmc.Model()
11✔
2235
        model.geometry = stoch_geom
11✔
2236

2237
        if temperature != None:
11✔
2238
            for material in model.geometry.get_all_materials().values():
11✔
2239
                material.temperature = temperature
11✔
2240

2241
        # Settings
2242
        model.settings.batches = 200
11✔
2243
        model.settings.inactive = 100
11✔
2244
        model.settings.particles = nparticles
11✔
2245
        model.settings.output = {'summary': True, 'tallies': False}
11✔
2246
        model.settings.temperature = temperature_settings
11✔
2247

2248
        # Define the sources
2249
        model.settings.source = source
11✔
2250

2251
        model.settings.run_mode = 'fixed source'
11✔
2252
        model.settings.create_fission_neutrons = False
11✔
2253

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

2256
        # Generate MGXS
2257
        mgxs_lib = Model._auto_generate_mgxs_lib(
11✔
2258
                model, groups, correction, directory)
2259

2260
        # Fetch all of the isothermal results.
2261
        if temperature != None:
11✔
2262
            return {
11✔
2263
                mat.name : mgxs_lib.get_xsdata(domain=mat, xsdata_name=mat.name,
2264
                                               temperature=temperature)
2265
                    for mat in mgxs_lib.domains
2266
            }
2267
        else:
2268
            return {
11✔
2269
                mat.name : mgxs_lib.get_xsdata(domain=mat, xsdata_name=mat.name)
2270
                    for mat in mgxs_lib.domains
2271
            }
2272

2273
    def _generate_stochastic_slab_mgxs(
11✔
2274
        self,
2275
        groups: openmc.mgxs.EnergyGroups,
2276
        nparticles: int,
2277
        mgxs_path: PathLike,
2278
        correction: str | None,
2279
        directory: PathLike,
2280
        source_energy: openmc.stats.Univariate | None = None,
2281
        temperatures: Sequence[float] | None = None,
2282
        temperature_settings: dict | None = None,
2283
    ) -> None:
2284
        """Generate MGXS assuming a stochastic "sandwich" of materials in a layered
2285
        slab geometry. While geometry-specific spatial shielding effects are not
2286
        captured, this method can be useful when the geometry has materials only
2287
        found far from the source region that the "material_wise" method would
2288
        not be capable of generating cross sections for. Conversely, this method
2289
        will generate cross sections for all materials in the problem regardless
2290
        of type. If this is a fixed source problem, a discrete source is used to
2291
        sample particles, with an equal strength spread across each of the
2292
        energy groups. If temperature data points are provided,
2293
        isothermal cross sections are generated at each temperature point for
2294
        the stochastic slab to build a temperature interpolation table.
2295

2296
        Parameters
2297
        ----------
2298
        groups : openmc.mgxs.EnergyGroups
2299
            Energy group structure for the MGXS.
2300
        nparticles : int
2301
            Number of particles to simulate per batch when generating MGXS.
2302
        mgxs_path : str
2303
            Filename for the MGXS HDF5 file.
2304
        correction : str
2305
            Transport correction to apply to the MGXS. Options are None and
2306
            "P0".
2307
        directory : str
2308
            Directory to run the simulation in, so as to contain XML files.
2309
        source_energy : openmc.stats.Univariate, optional
2310
            Energy distribution to use when generating MGXS data, replacing any
2311
            existing sources in the model. In all cases, a discrete source that
2312
            is uniform over all energy groups is created (strength = 0.01) to
2313
            ensure that total cross sections are generated for all energy
2314
            groups. In the case that the user has provided a source_energy
2315
            distribution as an argument, an additional source (strength = 0.99)
2316
            is created using that energy distribution. If the user has not
2317
            provided a source_energy distribution, but the model has sources
2318
            defined, and all of those sources are of IndependentSource type,
2319
            then additional sources are created based on the model's existing
2320
            sources, keeping their energy distributions but replacing their
2321
            spatial/angular distributions, with their combined strength being
2322
            0.99. If the user has not provided a source_energy distribution and
2323
            no sources are defined on the model and the run mode is
2324
            'eigenvalue', then a default Watt spectrum source (strength = 0.99)
2325
            is added.
2326
        temperatures : Sequence[float], optional
2327
            A list of temperatures to generate MGXS at. Each infinite material region
2328
            is isothermal at a given temperature data point for cross
2329
            section generation.
2330
        temperature_settings : dict, optional
2331
            A dictionary of temperature settings to use when generating MGXS.
2332
            Valid entries for temperature_settings are the same as the valid
2333
            entries in openmc.Settings.temperature_settings.
2334
        """
2335

2336
        # Stochastic slab geometry
2337
        geo, spatial_distribution = Model._create_stochastic_slab_geometry(
11✔
2338
            self.materials)
2339

2340
        src = self._create_mgxs_sources(
11✔
2341
            groups,
2342
            spatial_dist=spatial_distribution,
2343
            source_energy=source_energy
2344
        )
2345

2346
        temp_settings = {}
11✔
2347
        if temperature_settings == None:
11✔
2348
            temp_settings = self.settings.temperature
11✔
2349
        else:
2350
            temp_settings = temperature_settings
11✔
2351

2352
        if temperatures == None:
11✔
2353
            mgxs_sets = Model._isothermal_stochastic_slab_mgxs(
11✔
2354
                geo,
2355
                groups,
2356
                nparticles,
2357
                correction,
2358
                directory,
2359
                src,
2360
                temp_settings
2361
            ).values()
2362

2363
            # Write the file to disk.
2364
            mgxs_file = openmc.MGXSLibrary(energy_groups=groups)
11✔
2365
            for mgxs_set in mgxs_sets:
11✔
2366
                mgxs_file.add_xsdata(mgxs_set)
11✔
2367
            mgxs_file.export_to_hdf5(mgxs_path)
11✔
2368
        else:
2369
            # Build a series of XSData objects, one for each isothermal temperature value.
2370
            raw_mgxs_sets = {}
11✔
2371
            for temperature in temperatures:
11✔
2372
                raw_mgxs_sets[temperature] = Model._isothermal_stochastic_slab_mgxs(
11✔
2373
                    geo,
2374
                    groups,
2375
                    nparticles,
2376
                    correction,
2377
                    directory,
2378
                    src,
2379
                    temp_settings,
2380
                    temperature
2381
                )
2382

2383
            # Unpack the isothermal XSData objects and build a single XSData object per material.
2384
            mgxs_sets = []
11✔
2385
            for mat in self.materials:
11✔
2386
                mgxs_sets.append(openmc.XSdata(mat.name, groups, temperatures=temperatures))
11✔
2387
                mgxs_sets[-1].order = 0
11✔
2388
                for temperature in temperatures:
11✔
2389
                    mgxs_sets[-1].add_temperature_data(raw_mgxs_sets[temperature][mat.name])
11✔
2390

2391
            # Write the file to disk.
2392
            mgxs_file = openmc.MGXSLibrary(energy_groups=groups)
11✔
2393
            for mgxs_set in mgxs_sets:
11✔
2394
                mgxs_file.add_xsdata(mgxs_set)
11✔
2395
            mgxs_file.export_to_hdf5(mgxs_path)
11✔
2396

2397
    @staticmethod
11✔
2398
    def _isothermal_materialwise_mgxs(
11✔
2399
        input_model: openmc.Model,
2400
        groups: openmc.mgxs.EnergyGroups,
2401
        nparticles: int,
2402
        correction: str | None,
2403
        directory: PathLike,
2404
        temperature_settings: dict,
2405
        temperature: float | None = None,
2406
    ) -> dict[str, openmc.XSdata]:
2407
        """Generate a material-wise MGXS library for the model by running the
2408
        original continuous energy OpenMC simulation. If a temperature is
2409
        specified, each material in the input model is set to that temperature.
2410
        Otherwise, the original material temperatures are used. If temperature
2411
        data points are provided, isothermal cross sections are generated at
2412
        each temperature point for the whole model to build a temperature
2413
        interpolation table.
2414

2415
        Parameters
2416
        ----------
2417
        input_model : openmc.Model
2418
            The model to use when computing material-wise MGXS.
2419
        groups : openmc.mgxs.EnergyGroups
2420
            Energy group structure for the MGXS.
2421
        nparticles : int
2422
            Number of particles to simulate per batch when generating MGXS.
2423
        correction : str
2424
            Transport correction to apply to the MGXS. Options are None and
2425
            "P0".
2426
        directory : str
2427
            Directory to run the simulation in, so as to contain XML files.
2428
        temperature_settings : dict
2429
            A dictionary of temperature settings to use when generating MGXS.
2430
            Valid entries for temperature_settings are the same as the valid
2431
            entries in openmc.Settings.temperature_settings.
2432
        temperature : float, optional
2433
            The isothermal temperature value to apply to the materials in the
2434
            input model. If not specified, defaults to the temperatures in the
2435
            materials.
2436

2437
        Returns
2438
        -------
2439
        data : dict[str, openmc.XSdata]
2440
            A dictionary where the key is the name of the material and the value is the isothermal MGXS.
2441
        """
2442
        model = copy.deepcopy(input_model)
11✔
2443
        model.tallies = openmc.Tallies()
11✔
2444

2445
        if temperature != None:
11✔
2446
            for material in model.geometry.get_all_materials().values():
11✔
2447
                material.temperature = temperature
11✔
2448

2449
        # Settings
2450
        model.settings.batches = 200
11✔
2451
        model.settings.inactive = 100
11✔
2452
        model.settings.particles = nparticles
11✔
2453
        model.settings.output = {'summary': True, 'tallies': False}
11✔
2454
        model.settings.temperature = temperature_settings
11✔
2455

2456
        # Generate MGXS
2457
        mgxs_lib = Model._auto_generate_mgxs_lib(
11✔
2458
                model, groups, correction, directory)
2459

2460
        # Fetch all of the isothermal results.
2461
        if temperature != None:
11✔
2462
            return {
11✔
2463
                mat.name : mgxs_lib.get_xsdata(domain=mat, xsdata_name=mat.name,
2464
                                               temperature=temperature)
2465
                    for mat in mgxs_lib.domains
2466
            }
2467
        else:
2468
            return {
11✔
2469
                mat.name : mgxs_lib.get_xsdata(domain=mat, xsdata_name=mat.name)
2470
                    for mat in mgxs_lib.domains
2471
            }
2472

2473
    def _generate_material_wise_mgxs(
11✔
2474
        self,
2475
        groups: openmc.mgxs.EnergyGroups,
2476
        nparticles: int,
2477
        mgxs_path: PathLike,
2478
        correction: str | None,
2479
        directory: PathLike,
2480
        temperatures: Sequence[float] | None = None,
2481
        temperature_settings: dict | None = None,
2482
    ) -> None:
2483
        """Generate a material-wise MGXS library for the model by running the
2484
        original continuous energy OpenMC simulation of the full material
2485
        geometry and source, and tally MGXS data for each material. This method
2486
        accurately conserves reaction rates totaled over the entire simulation
2487
        domain. However, when the geometry has materials only found far from the
2488
        source region, it is possible the Monte Carlo solver may not be able to
2489
        score any tallies to these material types, thus resulting in zero cross
2490
        section values for these materials. For such cases, the "stochastic
2491
        slab" method may be more appropriate.
2492

2493
        Parameters
2494
        ----------
2495
        groups : openmc.mgxs.EnergyGroups
2496
            Energy group structure for the MGXS.
2497
        nparticles : int
2498
            Number of particles to simulate per batch when generating MGXS.
2499
        mgxs_path : PathLike
2500
            Filename for the MGXS HDF5 file.
2501
        correction : str
2502
            Transport correction to apply to the MGXS. Options are None and
2503
            "P0".
2504
        directory : PathLike
2505
            Directory to run the simulation in, so as to contain XML files.
2506
        temperatures : Sequence[float], optional
2507
            A list of temperatures to generate MGXS at. Each infinite material region
2508
            is isothermal at a given temperature data point for cross
2509
            section generation.
2510
        temperature_settings : dict, optional
2511
            A dictionary of temperature settings to use when generating MGXS.
2512
            Valid entries for temperature_settings are the same as the valid
2513
            entries in openmc.Settings.temperature_settings.
2514
        """
2515
        temp_settings = {}
11✔
2516
        if temperature_settings == None:
11✔
2517
            temp_settings = self.settings.temperature
11✔
2518
        else:
2519
            temp_settings = temperature_settings
11✔
2520

2521
        if temperatures == None:
11✔
2522
            mgxs_sets = Model._isothermal_materialwise_mgxs(
11✔
2523
                self,
2524
                groups,
2525
                nparticles,
2526
                correction,
2527
                directory,
2528
                temp_settings
2529
            ).values()
2530

2531
            # Write the file to disk.
2532
            mgxs_file = openmc.MGXSLibrary(energy_groups=groups)
11✔
2533
            for mgxs_set in mgxs_sets:
11✔
2534
                mgxs_file.add_xsdata(mgxs_set)
11✔
2535
            mgxs_file.export_to_hdf5(mgxs_path)
11✔
2536
        else:
2537
            # Build a series of XSData objects, one for each isothermal temperature value.
2538
            raw_mgxs_sets = {}
11✔
2539
            for temperature in temperatures:
11✔
2540
                raw_mgxs_sets[temperature] = Model._isothermal_materialwise_mgxs(
11✔
2541
                    self,
2542
                    groups,
2543
                    nparticles,
2544
                    correction,
2545
                    directory,
2546
                    temp_settings,
2547
                    temperature
2548
                )
2549

2550
            # Unpack the isothermal XSData objects and build a single XSData object per material.
2551
            mgxs_sets = []
11✔
2552
            for mat in self.materials:
11✔
2553
                mgxs_sets.append(openmc.XSdata(mat.name, groups, temperatures=temperatures))
11✔
2554
                mgxs_sets[-1].order = 0
11✔
2555
                for temperature in temperatures:
11✔
2556
                    mgxs_sets[-1].add_temperature_data(raw_mgxs_sets[temperature][mat.name])
11✔
2557

2558
            # Write the file to disk.
2559
            mgxs_file = openmc.MGXSLibrary(energy_groups=groups)
11✔
2560
            for mgxs_set in mgxs_sets:
11✔
2561
                mgxs_file.add_xsdata(mgxs_set)
11✔
2562
            mgxs_file.export_to_hdf5(mgxs_path)
11✔
2563

2564
    def convert_to_multigroup(
11✔
2565
        self,
2566
        method: str = "material_wise",
2567
        groups: str | Sequence[float] | openmc.mgxs.EnergyGroups = "CASMO-2",
2568
        nparticles: int = 2000,
2569
        overwrite_mgxs_library: bool = False,
2570
        mgxs_path: PathLike = "mgxs.h5",
2571
        correction: str | None = None,
2572
        source_energy: openmc.stats.Univariate | None = None,
2573
        temperatures: Sequence[float] | None = None,
2574
        temperature_settings: dict | None = None,
2575
    ):
2576
        """Convert all materials from continuous energy to multigroup.
2577

2578
        If no MGXS data library file is found, generate one using one or more
2579
        continuous energy Monte Carlo simulations.
2580

2581
        Parameters
2582
        ----------
2583
        method : {"material_wise", "stochastic_slab", "infinite_medium"}, optional
2584
            Method to generate the MGXS.
2585
        groups : openmc.mgxs.EnergyGroups, str, or sequence of float, optional
2586
            Energy group structure for the MGXS. Can be an
2587
            :class:`openmc.mgxs.EnergyGroups` object, a string name of a
2588
            predefined group structure from :data:`openmc.mgxs.GROUP_STRUCTURES`
2589
            (e.g., ``"CASMO-2"``), or a sequence of floats specifying energy
2590
            bin boundaries in eV (e.g., ``[0.0, 1e6]`` for a single group).
2591
            Defaults to ``"CASMO-2"``.
2592
        nparticles : int, optional
2593
            Number of particles to simulate per batch when generating MGXS.
2594
        overwrite_mgxs_library : bool, optional
2595
            Whether to overwrite an existing MGXS library file.
2596
        mgxs_path : str, optional
2597
            Path to the mgxs.h5 library file.
2598
        correction : str, optional
2599
            Transport correction to apply to the MGXS. Options are None and
2600
            "P0".
2601
        source_energy : openmc.stats.Univariate, optional
2602
            Energy distribution to use when generating MGXS data, replacing any
2603
            existing sources in the model. In all cases, a discrete source that
2604
            is uniform over all energy groups is created (strength = 0.01) to
2605
            ensure that total cross sections are generated for all energy
2606
            groups. In the case that the user has provided a source_energy
2607
            distribution as an argument, an additional source (strength = 0.99)
2608
            is created using that energy distribution. If the user has not
2609
            provided a source_energy distribution, but the model has sources
2610
            defined, and all of those sources are of IndependentSource type,
2611
            then additional sources are created based on the model's existing
2612
            sources, keeping their energy distributions but replacing their
2613
            spatial/angular distributions, with their combined strength being
2614
            0.99. If the user has not provided a source_energy distribution and
2615
            no sources are defined on the model and the run mode is
2616
            'eigenvalue', then a default Watt spectrum source (strength = 0.99)
2617
            is added. Note that this argument is only used when using the
2618
            "stochastic_slab" or "infinite_medium" MGXS generation methods.
2619
        temperatures : Sequence[float], optional
2620
            A list of temperatures to generate MGXS at. Each infinite material region
2621
            is isothermal at a given temperature data point for cross
2622
            section generation.
2623
        temperature_settings : dict, optional
2624
            A dictionary of temperature settings to use when generating MGXS.
2625
            Valid entries for temperature_settings are the same as the valid
2626
            entries in openmc.Settings.temperature_settings.
2627
        """
2628
        if not isinstance(groups, openmc.mgxs.EnergyGroups):
11✔
2629
            groups = openmc.mgxs.EnergyGroups(groups)
11✔
2630

2631
        # Do all work (including MGXS generation) in a temporary directory
2632
        # to avoid polluting the working directory with residual XML files
2633
        with TemporaryDirectory() as tmpdir:
11✔
2634

2635
            # Determine if there are DAGMC universes in the model. If so, we need to synchronize
2636
            # the dagmc materials with cells.
2637
            # TODO: Can this be done without having to init/finalize?
2638
            for univ in self.geometry.get_all_universes().values():
11✔
2639
                if isinstance(univ, openmc.DAGMCUniverse):
11✔
2640
                    # Initialize in stochastic volume mode (non-transport mode)
2641
                    # This mode doesn't require
2642
                    # valid transport settings like particles/batches
2643
                    original_run_mode = self.settings.run_mode
1✔
2644
                    self.settings.run_mode = 'volume'
1✔
2645
                    self.init_lib(directory=tmpdir)
1✔
2646
                    self.sync_dagmc_universes()
1✔
2647
                    self.finalize_lib()
1✔
2648
                    # Restore original run mode
2649
                    self.settings.run_mode = original_run_mode
1✔
2650
                    break
1✔
2651

2652
            # Make sure all materials have a name, and that the name is a valid HDF5
2653
            # dataset name
2654
            for material in self.materials:
11✔
2655
                if not material.name or not material.name.strip():
11✔
2656
                    material.name = f"material {material.id}"
×
2657
                material.name = re.sub(r'[^a-zA-Z0-9]', '_', material.name)
11✔
2658

2659
            # If needed, generate the needed MGXS data library file
2660
            if not Path(mgxs_path).is_file() or overwrite_mgxs_library:
11✔
2661
                if method == "infinite_medium":
11✔
2662
                    self._generate_infinite_medium_mgxs(
11✔
2663
                        groups, nparticles, mgxs_path, correction, tmpdir, source_energy,
2664
                        temperatures, temperature_settings)
2665
                elif method == "material_wise":
11✔
2666
                    self._generate_material_wise_mgxs(
11✔
2667
                        groups, nparticles, mgxs_path, correction, tmpdir,
2668
                        temperatures, temperature_settings)
2669
                elif method == "stochastic_slab":
11✔
2670
                    self._generate_stochastic_slab_mgxs(
11✔
2671
                        groups, nparticles, mgxs_path, correction, tmpdir, source_energy,
2672
                        temperatures, temperature_settings)
2673
                else:
2674
                    raise ValueError(
×
2675
                        f'MGXS generation method "{method}" not recognized')
2676
            else:
2677
                print(f'Existing MGXS library file "{mgxs_path}" will be used')
×
2678

2679
            # Convert all continuous energy materials to multigroup
2680
            self.materials.cross_sections = mgxs_path
11✔
2681
            for material in self.materials:
11✔
2682
                material.set_density('macro', 1.0)
11✔
2683
                material._nuclides = []
11✔
2684
                material._sab = []
11✔
2685
                material.add_macroscopic(material.name)
11✔
2686

2687
            self.settings.energy_mode = 'multi-group'
11✔
2688

2689
    def convert_to_random_ray(self):
11✔
2690
        """Convert a multigroup model to use random ray.
2691

2692
        This method determines values for the needed settings and adds them to
2693
        the settings.random_ray dictionary so as to enable random ray mode. The
2694
        settings that are populated are:
2695

2696
        - 'ray_source' (openmc.IndependentSource): Where random ray starting
2697
          points are sampled from.
2698
        - 'distance_inactive' (float): The "dead zone" distance at the beginning
2699
          of the ray.
2700
        - 'distance_active' (float): The "active" distance of the ray
2701
        - 'particles' (int): Number of rays to simulate
2702

2703
        The method will determine reasonable defaults for each of the above
2704
        variables based on analysis of the model's geometry. The function will
2705
        have no effect if the random ray dictionary is already defined in the
2706
        model settings.
2707
        """
2708
        # If the random ray dictionary is already set, don't overwrite it
2709
        if self.settings.random_ray:
11✔
2710
            warnings.warn("Random ray conversion skipped as "
×
2711
                          "settings.random_ray dictionary is already set.")
2712
            return
×
2713

2714
        if self.settings.energy_mode != 'multi-group':
11✔
2715
            raise ValueError(
×
2716
                "Random ray conversion failed: energy mode must be "
2717
                "'multi-group'. Use convert_to_multigroup() first."
2718
            )
2719

2720
        # Helper function for detecting infinity
2721
        def _replace_infinity(value):
11✔
2722
            if np.isinf(value):
11✔
2723
                return 1.0 if value > 0 else -1.0
11✔
2724
            return value
11✔
2725

2726
        # Get a bounding box for sampling rays. We can utilize the geometry's bounding box
2727
        # though for 2D problems we need to detect the infinities and replace them with an
2728
        # arbitrary finite value.
2729
        bounding_box = self.geometry.bounding_box
11✔
2730
        lower_left = [_replace_infinity(v) for v in bounding_box.lower_left]
11✔
2731
        upper_right = [_replace_infinity(v) for v in bounding_box.upper_right]
11✔
2732
        uniform_dist_ray = openmc.stats.Box(lower_left, upper_right)
11✔
2733
        rr_source = openmc.IndependentSource(space=uniform_dist_ray)
11✔
2734
        self.settings.random_ray['ray_source'] = rr_source
11✔
2735

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

2743
        self.settings.random_ray['distance_inactive'] = max_length
11✔
2744
        self.settings.random_ray['distance_active'] = 5 * max_length
11✔
2745

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

2749
    def keff_search(
11✔
2750
        self,
2751
        func: ModelModifier,
2752
        x0: float,
2753
        x1: float,
2754
        target: float = 1.0,
2755
        k_tol: float = 1e-4,
2756
        sigma_final: float = 3e-4,
2757
        p: float = 0.5,
2758
        q: float = 0.95,
2759
        memory: int = 4,
2760
        x_min: float | None = None,
2761
        x_max: float | None = None,
2762
        b0: int | None = None,
2763
        b_min: int = 20,
2764
        b_max: int | None = None,
2765
        maxiter: int = 50,
2766
        output: bool = False,
2767
        func_kwargs: dict[str, Any] | None = None,
2768
        run_kwargs: dict[str, Any] | None = None,
2769
    ) -> SearchResult:
2770
        r"""Perform a keff search on a model parametrized by a single variable.
2771

2772
        This method uses the GRsecant method described in a paper by `Price and
2773
        Roskoff <https://doi.org/10.1016/j.pnucene.2023.104731>`_. The GRsecant
2774
        method is a modification of the secant method that accounts for
2775
        uncertainties in the function evaluations. The method uses a weighted
2776
        linear fit of the most recent function evaluations to predict the next
2777
        point to evaluate. It also adaptively changes the number of batches to
2778
        meet the target uncertainty value at each iteration.
2779

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

2783
        .. math::
2784
            \sigma_{i+1} = q \sigma_\text{final} \left ( \frac{ \min \left \{
2785
            \left\lvert k_i - k_\text{target} \right\rvert : k=0,1,\dots,n
2786
            \right \} }{k_\text{tol}} \right )^p
2787

2788
        where :math:`q` is a multiplicative factor less than 1, given as the
2789
        ``sigma_factor`` parameter below.
2790

2791
        Parameters
2792
        ----------
2793
        func : ModelModifier
2794
            Function that takes the parameter to be searched and makes a
2795
            modification to the model.
2796
        x0 : float
2797
            First guess for the parameter passed to `func`
2798
        x1 : float
2799
            Second guess for the parameter passed to `func`
2800
        target : float, optional
2801
            keff value to search for
2802
        k_tol : float, optional
2803
            Stopping criterion on the function value; the absolute value must be
2804
            within ``k_tol`` of zero to be accepted.
2805
        sigma_final : float, optional
2806
            Maximum accepted k-effective uncertainty for the stopping criterion.
2807
        p : float, optional
2808
            Exponent used in the stopping criterion.
2809
        q : float, optional
2810
            Multiplicative factor used in the stopping criterion.
2811
        memory : int, optional
2812
            Number of most-recent points used in the weighted linear fit of
2813
            ``f(x) = a + b x`` to predict the next point.
2814
        x_min : float, optional
2815
            Minimum allowed value for the parameter ``x``.
2816
        x_max : float, optional
2817
            Maximum allowed value for the parameter ``x``.
2818
        b0 : int, optional
2819
            Number of active batches to use for the initial function
2820
            evaluations. If None, uses the model's current setting.
2821
        b_min : int, optional
2822
            Minimum number of active batches to use in a function evaluation.
2823
        b_max : int, optional
2824
            Maximum number of active batches to use in a function evaluation.
2825
        maxiter : int, optional
2826
            Maximum number of iterations to perform.
2827
        output : bool, optional
2828
            Whether or not to display output showing iteration progress.
2829
        func_kwargs : dict, optional
2830
            Keyword-based arguments to pass to the `func` function.
2831
        run_kwargs : dict, optional
2832
            Keyword arguments to pass to :meth:`openmc.Model.run` or
2833
            :meth:`openmc.lib.run`.
2834

2835
        Returns
2836
        -------
2837
        SearchResult
2838
            Result object containing the estimated root (parameter value) and
2839
            evaluation history (parameters, means, standard deviations, and
2840
            batches), plus convergence status and termination reason.
2841

2842
        """
2843
        import openmc.lib
11✔
2844

2845
        check_type('model modifier', func, Callable)
11✔
2846
        check_type('target', target, Real)
11✔
2847
        if memory < 2:
11✔
2848
            raise ValueError("memory must be ≥ 2")
×
2849
        func_kwargs = {} if func_kwargs is None else dict(func_kwargs)
11✔
2850
        run_kwargs = {} if run_kwargs is None else dict(run_kwargs)
11✔
2851
        run_kwargs.setdefault('output', False)
11✔
2852

2853
        # Create lists to store the history of evaluations
2854
        xs: list[float] = []
11✔
2855
        fs: list[float] = []
11✔
2856
        ss: list[float] = []
11✔
2857
        gs: list[int] = []
11✔
2858
        count = 0
11✔
2859

2860
        # Helper function to evaluate f and store results
2861
        def eval_at(x: float, batches: int) -> tuple[float, float]:
11✔
2862
            # Modify the model with the current guess
2863
            func(x, **func_kwargs)
11✔
2864

2865
            # Change the number of batches and run the model
2866
            batches += self.settings.inactive
11✔
2867
            if openmc.lib.is_initialized:
11✔
2868
                openmc.lib.settings.set_batches(batches)
×
2869
                openmc.lib.reset()
×
2870
                openmc.lib.run(**run_kwargs)
×
2871
                sp_filepath = f'statepoint.{batches}.h5'
×
2872
            else:
2873
                self.settings.batches = batches
11✔
2874
                sp_filepath = self.run(**run_kwargs)
11✔
2875

2876
            # Extract keff and its uncertainty
2877
            with openmc.StatePoint(sp_filepath) as sp:
11✔
2878
                keff = sp.keff
11✔
2879

2880
            if output:
11✔
2881
                nonlocal count
2882
                count += 1
11✔
2883
                print(f'Iteration {count}: {batches=}, {x=:.6g}, {keff=:.5f}')
11✔
2884

2885
            xs.append(float(x))
11✔
2886
            fs.append(float(keff.n - target))
11✔
2887
            ss.append(float(keff.s))
11✔
2888
            gs.append(int(batches))
11✔
2889
            return fs[-1], ss[-1]
11✔
2890

2891
        # Default b0 to current model settings if not explicitly provided
2892
        if b0 is None:
11✔
2893
            b0 = self.settings.batches - self.settings.inactive
11✔
2894

2895
        # Perform the search (inlined GRsecant) in a temporary directory
2896
        with TemporaryDirectory() as tmpdir:
11✔
2897
            if not openmc.lib.is_initialized:
11✔
2898
                run_kwargs.setdefault('cwd', tmpdir)
11✔
2899

2900
            # ---- Seed with two evaluations
2901
            f0, s0 = eval_at(x0, b0)
11✔
2902
            if abs(f0) <= k_tol and s0 <= sigma_final:
11✔
2903
                return SearchResult(x0, xs, fs, ss, gs, True, "converged")
×
2904
            f1, s1 = eval_at(x1, b0)
11✔
2905
            if abs(f1) <= k_tol and s1 <= sigma_final:
11✔
2906
                return SearchResult(x1, xs, fs, ss, gs, True, "converged")
×
2907

2908
            for _ in range(maxiter - 2):
11✔
2909
                # ------ Step 1: propose next x via GRsecant
2910
                m = min(memory, len(xs))
11✔
2911

2912
                # Perform a curve fit on f(x) = a + bx accounting for
2913
                # uncertainties. This is equivalent to minimizing the function
2914
                # in Equation (A.14)
2915
                (a, b), _ = curve_fit(
11✔
2916
                    lambda x, a, b: a + b*x,
2917
                    xs[-m:], fs[-m:], sigma=ss[-m:], absolute_sigma=True
2918
                )
2919
                x_new = float(-a / b)
11✔
2920

2921
                # Clamp x_new to the bounds if provided
2922
                if x_min is not None:
11✔
2923
                    x_new = max(x_new, x_min)
×
2924
                if x_max is not None:
11✔
2925
                    x_new = min(x_new, x_max)
×
2926

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

2929
                min_abs_f = float(np.min(np.abs(fs)))
11✔
2930
                base = q * sigma_final
11✔
2931
                ratio = min_abs_f / k_tol if k_tol > 0 else 1.0
11✔
2932
                sig = base * (ratio ** p)
11✔
2933
                sig_target = max(sig, base)
11✔
2934

2935
                # ------ Step 3: choose generations to hit σ_target (Appendix C)
2936

2937
                # Use at least two past points for regression
2938
                if len(gs) >= 2 and np.var(np.log(gs)) > 0.0:
11✔
2939
                    # Perform a curve fit based on Eq. (C.3) to solve for ln(k).
2940
                    # Note that unlike in the paper, we do not leave r as an
2941
                    # undetermined parameter and choose r=0.5.
2942
                    (ln_k,), _ = curve_fit(
11✔
2943
                        lambda ln_b, ln_k: ln_k - 0.5*ln_b,
2944
                        np.log(gs[-4:]), np.log(ss[-4:]),
2945
                    )
2946
                    k = float(np.exp(ln_k))
11✔
2947
                else:
2948
                    k = float(ss[-1] * math.sqrt(gs[-1]))
11✔
2949

2950
                b_new = (k / sig_target) ** 2
11✔
2951

2952
                # Clamp and round up to integer
2953
                b_new = max(b_min, math.ceil(b_new))
11✔
2954
                if b_max is not None:
11✔
2955
                    b_new = min(b_new, b_max)
×
2956

2957
                # Evaluate at proposed x with batches determined above
2958
                f_new, s_new = eval_at(x_new, b_new)
11✔
2959

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

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

2966

2967
@dataclass
11✔
2968
class SearchResult:
11✔
2969
    """Result of a GRsecant keff search.
2970

2971
    Attributes
2972
    ----------
2973
    root : float
2974
        Estimated parameter value where f(x) = 0 at termination.
2975
    parameters : list[float]
2976
        Parameter values (x) evaluated during the search, in order.
2977
    keffs : list[float]
2978
        Estimated keff values for each evaluation.
2979
    stdevs : list[float]
2980
        One-sigma uncertainties of keff for each evaluation.
2981
    batches : list[int]
2982
        Number of active batches used for each evaluation.
2983
    converged : bool
2984
        Whether both |f| <= k_tol and sigma <= sigma_final were met.
2985
    flag : str
2986
        Reason for termination (e.g., "converged", "maxiter").
2987
    """
2988
    root: float
11✔
2989
    parameters: list[float] = field(repr=False)
11✔
2990
    means: list[float] = field(repr=False)
11✔
2991
    stdevs: list[float] = field(repr=False)
11✔
2992
    batches: list[int] = field(repr=False)
11✔
2993
    converged: bool
11✔
2994
    flag: str
11✔
2995

2996
    @property
11✔
2997
    def function_calls(self) -> int:
11✔
2998
        """Number of function evaluations performed."""
2999
        return len(self.parameters)
11✔
3000

3001
    @property
11✔
3002
    def total_batches(self) -> int:
11✔
3003
        """Total number of active batches used across all evaluations."""
3004
        return sum(self.batches)
11✔
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