Implement vector fitting to replace external vectfit package (#3493)

Co-authored-by: azim_givron <a.givron@naarea.fr>
Co-authored-by: Paul Romano <paul.k.romano@gmail.com>
Co-authored-by: GuySten <guyste@post.bgu.ac.il>
Co-authored-by: GuySten <62616591+GuySten@users.noreply.github.com>
This commit is contained in:
azim-givron 2026-02-11 17:00:18 +01:00 committed by GitHub
parent 44da7022b8
commit 360ec24b41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1102 additions and 92 deletions

View file

@ -49,7 +49,6 @@ jobs:
dagmc: [n]
libmesh: [n]
event: [n]
vectfit: [n]
include:
- python-version: "3.12"
@ -74,14 +73,9 @@ jobs:
python-version: "3.11"
omp: y
mpi: n
- vectfit: y
python-version: "3.11"
omp: n
mpi: y
name: "Python ${{ matrix.python-version }} (omp=${{ matrix.omp }},
mpi=${{ matrix.mpi }}, dagmc=${{ matrix.dagmc }},
libmesh=${{ matrix.libmesh }}, event=${{ matrix.event }}
vectfit=${{ matrix.vectfit }})"
libmesh=${{ matrix.libmesh }}, event=${{ matrix.event }}"
env:
MPI: ${{ matrix.mpi }}
@ -89,7 +83,6 @@ jobs:
OMP: ${{ matrix.omp }}
DAGMC: ${{ matrix.dagmc }}
EVENT: ${{ matrix.event }}
VECTFIT: ${{ matrix.vectfit }}
LIBMESH: ${{ matrix.libmesh }}
NPY_DISABLE_CPU_FEATURES: "AVX512F AVX512_SKX"
OPENBLAS_NUM_THREADS: 1
@ -150,11 +143,6 @@ jobs:
sudo update-alternatives --set mpirun /usr/bin/mpirun.mpich
sudo update-alternatives --set mpi-x86_64-linux-gnu /usr/include/x86_64-linux-gnu/mpich
- name: Optional apt dependencies for vectfit
shell: bash
if: ${{ matrix.vectfit == 'y' }}
run: sudo apt install -y libblas-dev liblapack-dev
- name: install
shell: bash
run: |

View file

@ -33,5 +33,6 @@ from .resonance_covariance import *
from .multipole import *
from .grid import *
from .function import *
from .vectfit import *
from .effective_dose.dose import dose_coefficients

View file

@ -15,7 +15,7 @@ from . import WMP_VERSION, WMP_VERSION_MAJOR
from .data import K_BOLTZMANN
from .neutron import IncidentNeutron
from .resonance import ResonanceRange
from .vectfit import vectfit, evaluate
# Constants that determine which value to access
_MP_EA = 0 # Pole
@ -174,10 +174,6 @@ def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None,
(poles, residues)
"""
# import vectfit package: https://github.com/liangjg/vectfit
import vectfit as vf
ne = energy.size
nmt = len(mts)
if ce_xs.shape != (nmt, ne):
@ -194,8 +190,8 @@ def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None,
test_xs_ref[i] = np.interp(test_energy, energy, ce_xs[i])
if log:
print(f" energy: {energy[0]:.3e} to {energy[-1]:.3e} eV ({ne} points)")
print(f" error tolerance: rtol={rtol}, atol={atol}")
print(f"\tenergy: {energy[0]:.3e} to {energy[-1]:.3e} eV ({ne} points)")
print(f"\terror tolerance: rtol={rtol}, atol={atol}")
# transform xs (sigma) and energy (E) to f (sigma*E) and s (sqrt(E)) to be
# compatible with the multipole representation
@ -251,7 +247,7 @@ def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None,
print(f"VF iteration {i_vf + 1}/{n_vf_iter}")
# call vf
poles, residues, cf, f_fit, rms = vf.vectfit(f, s, poles, weight)
poles, residues, *_ = vectfit(f, s, poles, weight)
# convert real pole to conjugate pairs
n_real_poles = 0
@ -268,11 +264,11 @@ def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None,
if n_real_poles > 0:
if log >= DETAILED_LOGGING:
print(f" # real poles: {n_real_poles}")
new_poles, residues, cf, f_fit, rms = \
vf.vectfit(f, s, new_poles, weight, skip_pole=True)
new_poles, residues, *_ = \
vectfit(f, s, new_poles, weight, skip_pole_update=True)
# assess the result on test grid
test_xs = vf.evaluate(test_s, new_poles, residues) / test_energy
test_xs = evaluate(test_s, new_poles, residues) / test_energy
abserr = np.abs(test_xs - test_xs_ref)
with np.errstate(invalid='ignore', divide='ignore'):
relerr = abserr / test_xs_ref
@ -388,9 +384,9 @@ def _vectfit_xs(energy, ce_xs, mts, rtol=1e-3, atol=1e-5, orders=None,
return (mp_poles, mp_residues)
def vectfit_nuclide(endf_file, njoy_error=5e-4, vf_pieces=None,
log=False, path_out=None, mp_filename=None, **kwargs):
log=False, path_out=None, mp_filename=None,
**kwargs):
r"""Generate multipole data for a nuclide from ENDF.
Parameters
@ -571,10 +567,6 @@ def _windowing(mp_data, n_cf, rtol=1e-3, atol=1e-5, n_win=None, spacing=None,
format.
"""
# import vectfit package: https://github.com/liangjg/vectfit
import vectfit as vf
# unpack multipole data
name = mp_data["name"]
awr = mp_data["AWR"]
@ -645,7 +637,7 @@ def _windowing(mp_data, n_cf, rtol=1e-3, atol=1e-5, n_win=None, spacing=None,
# reference xs from multipole form, note the residue terms in the
# multipole and vector fitting representations differ by a 1j
xs_ref = vf.evaluate(energy_sqrt, poles, residues*1j) / energy
xs_ref = evaluate(energy_sqrt, poles, residues*1j) / energy
# curve fit matrix
matrix = np.vstack([energy**(0.5*i - 1) for i in range(n_cf + 1)]).T
@ -659,7 +651,7 @@ def _windowing(mp_data, n_cf, rtol=1e-3, atol=1e-5, n_win=None, spacing=None,
# calculate the cross sections contributed by the windowed poles
if rp > lp:
xs_wp = vf.evaluate(energy_sqrt, poles[lp:rp],
xs_wp = evaluate(energy_sqrt, poles[lp:rp],
residues[:, lp:rp]*1j) / energy
else:
xs_wp = np.zeros_like(xs_ref)
@ -1054,7 +1046,15 @@ class WindowedMultipole(EqualityMixin):
return cls.from_multipole(mp_data, **wmp_options)
@classmethod
def from_multipole(cls, mp_data, search=None, log=False, **kwargs):
def from_multipole(
cls,
mp_data,
search=None,
log=False,
search_n_win=20,
search_cf_orders=None,
**kwargs,
):
"""Generate windowed multipole neutron data from multipole data.
Parameters
@ -1066,8 +1066,14 @@ class WindowedMultipole(EqualityMixin):
Defaults to True if no windowing parameters are specified.
log : bool or int, optional
Whether to print running logs (use int for verbosity control)
search_n_win : int, optional
Number of window sizes to consider in the search grid when
``search`` is True.
search_cf_orders : iterable of int, optional
Curve-fit orders to consider in the search grid when ``search`` is
True. Defaults to integers from 10 down to 2.
**kwargs
Keyword arguments passed to :func:`openmc.data.multipole._windowing`
Keyword arguments passed to :func:`openmc.data.multipole._windowing`.
Returns
-------
@ -1098,12 +1104,17 @@ class WindowedMultipole(EqualityMixin):
# search optimal WMP from a range of window sizes and CF orders
if log:
print("Start searching ...")
if search_cf_orders is None:
search_cf_orders = range(10, 1, -1)
n_poles = sum([p.size for p in mp_data["poles"]])
n_win_min = max(5, n_poles // 20)
n_win_max = 2000 if n_poles < 2000 else 8000
best_wmp = best_metric = None
for n_w in np.unique(np.linspace(n_win_min, n_win_max, 20, dtype=int)):
for n_cf in range(10, 1, -1):
for n_w in np.unique(
np.linspace(n_win_min, n_win_max, search_n_win, dtype=int)
):
for n_cf in search_cf_orders:
if log:
print(f"Testing N_win={n_w} N_cf={n_cf}")

811
openmc/data/vectfit.py Normal file
View file

@ -0,0 +1,811 @@
"""
Fast Relaxed Vector Fitting function
Approximate f(s) with a rational function:
f(s)=R*(s*I-A)^(-1) + Polynomials*s
where f(s) is a vector of elements.
When f(s) is a vector, all elements become fitted with a common pole set. The
identification is done using the pole relocating method known as Vector Fitting
[1] with relaxed non-triviality constraint for faster convergence and smaller
fitting errors [2], and utilization of matrix structure for fast solution of the
pole identifion step [3].
[1] B. Gustavsen and A. Semlyen, "Rational approximation of frequency
domain responses by Vector Fitting", IEEE Trans. Power Delivery, vol. 14,
no. 3, pp. 1052-1061, July 1999.
[2] B. Gustavsen, "Improving the pole relocating properties of vector
fitting", IEEE Trans. Power Delivery, vol. 21, no. 3, pp. 1587-1592, July
2006.
[3] D. Deschrijver, M. Mrozowski, T. Dhaene, and D. De Zutter,
"Macromodeling of Multiport Systems Using a Fast Implementation of the
Vector Fitting Method", IEEE Microwave and Wireless Components Letters, vol.
18, no. 6, pp. 383-385, June 2008.
All credit goes to:
- Bjorn Gustavsen for his MATLAB implementation.
(http://www.sintef.no/Projectweb/VECTFIT/)
- Jingang Liang for his C++ implementation.
(https://github.com/mit-crpg/vectfit.git)
"""
from typing import Tuple
import numpy as np
from scipy.linalg import eigvals, lstsq, norm, qr
def wlstsq(a, b):
"""Apply least-squares solve with column normalization.
Notes
-----
This routine rescales columns of `a` to improve conditioning. Columns with
zero norm are left unscaled to avoid divide-by-zero warnings.
"""
col_norm = np.linalg.norm(a, axis=0)
scale = np.ones_like(col_norm, dtype=float)
nonzero = col_norm > 0.0
scale[nonzero] = 1.0 / col_norm[nonzero]
scale = np.nan_to_num(scale, nan=1.0, posinf=1.0, neginf=1.0)
sol = lstsq(a * scale, b)
return (sol[0] * scale, sol[1:])
def evaluate(
eval_points: np.ndarray,
pole_values: np.ndarray,
residue_matrix: np.ndarray,
poly_coefficients: np.ndarray | None = None,
) -> np.ndarray:
"""Evaluate the rational function approximation:
f(s) sum(residue / (s - pole)) + sum(poly_coefficients * s^j)
Parameters
----------
eval_points : np.ndarray
1D array of real scalar frequency values (s).
pole_values : np.ndarray
1D array of complex poles.
residue_matrix : np.ndarray
2D or 1D array of complex residues (shape: [num_vectors, num_poles] or [num_poles]).
poly_coefficients : np.ndarray, optional
2D or 1D array of real polynomial coefficients (shape: [num_vectors, num_polys]).
Returns
-------
np.ndarray
2D array of evaluated real function values (shape: [num_vectors, num_samples]).
Raises
------
ValueError
If input arrays have incompatible shapes.
"""
eval_points = np.asarray(eval_points)
pole_values = np.asarray(pole_values)
residue_matrix = np.asarray(residue_matrix)
if eval_points.ndim != 1:
raise ValueError("eval_points must be a 1D array")
if pole_values.ndim != 1:
raise ValueError("pole_values must be a 1D array")
if residue_matrix.ndim == 1:
residue_matrix = residue_matrix.reshape((1, -1))
num_vectors, _ = residue_matrix.shape
num_samples = len(eval_points)
if poly_coefficients is not None and isinstance(poly_coefficients, list):
poly_coefficients = np.array(poly_coefficients)
if poly_coefficients is None or poly_coefficients.size == 0:
poly_coefficients = np.zeros((num_vectors, 0))
else:
poly_coefficients = np.asarray(poly_coefficients)
if poly_coefficients.ndim == 1:
poly_coefficients = poly_coefficients.reshape((1, -1))
elif poly_coefficients.shape[0] != num_vectors:
raise ValueError("Mismatch in residues and poly_coefficients shapes")
num_coeffs = poly_coefficients.shape[1]
result = np.zeros((num_vectors, num_samples))
# term: sum over poles of (residues / (eval_points - poles))
denominator = (
eval_points[np.newaxis, :] - pole_values[:, np.newaxis]
) # shape: (num_poles, num_eval)
pole_terms = residue_matrix @ (1.0 / denominator) # shape: (num_vectors, num_eval)
result = np.real(pole_terms)
# polynomial part: sum over poly_idx of (coeff * eval_points**poly_idx)
if num_coeffs > 0:
powers = (
eval_points[np.newaxis, :] ** np.arange(num_coeffs)[:, np.newaxis]
) # shape: (num_coeffs, num_eval)
result += poly_coefficients @ powers # shape: (num_vectors, num_eval)
return result
def vectfit(
response_matrix: np.ndarray,
eval_points: np.ndarray,
initial_poles: np.ndarray,
weights: np.ndarray,
n_polys: int = 0,
skip_pole_update: bool = False,
skip_residue_update: bool = False,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, float]:
"""Perform vector fitting using the Fast Relaxed Vector Fitting algorithm.
Parameters
----------
response_matrix : np.ndarray
Complex matrix of frequency responses (shape: [num_vectors, num_samples]).
eval_points : np.ndarray
Real frequency samples (s), shape (num_samples,).
initial_poles : np.ndarray
Initial guess for poles (complex), shape (num_poles,).
weights : np.ndarray
Weighting matrix for fitting (same shape as response_matrix).
n_polys : int, optional
Number of real polynomial terms to include.
skip_pole_update : bool, optional
Whether to skip pole relocation step.
skip_residue_update : bool, optional
Whether to skip residue fitting step.
Returns
-------
Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, float]
- Updated poles (np.ndarray)
- Residues (np.ndarray)
- Polynomial coefficients (np.ndarray)
- Fitted response matrix (np.ndarray)
- Root-mean-square error (float)
"""
tol_low = 1e-18
tol_high = 1e18
response_matrix = np.asarray(response_matrix)
eval_points = np.asarray(eval_points)
initial_poles = np.asarray(initial_poles)
weights = np.asarray(weights)
num_vectors, num_samples = response_matrix.shape
num_poles = len(initial_poles)
if n_polys < 0 or n_polys > 11:
raise ValueError("n_polys must be in [0, 11]")
residue_matrix = np.zeros((num_vectors, num_poles), dtype=np.complex128)
poly_coefficients = np.zeros((num_vectors, n_polys))
fit_result = np.zeros_like(response_matrix)
rms_error = 0.0
if num_poles == 0 and n_polys == 0:
rms_error = norm(response_matrix) / np.sqrt(num_vectors * num_samples)
return initial_poles, residue_matrix, poly_coefficients, fit_result, rms_error
if not skip_pole_update and num_poles > 0:
updated_poles = identify_poles(
num_poles,
num_samples,
n_polys,
initial_poles,
eval_points,
tol_high,
weights,
response_matrix,
num_vectors,
tol_low,
)
else:
updated_poles = initial_poles
if not skip_residue_update:
fit_result, rms_error = identify_residues(
num_poles,
updated_poles,
num_samples,
n_polys,
eval_points,
num_vectors,
weights,
response_matrix,
poly_coefficients,
residue_matrix,
)
return updated_poles, residue_matrix, poly_coefficients, fit_result, rms_error
def compute_dk_matrix(
dk_matrix: np.ndarray,
eval_points: np.ndarray,
poles: np.ndarray,
conj_index: np.ndarray,
num_poles: int,
num_polys: int,
tol_high: float = None,
):
"""Compute the dk_matrix used in windowed multipole evaluations.
Parameters
----------
dk_matrix : ndarray of shape (len(eval_points), M)
The full matrix used in least-squares fitting or evaluation.
eval_points : ndarray of shape (N,)
Energy points at which to evaluate.
poles : ndarray of shape (num_poles,)
Complex poles used in the resonance model.
conj_index : ndarray of shape (num_poles,)
Index array indicating pole conjugacy behavior: 0 (normal), 1 (add conjugate), 2 (imaginary part).
num_poles : int
Number of complex poles.
num_polys : int
Number of polynomial terms (including constant term).
tol_high : float
Replacement value for infinities.
"""
# Broadcast shapes
eval_points_col = eval_points[:, np.newaxis]
poles_row = poles[np.newaxis, :]
# Compute base terms
term1 = 1.0 / (eval_points_col - poles_row)
term2 = 1.0 / (eval_points_col - np.conj(poles_row))
term3 = 1j / (eval_points_col - np.conj(poles_row)) - 1j / (
eval_points_col - poles_row
)
# Masks for different conjugacy types
mask0 = conj_index == 0
mask1 = conj_index == 1
mask2 = conj_index == 2
# Fill dk_matrix with pole terms
dk_matrix[:, :num_poles][:, mask0] = term1[:, mask0]
dk_matrix[:, :num_poles][:, mask1] = term1[:, mask1] + term2[:, mask1]
dk_matrix[:, :num_poles][:, mask2] = term3[:, mask2]
# Replace infinities with high tolerance value
if tol_high is not None:
inf_mask = np.isinf(dk_matrix)
dk_matrix[inf_mask] = tol_high + 0j
# Add polynomial basis (Chebyshev-like, just powers here)
powers = np.arange(num_polys)
dk_matrix[:, num_poles : num_poles + num_polys] = eval_points_col**powers + 0j
return dk_matrix
def row_block_matrix(
dk_matrix: np.ndarray,
weights: np.ndarray,
response_matrix: np.ndarray,
vec_idx: int,
num_poles: int,
num_polys: int,
) -> np.ndarray:
"""
Construct a single matrix row block for the given vector index.
Parameters
----------
dk_matrix : ndarray of shape (num_samples, num_poles + num_polys)
Basis function evaluations at each sample point.
weights : ndarray of shape (num_vectors, num_samples)
Sample weights for each vector.
response_matrix : ndarray of shape (num_vectors, num_samples)
Response values at each sample point.
vec_idx : int
Index of the vector to construct the A1 block for.
num_poles : int
Number of poles used in the model.
num_polys : int
Number of polynomial basis terms.
Returns
-------
A : ndarray of shape (num_samples, num_poles + num_polys + num_poles + 1)
Weighted and assembled matrix block for the current vector.
"""
num_samples = dk_matrix.shape[0]
A = np.zeros(
(num_samples, num_poles + num_polys + num_poles + 1), dtype=np.complex128
)
# Weighted basis terms
A[:, : num_poles + num_polys] = (
weights[vec_idx][:, np.newaxis] * dk_matrix[:, : num_poles + num_polys]
)
# Weighted response terms (includes poles + 1)
A[:, num_poles + num_polys : num_poles + num_polys + num_poles + 1] = (
-weights[vec_idx][:, np.newaxis]
* dk_matrix[:, : num_poles + 1]
* response_matrix[vec_idx][:, np.newaxis]
)
return A
def process_constrained_block(
vec_idx: int,
dk_matrix: np.ndarray,
weights: np.ndarray,
response_matrix: np.ndarray,
num_samples: int,
num_poles: int,
num_polys: int,
scale_factor: float,
num_vectors: int,
) -> Tuple[int, np.ndarray, np.ndarray]:
"""
Construct a constrained least-squares system block for the given vector index.
This function computes the A matrix using weighted evaluations of the basis functions
and response terms. It appends a constraint row to enforce physical properties
(e.g., normalization) **only for the final vector index**. The full matrix A is
decomposed via QR, and the resulting triangular block is returned.
This routine is intended for use in the main vector fitting loop when the denominator
is well-conditioned but requires an additional constraint row for physical consistency.
Parameters
----------
vec_idx : int
Index of the vector to process.
dk_matrix : ndarray of shape (num_samples, num_poles + num_polys)
Evaluated basis functions at sample points.
weights : ndarray of shape (num_vectors, num_samples)
Weight matrix per vector.
response_matrix : ndarray of shape (num_vectors, num_samples)
Response function values for each vector.
num_samples : int
Number of sample points.
num_poles : int
Number of poles in the model.
num_polys : int
Number of polynomial terms in the model.
scale_factor : float
Scaling factor applied to the final constraint row.
num_vectors : int
Total number of vectors to process.
Returns
-------
vec_idx : int
Index of the processed vector.
lhs_block : ndarray of shape (num_poles + 1, num_poles + 1)
Triangular matrix block from QR decomposition.
rhs_block : ndarray of shape (num_poles + 1,) or None
Right-hand side vector block (only returned for final vec_idx), else None.
"""
A1 = row_block_matrix(
dk_matrix, weights, response_matrix, vec_idx, num_poles, num_polys
)
A = np.zeros((2 * num_samples + 1, num_poles + num_polys + num_poles + 1))
A[:num_samples] = A1.real
A[num_samples : 2 * num_samples] = A1.imag
# Handle final row only if vec_idx is last
if vec_idx == num_vectors - 1:
A[
2 * num_samples,
num_poles + num_polys : num_poles + num_polys + num_poles + 1,
] = scale_factor * np.real(dk_matrix[:, : num_poles + 1].sum(axis=0))
Q, R = qr(A, mode="economic")
lhs_block = R[
num_poles + num_polys : num_poles + num_polys + num_poles + 1,
num_poles + num_polys : num_poles + num_polys + num_poles + 1,
]
if vec_idx == num_vectors - 1:
rhs_block = (
num_samples
* scale_factor
* Q[-1, num_poles + num_polys : num_poles + num_polys + num_poles + 1]
)
else:
rhs_block = np.zeros_like(
Q[-1, num_poles + num_polys : num_poles + num_polys + num_poles + 1]
)
return vec_idx, lhs_block, rhs_block
def process_unconstrained_block(
vec_idx: int,
dk_matrix: np.ndarray,
weights: np.ndarray,
response_matrix: np.ndarray,
denom: float,
num_poles: int,
num_polys: int,
) -> Tuple[int, np.ndarray, np.ndarray]:
"""
Construct an unconstrained least-squares system block for the given vector index.
This function is used when the fitting denominator becomes ill-conditioned
(too small or too large), and the original constrained system is replaced by
an alternative regularized least-squares problem. The A matrix is built by stacking
the real and imaginary parts of the basis evaluations, and the RHS vector b is
scaled by `denom`.
A standard QR decomposition is used to extract the square block of the system,
which can be solved independently from the constrained system.
Parameters
----------
vec_idx : int
Index of the vector to process.
dk_matrix : ndarray of shape (num_samples, num_poles + num_polys)
Evaluated basis functions at sample points.
weights : ndarray of shape (num_vectors, num_samples)
Weight matrix per vector.
response_matrix : ndarray of shape (num_vectors, num_samples)
Response function values for each vector.
denom : float
Scaling factor applied to the right-hand side vector b.
num_poles : int
Number of poles in the model.
num_polys : int
Number of polynomial terms in the model.
Returns
-------
vec_idx : int
Index of the processed vector.
lhs_block : ndarray of shape (num_poles, num_poles)
Triangular matrix block from QR decomposition.
rhs_block : ndarray of shape (num_poles,)
Right-hand side vector block for this vector.
"""
A1 = row_block_matrix(
dk_matrix, weights, response_matrix, vec_idx, num_poles, num_polys
)
A = np.vstack((A1.real, A1.imag))
b1 = denom * weights[vec_idx] * response_matrix[vec_idx]
b = np.concatenate((b1.real, b1.imag))
Q, R = qr(A, mode="economic")
lhs_block = R[
num_poles + num_polys : num_poles + num_polys + num_poles,
num_poles + num_polys : num_poles + num_polys + num_poles,
]
rhs_block = Q[:, num_poles + num_polys : num_poles + num_polys + num_poles].T @ b
return vec_idx, lhs_block, rhs_block
def identify_poles(
num_poles: int,
num_samples: int,
num_polys: int,
poles: np.ndarray,
eval_points: np.ndarray,
tol_high: float,
weights: np.ndarray,
response_matrix: np.ndarray,
num_vectors: int,
tol_low: float,
) -> np.ndarray:
"""
Internal routine to update poles via relaxed vector fitting.
Parameters
----------
num_poles : int
Number of poles.
num_samples : int
Number of frequency samples.
num_polys : int
Number of polynomial terms.
poles : np.ndarray
Initial poles (complex), shape (num_poles,).
eval_points : np.ndarray
Real frequency values, shape (num_samples,).
tol_high : float
Upper tolerance threshold for denominator.
weights : np.ndarray
Weighting matrix, shape (num_vectors, num_samples).
response_matrix : np.ndarray
Complex frequency responses, shape (num_vectors, num_samples).
num_vectors : int
Number of response vectors.
tol_low : float
Lower tolerance threshold for denominator.
Returns
-------
np.ndarray
Updated poles as eigenvalues (shape: [num_poles]).
"""
conj_index = label_conjugate_poles(poles)
dk_matrix = np.zeros(
(num_samples, num_poles + max(num_polys, 1)), dtype=np.complex128
)
compute_dk_matrix(
dk_matrix, eval_points, poles, conj_index, num_poles, num_polys, tol_high
)
# For relaxed vector fitting, include the constant term in the sigma(s)
# function even when no polynomial terms are requested. This ensures the
# constrained system is well-posed when n_polys == 0.
if num_polys == 0:
dk_matrix[:, num_poles] = 1.0 + 0j
scale_factor = (
np.sqrt(
sum(norm(weights[m] * response_matrix[m]) ** 2 for m in range(num_vectors))
)
/ num_samples
)
lhs_matrix = np.zeros((num_vectors * (num_poles + 1), num_poles + 1))
rhs_vector = np.zeros(num_vectors * (num_poles + 1))
for vec_idx in range(num_vectors):
vec_idx, lhs_block, rhs_block = process_constrained_block(
vec_idx,
dk_matrix,
weights,
response_matrix,
num_samples,
num_poles,
num_polys,
scale_factor,
num_vectors,
)
i0 = vec_idx * (num_poles + 1)
i1 = (vec_idx + 1) * (num_poles + 1)
lhs_matrix[i0:i1] = lhs_block
rhs_vector[i0:i1] = rhs_block
solution, *_ = wlstsq(lhs_matrix, rhs_vector)
coeffs = solution[:-1]
denom = solution[-1]
if abs(denom) < tol_low or abs(denom) > tol_high:
lhs_matrix = np.zeros((num_vectors * num_poles, num_poles))
rhs_vector = np.zeros(num_vectors * num_poles)
# Adjust denom
if denom == 0.0:
denom = 1.0
elif abs(denom) < tol_low:
denom = np.sign(denom) * tol_low
elif abs(denom) > tol_high:
denom = np.sign(denom) * tol_high
# Allocate output
lhs_matrix = np.zeros((num_vectors * num_poles, num_poles))
rhs_vector = np.zeros(num_vectors * num_poles)
for vec_idx in range(num_vectors):
vec_idx, lhs_block, rhs_block = process_unconstrained_block(
vec_idx,
dk_matrix,
weights,
response_matrix,
denom,
num_poles,
num_polys,
)
i0 = vec_idx * num_poles
i1 = (vec_idx + 1) * num_poles
lhs_matrix[i0:i1] = lhs_block
rhs_vector[i0:i1] = rhs_block
coeffs, *_ = wlstsq(lhs_matrix, rhs_vector)
lambda_matrix = np.zeros((num_poles, num_poles))
scale_vector = np.ones((num_poles, 1))
# Mask for real poles (conj_index == 0)
mask_real = conj_index == 0
real_indices = np.where(mask_real)[0]
lambda_matrix[real_indices, real_indices] = np.real(poles[real_indices])
# Mask for start of complex conjugate pairs (conj_index == 1)
mask_cplx_start = conj_index == 1
cplx_indices = np.where(mask_cplx_start)[0]
# Extract real and imaginary parts of complex conjugate poles
real_parts = np.real(poles[cplx_indices])
imag_parts = np.imag(poles[cplx_indices])
# Diagonal assignments
lambda_matrix[cplx_indices, cplx_indices] = real_parts
lambda_matrix[cplx_indices + 1, cplx_indices + 1] = real_parts
# Off-diagonal assignments
lambda_matrix[cplx_indices, cplx_indices + 1] = imag_parts
lambda_matrix[cplx_indices + 1, cplx_indices] = -imag_parts
# Scaling vector adjustments
scale_vector[cplx_indices, 0] = 2.0
scale_vector[cplx_indices + 1, 0] = 0.0
residue_matrix = lambda_matrix - np.outer(scale_vector.squeeze(), coeffs) / denom
return eigvals(residue_matrix)
def solve_vector_block(
vec_idx: int,
dk_matrix: np.ndarray,
weights: np.ndarray,
response_matrix: np.ndarray,
num_poles: int,
num_polys: int,
) -> Tuple[int, np.ndarray, np.ndarray]:
"""
Solve the least-squares system for a single vector index.
Parameters
----------
vec_idx : int
Index of the vector to solve.
dk_matrix : ndarray
Basis function evaluations of shape (num_samples, num_poles + num_polys).
weights : ndarray
Weight array of shape (num_vectors, num_samples).
response_matrix : ndarray
Response array of shape (num_vectors, num_samples).
num_poles : int
Number of poles.
num_polys : int
Number of polynomial coefficients.
Returns
-------
vec_idx : int
The index of the solved vector.
residues : ndarray
Solution vector for the residues (length = num_poles).
poly_coeffs : ndarray or None
Solution vector for polynomial coefficients (length = num_polys), or None if num_polys == 0.
"""
A = dk_matrix * weights[vec_idx][:, np.newaxis]
b = weights[vec_idx] * response_matrix[vec_idx]
lhs_matrix = np.vstack((A.real, A.imag))
rhs_vector = np.concatenate((b.real, b.imag))
x = wlstsq(lhs_matrix, rhs_vector)[0]
residues = x[:num_poles]
poly_coeffs = x[num_poles : num_poles + num_polys] if num_polys > 0 else None
return vec_idx, residues, poly_coeffs
def identify_residues(
num_poles: int,
poles: np.ndarray,
num_samples: int,
num_polys: int,
eval_points: np.ndarray,
num_vectors: int,
weights: np.ndarray,
response_matrix: np.ndarray,
poly_coefficients: np.ndarray,
residue_matrix: np.ndarray,
) -> Tuple[np.ndarray, float]:
"""
Internal routine to compute residues and polynomial coefficients.
Parameters
----------
num_poles : int
Number of poles.
poles : np.ndarray
Current poles (complex), shape (num_poles,).
num_samples : int
Number of frequency samples.
num_polys : int
Number of polynomial terms.
eval_points : np.ndarray
Real frequency values, shape (num_samples,).
num_vectors : int
Number of response vectors.
weights : np.ndarray
Weighting matrix, shape (num_vectors, num_samples).
response_matrix : np.ndarray
Complex frequency responses, shape (num_vectors, num_samples).
poly_coefficients : np.ndarray
Array to store output polynomial coefficients (in-place).
residue_matrix : np.ndarray
Array to store output residues (in-place).
Returns
-------
Tuple[np.ndarray, float]
- Fitted response matrix (np.ndarray)
- Root-mean-square fitting error (float)
"""
conj_index = label_conjugate_poles(poles)
dk_matrix = np.zeros((num_samples, num_poles + num_polys), dtype=np.complex128)
compute_dk_matrix(dk_matrix, eval_points, poles, conj_index, num_poles, num_polys)
real_residues = np.zeros((num_vectors, num_poles), dtype=np.float64)
for vec_idx in range(num_vectors):
vec_idx, residues, poly_coeffs = solve_vector_block(
vec_idx,
dk_matrix,
weights,
response_matrix,
num_poles,
num_polys,
)
real_residues[vec_idx] = residues
if poly_coeffs is not None:
poly_coefficients[vec_idx] = poly_coeffs
# Mask for real poles
mask_real = conj_index == 0
real_indices = np.where(mask_real)[0]
residue_matrix[:, real_indices] = real_residues[:, real_indices]
# Mask for first of complex conjugate pairs
mask_cplx_start = conj_index == 1
cplx_indices = np.where(mask_cplx_start)[0]
# Compute complex residues using vectorized operations
residue_matrix[:, cplx_indices] = (
real_residues[:, cplx_indices] + 1j * real_residues[:, cplx_indices + 1]
)
residue_matrix[:, cplx_indices + 1] = (
real_residues[:, cplx_indices] - 1j * real_residues[:, cplx_indices + 1]
)
fit_result = evaluate(eval_points, poles, residue_matrix, poly_coefficients)
rms_error = norm(fit_result - response_matrix) / np.sqrt(num_vectors * num_samples)
return fit_result, rms_error
def label_conjugate_poles(poles: np.ndarray) -> np.ndarray:
"""
Ensure complex poles appear in conjugate pairs and label them accordingly.
Parameters
----------
poles : np.ndarray
1D array of complex poles.
Returns
-------
np.ndarray
Array of integers indicating pole type:
- 0: real pole
- 1: first in a complex-conjugate pair
- 2: second in a complex-conjugate pair
Raises
------
ValueError
If any complex pole does not have a valid conjugate pair.
"""
num_poles = len(poles)
conj_index = np.zeros(num_poles, dtype=int)
# Identify complex poles (nonzero imaginary part)
is_complex = np.imag(poles) != 0.0
# Find conjugate pairs: poles[i+1] ≈ conj(poles[i])
is_pair_start = is_complex[:-1] & np.isclose(np.conj(poles[:-1]), poles[1:])
# Mark valid conjugate pair entries
conj_index[:-1][is_pair_start] = 1 # mark i with 1
conj_index[1:][is_pair_start] = 2 # mark i+1 with 2
# Now validate: all complex poles must be part of valid conjugate pairs
unmatched_complex = is_complex & (conj_index == 0)
if np.any(unmatched_complex):
raise ValueError("Complex poles must appear in conjugate pairs")
return conj_index

View file

@ -49,14 +49,40 @@ def test_export_to_hdf5(tmpdir, u235):
def test_from_endf(endf_data):
pytest.importorskip('vectfit')
endf_file = os.path.join(endf_data, 'neutrons', 'n-001_H_001.endf')
assert openmc.data.WindowedMultipole.from_endf(
endf_file, log=True, wmp_options={"n_win": 400, "n_cf": 3})
endf_file,
log=True,
# Keep the test lightweight
vf_options={
"njoy_error": 5e-3,
"vf_pieces": 1,
"rtol": 5e-2,
"atol": 1e-3,
"orders": [8, 12],
"n_vf_iter": 6,
},
wmp_options={"n_win": 50, "n_cf": 3, "rtol": 5e-2, "atol": 1e-3},
)
def test_from_endf_search(endf_data):
pytest.importorskip('vectfit')
endf_file = os.path.join(endf_data, 'neutrons', 'n-095_Am_244.endf')
endf_file = os.path.join(endf_data, 'neutrons', 'n-001_H_001.endf')
assert openmc.data.WindowedMultipole.from_endf(
endf_file, log=True, wmp_options={"search": True, 'rtol':1e-2})
endf_file,
log=True,
vf_options={
"njoy_error": 5e-3,
"vf_pieces": 1,
"rtol": 5e-2,
"atol": 1e-3,
"orders": [8, 12],
"n_vf_iter": 6,
},
wmp_options={
"search": True,
"rtol": 5e-2,
"search_n_win": 3,
"search_cf_orders": [5, 3],
},
)

View file

@ -0,0 +1,224 @@
"""
Initially from Jingang Liang: https://github.com/mit-crpg/vectfit.git
"""
import numpy as np
import pytest
from openmc.data.vectfit import evaluate, vectfit
@pytest.fixture
def ref_poles():
"""Reference poles for real-pole test."""
return np.array(
[
9.709261771920490e02 + 0.0j,
-1.120960794075339e03 + 0.0j,
1.923889557426567e00 + 7.543700246109742e01j,
1.923889557426567e00 - 7.543700246109742e01j,
1.159741300380281e02 + 3.595650922556496e-02j,
1.159741300380281e02 - 3.595650922556496e-02j,
1.546932165729394e02 + 8.728391144940301e-02j,
1.546932165729394e02 - 8.728391144940301e-02j,
2.280349190818197e02 + 2.814037559718684e-01j,
2.280349190818197e02 - 2.814037559718684e-01j,
2.313004772627853e02 + 3.004628477692201e-01j,
2.313004772627853e02 - 3.004628477692201e-01j,
2.787470098364861e02 + 3.414179169920170e-01j,
2.787470098364861e02 - 3.414179169920170e-01j,
3.570711338764254e02 + 4.485587371149193e-01j,
3.570711338764254e02 - 4.485587371149193e-01j,
4.701059001346060e02 + 6.598089307174224e-01j,
4.701059001346060e02 - 6.598089307174224e-01j,
7.275819506342254e02 + 1.189678974845038e03j,
7.275819506342254e02 - 1.189678974845038e03j,
]
)
@pytest.fixture
def ref_residues():
"""Reference residues for real-pole test."""
return np.array(
[
[
-3.269879776751686e07 + 0.0j,
1.131087935798761e09 + 0.0j,
1.634151281869857e04 + 2.251103589277891e05j,
1.634151281869857e04 - 2.251103589277891e05j,
3.281792303833561e03 - 1.756079516325274e04j,
3.281792303833561e03 + 1.756079516325274e04j,
1.110800880243503e04 - 4.324813594540043e04j,
1.110800880243503e04 + 4.324813594540043e04j,
8.812700704117636e04 - 2.256520243571103e05j,
8.812700704117636e04 + 2.256520243571103e05j,
5.842090495551535e04 - 1.442159380741478e05j,
5.842090495551535e04 + 1.442159380741478e05j,
1.339410514130921e05 - 2.640767909713812e05j,
1.339410514130921e05 + 2.640767909713812e05j,
2.211245633333130e05 - 3.222447758311512e05j,
2.211245633333130e05 + 3.222447758311512e05j,
4.124430059785149e05 - 4.076023108323907e05j,
4.124430059785149e05 + 4.076023108323907e05j,
1.607378314999252e09 - 1.401163320110452e08j,
1.607378314999252e09 + 1.401163320110452e08j,
]
]
)
@pytest.fixture
def vector_test_data():
"""Simple 2-signal test with known poles and residues."""
Ns = 101
s = np.linspace(3.0, 7.0, Ns)
poles = [5.0 + 0.1j, 5.0 - 0.1j]
residues = [[0.5 - 11.0j, 0.5 + 11.0j], [1.5 - 20.0j, 1.5 + 20.0j]]
f = np.zeros((2, Ns))
for i in range(2):
f[i, :] = np.real(
residues[i][0] / (s - poles[0]) + residues[i][1] / (s - poles[1])
)
weight = 1.0 / f
init_poles = [3.5 + 0.035j, 3.5 - 0.035j]
return s, poles, residues, f, weight, init_poles
@pytest.fixture
def poly_test_data():
"""Test data with rational function plus polynomial terms."""
Ns = 201
s = np.linspace(0.0, 5.0, Ns)
poles = [-20.0 + 30.0j, -20.0 - 30.0j]
residues = [[5.0 + 10.0j, 5.0 - 10.0j]]
polys = [[1.0, 2.0, 0.3]]
f = evaluate(s, poles, residues, polys)
weight = 1.0 / f
init_poles = [2.5 + 0.025j, 2.5 - 0.025j]
return s, poles, residues, polys, f, weight, init_poles
@pytest.fixture
def real_poles_data(ref_poles, ref_residues):
"""Large-scale signal using complex and real poles."""
Ns = 2000
s = np.linspace(1.0e-2, 5.0e3, Ns)
f = np.zeros((1, Ns))
for p, r in zip(ref_poles, ref_residues[0]):
f[0] += (r / (s - p)).real
weight = 1.0 / f
poles = np.linspace(1.1e-2, 4.8e3, 10)
poles = poles + poles * 0.01j
poles = np.sort(np.append(poles, np.conj(poles)))
return s, f, weight, poles
@pytest.fixture
def large_test_data():
"""Stress test data with thousands of poles and samples."""
Ns = 3000
N = 200
s = np.linspace(1.0e-2, 5.0e3, Ns)
poles = np.linspace(1.1e-2, 4.8e3, N // 2) + 0.01j * np.linspace(
1.1e-2, 4.8e3, N // 2
)
poles = np.sort(np.append(poles, np.conj(poles)))
residues = np.linspace(1e2, 1e6, N // 2) + 0.5j * np.linspace(1e2, 1e6, N // 2)
residues = np.sort(np.append(residues, np.conj(residues))).reshape((1, N))
f = np.zeros((1, Ns))
for p, r in zip(poles, residues[0]):
f[0] += (r / (s - p)).real
weight = 1.0 / f
init_poles = np.linspace(1.2e-2, 4.7e3, N // 2) + 0.01j * np.linspace(
1.2e-2, 4.7e3, N // 2
)
init_poles = np.sort(np.append(init_poles, np.conj(init_poles)))
return s, f, weight, init_poles
@pytest.fixture
def eval_test_data():
"""Reference data for evaluating rational + polynomial models."""
Ns = 101
s = np.linspace(-5.0, 5.0, Ns)
poles = [-2.0 + 30.0j, -2.0 - 30.0j]
residues = [5.0 + 10.0j, 5.0 - 10.0j]
polys = [1.0, 2.0, 0.3]
return s, poles, residues, polys
def test_vector(vector_test_data):
"""Test vectfit with vector samples and simple poles.
It is expected to get exact results with one iteration.
"""
s, expected_poles, expected_residues, f, weight, init_poles = vector_test_data
poles, residues, _, fit, _ = vectfit(f, s, init_poles, weight)
assert np.allclose(
np.sort_complex(poles), np.sort_complex(expected_poles), rtol=1e-7
)
assert np.allclose(f, evaluate(s, poles, residues), rtol=1e-7)
assert np.allclose(f, fit, rtol=1e-5)
def test_poly(poly_test_data):
"""Test vectfit with polynomials."""
s, expected_poles, expected_residues, expected_polys, f, weight, init_poles = (
poly_test_data
)
poles, residues, cf, fit, _ = vectfit(f, s, init_poles, weight, n_polys=3)
poles, residues, cf, fit, _ = vectfit(f, s, poles, weight, n_polys=3)
assert np.allclose(
np.sort_complex(poles), np.sort_complex(expected_poles), rtol=1e-5
)
assert np.allclose(f, evaluate(s, poles, residues, cf), rtol=1e-5)
assert np.allclose(cf, expected_polys, rtol=1e-5)
assert np.allclose(f, fit, rtol=1e-4)
def test_real_poles(real_poles_data, ref_poles, ref_residues):
"""Test vectfit with more poles including real poles"""
s, f, weight, poles = real_poles_data
for _ in range(6):
poles, residues, _, fit, _ = vectfit(f, s, poles, weight)
assert np.allclose(
np.sort_complex(poles), np.sort_complex(ref_poles), rtol=1e-5, atol=1e-8
)
assert np.allclose(f, evaluate(s, poles, residues), rtol=1e-4)
assert np.allclose(f, fit, rtol=1e-3)
def test_large(large_test_data):
"""Test vectfit with a large set of poles and samples"""
s, f, weight, init_poles = large_test_data
poles_fit, residues_fit, _, f_fit, _ = vectfit(f, s, init_poles, weight)
assert np.allclose(f, f_fit, rtol=1e-3)
def test_evaluate(eval_test_data):
"""Test evaluate function"""
s, poles, residues, polys = eval_test_data
# Single signal, no polynomial
f_ref = np.real(residues[0] / (s - poles[0]) + residues[1] / (s - poles[1]))
f = evaluate(s, poles, residues)
assert np.allclose(f[0], f_ref)
# Single signal, with polynomial
for n, c in enumerate(polys):
f_ref += c * np.power(s, n)
f = evaluate(s, poles, residues, polys)
assert np.allclose(f[0], f_ref)
# Multi-signal, multi-residue, multi-poly
poles = [5.0 + 0.1j, 5.0 - 0.1j]
residues = [[0.5 - 11.0j, 0.5 + 11.0j], [1.5 - 20.0j, 1.5 + 20.0j]]
polys = [[1.0, 2.0, 0.3], [4.0, -2.0, -10.0]]
f_ref = np.zeros((2, len(s)))
for i in range(2):
f_ref[i, :] = np.real(
residues[i][0] / (s - poles[0]) + residues[i][1] / (s - poles[1])
)
for n, c in enumerate(polys[i]):
f_ref[i, :] += c * np.power(s, n)
f = evaluate(s, poles, residues, polys)
assert np.allclose(f, f_ref)

View file

@ -1,46 +0,0 @@
#!/bin/bash
set -ex
PYBIND_BRANCH='master'
PYBIND_REPO='https://github.com/pybind/pybind11'
XTL_BRANCH='0.6.13'
XTL_REPO='https://github.com/xtensor-stack/xtl'
XTENSOR_BRANCH='0.21.3'
XTENSOR_REPO='https://github.com/xtensor-stack/xtensor'
XTENSOR_PYTHON_BRANCH='0.24.1'
XTENSOR_PYTHON_REPO='https://github.com/xtensor-stack/xtensor-python'
XTENSOR_BLAS_BRANCH='0.17.1'
XTENSOR_BLAS_REPO='https://github.com/xtensor-stack/xtensor-blas'
cd $HOME
git clone -b $PYBIND_BRANCH $PYBIND_REPO
cd pybind11 && mkdir build && cd build && cmake .. && sudo make install
pip install $HOME/pybind11
cd $HOME
git clone -b $XTL_BRANCH $XTL_REPO
cd xtl && mkdir build && cd build && cmake .. && sudo make install
cd $HOME
git clone -b $XTENSOR_BRANCH $XTENSOR_REPO
cd xtensor && mkdir build && cd build && cmake .. && sudo make install
cd $HOME
git clone -b $XTENSOR_PYTHON_BRANCH $XTENSOR_PYTHON_REPO
cd xtensor-python && mkdir build && cd build && cmake .. && sudo make install
cd $HOME
git clone -b $XTENSOR_BLAS_BRANCH $XTENSOR_BLAS_REPO
cd xtensor-blas && mkdir build && cd build && cmake .. && sudo make install
# Install wheel (remove when vectfit supports installation with build isolation)
pip install wheel
# Install vectfit
cd $HOME
git clone https://github.com/liangjg/vectfit.git
pip install --no-build-isolation ./vectfit

View file

@ -18,11 +18,6 @@ fi
pip install 'ncrystal>=4.1.0'
nctool --test
# Install vectfit for WMP generation if needed
if [[ $VECTFIT = 'y' ]]; then
./tools/ci/gha-install-vectfit.sh
fi
# Install libMesh if needed
if [[ $LIBMESH = 'y' ]]; then
./tools/ci/gha-install-libmesh.sh