Source code for fdscore.psd_welch
"""Welch PSD estimation for vibration and inversion workflows.
This module provides the library's standard power spectral density
estimator for stationary time histories. The implementation is a thin
validated wrapper around ``scipy.signal.welch``, with explicit
handling of positivity, optional band cropping, and metadata-friendly
error reporting.
"""
from __future__ import annotations
import numpy as np
from scipy.signal import welch
from .types import PSDParams
from .validate import ValidationError
from ._psd_utils import clip_tiny_negative_psd_or_raise
[docs]
def compute_psd_welch(
x: np.ndarray,
fs: float,
psd: PSDParams,
) -> tuple[np.ndarray, np.ndarray]:
"""Compute a one-sided PSD using Welch's method.
Parameters
----------
x : numpy.ndarray
One-dimensional input time history.
fs : float
Sampling rate in Hz.
psd : PSDParams
PSD-estimation settings. The current implementation supports only
``method="welch"``.
Returns
-------
f_hz : numpy.ndarray
One-dimensional frequency grid in Hz.
psd_values : numpy.ndarray
One-dimensional PSD values in units squared per hertz.
Notes
-----
This helper is purely numerical and performs no file I/O.
The output is the one-sided PSD returned by Welch's method under the
requested detrending, window, overlap, and segment-length settings.
When ``psd.fmin`` or ``psd.fmax`` is provided, the spectrum is
cropped after estimation.
Tiny negative PSD values caused by floating-point noise are clipped to
zero. Materially negative values are rejected because they would
invalidate downstream log-domain operations and stochastic synthesis.
References
----------
Welch, P. D. (1967). The use of the fast Fourier transform for the
estimation of power spectra: A method based on time averaging over
short, modified periodograms. *IEEE Transactions on Audio and
Electroacoustics*, 15(2), 70-73.
"""
if psd.method != "welch":
raise ValidationError("PSDParams.method must be 'welch'.")
if not np.isfinite(fs) or float(fs) <= 0:
raise ValidationError("fs must be finite and > 0.")
x = np.asarray(x, dtype=float)
if x.ndim != 1 or x.size < 4:
raise ValidationError("x must be a 1D array with length >= 4.")
if not np.all(np.isfinite(x)):
raise ValidationError("x must contain only finite values.")
detrend = False if psd.detrend == "none" else psd.detrend
f, Pxx = welch(
x,
fs=float(fs),
window=str(psd.window),
nperseg=psd.nperseg,
noverlap=psd.noverlap,
detrend=detrend,
scaling=str(psd.scaling),
return_onesided=bool(psd.onesided),
)
f = np.asarray(f, dtype=float)
Pxx = np.asarray(Pxx, dtype=float)
if f.ndim != 1 or Pxx.ndim != 1 or f.shape != Pxx.shape:
raise ValidationError("welch returned unexpected shapes.")
if not (np.all(np.isfinite(f)) and np.all(np.isfinite(Pxx))):
raise ValidationError("welch produced non-finite outputs.")
Pxx = clip_tiny_negative_psd_or_raise(Pxx, label="welch PSD")
if psd.fmin is not None or psd.fmax is not None:
fmin = float(psd.fmin) if psd.fmin is not None else float(f[0])
fmax = float(psd.fmax) if psd.fmax is not None else float(f[-1])
if fmax <= fmin:
raise ValidationError("PSDParams requires fmax > fmin when cropping.")
m = (f >= fmin) & (f <= fmax)
f = f[m]
Pxx = Pxx[m]
if f.size < 2:
raise ValidationError("PSD frequency grid too small after cropping.")
return f, Pxx