Skip to content

Commit 49b896b

Browse files
authored
Enable "hybrid" tallies in get_microxs_and_flux (#3831)
1 parent 823b4c9 commit 49b896b

4 files changed

Lines changed: 279 additions & 16 deletions

File tree

openmc/deplete/microxs.py

Lines changed: 133 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ def get_microxs_and_flux(
5050
chain_file: PathLike | Chain | None = None,
5151
path_statepoint: PathLike | None = None,
5252
path_input: PathLike | None = None,
53-
run_kwargs=None
53+
run_kwargs=None,
54+
reaction_rate_opts: dict | None = None,
5455
) -> tuple[list[np.ndarray], list[MicroXS]]:
5556
"""Generate microscopic cross sections and fluxes for multiple domains.
5657
@@ -80,10 +81,12 @@ def get_microxs_and_flux(
8081
Energy group boundaries in [eV] or the name of the group structure.
8182
If left as None energies will default to [0.0, 100e6]
8283
reaction_rate_mode : {"direct", "flux"}, optional
83-
Indicate how reaction rates should be calculated. The "direct" method
84-
tallies reaction rates directly. The "flux" method tallies a multigroup
85-
flux spectrum and then collapses multigroup reaction rates after a
86-
transport solve (with an option to tally some reaction rates directly).
84+
The "direct" method tallies reaction rates directly (per energy
85+
group). The "flux" method tallies a multigroup flux spectrum and then
86+
collapses reaction rates after a transport solve. When
87+
`reaction_rate_opts` is provided with `reaction_rate_mode='flux'`, the
88+
specified nuclide/reaction pairs are tallied directly and those values
89+
override the flux-collapsed values.
8790
chain_file : PathLike or Chain, optional
8891
Path to the depletion chain XML file or an instance of
8992
openmc.deplete.Chain. Used to determine cross sections for materials not
@@ -99,6 +102,10 @@ def get_microxs_and_flux(
99102
not kept.
100103
run_kwargs : dict, optional
101104
Keyword arguments passed to :meth:`openmc.Model.run`
105+
reaction_rate_opts : dict, optional
106+
When `reaction_rate_mode="flux"`, allows selecting a subset of
107+
nuclide/reaction pairs to be computed via direct reaction-rate tallies
108+
(per energy group). Supported keys: "nuclides", "reactions".
102109
103110
Returns
104111
-------
@@ -153,12 +160,36 @@ def get_microxs_and_flux(
153160
flux_tally.scores = ['flux']
154161
model.tallies = [flux_tally]
155162

163+
# Prepare reaction-rate tally for 'direct' or subset for 'flux' with opts
164+
rr_tally = None
165+
rr_nuclides: list[str] = []
166+
rr_reactions: list[str] = []
156167
if reaction_rate_mode == 'direct':
168+
rr_nuclides = list(nuclides)
169+
rr_reactions = list(reactions)
170+
elif reaction_rate_mode == 'flux' and reaction_rate_opts:
171+
opts = reaction_rate_opts or {}
172+
rr_nuclides = list(opts.get('nuclides', []))
173+
rr_reactions = list(opts.get('reactions', []))
174+
# Keep only requested pairs within overall sets
175+
if rr_nuclides:
176+
rr_nuclides = [n for n in rr_nuclides if n in set(nuclides)]
177+
if rr_reactions:
178+
rr_reactions = [r for r in rr_reactions if r in set(reactions)]
179+
180+
# Only construct tally if both lists are non-empty
181+
if rr_nuclides and rr_reactions:
157182
rr_tally = openmc.Tally(name='MicroXS RR')
158-
rr_tally.filters = [domain_filter, energy_filter]
159-
rr_tally.nuclides = nuclides
183+
# Use 1-group energy filter for RR in flux mode
184+
if reaction_rate_mode == 'flux':
185+
rr_energy_filter = openmc.EnergyFilter(
186+
[energy_filter.values[0], energy_filter.values[-1]])
187+
else:
188+
rr_energy_filter = energy_filter
189+
rr_tally.filters = [domain_filter, rr_energy_filter]
190+
rr_tally.nuclides = rr_nuclides
160191
rr_tally.multiply_density = False
161-
rr_tally.scores = reactions
192+
rr_tally.scores = rr_reactions
162193
model.tallies.append(rr_tally)
163194

164195
if openmc.lib.is_initialized:
@@ -196,7 +227,7 @@ def get_microxs_and_flux(
196227

197228
# Read in tally results (on all ranks)
198229
with StatePoint(statepoint_path) as sp:
199-
if reaction_rate_mode == 'direct':
230+
if rr_tally is not None:
200231
rr_tally = sp.tallies[rr_tally.id]
201232
rr_tally._read_results()
202233
flux_tally = sp.tallies[flux_tally.id]
@@ -209,31 +240,46 @@ def get_microxs_and_flux(
209240
# Create list where each item corresponds to one domain
210241
fluxes = list(flux.squeeze((1, 2)))
211242

212-
if reaction_rate_mode == 'direct':
243+
# If we built a reaction-rate tally, compute microscopic cross sections
244+
if rr_tally is not None:
213245
# Get reaction rates
214246
reaction_rates = rr_tally.get_reshaped_data() # (domains, groups, nuclides, reactions)
215247

216248
# Make energy groups last dimension
217249
reaction_rates = np.moveaxis(reaction_rates, 1, -1) # (domains, nuclides, reactions, groups)
218250

251+
# If RR is 1-group, sum flux over groups
252+
if reaction_rate_mode == "flux":
253+
flux = flux.sum(axis=-1, keepdims=True) # (domains, 1, 1, 1)
254+
219255
# Divide RR by flux to get microscopic cross sections. The indexing
220256
# ensures that only non-zero flux values are used, and broadcasting is
221257
# applied to align the shapes of reaction_rates and flux for division.
222-
xs = np.empty_like(reaction_rates) # (domains, nuclides, reactions, groups)
258+
xs = np.zeros_like(reaction_rates) # (domains, nuclides, reactions, groups)
223259
d, _, _, g = np.nonzero(flux)
224260
xs[d, ..., g] = reaction_rates[d, ..., g] / flux[d, :, :, g]
225261

226262
# Create lists where each item corresponds to one domain
227-
micros = [MicroXS(xs_i, nuclides, reactions) for xs_i in xs]
228-
else:
229-
micros = [MicroXS.from_multigroup_flux(
263+
direct_micros = [MicroXS(xs_i, rr_nuclides, rr_reactions) for xs_i in xs]
264+
265+
# If using flux mode, compute flux-collapsed microscopic XS
266+
if reaction_rate_mode == 'flux':
267+
flux_micros = [MicroXS.from_multigroup_flux(
230268
energies=energies,
231269
multigroup_flux=flux_i,
232270
chain_file=chain_file,
233271
nuclides=nuclides,
234272
reactions=reactions
235273
) for flux_i in fluxes]
236274

275+
# Decide which micros to use and merge if needed
276+
if reaction_rate_mode == 'flux' and rr_tally is not None:
277+
micros = [m1.merge(m2) for m1, m2 in zip(flux_micros, direct_micros)]
278+
elif rr_tally is not None:
279+
micros = direct_micros
280+
else:
281+
micros = flux_micros
282+
237283
# Reset tallies
238284
model.tallies = original_tallies
239285

@@ -484,6 +530,79 @@ def from_hdf5(cls, group_or_filename: h5py.Group | PathLike) -> Self:
484530

485531
return cls(data, nuclides, reactions)
486532

533+
def merge(self, other: Self, prefer: str = 'other') -> Self:
534+
"""Merge two MicroXS objects by taking the union of nuclides/reactions.
535+
536+
If the two objects contain overlapping nuclide/reaction entries, values
537+
from `other` will overwrite values from `self` when `prefer='other'`.
538+
When `prefer='self'`, values from `self` are retained for overlapping
539+
entries, and values from `other` are used only for non-overlapping
540+
entries.
541+
542+
Parameters
543+
----------
544+
other : MicroXS
545+
Other MicroXS instance to merge with this one.
546+
prefer : {"other", "self"}
547+
Which instance's data should take precedence on overlap.
548+
549+
Returns
550+
-------
551+
MicroXS
552+
New instance containing the merged data.
553+
"""
554+
check_value('prefer', prefer, {'other', 'self'})
555+
556+
# Require same number of energy groups
557+
if self.data.shape[2] != other.data.shape[2]:
558+
raise ValueError(
559+
'Cannot merge MicroXS with different number of energy groups: '
560+
f"{self.data.shape[2]} vs {other.data.shape[2]}. Ensure that "
561+
'both were generated with consistent group structures and '
562+
'treatments (e.g., both multigroup or both collapsed).'
563+
)
564+
565+
# Build unified axes preserving order (self first, then other's new)
566+
new_nuclides = list(self.nuclides)
567+
for nuc in other.nuclides:
568+
if nuc not in self._index_nuc:
569+
new_nuclides.append(nuc)
570+
new_reactions = list(self.reactions)
571+
for rx in other.reactions:
572+
if rx not in self._index_rx:
573+
new_reactions.append(rx)
574+
575+
# Allocate and fill from self (self's nuclides/reactions map to the
576+
# first indices of new_nuclides/new_reactions by construction)
577+
groups = self.data.shape[2]
578+
data = np.zeros((len(new_nuclides), len(new_reactions), groups))
579+
idx_n = {nuc: i for i, nuc in enumerate(new_nuclides)}
580+
idx_r = {rx: i for i, rx in enumerate(new_reactions)}
581+
582+
n_self = len(self.nuclides)
583+
r_self = len(self.reactions)
584+
data[:n_self, :r_self] = self.data
585+
586+
# Build destination index arrays for other's nuclides/reactions
587+
dst_n = np.array([idx_n[nuc] for nuc in other.nuclides])
588+
dst_r = np.array([idx_r[rx] for rx in other.reactions])
589+
590+
# Copy from other, respecting precedence
591+
if prefer == 'other':
592+
data[np.ix_(dst_n, dst_r)] = other.data
593+
else:
594+
# Copy only entries where nuc or rx is absent from self
595+
nuc_is_new = np.array(
596+
[nuc not in self._index_nuc for nuc in other.nuclides])
597+
rx_is_new = np.array(
598+
[rx not in self._index_rx for rx in other.reactions])
599+
mask = nuc_is_new[:, np.newaxis] | rx_is_new[np.newaxis, :]
600+
src_i, src_j = np.where(mask)
601+
if src_i.size:
602+
data[dst_n[src_i], dst_r[src_j]] = other.data[src_i, src_j]
603+
604+
return MicroXS(data, new_nuclides, new_reactions)
605+
487606

488607
def write_microxs_hdf5(
489608
micros: Sequence[MicroXS],

tests/regression_tests/microxs/test.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def model():
3434
[
3535
("materials", "direct"),
3636
("materials", "flux"),
37+
("materials", "hybrid"),
3738
("mesh", "direct"),
3839
("mesh", "flux"),
3940
]
@@ -49,12 +50,21 @@ def test_from_model(model, domain_type, rr_mode):
4950
domains = mesh
5051
nuclides = ['U235', 'O16', 'Xe135']
5152
kwargs = {
52-
'reaction_rate_mode': rr_mode,
5353
'chain_file': CHAIN_FILE,
5454
'path_statepoint': 'neutron_transport.h5',
5555
}
5656
if rr_mode == 'flux':
57+
kwargs['reaction_rate_mode'] = 'flux'
5758
kwargs['energies'] = 'CASMO-40'
59+
elif rr_mode == 'hybrid':
60+
kwargs['reaction_rate_mode'] = 'flux'
61+
kwargs['energies'] = 'CASMO-40'
62+
kwargs['reaction_rate_opts'] = {
63+
'nuclides': ['U235'],
64+
'reactions': ['fission'],
65+
}
66+
else:
67+
kwargs['reaction_rate_mode'] = rr_mode
5868
_, test_xs = get_microxs_and_flux(model, domains, nuclides, **kwargs)
5969
if config['update']:
6070
test_xs[0].to_csv(f'test_reference_{domain_type}_{rr_mode}.csv')
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
nuclides,reactions,groups,xs
2+
U235,"(n,gamma)",1,0.1500301670375847
3+
U235,fission,1,1.2578145727734291
4+
O16,"(n,gamma)",1,0.00012069778439640312
5+
O16,fission,1,0.0
6+
Xe135,"(n,gamma)",1,0.014820264774863558
7+
Xe135,fission,1,0.0

0 commit comments

Comments
 (0)