3131from .pool import deplete
3232from .reaction_rates import ReactionRates
3333from .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
10541185class SIIntegrator (Integrator ):
10551186 r"""Abstract class for the Stochastic Implicit Euler integrators
0 commit comments