OpenMC/openmc/data/vectfit.py
azim-givron 360ec24b41
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>
2026-02-11 10:00:18 -06:00

811 lines
27 KiB
Python

"""
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