• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

openmc-dev / openmc / 21489819490

29 Jan 2026 06:21PM UTC coverage: 80.077% (-1.9%) from 81.953%
21489819490

Pull #3757

github

web-flow
Merge d08626053 into f7a734189
Pull Request #3757: Testing point detectors

16004 of 22621 branches covered (70.75%)

Branch coverage included in aggregate %.

94 of 518 new or added lines in 26 files covered. (18.15%)

1021 existing lines in 52 files now uncovered.

53779 of 64524 relevant lines covered (83.35%)

8016833.26 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

38.66
/openmc/data/multipole.py
1
from numbers import Real
1✔
2
from math import exp, erf, pi, sqrt
1✔
3
from copy import deepcopy
1✔
4

5
import os
1✔
6
import h5py
1✔
7
import pickle
1✔
8
import numpy as np
1✔
9
from scipy.signal import find_peaks
1✔
10

11
import openmc.checkvalue as cv
1✔
12
from ..exceptions import DataError
1✔
13
from ..mixin import EqualityMixin
1✔
14
from . import WMP_VERSION, WMP_VERSION_MAJOR
1✔
15
from .data import K_BOLTZMANN
1✔
16
from .neutron import IncidentNeutron
1✔
17
from .resonance import ResonanceRange
1✔
18

19

20
# Constants that determine which value to access
21
_MP_EA = 0       # Pole
1✔
22

23
# Residue indices
24
_MP_RS = 1       # Residue scattering
1✔
25
_MP_RA = 2       # Residue absorption
1✔
26
_MP_RF = 3       # Residue fission
1✔
27

28
# Polynomial fit indices
29
_FIT_S = 0       # Scattering
1✔
30
_FIT_A = 1       # Absorption
1✔
31
_FIT_F = 2       # Fission
1✔
32

33
# Upper temperature limit (K)
34
TEMPERATURE_LIMIT = 3000
1✔
35

36
# Logging control
37
DETAILED_LOGGING = 2
1✔
38

39

40
def _faddeeva(z):
1✔
41
    r"""Evaluate the complex Faddeeva function.
42

43
    Technically, the value we want is given by the equation:
44

45
    .. math::
46
        w(z) = \frac{i}{\pi} \int_{-\infty}^{\infty} \frac{1}{z - t}
47
        \exp(-t^2) \text{d}t
48

49
    as shown in Equation 63 from Hwang, R. N. "A rigorous pole
50
    representation of multilevel cross sections and its practical
51
    applications." Nuclear Science and Engineering 96.3 (1987): 192-209.
52

53
    The :func:`scipy.special.wofz` function evaluates
54
    :math:`w(z) = \exp(-z^2) \text{erfc}(-iz)`. These two forms of the Faddeeva
55
    function are related by a transformation.
56

57
    If we call the integral form :math:`w_\text{int}`, and the function form
58
    :math:`w_\text{fun}`:
59

60
    .. math::
61
        w_\text{int}(z) =
62
        \begin{cases}
63
            w_\text{fun}(z) & \text{for } \text{Im}(z) > 0\\
64
            -w_\text{fun}(z^*)^* & \text{for } \text{Im}(z) < 0
65
        \end{cases}
66

67
    Parameters
68
    ----------
69
    z : complex
70
        Argument to the Faddeeva function.
71

72
    Returns
73
    -------
74
    complex
75
        :math:`\frac{i}{\pi} \int_{-\infty}^{\infty} \frac{1}{z - t} \exp(-t^2)
76
        \text{d}t`
77

78
    """
79
    from scipy.special import wofz
1✔
80
    if np.angle(z) > 0:
1✔
81
        return wofz(z)
×
82
    else:
83
        return -np.conj(wofz(z.conjugate()))
1✔
84

85

86
def _broaden_wmp_polynomials(E, dopp, n):
1✔
87
    r"""Evaluate Doppler-broadened windowed multipole curvefit.
88

89
    The curvefit is a polynomial of the form :math:`\frac{a}{E}
90
    + \frac{b}{\sqrt{E}} + c + d \sqrt{E} + \ldots`
91

92
    Parameters
93
    ----------
94
    E : float
95
        Energy to evaluate at.
96
    dopp : float
97
        sqrt(atomic weight ratio / kT) in units of eV.
98
    n : int
99
        Number of components to the polynomial.
100

101
    Returns
102
    -------
103
    np.ndarray
104
        The value of each Doppler-broadened curvefit polynomial term.
105

106
    """
107
    sqrtE = sqrt(E)
1✔
108
    beta = sqrtE * dopp
1✔
109
    half_inv_dopp2 = 0.5 / dopp**2
1✔
110
    quarter_inv_dopp4 = half_inv_dopp2**2
1✔
111

112
    if beta > 6.0:
1✔
113
        # Save time, ERF(6) is 1 to machine precision.
114
        # beta/sqrtpi*exp(-beta**2) is also approximately 1 machine epsilon.
115
        erf_beta = 1.0
1✔
116
        exp_m_beta2 = 0.0
1✔
117
    else:
118
        erf_beta = erf(beta)
1✔
119
        exp_m_beta2 = exp(-beta**2)
1✔
120

121
    # Assume that, for sure, we'll use a second order (1/E, 1/V, const)
122
    # fit, and no less.
123

124
    factors = np.zeros(n)
1✔
125

126
    factors[0] = erf_beta / E
1✔
127
    factors[1] = 1.0 / sqrtE
1✔
128
    factors[2] = (factors[0] * (half_inv_dopp2 + E)
1✔
129
                  + exp_m_beta2 / (beta * sqrt(pi)))
130

131
    # Perform recursive broadening of high order components. range(1, n-2)
132
    # replaces a do i = 1, n-3.  All indices are reduced by one due to the
133
    # 1-based vs. 0-based indexing.
134
    for i in range(1, n-2):
1✔
135
        if i != 1:
1✔
136
            factors[i+2] = (-factors[i-2] * (i - 1.0) * i * quarter_inv_dopp4
1✔
137
                + factors[i] * (E + (1.0 + 2.0 * i) * half_inv_dopp2))
138
        else:
139
            factors[i+2] = factors[i]*(E + (1.0 + 2.0 * i) * half_inv_dopp2)
1✔
140

141
    return factors
1✔
142

143

144
def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None,
1✔
145
                n_vf_iter=30, log=False, path_out=None):
146
    """Convert point-wise cross section to multipole data via vector fitting.
147

148
    Parameters
149
    ----------
150
    energy : np.ndarray
151
        Energy array
152
    ce_xs : np.ndarray
153
        Point-wise cross sections to be fitted, with shape (number of reactions,
154
        number of energy points)
155
    mts : Iterable of int
156
        Reaction list
157
    rtol : float, optional
158
        Relative error tolerance
159
    atol : float, optional
160
        Absolute error tolerance
161
    orders : Iterable of int, optional
162
        A list of orders (number of poles) to be searched
163
    n_vf_iter : int, optional
164
        Number of maximum VF iterations
165
    log : bool or int, optional
166
        Whether to print running logs (use int for verbosity control)
167
    path_out : str, optional
168
        Path to save the figures to show discrepancies between the original and
169
        fitted cross sections for different reactions
170

171
    Returns
172
    -------
173
    tuple
174
        (poles, residues)
175

176
    """
177

178
    # import vectfit package: https://github.com/liangjg/vectfit
UNCOV
179
    import vectfit as vf
×
180

UNCOV
181
    ne = energy.size
×
UNCOV
182
    nmt = len(mts)
×
UNCOV
183
    if ce_xs.shape != (nmt, ne):
×
184
        raise ValueError('Inconsistent cross section data.')
×
185

186
    # construct test data: interpolate xs with finer grids
UNCOV
187
    n_finer = 10
×
UNCOV
188
    ne_test = (ne - 1)*n_finer + 1
×
UNCOV
189
    test_energy = np.interp(np.arange(ne_test),
×
190
                            np.arange(ne_test, step=n_finer), energy)
UNCOV
191
    test_energy[[0, -1]] = energy[[0, -1]]  # avoid numerical issue
×
UNCOV
192
    test_xs_ref = np.zeros((nmt, ne_test))
×
UNCOV
193
    for i in range(nmt):
×
UNCOV
194
        test_xs_ref[i] = np.interp(test_energy, energy, ce_xs[i])
×
195

UNCOV
196
    if log:
×
UNCOV
197
        print(f"  energy: {energy[0]:.3e} to {energy[-1]:.3e} eV ({ne} points)")
×
UNCOV
198
        print(f"  error tolerance: rtol={rtol}, atol={atol}")
×
199

200
    # transform xs (sigma) and energy (E) to f (sigma*E) and s (sqrt(E)) to be
201
    # compatible with the multipole representation
UNCOV
202
    f = ce_xs * energy
×
UNCOV
203
    s = np.sqrt(energy)
×
UNCOV
204
    test_s = np.sqrt(test_energy)
×
205

206
    # inverse weighting is used for minimizing the relative deviation instead of
207
    # absolute deviation in vector fitting
UNCOV
208
    with np.errstate(divide='ignore'):
×
UNCOV
209
        weight = 1.0/f
×
210

211
    # avoid too large weights which will harm the fitting accuracy
UNCOV
212
    min_cross_section = 1e-7
×
UNCOV
213
    for i in range(nmt):
×
UNCOV
214
        if np.all(ce_xs[i] <= min_cross_section):
×
215
            weight[i] = 1.0
×
UNCOV
216
        elif np.any(ce_xs[i] <= min_cross_section):
×
217
            weight[i, ce_xs[i] <= min_cross_section] = \
×
218
               max(weight[i, ce_xs[i] > min_cross_section])
219

220
    # detect peaks (resonances) and determine VF order search range
UNCOV
221
    peaks, _ = find_peaks(ce_xs[0] + ce_xs[1])
×
UNCOV
222
    n_peaks = peaks.size
×
UNCOV
223
    if orders is not None:
×
224
        # make sure orders are even integers
225
        orders = list(set([int(i/2)*2 for i in orders if i >= 2]))
×
226
    else:
UNCOV
227
        lowest_order = max(2, 2*n_peaks)
×
UNCOV
228
        highest_order = max(200, 4*n_peaks)
×
UNCOV
229
        orders = list(range(lowest_order, highest_order + 1, 2))
×
230

UNCOV
231
    if log:
×
UNCOV
232
        print(f"Found {n_peaks} peaks")
×
UNCOV
233
        print(f"Fitting orders from {orders[0]} to {orders[-1]}")
×
234

235
    # perform VF with increasing orders
UNCOV
236
    found_ideal = False
×
UNCOV
237
    n_discarded = 0  # for accelation, number of discarded searches
×
UNCOV
238
    best_quality = best_ratio = -np.inf
×
UNCOV
239
    for i, order in enumerate(orders):
×
UNCOV
240
        if log:
×
UNCOV
241
            print(f"Order={order}({i}/{len(orders)})")
×
242
        # initial guessed poles
UNCOV
243
        poles_r = np.linspace(s[0], s[-1], order//2)
×
UNCOV
244
        poles = poles_r + poles_r*0.01j
×
UNCOV
245
        poles = np.sort(np.append(poles, np.conj(poles)))
×
246

UNCOV
247
        found_better = False
×
248
        # fitting iteration
UNCOV
249
        for i_vf in range(n_vf_iter):
×
UNCOV
250
            if log >= DETAILED_LOGGING:
×
251
                print(f"VF iteration {i_vf + 1}/{n_vf_iter}")
×
252

253
            # call vf
UNCOV
254
            poles, residues, cf, f_fit, rms = vf.vectfit(f, s, poles, weight)
×
255

256
            # convert real pole to conjugate pairs
UNCOV
257
            n_real_poles = 0
×
UNCOV
258
            new_poles = []
×
UNCOV
259
            for p in poles:
×
UNCOV
260
                p_r, p_i = np.real(p), np.imag(p)
×
UNCOV
261
                if (s[0] <= p_r <= s[-1]) and p_i == 0.:
×
UNCOV
262
                    new_poles += [p_r+p_r*0.01j, p_r-p_r*0.01j]
×
UNCOV
263
                    n_real_poles += 1
×
264
                else:
UNCOV
265
                    new_poles += [p]
×
UNCOV
266
            new_poles = np.array(new_poles)
×
267
            # re-calculate residues if poles changed
UNCOV
268
            if n_real_poles > 0:
×
UNCOV
269
                if log >= DETAILED_LOGGING:
×
270
                    print(f"  # real poles: {n_real_poles}")
×
UNCOV
271
                new_poles, residues, cf, f_fit, rms = \
×
272
                      vf.vectfit(f, s, new_poles, weight, skip_pole=True)
273

274
            # assess the result on test grid
UNCOV
275
            test_xs = vf.evaluate(test_s, new_poles, residues) / test_energy
×
UNCOV
276
            abserr = np.abs(test_xs - test_xs_ref)
×
UNCOV
277
            with np.errstate(invalid='ignore', divide='ignore'):
×
UNCOV
278
                relerr = abserr / test_xs_ref
×
UNCOV
279
                if np.any(np.isnan(abserr)):
×
280
                    maxre, ratio, ratio2 = np.inf, -np.inf, -np.inf
×
UNCOV
281
                elif np.all(abserr <= atol):
×
282
                    maxre, ratio, ratio2 = 0., 1., 1.
×
283
                else:
UNCOV
284
                    maxre = np.max(relerr[abserr > atol])
×
UNCOV
285
                    ratio = np.sum((relerr < rtol) | (abserr < atol)) / relerr.size
×
UNCOV
286
                    ratio2 = np.sum((relerr < 10*rtol) | (abserr < atol)) / relerr.size
×
287

288
            # define a metric for choosing the best fitting results
289
            # basically, it is preferred to have more points within accuracy
290
            # tolerance, smaller maximum deviation and fewer poles
291
            #TODO: improve the metric with clearer basis
UNCOV
292
            quality = ratio + ratio2 - min(0.1*maxre, 1) - 0.001*new_poles.size
×
293

UNCOV
294
            if np.any(test_xs < -atol):
×
UNCOV
295
                quality = -np.inf
×
296

UNCOV
297
            if log >= DETAILED_LOGGING:
×
298
                print(f"  # poles: {new_poles.size}")
×
299
                print(f"  Max relative error: {maxre * 100:.3f}%")
×
300
                print(f"  Satisfaction: {ratio * 100:.1f}%, {ratio2 * 100:.1f}%")
×
301
                print(f"  Quality: {quality:.2f}")
×
302

UNCOV
303
            if quality > best_quality:
×
UNCOV
304
                if log >= DETAILED_LOGGING:
×
305
                    print("  Best so far!")
×
UNCOV
306
                found_better = True
×
UNCOV
307
                best_quality, best_ratio = quality, ratio
×
UNCOV
308
                best_poles, best_residues = new_poles, residues
×
UNCOV
309
                best_test_xs, best_relerr = test_xs, relerr
×
UNCOV
310
                if best_ratio >= 1.0:
×
311
                    if log:
×
312
                        print("Found ideal results. Stop!")
×
313
                    found_ideal = True
×
314
                    break
×
315
            else:
UNCOV
316
                if log >= DETAILED_LOGGING:
×
317
                    print("  Discarded!")
×
318

UNCOV
319
        if found_ideal:
×
320
            break
×
321

322
        # acceleration
UNCOV
323
        if found_better:
×
UNCOV
324
            n_discarded = 0
×
325
        else:
UNCOV
326
            if order > max(2*n_peaks, 50) and best_ratio > 0.7:
×
UNCOV
327
                n_discarded += 1
×
UNCOV
328
                if n_discarded >= 10 or (n_discarded >= 5 and best_ratio > 0.9):
×
UNCOV
329
                    if log >= DETAILED_LOGGING:
×
330
                        print("Couldn't get better results. Stop!")
×
UNCOV
331
                    break
×
332

333
    # merge conjugate poles
UNCOV
334
    real_idx = []
×
UNCOV
335
    conj_idx = []
×
UNCOV
336
    found_conj = False
×
UNCOV
337
    for i, p in enumerate(best_poles):
×
UNCOV
338
        if found_conj:
×
UNCOV
339
            found_conj = False
×
UNCOV
340
            continue
×
UNCOV
341
        if np.imag(p) == 0.:
×
UNCOV
342
            real_idx.append(i)
×
343
        else:
UNCOV
344
            if i < best_poles.size and np.conj(p) == best_poles[i + 1]:
×
UNCOV
345
                found_conj = True
×
UNCOV
346
                conj_idx.append(i)
×
347
            else:
348
                raise RuntimeError("Complex poles are not conjugate!")
×
UNCOV
349
    if log:
×
UNCOV
350
        print("Found {} real poles and {} conjugate complex pairs.".format(
×
351
               len(real_idx), len(conj_idx)))
UNCOV
352
    mp_poles = best_poles[real_idx + conj_idx]
×
UNCOV
353
    mp_residues = np.concatenate((best_residues[:, real_idx],
×
354
                                  best_residues[:, conj_idx]*2), axis=1)/1j
UNCOV
355
    if log:
×
UNCOV
356
        print(f"Final number of poles: {mp_poles.size}")
×
357

UNCOV
358
    if path_out:
×
359
        if not os.path.exists(path_out):
×
360
            os.makedirs(path_out)
×
361
        for i, mt in enumerate(mts):
×
362
            if not test_xs_ref[i].any():
×
363
                continue
×
364
            import matplotlib.pyplot as plt
×
365
            fig, ax1 = plt.subplots()
×
366
            lns1 = ax1.loglog(test_energy, test_xs_ref[i], 'g', label="ACE xs")
×
367
            lns2 = ax1.loglog(test_energy, best_test_xs[i], 'b', label="VF xs")
×
368
            ax2 = ax1.twinx()
×
369
            lns3 = ax2.loglog(test_energy, best_relerr[i], 'r',
×
370
                              label="Relative error", alpha=0.5)
371
            lns = lns1 + lns2 + lns3
×
372
            labels = [l.get_label() for l in lns]
×
373
            ax1.legend(lns, labels, loc='best')
×
374
            ax1.set_xlabel('energy (eV)')
×
375
            ax1.set_ylabel('cross section (b)', color='b')
×
376
            ax1.tick_params('y', colors='b')
×
377
            ax2.set_ylabel('relative error', color='r')
×
378
            ax2.tick_params('y', colors='r')
×
379

380
            plt.title(f"MT {mt} vector fitted with {mp_poles.size} poles")
×
381
            fig.tight_layout()
×
382
            fig_file = os.path.join(path_out, "{:.0f}-{:.0f}_MT{}.png".format(
×
383
                                    energy[0], energy[-1], mt))
384
            plt.savefig(fig_file)
×
385
            plt.close()
×
386
            if log:
×
387
                print(f"Saved figure: {fig_file}")
×
388

UNCOV
389
    return (mp_poles, mp_residues)
×
390

391

392
def vectfit_nuclide(endf_file, njoy_error=5e-4, vf_pieces=None,
1✔
393
                    log=False, path_out=None, mp_filename=None, **kwargs):
394
    r"""Generate multipole data for a nuclide from ENDF.
395

396
    Parameters
397
    ----------
398
    endf_file : str
399
        Path to ENDF evaluation
400
    njoy_error : float, optional
401
        Fractional error tolerance for processing point-wise data with NJOY
402
    vf_pieces : integer, optional
403
        Number of equal-in-momentum spaced energy pieces for data fitting
404
    log : bool or int, optional
405
        Whether to print running logs (use int for verbosity control)
406
    path_out : str, optional
407
        Path to write out mutipole data file and vector fitting figures
408
    mp_filename : str, optional
409
        File name to write out multipole data
410
    **kwargs
411
        Keyword arguments passed to :func:`openmc.data.multipole._vectfit_xs`
412

413
    Returns
414
    -------
415
    mp_data
416
        Dictionary containing necessary multipole data of the nuclide
417

418
    """
419

420
    # ======================================================================
421
    # PREPARE POINT-WISE XS
422

423
    # make 0K ACE data using njoy
UNCOV
424
    if log:
×
UNCOV
425
        print(f"Running NJOY to get 0K point-wise data (error={njoy_error})...")
×
426

UNCOV
427
    nuc_ce = IncidentNeutron.from_njoy(endf_file, temperatures=[0.0],
×
428
             error=njoy_error, broadr=False, heatr=False, purr=False)
429

UNCOV
430
    if log:
×
UNCOV
431
        print("Parsing cross sections within resolved resonance range...")
×
432

433
    # Determine upper energy: the lower of RRR upper bound and first threshold
UNCOV
434
    endf_res = IncidentNeutron.from_endf(endf_file).resonances
×
UNCOV
435
    if hasattr(endf_res, 'resolved') and \
×
436
       hasattr(endf_res.resolved, 'energy_max') and \
437
       type(endf_res.resolved) is not ResonanceRange:
438
        E_max = endf_res.resolved.energy_max
×
UNCOV
439
    elif hasattr(endf_res, 'unresolved') and \
×
440
         hasattr(endf_res.unresolved, 'energy_min'):
441
        E_max = endf_res.unresolved.energy_min
×
442
    else:
UNCOV
443
        E_max = nuc_ce.energy['0K'][-1]
×
UNCOV
444
    E_max_idx = np.searchsorted(nuc_ce.energy['0K'], E_max, side='right') - 1
×
UNCOV
445
    for mt in nuc_ce.reactions:
×
UNCOV
446
        if hasattr(nuc_ce.reactions[mt].xs['0K'], '_threshold_idx'):
×
UNCOV
447
            threshold_idx = nuc_ce.reactions[mt].xs['0K']._threshold_idx
×
UNCOV
448
            if 0 < threshold_idx < E_max_idx:
×
UNCOV
449
                E_max_idx = threshold_idx
×
450

451
    # parse energy and cross sections
UNCOV
452
    energy = nuc_ce.energy['0K'][:E_max_idx + 1]
×
UNCOV
453
    E_min, E_max = energy[0], energy[-1]
×
UNCOV
454
    n_points = energy.size
×
UNCOV
455
    total_xs = nuc_ce[1].xs['0K'](energy)
×
UNCOV
456
    elastic_xs = nuc_ce[2].xs['0K'](energy)
×
457

UNCOV
458
    try:
×
UNCOV
459
        absorption_xs = nuc_ce[27].xs['0K'](energy)
×
460
    except KeyError:
×
461
        absorption_xs = np.zeros_like(total_xs)
×
462

UNCOV
463
    fissionable = False
×
UNCOV
464
    try:
×
UNCOV
465
        fission_xs = nuc_ce[18].xs['0K'](energy)
×
UNCOV
466
        fissionable = True
×
UNCOV
467
    except KeyError:
×
UNCOV
468
        pass
×
469

470
    # make vectors
UNCOV
471
    if fissionable:
×
UNCOV
472
        ce_xs = np.vstack((elastic_xs, absorption_xs, fission_xs))
×
UNCOV
473
        mts = [2, 27, 18]
×
474
    else:
UNCOV
475
        ce_xs = np.vstack((elastic_xs, absorption_xs))
×
UNCOV
476
        mts = [2, 27]
×
477

UNCOV
478
    if log:
×
UNCOV
479
        print(f"  MTs: {mts}")
×
UNCOV
480
        print(f"  Energy range: {E_min:.3e} to {E_max:.3e} eV ({n_points} points)")
×
481

482
    # ======================================================================
483
    # PERFORM VECTOR FITTING
484

UNCOV
485
    if vf_pieces is None:
×
486
        # divide into pieces for complex nuclides
UNCOV
487
        peaks, _ = find_peaks(total_xs)
×
UNCOV
488
        n_peaks = peaks.size
×
UNCOV
489
        if n_peaks > 200 or n_points > 30000 or n_peaks * n_points > 100*10000:
×
490
            vf_pieces = max(5, n_peaks // 50,  n_points // 2000)
×
491
        else:
UNCOV
492
            vf_pieces = 1
×
UNCOV
493
    piece_width = (sqrt(E_max) - sqrt(E_min)) / vf_pieces
×
494

UNCOV
495
    alpha = nuc_ce.atomic_weight_ratio/(K_BOLTZMANN*TEMPERATURE_LIMIT)
×
496

UNCOV
497
    poles, residues = [], []
×
498
    # VF piece by piece
UNCOV
499
    for i_piece in range(vf_pieces):
×
UNCOV
500
        if log:
×
UNCOV
501
            print(f"Vector fitting piece {i_piece + 1}/{vf_pieces}...")
×
502
        # start E of this piece
UNCOV
503
        e_bound = (sqrt(E_min) + piece_width*(i_piece-0.5))**2
×
UNCOV
504
        if i_piece == 0 or sqrt(alpha*e_bound) < 4.0:
×
UNCOV
505
            e_start = E_min
×
UNCOV
506
            e_start_idx = 0
×
507
        else:
508
            e_start = max(E_min, (sqrt(alpha*e_bound) - 4.0)**2/alpha)
×
509
            e_start_idx = np.searchsorted(energy, e_start, side='right') - 1
×
510
        # end E of this piece
UNCOV
511
        e_bound = (sqrt(E_min) + piece_width*(i_piece + 1))**2
×
UNCOV
512
        e_end = min(E_max, (sqrt(alpha*e_bound) + 4.0)**2/alpha)
×
UNCOV
513
        e_end_idx = np.searchsorted(energy, e_end, side='left') + 1
×
UNCOV
514
        e_idx = range(e_start_idx, min(e_end_idx + 1, n_points))
×
515

UNCOV
516
        p, r = _vectfit_xs(energy[e_idx], ce_xs[:, e_idx], mts, log=log,
×
517
                           path_out=path_out, **kwargs)
518

UNCOV
519
        poles.append(p)
×
UNCOV
520
        residues.append(r)
×
521

522
    # collect multipole data into a dictionary
UNCOV
523
    mp_data = {"name": nuc_ce.name,
×
524
               "AWR": nuc_ce.atomic_weight_ratio,
525
               "E_min": E_min,
526
               "E_max": E_max,
527
               "poles": poles,
528
               "residues": residues}
529

530
    # dump multipole data to file
UNCOV
531
    if path_out:
×
532
        if not os.path.exists(path_out):
×
533
            os.makedirs(path_out)
×
534
        if not mp_filename:
×
535
            mp_filename = f"{nuc_ce.name}_mp.pickle"
×
536
        mp_filename = os.path.join(path_out, mp_filename)
×
537
        with open(mp_filename, 'wb') as f:
×
538
            pickle.dump(mp_data, f)
×
539
        if log:
×
540
            print(f"Dumped multipole data to file: {mp_filename}")
×
541

UNCOV
542
    return mp_data
×
543

544

545
def _windowing(mp_data, n_cf, rtol=1e-3, atol=1e-5, n_win=None, spacing=None,
1✔
546
               log=False):
547
    """Generate windowed multipole library from multipole data with specific
548
        settings of window size, curve fit order, etc.
549

550
    Parameters
551
    ----------
552
    mp_data : dict
553
        Multipole data
554
    n_cf : int
555
        Curve fitting order
556
    rtol : float, optional
557
        Maximum relative error tolerance
558
    atol : float, optional
559
        Minimum absolute error tolerance
560
    n_win : int, optional
561
        Number of equal-in-mementum spaced energy windows
562
    spacing : float, optional
563
        Inner window spacing (sqrt energy space)
564
    log : bool or int, optional
565
        Whether to print running logs (use int for verbosity control)
566

567
    Returns
568
    -------
569
    openmc.data.WindowedMultipole
570
        Resonant cross sections represented in the windowed multipole
571
        format.
572

573
    """
574

575
    # import vectfit package: https://github.com/liangjg/vectfit
UNCOV
576
    import vectfit as vf
×
577

578
    # unpack multipole data
UNCOV
579
    name = mp_data["name"]
×
UNCOV
580
    awr = mp_data["AWR"]
×
UNCOV
581
    E_min = mp_data["E_min"]
×
UNCOV
582
    E_max = mp_data["E_max"]
×
UNCOV
583
    mp_poles = mp_data["poles"]
×
UNCOV
584
    mp_residues = mp_data["residues"]
×
585

UNCOV
586
    n_pieces = len(mp_poles)
×
UNCOV
587
    piece_width = (sqrt(E_max) - sqrt(E_min)) / n_pieces
×
UNCOV
588
    alpha = awr / (K_BOLTZMANN*TEMPERATURE_LIMIT)
×
589

590
    # determine window size
UNCOV
591
    if n_win is None:
×
592
        if spacing is not None:
×
593
            # ensure the windows are within the multipole energy range
594
            n_win = int((sqrt(E_max) - sqrt(E_min)) / spacing)
×
595
            E_max = (sqrt(E_min) + n_win*spacing)**2
×
596
        else:
597
            n_win = 1000
×
598
    # inner window size
UNCOV
599
    spacing = (sqrt(E_max) - sqrt(E_min)) / n_win
×
600
    # make sure inner window size is smaller than energy piece size
UNCOV
601
    if spacing > piece_width:
×
602
        raise ValueError('Window spacing cannot be larger than piece spacing.')
×
603

UNCOV
604
    if log:
×
UNCOV
605
        print("Windowing:")
×
UNCOV
606
        print(f"  config: # windows={n_win}, spacing={spacing}, CF order={n_cf}")
×
UNCOV
607
        print(f"  error tolerance: rtol={rtol}, atol={atol}")
×
608

609
    # sort poles (and residues) by the real component of the pole
UNCOV
610
    for ip in range(n_pieces):
×
UNCOV
611
        indices = mp_poles[ip].argsort()
×
UNCOV
612
        mp_poles[ip] = mp_poles[ip][indices]
×
UNCOV
613
        mp_residues[ip] = mp_residues[ip][:, indices]
×
614

615
    # initialize an array to record whether each pole is used or not
UNCOV
616
    poles_unused = [np.ones_like(p, dtype=int) for p in mp_poles]
×
617

618
    # optimize the windows: the goal is to find the least set of significant
619
    # consecutive poles and curve fit coefficients to reproduce cross section
UNCOV
620
    win_data = []
×
UNCOV
621
    for iw in range(n_win):
×
UNCOV
622
        if log >= DETAILED_LOGGING:
×
623
            print(f"Processing window {iw + 1}/{n_win}...")
×
624

625
        # inner window boundaries
UNCOV
626
        inbegin = sqrt(E_min) + spacing * iw
×
UNCOV
627
        inend = inbegin + spacing
×
UNCOV
628
        incenter = (inbegin + inend) / 2.0
×
629
        # extend window energy range for Doppler broadening
UNCOV
630
        if iw == 0 or sqrt(alpha)*inbegin < 4.0:
×
UNCOV
631
            e_start = inbegin**2
×
632
        else:
UNCOV
633
            e_start = max(E_min, (sqrt(alpha)*inbegin - 4.0)**2/alpha)
×
UNCOV
634
        e_end = min(E_max, (sqrt(alpha)*inend + 4.0)**2/alpha)
×
635

636
        # locate piece and relevant poles
UNCOV
637
        i_piece = min(n_pieces - 1, int((inbegin - sqrt(E_min))/piece_width + 0.5))
×
UNCOV
638
        poles, residues = mp_poles[i_piece], mp_residues[i_piece]
×
UNCOV
639
        n_poles = poles.size
×
640

641
        # generate energy points for fitting: equally spaced in momentum
UNCOV
642
        n_points = min(max(100, int((e_end - e_start)*4)), 10000)
×
UNCOV
643
        energy_sqrt = np.linspace(np.sqrt(e_start), np.sqrt(e_end), n_points)
×
UNCOV
644
        energy = energy_sqrt**2
×
645

646
        # reference xs from multipole form, note the residue terms in the
647
        # multipole and vector fitting representations differ by a 1j
UNCOV
648
        xs_ref = vf.evaluate(energy_sqrt, poles, residues*1j) / energy
×
649

650
        # curve fit matrix
UNCOV
651
        matrix = np.vstack([energy**(0.5*i - 1) for i in range(n_cf + 1)]).T
×
652

653
        # start from 0 poles, initialize pointers to the center nearest pole
UNCOV
654
        center_pole_ind = np.argmin((np.fabs(poles.real - incenter)))
×
UNCOV
655
        lp = rp = center_pole_ind
×
UNCOV
656
        while True:
×
UNCOV
657
            if log >= DETAILED_LOGGING:
×
658
                print(f"Trying poles {lp} to {rp}")
×
659

660
            # calculate the cross sections contributed by the windowed poles
UNCOV
661
            if rp > lp:
×
UNCOV
662
                xs_wp = vf.evaluate(energy_sqrt, poles[lp:rp],
×
663
                                    residues[:, lp:rp]*1j) / energy
664
            else:
UNCOV
665
                xs_wp = np.zeros_like(xs_ref)
×
666

667
            # do least square curve fit on the remains
UNCOV
668
            coefs = np.linalg.lstsq(matrix, (xs_ref - xs_wp).T, rcond=None)[0]
×
UNCOV
669
            xs_fit = (matrix @ coefs).T
×
670

671
            # assess the result
UNCOV
672
            abserr = np.abs(xs_fit + xs_wp - xs_ref)
×
UNCOV
673
            with np.errstate(invalid='ignore', divide='ignore'):
×
UNCOV
674
                relerr = abserr / xs_ref
×
UNCOV
675
            if not np.any(np.isnan(abserr)):
×
UNCOV
676
                re = relerr[abserr > atol]
×
UNCOV
677
                if re.size == 0 or np.all(re <= rtol) or \
×
678
                   (re.max() <= 2*rtol and (re > rtol).sum() <= 0.01*relerr.size) or \
679
                   (iw == 0 and np.all(relerr.mean(axis=1) <= rtol)):
680
                    # meet tolerances
UNCOV
681
                    if log >= DETAILED_LOGGING:
×
682
                        print("Accuracy satisfied.")
×
UNCOV
683
                    break
×
684

685
            # we expect pure curvefit will succeed for the first window
686
            # TODO: find the energy boundary below which no poles are allowed
UNCOV
687
            if iw == 0:
×
UNCOV
688
                raise RuntimeError('Pure curvefit failed for the first window!')
×
689

690
            # try to include one more pole (next center nearest)
UNCOV
691
            if rp >= n_poles:
×
692
                lp -= 1
×
UNCOV
693
            elif lp <= 0 or poles[rp] - incenter <= incenter - poles[lp - 1]:
×
UNCOV
694
                rp += 1
×
695
            else:
UNCOV
696
                lp -= 1
×
697

698
        # save data for this window
UNCOV
699
        win_data.append((i_piece, lp, rp, coefs))
×
700

701
        # mark the windowed poles as used poles
UNCOV
702
        poles_unused[i_piece][lp:rp] = 0
×
703

704
    # flatten and shrink by removing unused poles
UNCOV
705
    data = []  # used poles and residues
×
UNCOV
706
    for ip in range(n_pieces):
×
UNCOV
707
        used = (poles_unused[ip] == 0)
×
708
        # stack poles and residues for library format
UNCOV
709
        data.append(np.vstack([mp_poles[ip][used], mp_residues[ip][:, used]]).T)
×
710
    # stack poles/residues in sequence vertically
UNCOV
711
    data = np.vstack(data)
×
712
    # new start/end pole indices
UNCOV
713
    windows = []
×
UNCOV
714
    curvefit = []
×
UNCOV
715
    for iw in range(n_win):
×
UNCOV
716
        ip, lp, rp, coefs = win_data[iw]
×
717
        # adjust indices and change to 1-based for the library format
UNCOV
718
        n_prev_poles = sum([poles_unused[i].size for i in range(ip)])
×
UNCOV
719
        n_unused = sum([(poles_unused[i] == 1).sum() for i in range(ip)]) + \
×
720
                  (poles_unused[ip][:lp] == 1).sum()
UNCOV
721
        lp += n_prev_poles - n_unused + 1
×
UNCOV
722
        rp += n_prev_poles - n_unused
×
UNCOV
723
        windows.append([lp, rp])
×
UNCOV
724
        curvefit.append(coefs)
×
725

726
    # construct the WindowedMultipole object
UNCOV
727
    wmp = WindowedMultipole(name)
×
UNCOV
728
    wmp.spacing = spacing
×
UNCOV
729
    wmp.sqrtAWR = sqrt(awr)
×
UNCOV
730
    wmp.E_min = E_min
×
UNCOV
731
    wmp.E_max = E_max
×
UNCOV
732
    wmp.data = data
×
UNCOV
733
    wmp.windows = np.asarray(windows)
×
UNCOV
734
    wmp.curvefit = np.asarray(curvefit)
×
735
    # TODO: check if Doppler brodening of the polynomial curvefit is negligible
UNCOV
736
    wmp.broaden_poly = np.ones((n_win,), dtype=bool)
×
737

UNCOV
738
    return wmp
×
739

740

741
class WindowedMultipole(EqualityMixin):
1✔
742
    """Resonant cross sections represented in the windowed multipole format.
743

744
    Parameters
745
    ----------
746
    name : str
747
        Name of the nuclide using the GNDS naming convention
748

749
    Attributes
750
    ----------
751
    name : str
752
        Name of the nuclide using the GNDS naming convention
753
    spacing : float
754
        The width of each window in sqrt(E)-space.  For example, the frst window
755
        will end at (sqrt(E_min) + spacing)**2 and the second window at
756
        (sqrt(E_min) + 2*spacing)**2.
757
    sqrtAWR : float
758
        Square root of the atomic weight ratio of the target nuclide.
759
    E_min : float
760
        Lowest energy in eV the library is valid for.
761
    E_max : float
762
        Highest energy in eV the library is valid for.
763
    data : np.ndarray
764
        A 2D array of complex poles and residues.  data[i, 0] gives the energy
765
        at which pole i is located.  data[i, 1:] gives the residues associated
766
        with the i-th pole.  There are 3 residues, one each for the scattering,
767
        absorption, and fission channels.
768
    windows : np.ndarray
769
        A 2D array of Integral values.  windows[i, 0] - 1 is the index of the
770
        first pole in window i. windows[i, 1] - 1 is the index of the last pole
771
        in window i.
772
    broaden_poly : np.ndarray
773
        A 1D array of boolean values indicating whether or not the polynomial
774
        curvefit in that window should be Doppler broadened.
775
    curvefit : np.ndarray
776
        A 3D array of Real curvefit polynomial coefficients.  curvefit[i, 0, :]
777
        gives coefficients for the scattering cross section in window i.
778
        curvefit[i, 1, :] gives absorption coefficients and curvefit[i, 2, :]
779
        gives fission coefficients.  The polynomial terms are increasing powers
780
        of sqrt(E) starting with 1/E e.g:
781
        a/E + b/sqrt(E) + c + d sqrt(E) + ...
782

783
    """
784
    def __init__(self, name):
1✔
785
        self.name = name
1✔
786
        self.spacing = None
1✔
787
        self.sqrtAWR = None
1✔
788
        self.E_min = None
1✔
789
        self.E_max = None
1✔
790
        self.data = None
1✔
791
        self.windows = None
1✔
792
        self.broaden_poly = None
1✔
793
        self.curvefit = None
1✔
794

795
    @property
1✔
796
    def name(self):
1✔
797
        return self._name
1✔
798

799
    @name.setter
1✔
800
    def name(self, name):
1✔
801
        cv.check_type('name', name, str)
1✔
802
        self._name = name
1✔
803

804
    @property
1✔
805
    def fit_order(self):
1✔
806
        return self.curvefit.shape[1] - 1
1✔
807

808
    @property
1✔
809
    def fissionable(self):
1✔
810
        return self.data.shape[1] == 4
1✔
811

812
    @property
1✔
813
    def n_poles(self):
1✔
UNCOV
814
        return self.data.shape[0]
×
815

816
    @property
1✔
817
    def n_windows(self):
1✔
818
        return self.windows.shape[0]
1✔
819

820
    @property
1✔
821
    def poles_per_window(self):
1✔
UNCOV
822
        return (self.windows[:, 1] - self.windows[:, 0] + 1).mean()
×
823

824
    @property
1✔
825
    def spacing(self):
1✔
826
        return self._spacing
1✔
827

828
    @spacing.setter
1✔
829
    def spacing(self, spacing):
1✔
830
        if spacing is not None:
1✔
831
            cv.check_type('spacing', spacing, Real)
1✔
832
            cv.check_greater_than('spacing', spacing, 0.0, equality=False)
1✔
833
        self._spacing = spacing
1✔
834

835
    @property
1✔
836
    def sqrtAWR(self):
1✔
837
        return self._sqrtAWR
1✔
838

839
    @sqrtAWR.setter
1✔
840
    def sqrtAWR(self, sqrtAWR):
1✔
841
        if sqrtAWR is not None:
1✔
842
            cv.check_type('sqrtAWR', sqrtAWR, Real)
1✔
843
            cv.check_greater_than('sqrtAWR', sqrtAWR, 0.0, equality=False)
1✔
844
        self._sqrtAWR = sqrtAWR
1✔
845

846
    @property
1✔
847
    def E_min(self):
1✔
848
        return self._E_min
1✔
849

850
    @E_min.setter
1✔
851
    def E_min(self, E_min):
1✔
852
        if E_min is not None:
1✔
853
            cv.check_type('E_min', E_min, Real)
1✔
854
            cv.check_greater_than('E_min', E_min, 0.0, equality=True)
1✔
855
        self._E_min = E_min
1✔
856

857
    @property
1✔
858
    def E_max(self):
1✔
859
        return self._E_max
1✔
860

861
    @E_max.setter
1✔
862
    def E_max(self, E_max):
1✔
863
        if E_max is not None:
1✔
864
            cv.check_type('E_max', E_max, Real)
1✔
865
            cv.check_greater_than('E_max', E_max, 0.0, equality=False)
1✔
866
        self._E_max = E_max
1✔
867

868
    @property
1✔
869
    def data(self):
1✔
870
        return self._data
1✔
871

872
    @data.setter
1✔
873
    def data(self, data):
1✔
874
        if data is not None:
1✔
875
            cv.check_type('data', data, np.ndarray)
1✔
876
            if len(data.shape) != 2:
1✔
877
                raise ValueError('Multipole data arrays must be 2D')
×
878
            if data.shape[1] not in (3, 4):
1✔
879
                raise ValueError(
×
880
                     'data.shape[1] must be 3 or 4. One value for the pole.'
881
                     ' One each for the scattering and absorption residues. '
882
                     'Possibly one more for a fission residue.')
883
            if not np.issubdtype(data.dtype, np.complexfloating):
1✔
884
                raise TypeError('Multipole data arrays must be complex dtype')
×
885
        self._data = data
1✔
886

887
    @property
1✔
888
    def windows(self):
1✔
889
        return self._windows
1✔
890

891
    @windows.setter
1✔
892
    def windows(self, windows):
1✔
893
        if windows is not None:
1✔
894
            cv.check_type('windows', windows, np.ndarray)
1✔
895
            if len(windows.shape) != 2:
1✔
896
                raise ValueError('Multipole windows arrays must be 2D')
×
897
            if not np.issubdtype(windows.dtype, np.integer):
1✔
898
                raise TypeError('Multipole windows arrays must be integer'
×
899
                                ' dtype')
900
        self._windows = windows
1✔
901

902
    @property
1✔
903
    def broaden_poly(self):
1✔
904
        return self._broaden_poly
1✔
905

906
    @broaden_poly.setter
1✔
907
    def broaden_poly(self, broaden_poly):
1✔
908
        if broaden_poly is not None:
1✔
909
            cv.check_type('broaden_poly', broaden_poly, np.ndarray)
1✔
910
            if len(broaden_poly.shape) != 1:
1✔
911
                raise ValueError('Multipole broaden_poly arrays must be 1D')
×
912
            if not np.issubdtype(broaden_poly.dtype, np.bool_):
1✔
913
                raise TypeError('Multipole broaden_poly arrays must be boolean'
×
914
                                ' dtype')
915
        self._broaden_poly = broaden_poly
1✔
916

917
    @property
1✔
918
    def curvefit(self):
1✔
919
        return self._curvefit
1✔
920

921
    @curvefit.setter
1✔
922
    def curvefit(self, curvefit):
1✔
923
        if curvefit is not None:
1✔
924
            cv.check_type('curvefit', curvefit, np.ndarray)
1✔
925
            if len(curvefit.shape) != 3:
1✔
926
                raise ValueError('Multipole curvefit arrays must be 3D')
×
927
            if curvefit.shape[2] not in (2, 3):  # sig_s, sig_a (maybe sig_f)
1✔
928
                raise ValueError('The third dimension of multipole curvefit'
×
929
                                 ' arrays must have a length of 2 or 3')
930
            if not np.issubdtype(curvefit.dtype, np.floating):
1✔
931
                raise TypeError('Multipole curvefit arrays must be float dtype')
×
932
        self._curvefit = curvefit
1✔
933

934
    @classmethod
1✔
935
    def from_hdf5(cls, group_or_filename):
1✔
936
        """Construct a WindowedMultipole object from an HDF5 group or file.
937

938
        Parameters
939
        ----------
940
        group_or_filename : h5py.Group or str
941
            HDF5 group containing multipole data. If given as a string, it is
942
            assumed to be the filename for the HDF5 file, and the first group is
943
            used to read from.
944

945
        Returns
946
        -------
947
        openmc.data.WindowedMultipole
948
            Resonant cross sections represented in the windowed multipole
949
            format.
950

951
        """
952

953
        if isinstance(group_or_filename, h5py.Group):
1✔
954
            group = group_or_filename
×
955
            need_to_close = False
×
956
        else:
957
            h5file = h5py.File(str(group_or_filename), 'r')
1✔
958
            need_to_close = True
1✔
959

960
            # Make sure version matches
961
            if 'version' in h5file.attrs:
1✔
962
                major, minor = h5file.attrs['version']
1✔
963
                if major != WMP_VERSION_MAJOR:
1✔
964
                    raise DataError(
×
965
                        'WMP data format uses version {}. {} whereas your '
966
                        'installation of the OpenMC Python API expects version '
967
                        '{}.x.'.format(major, minor, WMP_VERSION_MAJOR))
968
            else:
969
                raise DataError(
×
970
                    'WMP data does not indicate a version. Your installation of '
971
                    'the OpenMC Python API expects version {}.x data.'
972
                    .format(WMP_VERSION_MAJOR))
973

974
            group = list(h5file.values())[0]
1✔
975

976
        name = group.name[1:]
1✔
977
        out = cls(name)
1✔
978

979
        # Read scalars.
980

981
        out.spacing = group['spacing'][()]
1✔
982
        out.sqrtAWR = group['sqrtAWR'][()]
1✔
983
        out.E_min = group['E_min'][()]
1✔
984
        out.E_max = group['E_max'][()]
1✔
985

986
        # Read arrays.
987

988
        err = "WMP '{}' array shape is not consistent with the '{}' array shape"
1✔
989

990
        out.data = group['data'][()]
1✔
991

992
        out.windows = group['windows'][()]
1✔
993

994
        out.broaden_poly = group['broaden_poly'][...].astype(bool)
1✔
995
        if out.broaden_poly.shape[0] != out.windows.shape[0]:
1✔
996
            raise ValueError(err.format('broaden_poly', 'windows'))
×
997

998
        out.curvefit = group['curvefit'][()]
1✔
999
        if out.curvefit.shape[0] != out.windows.shape[0]:
1✔
1000
            raise ValueError(err.format('curvefit', 'windows'))
×
1001

1002
        # _broaden_wmp_polynomials assumes the curve fit has at least 3 terms.
1003
        if out.fit_order < 2:
1✔
1004
            raise ValueError("Windowed multipole is only supported for "
×
1005
                             "curvefits with 3 or more terms.")
1006

1007
        # If HDF5 file was opened here, make sure it gets closed
1008
        if need_to_close:
1✔
1009
            h5file.close()
1✔
1010

1011
        return out
1✔
1012

1013
    @classmethod
1✔
1014
    def from_endf(cls, endf_file, log=False, vf_options=None, wmp_options=None):
1✔
1015
        """Generate windowed multipole neutron data from an ENDF evaluation.
1016

1017
        .. versionadded:: 0.12.1
1018

1019
        Parameters
1020
        ----------
1021
        endf_file : str
1022
            Path to ENDF evaluation
1023
        log : bool or int, optional
1024
            Whether to print running logs (use int for verbosity control)
1025
        vf_options : dict, optional
1026
            Dictionary of keyword arguments, e.g. {'njoy_error': 0.001},
1027
            passed to :func:`openmc.data.multipole.vectfit_nuclide`
1028
        wmp_options : dict, optional
1029
            Dictionary of keyword arguments, e.g. {'search': True, 'rtol': 0.01},
1030
            passed to :func:`openmc.data.WindowedMultipole.from_multipole`
1031

1032
        Returns
1033
        -------
1034
        openmc.data.WindowedMultipole
1035
            Resonant cross sections represented in the windowed multipole
1036
            format.
1037

1038
        """
1039

UNCOV
1040
        if vf_options is None:
×
UNCOV
1041
            vf_options = {}
×
1042

UNCOV
1043
        if wmp_options is None:
×
1044
            wmp_options = {}
×
1045

UNCOV
1046
        if log:
×
UNCOV
1047
            vf_options.update(log=log)
×
UNCOV
1048
            wmp_options.update(log=log)
×
1049

1050
        # generate multipole data from EDNF
UNCOV
1051
        mp_data = vectfit_nuclide(endf_file, **vf_options)
×
1052

1053
        # windowing
UNCOV
1054
        return cls.from_multipole(mp_data, **wmp_options)
×
1055

1056
    @classmethod
1✔
1057
    def from_multipole(cls, mp_data, search=None, log=False, **kwargs):
1✔
1058
        """Generate windowed multipole neutron data from multipole data.
1059

1060
        Parameters
1061
        ----------
1062
        mp_data : dictionary or str
1063
            Dictionary or Path to the multipole data stored in a pickle file
1064
        search : bool, optional
1065
            Whether to search for optimal window size and curvefit order.
1066
            Defaults to True if no windowing parameters are specified.
1067
        log : bool or int, optional
1068
            Whether to print running logs (use int for verbosity control)
1069
        **kwargs
1070
            Keyword arguments passed to :func:`openmc.data.multipole._windowing`
1071

1072
        Returns
1073
        -------
1074
        openmc.data.WindowedMultipole
1075
            Resonant cross sections represented in the windowed multipole
1076
            format.
1077

1078
        """
1079

UNCOV
1080
        if isinstance(mp_data, str):
×
1081
            # load multipole data from file
1082
            with open(mp_data, 'rb') as f:
×
1083
                mp_data = pickle.load(f)
×
1084

UNCOV
1085
        if search is None:
×
UNCOV
1086
            if 'n_cf' in kwargs and ('n_win' in kwargs or 'spacing' in kwargs):
×
UNCOV
1087
                search = False
×
1088
            else:
1089
                search = True
×
1090

1091
        # windowing with specific options
UNCOV
1092
        if not search:
×
1093
            # set default value for curvefit order if not specified
UNCOV
1094
            if 'n_cf' not in kwargs:
×
1095
                kwargs.update(n_cf=5)
×
UNCOV
1096
            return _windowing(mp_data, log=log, **kwargs)
×
1097

1098
        # search optimal WMP from a range of window sizes and CF orders
UNCOV
1099
        if log:
×
UNCOV
1100
            print("Start searching ...")
×
UNCOV
1101
        n_poles = sum([p.size for p in mp_data["poles"]])
×
UNCOV
1102
        n_win_min = max(5, n_poles // 20)
×
UNCOV
1103
        n_win_max = 2000 if n_poles < 2000 else 8000
×
UNCOV
1104
        best_wmp = best_metric = None
×
UNCOV
1105
        for n_w in np.unique(np.linspace(n_win_min, n_win_max, 20, dtype=int)):
×
UNCOV
1106
            for n_cf in range(10, 1, -1):
×
UNCOV
1107
                if log:
×
UNCOV
1108
                    print(f"Testing N_win={n_w} N_cf={n_cf}")
×
1109

1110
                # update arguments dictionary
UNCOV
1111
                kwargs.update(n_win=n_w, n_cf=n_cf)
×
1112

1113
                # windowing
UNCOV
1114
                try:
×
UNCOV
1115
                    wmp = _windowing(mp_data, log=log, **kwargs)
×
UNCOV
1116
                except Exception as e:
×
UNCOV
1117
                    if log:
×
UNCOV
1118
                        print('Failed: ' + str(e))
×
UNCOV
1119
                    break
×
1120

1121
                # select wmp library with metric:
1122
                # - performance: average # used poles per window and CF order
1123
                # - memory: # windows
UNCOV
1124
                metric = -(wmp.poles_per_window * 10. + wmp.fit_order * 1. +
×
1125
                           wmp.n_windows * 0.01)
UNCOV
1126
                if best_wmp is None or metric > best_metric:
×
UNCOV
1127
                    if log:
×
UNCOV
1128
                        print("Best library so far.")
×
UNCOV
1129
                    best_wmp = deepcopy(wmp)
×
UNCOV
1130
                    best_metric = metric
×
1131

1132
        # return the best wmp library
UNCOV
1133
        if log:
×
UNCOV
1134
            print("Final library: {} poles, {} windows, {:.2g} poles per window, "
×
1135
                  "{} CF order".format(best_wmp.n_poles, best_wmp.n_windows,
1136
                   best_wmp.poles_per_window, best_wmp.fit_order))
1137

UNCOV
1138
        return best_wmp
×
1139

1140
    def _evaluate(self, E, T):
1✔
1141
        """Compute scattering, absorption, and fission cross sections.
1142

1143
        Parameters
1144
        ----------
1145
        E : Real
1146
            Energy of the incident neutron in eV.
1147
        T : Real
1148
            Temperature of the target in K.
1149

1150
        Returns
1151
        -------
1152
        3-tuple of Real
1153
            Scattering, absorption, and fission microscopic cross sections
1154
            at the given energy and temperature.
1155

1156
        """
1157

1158
        if E < self.E_min: return (0, 0, 0)
1✔
1159
        if E > self.E_max: return (0, 0, 0)
1✔
1160

1161
        # ======================================================================
1162
        # Bookkeeping
1163

1164
        # Define some frequently used variables.
1165
        sqrtkT = sqrt(K_BOLTZMANN * T)
1✔
1166
        sqrtE = sqrt(E)
1✔
1167
        invE = 1.0 / E
1✔
1168

1169
        # Locate us.  The i_window calc omits a + 1 present from the legacy
1170
        # Fortran version of OpenMC because of the 1-based vs. 0-based
1171
        # indexing.  Similarly startw needs to be decreased by 1.  endw does
1172
        # not need to be decreased because range(startw, endw) does not include
1173
        # endw.
1174
        i_window = min(self.n_windows - 1,
1✔
1175
                       int(np.floor((sqrtE - sqrt(self.E_min)) / self.spacing)))
1176
        startw = self.windows[i_window, 0] - 1
1✔
1177
        endw = self.windows[i_window, 1]
1✔
1178

1179
        # Initialize the ouptut cross sections.
1180
        sig_s = 0.0
1✔
1181
        sig_a = 0.0
1✔
1182
        sig_f = 0.0
1✔
1183

1184
        # ======================================================================
1185
        # Add the contribution from the curvefit polynomial.
1186

1187
        if sqrtkT != 0 and self.broaden_poly[i_window]:
1✔
1188
            # Broaden the curvefit.
1189
            dopp = self.sqrtAWR / sqrtkT
1✔
1190
            broadened_polynomials = _broaden_wmp_polynomials(E, dopp,
1✔
1191
                                                             self.fit_order + 1)
1192
            for i_poly in range(self.fit_order + 1):
1✔
1193
                sig_s += (self.curvefit[i_window, i_poly, _FIT_S]
1✔
1194
                          * broadened_polynomials[i_poly])
1195
                sig_a += (self.curvefit[i_window, i_poly, _FIT_A]
1✔
1196
                          * broadened_polynomials[i_poly])
1197
                if self.fissionable:
1✔
1198
                    sig_f += (self.curvefit[i_window, i_poly, _FIT_F]
1✔
1199
                              * broadened_polynomials[i_poly])
1200
        else:
1201
            temp = invE
1✔
1202
            for i_poly in range(self.fit_order + 1):
1✔
1203
                sig_s += self.curvefit[i_window, i_poly, _FIT_S] * temp
1✔
1204
                sig_a += self.curvefit[i_window, i_poly, _FIT_A] * temp
1✔
1205
                if self.fissionable:
1✔
1206
                    sig_f += self.curvefit[i_window, i_poly, _FIT_F] * temp
1✔
1207
                temp *= sqrtE
1✔
1208

1209
        # ======================================================================
1210
        # Add the contribution from the poles in this window.
1211

1212
        if sqrtkT == 0.0:
1✔
1213
            # If at 0K, use asymptotic form.
1214
            for i_pole in range(startw, endw):
1✔
1215
                psi_chi = -1j / (self.data[i_pole, _MP_EA] - sqrtE)
1✔
1216
                c_temp = psi_chi / E
1✔
1217
                sig_s += (self.data[i_pole, _MP_RS] * c_temp).real
1✔
1218
                sig_a += (self.data[i_pole, _MP_RA] * c_temp).real
1✔
1219
                if self.fissionable:
1✔
1220
                    sig_f += (self.data[i_pole, _MP_RF] * c_temp).real
1✔
1221

1222
        else:
1223
            # At temperature, use Faddeeva function-based form.
1224
            dopp = self.sqrtAWR / sqrtkT
1✔
1225
            for i_pole in range(startw, endw):
1✔
1226
                Z = (sqrtE - self.data[i_pole, _MP_EA]) * dopp
1✔
1227
                w_val = _faddeeva(Z) * dopp * invE * sqrt(pi)
1✔
1228
                sig_s += (self.data[i_pole, _MP_RS] * w_val).real
1✔
1229
                sig_a += (self.data[i_pole, _MP_RA] * w_val).real
1✔
1230
                if self.fissionable:
1✔
1231
                    sig_f += (self.data[i_pole, _MP_RF] * w_val).real
1✔
1232

1233
        return sig_s, sig_a, sig_f
1✔
1234

1235
    def __call__(self, E, T):
1✔
1236
        """Compute scattering, absorption, and fission cross sections.
1237

1238
        Parameters
1239
        ----------
1240
        E : Real or Iterable of Real
1241
            Energy of the incident neutron in eV.
1242
        T : Real
1243
            Temperature of the target in K.
1244

1245
        Returns
1246
        -------
1247
        3-tuple of Real or 3-tuple of numpy.ndarray
1248
            Scattering, absorption, and fission microscopic cross sections
1249
            at the given energy and temperature.
1250

1251
        """
1252

1253
        fun = np.vectorize(lambda x: self._evaluate(x, T))
1✔
1254
        return fun(E)
1✔
1255

1256
    def export_to_hdf5(self, path, mode='a', libver='earliest'):
1✔
1257
        """Export windowed multipole data to an HDF5 file.
1258

1259
        Parameters
1260
        ----------
1261
        path : str
1262
            Path to write HDF5 file to
1263
        mode : {'r+', 'w', 'x', 'a'}
1264
            Mode that is used to open the HDF5 file. This is the second argument
1265
            to the :class:`h5py.File` constructor.
1266
        libver : {'earliest', 'latest'}
1267
            Compatibility mode for the HDF5 file. 'latest' will produce files
1268
            that are less backwards compatible but have performance benefits.
1269

1270
        """
1271

1272
        # Open file and write version.
1273
        with h5py.File(str(path), mode, libver=libver) as f:
1✔
1274
            f.attrs['filetype'] = np.bytes_('data_wmp')
1✔
1275
            f.attrs['version'] = np.array(WMP_VERSION)
1✔
1276

1277
            g = f.create_group(self.name)
1✔
1278

1279
            # Write scalars.
1280
            g.create_dataset('spacing', data=np.array(self.spacing))
1✔
1281
            g.create_dataset('sqrtAWR', data=np.array(self.sqrtAWR))
1✔
1282
            g.create_dataset('E_min', data=np.array(self.E_min))
1✔
1283
            g.create_dataset('E_max', data=np.array(self.E_max))
1✔
1284

1285
            # Write arrays.
1286
            g.create_dataset('data', data=self.data)
1✔
1287
            g.create_dataset('windows', data=self.windows)
1✔
1288
            g.create_dataset('broaden_poly',
1✔
1289
                             data=self.broaden_poly.astype(np.int8))
1290
            g.create_dataset('curvefit', data=self.curvefit)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc