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

openmc-dev / openmc / 20278515727

16 Dec 2025 06:26PM UTC coverage: 81.822% (-0.1%) from 81.92%
20278515727

Pull #3493

github

web-flow
Merge 7f36869a3 into bbfa18d72
Pull Request #3493: Implement vector fitting to replace external `vectfit` package

17013 of 23575 branches covered (72.17%)

Branch coverage included in aggregate %.

188 of 207 new or added lines in 3 files covered. (90.82%)

3101 existing lines in 56 files now uncovered.

54973 of 64404 relevant lines covered (85.36%)

41385524.25 hits per line

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

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

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

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

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

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

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

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

36
# Logging control
37
DETAILED_LOGGING = 2
10✔
38

39

40
def _faddeeva(z):
10✔
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
10✔
80
    if np.angle(z) > 0:
10✔
81
        return wofz(z)
×
82
    else:
83
        return -np.conj(wofz(z.conjugate()))
10✔
84

85

86
def _broaden_wmp_polynomials(E, dopp, n):
10✔
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)
10✔
108
    beta = sqrtE * dopp
10✔
109
    half_inv_dopp2 = 0.5 / dopp**2
10✔
110
    quarter_inv_dopp4 = half_inv_dopp2**2
10✔
111

112
    if beta > 6.0:
10✔
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
10✔
116
        exp_m_beta2 = 0.0
10✔
117
    else:
118
        erf_beta = erf(beta)
10✔
119
        exp_m_beta2 = exp(-beta**2)
10✔
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)
10✔
125

126
    factors[0] = erf_beta / E
10✔
127
    factors[1] = 1.0 / sqrtE
10✔
128
    factors[2] = (factors[0] * (half_inv_dopp2 + E)
10✔
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):
10✔
135
        if i != 1:
10✔
136
            factors[i+2] = (-factors[i-2] * (i - 1.0) * i * quarter_inv_dopp4
10✔
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)
10✔
140

141
    return factors
10✔
142

143

144
def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None,
10✔
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
    """
UNCOV
177
    ne = energy.size
×
UNCOV
178
    nmt = len(mts)
×
UNCOV
179
    if ce_xs.shape != (nmt, ne):
×
180
        raise ValueError('Inconsistent cross section data.')
×
181

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

UNCOV
192
    if log:
×
NEW
193
        print(f"\tenergy: {energy[0]:.3e} to {energy[-1]:.3e} eV ({ne} points)")
×
NEW
194
        print(f"\terror tolerance: rtol={rtol}, atol={atol}")
×
195

196
    # transform xs (sigma) and energy (E) to f (sigma*E) and s (sqrt(E)) to be
197
    # compatible with the multipole representation
UNCOV
198
    f = ce_xs * energy
×
UNCOV
199
    s = np.sqrt(energy)
×
UNCOV
200
    test_s = np.sqrt(test_energy)
×
201

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

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

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

UNCOV
227
    if log:
×
UNCOV
228
        print(f"Found {n_peaks} peaks")
×
UNCOV
229
        print(f"Fitting orders from {orders[0]} to {orders[-1]}")
×
230

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

UNCOV
243
        found_better = False
×
244
        # fitting iteration
UNCOV
245
        for i_vf in range(n_vf_iter):
×
UNCOV
246
            if log >= DETAILED_LOGGING:
×
247
                print(f"VF iteration {i_vf + 1}/{n_vf_iter}")
×
248

249
            # call vf
NEW
250
            poles, residues, *_ = vectfit(f, s, poles, weight)
×
251

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

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

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

UNCOV
290
            if np.any(test_xs < -atol):
×
UNCOV
291
                quality = -np.inf
×
292

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

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

UNCOV
315
        if found_ideal:
×
316
            break
×
317

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

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

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

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

UNCOV
385
    return (mp_poles, mp_residues)
×
386

387
def vectfit_nuclide(endf_file, njoy_error=5e-4, vf_pieces=None,
10✔
388
                    log=False, path_out=None, mp_filename=None,
389
                    **kwargs):
390
    r"""Generate multipole data for a nuclide from ENDF.
391

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

409
    Returns
410
    -------
411
    mp_data
412
        Dictionary containing necessary multipole data of the nuclide
413

414
    """
415

416
    # ======================================================================
417
    # PREPARE POINT-WISE XS
418

419
    # make 0K ACE data using njoy
UNCOV
420
    if log:
×
UNCOV
421
        print(f"Running NJOY to get 0K point-wise data (error={njoy_error})...")
×
422

UNCOV
423
    nuc_ce = IncidentNeutron.from_njoy(endf_file, temperatures=[0.0],
×
424
             error=njoy_error, broadr=False, heatr=False, purr=False)
425

UNCOV
426
    if log:
×
UNCOV
427
        print("Parsing cross sections within resolved resonance range...")
×
428

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

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

UNCOV
454
    try:
×
UNCOV
455
        absorption_xs = nuc_ce[27].xs['0K'](energy)
×
456
    except KeyError:
×
457
        absorption_xs = np.zeros_like(total_xs)
×
458

UNCOV
459
    fissionable = False
×
UNCOV
460
    try:
×
UNCOV
461
        fission_xs = nuc_ce[18].xs['0K'](energy)
×
UNCOV
462
        fissionable = True
×
UNCOV
463
    except KeyError:
×
UNCOV
464
        pass
×
465

466
    # make vectors
UNCOV
467
    if fissionable:
×
UNCOV
468
        ce_xs = np.vstack((elastic_xs, absorption_xs, fission_xs))
×
UNCOV
469
        mts = [2, 27, 18]
×
470
    else:
UNCOV
471
        ce_xs = np.vstack((elastic_xs, absorption_xs))
×
UNCOV
472
        mts = [2, 27]
×
473

UNCOV
474
    if log:
×
UNCOV
475
        print(f"  MTs: {mts}")
×
UNCOV
476
        print(f"  Energy range: {E_min:.3e} to {E_max:.3e} eV ({n_points} points)")
×
477

478
    # ======================================================================
479
    # PERFORM VECTOR FITTING
480

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

UNCOV
491
    alpha = nuc_ce.atomic_weight_ratio/(K_BOLTZMANN*TEMPERATURE_LIMIT)
×
492

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

UNCOV
512
        p, r = _vectfit_xs(energy[e_idx], ce_xs[:, e_idx], mts, log=log,
×
513
                           path_out=path_out, **kwargs)
514

UNCOV
515
        poles.append(p)
×
UNCOV
516
        residues.append(r)
×
517

518
    # collect multipole data into a dictionary
UNCOV
519
    mp_data = {"name": nuc_ce.name,
×
520
               "AWR": nuc_ce.atomic_weight_ratio,
521
               "E_min": E_min,
522
               "E_max": E_max,
523
               "poles": poles,
524
               "residues": residues}
525

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

UNCOV
538
    return mp_data
×
539

540

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

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

563
    Returns
564
    -------
565
    openmc.data.WindowedMultipole
566
        Resonant cross sections represented in the windowed multipole
567
        format.
568

569
    """
570
    # unpack multipole data
UNCOV
571
    name = mp_data["name"]
×
UNCOV
572
    awr = mp_data["AWR"]
×
UNCOV
573
    E_min = mp_data["E_min"]
×
UNCOV
574
    E_max = mp_data["E_max"]
×
UNCOV
575
    mp_poles = mp_data["poles"]
×
UNCOV
576
    mp_residues = mp_data["residues"]
×
577

UNCOV
578
    n_pieces = len(mp_poles)
×
UNCOV
579
    piece_width = (sqrt(E_max) - sqrt(E_min)) / n_pieces
×
UNCOV
580
    alpha = awr / (K_BOLTZMANN*TEMPERATURE_LIMIT)
×
581

582
    # determine window size
UNCOV
583
    if n_win is None:
×
584
        if spacing is not None:
×
585
            # ensure the windows are within the multipole energy range
586
            n_win = int((sqrt(E_max) - sqrt(E_min)) / spacing)
×
587
            E_max = (sqrt(E_min) + n_win*spacing)**2
×
588
        else:
589
            n_win = 1000
×
590
    # inner window size
UNCOV
591
    spacing = (sqrt(E_max) - sqrt(E_min)) / n_win
×
592
    # make sure inner window size is smaller than energy piece size
UNCOV
593
    if spacing > piece_width:
×
594
        raise ValueError('Window spacing cannot be larger than piece spacing.')
×
595

UNCOV
596
    if log:
×
UNCOV
597
        print("Windowing:")
×
UNCOV
598
        print(f"  config: # windows={n_win}, spacing={spacing}, CF order={n_cf}")
×
UNCOV
599
        print(f"  error tolerance: rtol={rtol}, atol={atol}")
×
600

601
    # sort poles (and residues) by the real component of the pole
UNCOV
602
    for ip in range(n_pieces):
×
UNCOV
603
        indices = mp_poles[ip].argsort()
×
UNCOV
604
        mp_poles[ip] = mp_poles[ip][indices]
×
UNCOV
605
        mp_residues[ip] = mp_residues[ip][:, indices]
×
606

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

610
    # optimize the windows: the goal is to find the least set of significant
611
    # consecutive poles and curve fit coefficients to reproduce cross section
UNCOV
612
    win_data = []
×
UNCOV
613
    for iw in range(n_win):
×
UNCOV
614
        if log >= DETAILED_LOGGING:
×
615
            print(f"Processing window {iw + 1}/{n_win}...")
×
616

617
        # inner window boundaries
UNCOV
618
        inbegin = sqrt(E_min) + spacing * iw
×
UNCOV
619
        inend = inbegin + spacing
×
UNCOV
620
        incenter = (inbegin + inend) / 2.0
×
621
        # extend window energy range for Doppler broadening
UNCOV
622
        if iw == 0 or sqrt(alpha)*inbegin < 4.0:
×
UNCOV
623
            e_start = inbegin**2
×
624
        else:
UNCOV
625
            e_start = max(E_min, (sqrt(alpha)*inbegin - 4.0)**2/alpha)
×
UNCOV
626
        e_end = min(E_max, (sqrt(alpha)*inend + 4.0)**2/alpha)
×
627

628
        # locate piece and relevant poles
UNCOV
629
        i_piece = min(n_pieces - 1, int((inbegin - sqrt(E_min))/piece_width + 0.5))
×
UNCOV
630
        poles, residues = mp_poles[i_piece], mp_residues[i_piece]
×
UNCOV
631
        n_poles = poles.size
×
632

633
        # generate energy points for fitting: equally spaced in momentum
UNCOV
634
        n_points = min(max(100, int((e_end - e_start)*4)), 10000)
×
UNCOV
635
        energy_sqrt = np.linspace(np.sqrt(e_start), np.sqrt(e_end), n_points)
×
UNCOV
636
        energy = energy_sqrt**2
×
637

638
        # reference xs from multipole form, note the residue terms in the
639
        # multipole and vector fitting representations differ by a 1j
NEW
640
        xs_ref = evaluate(energy_sqrt, poles, residues*1j) / energy
×
641

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

645
        # start from 0 poles, initialize pointers to the center nearest pole
UNCOV
646
        center_pole_ind = np.argmin((np.fabs(poles.real - incenter)))
×
UNCOV
647
        lp = rp = center_pole_ind
×
UNCOV
648
        while True:
×
UNCOV
649
            if log >= DETAILED_LOGGING:
×
650
                print(f"Trying poles {lp} to {rp}")
×
651

652
            # calculate the cross sections contributed by the windowed poles
UNCOV
653
            if rp > lp:
×
NEW
654
                xs_wp = evaluate(energy_sqrt, poles[lp:rp],
×
655
                                    residues[:, lp:rp]*1j) / energy
656
            else:
UNCOV
657
                xs_wp = np.zeros_like(xs_ref)
×
658

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

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

677
            # we expect pure curvefit will succeed for the first window
678
            # TODO: find the energy boundary below which no poles are allowed
UNCOV
679
            if iw == 0:
×
UNCOV
680
                raise RuntimeError('Pure curvefit failed for the first window!')
×
681

682
            # try to include one more pole (next center nearest)
UNCOV
683
            if rp >= n_poles:
×
684
                lp -= 1
×
UNCOV
685
            elif lp <= 0 or poles[rp] - incenter <= incenter - poles[lp - 1]:
×
UNCOV
686
                rp += 1
×
687
            else:
UNCOV
688
                lp -= 1
×
689

690
        # save data for this window
UNCOV
691
        win_data.append((i_piece, lp, rp, coefs))
×
692

693
        # mark the windowed poles as used poles
UNCOV
694
        poles_unused[i_piece][lp:rp] = 0
×
695

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

718
    # construct the WindowedMultipole object
UNCOV
719
    wmp = WindowedMultipole(name)
×
UNCOV
720
    wmp.spacing = spacing
×
UNCOV
721
    wmp.sqrtAWR = sqrt(awr)
×
UNCOV
722
    wmp.E_min = E_min
×
UNCOV
723
    wmp.E_max = E_max
×
UNCOV
724
    wmp.data = data
×
UNCOV
725
    wmp.windows = np.asarray(windows)
×
UNCOV
726
    wmp.curvefit = np.asarray(curvefit)
×
727
    # TODO: check if Doppler brodening of the polynomial curvefit is negligible
UNCOV
728
    wmp.broaden_poly = np.ones((n_win,), dtype=bool)
×
729

UNCOV
730
    return wmp
×
731

732

733
class WindowedMultipole(EqualityMixin):
10✔
734
    """Resonant cross sections represented in the windowed multipole format.
735

736
    Parameters
737
    ----------
738
    name : str
739
        Name of the nuclide using the GNDS naming convention
740

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

775
    """
776
    def __init__(self, name):
10✔
777
        self.name = name
10✔
778
        self.spacing = None
10✔
779
        self.sqrtAWR = None
10✔
780
        self.E_min = None
10✔
781
        self.E_max = None
10✔
782
        self.data = None
10✔
783
        self.windows = None
10✔
784
        self.broaden_poly = None
10✔
785
        self.curvefit = None
10✔
786

787
    @property
10✔
788
    def name(self):
10✔
789
        return self._name
10✔
790

791
    @name.setter
10✔
792
    def name(self, name):
10✔
793
        cv.check_type('name', name, str)
10✔
794
        self._name = name
10✔
795

796
    @property
10✔
797
    def fit_order(self):
10✔
798
        return self.curvefit.shape[1] - 1
10✔
799

800
    @property
10✔
801
    def fissionable(self):
10✔
802
        return self.data.shape[1] == 4
10✔
803

804
    @property
10✔
805
    def n_poles(self):
10✔
UNCOV
806
        return self.data.shape[0]
×
807

808
    @property
10✔
809
    def n_windows(self):
10✔
810
        return self.windows.shape[0]
10✔
811

812
    @property
10✔
813
    def poles_per_window(self):
10✔
UNCOV
814
        return (self.windows[:, 1] - self.windows[:, 0] + 1).mean()
×
815

816
    @property
10✔
817
    def spacing(self):
10✔
818
        return self._spacing
10✔
819

820
    @spacing.setter
10✔
821
    def spacing(self, spacing):
10✔
822
        if spacing is not None:
10✔
823
            cv.check_type('spacing', spacing, Real)
10✔
824
            cv.check_greater_than('spacing', spacing, 0.0, equality=False)
10✔
825
        self._spacing = spacing
10✔
826

827
    @property
10✔
828
    def sqrtAWR(self):
10✔
829
        return self._sqrtAWR
10✔
830

831
    @sqrtAWR.setter
10✔
832
    def sqrtAWR(self, sqrtAWR):
10✔
833
        if sqrtAWR is not None:
10✔
834
            cv.check_type('sqrtAWR', sqrtAWR, Real)
10✔
835
            cv.check_greater_than('sqrtAWR', sqrtAWR, 0.0, equality=False)
10✔
836
        self._sqrtAWR = sqrtAWR
10✔
837

838
    @property
10✔
839
    def E_min(self):
10✔
840
        return self._E_min
10✔
841

842
    @E_min.setter
10✔
843
    def E_min(self, E_min):
10✔
844
        if E_min is not None:
10✔
845
            cv.check_type('E_min', E_min, Real)
10✔
846
            cv.check_greater_than('E_min', E_min, 0.0, equality=True)
10✔
847
        self._E_min = E_min
10✔
848

849
    @property
10✔
850
    def E_max(self):
10✔
851
        return self._E_max
10✔
852

853
    @E_max.setter
10✔
854
    def E_max(self, E_max):
10✔
855
        if E_max is not None:
10✔
856
            cv.check_type('E_max', E_max, Real)
10✔
857
            cv.check_greater_than('E_max', E_max, 0.0, equality=False)
10✔
858
        self._E_max = E_max
10✔
859

860
    @property
10✔
861
    def data(self):
10✔
862
        return self._data
10✔
863

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

879
    @property
10✔
880
    def windows(self):
10✔
881
        return self._windows
10✔
882

883
    @windows.setter
10✔
884
    def windows(self, windows):
10✔
885
        if windows is not None:
10✔
886
            cv.check_type('windows', windows, np.ndarray)
10✔
887
            if len(windows.shape) != 2:
10✔
888
                raise ValueError('Multipole windows arrays must be 2D')
×
889
            if not np.issubdtype(windows.dtype, np.integer):
10✔
890
                raise TypeError('Multipole windows arrays must be integer'
×
891
                                ' dtype')
892
        self._windows = windows
10✔
893

894
    @property
10✔
895
    def broaden_poly(self):
10✔
896
        return self._broaden_poly
10✔
897

898
    @broaden_poly.setter
10✔
899
    def broaden_poly(self, broaden_poly):
10✔
900
        if broaden_poly is not None:
10✔
901
            cv.check_type('broaden_poly', broaden_poly, np.ndarray)
10✔
902
            if len(broaden_poly.shape) != 1:
10✔
903
                raise ValueError('Multipole broaden_poly arrays must be 1D')
×
904
            if not np.issubdtype(broaden_poly.dtype, np.bool_):
10✔
905
                raise TypeError('Multipole broaden_poly arrays must be boolean'
×
906
                                ' dtype')
907
        self._broaden_poly = broaden_poly
10✔
908

909
    @property
10✔
910
    def curvefit(self):
10✔
911
        return self._curvefit
10✔
912

913
    @curvefit.setter
10✔
914
    def curvefit(self, curvefit):
10✔
915
        if curvefit is not None:
10✔
916
            cv.check_type('curvefit', curvefit, np.ndarray)
10✔
917
            if len(curvefit.shape) != 3:
10✔
918
                raise ValueError('Multipole curvefit arrays must be 3D')
×
919
            if curvefit.shape[2] not in (2, 3):  # sig_s, sig_a (maybe sig_f)
10✔
920
                raise ValueError('The third dimension of multipole curvefit'
×
921
                                 ' arrays must have a length of 2 or 3')
922
            if not np.issubdtype(curvefit.dtype, np.floating):
10✔
923
                raise TypeError('Multipole curvefit arrays must be float dtype')
×
924
        self._curvefit = curvefit
10✔
925

926
    @classmethod
10✔
927
    def from_hdf5(cls, group_or_filename):
10✔
928
        """Construct a WindowedMultipole object from an HDF5 group or file.
929

930
        Parameters
931
        ----------
932
        group_or_filename : h5py.Group or str
933
            HDF5 group containing multipole data. If given as a string, it is
934
            assumed to be the filename for the HDF5 file, and the first group is
935
            used to read from.
936

937
        Returns
938
        -------
939
        openmc.data.WindowedMultipole
940
            Resonant cross sections represented in the windowed multipole
941
            format.
942

943
        """
944

945
        if isinstance(group_or_filename, h5py.Group):
10✔
946
            group = group_or_filename
×
947
            need_to_close = False
×
948
        else:
949
            h5file = h5py.File(str(group_or_filename), 'r')
10✔
950
            need_to_close = True
10✔
951

952
            # Make sure version matches
953
            if 'version' in h5file.attrs:
10✔
954
                major, minor = h5file.attrs['version']
10✔
955
                if major != WMP_VERSION_MAJOR:
10✔
956
                    raise DataError(
×
957
                        'WMP data format uses version {}. {} whereas your '
958
                        'installation of the OpenMC Python API expects version '
959
                        '{}.x.'.format(major, minor, WMP_VERSION_MAJOR))
960
            else:
961
                raise DataError(
×
962
                    'WMP data does not indicate a version. Your installation of '
963
                    'the OpenMC Python API expects version {}.x data.'
964
                    .format(WMP_VERSION_MAJOR))
965

966
            group = list(h5file.values())[0]
10✔
967

968
        name = group.name[1:]
10✔
969
        out = cls(name)
10✔
970

971
        # Read scalars.
972

973
        out.spacing = group['spacing'][()]
10✔
974
        out.sqrtAWR = group['sqrtAWR'][()]
10✔
975
        out.E_min = group['E_min'][()]
10✔
976
        out.E_max = group['E_max'][()]
10✔
977

978
        # Read arrays.
979

980
        err = "WMP '{}' array shape is not consistent with the '{}' array shape"
10✔
981

982
        out.data = group['data'][()]
10✔
983

984
        out.windows = group['windows'][()]
10✔
985

986
        out.broaden_poly = group['broaden_poly'][...].astype(bool)
10✔
987
        if out.broaden_poly.shape[0] != out.windows.shape[0]:
10✔
988
            raise ValueError(err.format('broaden_poly', 'windows'))
×
989

990
        out.curvefit = group['curvefit'][()]
10✔
991
        if out.curvefit.shape[0] != out.windows.shape[0]:
10✔
992
            raise ValueError(err.format('curvefit', 'windows'))
×
993

994
        # _broaden_wmp_polynomials assumes the curve fit has at least 3 terms.
995
        if out.fit_order < 2:
10✔
996
            raise ValueError("Windowed multipole is only supported for "
×
997
                             "curvefits with 3 or more terms.")
998

999
        # If HDF5 file was opened here, make sure it gets closed
1000
        if need_to_close:
10✔
1001
            h5file.close()
10✔
1002

1003
        return out
10✔
1004

1005
    @classmethod
10✔
1006
    def from_endf(cls, endf_file, log=False, vf_options=None, wmp_options=None):
10✔
1007
        """Generate windowed multipole neutron data from an ENDF evaluation.
1008

1009
        .. versionadded:: 0.12.1
1010

1011
        Parameters
1012
        ----------
1013
        endf_file : str
1014
            Path to ENDF evaluation
1015
        log : bool or int, optional
1016
            Whether to print running logs (use int for verbosity control)
1017
        vf_options : dict, optional
1018
            Dictionary of keyword arguments, e.g. {'njoy_error': 0.001},
1019
            passed to :func:`openmc.data.multipole.vectfit_nuclide`
1020
        wmp_options : dict, optional
1021
            Dictionary of keyword arguments, e.g. {'search': True, 'rtol': 0.01},
1022
            passed to :func:`openmc.data.WindowedMultipole.from_multipole`
1023

1024
        Returns
1025
        -------
1026
        openmc.data.WindowedMultipole
1027
            Resonant cross sections represented in the windowed multipole
1028
            format.
1029

1030
        """
1031

UNCOV
1032
        if vf_options is None:
×
UNCOV
1033
            vf_options = {}
×
1034

UNCOV
1035
        if wmp_options is None:
×
1036
            wmp_options = {}
×
1037

UNCOV
1038
        if log:
×
UNCOV
1039
            vf_options.update(log=log)
×
UNCOV
1040
            wmp_options.update(log=log)
×
1041

1042
        # generate multipole data from EDNF
UNCOV
1043
        mp_data = vectfit_nuclide(endf_file, **vf_options)
×
1044

1045
        # windowing
UNCOV
1046
        return cls.from_multipole(mp_data, **wmp_options)
×
1047

1048
    @classmethod
10✔
1049
    def from_multipole(cls, mp_data, search=None, log=False, **kwargs):
10✔
1050
        """Generate windowed multipole neutron data from multipole data.
1051

1052
        Parameters
1053
        ----------
1054
        mp_data : dictionary or str
1055
            Dictionary or Path to the multipole data stored in a pickle file
1056
        search : bool, optional
1057
            Whether to search for optimal window size and curvefit order.
1058
            Defaults to True if no windowing parameters are specified.
1059
        log : bool or int, optional
1060
            Whether to print running logs (use int for verbosity control)
1061
        **kwargs
1062
            Keyword arguments passed to :func:`openmc.data.multipole._windowing`
1063

1064
        Returns
1065
        -------
1066
        openmc.data.WindowedMultipole
1067
            Resonant cross sections represented in the windowed multipole
1068
            format.
1069

1070
        """
1071

UNCOV
1072
        if isinstance(mp_data, str):
×
1073
            # load multipole data from file
1074
            with open(mp_data, 'rb') as f:
×
1075
                mp_data = pickle.load(f)
×
1076

UNCOV
1077
        if search is None:
×
UNCOV
1078
            if 'n_cf' in kwargs and ('n_win' in kwargs or 'spacing' in kwargs):
×
UNCOV
1079
                search = False
×
1080
            else:
1081
                search = True
×
1082

1083
        # windowing with specific options
UNCOV
1084
        if not search:
×
1085
            # set default value for curvefit order if not specified
UNCOV
1086
            if 'n_cf' not in kwargs:
×
1087
                kwargs.update(n_cf=5)
×
UNCOV
1088
            return _windowing(mp_data, log=log, **kwargs)
×
1089

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

1102
                # update arguments dictionary
UNCOV
1103
                kwargs.update(n_win=n_w, n_cf=n_cf)
×
1104

1105
                # windowing
UNCOV
1106
                try:
×
UNCOV
1107
                    wmp = _windowing(mp_data, log=log, **kwargs)
×
UNCOV
1108
                except Exception as e:
×
UNCOV
1109
                    if log:
×
UNCOV
1110
                        print('Failed: ' + str(e))
×
UNCOV
1111
                    break
×
1112

1113
                # select wmp library with metric:
1114
                # - performance: average # used poles per window and CF order
1115
                # - memory: # windows
UNCOV
1116
                metric = -(wmp.poles_per_window * 10. + wmp.fit_order * 1. +
×
1117
                           wmp.n_windows * 0.01)
UNCOV
1118
                if best_wmp is None or metric > best_metric:
×
UNCOV
1119
                    if log:
×
UNCOV
1120
                        print("Best library so far.")
×
UNCOV
1121
                    best_wmp = deepcopy(wmp)
×
UNCOV
1122
                    best_metric = metric
×
1123

1124
        # return the best wmp library
UNCOV
1125
        if log:
×
UNCOV
1126
            print("Final library: {} poles, {} windows, {:.2g} poles per window, "
×
1127
                  "{} CF order".format(best_wmp.n_poles, best_wmp.n_windows,
1128
                   best_wmp.poles_per_window, best_wmp.fit_order))
1129

UNCOV
1130
        return best_wmp
×
1131

1132
    def _evaluate(self, E, T):
10✔
1133
        """Compute scattering, absorption, and fission cross sections.
1134

1135
        Parameters
1136
        ----------
1137
        E : Real
1138
            Energy of the incident neutron in eV.
1139
        T : Real
1140
            Temperature of the target in K.
1141

1142
        Returns
1143
        -------
1144
        3-tuple of Real
1145
            Scattering, absorption, and fission microscopic cross sections
1146
            at the given energy and temperature.
1147

1148
        """
1149

1150
        if E < self.E_min: return (0, 0, 0)
10✔
1151
        if E > self.E_max: return (0, 0, 0)
10✔
1152

1153
        # ======================================================================
1154
        # Bookkeeping
1155

1156
        # Define some frequently used variables.
1157
        sqrtkT = sqrt(K_BOLTZMANN * T)
10✔
1158
        sqrtE = sqrt(E)
10✔
1159
        invE = 1.0 / E
10✔
1160

1161
        # Locate us.  The i_window calc omits a + 1 present from the legacy
1162
        # Fortran version of OpenMC because of the 1-based vs. 0-based
1163
        # indexing.  Similarly startw needs to be decreased by 1.  endw does
1164
        # not need to be decreased because range(startw, endw) does not include
1165
        # endw.
1166
        i_window = min(self.n_windows - 1,
10✔
1167
                       int(np.floor((sqrtE - sqrt(self.E_min)) / self.spacing)))
1168
        startw = self.windows[i_window, 0] - 1
10✔
1169
        endw = self.windows[i_window, 1]
10✔
1170

1171
        # Initialize the ouptut cross sections.
1172
        sig_s = 0.0
10✔
1173
        sig_a = 0.0
10✔
1174
        sig_f = 0.0
10✔
1175

1176
        # ======================================================================
1177
        # Add the contribution from the curvefit polynomial.
1178

1179
        if sqrtkT != 0 and self.broaden_poly[i_window]:
10✔
1180
            # Broaden the curvefit.
1181
            dopp = self.sqrtAWR / sqrtkT
10✔
1182
            broadened_polynomials = _broaden_wmp_polynomials(E, dopp,
10✔
1183
                                                             self.fit_order + 1)
1184
            for i_poly in range(self.fit_order + 1):
10✔
1185
                sig_s += (self.curvefit[i_window, i_poly, _FIT_S]
10✔
1186
                          * broadened_polynomials[i_poly])
1187
                sig_a += (self.curvefit[i_window, i_poly, _FIT_A]
10✔
1188
                          * broadened_polynomials[i_poly])
1189
                if self.fissionable:
10✔
1190
                    sig_f += (self.curvefit[i_window, i_poly, _FIT_F]
10✔
1191
                              * broadened_polynomials[i_poly])
1192
        else:
1193
            temp = invE
10✔
1194
            for i_poly in range(self.fit_order + 1):
10✔
1195
                sig_s += self.curvefit[i_window, i_poly, _FIT_S] * temp
10✔
1196
                sig_a += self.curvefit[i_window, i_poly, _FIT_A] * temp
10✔
1197
                if self.fissionable:
10✔
1198
                    sig_f += self.curvefit[i_window, i_poly, _FIT_F] * temp
10✔
1199
                temp *= sqrtE
10✔
1200

1201
        # ======================================================================
1202
        # Add the contribution from the poles in this window.
1203

1204
        if sqrtkT == 0.0:
10✔
1205
            # If at 0K, use asymptotic form.
1206
            for i_pole in range(startw, endw):
10✔
1207
                psi_chi = -1j / (self.data[i_pole, _MP_EA] - sqrtE)
10✔
1208
                c_temp = psi_chi / E
10✔
1209
                sig_s += (self.data[i_pole, _MP_RS] * c_temp).real
10✔
1210
                sig_a += (self.data[i_pole, _MP_RA] * c_temp).real
10✔
1211
                if self.fissionable:
10✔
1212
                    sig_f += (self.data[i_pole, _MP_RF] * c_temp).real
10✔
1213

1214
        else:
1215
            # At temperature, use Faddeeva function-based form.
1216
            dopp = self.sqrtAWR / sqrtkT
10✔
1217
            for i_pole in range(startw, endw):
10✔
1218
                Z = (sqrtE - self.data[i_pole, _MP_EA]) * dopp
10✔
1219
                w_val = _faddeeva(Z) * dopp * invE * sqrt(pi)
10✔
1220
                sig_s += (self.data[i_pole, _MP_RS] * w_val).real
10✔
1221
                sig_a += (self.data[i_pole, _MP_RA] * w_val).real
10✔
1222
                if self.fissionable:
10✔
1223
                    sig_f += (self.data[i_pole, _MP_RF] * w_val).real
10✔
1224

1225
        return sig_s, sig_a, sig_f
10✔
1226

1227
    def __call__(self, E, T):
10✔
1228
        """Compute scattering, absorption, and fission cross sections.
1229

1230
        Parameters
1231
        ----------
1232
        E : Real or Iterable of Real
1233
            Energy of the incident neutron in eV.
1234
        T : Real
1235
            Temperature of the target in K.
1236

1237
        Returns
1238
        -------
1239
        3-tuple of Real or 3-tuple of numpy.ndarray
1240
            Scattering, absorption, and fission microscopic cross sections
1241
            at the given energy and temperature.
1242

1243
        """
1244

1245
        fun = np.vectorize(lambda x: self._evaluate(x, T))
10✔
1246
        return fun(E)
10✔
1247

1248
    def export_to_hdf5(self, path, mode='a', libver='earliest'):
10✔
1249
        """Export windowed multipole data to an HDF5 file.
1250

1251
        Parameters
1252
        ----------
1253
        path : str
1254
            Path to write HDF5 file to
1255
        mode : {'r+', 'w', 'x', 'a'}
1256
            Mode that is used to open the HDF5 file. This is the second argument
1257
            to the :class:`h5py.File` constructor.
1258
        libver : {'earliest', 'latest'}
1259
            Compatibility mode for the HDF5 file. 'latest' will produce files
1260
            that are less backwards compatible but have performance benefits.
1261

1262
        """
1263

1264
        # Open file and write version.
1265
        with h5py.File(str(path), mode, libver=libver) as f:
10✔
1266
            f.attrs['filetype'] = np.bytes_('data_wmp')
10✔
1267
            f.attrs['version'] = np.array(WMP_VERSION)
10✔
1268

1269
            g = f.create_group(self.name)
10✔
1270

1271
            # Write scalars.
1272
            g.create_dataset('spacing', data=np.array(self.spacing))
10✔
1273
            g.create_dataset('sqrtAWR', data=np.array(self.sqrtAWR))
10✔
1274
            g.create_dataset('E_min', data=np.array(self.E_min))
10✔
1275
            g.create_dataset('E_max', data=np.array(self.E_max))
10✔
1276

1277
            # Write arrays.
1278
            g.create_dataset('data', data=self.data)
10✔
1279
            g.create_dataset('windows', data=self.windows)
10✔
1280
            g.create_dataset('broaden_poly',
10✔
1281
                             data=self.broaden_poly.astype(np.int8))
1282
            g.create_dataset('curvefit', data=self.curvefit)
10✔
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