Skip to content

Commit e431d49

Browse files
church89drewejohnsonpaulromano
authored
Add reactivity control to coupled transport-depletion analyses (#2693)
Co-authored-by: Andrew Johnson <drewejohnson@users.noreply.github.com> Co-authored-by: Paul Romano <paul.k.romano@gmail.com>
1 parent 36e70d8 commit e431d49

12 files changed

Lines changed: 548 additions & 10 deletions

File tree

docs/source/io_formats/depletion_results.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
Depletion Results File Format
55
=============================
66

7-
The current version of the depletion results file format is 1.2.
7+
The current version of the depletion results file format is 1.3.
88

99
**/**
1010

@@ -29,6 +29,8 @@ The current version of the depletion results file format is 1.2.
2929
- **depletion time** (*double[]*) -- Average process time in [s]
3030
spent depleting a material across all burnable materials and,
3131
if applicable, MPI processes.
32+
- **keff_search_root** (*double[]*) -- Root of the keff search at the
33+
end of the timestep, if applicable.
3234

3335
**/materials/<id>/**
3436

openmc/deplete/abc.py

Lines changed: 137 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from .pool import deplete
3232
from .reaction_rates import ReactionRates
3333
from .transfer_rates import TransferRates, ExternalSourceRates
34+
from .keff_search_control import _KeffSearchControl
3435

3536

3637
__all__ = [
@@ -159,7 +160,7 @@ def __init__(self, chain_file=None, fission_q=None, prev_results=None):
159160
self.prev_res = prev_results
160161

161162
@abstractmethod
162-
def __call__(self, vec, source_rate):
163+
def __call__(self, vec, source_rate) -> OperatorResult:
163164
"""Runs a simulation.
164165
165166
Parameters
@@ -686,6 +687,7 @@ def __init__(
686687

687688
self.transfer_rates = None
688689
self.external_source_rates = None
690+
self._keff_search_control = None
689691

690692
if isinstance(solver, str):
691693
# Delay importing of cram module, which requires this file
@@ -839,6 +841,37 @@ def _get_start_data(self) -> tuple[float, int]:
839841
return (self.operator.prev_res[-1].time[0],
840842
len(self.operator.prev_res) - 1)
841843

844+
def _restore_keff_search_control(self, res: StepResult):
845+
"""Restore keff search control from restart results."""
846+
keff_search_root = res.keff_search_root
847+
if keff_search_root is None:
848+
raise ValueError(
849+
"Cannot restore keff search control from restart "
850+
"results because no stored keff_search_root is "
851+
"available."
852+
)
853+
self._keff_search_control.function(keff_search_root)
854+
return keff_search_root
855+
856+
def _get_bos_data(self, step_index, source_rate, bos_conc):
857+
"""Get beginning-of-step concentrations, rates, and control state."""
858+
if step_index > 0 or self.operator.prev_res is None:
859+
if self._keff_search_control is not None and source_rate != 0.0:
860+
keff_search_root = self._keff_search_control.run(bos_conc)
861+
else:
862+
keff_search_root = None
863+
bos_conc, res = self._get_bos_data_from_operator(
864+
step_index, source_rate, bos_conc)
865+
else:
866+
bos_conc, res = self._get_bos_data_from_restart(
867+
source_rate, bos_conc)
868+
if self._keff_search_control is not None and source_rate != 0.0:
869+
keff_search_root = self._restore_keff_search_control(self.operator.prev_res[-1])
870+
else:
871+
keff_search_root = None
872+
873+
return bos_conc, res, keff_search_root
874+
842875
def integrate(
843876
self,
844877
final_step: bool = True,
@@ -877,11 +910,8 @@ def integrate(
877910
if output and comm.rank == 0:
878911
print(f"[openmc.deplete] t={t} s, dt={dt} s, source={source_rate}")
879912

880-
# Solve transport equation (or obtain result from restart)
881-
if i > 0 or self.operator.prev_res is None:
882-
n, res = self._get_bos_data_from_operator(i, source_rate, n)
883-
else:
884-
n, res = self._get_bos_data_from_restart(source_rate, n)
913+
# Get beginning-of-step data from operator or restart results
914+
n, res, keff_search_root = self._get_bos_data(i, source_rate, n)
885915

886916
# Solve Bateman equations over time interval
887917
proc_time, n_end = self(n, res.rates, dt, source_rate, i)
@@ -895,6 +925,7 @@ def integrate(
895925
self._i_res + i,
896926
proc_time,
897927
write_rates=write_rates,
928+
keff_search_root=keff_search_root,
898929
path=path
899930
)
900931

@@ -908,6 +939,10 @@ def integrate(
908939
# solve)
909940
if output and final_step and comm.rank == 0:
910941
print(f"[openmc.deplete] t={t} (final operator evaluation)")
942+
if self._keff_search_control is not None and source_rate != 0.0:
943+
keff_search_root = self._keff_search_control.run(n)
944+
else:
945+
keff_search_root = None
911946
res_final = self.operator(n, source_rate if final_step else 0.0)
912947
StepResult.save(
913948
self.operator,
@@ -918,6 +953,7 @@ def integrate(
918953
self._i_res + len(self),
919954
proc_time,
920955
write_rates=write_rates,
956+
keff_search_root=keff_search_root,
921957
path=path
922958
)
923959
self.operator.write_bos_data(len(self) + self._i_res)
@@ -1050,6 +1086,101 @@ def add_redox(self, material, buffer, oxidation_states, timesteps=None):
10501086

10511087
self.transfer_rates.set_redox(material, buffer, oxidation_states, timesteps)
10521088

1089+
def add_keff_search_control(
1090+
self,
1091+
function: Callable,
1092+
x0: float,
1093+
x1: float,
1094+
bracket: Sequence[float],
1095+
**search_kwargs
1096+
):
1097+
"""Add keff search to the integrator scheme.
1098+
1099+
This method causes OpenMC to perform a keff search during depletion to
1100+
maintain a target keff by adjusting a model parameter through the
1101+
provided function.
1102+
1103+
.. important::
1104+
The function **must** modify the model through ``openmc.lib`` (e.g.,
1105+
``openmc.lib.cells``, ``openmc.lib.materials``) and **NOT** through
1106+
``openmc.Model``. The function is called within a
1107+
:class:`openmc.lib.TemporarySession` context where only the C API
1108+
(``openmc.lib``) is available for modifications.
1109+
1110+
Parameters
1111+
----------
1112+
function : Callable
1113+
Function that takes a single float argument and modifies the model
1114+
through :mod:`openmc.lib`.
1115+
x0 : float
1116+
Initial lower bound for the keff search.
1117+
x1 : float
1118+
Initial upper bound for the keff search.
1119+
bracket : sequence of float
1120+
Bracket interval [x_min, x_max] that constrains the allowed parameter
1121+
values during the keff search. This is a required parameter
1122+
that defines the absolute bounds for the search. The bracket must contain
1123+
exactly 2 elements with bracket[0] < bracket[1]. These values are passed
1124+
directly to the ``x_min`` and ``x_max`` optional arguments in
1125+
:meth:`openmc.Model.keff_search`, which enforce hard limits on the
1126+
parameter range. If the keff search converges to a value outside this
1127+
bracket, it will be clamped to the nearest bracket bound with a warning.
1128+
**search_kwargs
1129+
Additional keyword arguments passed to
1130+
:meth:`openmc.Model.keff_search`. Common options include:
1131+
1132+
* ``target`` : float, optional
1133+
Target keff value to search for. Defaults to 1.0.
1134+
* ``k_tol`` : float, optional
1135+
Stopping criterion on the function value. Defaults to 1e-4.
1136+
* ``sigma_final`` : float, optional
1137+
Maximum accepted k-effective uncertainty. Defaults to 3e-4.
1138+
* ``maxiter`` : int, optional
1139+
Maximum number of iterations. Defaults to 50.
1140+
1141+
See :meth:`openmc.Model.keff_search` for a complete list of
1142+
available options.
1143+
1144+
Examples
1145+
--------
1146+
Add keff search that adjusts a control rod position:
1147+
1148+
>>> def adjust_rod_position(position):
1149+
... openmc.lib.cells[rod_cell.id].translation = [0, 0, position]
1150+
>>> integrator.add_keff_search_control(
1151+
... adjust_rod_position,
1152+
... x0=0.0,
1153+
... x1=5.0,
1154+
... bracket=[-10,10],
1155+
... target=1.0,
1156+
... k_tol=1e-4
1157+
... )
1158+
1159+
Add keff search that adjusts the U235 density:
1160+
1161+
>>> def set_u235_density(u235_density):
1162+
... # Get the material from openmc.lib
1163+
... lib_mat = openmc.lib.materials[material_id]
1164+
... # Get current nuclides and densities
1165+
... nuclides = lib_mat.nuclides
1166+
... densities = lib_mat.densities
1167+
... u235_idx = nuclides.index('U235')
1168+
... densities[u235_idx] = u235_density
1169+
... lib_mat.set_densities(nuclides, densities)
1170+
>>> integrator.add_keff_search_control(
1171+
... set_u235_density,
1172+
... x0=5.0e-4,
1173+
... x1=1.0e-3,
1174+
... bracket=[1.0e-4, 2.0e-3],
1175+
... target=1.0
1176+
... )
1177+
1178+
.. versionadded:: 0.15.4
1179+
1180+
"""
1181+
self._keff_search_control = _KeffSearchControl(
1182+
self.operator, function, x0, x1, bracket, **search_kwargs)
1183+
10531184
@add_params
10541185
class SIIntegrator(Integrator):
10551186
r"""Abstract class for the Stochastic Implicit Euler integrators

openmc/deplete/coupled_operator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ def _generate_materials_xml(self):
399399

400400
self.materials.export_to_xml(nuclides_to_ignore=self._decay_nucs)
401401

402-
def __call__(self, vec, source_rate):
402+
def __call__(self, vec, source_rate) -> OperatorResult:
403403
"""Runs a simulation.
404404
405405
Simulation will abort under the following circumstances:

openmc/deplete/independent_operator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ def initial_condition(self):
384384
# Return number density vector
385385
return super().initial_condition(self.materials)
386386

387-
def __call__(self, vec, source_rate):
387+
def __call__(self, vec, source_rate) -> OperatorResult:
388388
"""Obtain the reaction rates
389389
390390
Parameters
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
from typing import Callable
2+
from warnings import warn
3+
4+
import openmc.lib
5+
6+
7+
class _KeffSearchControl:
8+
"""Controller for keff search during depletion calculations.
9+
10+
This class performs keff searches to maintain a target keff by adjusting a
11+
model parameter through a provided function.
12+
13+
Parameters
14+
----------
15+
operator : openmc.deplete.Operator
16+
Depletion operator instance
17+
function : Callable
18+
Function that modifies the model based on a parameter value
19+
x0 : float
20+
Initial lower bound for the keff search
21+
x1 : float
22+
Initial upper bound for the keff search
23+
bracket : list[float]
24+
Absolute bracketing interval lower and upper. If the keff search
25+
solution lies off these limits the closest limit will be set as new
26+
result.
27+
**search_kwargs : dict, optional
28+
Additional keyword arguments to pass to :meth:`openmc.Model.keff_search`
29+
30+
"""
31+
def __init__(self, operator, function: Callable, x0: float, x1: float, bracket: list[float], **search_kwargs):
32+
if len(bracket) != 2:
33+
raise ValueError(f"bracket must have exactly 2 elements, got {len(bracket)}")
34+
if bracket[0] >= bracket[1]:
35+
raise ValueError(f"bracket[0] must be < bracket[1], got {bracket}")
36+
self.x0 = x0
37+
self.x1 = x1
38+
self.operator = operator
39+
self.function = function
40+
self.search_kwargs = search_kwargs
41+
self.search_kwargs['x_min'] = bracket[0]
42+
self.search_kwargs['x_max'] = bracket[1]
43+
44+
def run(self, x):
45+
"""Perform keff search and update the atom density vector.
46+
47+
Parameters
48+
----------
49+
x : list of numpy.ndarray
50+
Current atom density vector (atoms per material)
51+
52+
Returns
53+
-------
54+
root : float
55+
Parameter value that achieves target keff
56+
"""
57+
root = self._search_for_keff()
58+
self._update_vec(x)
59+
return root
60+
61+
def _search_for_keff(self) -> float:
62+
"""Perform the keff search using the model's keff_search method.
63+
64+
Returns
65+
-------
66+
float
67+
Parameter value that achieves target keff
68+
69+
Raises
70+
------
71+
ValueError
72+
If the keff search fails to converge
73+
"""
74+
with openmc.lib.TemporarySession(self.operator.model):
75+
# Only pass the first 3 required args plus explicitly provided kwargs
76+
result = self.operator.model.keff_search(
77+
self.function, self.x0, self.x1, **self.search_kwargs
78+
)
79+
if not result.converged:
80+
raise ValueError(
81+
f"Search for keff failed to converge. "
82+
f"Termination reason: {result.flag}"
83+
)
84+
85+
root = result.root
86+
87+
# Check if root is outside the bracket bounds and give a warning
88+
if root < self.search_kwargs['x_min']:
89+
warn(f"keff search result ({root:.6f}) is below the lower bracket "
90+
f"bound ({self.search_kwargs['x_min']:.6f}).", UserWarning)
91+
elif root > self.search_kwargs['x_max']:
92+
warn(f"keff search result ({root:.6f}) is above the upper bracket "
93+
f"bound ({self.search_kwargs['x_max']:.6f}).", UserWarning)
94+
95+
# Restore the number of initial batches
96+
openmc.lib.settings.set_batches(self.operator.model.settings.batches)
97+
98+
return root
99+
100+
def _update_vec(self, x):
101+
"""Update the atom density vector from openmc.lib.materials and AtomNumber object.
102+
103+
The depletion vector ``x`` is rank-local, matching the materials owned
104+
by ``self.operator.number`` on the current MPI rank. We therefore only
105+
update entries for locally owned materials using the compositions
106+
currently stored in ``openmc.lib.materials``.
107+
108+
Parameters
109+
----------
110+
x : list of numpy.ndarray
111+
Atom density vector to update (atoms per material)
112+
113+
"""
114+
number = self.operator.number
115+
116+
for mat_idx, mat in enumerate(number.materials):
117+
lib_material = openmc.lib.materials[int(mat)]
118+
nuclides = lib_material.nuclides
119+
densities = 1e24 * lib_material.densities
120+
volume = number.get_mat_volume(mat)
121+
122+
for nuc_idx, nuc in enumerate(number.burnable_nuclides):
123+
if nuc in nuclides:
124+
lib_nuc_idx = nuclides.index(nuc)
125+
atom_density = densities[lib_nuc_idx]
126+
else:
127+
atom_density = number.get_atom_density(mat, nuc)
128+
x[mat_idx][nuc_idx] = atom_density * volume

0 commit comments

Comments
 (0)