openmc-depletion-feedback 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: openmc-depletion-feedback
3
+ Version: 0.1.0
4
+ Summary: Reusable OpenMC depletion simulations with optional soluble-species feedback control
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: openmc>=0.14
8
+ Provides-Extra: test
9
+ Requires-Dist: pytest>=7; extra == "test"
10
+
11
+ # openmc-depletion-feedback
12
+
13
+ An importable wrapper around OpenMC depletion. The package accepts a prepared
14
+ OpenMC `Model` or a directory containing OpenMC XML inputs and runs depletion
15
+ with one function. Provide the reactor geometry, materials, temperatures,
16
+ volumes, cross-section data, and depletion chain for your calculation.
17
+
18
+ ## Install
19
+
20
+ Install OpenMC and its nuclear data according to the [OpenMC documentation](https://docs.openmc.org/), then install this package:
21
+
22
+ ```bash
23
+ pip install .
24
+ ```
25
+
26
+ Install the released package with:
27
+ ```bash
28
+ pip install openmc-depletion-feedback
29
+ ```
30
+
31
+ ## Documentation
32
+
33
+ The full user guide can be found here: [openmc-depletion-feedback documentation](https://qyxen.github.io/openmc-depletion-feedback/)
34
+
35
+ ## Ordinary depletion
36
+
37
+ ```python
38
+ import openmc
39
+ from openmc_depletion_feedback import run_depletion
40
+
41
+ run_depletion(
42
+ model="model", # directory containing geometry.xml, materials.xml, settings.xml, etc.
43
+ timesteps=[1, 7, 30, 90],
44
+ timestep_units="d",
45
+ power=160e6, # watts
46
+ chain_file="data/chain.xml",
47
+ output_dir="depletion-results",
48
+ )
49
+ ```
50
+
51
+ ## Soluble-species feedback
52
+
53
+ Provide a named material containing the soluble species at the stated
54
+ reference concentration. The controller scales the listed nuclide atom
55
+ densities together, leaving every other nuclide in the material untouched.
56
+ Species concentration units are intentionally user-defined, so the same
57
+ controller can represent ppm boron, ppm gadolinium, or another soluble
58
+ absorber when the reference material has been prepared consistently.
59
+
60
+ ```python
61
+ from openmc_depletion_feedback import FeedbackControl, run_depletion
62
+
63
+ control = FeedbackControl(
64
+ material_name="Primary coolant",
65
+ nuclides=("B10", "B11"),
66
+ reference_concentration=1000, # ppm represented by the model densities
67
+ minimum=0,
68
+ maximum=1000,
69
+ target_keff=1.005,
70
+ search_particles=5000,
71
+ search_batches=30,
72
+ search_inactive=5,
73
+ )
74
+ run_depletion(
75
+ model="model", # directory containing the OpenMC XML inputs
76
+ timesteps=[1, 7, 30],
77
+ power=160e6,
78
+ chain_file="data/chain.xml",
79
+ control=control,
80
+ output_dir="controlled-results",
81
+ )
82
+ ```
83
+
84
+ ## Citation
85
+
86
+ Citation metadata is provided in [`CITATION.cff`](CITATION.cff).
@@ -0,0 +1,76 @@
1
+ # openmc-depletion-feedback
2
+
3
+ An importable wrapper around OpenMC depletion. The package accepts a prepared
4
+ OpenMC `Model` or a directory containing OpenMC XML inputs and runs depletion
5
+ with one function. Provide the reactor geometry, materials, temperatures,
6
+ volumes, cross-section data, and depletion chain for your calculation.
7
+
8
+ ## Install
9
+
10
+ Install OpenMC and its nuclear data according to the [OpenMC documentation](https://docs.openmc.org/), then install this package:
11
+
12
+ ```bash
13
+ pip install .
14
+ ```
15
+
16
+ Install the released package with:
17
+ ```bash
18
+ pip install openmc-depletion-feedback
19
+ ```
20
+
21
+ ## Documentation
22
+
23
+ The full user guide can be found here: [openmc-depletion-feedback documentation](https://qyxen.github.io/openmc-depletion-feedback/)
24
+
25
+ ## Ordinary depletion
26
+
27
+ ```python
28
+ import openmc
29
+ from openmc_depletion_feedback import run_depletion
30
+
31
+ run_depletion(
32
+ model="model", # directory containing geometry.xml, materials.xml, settings.xml, etc.
33
+ timesteps=[1, 7, 30, 90],
34
+ timestep_units="d",
35
+ power=160e6, # watts
36
+ chain_file="data/chain.xml",
37
+ output_dir="depletion-results",
38
+ )
39
+ ```
40
+
41
+ ## Soluble-species feedback
42
+
43
+ Provide a named material containing the soluble species at the stated
44
+ reference concentration. The controller scales the listed nuclide atom
45
+ densities together, leaving every other nuclide in the material untouched.
46
+ Species concentration units are intentionally user-defined, so the same
47
+ controller can represent ppm boron, ppm gadolinium, or another soluble
48
+ absorber when the reference material has been prepared consistently.
49
+
50
+ ```python
51
+ from openmc_depletion_feedback import FeedbackControl, run_depletion
52
+
53
+ control = FeedbackControl(
54
+ material_name="Primary coolant",
55
+ nuclides=("B10", "B11"),
56
+ reference_concentration=1000, # ppm represented by the model densities
57
+ minimum=0,
58
+ maximum=1000,
59
+ target_keff=1.005,
60
+ search_particles=5000,
61
+ search_batches=30,
62
+ search_inactive=5,
63
+ )
64
+ run_depletion(
65
+ model="model", # directory containing the OpenMC XML inputs
66
+ timesteps=[1, 7, 30],
67
+ power=160e6,
68
+ chain_file="data/chain.xml",
69
+ control=control,
70
+ output_dir="controlled-results",
71
+ )
72
+ ```
73
+
74
+ ## Citation
75
+
76
+ Citation metadata is provided in [`CITATION.cff`](CITATION.cff).
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openmc-depletion-feedback"
7
+ version = "0.1.0"
8
+ description = "Reusable OpenMC depletion simulations with optional soluble-species feedback control"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = ["openmc>=0.14"]
12
+
13
+ [project.optional-dependencies]
14
+ test = ["pytest>=7"]
15
+
16
+ [tool.setuptools.packages.find]
17
+ where = ["src"]
18
+
19
+ [tool.pytest.ini_options]
20
+ pythonpath = ["src"]
21
+ testpaths = ["tests"]
22
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """Reusable depletion workflows built on OpenMC."""
2
+
3
+ from .api import run_depletion
4
+ from .control import FeedbackControl
5
+
6
+ __all__ = ["FeedbackControl", "run_depletion"]
7
+
@@ -0,0 +1,336 @@
1
+ """Public depletion API."""
2
+
3
+ from pathlib import Path
4
+ import csv
5
+ from types import SimpleNamespace
6
+
7
+ import openmc
8
+ import openmc.deplete as deplete
9
+
10
+
11
+ def _nominal_keff(result):
12
+ value = getattr(result, "k", getattr(result, "keff", None))
13
+ if value is None:
14
+ raise AttributeError("OpenMC operator result does not expose k or keff")
15
+ return float(getattr(value, "nominal_value", value))
16
+
17
+
18
+ def _keff_stats(result):
19
+ """Return the keff estimate and its reported standard deviation, if any."""
20
+ value = getattr(result, "k", getattr(result, "keff", None))
21
+ if value is None:
22
+ raise AttributeError("OpenMC operator result does not expose k or keff")
23
+ mean = float(getattr(value, "nominal_value", value))
24
+ sigma = getattr(value, "std_dev", None)
25
+ return mean, None if sigma is None else float(sigma)
26
+
27
+
28
+ class _FeedbackOperator(deplete.CoupledOperator):
29
+ def __init__(self, model, chain_file, control):
30
+ super().__init__(model, chain_file=chain_file)
31
+ self.control = control
32
+ self.material = None
33
+ self.base_densities = None
34
+ self.original_stats = None
35
+ self.history_path = None
36
+ self._step = 0
37
+ self._trial = 0
38
+ self._previous_concentration = None
39
+ self._previous_boc_keff = None
40
+ self.expected_step_keff_loss = control.expected_step_keff_loss
41
+ self.search_level = 0
42
+
43
+ def initial_condition(self):
44
+ result = super().initial_condition()
45
+ c = self.control
46
+ source = next((m for m in self.model.materials if m.name == c.material_name), None)
47
+ if source is None:
48
+ raise ValueError(f"Control material {c.material_name!r} was not found")
49
+ self.material = openmc.lib.materials[source.id]
50
+ names = self.material.nuclides
51
+ dens = self.material.densities
52
+ resolved = []
53
+ missing = []
54
+ for requested in c.nuclides:
55
+ matches = [n for n in names if n == requested or
56
+ (requested.isalpha() and n.startswith(requested) and
57
+ n[len(requested):].isdigit())]
58
+ if not matches:
59
+ missing.append(requested)
60
+ else:
61
+ resolved.extend(matches)
62
+ if missing:
63
+ raise ValueError(f"Control material lacks nuclides: {', '.join(missing)}")
64
+ self.control_nuclides = tuple(dict.fromkeys(resolved))
65
+ self.base_densities = {n: float(dens[names.index(n)]) for n in self.control_nuclides}
66
+ settings = openmc.lib.settings
67
+ self.original_stats = (settings.particles, settings.get_batches(), settings.inactive)
68
+ self.history_path = Path(self.output_dir) / c.history_file
69
+ self.history_path.parent.mkdir(parents=True, exist_ok=True)
70
+ with self.history_path.open("w", newline="") as f:
71
+ csv.writer(f).writerow([
72
+ "step", "trial", "phase", "concentration", "keff",
73
+ "keff_std_dev", "target_keff", "lower_bound", "upper_bound",
74
+ "search_level", "particles", "batches", "selected",
75
+ "keff_error", "status", "stopping_reason",
76
+ ])
77
+ return result
78
+
79
+ def _set_concentration(self, concentration):
80
+ c = self.control
81
+ factor = concentration / c.reference_concentration
82
+ names, dens = self.material.nuclides, self.material.densities.copy()
83
+ for nuclide, base in self.base_densities.items():
84
+ dens[names.index(nuclide)] = base * factor
85
+ self.material.set_densities(names, dens)
86
+
87
+ def _transport(self, vec, source_rate, concentration, final=False,
88
+ search_level=None):
89
+ c, settings = self.control, openmc.lib.settings
90
+ if not final:
91
+ level = self.search_level if search_level is None else search_level
92
+ factor = c.refinement_factor ** level
93
+ base_particles, base_batches, base_inactive = self.original_stats
94
+ settings.particles = round((c.search_particles or base_particles) * factor)
95
+ settings.set_batches(round((c.search_batches or base_batches) * factor))
96
+ if c.search_inactive is not None:
97
+ settings.inactive = c.search_inactive
98
+ else:
99
+ settings.inactive = base_inactive
100
+ else:
101
+ settings.particles, batches, settings.inactive = self.original_stats
102
+ settings.set_batches(batches)
103
+ self._set_concentration(concentration)
104
+ return super().__call__(vec, source_rate)
105
+
106
+ def _record_trial(self, concentration, mean, sigma, target, lower, upper,
107
+ phase, selected=False, status="", stopping_reason=""):
108
+ settings = getattr(openmc.lib, "settings", None)
109
+ if settings is None:
110
+ settings = SimpleNamespace()
111
+ batches = settings.get_batches() if hasattr(settings, "get_batches") else ""
112
+ with self.history_path.open("a", newline="") as f:
113
+ csv.writer(f).writerow([
114
+ self._step, self._trial, phase, f"{concentration:.8f}",
115
+ f"{mean:.8f}", "" if sigma is None else f"{sigma:.8f}",
116
+ f"{target:.8f}", f"{lower:.8f}", f"{upper:.8f}",
117
+ self.search_level, getattr(settings, "particles", ""), batches,
118
+ int(selected), f"{abs(mean - target):.8f}", status,
119
+ stopping_reason,
120
+ ])
121
+ self._trial += 1
122
+
123
+ def _evaluate(self, vec, source_rate, concentration, target, lower, upper,
124
+ phase="search", level=None):
125
+ previous_level = self.search_level
126
+ if level is not None:
127
+ self.search_level = level
128
+ result = self._transport(vec, source_rate, concentration,
129
+ search_level=level)
130
+ mean, sigma = _keff_stats(result)
131
+ self._record_trial(concentration, mean, sigma, target, lower, upper,
132
+ phase)
133
+ self.search_level = previous_level
134
+ return mean, sigma
135
+
136
+ def _confidence_met(self, mean, sigma, target):
137
+ if sigma is None:
138
+ return False
139
+ return abs(mean - target) + self.control.confidence_z * sigma <= self.control.keff_tolerance
140
+
141
+ def _target_for_call(self, vec, source_rate):
142
+ c = self.control
143
+ if c.target_mode != "end" or self._previous_concentration is None:
144
+ return c.target_keff + (self.expected_step_keff_loss if c.target_mode == "end" else 0.0)
145
+
146
+ # Predictor mode calls the operator once for the depleted state at the
147
+ # start of the next step. Measure the previous step's loss at its
148
+ # selected absorber concentration, then filter and bound the estimate.
149
+ self.search_level = 0
150
+ current_keff, _ = self._evaluate(
151
+ vec, source_rate, self._previous_concentration,
152
+ c.target_keff + self.expected_step_keff_loss,
153
+ c.minimum, c.maximum, phase="loss_measurement", level=0,
154
+ )
155
+ measured_loss = self._previous_boc_keff - current_keff
156
+ measured_loss = min(c.maximum_step_keff_loss,
157
+ max(c.minimum_step_keff_loss, measured_loss))
158
+ alpha = c.loss_filter_alpha
159
+ self.expected_step_keff_loss = (
160
+ (1.0 - alpha) * self.expected_step_keff_loss + alpha * measured_loss
161
+ )
162
+ return c.target_keff + self.expected_step_keff_loss
163
+
164
+ @staticmethod
165
+ def _is_bracketed(klo, khi, target):
166
+ return (klo - target) * (khi - target) <= 0.0
167
+
168
+ def _warm_bracket(self, vec, source_rate, target):
169
+ c = self.control
170
+ if self._previous_concentration is None:
171
+ lo, hi = c.minimum, c.maximum
172
+ else:
173
+ width = c.warm_start_width
174
+ center = min(c.maximum, max(c.minimum, self._previous_concentration))
175
+ lo = max(c.minimum, center - width)
176
+ hi = min(c.maximum, center + width)
177
+
178
+ while True:
179
+ klo, _ = self._evaluate(vec, source_rate, lo, target, lo, hi,
180
+ phase="bracket")
181
+ khi, _ = self._evaluate(vec, source_rate, hi, target, lo, hi,
182
+ phase="bracket")
183
+ if self._is_bracketed(klo, khi, target):
184
+ return lo, hi, klo, khi, True
185
+ if lo == c.minimum and hi == c.maximum:
186
+ return lo, hi, klo, khi, False
187
+ width *= c.warm_start_expansion
188
+ center = min(c.maximum, max(c.minimum, self._previous_concentration))
189
+ new_lo = max(c.minimum, center - width)
190
+ new_hi = min(c.maximum, center + width)
191
+ if new_lo == lo and new_hi == hi:
192
+ new_lo, new_hi = c.minimum, c.maximum
193
+ lo, hi = new_lo, new_hi
194
+
195
+ def _candidate(self, lo, hi, klo, khi, target):
196
+ if self.control.use_secant and khi != klo:
197
+ secant = lo + (target - klo) * (hi - lo) / (khi - klo)
198
+ margin = 0.1 * (hi - lo)
199
+ if lo + margin <= secant <= hi - margin:
200
+ return secant
201
+ return 0.5 * (lo + hi)
202
+
203
+ def __call__(self, vec, source_rate):
204
+ if source_rate == 0.0:
205
+ return super().__call__(vec, source_rate)
206
+ c = self.control
207
+ self._trial = 0
208
+ target = self._target_for_call(vec, source_rate)
209
+ lo, hi, klo, khi, bracketed = self._warm_bracket(vec, source_rate, target)
210
+
211
+ if not bracketed:
212
+ selected = lo if abs(klo - target) < abs(khi - target) else hi
213
+ selected_mean, selected_sigma = (klo, None) if selected == lo else (khi, None)
214
+ stop_reason = "lower_bound" if selected == c.minimum else "upper_bound"
215
+ status = "target_unmet"
216
+ else:
217
+ increasing = khi > klo
218
+ selected = 0.5 * (lo + hi)
219
+ selected_mean, selected_sigma = None, None
220
+ status, stop_reason = "target_unmet", "max_iterations"
221
+ for _ in range(c.max_iterations):
222
+ candidate = self._candidate(lo, hi, klo, khi, target)
223
+ mean, sigma = self._evaluate(vec, source_rate, candidate,
224
+ target, lo, hi)
225
+ selected, selected_mean, selected_sigma = candidate, mean, sigma
226
+
227
+ # Near-target candidates are rerun with progressively more
228
+ # histories until their confidence interval supports the
229
+ # requested keff tolerance, or the refinement budget ends.
230
+ if abs(mean - target) <= c.keff_tolerance:
231
+ for level in range(1, c.max_search_refinements + 1):
232
+ if self._confidence_met(mean, sigma, target):
233
+ break
234
+ mean, sigma = self._evaluate(
235
+ vec, source_rate, candidate, target, lo, hi,
236
+ phase="refinement", level=level,
237
+ )
238
+ selected_mean, selected_sigma = mean, sigma
239
+ if self._confidence_met(mean, sigma, target):
240
+ status, stop_reason = "target_met", "keff_tolerance"
241
+ break
242
+
243
+ if (mean < target) == increasing:
244
+ lo, klo = candidate, mean
245
+ else:
246
+ hi, khi = candidate, mean
247
+ if hi - lo <= c.tolerance:
248
+ status = "target_met" if self._confidence_met(mean, sigma, target) else "target_unmet"
249
+ stop_reason = "concentration_tolerance"
250
+ if sigma is None or sigma * c.confidence_z > c.keff_tolerance:
251
+ stop_reason = "uncertainty_limit"
252
+ break
253
+ else:
254
+ # The loop exhausted its budget. Keep the last candidate and
255
+ # distinguish unresolved Monte Carlo uncertainty explicitly.
256
+ if selected_sigma is None or selected_sigma * c.confidence_z > c.keff_tolerance:
257
+ stop_reason = "uncertainty_limit"
258
+
259
+ self.search_level = 0
260
+ result = self._transport(vec, source_rate, selected, final=True)
261
+ final_mean, final_sigma = _keff_stats(result)
262
+ target_met = self._confidence_met(final_mean, final_sigma, target)
263
+ if target_met:
264
+ status = "target_met"
265
+ if stop_reason in {"lower_bound", "upper_bound"}:
266
+ stop_reason = "endpoint_within_tolerance"
267
+ else:
268
+ status = "target_unmet"
269
+ if stop_reason not in {"lower_bound", "upper_bound"}:
270
+ if final_sigma is None or final_sigma * c.confidence_z > c.keff_tolerance:
271
+ stop_reason = "uncertainty_limit"
272
+ elif abs(final_mean - target) > c.keff_tolerance:
273
+ stop_reason = "final_keff_outside_tolerance"
274
+ self._record_trial(selected, final_mean, final_sigma, target, lo, hi,
275
+ "selected_final", selected=True, status=status,
276
+ stopping_reason=stop_reason)
277
+ print(
278
+ f"[feedback] {status} ({stop_reason}): {selected:g}, "
279
+ f"keff={final_mean:.6f}, target={target:.6f}"
280
+ )
281
+ self._previous_concentration = selected
282
+ self._previous_boc_keff = final_mean
283
+ self._step += 1
284
+ return result
285
+
286
+ def run_depletion(model, timesteps, power, chain_file, *, timestep_units="d",
287
+ integrator="predictor", control=None, output_dir="."):
288
+ """Run an OpenMC depletion calculation.
289
+
290
+ Parameters
291
+ ----------
292
+ model : openmc.Model or path-like
293
+ A prepared OpenMC model (or directory containing OpenMC XML inputs).
294
+ timesteps : sequence
295
+ Step durations, interpreted using ``timestep_units``.
296
+ power : float
297
+ Reactor power in watts.
298
+ chain_file : path-like
299
+ OpenMC depletion chain XML file.
300
+ control : FeedbackControl, optional
301
+ Enable feedback by scaling configured nuclide densities in a named
302
+ material. Omit for an ordinary depletion run.
303
+ output_dir : path-like
304
+ Directory for depletion results and feedback history.
305
+
306
+ Returns the configured OpenMC integrator after it completes.
307
+ """
308
+ chain_file = Path(chain_file)
309
+ if not chain_file.is_file():
310
+ raise FileNotFoundError(f"Depletion chain not found: {chain_file}")
311
+ if not isinstance(model, openmc.Model):
312
+ model = openmc.Model.from_xml(directory=str(model))
313
+ missing = [m.name for m in model.materials if m.depletable and (m.volume is None or m.volume <= 0)]
314
+ if missing:
315
+ raise ValueError("Depletable materials need positive volumes: " + ", ".join(missing))
316
+ integrators = {"predictor": deplete.PredictorIntegrator,
317
+ "cecm": deplete.CECMIntegrator,
318
+ "epc-rk4": deplete.EPCRK4Integrator}
319
+ try:
320
+ integrator_name = integrator.lower()
321
+ integrator_cls = integrators[integrator_name]
322
+ except (AttributeError, KeyError) as exc:
323
+ raise ValueError(f"Unsupported integrator {integrator!r}; choose {', '.join(integrators)}") from exc
324
+ if control is not None and control.target_mode == "end" and integrator_name != "predictor":
325
+ raise ValueError("target_mode='end' currently requires integrator='predictor'")
326
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
327
+ operator_cls = _FeedbackOperator if control is not None else deplete.CoupledOperator
328
+ if control is None:
329
+ operator = operator_cls(model, chain_file=str(chain_file))
330
+ else:
331
+ operator = operator_cls(model, chain_file=str(chain_file), control=control)
332
+ calculation = integrator_cls(operator, timesteps, power=power,
333
+ timestep_units=timestep_units)
334
+ calculation.integrate(output_dir=str(output_dir))
335
+ return calculation
336
+
@@ -0,0 +1,76 @@
1
+ """Generic reactivity feedback using a soluble species in one material."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import csv
6
+
7
+
8
+ @dataclass
9
+ class FeedbackControl:
10
+ """Configuration for bounded keff feedback.
11
+
12
+ ``reference_concentration`` is the concentration represented by the
13
+ isotope densities currently in ``material_name``. The controller scales
14
+ only ``nuclides`` linearly from zero to that reference concentration.
15
+ Concentration units are user-defined (ppm, g/kg, or another consistent
16
+ unit); ``minimum`` and ``maximum`` use the same unit.
17
+ """
18
+
19
+ material_name: str
20
+ nuclides: tuple[str, ...]
21
+ reference_concentration: float
22
+ minimum: float
23
+ maximum: float
24
+ target_keff: float
25
+ target_mode: str = "beginning"
26
+ expected_step_keff_loss: float = 0.01
27
+ loss_filter_alpha: float = 0.3
28
+ minimum_step_keff_loss: float = -0.01
29
+ maximum_step_keff_loss: float = 0.02
30
+ search_particles: int | None = None
31
+ search_batches: int | None = None
32
+ search_inactive: int | None = None
33
+ max_search_refinements: int = 2
34
+ refinement_factor: float = 2.0
35
+ confidence_z: float = 2.0
36
+ warm_start_width: float = 100.0
37
+ warm_start_expansion: float = 2.0
38
+ use_secant: bool = True
39
+ tolerance: float = 0.001
40
+ keff_tolerance: float = 0.0005
41
+ max_iterations: int = 12
42
+ history_file: str | Path = "feedback_history.csv"
43
+
44
+ def __post_init__(self):
45
+ if not self.material_name or not self.nuclides:
46
+ raise ValueError("material_name and at least one nuclide are required")
47
+ if self.reference_concentration <= 0:
48
+ raise ValueError("reference_concentration must be positive")
49
+ if not (0 <= self.minimum < self.maximum <= self.reference_concentration):
50
+ raise ValueError("Require 0 <= minimum < maximum <= reference_concentration")
51
+ if self.tolerance <= 0 or self.max_iterations < 1:
52
+ raise ValueError("tolerance and max_iterations must be positive")
53
+ if self.keff_tolerance <= 0:
54
+ raise ValueError("keff_tolerance must be positive")
55
+ for name in ("search_particles", "search_batches"):
56
+ value = getattr(self, name)
57
+ if value is not None and value < 1:
58
+ raise ValueError(f"{name} must be a positive integer")
59
+ if self.search_inactive is not None and self.search_inactive < 0:
60
+ raise ValueError("search_inactive must be nonnegative")
61
+ if self.target_mode not in {"beginning", "end"}:
62
+ raise ValueError("target_mode must be 'beginning' or 'end'")
63
+ if not 0.0 <= self.loss_filter_alpha <= 1.0:
64
+ raise ValueError("loss_filter_alpha must be between 0 and 1")
65
+ if self.minimum_step_keff_loss > self.maximum_step_keff_loss:
66
+ raise ValueError("minimum_step_keff_loss must not exceed maximum_step_keff_loss")
67
+ if self.max_search_refinements < 0:
68
+ raise ValueError("max_search_refinements must be nonnegative")
69
+ if self.refinement_factor <= 1.0:
70
+ raise ValueError("refinement_factor must be greater than 1")
71
+ if self.confidence_z <= 0:
72
+ raise ValueError("confidence_z must be positive")
73
+ if self.warm_start_width <= 0 or self.warm_start_expansion <= 1.0:
74
+ raise ValueError("warm-start width must be positive and expansion must exceed 1")
75
+
76
+
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: openmc-depletion-feedback
3
+ Version: 0.1.0
4
+ Summary: Reusable OpenMC depletion simulations with optional soluble-species feedback control
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: openmc>=0.14
8
+ Provides-Extra: test
9
+ Requires-Dist: pytest>=7; extra == "test"
10
+
11
+ # openmc-depletion-feedback
12
+
13
+ An importable wrapper around OpenMC depletion. The package accepts a prepared
14
+ OpenMC `Model` or a directory containing OpenMC XML inputs and runs depletion
15
+ with one function. Provide the reactor geometry, materials, temperatures,
16
+ volumes, cross-section data, and depletion chain for your calculation.
17
+
18
+ ## Install
19
+
20
+ Install OpenMC and its nuclear data according to the [OpenMC documentation](https://docs.openmc.org/), then install this package:
21
+
22
+ ```bash
23
+ pip install .
24
+ ```
25
+
26
+ Install the released package with:
27
+ ```bash
28
+ pip install openmc-depletion-feedback
29
+ ```
30
+
31
+ ## Documentation
32
+
33
+ The full user guide can be found here: [openmc-depletion-feedback documentation](https://qyxen.github.io/openmc-depletion-feedback/)
34
+
35
+ ## Ordinary depletion
36
+
37
+ ```python
38
+ import openmc
39
+ from openmc_depletion_feedback import run_depletion
40
+
41
+ run_depletion(
42
+ model="model", # directory containing geometry.xml, materials.xml, settings.xml, etc.
43
+ timesteps=[1, 7, 30, 90],
44
+ timestep_units="d",
45
+ power=160e6, # watts
46
+ chain_file="data/chain.xml",
47
+ output_dir="depletion-results",
48
+ )
49
+ ```
50
+
51
+ ## Soluble-species feedback
52
+
53
+ Provide a named material containing the soluble species at the stated
54
+ reference concentration. The controller scales the listed nuclide atom
55
+ densities together, leaving every other nuclide in the material untouched.
56
+ Species concentration units are intentionally user-defined, so the same
57
+ controller can represent ppm boron, ppm gadolinium, or another soluble
58
+ absorber when the reference material has been prepared consistently.
59
+
60
+ ```python
61
+ from openmc_depletion_feedback import FeedbackControl, run_depletion
62
+
63
+ control = FeedbackControl(
64
+ material_name="Primary coolant",
65
+ nuclides=("B10", "B11"),
66
+ reference_concentration=1000, # ppm represented by the model densities
67
+ minimum=0,
68
+ maximum=1000,
69
+ target_keff=1.005,
70
+ search_particles=5000,
71
+ search_batches=30,
72
+ search_inactive=5,
73
+ )
74
+ run_depletion(
75
+ model="model", # directory containing the OpenMC XML inputs
76
+ timesteps=[1, 7, 30],
77
+ power=160e6,
78
+ chain_file="data/chain.xml",
79
+ control=control,
80
+ output_dir="controlled-results",
81
+ )
82
+ ```
83
+
84
+ ## Citation
85
+
86
+ Citation metadata is provided in [`CITATION.cff`](CITATION.cff).
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/openmc_depletion_feedback/__init__.py
4
+ src/openmc_depletion_feedback/api.py
5
+ src/openmc_depletion_feedback/control.py
6
+ src/openmc_depletion_feedback.egg-info/PKG-INFO
7
+ src/openmc_depletion_feedback.egg-info/SOURCES.txt
8
+ src/openmc_depletion_feedback.egg-info/dependency_links.txt
9
+ src/openmc_depletion_feedback.egg-info/requires.txt
10
+ src/openmc_depletion_feedback.egg-info/top_level.txt
11
+ tests/test_control.py
12
+ tests/test_feedback_search.py
@@ -0,0 +1,4 @@
1
+ openmc>=0.14
2
+
3
+ [test]
4
+ pytest>=7
@@ -0,0 +1,42 @@
1
+ import pytest
2
+
3
+ from openmc_depletion_feedback import FeedbackControl
4
+
5
+
6
+ def make_control(**overrides):
7
+ values = {
8
+ "material_name": "Coolant",
9
+ "nuclides": ("B",),
10
+ "reference_concentration": 1000.0,
11
+ "minimum": 0.0,
12
+ "maximum": 1000.0,
13
+ "target_keff": 1.0,
14
+ }
15
+ values.update(overrides)
16
+ return FeedbackControl(**values)
17
+
18
+
19
+ def test_accepts_valid_control():
20
+ control = make_control()
21
+
22
+ assert control.material_name == "Coolant"
23
+ assert control.nuclides == ("B",)
24
+ assert control.minimum == 0.0
25
+ assert control.maximum == 1000.0
26
+
27
+
28
+ @pytest.mark.parametrize(
29
+ "overrides, message",
30
+ [
31
+ ({"material_name": ""}, "material_name"),
32
+ ({"nuclides": ()}, "nuclide"),
33
+ ({"reference_concentration": 0.0}, "reference_concentration"),
34
+ ({"minimum": -1.0}, "Require 0 <= minimum"),
35
+ ({"maximum": 1001.0}, "reference_concentration"),
36
+ ({"tolerance": 0.0}, "tolerance and max_iterations"),
37
+ ({"max_iterations": 0}, "tolerance and max_iterations"),
38
+ ],
39
+ )
40
+ def test_rejects_invalid_control(overrides, message):
41
+ with pytest.raises(ValueError, match=message):
42
+ make_control(**overrides)
@@ -0,0 +1,176 @@
1
+ import csv
2
+ from types import MethodType, SimpleNamespace
3
+
4
+ import pytest
5
+
6
+ from openmc_depletion_feedback import FeedbackControl
7
+ from openmc_depletion_feedback.api import _FeedbackOperator, _keff_stats, _nominal_keff
8
+
9
+
10
+ def make_operator(tmp_path, keff_at_concentration, **control_options):
11
+ options = {
12
+ "material_name": "Coolant",
13
+ "nuclides": ("B",),
14
+ "reference_concentration": 1000.0,
15
+ "minimum": 0.0,
16
+ "maximum": 1000.0,
17
+ "target_keff": 1.01,
18
+ "tolerance": 1.0,
19
+ "keff_tolerance": 1.0e-8,
20
+ "max_iterations": 12,
21
+ "history_file": "feedback.csv",
22
+ }
23
+ options.update(control_options)
24
+ control = FeedbackControl(**options)
25
+ # Bypass OpenMC initialization: these tests exercise the root-search
26
+ # control flow with deterministic keff values, not transport physics.
27
+ operator = object.__new__(_FeedbackOperator)
28
+ operator.control = control
29
+ operator.history_path = tmp_path / "feedback.csv"
30
+ operator._step = 0
31
+ operator._trial = 0
32
+ operator._previous_concentration = None
33
+ operator._previous_boc_keff = None
34
+ operator.expected_step_keff_loss = control.expected_step_keff_loss
35
+ operator.search_level = 0
36
+ operator.calls = []
37
+ with operator.history_path.open("w", newline="") as f:
38
+ csv.writer(f).writerow(
39
+ ["step", "trial", "phase", "concentration", "keff",
40
+ "keff_std_dev", "target_keff", "lower_bound", "upper_bound",
41
+ "search_level", "particles", "batches", "selected",
42
+ "keff_error", "status", "stopping_reason"]
43
+ )
44
+
45
+ def transport(self, vec, source_rate, concentration, final=False, search_level=None):
46
+ self.calls.append((concentration, final, search_level))
47
+ value = keff_at_concentration(concentration, search_level, final)
48
+ if isinstance(value, tuple):
49
+ value = SimpleNamespace(nominal_value=value[0], std_dev=value[1])
50
+ else:
51
+ value = SimpleNamespace(nominal_value=value, std_dev=0.0)
52
+ return SimpleNamespace(k=value)
53
+
54
+ operator._transport = MethodType(transport, operator)
55
+ return operator
56
+
57
+
58
+ def test_nominal_keff_supports_scalar_and_uncertain_values():
59
+ assert _nominal_keff(SimpleNamespace(k=1.002)) == pytest.approx(1.002)
60
+ assert _nominal_keff(
61
+ SimpleNamespace(k=SimpleNamespace(nominal_value=1.002, std_dev=0.0001))
62
+ ) == pytest.approx(1.002)
63
+
64
+
65
+ def test_bisection_finds_decreasing_boron_response_and_writes_history(tmp_path):
66
+ operator = make_operator(tmp_path, lambda ppm, level, final: 1.02 - 0.00002 * ppm)
67
+
68
+ result = operator((object(),), source_rate=1.0)
69
+
70
+ assert _nominal_keff(result) == pytest.approx(1.01)
71
+ assert operator.calls[-1] == (500.0, True, None)
72
+ with (tmp_path / "feedback.csv").open(newline="") as f:
73
+ rows = list(csv.DictReader(f))
74
+ selected = next(row for row in rows if row["selected"] == "1")
75
+ assert selected["concentration"] == "500.00000000"
76
+ assert selected["keff"] == "1.01000000"
77
+ assert selected["keff_std_dev"] == "0.00000000"
78
+ assert selected["status"] == "target_met"
79
+ assert selected["stopping_reason"] == "keff_tolerance"
80
+
81
+
82
+ def test_bisection_handles_increasing_response(tmp_path):
83
+ operator = make_operator(tmp_path, lambda ppm, level, final: 1.0 + 0.00002 * ppm)
84
+ operator.control.target_keff = 1.01
85
+
86
+ operator((object(),), source_rate=1.0)
87
+
88
+ assert operator.calls[-1] == (500.0, True, None)
89
+
90
+
91
+ @pytest.mark.parametrize(
92
+ "response, expected_concentration, expected_status",
93
+ [
94
+ (lambda ppm, level, final: 1.02 - 0.000001 * ppm, 1000.0, "upper_bound"),
95
+ (lambda ppm, level, final: 1.00 - 0.000001 * ppm, 0.0, "lower_bound"),
96
+ ],
97
+ )
98
+ def test_unbracketed_target_selects_closest_endpoint(
99
+ tmp_path, response, expected_concentration, expected_status
100
+ ):
101
+ operator = make_operator(tmp_path, response)
102
+
103
+ operator((object(),), source_rate=1.0)
104
+
105
+ assert operator.calls[-1] == (expected_concentration, True, None)
106
+ with (tmp_path / "feedback.csv").open(newline="") as f:
107
+ row = next(row for row in csv.DictReader(f) if row["selected"] == "1")
108
+ assert row["stopping_reason"] == expected_status
109
+
110
+
111
+ def test_warm_start_searches_local_interval_first(tmp_path):
112
+ operator = make_operator(
113
+ tmp_path,
114
+ lambda ppm, level, final: 1.02 - (0.01 / 620.0) * ppm,
115
+ warm_start_width=100.0,
116
+ )
117
+ operator._previous_concentration = 600.0
118
+
119
+ operator((object(),), source_rate=1.0)
120
+
121
+ assert operator.calls[:2] == [(500.0, False, None), (700.0, False, None)]
122
+ assert operator.calls[-1][0] == pytest.approx(620.0)
123
+ assert operator.calls[-1][1:] == (True, None)
124
+
125
+
126
+ def test_search_refines_histories_when_keff_uncertainty_is_too_large(tmp_path):
127
+ def noisy_response(ppm, level, final):
128
+ mean = 1.02 - 0.00002 * ppm
129
+ sigma = 0.0004 if level in (None, 0) else 0.00005
130
+ return mean, sigma
131
+
132
+ operator = make_operator(
133
+ tmp_path,
134
+ noisy_response,
135
+ keff_tolerance=0.0002,
136
+ max_search_refinements=2,
137
+ confidence_z=2.0,
138
+ )
139
+
140
+ result = operator((object(),), source_rate=1.0)
141
+
142
+ assert _keff_stats(result) == pytest.approx((1.01, 0.0004))
143
+ assert (500.0, False, 1) in operator.calls
144
+ with (tmp_path / "feedback.csv").open(newline="") as f:
145
+ rows = list(csv.DictReader(f))
146
+ refinements = [row for row in rows if row["phase"] == "refinement"]
147
+ assert refinements
148
+ assert refinements[-1]["search_level"] == "1"
149
+
150
+
151
+ def test_end_target_compensates_with_filtered_previous_step_loss(tmp_path):
152
+ operator = make_operator(
153
+ tmp_path,
154
+ lambda ppm, level, final: 1.0,
155
+ target_mode="end",
156
+ target_keff=1.005,
157
+ expected_step_keff_loss=0.01,
158
+ loss_filter_alpha=0.5,
159
+ )
160
+ operator._previous_concentration = 300.0
161
+ operator._previous_boc_keff = 1.02
162
+ evaluated = {}
163
+
164
+ def evaluate(self, vec, source_rate, concentration, target, lower, upper,
165
+ phase="search", level=None):
166
+ evaluated["concentration"] = concentration
167
+ evaluated["phase"] = phase
168
+ return 1.012, 0.0001
169
+
170
+ operator._evaluate = MethodType(evaluate, operator)
171
+
172
+ target = operator._target_for_call((object(),), source_rate=1.0)
173
+
174
+ assert evaluated == {"concentration": 300.0, "phase": "loss_measurement"}
175
+ assert operator.expected_step_keff_loss == pytest.approx(0.009)
176
+ assert target == pytest.approx(1.014)