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.
- openmc_depletion_feedback-0.1.0/PKG-INFO +86 -0
- openmc_depletion_feedback-0.1.0/README.md +76 -0
- openmc_depletion_feedback-0.1.0/pyproject.toml +22 -0
- openmc_depletion_feedback-0.1.0/setup.cfg +4 -0
- openmc_depletion_feedback-0.1.0/src/openmc_depletion_feedback/__init__.py +7 -0
- openmc_depletion_feedback-0.1.0/src/openmc_depletion_feedback/api.py +336 -0
- openmc_depletion_feedback-0.1.0/src/openmc_depletion_feedback/control.py +76 -0
- openmc_depletion_feedback-0.1.0/src/openmc_depletion_feedback.egg-info/PKG-INFO +86 -0
- openmc_depletion_feedback-0.1.0/src/openmc_depletion_feedback.egg-info/SOURCES.txt +12 -0
- openmc_depletion_feedback-0.1.0/src/openmc_depletion_feedback.egg-info/dependency_links.txt +1 -0
- openmc_depletion_feedback-0.1.0/src/openmc_depletion_feedback.egg-info/requires.txt +4 -0
- openmc_depletion_feedback-0.1.0/src/openmc_depletion_feedback.egg-info/top_level.txt +1 -0
- openmc_depletion_feedback-0.1.0/tests/test_control.py +42 -0
- openmc_depletion_feedback-0.1.0/tests/test_feedback_search.py +176 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openmc_depletion_feedback
|
|
@@ -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)
|