import numpy as np
import warnings
from .. import backend as B
from ..utils import estimate_hurst_from_scaling
[docs]
def power_spectrum_binned(data, max_wavelength=None, min_wavelength=None, nbins=50, axis=0):
"""
Compute binned power spectral density for data along specified axis.
Note: this estimator is FFT-based and therefore inherently treats the
domain as periodic, so there is no ``periodic`` flag (unlike
``structure_function`` / ``haar_fluctuation``).
Parameters
----------
data : array-like
N-dimensional input data array
max_wavelength : float, optional
Maximum wavelength to include in analysis
min_wavelength : float, optional
Minimum wavelength to include in analysis
nbins : int, optional
Number of logarithmic bins for PSD (default: 50)
axis : int, optional
Axis along which to compute FFT (default: 0)
Returns
-------
tuple (np.ndarray, np.ndarray)
binned_freq : 1-D array of binned frequencies
binned_psd : 1-D array of binned power spectral density values
"""
data = B.asarray(data)
if B.numel(data) == 0:
raise ValueError("Input data must not be empty")
if axis >= data.ndim or axis < -data.ndim:
raise ValueError(f"axis {axis} is out of bounds for array with {data.ndim} dimensions")
if max_wavelength is not None and max_wavelength <= 0:
raise ValueError("max_wavelength must be positive")
if min_wavelength is not None and min_wavelength <= 0:
raise ValueError("min_wavelength must be positive")
if min_wavelength is not None and max_wavelength is not None and min_wavelength >= max_wavelength:
raise ValueError(f"min_wavelength ({min_wavelength}) must be less than max_wavelength ({max_wavelength})")
n = data.shape[axis]
# Compute the one-sided FFT and corresponding power spectral density along specified axis
fft_vals = B.rfft(data, axis=axis)
psd = B.abs_squared(fft_vals) / n
del fft_vals
# Average PSD across other dimensions
avg_axes = tuple(i for i in range(data.ndim) if i != axis)
if avg_axes:
psd = B.mean(psd, axis=avg_axes)
freqs = B.rfftfreq(n, d=1) # assuming unit spacing
# Convert to numpy for the binning loop (small arrays, ~50 bins)
freqs = B.to_numpy(freqs)
psd = B.to_numpy(psd)
# Filter frequencies based on wavelength constraints
if max_wavelength is not None:
valid = (freqs > 0) & (freqs >= 1 / max_wavelength)
else:
valid = (freqs > 0)
if min_wavelength is not None:
valid = valid & (freqs <= 1 / min_wavelength)
if np.sum(valid) < 2:
raise ValueError("Not enough frequency points available for the given wavelength range")
f_valid = freqs[valid]
p_valid = psd[valid]
# Create logarithmic bins
bins = np.logspace(np.log10(f_valid.min()), np.log10(f_valid.max()), nbins + 1, dtype=np.float64)
# Bin the data
binned_freq = []
binned_psd = []
for i in range(nbins):
in_bin = (f_valid >= bins[i]) & (f_valid < bins[i+1])
if np.any(in_bin):
binned_freq.append(f_valid[in_bin].mean())
binned_psd.append(p_valid[in_bin].mean())
return np.array(binned_freq, dtype=np.float64), np.array(binned_psd, dtype=np.float64)
[docs]
def spectral_hurst(data, max_wavelength=None, min_wavelength=None, nbins=50, axis=0, return_fit=False):
"""
Estimate the Hurst exponent from the power spectral density along specified axis.
For fractional Brownian motion, PSD ~ f^(-β) with β = 2H + 1.
This function estimates H by performing linear regression on log(PSD) vs log(frequency).
Note: this estimator is FFT-based and therefore inherently treats the
domain as periodic, so there is no ``periodic`` flag (unlike
``structure_function_hurst`` / ``haar_fluctuation_hurst``).
Parameters
----------
data : array-like
N-dimensional input data array
max_wavelength : float, optional
Maximum wavelength to include in analysis. If None, defaults to domain_size/8
or domain_size if domain_size < 512 (with warning).
min_wavelength : float, optional
Minimum wavelength to include in analysis. If None, defaults to 4*Nyquist
or Nyquist if domain_size < 512.
nbins : int, optional
Number of logarithmic bins for PSD (default: 50)
axis : int, optional
Axis along which to compute FFT (default: 0)
return_fit : bool, optional
If True, return frequencies, PSD values, and fitted line
Returns
-------
If return_fit is False:
H : float
Estimated Hurst exponent
uncertainty : float
Standard error of the Hurst exponent estimate
If return_fit is True:
H : float
Estimated Hurst exponent
uncertainty : float
Standard error of the Hurst exponent estimate
freqs : np.ndarray
The frequencies used in the calculation
psd_values : np.ndarray
The power spectral density values
fit_line : np.ndarray
The fitted line used to estimate H
"""
data = B.asarray(data)
array_size = data.shape[axis]
# Check minimum array size for reliable Hurst estimation
if array_size < 16:
raise ValueError(f"Array size along axis {axis} is {array_size}, but minimum 16 points required for Hurst estimation")
# Set default wavelengths to isolate scaling range >> grid size and << domain size
domain_size = array_size # In grid units
nyquist_wavelength = 2 # Nyquist wavelength in grid units
if max_wavelength is None:
if array_size <= 512:
max_wavelength = domain_size
warnings.warn(f"Array size ({array_size}) is small. Using max_wavelength={max_wavelength}. "
"Consider using larger arrays for more reliable Hurst estimation.",
UserWarning)
else:
max_wavelength = domain_size / 8
if min_wavelength is None:
if array_size <= 512:
min_wavelength = nyquist_wavelength
else:
min_wavelength = 4 * nyquist_wavelength
# Compute binned power spectral density
binned_freq, binned_psd = power_spectrum_binned(data, max_wavelength, min_wavelength, nbins, axis)
if len(binned_freq) < 2:
raise ValueError("Not enough frequency bins for Hurst estimation")
# For fBm: PSD ~ f^(-β) where β = 2H + 1
# So H = ((-slope) - 1) / 2
# We use the common regression utility, then transform the slope
slope, slope_err, freqs_fit, psd_fit, fit_line = estimate_hurst_from_scaling(
binned_freq, binned_psd, return_fit=True
)
# Transform slope to Hurst exponent: H = ((-slope) - 1) / 2
H = ((-slope) - 1) / 2
H_uncertainty = slope_err / 2 # Error propagation
if return_fit:
return H, H_uncertainty, freqs_fit, psd_fit, fit_line
else:
return H, H_uncertainty
[docs]
def spectral_analysis(*args, **kwargs):
"""Deprecated: use power_spectrum_binned() instead."""
import warnings
warnings.warn("spectral_analysis is deprecated, use power_spectrum_binned instead",
DeprecationWarning, stacklevel=2)
return power_spectrum_binned(*args, **kwargs)