Try it — compute all 424 features on a real signal
Pick a preset (MAFAULDA-shape synthetic with textbook diagnostic
signatures) or upload your own triaxial .csv / .npy. Values appear
inline next to every feature card below. Computed by the production
v5.lib.features_v5.extract_features on kaltech-ml.
Contents
Time-domain statistics
11 feature definitionsStatistical moments and shape factors computed directly on the acceleration waveform. Kurtosis and crest factor are the textbook early-warning indicators of impulsive faults; RMS and peak govern ISO 10816 severity zoning.
Randall 2011 §2.4 covers the classical 11-feature set. Kurtosis convention is Pearson (non-excess) — Gaussian = 3.0 — which all KALTECH thresholds (4.0, 4.5, 8.0) are calibrated to.
rms Root Mean Square per-axis (×3) ———
- Units
- g (acceleration)
- Textbook
- Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:831–836 :: extract_features # -----------------------------------------------------------------------
# TIME-DOMAIN STATISTICAL (11 features)
# -----------------------------------------------------------------------
rms = float(np.sqrt(np.mean(signal ** 2)))
peak = float(np.max(np.abs(signal)))
crest_factor = peak / rms if rms > 0 else 0.0
peak Peak amplitude per-axis (×3) ———
- Units
- g
- Textbook
- Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:831–836 :: extract_features # -----------------------------------------------------------------------
# TIME-DOMAIN STATISTICAL (11 features)
# -----------------------------------------------------------------------
rms = float(np.sqrt(np.mean(signal ** 2)))
peak = float(np.max(np.abs(signal)))
crest_factor = peak / rms if rms > 0 else 0.0
mean Mean (DC offset) per-axis (×3) ———
- Units
- g
- Textbook
- Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:838–844 :: extract_features mean = float(np.mean(signal))
std_dev = float(np.std(signal))
var = std_dev ** 2
# Non-excess kurtosis: mu_4/sigma^4 (Gaussian = 3.0, NOT 0.0)
# All downstream thresholds (4.0, 8.0) are calibrated to this definition.
kurtosis = float(np.mean((signal - mean) ** 4) / (var ** 2)) if var > 0 else 0.0
skewness = float(np.mean((signal - mean) ** 3) / (std_dev ** 3)) if std_dev > 0 else 0.0
std_dev Standard deviation per-axis (×3) ———
- Units
- g
- Textbook
- Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:838–844 :: extract_features mean = float(np.mean(signal))
std_dev = float(np.std(signal))
var = std_dev ** 2
# Non-excess kurtosis: mu_4/sigma^4 (Gaussian = 3.0, NOT 0.0)
# All downstream thresholds (4.0, 8.0) are calibrated to this definition.
kurtosis = float(np.mean((signal - mean) ** 4) / (var ** 2)) if var > 0 else 0.0
skewness = float(np.mean((signal - mean) ** 3) / (std_dev ** 3)) if std_dev > 0 else 0.0
variance Variance per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:838–844 :: extract_features mean = float(np.mean(signal))
std_dev = float(np.std(signal))
var = std_dev ** 2
# Non-excess kurtosis: mu_4/sigma^4 (Gaussian = 3.0, NOT 0.0)
# All downstream thresholds (4.0, 8.0) are calibrated to this definition.
kurtosis = float(np.mean((signal - mean) ** 4) / (var ** 2)) if var > 0 else 0.0
skewness = float(np.mean((signal - mean) ** 3) / (std_dev ** 3)) if std_dev > 0 else 0.0
kurtosis Kurtosis (Pearson) per-axis (×3) ———
- Units
- dimensionless
- Textbook
- Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:838–844 :: extract_features mean = float(np.mean(signal))
std_dev = float(np.std(signal))
var = std_dev ** 2
# Non-excess kurtosis: mu_4/sigma^4 (Gaussian = 3.0, NOT 0.0)
# All downstream thresholds (4.0, 8.0) are calibrated to this definition.
kurtosis = float(np.mean((signal - mean) ** 4) / (var ** 2)) if var > 0 else 0.0
skewness = float(np.mean((signal - mean) ** 3) / (std_dev ** 3)) if std_dev > 0 else 0.0
skewness Skewness per-axis (×3) ———
- Units
- dimensionless
- Textbook
- Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:838–844 :: extract_features mean = float(np.mean(signal))
std_dev = float(np.std(signal))
var = std_dev ** 2
# Non-excess kurtosis: mu_4/sigma^4 (Gaussian = 3.0, NOT 0.0)
# All downstream thresholds (4.0, 8.0) are calibrated to this definition.
kurtosis = float(np.mean((signal - mean) ** 4) / (var ** 2)) if var > 0 else 0.0
skewness = float(np.mean((signal - mean) ** 3) / (std_dev ** 3)) if std_dev > 0 else 0.0
crest_factor Crest factor per-axis (×3) ———
- Units
- dimensionless
- Textbook
- Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:831–836 :: extract_features # -----------------------------------------------------------------------
# TIME-DOMAIN STATISTICAL (11 features)
# -----------------------------------------------------------------------
rms = float(np.sqrt(np.mean(signal ** 2)))
peak = float(np.max(np.abs(signal)))
crest_factor = peak / rms if rms > 0 else 0.0
shape_factor Shape factor per-axis (×3) ———
- Units
- dimensionless
- Textbook
- Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:846–849 :: extract_features abs_signal = np.abs(signal)
mean_abs = float(np.mean(abs_signal))
shape_factor = rms / mean_abs if mean_abs > 0 else 0.0
impulse_factor = peak / mean_abs if mean_abs > 0 else 0.0
impulse_factor Impulse factor per-axis (×3) ———
- Units
- dimensionless
- Textbook
- Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:846–849 :: extract_features abs_signal = np.abs(signal)
mean_abs = float(np.mean(abs_signal))
shape_factor = rms / mean_abs if mean_abs > 0 else 0.0
impulse_factor = peak / mean_abs if mean_abs > 0 else 0.0
clearance_factor Clearance factor per-axis (×3) ———
- Units
- dimensionless
- Textbook
- Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:851–852 :: extract_features mean_sqrt_abs = float(np.mean(np.sqrt(abs_signal)))
clearance_factor = peak / (mean_sqrt_abs ** 2) if mean_sqrt_abs > 0 else 0.0
Frequency-domain statistics
10 feature definitionsBulk descriptors of the FFT magnitude spectrum — used as priors by the ML head and as gating features in the verdict engine. None of these alone diagnoses a bearing fault; they characterise the energy distribution and are aggregated with defect-frequency energies (Group 4–8) for diagnosis.
Randall 2011 §3.6. Computed from |rfft(x)|·(2/N) with DC and Nyquist bins scaled by 1/N — matches NumPy's amplitude convention used in features.py compute_fft.
dominant_freq_hz Dominant frequency per-axis (×3) ———
- Units
- Hz
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
- Visualisation
- spectrum
v5/lib/features_v5.py:863–863 :: extract_features dominant_freq_hz = float(freqs_ndc[np.argmax(mags_ndc)]) if len(mags_ndc) > 0 else 0.0
spectral_centroid Spectral centroid per-axis (×3) ———
- Units
- Hz
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
- Visualisation
- spectrum
v5/lib/features_v5.py:869–872 :: extract_features if total_power > 0:
weights = power / total_power
spectral_centroid = float(np.sum(freqs_ndc * weights))
spectral_bandwidth = float(np.sqrt(np.sum(((freqs_ndc - spectral_centroid) ** 2) * weights)))
spectral_bandwidth Spectral bandwidth per-axis (×3) ———
- Units
- Hz
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
- Visualisation
- spectrum
v5/lib/features_v5.py:869–872 :: extract_features if total_power > 0:
weights = power / total_power
spectral_centroid = float(np.sum(freqs_ndc * weights))
spectral_bandwidth = float(np.sqrt(np.sum(((freqs_ndc - spectral_centroid) ** 2) * weights)))
spectral_rolloff Spectral rolloff (85%) per-axis (×3) ———
- Units
- Hz
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
- Visualisation
- spectrum
v5/lib/features_v5.py:874–877 :: extract_features # Rolloff: frequency below which 85% of spectral energy resides
cumulative_energy = np.cumsum(power) / total_power
rolloff_idx = np.searchsorted(cumulative_energy, 0.85)
spectral_rolloff = float(freqs_ndc[min(rolloff_idx, len(freqs_ndc) - 1)])
spectral_flatness Spectral flatness (Wiener entropy) per-axis (×3) ———
- Units
- dimensionless
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:879–889 :: extract_features # Flatness: geometric mean / arithmetic mean of power spectrum
# High flatness → noise-like; low flatness → tonal/harmonic
log_power = np.log(power + 1e-20)
geo_mean = float(np.exp(np.mean(log_power)))
arith_mean = float(np.mean(power))
spectral_flatness = geo_mean / arith_mean if arith_mean > 0 else 0.0
else:
spectral_centroid = 0.0
spectral_bandwidth = 0.0
spectral_rolloff = 0.0
spectral_flatness = 0.0
spectral_entropy Spectral entropy per-axis (×3) ———
- Units
- nats
- Textbook
- Brandt 2011, §3 + §6 — Noise and Vibration Analysis — DSP fundamentals
v5/lib/features_v5.py:1054–1063 :: extract_features # Spectral entropy — disorder measure (0=pure tone, 1=white noise)
if total_power > 0:
p_norm = power / total_power
p_nz = p_norm[p_norm > 1e-20]
spectral_entropy = (
float(-np.sum(p_nz * np.log2(p_nz))) / max(np.log2(len(p_nz)), 1.0)
if len(p_nz) > 1 else 0.0
)
else:
spectral_entropy = 0.0
band_energy_low Band energy — low per-axis (×3) ———
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:891–902 :: extract_features # Band energy ratios (low / mid / high thirds of Nyquist range)
nyquist = fs / 2.0
band_edges = [0.0, nyquist / 3.0, 2.0 * nyquist / 3.0, nyquist]
band_energies = []
for i in range(3):
mask = (freqs >= band_edges[i]) & (freqs < band_edges[i + 1])
band_e = float(np.sum(magnitudes[mask] ** 2))
band_energies.append(band_e)
total_band = sum(band_energies) + 1e-20
band_energy_low = band_energies[0] / total_band
band_energy_mid = band_energies[1] / total_band
band_energy_high = band_energies[2] / total_band
band_energy_mid Band energy — mid per-axis (×3) ———
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:891–902 :: extract_features # Band energy ratios (low / mid / high thirds of Nyquist range)
nyquist = fs / 2.0
band_edges = [0.0, nyquist / 3.0, 2.0 * nyquist / 3.0, nyquist]
band_energies = []
for i in range(3):
mask = (freqs >= band_edges[i]) & (freqs < band_edges[i + 1])
band_e = float(np.sum(magnitudes[mask] ** 2))
band_energies.append(band_e)
total_band = sum(band_energies) + 1e-20
band_energy_low = band_energies[0] / total_band
band_energy_mid = band_energies[1] / total_band
band_energy_high = band_energies[2] / total_band
band_energy_high Band energy — high per-axis (×3) ———
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:891–902 :: extract_features # Band energy ratios (low / mid / high thirds of Nyquist range)
nyquist = fs / 2.0
band_edges = [0.0, nyquist / 3.0, 2.0 * nyquist / 3.0, nyquist]
band_energies = []
for i in range(3):
mask = (freqs >= band_edges[i]) & (freqs < band_edges[i + 1])
band_e = float(np.sum(magnitudes[mask] ** 2))
band_energies.append(band_e)
total_band = sum(band_energies) + 1e-20
band_energy_low = band_energies[0] / total_band
band_energy_mid = band_energies[1] / total_band
band_energy_high = band_energies[2] / total_band
band_energy_ultrasonic Band energy — ultrasonic per-axis (×3) ———
- Units
- dimensionless (0–1)
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1044–1052 :: extract_features # -----------------------------------------------------------------------
# ADDITIONAL SPECTRAL (2 features)
# -----------------------------------------------------------------------
# Band energy ultrasonic (>10 kHz) — SKF Stage 1 detection
if nyquist > 10000:
mask_ultra = freqs >= 10000
band_energy_ultrasonic = float(np.sum(magnitudes[mask_ultra] ** 2)) / (total_band + 1e-20)
else:
band_energy_ultrasonic = 0.0
ISO 10816 / 20816 broadband severity
4 feature definitionsThe four canonical industrial severity scalars. RMS velocity is the headline ISO 10816 zone metric; peak-to-peak displacement is the slow-speed equivalent used in API 670; RMS acceleration completes the integrate-once vs integrate-twice triple for the 10–1000 Hz band.
ISO 10816-3 / ISO 20816-1 Class IV (large rotating machinery on flexible foundation) zone thresholds: GOOD ≤ 2.3 mm/s, SATISFACTORY ≤ 4.5, UNSATISFACTORY ≤ 7.1, UNACCEPTABLE > 7.1.
rms_velocity_mm_s RMS velocity per-axis (×3) ———
- Units
- mm/s
- Textbook
- ISO 10816-1/3 — Mechanical vibration — broadband velocity severity zones
v5/lib/features_v5.py:717–745 :: compute_rms_velocitydef compute_rms_velocity(signal: np.ndarray, fs: int = CWRU_FS) -> float:
"""
Integrate acceleration signal to velocity and compute RMS in mm/s.
ISO 10816-1 specifies velocity RMS in the 10-1000 Hz band.
Integration via cumulative trapezoidal rule, followed by 10-1000 Hz
bandpass filter, then RMS computation.
Args:
signal: 1-D acceleration time series (assumed pre-calibrated in g or m/s²).
fs: Sampling frequency in Hz.
Returns:
RMS velocity in mm/s per ISO 10816-1 (10-1000 Hz band).
"""
dt = 1.0 / fs
velocity = cumulative_trapezoid(signal, dx=dt, initial=0.0)
# Remove linear drift from integration
velocity -= np.linspace(velocity[0], velocity[-1], len(velocity))
# Input is in g, integration gives g·s. Convert: g·s × 9.81 m/s²/g × 1000 mm/m
velocity_mm_s = velocity * 9.81 * 1000.0 # g·s → mm/s (ISO 10816)
# ISO 10816-1: bandpass 10-1000 Hz
nyquist = fs / 2.0
lo = max(10.0 / nyquist, 0.001)
hi = min(1000.0 / nyquist, 0.99)
if lo < hi:
b, a = butter(4, [lo, hi], btype="band")
velocity_mm_s = filtfilt(b, a, velocity_mm_s)
return float(np.sqrt(np.mean(velocity_mm_s ** 2)))
peak_velocity_mm_s Peak velocity per-axis (×3) ———
- Units
- mm/s
- Textbook
- ISO 10816-1/3 — Mechanical vibration — broadband velocity severity zones
v5/lib/features_v5.py:717–745 :: compute_rms_velocitydef compute_rms_velocity(signal: np.ndarray, fs: int = CWRU_FS) -> float:
"""
Integrate acceleration signal to velocity and compute RMS in mm/s.
ISO 10816-1 specifies velocity RMS in the 10-1000 Hz band.
Integration via cumulative trapezoidal rule, followed by 10-1000 Hz
bandpass filter, then RMS computation.
Args:
signal: 1-D acceleration time series (assumed pre-calibrated in g or m/s²).
fs: Sampling frequency in Hz.
Returns:
RMS velocity in mm/s per ISO 10816-1 (10-1000 Hz band).
"""
dt = 1.0 / fs
velocity = cumulative_trapezoid(signal, dx=dt, initial=0.0)
# Remove linear drift from integration
velocity -= np.linspace(velocity[0], velocity[-1], len(velocity))
# Input is in g, integration gives g·s. Convert: g·s × 9.81 m/s²/g × 1000 mm/m
velocity_mm_s = velocity * 9.81 * 1000.0 # g·s → mm/s (ISO 10816)
# ISO 10816-1: bandpass 10-1000 Hz
nyquist = fs / 2.0
lo = max(10.0 / nyquist, 0.001)
hi = min(1000.0 / nyquist, 0.99)
if lo < hi:
b, a = butter(4, [lo, hi], btype="band")
velocity_mm_s = filtfilt(b, a, velocity_mm_s)
return float(np.sqrt(np.mean(velocity_mm_s ** 2)))
rms_acceleration_g RMS acceleration (10–1000 Hz) per-axis (×3) ———
- Units
- g
- Textbook
- ISO 10816-1/3 — Mechanical vibration — broadband velocity severity zones
v5/lib/features_v5.py:1337–1451 :: extract_randall_featuresdef extract_randall_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, Any]:
"""
Extract advanced diagnostic features using Randall's techniques.
Complements extract_features() with:
- Cepstrum-based periodicity detection
- Spectral kurtosis band selection
- Optimal-band envelope analysis
"""
features: dict[str, Any] = {}
# --- Cepstrum analysis ---
quefrency, cepstrum = compute_cepstrum(signal, fs)
# Peak cepstrum value (excluding near-zero quefrency)
min_quef = 0.002 # 2ms minimum (500 Hz max)
max_quef = 0.1 # 100ms maximum (10 Hz min)
mask = (quefrency >= min_quef) & (quefrency <= max_quef)
if np.any(mask):
peak_idx = np.argmax(cepstrum[mask])
features["cepstrum_peak_quefrency"] = float(quefrency[mask][peak_idx])
features["cepstrum_peak_magnitude"] = float(cepstrum[mask][peak_idx])
features["cepstrum_peak_freq"] = (
1.0 / features["cepstrum_peak_quefrency"]
if features["cepstrum_peak_quefrency"] > 0
else 0.0
)
else:
features["cepstrum_peak_quefrency"] = 0.0
features["cepstrum_peak_magnitude"] = 0.0
features["cepstrum_peak_freq"] = 0.0
# Check if cepstrum peak matches any defect frequency.
# A bearing impact train has cepstrum peaks at the IMPACT PERIOD (1/BPFO)
# AND its integer multiples (2/BPFO, 3/BPFO, ...). Original implementation
# only matched the fundamental cep_freq vs defect freq within ±5%, which
# missed signals where the algorithm picked a higher cepstral harmonic.
# Fix: check if peak_quefrency × defect_freq is within ±10% of any integer
# K ∈ [1, 5]. K=1 reduces to the original test (slightly relaxed), K≥2
# captures higher-order cepstral harmonics. Mirrors firmware fix in
# kaltech_cepstrum_extended_f32.
# cepstrum_defect_match: categorical {none, bpfo, bpfi, bsf, ftf}.
# Pre-seed to "none" so the key always exists in the returned dict —
# downstream callers (services/features_api/main.py contract check,
# NPZ training pipeline) require stable schema regardless of whether
# rpm is provided. Wave-2 fix C-1.
features["cepstrum_defect_match"] = "none"
if rpm is not None and rpm > 0:
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
peak_q = features["cepstrum_peak_quefrency"]
if peak_q > 0:
best_err = 1e30
best_name = "none"
for name, freq in defect_freqs.items():
if freq <= 0:
continue
qf = peak_q * freq
K = int(round(qf))
if K < 1 or K > 5:
continue
err = abs(qf - K) / K
if err < 0.10 and err < best_err:
best_err = err
best_name = name
features["cepstrum_defect_match"] = best_name
# --- Spectral kurtosis ---
f_sk, sk = compute_spectral_kurtosis(signal, fs)
features["sk_max"] = float(np.max(sk[f_sk > 50])) if np.any(f_sk > 50) else 0.0
features["sk_mean"] = float(np.mean(sk[f_sk > 50])) if np.any(f_sk > 50) else 0.0
# --- Optimal demodulation band (kurtogram) ---
opt_band = find_optimal_demod_band(signal, fs)
features["optimal_band_center"] = opt_band["center_freq"]
features["optimal_band_bw"] = opt_band["bandwidth"]
# … 35 more lines truncated …
pp_displacement_um Peak-to-peak displacement per-axis (×3) ———
- Units
- µm
- Textbook
- ISO 10816-1/3 — Mechanical vibration — broadband velocity severity zones
v5/lib/features_v5.py:1337–1451 :: extract_randall_featuresdef extract_randall_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, Any]:
"""
Extract advanced diagnostic features using Randall's techniques.
Complements extract_features() with:
- Cepstrum-based periodicity detection
- Spectral kurtosis band selection
- Optimal-band envelope analysis
"""
features: dict[str, Any] = {}
# --- Cepstrum analysis ---
quefrency, cepstrum = compute_cepstrum(signal, fs)
# Peak cepstrum value (excluding near-zero quefrency)
min_quef = 0.002 # 2ms minimum (500 Hz max)
max_quef = 0.1 # 100ms maximum (10 Hz min)
mask = (quefrency >= min_quef) & (quefrency <= max_quef)
if np.any(mask):
peak_idx = np.argmax(cepstrum[mask])
features["cepstrum_peak_quefrency"] = float(quefrency[mask][peak_idx])
features["cepstrum_peak_magnitude"] = float(cepstrum[mask][peak_idx])
features["cepstrum_peak_freq"] = (
1.0 / features["cepstrum_peak_quefrency"]
if features["cepstrum_peak_quefrency"] > 0
else 0.0
)
else:
features["cepstrum_peak_quefrency"] = 0.0
features["cepstrum_peak_magnitude"] = 0.0
features["cepstrum_peak_freq"] = 0.0
# Check if cepstrum peak matches any defect frequency.
# A bearing impact train has cepstrum peaks at the IMPACT PERIOD (1/BPFO)
# AND its integer multiples (2/BPFO, 3/BPFO, ...). Original implementation
# only matched the fundamental cep_freq vs defect freq within ±5%, which
# missed signals where the algorithm picked a higher cepstral harmonic.
# Fix: check if peak_quefrency × defect_freq is within ±10% of any integer
# K ∈ [1, 5]. K=1 reduces to the original test (slightly relaxed), K≥2
# captures higher-order cepstral harmonics. Mirrors firmware fix in
# kaltech_cepstrum_extended_f32.
# cepstrum_defect_match: categorical {none, bpfo, bpfi, bsf, ftf}.
# Pre-seed to "none" so the key always exists in the returned dict —
# downstream callers (services/features_api/main.py contract check,
# NPZ training pipeline) require stable schema regardless of whether
# rpm is provided. Wave-2 fix C-1.
features["cepstrum_defect_match"] = "none"
if rpm is not None and rpm > 0:
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
peak_q = features["cepstrum_peak_quefrency"]
if peak_q > 0:
best_err = 1e30
best_name = "none"
for name, freq in defect_freqs.items():
if freq <= 0:
continue
qf = peak_q * freq
K = int(round(qf))
if K < 1 or K > 5:
continue
err = abs(qf - K) / K
if err < 0.10 and err < best_err:
best_err = err
best_name = name
features["cepstrum_defect_match"] = best_name
# --- Spectral kurtosis ---
f_sk, sk = compute_spectral_kurtosis(signal, fs)
features["sk_max"] = float(np.max(sk[f_sk > 50])) if np.any(f_sk > 50) else 0.0
features["sk_mean"] = float(np.mean(sk[f_sk > 50])) if np.any(f_sk > 50) else 0.0
# --- Optimal demodulation band (kurtogram) ---
opt_band = find_optimal_demod_band(signal, fs)
features["optimal_band_center"] = opt_band["center_freq"]
features["optimal_band_bw"] = opt_band["bandwidth"]
# … 35 more lines truncated …
FFT defect energies (1× / 2× / 3×)
12 feature definitionsDirect FFT energy at the four bearing defect frequencies and their 2nd and 3rd harmonics. Computed by summing |X[k]|² for bins within ±5 Hz of each target. The fundamentals are sometimes masked by shaft-rate spillover; the harmonics often persist and are the diagnostic signal of choice in noisy installations.
Randall §5.4 — bearing defect frequencies derived from geometry (pitch diameter, ball diameter, contact angle, number of rolling elements) per the Harris formulae.
energy_at_bpfo FFT energy at BPFO per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
energy_at_bpfi FFT energy at BPFI per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
energy_at_bsf FFT energy at BSF per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
energy_at_ftf FFT energy at FTF per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
energy_at_2x_bpfo FFT energy at 2× BPFO per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
energy_at_2x_bpfi FFT energy at 2× BPFI per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
energy_at_2x_bsf FFT energy at 2× BSF per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
energy_at_2x_ftf FFT energy at 2× FTF per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
energy_at_3x_bpfo FFT energy at 3× BPFO per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
energy_at_3x_bpfi FFT energy at 3× BPFI per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
energy_at_3x_bsf FFT energy at 3× BSF per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
energy_at_3x_ftf FFT energy at 3× FTF per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
Envelope-spectrum defect energies (1× / 2× / 3×)
12 feature definitionsEnvelope demodulation in the broadband bearing-resonance band (default 2–5 kHz at fs=25.6 kHz) followed by FFT, then energy summation around each defect frequency × harmonic combination. The textbook bearing-fault detector — Randall §5.5.
Randall 2011 §5.5 eq. 5.27: x → bandpass (Butterworth-4 SOS, filtfilt) → |Hilbert(·)| → DC-remove → |FFT|/N. Squared variant (Group 7/8) replaces |Hilbert(·)| with |Hilbert(·)|² per Fig 5.39.
envelope_energy_bpfo Envelope energy at BPFO per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
- Visualisation
- envelope
v5/lib/features_v5.py:336–382 :: envelope_spectrumdef envelope_spectrum(
signal: np.ndarray,
fs: int = CWRU_FS,
band_low: float = 2000.0,
band_high: float = 5000.0,
squared: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""
Hilbert-transform envelope analysis.
Steps:
1. Bandpass filter around housing resonance frequency.
2. Hilbert transform → analytic signal → take magnitude (envelope).
3. Optionally square the envelope (Randall §5.5, Fig 5.39).
4. Remove DC component.
5. FFT of envelope → reveals fault modulation frequencies.
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
band_low: Lower edge of bandpass filter (Hz).
band_high: Upper edge of bandpass filter (Hz).
squared: If True, FFT operates on envelope² instead of envelope.
Squared envelope prevents aliasing of the magnitude operation
(Randall p.201) and concentrates energy at modulation lines.
V5 uses squared=True for the new drs_sq_* features.
Returns:
(freqs, fft_env) — frequency axis and envelope spectrum magnitudes.
"""
nyq = fs / 2.0
b, a = butter(4, [band_low / nyq, band_high / nyq], btype="band")
filtered = filtfilt(b, a, signal)
analytic = hilbert(filtered)
envelope = np.abs(analytic)
if squared:
# Square then DC-remove → preserves Randall's recommended pipeline:
# |FFT(envelope² - mean(envelope²))| / N
envelope = envelope * envelope
envelope -= np.mean(envelope)
N = len(envelope)
freqs = np.fft.rfftfreq(N, d=1.0 / fs)
fft_env = np.abs(np.fft.rfft(envelope)) / N
return freqs, fft_env
envelope_energy_bpfi Envelope energy at BPFI per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
- Visualisation
- envelope
v5/lib/features_v5.py:336–382 :: envelope_spectrumdef envelope_spectrum(
signal: np.ndarray,
fs: int = CWRU_FS,
band_low: float = 2000.0,
band_high: float = 5000.0,
squared: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""
Hilbert-transform envelope analysis.
Steps:
1. Bandpass filter around housing resonance frequency.
2. Hilbert transform → analytic signal → take magnitude (envelope).
3. Optionally square the envelope (Randall §5.5, Fig 5.39).
4. Remove DC component.
5. FFT of envelope → reveals fault modulation frequencies.
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
band_low: Lower edge of bandpass filter (Hz).
band_high: Upper edge of bandpass filter (Hz).
squared: If True, FFT operates on envelope² instead of envelope.
Squared envelope prevents aliasing of the magnitude operation
(Randall p.201) and concentrates energy at modulation lines.
V5 uses squared=True for the new drs_sq_* features.
Returns:
(freqs, fft_env) — frequency axis and envelope spectrum magnitudes.
"""
nyq = fs / 2.0
b, a = butter(4, [band_low / nyq, band_high / nyq], btype="band")
filtered = filtfilt(b, a, signal)
analytic = hilbert(filtered)
envelope = np.abs(analytic)
if squared:
# Square then DC-remove → preserves Randall's recommended pipeline:
# |FFT(envelope² - mean(envelope²))| / N
envelope = envelope * envelope
envelope -= np.mean(envelope)
N = len(envelope)
freqs = np.fft.rfftfreq(N, d=1.0 / fs)
fft_env = np.abs(np.fft.rfft(envelope)) / N
return freqs, fft_env
envelope_energy_bsf Envelope energy at BSF per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
- Visualisation
- envelope
v5/lib/features_v5.py:336–382 :: envelope_spectrumdef envelope_spectrum(
signal: np.ndarray,
fs: int = CWRU_FS,
band_low: float = 2000.0,
band_high: float = 5000.0,
squared: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""
Hilbert-transform envelope analysis.
Steps:
1. Bandpass filter around housing resonance frequency.
2. Hilbert transform → analytic signal → take magnitude (envelope).
3. Optionally square the envelope (Randall §5.5, Fig 5.39).
4. Remove DC component.
5. FFT of envelope → reveals fault modulation frequencies.
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
band_low: Lower edge of bandpass filter (Hz).
band_high: Upper edge of bandpass filter (Hz).
squared: If True, FFT operates on envelope² instead of envelope.
Squared envelope prevents aliasing of the magnitude operation
(Randall p.201) and concentrates energy at modulation lines.
V5 uses squared=True for the new drs_sq_* features.
Returns:
(freqs, fft_env) — frequency axis and envelope spectrum magnitudes.
"""
nyq = fs / 2.0
b, a = butter(4, [band_low / nyq, band_high / nyq], btype="band")
filtered = filtfilt(b, a, signal)
analytic = hilbert(filtered)
envelope = np.abs(analytic)
if squared:
# Square then DC-remove → preserves Randall's recommended pipeline:
# |FFT(envelope² - mean(envelope²))| / N
envelope = envelope * envelope
envelope -= np.mean(envelope)
N = len(envelope)
freqs = np.fft.rfftfreq(N, d=1.0 / fs)
fft_env = np.abs(np.fft.rfft(envelope)) / N
return freqs, fft_env
envelope_energy_ftf Envelope energy at FTF per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
- Visualisation
- envelope
v5/lib/features_v5.py:336–382 :: envelope_spectrumdef envelope_spectrum(
signal: np.ndarray,
fs: int = CWRU_FS,
band_low: float = 2000.0,
band_high: float = 5000.0,
squared: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""
Hilbert-transform envelope analysis.
Steps:
1. Bandpass filter around housing resonance frequency.
2. Hilbert transform → analytic signal → take magnitude (envelope).
3. Optionally square the envelope (Randall §5.5, Fig 5.39).
4. Remove DC component.
5. FFT of envelope → reveals fault modulation frequencies.
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
band_low: Lower edge of bandpass filter (Hz).
band_high: Upper edge of bandpass filter (Hz).
squared: If True, FFT operates on envelope² instead of envelope.
Squared envelope prevents aliasing of the magnitude operation
(Randall p.201) and concentrates energy at modulation lines.
V5 uses squared=True for the new drs_sq_* features.
Returns:
(freqs, fft_env) — frequency axis and envelope spectrum magnitudes.
"""
nyq = fs / 2.0
b, a = butter(4, [band_low / nyq, band_high / nyq], btype="band")
filtered = filtfilt(b, a, signal)
analytic = hilbert(filtered)
envelope = np.abs(analytic)
if squared:
# Square then DC-remove → preserves Randall's recommended pipeline:
# |FFT(envelope² - mean(envelope²))| / N
envelope = envelope * envelope
envelope -= np.mean(envelope)
N = len(envelope)
freqs = np.fft.rfftfreq(N, d=1.0 / fs)
fft_env = np.abs(np.fft.rfft(envelope)) / N
return freqs, fft_env
envelope_energy_2x_bpfo Envelope energy at 2× BPFO per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
- Visualisation
- envelope
v5/lib/features_v5.py:336–382 :: envelope_spectrumdef envelope_spectrum(
signal: np.ndarray,
fs: int = CWRU_FS,
band_low: float = 2000.0,
band_high: float = 5000.0,
squared: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""
Hilbert-transform envelope analysis.
Steps:
1. Bandpass filter around housing resonance frequency.
2. Hilbert transform → analytic signal → take magnitude (envelope).
3. Optionally square the envelope (Randall §5.5, Fig 5.39).
4. Remove DC component.
5. FFT of envelope → reveals fault modulation frequencies.
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
band_low: Lower edge of bandpass filter (Hz).
band_high: Upper edge of bandpass filter (Hz).
squared: If True, FFT operates on envelope² instead of envelope.
Squared envelope prevents aliasing of the magnitude operation
(Randall p.201) and concentrates energy at modulation lines.
V5 uses squared=True for the new drs_sq_* features.
Returns:
(freqs, fft_env) — frequency axis and envelope spectrum magnitudes.
"""
nyq = fs / 2.0
b, a = butter(4, [band_low / nyq, band_high / nyq], btype="band")
filtered = filtfilt(b, a, signal)
analytic = hilbert(filtered)
envelope = np.abs(analytic)
if squared:
# Square then DC-remove → preserves Randall's recommended pipeline:
# |FFT(envelope² - mean(envelope²))| / N
envelope = envelope * envelope
envelope -= np.mean(envelope)
N = len(envelope)
freqs = np.fft.rfftfreq(N, d=1.0 / fs)
fft_env = np.abs(np.fft.rfft(envelope)) / N
return freqs, fft_env
envelope_energy_2x_bpfi Envelope energy at 2× BPFI per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
- Visualisation
- envelope
v5/lib/features_v5.py:336–382 :: envelope_spectrumdef envelope_spectrum(
signal: np.ndarray,
fs: int = CWRU_FS,
band_low: float = 2000.0,
band_high: float = 5000.0,
squared: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""
Hilbert-transform envelope analysis.
Steps:
1. Bandpass filter around housing resonance frequency.
2. Hilbert transform → analytic signal → take magnitude (envelope).
3. Optionally square the envelope (Randall §5.5, Fig 5.39).
4. Remove DC component.
5. FFT of envelope → reveals fault modulation frequencies.
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
band_low: Lower edge of bandpass filter (Hz).
band_high: Upper edge of bandpass filter (Hz).
squared: If True, FFT operates on envelope² instead of envelope.
Squared envelope prevents aliasing of the magnitude operation
(Randall p.201) and concentrates energy at modulation lines.
V5 uses squared=True for the new drs_sq_* features.
Returns:
(freqs, fft_env) — frequency axis and envelope spectrum magnitudes.
"""
nyq = fs / 2.0
b, a = butter(4, [band_low / nyq, band_high / nyq], btype="band")
filtered = filtfilt(b, a, signal)
analytic = hilbert(filtered)
envelope = np.abs(analytic)
if squared:
# Square then DC-remove → preserves Randall's recommended pipeline:
# |FFT(envelope² - mean(envelope²))| / N
envelope = envelope * envelope
envelope -= np.mean(envelope)
N = len(envelope)
freqs = np.fft.rfftfreq(N, d=1.0 / fs)
fft_env = np.abs(np.fft.rfft(envelope)) / N
return freqs, fft_env
envelope_energy_2x_bsf Envelope energy at 2× BSF per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
- Visualisation
- envelope
v5/lib/features_v5.py:336–382 :: envelope_spectrumdef envelope_spectrum(
signal: np.ndarray,
fs: int = CWRU_FS,
band_low: float = 2000.0,
band_high: float = 5000.0,
squared: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""
Hilbert-transform envelope analysis.
Steps:
1. Bandpass filter around housing resonance frequency.
2. Hilbert transform → analytic signal → take magnitude (envelope).
3. Optionally square the envelope (Randall §5.5, Fig 5.39).
4. Remove DC component.
5. FFT of envelope → reveals fault modulation frequencies.
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
band_low: Lower edge of bandpass filter (Hz).
band_high: Upper edge of bandpass filter (Hz).
squared: If True, FFT operates on envelope² instead of envelope.
Squared envelope prevents aliasing of the magnitude operation
(Randall p.201) and concentrates energy at modulation lines.
V5 uses squared=True for the new drs_sq_* features.
Returns:
(freqs, fft_env) — frequency axis and envelope spectrum magnitudes.
"""
nyq = fs / 2.0
b, a = butter(4, [band_low / nyq, band_high / nyq], btype="band")
filtered = filtfilt(b, a, signal)
analytic = hilbert(filtered)
envelope = np.abs(analytic)
if squared:
# Square then DC-remove → preserves Randall's recommended pipeline:
# |FFT(envelope² - mean(envelope²))| / N
envelope = envelope * envelope
envelope -= np.mean(envelope)
N = len(envelope)
freqs = np.fft.rfftfreq(N, d=1.0 / fs)
fft_env = np.abs(np.fft.rfft(envelope)) / N
return freqs, fft_env
envelope_energy_2x_ftf Envelope energy at 2× FTF per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
- Visualisation
- envelope
v5/lib/features_v5.py:336–382 :: envelope_spectrumdef envelope_spectrum(
signal: np.ndarray,
fs: int = CWRU_FS,
band_low: float = 2000.0,
band_high: float = 5000.0,
squared: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""
Hilbert-transform envelope analysis.
Steps:
1. Bandpass filter around housing resonance frequency.
2. Hilbert transform → analytic signal → take magnitude (envelope).
3. Optionally square the envelope (Randall §5.5, Fig 5.39).
4. Remove DC component.
5. FFT of envelope → reveals fault modulation frequencies.
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
band_low: Lower edge of bandpass filter (Hz).
band_high: Upper edge of bandpass filter (Hz).
squared: If True, FFT operates on envelope² instead of envelope.
Squared envelope prevents aliasing of the magnitude operation
(Randall p.201) and concentrates energy at modulation lines.
V5 uses squared=True for the new drs_sq_* features.
Returns:
(freqs, fft_env) — frequency axis and envelope spectrum magnitudes.
"""
nyq = fs / 2.0
b, a = butter(4, [band_low / nyq, band_high / nyq], btype="band")
filtered = filtfilt(b, a, signal)
analytic = hilbert(filtered)
envelope = np.abs(analytic)
if squared:
# Square then DC-remove → preserves Randall's recommended pipeline:
# |FFT(envelope² - mean(envelope²))| / N
envelope = envelope * envelope
envelope -= np.mean(envelope)
N = len(envelope)
freqs = np.fft.rfftfreq(N, d=1.0 / fs)
fft_env = np.abs(np.fft.rfft(envelope)) / N
return freqs, fft_env
envelope_energy_3x_bpfo Envelope energy at 3× BPFO per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
- Visualisation
- envelope
v5/lib/features_v5.py:336–382 :: envelope_spectrumdef envelope_spectrum(
signal: np.ndarray,
fs: int = CWRU_FS,
band_low: float = 2000.0,
band_high: float = 5000.0,
squared: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""
Hilbert-transform envelope analysis.
Steps:
1. Bandpass filter around housing resonance frequency.
2. Hilbert transform → analytic signal → take magnitude (envelope).
3. Optionally square the envelope (Randall §5.5, Fig 5.39).
4. Remove DC component.
5. FFT of envelope → reveals fault modulation frequencies.
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
band_low: Lower edge of bandpass filter (Hz).
band_high: Upper edge of bandpass filter (Hz).
squared: If True, FFT operates on envelope² instead of envelope.
Squared envelope prevents aliasing of the magnitude operation
(Randall p.201) and concentrates energy at modulation lines.
V5 uses squared=True for the new drs_sq_* features.
Returns:
(freqs, fft_env) — frequency axis and envelope spectrum magnitudes.
"""
nyq = fs / 2.0
b, a = butter(4, [band_low / nyq, band_high / nyq], btype="band")
filtered = filtfilt(b, a, signal)
analytic = hilbert(filtered)
envelope = np.abs(analytic)
if squared:
# Square then DC-remove → preserves Randall's recommended pipeline:
# |FFT(envelope² - mean(envelope²))| / N
envelope = envelope * envelope
envelope -= np.mean(envelope)
N = len(envelope)
freqs = np.fft.rfftfreq(N, d=1.0 / fs)
fft_env = np.abs(np.fft.rfft(envelope)) / N
return freqs, fft_env
envelope_energy_3x_bpfi Envelope energy at 3× BPFI per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
- Visualisation
- envelope
v5/lib/features_v5.py:336–382 :: envelope_spectrumdef envelope_spectrum(
signal: np.ndarray,
fs: int = CWRU_FS,
band_low: float = 2000.0,
band_high: float = 5000.0,
squared: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""
Hilbert-transform envelope analysis.
Steps:
1. Bandpass filter around housing resonance frequency.
2. Hilbert transform → analytic signal → take magnitude (envelope).
3. Optionally square the envelope (Randall §5.5, Fig 5.39).
4. Remove DC component.
5. FFT of envelope → reveals fault modulation frequencies.
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
band_low: Lower edge of bandpass filter (Hz).
band_high: Upper edge of bandpass filter (Hz).
squared: If True, FFT operates on envelope² instead of envelope.
Squared envelope prevents aliasing of the magnitude operation
(Randall p.201) and concentrates energy at modulation lines.
V5 uses squared=True for the new drs_sq_* features.
Returns:
(freqs, fft_env) — frequency axis and envelope spectrum magnitudes.
"""
nyq = fs / 2.0
b, a = butter(4, [band_low / nyq, band_high / nyq], btype="band")
filtered = filtfilt(b, a, signal)
analytic = hilbert(filtered)
envelope = np.abs(analytic)
if squared:
# Square then DC-remove → preserves Randall's recommended pipeline:
# |FFT(envelope² - mean(envelope²))| / N
envelope = envelope * envelope
envelope -= np.mean(envelope)
N = len(envelope)
freqs = np.fft.rfftfreq(N, d=1.0 / fs)
fft_env = np.abs(np.fft.rfft(envelope)) / N
return freqs, fft_env
envelope_energy_3x_bsf Envelope energy at 3× BSF per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
- Visualisation
- envelope
v5/lib/features_v5.py:336–382 :: envelope_spectrumdef envelope_spectrum(
signal: np.ndarray,
fs: int = CWRU_FS,
band_low: float = 2000.0,
band_high: float = 5000.0,
squared: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""
Hilbert-transform envelope analysis.
Steps:
1. Bandpass filter around housing resonance frequency.
2. Hilbert transform → analytic signal → take magnitude (envelope).
3. Optionally square the envelope (Randall §5.5, Fig 5.39).
4. Remove DC component.
5. FFT of envelope → reveals fault modulation frequencies.
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
band_low: Lower edge of bandpass filter (Hz).
band_high: Upper edge of bandpass filter (Hz).
squared: If True, FFT operates on envelope² instead of envelope.
Squared envelope prevents aliasing of the magnitude operation
(Randall p.201) and concentrates energy at modulation lines.
V5 uses squared=True for the new drs_sq_* features.
Returns:
(freqs, fft_env) — frequency axis and envelope spectrum magnitudes.
"""
nyq = fs / 2.0
b, a = butter(4, [band_low / nyq, band_high / nyq], btype="band")
filtered = filtfilt(b, a, signal)
analytic = hilbert(filtered)
envelope = np.abs(analytic)
if squared:
# Square then DC-remove → preserves Randall's recommended pipeline:
# |FFT(envelope² - mean(envelope²))| / N
envelope = envelope * envelope
envelope -= np.mean(envelope)
N = len(envelope)
freqs = np.fft.rfftfreq(N, d=1.0 / fs)
fft_env = np.abs(np.fft.rfft(envelope)) / N
return freqs, fft_env
envelope_energy_3x_ftf Envelope energy at 3× FTF per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
- Visualisation
- envelope
v5/lib/features_v5.py:336–382 :: envelope_spectrumdef envelope_spectrum(
signal: np.ndarray,
fs: int = CWRU_FS,
band_low: float = 2000.0,
band_high: float = 5000.0,
squared: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""
Hilbert-transform envelope analysis.
Steps:
1. Bandpass filter around housing resonance frequency.
2. Hilbert transform → analytic signal → take magnitude (envelope).
3. Optionally square the envelope (Randall §5.5, Fig 5.39).
4. Remove DC component.
5. FFT of envelope → reveals fault modulation frequencies.
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
band_low: Lower edge of bandpass filter (Hz).
band_high: Upper edge of bandpass filter (Hz).
squared: If True, FFT operates on envelope² instead of envelope.
Squared envelope prevents aliasing of the magnitude operation
(Randall p.201) and concentrates energy at modulation lines.
V5 uses squared=True for the new drs_sq_* features.
Returns:
(freqs, fft_env) — frequency axis and envelope spectrum magnitudes.
"""
nyq = fs / 2.0
b, a = butter(4, [band_low / nyq, band_high / nyq], btype="band")
filtered = filtfilt(b, a, signal)
analytic = hilbert(filtered)
envelope = np.abs(analytic)
if squared:
# Square then DC-remove → preserves Randall's recommended pipeline:
# |FFT(envelope² - mean(envelope²))| / N
envelope = envelope * envelope
envelope -= np.mean(envelope)
N = len(envelope)
freqs = np.fft.rfftfreq(N, d=1.0 / fs)
fft_env = np.abs(np.fft.rfft(envelope)) / N
return freqs, fft_env
Optimal-band envelope defect energies (1× / 2× / 3×)
12 feature definitionsKurtogram-selected demodulation band replaces the fixed broadband band. The optimal band is the (centre frequency, bandwidth) pair with maximum spectral kurtosis across a 4-level scan (window sizes 64/128/256/512). Catches bearing damage when the resonance has shifted or when the broadband band is contaminated by structural modes.
Antoni 2007, Fast Kurtogram (MSSP 21(1)). Filter bank catalogue in firmware/common/dsp/kaltech_filter_bank.h pre-computes SOS coefficients for each (fs, band) pair to keep MCU work bounded.
optimal_env_bpfo Optimal-band envelope energy at BPFO per-axis (×3) ———
- Units
- g²
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
- Visualisation
- kurtogram
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
optimal_env_bpfi Optimal-band envelope energy at BPFI per-axis (×3) ———
- Units
- g²
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
- Visualisation
- kurtogram
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
optimal_env_bsf Optimal-band envelope energy at BSF per-axis (×3) ———
- Units
- g²
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
- Visualisation
- kurtogram
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
optimal_env_ftf Optimal-band envelope energy at FTF per-axis (×3) ———
- Units
- g²
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
- Visualisation
- kurtogram
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
optimal_env_2x_bpfo Optimal-band envelope energy at 2× BPFO per-axis (×3) ———
- Units
- g²
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
- Visualisation
- kurtogram
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
optimal_env_2x_bpfi Optimal-band envelope energy at 2× BPFI per-axis (×3) ———
- Units
- g²
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
- Visualisation
- kurtogram
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
optimal_env_2x_bsf Optimal-band envelope energy at 2× BSF per-axis (×3) ———
- Units
- g²
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
- Visualisation
- kurtogram
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
optimal_env_2x_ftf Optimal-band envelope energy at 2× FTF per-axis (×3) ———
- Units
- g²
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
- Visualisation
- kurtogram
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
optimal_env_3x_bpfo Optimal-band envelope energy at 3× BPFO per-axis (×3) ———
- Units
- g²
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
- Visualisation
- kurtogram
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
optimal_env_3x_bpfi Optimal-band envelope energy at 3× BPFI per-axis (×3) ———
- Units
- g²
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
- Visualisation
- kurtogram
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
optimal_env_3x_bsf Optimal-band envelope energy at 3× BSF per-axis (×3) ———
- Units
- g²
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
- Visualisation
- kurtogram
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
optimal_env_3x_ftf Optimal-band envelope energy at 3× FTF per-axis (×3) ———
- Units
- g²
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
- Visualisation
- kurtogram
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
DRS + squared envelope, broadband (V5-new)
12 feature definitionsTwelve V5-new features: DRS residual → broadband bandpass → squared Hilbert envelope → FFT energy at {1×, 2×, 3×} × {BPFO, BPFI, BSF, FTF}. The patent's bearing-fault diagnostic of choice when the rig has any gear-mesh or shaft-harmonic structure to strip — that is, every real industrial machine.
Sawalhi & Randall 2011 (MSSP 25) for DRS; Randall §5.5 Fig 5.39 for squared envelope. Combined transform was previously available in research code (PyBearingFault, Endaq) but not as on-chip features.
drs_sq_env_bpfo DRS squared-envelope energy at BPFO V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_env_bpfi DRS squared-envelope energy at BPFI V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_env_bsf DRS squared-envelope energy at BSF V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_env_ftf DRS squared-envelope energy at FTF V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_env_2x_bpfo DRS squared-envelope energy at 2× BPFO V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_env_2x_bpfi DRS squared-envelope energy at 2× BPFI V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_env_2x_bsf DRS squared-envelope energy at 2× BSF V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_env_2x_ftf DRS squared-envelope energy at 2× FTF V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_env_3x_bpfo DRS squared-envelope energy at 3× BPFO V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_env_3x_bpfi DRS squared-envelope energy at 3× BPFI V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_env_3x_bsf DRS squared-envelope energy at 3× BSF V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_env_3x_ftf DRS squared-envelope energy at 3× FTF V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
DRS + squared envelope, kurtogram band (V5-new)
12 feature definitionsTwelve V5-new features mirroring Group 7 but with the kurtogram selecting the demodulation band. Best-effort early-warning when the bearing's housing resonance is unknown or drifts.
Same as Group 7, with band selection from Antoni 2007 Fast Kurtogram.
drs_sq_optimal_env_bpfo DRS squared-envelope (kurtogram band) at BPFO V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_optimal_env_bpfi DRS squared-envelope (kurtogram band) at BPFI V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_optimal_env_bsf DRS squared-envelope (kurtogram band) at BSF V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_optimal_env_ftf DRS squared-envelope (kurtogram band) at FTF V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_optimal_env_2x_bpfo DRS squared-envelope (kurtogram band) at 2× BPFO V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_optimal_env_2x_bpfi DRS squared-envelope (kurtogram band) at 2× BPFI V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_optimal_env_2x_bsf DRS squared-envelope (kurtogram band) at 2× BSF V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_optimal_env_2x_ftf DRS squared-envelope (kurtogram band) at 2× FTF V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_optimal_env_3x_bpfo DRS squared-envelope (kurtogram band) at 3× BPFO V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_optimal_env_3x_bpfi DRS squared-envelope (kurtogram band) at 3× BPFI V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_optimal_env_3x_bsf DRS squared-envelope (kurtogram band) at 3× BSF V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
drs_sq_optimal_env_3x_ftf DRS squared-envelope (kurtogram band) at 3× FTF V5-newper-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
- Visualisation
- drs
v5/lib/features_v5.py:390–473 :: drs_squared_envelope_featuresdef drs_squared_envelope_features(
signal: np.ndarray,
fs: int,
bpfo: float,
bpfi: float,
bsf: float,
ftf: float,
kurt_center_hz: float,
kurt_bw_hz: float,
) -> dict[str, float]:
"""V5 helper: DRS residual → squared envelope → energies at 1x/2x/3x of all
bearing defect frequencies, in BOTH broadband and kurtogram-selected bands.
Returns 24 keys:
drs_sq_env_{bpfo,bpfi,bsf,ftf} (broadband, 1x)
drs_sq_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (broadband, 2x/3x harmonics)
drs_sq_optimal_env_{bpfo,bpfi,bsf,ftf} (kurtogram band, 1x)
drs_sq_optimal_env_{2x,3x}_{bpfo,bpfi,bsf,ftf} (kurtogram band, 2x/3x)
Combined V5 transformation: signal → DRS-residual → bandpass → hilbert →
envelope² → rfft → energy_near(defect_freq * k) for k ∈ {1, 2, 3}.
Randall §3.6.6 (DRS) + §5.5 (squared envelope, Fig 5.39): the squared
envelope is the canonical bearing diagnostic; DRS strips deterministic
rotation-locked content (gears, shaft harmonics, electrical line) so
impulsive bearing-defect energy stands out cleanly.
"""
names = ("bpfo", "bpfi", "bsf", "ftf")
freqs_in = {"bpfo": bpfo, "bpfi": bpfi, "bsf": bsf, "ftf": ftf}
out: dict[str, float] = {}
for name in names:
out[f"drs_sq_env_{name}"] = 0.0
out[f"drs_sq_env_2x_{name}"] = 0.0
out[f"drs_sq_env_3x_{name}"] = 0.0
out[f"drs_sq_optimal_env_{name}"] = 0.0
out[f"drs_sq_optimal_env_2x_{name}"] = 0.0
out[f"drs_sq_optimal_env_3x_{name}"] = 0.0
if len(signal) < 32:
return out
drs_resid = drs(signal)
nyq = fs * 0.5
# Broadband squared envelope
e_freqs, e_mags = envelope_spectrum(drs_resid, fs=fs, squared=True)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k_idx, k in enumerate((1.0, 2.0, 3.0)):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 5.0 if k == 1.0 else 3.0 # tighter bandwidth at harmonics
key = f"drs_sq_env_{name}" if k == 1.0 else f"drs_sq_env_{int(k)}x_{name}"
out[key] = _energy_near(e_freqs, e_mags, f_at_k, bandwidth_hz=bw)
# Kurtogram-band squared envelope (only if a valid kurtogram band exists)
if kurt_bw_hz >= 50.0:
lo = max(50.0, kurt_center_hz - kurt_bw_hz * 0.5)
hi = min(nyq - 50.0, kurt_center_hz + kurt_bw_hz * 0.5)
if hi > lo + 50.0:
o_freqs, o_mags = envelope_spectrum(
drs_resid, fs=fs, band_low=lo, band_high=hi, squared=True
)
for name in names:
f = freqs_in[name]
if f <= 0:
continue
for k in (1.0, 2.0, 3.0):
f_at_k = k * f
if f_at_k >= nyq:
continue
bw = 3.0
key = (
f"drs_sq_optimal_env_{name}"
if k == 1.0
else f"drs_sq_optimal_env_{int(k)}x_{name}"
# … 4 more lines truncated …
Shaft harmonics
3 feature definitionsEnergy at 1×, 2×, and 3× the shaft rotation rate. 1× tracks imbalance; 2× tracks misalignment; 3× tracks shaft looseness and severe misalignment.
Brandt §6.4 — these are the three canonical imbalance/misalignment indicators in rotating-machinery vibration.
harmonic_1x 1× shaft harmonic per-axis (×3) ———
- Units
- g²
- Textbook
- Brandt 2011, §6.4 — Harmonic energy at integer multiples of shaft rotation rate
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
harmonic_2x 2× shaft harmonic per-axis (×3) ———
- Units
- g²
- Textbook
- Brandt 2011, §6.4 — Harmonic energy at integer multiples of shaft rotation rate
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
harmonic_3x 3× shaft harmonic per-axis (×3) ———
- Units
- g²
- Textbook
- Brandt 2011, §6.4 — Harmonic energy at integer multiples of shaft rotation rate
- Visualisation
- spectrum
v5/lib/features_v5.py:752–760 :: _energy_neardef _energy_near(
freqs: np.ndarray,
magnitudes: np.ndarray,
target_hz: float,
bandwidth_hz: float = 5.0,
) -> float:
"""Return total spectral energy within ±bandwidth_hz of target_hz."""
mask = np.abs(freqs - target_hz) <= bandwidth_hz
return float(np.sum(magnitudes[mask] ** 2))
Cepstrum analysis
4 feature definitionsThe real cepstrum reveals periodicity in the *log* spectrum — which manifests as harmonic series of any fundamental frequency, i.e. bearing-defect modulation trains. Four features per axis: peak quefrency, peak magnitude, derived frequency, and a categorical defect-match indicator.
Randall §5.7 + §3.7. Cepstrum = IFFT(log(|FFT(x)|²)). Peaks at 1/f_defect indicate periodic impacts.
cepstrum_peak_quefrency Cepstrum peak quefrency per-axis (×3) ———
- Units
- s (seconds)
- Textbook
- Randall 2011, §3.7 + §5.7, p. 95–101 / 230–238 — Cepstrum — periodicity in the log spectrum
v5/lib/features_v5.py:525–551 :: compute_cepstrumdef compute_cepstrum(
signal: np.ndarray,
fs: int = CWRU_FS,
) -> tuple[np.ndarray, np.ndarray]:
"""
Compute the real cepstrum of a signal (Randall Ch. 8).
The cepstrum = IFFT(log(|FFT(x)|)) reveals periodicity in the spectrum.
Peaks in the cepstrum (called "rahmonics") indicate:
- Bearing defects: peak at 1/defect_freq (e.g., 1/BPFO)
- Gear mesh: peak at 1/mesh_freq
- Echo/reflection: peak at delay time
Returns:
(quefrency, cepstrum) — quefrency axis in seconds, cepstrum magnitudes
"""
N = len(signal)
windowed = signal * np.hanning(N)
fft_mag = np.abs(np.fft.rfft(windowed))
fft_mag = np.maximum(fft_mag, 1e-20) # avoid log(0)
log_spectrum = np.log(fft_mag)
cepstrum = np.fft.irfft(log_spectrum)
quefrency = np.arange(len(cepstrum)) / fs
return quefrency, np.abs(cepstrum)
cepstrum_peak_magnitude Cepstrum peak magnitude per-axis (×3) ———
- Units
- dimensionless
- Textbook
- Randall 2011, §3.7 + §5.7, p. 95–101 / 230–238 — Cepstrum — periodicity in the log spectrum
v5/lib/features_v5.py:525–551 :: compute_cepstrumdef compute_cepstrum(
signal: np.ndarray,
fs: int = CWRU_FS,
) -> tuple[np.ndarray, np.ndarray]:
"""
Compute the real cepstrum of a signal (Randall Ch. 8).
The cepstrum = IFFT(log(|FFT(x)|)) reveals periodicity in the spectrum.
Peaks in the cepstrum (called "rahmonics") indicate:
- Bearing defects: peak at 1/defect_freq (e.g., 1/BPFO)
- Gear mesh: peak at 1/mesh_freq
- Echo/reflection: peak at delay time
Returns:
(quefrency, cepstrum) — quefrency axis in seconds, cepstrum magnitudes
"""
N = len(signal)
windowed = signal * np.hanning(N)
fft_mag = np.abs(np.fft.rfft(windowed))
fft_mag = np.maximum(fft_mag, 1e-20) # avoid log(0)
log_spectrum = np.log(fft_mag)
cepstrum = np.fft.irfft(log_spectrum)
quefrency = np.arange(len(cepstrum)) / fs
return quefrency, np.abs(cepstrum)
cepstrum_peak_freq Cepstrum-derived frequency per-axis (×3) ———
- Units
- Hz
- Textbook
- Randall 2011, §3.7 + §5.7, p. 95–101 / 230–238 — Cepstrum — periodicity in the log spectrum
v5/lib/features_v5.py:525–551 :: compute_cepstrumdef compute_cepstrum(
signal: np.ndarray,
fs: int = CWRU_FS,
) -> tuple[np.ndarray, np.ndarray]:
"""
Compute the real cepstrum of a signal (Randall Ch. 8).
The cepstrum = IFFT(log(|FFT(x)|)) reveals periodicity in the spectrum.
Peaks in the cepstrum (called "rahmonics") indicate:
- Bearing defects: peak at 1/defect_freq (e.g., 1/BPFO)
- Gear mesh: peak at 1/mesh_freq
- Echo/reflection: peak at delay time
Returns:
(quefrency, cepstrum) — quefrency axis in seconds, cepstrum magnitudes
"""
N = len(signal)
windowed = signal * np.hanning(N)
fft_mag = np.abs(np.fft.rfft(windowed))
fft_mag = np.maximum(fft_mag, 1e-20) # avoid log(0)
log_spectrum = np.log(fft_mag)
cepstrum = np.fft.irfft(log_spectrum)
quefrency = np.arange(len(cepstrum)) / fs
return quefrency, np.abs(cepstrum)
cepstrum_defect_match Cepstrum defect match (categorical) per-axis (×3) ———
- Units
- categorical {0, 1, 2, 3, 4}
- Textbook
- Randall 2011, §3.7 + §5.7, p. 95–101 / 230–238 — Cepstrum — periodicity in the log spectrum
v5/lib/features_v5.py:525–551 :: compute_cepstrumdef compute_cepstrum(
signal: np.ndarray,
fs: int = CWRU_FS,
) -> tuple[np.ndarray, np.ndarray]:
"""
Compute the real cepstrum of a signal (Randall Ch. 8).
The cepstrum = IFFT(log(|FFT(x)|)) reveals periodicity in the spectrum.
Peaks in the cepstrum (called "rahmonics") indicate:
- Bearing defects: peak at 1/defect_freq (e.g., 1/BPFO)
- Gear mesh: peak at 1/mesh_freq
- Echo/reflection: peak at delay time
Returns:
(quefrency, cepstrum) — quefrency axis in seconds, cepstrum magnitudes
"""
N = len(signal)
windowed = signal * np.hanning(N)
fft_mag = np.abs(np.fft.rfft(windowed))
fft_mag = np.maximum(fft_mag, 1e-20) # avoid log(0)
log_spectrum = np.log(fft_mag)
cepstrum = np.fft.irfft(log_spectrum)
quefrency = np.arange(len(cepstrum)) / fs
return quefrency, np.abs(cepstrum)
Spectral kurtosis + Kurtogram
5 feature definitionsAntoni's spectral kurtosis at a single STFT window (default 256, overlap 75%) and the full 4-level Fast Kurtogram. Outputs the (centre, bandwidth) pair with maximum SK, which both Group 6 and Group 8 use as the demodulation band.
Antoni & Randall 2006 (MSSP 20), Antoni 2007 (MSSP 21). Mirrors scipy.signal.stft with boundary='zeros', padded=True, window='hann' periodic.
sk_max Max spectral kurtosis per-axis (×3) ———
- Units
- dimensionless
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
v5/lib/features_v5.py:554–596 :: compute_spectral_kurtosisdef compute_spectral_kurtosis(
signal: np.ndarray,
fs: int = CWRU_FS,
window_size: int = 256,
overlap: int = 192,
) -> tuple[np.ndarray, np.ndarray]:
"""
Compute Spectral Kurtosis via STFT (Randall Ch. 9, Antoni 2006).
SK(f) = <|X(f)|^4> / <|X(f)|^2>^2 - 2
High SK at a frequency → impulsive/transient content (bearing impacts).
Low SK → stationary content (shaft rotation, noise).
Use SK to identify the optimal frequency band for envelope demodulation.
Convention / baseline note
--------------------------
Returns Antoni (2006) MSSP 20(2):282-307 eq. 6 baseline:
SK = E[|X|^4] / E[|X|^2]^2 - 2.
The -2 term is the complex-circular-Gaussian baseline. We compute SK
on magnitude spectra (np.abs(rfft) upstream — power = |X|^2 of those
magnitudes), so a real-Gaussian signal yields SK_baseline ≈ -1 rather
than 0. This is a deliberate convention choice: we use SK for
ORDERING bands (kurtogram band search) where the monotonic behaviour
is unchanged. Absolute SK values reported as features should be
interpreted relative to this baseline, not as Antoni's normalised SK.
Returns:
(frequencies, sk_values) — frequency axis and spectral kurtosis per bin
"""
f, _t, Zxx = stft(signal, fs=fs, nperseg=window_size, noverlap=overlap)
power = np.abs(Zxx) ** 2
# SK = E[|X|^4] / E[|X|^2]^2 - 2
mean_p2 = np.mean(power ** 2, axis=1)
mean_p = np.mean(power, axis=1)
sk = np.zeros_like(f)
nonzero = mean_p > 1e-20
sk[nonzero] = mean_p2[nonzero] / (mean_p[nonzero] ** 2) - 2.0
return f, sk
sk_mean Mean spectral kurtosis per-axis (×3) ———
- Units
- dimensionless
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
v5/lib/features_v5.py:554–596 :: compute_spectral_kurtosisdef compute_spectral_kurtosis(
signal: np.ndarray,
fs: int = CWRU_FS,
window_size: int = 256,
overlap: int = 192,
) -> tuple[np.ndarray, np.ndarray]:
"""
Compute Spectral Kurtosis via STFT (Randall Ch. 9, Antoni 2006).
SK(f) = <|X(f)|^4> / <|X(f)|^2>^2 - 2
High SK at a frequency → impulsive/transient content (bearing impacts).
Low SK → stationary content (shaft rotation, noise).
Use SK to identify the optimal frequency band for envelope demodulation.
Convention / baseline note
--------------------------
Returns Antoni (2006) MSSP 20(2):282-307 eq. 6 baseline:
SK = E[|X|^4] / E[|X|^2]^2 - 2.
The -2 term is the complex-circular-Gaussian baseline. We compute SK
on magnitude spectra (np.abs(rfft) upstream — power = |X|^2 of those
magnitudes), so a real-Gaussian signal yields SK_baseline ≈ -1 rather
than 0. This is a deliberate convention choice: we use SK for
ORDERING bands (kurtogram band search) where the monotonic behaviour
is unchanged. Absolute SK values reported as features should be
interpreted relative to this baseline, not as Antoni's normalised SK.
Returns:
(frequencies, sk_values) — frequency axis and spectral kurtosis per bin
"""
f, _t, Zxx = stft(signal, fs=fs, nperseg=window_size, noverlap=overlap)
power = np.abs(Zxx) ** 2
# SK = E[|X|^4] / E[|X|^2]^2 - 2
mean_p2 = np.mean(power ** 2, axis=1)
mean_p = np.mean(power, axis=1)
sk = np.zeros_like(f)
nonzero = mean_p > 1e-20
sk[nonzero] = mean_p2[nonzero] / (mean_p[nonzero] ** 2) - 2.0
return f, sk
optimal_band_center Optimal band — centre frequency per-axis (×3) ———
- Units
- Hz
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
- Visualisation
- kurtogram
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
optimal_band_bw Optimal band — bandwidth per-axis (×3) ———
- Units
- Hz
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
optimal_band_sk Optimal band — SK per-axis (×3) ———
- Units
- dimensionless
- Textbook
- Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
v5/lib/features_v5.py:660–673 :: find_optimal_demod_banddef find_optimal_demod_band(
signal: np.ndarray,
fs: int = CWRU_FS,
n_levels: int = 3,
) -> dict[str, float]:
"""DEPRECATED alias for `stft_sk_band_search`.
Kept for backward compatibility with existing callers (extract_features
integration, parity tests, dashboard). The original name overstated
the algorithm as full kurtogram; in fact we use a level-only STFT-SK
sweep, not Antoni's 1/3-binary-tree decomposition. New code should
call `stft_sk_band_search` directly. See its docstring for details.
"""
return stft_sk_band_search(signal, fs=fs, n_levels=n_levels)
Order spectrum analysis
23 feature definitionsTwenty-three features computed on the order spectrum (FFT magnitude rescaled by f_shaft so the abscissa becomes 'orders of shaft'). Includes shaft orders 1×–10×, defect orders at the BPFO/BPFI/BSF/FTF order-equivalents, sidebands around BPFO and BPFI, and scalar summaries (dominant order, sub-synchronous energy, 1×/2× ratio).
Randall §3.6.5. Order tracking removes RPM-drift artefacts; essential for variable-speed machines and tachless tracks (Group 13).
order_1x_energy Order spectrum energy — order 1 per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_2x_energy Order spectrum energy — order 2 per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_3x_energy Order spectrum energy — order 3 per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_4x_energy Order spectrum energy — order 4 per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_5x_energy Order spectrum energy — order 5 per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_6x_energy Order spectrum energy — order 6 per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_7x_energy Order spectrum energy — order 7 per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_8x_energy Order spectrum energy — order 8 per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_9x_energy Order spectrum energy — order 9 per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_10x_energy Order spectrum energy — order 10 per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_energy_bpfo_order Order energy at BPFO order per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_energy_bpfi_order Order energy at BPFI order per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_energy_bsf_order Order energy at BSF order per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_energy_ftf_order Order energy at FTF order per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_sideband_bpfo Sideband energy around BPFO per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_sideband_bpfi Sideband energy around BPFI per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
dominant_order Dominant order per-axis (×3) ———
- Units
- orders
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_1x_2x_ratio 1×/2× order ratio per-axis (×3) ———
- Units
- dimensionless
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
order_subsync_energy Sub-synchronous order energy per-axis (×3) ———
- Units
- g²
- Textbook
- Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
bpfo_order BPFO order multiplier per-axis (×3) ———
- Units
- orders
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
bpfi_order BPFI order multiplier per-axis (×3) ———
- Units
- orders
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
bsf_order BSF order multiplier per-axis (×3) ———
- Units
- orders
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
ftf_order FTF order multiplier per-axis (×3) ———
- Units
- orders
- Textbook
- Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
v5/lib/features_v5.py:1232–1334 :: extract_order_featuresdef extract_order_features(
signal: np.ndarray,
fs: int = CWRU_FS,
rpm: float = 1800.0,
bearing_type: str = "6205",
max_order: float = 20.0,
) -> dict[str, Any]:
"""
Extract order-based features for VFD-driven motor analysis.
Unlike Hz-based features, order features are RPM-invariant —
the same fault produces the same order signature regardless of VFD speed setpoint.
Features returned:
order_1x_energy .. order_10x_energy — energy at integer shaft orders
order_energy_bpfo_order, _bpfi_order, _bsf_order, _ftf_order — defect order energies
order_sideband_bpfo, order_sideband_bpfi — modulation sideband energies
dominant_order — highest-magnitude order (excluding DC)
order_1x_2x_ratio — imbalance indicator
order_subsync_energy — sub-synchronous energy (looseness indicator)
bpfo_order, bpfi_order, bsf_order, ftf_order — defect order values for reference
Args:
signal: 1-D acceleration time series.
fs: Sampling frequency in Hz.
rpm: Shaft speed in revolutions per minute.
bearing_type: Bearing identifier key in BEARING_DB (default "6205").
max_order: Maximum order to include in analysis (default 20.0).
Returns:
Dict with order-domain features complementing extract_features().
Empty dict if rpm is non-positive.
"""
shaft_freq = rpm / 60.0
if shaft_freq <= 0:
return {}
orders, magnitudes = compute_order_spectrum(signal, fs, rpm)
# Trim to max_order for focused analysis
mask = orders <= max_order
orders_trim = orders[mask]
mags_trim = magnitudes[mask]
if len(orders_trim) == 0:
return {}
# Energy at integer orders (1x through 10x)
order_energies: dict[str, Any] = {}
for n in range(1, 11):
bw = 0.15 # ±0.15 orders bandwidth
mask_n = np.abs(orders_trim - float(n)) <= bw
order_energies[f"order_{n}x_energy"] = float(np.sum(mags_trim[mask_n] ** 2))
# Bearing defect orders (defect_freq / shaft_freq)
defect_freqs = bearing_defect_freqs(rpm, bearing_type)
defect_orders = {
"bpfo_order": defect_freqs["bpfo"] / shaft_freq,
"bpfi_order": defect_freqs["bpfi"] / shaft_freq,
"bsf_order": defect_freqs["bsf"] / shaft_freq,
"ftf_order": defect_freqs["ftf"] / shaft_freq,
}
# Energy at defect orders (±0.15 order bandwidth)
bw = 0.15
for name, order_val in defect_orders.items():
feat_name = f"order_energy_{name}" # e.g. order_energy_bpfo_order
mask_d = np.abs(orders_trim - order_val) <= bw
order_energies[feat_name] = float(np.sum(mags_trim[mask_d] ** 2))
# Sideband detection: check for modulation sidebands around defect orders
# Sidebands at defect_order ± 1 indicate distributed faults
for defect_name in ["bpfo", "bpfi"]:
center_order = defect_orders[f"{defect_name}_order"]
lower_sb = center_order - 1.0
upper_sb = center_order + 1.0
mask_lower = np.abs(orders_trim - lower_sb) <= bw
mask_upper = np.abs(orders_trim - upper_sb) <= bw
sb_energy = float(np.sum(mags_trim[mask_lower] ** 2) + np.sum(mags_trim[mask_upper] ** 2))
# … 23 more lines truncated …
Tachless on-edge cyclic order tracking (V5-new, Patent Claims 18–19)
4 feature definitionsFour V5-new features per axis. Estimates the shaft rotation rate from the vibration signal itself — no tachometer pin required — via STFT + log-parabolic peak interpolation + continuity tracking. Outputs an estimate, its drift over the analysis window, a steady flag, and a confidence score that gates downstream interpretation.
Bonnardot et al. 2005 (MSSP 19) + Combet & Gelman 2007 (MSSP 21). KALTECH's contribution is the on-edge INT8-budget implementation + the σ_pp variability feature (Patent Claim 19) that converts raw drift into an ML-gating signal.
rpm_estimate_hz Tachless RPM estimate (Hz) V5-newper-axis (×3) ———
- Units
- Hz
- Textbook
- Bonnardot et al. 2005, MSSP 19(4), p. 766–785, eq. 12 — Tachless angular resampling from phase demodulation
- Visualisation
- cot
v5/lib/features_v5.py:476–522 :: tachless_rpm_featuresdef tachless_rpm_features(
signal: np.ndarray,
fs: int,
approximate_rpm: float,
) -> dict[str, float]:
"""V5 helper: tachless on-edge shaft rotation rate estimation (Claims 18-19).
Wraps v5.lib.tachless_cot.tachless_cot and returns 4 features per axis:
rpm_estimate_hz — tachless estimate of average shaft frequency (Hz)
rpm_drift_pct — sigma_pp peak-to-peak variability fraction (×100 for %)
rpm_is_steady — 1.0 if sigma_pp <= 5%, else 0.0
rpm_confidence — 0.0-1.0 quality score for the estimate
Semantic states (consumer reads `rpm_confidence` to distinguish):
- approximate_rpm <= 0 → all zeros (no RPM provided)
- tachless_cot raises → all zeros (computation failed)
- tachless_cot degenerate → omega_bar=0.0, confidence=0.0 (no shaft signal)
- tachless_cot valid → real values, confidence > 0.0
When training KTnet V5: treat `rpm_confidence < 0.05` as "rpm unreliable" —
the value of rpm_estimate_hz then becomes uninformative regardless of state.
"""
out = {
"rpm_estimate_hz": 0.0,
"rpm_drift_pct": 0.0,
"rpm_is_steady": 0.0,
"rpm_confidence": 0.0,
}
if approximate_rpm <= 0:
return out
try:
result = tachless_cot(signal, float(fs), approximate_rpm=float(approximate_rpm))
except ValueError as e:
# Log the failure so NPZ-build runs leave a trail. Returning zeros is
# appropriate (downstream reads rpm_confidence=0 → unreliable), but we
# want to know if a fixture problem is producing many failed windows.
logger.warning(
"tachless_cot raised ValueError on signal len=%d, fs=%s, "
"approximate_rpm=%s: %s — returning zero RPM features",
len(signal), fs, approximate_rpm, e,
)
return out
out["rpm_estimate_hz"] = float(result.omega_bar_hz)
out["rpm_drift_pct"] = float(result.sigma_pp * 100.0)
out["rpm_is_steady"] = 1.0 if result.is_steady else 0.0
out["rpm_confidence"] = float(result.confidence)
return out
rpm_drift_pct Tachless RPM drift V5-newper-axis (×3) ———
- Units
- percent
- Textbook
- Bonnardot et al. 2005, MSSP 19(4), p. 766–785, eq. 12 — Tachless angular resampling from phase demodulation
- Visualisation
- cot
v5/lib/features_v5.py:476–522 :: tachless_rpm_featuresdef tachless_rpm_features(
signal: np.ndarray,
fs: int,
approximate_rpm: float,
) -> dict[str, float]:
"""V5 helper: tachless on-edge shaft rotation rate estimation (Claims 18-19).
Wraps v5.lib.tachless_cot.tachless_cot and returns 4 features per axis:
rpm_estimate_hz — tachless estimate of average shaft frequency (Hz)
rpm_drift_pct — sigma_pp peak-to-peak variability fraction (×100 for %)
rpm_is_steady — 1.0 if sigma_pp <= 5%, else 0.0
rpm_confidence — 0.0-1.0 quality score for the estimate
Semantic states (consumer reads `rpm_confidence` to distinguish):
- approximate_rpm <= 0 → all zeros (no RPM provided)
- tachless_cot raises → all zeros (computation failed)
- tachless_cot degenerate → omega_bar=0.0, confidence=0.0 (no shaft signal)
- tachless_cot valid → real values, confidence > 0.0
When training KTnet V5: treat `rpm_confidence < 0.05` as "rpm unreliable" —
the value of rpm_estimate_hz then becomes uninformative regardless of state.
"""
out = {
"rpm_estimate_hz": 0.0,
"rpm_drift_pct": 0.0,
"rpm_is_steady": 0.0,
"rpm_confidence": 0.0,
}
if approximate_rpm <= 0:
return out
try:
result = tachless_cot(signal, float(fs), approximate_rpm=float(approximate_rpm))
except ValueError as e:
# Log the failure so NPZ-build runs leave a trail. Returning zeros is
# appropriate (downstream reads rpm_confidence=0 → unreliable), but we
# want to know if a fixture problem is producing many failed windows.
logger.warning(
"tachless_cot raised ValueError on signal len=%d, fs=%s, "
"approximate_rpm=%s: %s — returning zero RPM features",
len(signal), fs, approximate_rpm, e,
)
return out
out["rpm_estimate_hz"] = float(result.omega_bar_hz)
out["rpm_drift_pct"] = float(result.sigma_pp * 100.0)
out["rpm_is_steady"] = 1.0 if result.is_steady else 0.0
out["rpm_confidence"] = float(result.confidence)
return out
rpm_is_steady Tachless RPM steady flag V5-newper-axis (×3) ———
- Units
- boolean (0/1)
- Textbook
- Bonnardot et al. 2005, MSSP 19(4), p. 766–785, eq. 12 — Tachless angular resampling from phase demodulation
v5/lib/features_v5.py:476–522 :: tachless_rpm_featuresdef tachless_rpm_features(
signal: np.ndarray,
fs: int,
approximate_rpm: float,
) -> dict[str, float]:
"""V5 helper: tachless on-edge shaft rotation rate estimation (Claims 18-19).
Wraps v5.lib.tachless_cot.tachless_cot and returns 4 features per axis:
rpm_estimate_hz — tachless estimate of average shaft frequency (Hz)
rpm_drift_pct — sigma_pp peak-to-peak variability fraction (×100 for %)
rpm_is_steady — 1.0 if sigma_pp <= 5%, else 0.0
rpm_confidence — 0.0-1.0 quality score for the estimate
Semantic states (consumer reads `rpm_confidence` to distinguish):
- approximate_rpm <= 0 → all zeros (no RPM provided)
- tachless_cot raises → all zeros (computation failed)
- tachless_cot degenerate → omega_bar=0.0, confidence=0.0 (no shaft signal)
- tachless_cot valid → real values, confidence > 0.0
When training KTnet V5: treat `rpm_confidence < 0.05` as "rpm unreliable" —
the value of rpm_estimate_hz then becomes uninformative regardless of state.
"""
out = {
"rpm_estimate_hz": 0.0,
"rpm_drift_pct": 0.0,
"rpm_is_steady": 0.0,
"rpm_confidence": 0.0,
}
if approximate_rpm <= 0:
return out
try:
result = tachless_cot(signal, float(fs), approximate_rpm=float(approximate_rpm))
except ValueError as e:
# Log the failure so NPZ-build runs leave a trail. Returning zeros is
# appropriate (downstream reads rpm_confidence=0 → unreliable), but we
# want to know if a fixture problem is producing many failed windows.
logger.warning(
"tachless_cot raised ValueError on signal len=%d, fs=%s, "
"approximate_rpm=%s: %s — returning zero RPM features",
len(signal), fs, approximate_rpm, e,
)
return out
out["rpm_estimate_hz"] = float(result.omega_bar_hz)
out["rpm_drift_pct"] = float(result.sigma_pp * 100.0)
out["rpm_is_steady"] = 1.0 if result.is_steady else 0.0
out["rpm_confidence"] = float(result.confidence)
return out
rpm_confidence Tachless RPM confidence V5-newper-axis (×3) ———
- Units
- dimensionless (0–1)
- Textbook
- Bonnardot et al. 2005, MSSP 19(4), p. 766–785, eq. 12 — Tachless angular resampling from phase demodulation
v5/lib/features_v5.py:476–522 :: tachless_rpm_featuresdef tachless_rpm_features(
signal: np.ndarray,
fs: int,
approximate_rpm: float,
) -> dict[str, float]:
"""V5 helper: tachless on-edge shaft rotation rate estimation (Claims 18-19).
Wraps v5.lib.tachless_cot.tachless_cot and returns 4 features per axis:
rpm_estimate_hz — tachless estimate of average shaft frequency (Hz)
rpm_drift_pct — sigma_pp peak-to-peak variability fraction (×100 for %)
rpm_is_steady — 1.0 if sigma_pp <= 5%, else 0.0
rpm_confidence — 0.0-1.0 quality score for the estimate
Semantic states (consumer reads `rpm_confidence` to distinguish):
- approximate_rpm <= 0 → all zeros (no RPM provided)
- tachless_cot raises → all zeros (computation failed)
- tachless_cot degenerate → omega_bar=0.0, confidence=0.0 (no shaft signal)
- tachless_cot valid → real values, confidence > 0.0
When training KTnet V5: treat `rpm_confidence < 0.05` as "rpm unreliable" —
the value of rpm_estimate_hz then becomes uninformative regardless of state.
"""
out = {
"rpm_estimate_hz": 0.0,
"rpm_drift_pct": 0.0,
"rpm_is_steady": 0.0,
"rpm_confidence": 0.0,
}
if approximate_rpm <= 0:
return out
try:
result = tachless_cot(signal, float(fs), approximate_rpm=float(approximate_rpm))
except ValueError as e:
# Log the failure so NPZ-build runs leave a trail. Returning zeros is
# appropriate (downstream reads rpm_confidence=0 → unreliable), but we
# want to know if a fixture problem is producing many failed windows.
logger.warning(
"tachless_cot raised ValueError on signal len=%d, fs=%s, "
"approximate_rpm=%s: %s — returning zero RPM features",
len(signal), fs, approximate_rpm, e,
)
return out
out["rpm_estimate_hz"] = float(result.omega_bar_hz)
out["rpm_drift_pct"] = float(result.sigma_pp * 100.0)
out["rpm_is_steady"] = 1.0 if result.is_steady else 0.0
out["rpm_confidence"] = float(result.confidence)
return out
Cross-axis features
29 feature definitionsTwenty-nine features that require all three accelerometer axes (X = radial, Y = axial, Z = tangential). The first 12 are classical pairwise statistics (correlations, kurtosis ratios, RMS magnitude, energy anisotropy). The remaining 17 implement Patent Claims 4(b), 10, 15: coherence at shaft + defect frequencies for all three axis pairs, orbit ellipticity, and direction of maximum vibration.
Wowk 1991 (orbit analysis), Randall §3.8 (coherence). Coherence uses Welch's method (nperseg=256, hann, 50% overlap, detrend='constant') — matches scipy.signal.coherence with all defaults explicit per parity-porting.md.
cross_corr_xy Cross-correlation (X,Y) —
- Units
- dimensionless (−1..1)
- Textbook
- Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
cross_corr_xz Cross-correlation (X,Z) —
- Units
- dimensionless (−1..1)
- Textbook
- Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
cross_corr_yz Cross-correlation (Y,Z) —
- Units
- dimensionless (−1..1)
- Textbook
- Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
kurtosis_ratio_xy Kurtosis ratio (X/Y) —
- Units
- dimensionless
- Textbook
- Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
kurtosis_ratio_xz Kurtosis ratio (X/Z) —
- Units
- dimensionless
- Textbook
- Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
kurtosis_ratio_yz Kurtosis ratio (Y/Z) —
- Units
- dimensionless
- Textbook
- Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
rms_vector_magnitude RMS vector magnitude —
- Units
- g
- Textbook
- Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
rms_ratio_xy RMS ratio (X/Y) —
- Units
- dimensionless
- Textbook
- Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
rms_ratio_xz RMS ratio (X/Z) —
- Units
- dimensionless
- Textbook
- Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
crest_max_axis Max crest factor across axes —
- Units
- dimensionless
- Textbook
- Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
energy_anisotropy Energy anisotropy —
- Units
- dimensionless (0–1)
- Textbook
- Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_mean Mean coherence across all pairs —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_shaft_xy Coherence at SHAFT (XY) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_shaft_xz Coherence at SHAFT (XZ) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_shaft_yz Coherence at SHAFT (YZ) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_bpfo_xy Coherence at BPFO (XY) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_bpfo_xz Coherence at BPFO (XZ) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_bpfo_yz Coherence at BPFO (YZ) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_bpfi_xy Coherence at BPFI (XY) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_bpfi_xz Coherence at BPFI (XZ) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_bpfi_yz Coherence at BPFI (YZ) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_bsf_xy Coherence at BSF (XY) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_bsf_xz Coherence at BSF (XZ) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_bsf_yz Coherence at BSF (YZ) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_ftf_xy Coherence at FTF (XY) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_ftf_xz Coherence at FTF (XZ) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
coherence_at_ftf_yz Coherence at FTF (YZ) —
- Units
- dimensionless (0–1)
- Textbook
- Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
orbit_ellipticity Orbit ellipticity —
- Units
- dimensionless (0–1)
- Textbook
- Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
direction_of_max_vibration Direction of max vibration —
- Units
- radians (0..π/2 for non-negative RMS)
- Textbook
- Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1476–1663 :: compute_cross_axis_featuresdef compute_cross_axis_features(
seg_x: np.ndarray,
seg_y: np.ndarray,
seg_z: np.ndarray,
fs: int = CWRU_FS,
rpm: float | None = None,
bearing_type: str = "6205",
) -> dict[str, float]:
"""Compute 29 cross-axis features from triaxial vibration segments.
x = radial, y = axial, z = tangential.
For single-axis datasets, pass zeros for y and z — features default to 0.
Features:
Existing 12: correlations, kurtosis ratios, RMS magnitude/ratios,
crest max, energy anisotropy, broadband coherence mean.
New 17 (patent-required):
- Coherence at shaft frequency (3 axis pairs) — Claim 15
- Coherence at 4 defect frequencies × 3 pairs = 12 — Claim 15
- Orbit ellipticity (minor/major from 2 radial axes at 1x) — Claim 4(b), 15
- Direction of maximum vibration (angle in measurement plane) — Claim 4(b), 15
"""
from scipy.stats import kurtosis as sp_kurtosis
result = {k: 0.0 for k in CROSS_AXIS_KEYS}
has_y = np.any(seg_y != 0)
has_z = np.any(seg_z != 0)
if not has_y and not has_z:
return result
eps = 1e-12
# ── RMS per axis ──
rms_x = float(np.sqrt(np.mean(seg_x**2))) + eps
rms_y = float(np.sqrt(np.mean(seg_y**2))) + eps
rms_z = float(np.sqrt(np.mean(seg_z**2))) + eps
# ── Cross-correlations (Pearson) ──
def _corr(a: np.ndarray, b: np.ndarray) -> float:
if np.std(a) < 1e-10 or np.std(b) < 1e-10:
return 0.0
return float(np.corrcoef(a, b)[0, 1])
result["cross_corr_xy"] = _corr(seg_x, seg_y)
result["cross_corr_xz"] = _corr(seg_x, seg_z)
result["cross_corr_yz"] = _corr(seg_y, seg_z)
# ── Kurtosis ratios (Pearson convention, Gaussian=3.0 — matches extract_features) ──
kurt_x = float(sp_kurtosis(seg_x, fisher=False)) if len(seg_x) > 4 else 3.0
kurt_y = float(sp_kurtosis(seg_y, fisher=False)) if has_y and len(seg_y) > 4 else 3.0
kurt_z = float(sp_kurtosis(seg_z, fisher=False)) if has_z and len(seg_z) > 4 else 3.0
result["kurtosis_ratio_xy"] = kurt_x / (abs(kurt_y) + 1e-8)
result["kurtosis_ratio_xz"] = kurt_x / (abs(kurt_z) + 1e-8)
result["kurtosis_ratio_yz"] = kurt_y / (abs(kurt_z) + 1e-8)
# ── RMS vector magnitude and ratios ──
result["rms_vector_magnitude"] = float(np.sqrt(rms_x**2 + rms_y**2 + rms_z**2))
result["rms_ratio_xy"] = rms_x / rms_y
result["rms_ratio_xz"] = rms_x / rms_z
# ── Crest factor max across axes ──
def _crest(s: np.ndarray) -> float:
r = np.sqrt(np.mean(s**2))
return float(np.max(np.abs(s)) / (r + eps))
result["crest_max_axis"] = max(_crest(seg_x), _crest(seg_y), _crest(seg_z))
# ── Energy anisotropy ──
rms_vals = [rms_x, rms_y, rms_z]
result["energy_anisotropy"] = max(rms_vals) / min(rms_vals)
# ── Coherence computation (Welch-averaged, per Bendat & Piersol) ──
# Single-block MSC is always 1.0 — must average over multiple segments.
# Use scipy.signal.coherence which implements Welch's method internally.
from scipy.signal import coherence as _scipy_coherence
n = len(seg_x)
# nperseg=256 with 2048-sample window gives ~15 averages (enough for meaningful MSC)
_coh_nperseg = min(256, n // 4) if n >= 64 else n
_coh_noverlap = _coh_nperseg // 2
# … 108 more lines truncated …
Ultrasonic features
17 feature definitionsSeventeen features computed on the high-frequency microphone channel (IMP23ABSU on Pro/Rail tiers, 192 kHz PDM decimated to ~100 kHz PCM). Bands: bearing 20–60 kHz, leak 40–100 kHz, electrical 80–100 kHz. **Not computable from MAFAULDA — that dataset has no ultrasonic channel; values reported as N/A.**
ISO 18436-8 (acoustic emission practice). KALTECH band definitions match SDT/UE Systems convention for industrial AE.
us_rms_overall Overall ultrasonic RMS N/A on MAFAULDA —
- Units
- dBµV
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_rms_bearing Ultrasonic RMS — bearing band N/A on MAFAULDA —
- Units
- dBµV
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_rms_leak Ultrasonic RMS — leak band N/A on MAFAULDA —
- Units
- dBµV
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_rms_electrical Ultrasonic RMS — electrical band N/A on MAFAULDA —
- Units
- dBµV
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_peak Ultrasonic peak N/A on MAFAULDA —
- Units
- dBµV
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_crest_factor Ultrasonic crest factor N/A on MAFAULDA —
- Units
- dimensionless
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_baseline_delta_dB Baseline delta (dB) N/A on MAFAULDA —
- Units
- dB
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_spectral_flatness Ultrasonic spectral flatness N/A on MAFAULDA —
- Units
- dimensionless (0–1)
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_dominant_freq Ultrasonic dominant frequency N/A on MAFAULDA —
- Units
- Hz
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_is_steady Ultrasonic steady flag N/A on MAFAULDA —
- Units
- boolean (0/1)
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_max_rms Max windowed RMS N/A on MAFAULDA —
- Units
- dBµV
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_rms_bearing_band RMS — bearing band (linear, redundant) N/A on MAFAULDA —
- Units
- g (mic equivalent)
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_rms_leak_band RMS — leak band (linear, redundant) N/A on MAFAULDA —
- Units
- g (mic equivalent)
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_rms_electrical_band RMS — electrical band (linear, redundant) N/A on MAFAULDA —
- Units
- g (mic equivalent)
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_kurtosis Ultrasonic kurtosis N/A on MAFAULDA —
- Units
- dimensionless
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_steadiness Ultrasonic steadiness score N/A on MAFAULDA —
- Units
- dimensionless (0–1)
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
us_max_rms_100ms Max 100 ms RMS N/A on MAFAULDA —
- Units
- dBµV
- Textbook
- ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1702–1913 :: extract_ultrasonic_featuresdef extract_ultrasonic_features(
us_signal: np.ndarray,
fs_us: int = 192000,
baseline_rms_dbuv: float | None = None,
) -> dict[str, Any]:
"""
Extract 11 ultrasonic condition monitoring features from IMP23ABSU signal.
Standards: ISO 29821:2018, NASA bearing research, SDT 4CI methodology.
Sensor: IMP23ABSU (100 Hz – 80 kHz airborne MEMS mic, ~$1.50).
Frequency bands:
20–80 kHz overall ultrasonic
25–40 kHz bearing friction / lubrication quality
38–42 kHz compressed air leak detection (ISO 50001)
30–50 kHz electrical discharge / arcing / corona
Alarm thresholds (relative to per-machine baseline):
+8 dB lubrication needed (pre-failure)
+12 dB beginning of failure mode
+16 dB bearing damage confirmed
+35 dB catastrophic failure imminent
Args:
us_signal: Raw signal from IMP23ABSU.
fs_us: Sampling rate (192 kHz for full 80 kHz BW).
baseline_rms_dbuv: Machine's baseline RMS in dBuV (25–40 kHz band).
None if no baseline established yet.
Returns:
Dict with 11 features (us_* prefix).
"""
import logging as _logging
_us_log = _logging.getLogger("kaltech.features.ultrasonic")
nyq = fs_us / 2.0
# --- Input validation ---
_MIN_FILTER_SAMPLES = 27 # filtfilt min for 4th-order Butterworth
if len(us_signal) == 0:
raise ValueError("extract_ultrasonic_features: empty signal")
if len(us_signal) < _MIN_FILTER_SAMPLES:
raise ValueError(
f"extract_ultrasonic_features: signal length {len(us_signal)} < "
f"{_MIN_FILTER_SAMPLES} (minimum for bandpass filtering)"
)
if nyq <= 40000:
raise ValueError(
f"extract_ultrasonic_features: fs_us={fs_us} Hz (Nyquist={nyq:.0f} Hz) "
f"is too low for ultrasonic band (need Nyquist > 40 kHz). "
f"Wrong sample rate passed?"
)
if np.all(np.isnan(us_signal)):
raise ValueError(
"extract_ultrasonic_features: signal is all NaN — sensor failure"
)
if np.all(us_signal == 0.0):
_us_log.warning("Signal is all zeros — sensor may be disconnected")
# --- Filter each band ONCE, reuse for RMS + sub-window analysis ---
def _filter_band(low_hz: float, high_hz: float) -> np.ndarray | None:
if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
return None
lo = max(low_hz / nyq, 0.001)
hi = min(high_hz / nyq, 0.999)
if hi <= lo:
return None
b, a = butter(4, [lo, hi], btype="band")
return filtfilt(b, a, us_signal)
filt_overall = _filter_band(20000, 80000)
filt_bearing = _filter_band(25000, 40000)
filt_leak = _filter_band(38000, 42000)
filt_electrical = _filter_band(30000, 50000)
# --- Band-limited RMS values (from pre-filtered signals) ---
def _rms_of(arr: np.ndarray | None) -> float:
if arr is None:
return 0.0
return float(np.sqrt(np.mean(arr ** 2)))
# … 132 more lines truncated …
Temperature features
6 feature definitionsSix features from the surface temperature, ambient temperature, humidity, and their time histories. ISO 14224 / API 670 / UIC 518 converge on the same thresholds: ΔT > 50 °C warns, ΔT > 90 °C alarms; rate-of-rise > 2 °C/min escalates one tier. **N/A for MAFAULDA — accelerometer dataset, no temperature channel.**
ISO 14224 (O&G reliability), API 670 (machinery protection), UIC 518 (railway hot-box), SKF/CSI/B&K CM guidance all converge on these thresholds.
surface_temp_c Surface temperature N/A on MAFAULDA —
- Units
- °C
- Textbook
- ISO 14224 + API 670 + UIC 518 — Bearing thermal protection — multi-standard threshold convergence
v5/lib/features_v5.py:1920–1996 :: extract_temperature_featuresdef extract_temperature_features(
surface_temp_c: float,
ambient_temp_c: float,
humidity_pct: float,
temp_history: list[float] | None = None,
rms_history: list[float] | None = None,
) -> dict[str, Any]:
"""
Extract 6 temperature condition monitoring features.
Sensors: STTS22H (surface), SHT45 (ambient + humidity), NTC (hot axle).
Features:
surface_temp_c — direct surface reading (degC)
ambient_temp_c — ambient reading (degC)
delta_t — surface - ambient (>40C = hot axle alert)
humidity_pct — relative humidity (>80% = condensation risk)
temp_rate_of_rise — degC/min from recent temp_history (0.0 if no history)
temp_vibration_correlation — Pearson correlation between temp and RMS histories
(Claim 10: concurrent positive = friction-driven degradation)
Args:
surface_temp_c: Surface temperature from STTS22H/NTC (degC).
ambient_temp_c: Ambient temperature from SHT45 (degC).
humidity_pct: Relative humidity from SHT45 (0-100%).
temp_history: List of recent surface temps at 1-minute intervals,
most recent last. If None or too short, rate_of_rise = 0.
rms_history: List of recent vibration RMS values (same interval as temp_history).
Used to compute temperature-vibration correlation (Claim 10).
Returns:
Dict with 6 temperature features.
"""
if not math.isfinite(surface_temp_c):
logger.warning("surface_temp_c=%s is not finite — possible sensor failure", surface_temp_c)
surface_temp_c = 0.0
if not math.isfinite(ambient_temp_c):
logger.warning("ambient_temp_c=%s is not finite — possible sensor failure", ambient_temp_c)
ambient_temp_c = 25.0
delta_t = surface_temp_c - ambient_temp_c
# Rate of rise: linear regression slope over temp_history (degC/min)
temp_rate_of_rise = 0.0
if temp_history and len(temp_history) >= 2:
n = len(temp_history)
x = np.arange(n, dtype=np.float64)
y = np.array(temp_history, dtype=np.float64)
x_mean = np.mean(x)
y_mean = np.mean(y)
cov_xy = np.sum((x - x_mean) * (y - y_mean))
var_x = np.sum((x - x_mean) ** 2)
if var_x > 0:
temp_rate_of_rise = float(cov_xy / var_x)
# Temperature-vibration correlation (Claim 10)
# Concurrent positive correlation indicates friction-driven degradation
# as distinguished from environmental temperature confounders
temp_vibration_correlation = 0.0
if (temp_history and rms_history
and len(temp_history) >= 3 and len(rms_history) >= 3):
min_len = min(len(temp_history), len(rms_history))
t_arr = np.array(temp_history[-min_len:], dtype=np.float64)
r_arr = np.array(rms_history[-min_len:], dtype=np.float64)
if np.std(t_arr) > 1e-10 and np.std(r_arr) > 1e-10:
temp_vibration_correlation = float(np.corrcoef(t_arr, r_arr)[0, 1])
if not math.isfinite(temp_vibration_correlation):
temp_vibration_correlation = 0.0
return {
"surface_temp_c": float(surface_temp_c),
"ambient_temp_c": float(ambient_temp_c),
"delta_t": float(delta_t),
"humidity_pct": float(humidity_pct),
"temp_rate_of_rise": float(temp_rate_of_rise),
"temp_vibration_correlation": float(temp_vibration_correlation),
}
ambient_temp_c Ambient temperature N/A on MAFAULDA —
- Units
- °C
- Textbook
- ISO 14224 + API 670 + UIC 518 — Bearing thermal protection — multi-standard threshold convergence
v5/lib/features_v5.py:1920–1996 :: extract_temperature_featuresdef extract_temperature_features(
surface_temp_c: float,
ambient_temp_c: float,
humidity_pct: float,
temp_history: list[float] | None = None,
rms_history: list[float] | None = None,
) -> dict[str, Any]:
"""
Extract 6 temperature condition monitoring features.
Sensors: STTS22H (surface), SHT45 (ambient + humidity), NTC (hot axle).
Features:
surface_temp_c — direct surface reading (degC)
ambient_temp_c — ambient reading (degC)
delta_t — surface - ambient (>40C = hot axle alert)
humidity_pct — relative humidity (>80% = condensation risk)
temp_rate_of_rise — degC/min from recent temp_history (0.0 if no history)
temp_vibration_correlation — Pearson correlation between temp and RMS histories
(Claim 10: concurrent positive = friction-driven degradation)
Args:
surface_temp_c: Surface temperature from STTS22H/NTC (degC).
ambient_temp_c: Ambient temperature from SHT45 (degC).
humidity_pct: Relative humidity from SHT45 (0-100%).
temp_history: List of recent surface temps at 1-minute intervals,
most recent last. If None or too short, rate_of_rise = 0.
rms_history: List of recent vibration RMS values (same interval as temp_history).
Used to compute temperature-vibration correlation (Claim 10).
Returns:
Dict with 6 temperature features.
"""
if not math.isfinite(surface_temp_c):
logger.warning("surface_temp_c=%s is not finite — possible sensor failure", surface_temp_c)
surface_temp_c = 0.0
if not math.isfinite(ambient_temp_c):
logger.warning("ambient_temp_c=%s is not finite — possible sensor failure", ambient_temp_c)
ambient_temp_c = 25.0
delta_t = surface_temp_c - ambient_temp_c
# Rate of rise: linear regression slope over temp_history (degC/min)
temp_rate_of_rise = 0.0
if temp_history and len(temp_history) >= 2:
n = len(temp_history)
x = np.arange(n, dtype=np.float64)
y = np.array(temp_history, dtype=np.float64)
x_mean = np.mean(x)
y_mean = np.mean(y)
cov_xy = np.sum((x - x_mean) * (y - y_mean))
var_x = np.sum((x - x_mean) ** 2)
if var_x > 0:
temp_rate_of_rise = float(cov_xy / var_x)
# Temperature-vibration correlation (Claim 10)
# Concurrent positive correlation indicates friction-driven degradation
# as distinguished from environmental temperature confounders
temp_vibration_correlation = 0.0
if (temp_history and rms_history
and len(temp_history) >= 3 and len(rms_history) >= 3):
min_len = min(len(temp_history), len(rms_history))
t_arr = np.array(temp_history[-min_len:], dtype=np.float64)
r_arr = np.array(rms_history[-min_len:], dtype=np.float64)
if np.std(t_arr) > 1e-10 and np.std(r_arr) > 1e-10:
temp_vibration_correlation = float(np.corrcoef(t_arr, r_arr)[0, 1])
if not math.isfinite(temp_vibration_correlation):
temp_vibration_correlation = 0.0
return {
"surface_temp_c": float(surface_temp_c),
"ambient_temp_c": float(ambient_temp_c),
"delta_t": float(delta_t),
"humidity_pct": float(humidity_pct),
"temp_rate_of_rise": float(temp_rate_of_rise),
"temp_vibration_correlation": float(temp_vibration_correlation),
}
delta_t Temperature excess N/A on MAFAULDA —
- Units
- °C
- Textbook
- ISO 14224 + API 670 + UIC 518 — Bearing thermal protection — multi-standard threshold convergence
v5/lib/features_v5.py:1920–1996 :: extract_temperature_featuresdef extract_temperature_features(
surface_temp_c: float,
ambient_temp_c: float,
humidity_pct: float,
temp_history: list[float] | None = None,
rms_history: list[float] | None = None,
) -> dict[str, Any]:
"""
Extract 6 temperature condition monitoring features.
Sensors: STTS22H (surface), SHT45 (ambient + humidity), NTC (hot axle).
Features:
surface_temp_c — direct surface reading (degC)
ambient_temp_c — ambient reading (degC)
delta_t — surface - ambient (>40C = hot axle alert)
humidity_pct — relative humidity (>80% = condensation risk)
temp_rate_of_rise — degC/min from recent temp_history (0.0 if no history)
temp_vibration_correlation — Pearson correlation between temp and RMS histories
(Claim 10: concurrent positive = friction-driven degradation)
Args:
surface_temp_c: Surface temperature from STTS22H/NTC (degC).
ambient_temp_c: Ambient temperature from SHT45 (degC).
humidity_pct: Relative humidity from SHT45 (0-100%).
temp_history: List of recent surface temps at 1-minute intervals,
most recent last. If None or too short, rate_of_rise = 0.
rms_history: List of recent vibration RMS values (same interval as temp_history).
Used to compute temperature-vibration correlation (Claim 10).
Returns:
Dict with 6 temperature features.
"""
if not math.isfinite(surface_temp_c):
logger.warning("surface_temp_c=%s is not finite — possible sensor failure", surface_temp_c)
surface_temp_c = 0.0
if not math.isfinite(ambient_temp_c):
logger.warning("ambient_temp_c=%s is not finite — possible sensor failure", ambient_temp_c)
ambient_temp_c = 25.0
delta_t = surface_temp_c - ambient_temp_c
# Rate of rise: linear regression slope over temp_history (degC/min)
temp_rate_of_rise = 0.0
if temp_history and len(temp_history) >= 2:
n = len(temp_history)
x = np.arange(n, dtype=np.float64)
y = np.array(temp_history, dtype=np.float64)
x_mean = np.mean(x)
y_mean = np.mean(y)
cov_xy = np.sum((x - x_mean) * (y - y_mean))
var_x = np.sum((x - x_mean) ** 2)
if var_x > 0:
temp_rate_of_rise = float(cov_xy / var_x)
# Temperature-vibration correlation (Claim 10)
# Concurrent positive correlation indicates friction-driven degradation
# as distinguished from environmental temperature confounders
temp_vibration_correlation = 0.0
if (temp_history and rms_history
and len(temp_history) >= 3 and len(rms_history) >= 3):
min_len = min(len(temp_history), len(rms_history))
t_arr = np.array(temp_history[-min_len:], dtype=np.float64)
r_arr = np.array(rms_history[-min_len:], dtype=np.float64)
if np.std(t_arr) > 1e-10 and np.std(r_arr) > 1e-10:
temp_vibration_correlation = float(np.corrcoef(t_arr, r_arr)[0, 1])
if not math.isfinite(temp_vibration_correlation):
temp_vibration_correlation = 0.0
return {
"surface_temp_c": float(surface_temp_c),
"ambient_temp_c": float(ambient_temp_c),
"delta_t": float(delta_t),
"humidity_pct": float(humidity_pct),
"temp_rate_of_rise": float(temp_rate_of_rise),
"temp_vibration_correlation": float(temp_vibration_correlation),
}
humidity_pct Relative humidity N/A on MAFAULDA —
- Units
- percent
- Textbook
- ISO 14224 + API 670 + UIC 518 — Bearing thermal protection — multi-standard threshold convergence
v5/lib/features_v5.py:1920–1996 :: extract_temperature_featuresdef extract_temperature_features(
surface_temp_c: float,
ambient_temp_c: float,
humidity_pct: float,
temp_history: list[float] | None = None,
rms_history: list[float] | None = None,
) -> dict[str, Any]:
"""
Extract 6 temperature condition monitoring features.
Sensors: STTS22H (surface), SHT45 (ambient + humidity), NTC (hot axle).
Features:
surface_temp_c — direct surface reading (degC)
ambient_temp_c — ambient reading (degC)
delta_t — surface - ambient (>40C = hot axle alert)
humidity_pct — relative humidity (>80% = condensation risk)
temp_rate_of_rise — degC/min from recent temp_history (0.0 if no history)
temp_vibration_correlation — Pearson correlation between temp and RMS histories
(Claim 10: concurrent positive = friction-driven degradation)
Args:
surface_temp_c: Surface temperature from STTS22H/NTC (degC).
ambient_temp_c: Ambient temperature from SHT45 (degC).
humidity_pct: Relative humidity from SHT45 (0-100%).
temp_history: List of recent surface temps at 1-minute intervals,
most recent last. If None or too short, rate_of_rise = 0.
rms_history: List of recent vibration RMS values (same interval as temp_history).
Used to compute temperature-vibration correlation (Claim 10).
Returns:
Dict with 6 temperature features.
"""
if not math.isfinite(surface_temp_c):
logger.warning("surface_temp_c=%s is not finite — possible sensor failure", surface_temp_c)
surface_temp_c = 0.0
if not math.isfinite(ambient_temp_c):
logger.warning("ambient_temp_c=%s is not finite — possible sensor failure", ambient_temp_c)
ambient_temp_c = 25.0
delta_t = surface_temp_c - ambient_temp_c
# Rate of rise: linear regression slope over temp_history (degC/min)
temp_rate_of_rise = 0.0
if temp_history and len(temp_history) >= 2:
n = len(temp_history)
x = np.arange(n, dtype=np.float64)
y = np.array(temp_history, dtype=np.float64)
x_mean = np.mean(x)
y_mean = np.mean(y)
cov_xy = np.sum((x - x_mean) * (y - y_mean))
var_x = np.sum((x - x_mean) ** 2)
if var_x > 0:
temp_rate_of_rise = float(cov_xy / var_x)
# Temperature-vibration correlation (Claim 10)
# Concurrent positive correlation indicates friction-driven degradation
# as distinguished from environmental temperature confounders
temp_vibration_correlation = 0.0
if (temp_history and rms_history
and len(temp_history) >= 3 and len(rms_history) >= 3):
min_len = min(len(temp_history), len(rms_history))
t_arr = np.array(temp_history[-min_len:], dtype=np.float64)
r_arr = np.array(rms_history[-min_len:], dtype=np.float64)
if np.std(t_arr) > 1e-10 and np.std(r_arr) > 1e-10:
temp_vibration_correlation = float(np.corrcoef(t_arr, r_arr)[0, 1])
if not math.isfinite(temp_vibration_correlation):
temp_vibration_correlation = 0.0
return {
"surface_temp_c": float(surface_temp_c),
"ambient_temp_c": float(ambient_temp_c),
"delta_t": float(delta_t),
"humidity_pct": float(humidity_pct),
"temp_rate_of_rise": float(temp_rate_of_rise),
"temp_vibration_correlation": float(temp_vibration_correlation),
}
temp_rate_of_rise Temperature rate of rise N/A on MAFAULDA —
- Units
- °C/min
- Textbook
- ISO 14224 + API 670 + UIC 518 — Bearing thermal protection — multi-standard threshold convergence
v5/lib/features_v5.py:1920–1996 :: extract_temperature_featuresdef extract_temperature_features(
surface_temp_c: float,
ambient_temp_c: float,
humidity_pct: float,
temp_history: list[float] | None = None,
rms_history: list[float] | None = None,
) -> dict[str, Any]:
"""
Extract 6 temperature condition monitoring features.
Sensors: STTS22H (surface), SHT45 (ambient + humidity), NTC (hot axle).
Features:
surface_temp_c — direct surface reading (degC)
ambient_temp_c — ambient reading (degC)
delta_t — surface - ambient (>40C = hot axle alert)
humidity_pct — relative humidity (>80% = condensation risk)
temp_rate_of_rise — degC/min from recent temp_history (0.0 if no history)
temp_vibration_correlation — Pearson correlation between temp and RMS histories
(Claim 10: concurrent positive = friction-driven degradation)
Args:
surface_temp_c: Surface temperature from STTS22H/NTC (degC).
ambient_temp_c: Ambient temperature from SHT45 (degC).
humidity_pct: Relative humidity from SHT45 (0-100%).
temp_history: List of recent surface temps at 1-minute intervals,
most recent last. If None or too short, rate_of_rise = 0.
rms_history: List of recent vibration RMS values (same interval as temp_history).
Used to compute temperature-vibration correlation (Claim 10).
Returns:
Dict with 6 temperature features.
"""
if not math.isfinite(surface_temp_c):
logger.warning("surface_temp_c=%s is not finite — possible sensor failure", surface_temp_c)
surface_temp_c = 0.0
if not math.isfinite(ambient_temp_c):
logger.warning("ambient_temp_c=%s is not finite — possible sensor failure", ambient_temp_c)
ambient_temp_c = 25.0
delta_t = surface_temp_c - ambient_temp_c
# Rate of rise: linear regression slope over temp_history (degC/min)
temp_rate_of_rise = 0.0
if temp_history and len(temp_history) >= 2:
n = len(temp_history)
x = np.arange(n, dtype=np.float64)
y = np.array(temp_history, dtype=np.float64)
x_mean = np.mean(x)
y_mean = np.mean(y)
cov_xy = np.sum((x - x_mean) * (y - y_mean))
var_x = np.sum((x - x_mean) ** 2)
if var_x > 0:
temp_rate_of_rise = float(cov_xy / var_x)
# Temperature-vibration correlation (Claim 10)
# Concurrent positive correlation indicates friction-driven degradation
# as distinguished from environmental temperature confounders
temp_vibration_correlation = 0.0
if (temp_history and rms_history
and len(temp_history) >= 3 and len(rms_history) >= 3):
min_len = min(len(temp_history), len(rms_history))
t_arr = np.array(temp_history[-min_len:], dtype=np.float64)
r_arr = np.array(rms_history[-min_len:], dtype=np.float64)
if np.std(t_arr) > 1e-10 and np.std(r_arr) > 1e-10:
temp_vibration_correlation = float(np.corrcoef(t_arr, r_arr)[0, 1])
if not math.isfinite(temp_vibration_correlation):
temp_vibration_correlation = 0.0
return {
"surface_temp_c": float(surface_temp_c),
"ambient_temp_c": float(ambient_temp_c),
"delta_t": float(delta_t),
"humidity_pct": float(humidity_pct),
"temp_rate_of_rise": float(temp_rate_of_rise),
"temp_vibration_correlation": float(temp_vibration_correlation),
}
temp_vibration_correlation Temperature–vibration correlation N/A on MAFAULDA —
- Units
- dimensionless (−1..1)
- Textbook
- ISO 14224 + API 670 + UIC 518 — Bearing thermal protection — multi-standard threshold convergence
v5/lib/features_v5.py:1920–1996 :: extract_temperature_featuresdef extract_temperature_features(
surface_temp_c: float,
ambient_temp_c: float,
humidity_pct: float,
temp_history: list[float] | None = None,
rms_history: list[float] | None = None,
) -> dict[str, Any]:
"""
Extract 6 temperature condition monitoring features.
Sensors: STTS22H (surface), SHT45 (ambient + humidity), NTC (hot axle).
Features:
surface_temp_c — direct surface reading (degC)
ambient_temp_c — ambient reading (degC)
delta_t — surface - ambient (>40C = hot axle alert)
humidity_pct — relative humidity (>80% = condensation risk)
temp_rate_of_rise — degC/min from recent temp_history (0.0 if no history)
temp_vibration_correlation — Pearson correlation between temp and RMS histories
(Claim 10: concurrent positive = friction-driven degradation)
Args:
surface_temp_c: Surface temperature from STTS22H/NTC (degC).
ambient_temp_c: Ambient temperature from SHT45 (degC).
humidity_pct: Relative humidity from SHT45 (0-100%).
temp_history: List of recent surface temps at 1-minute intervals,
most recent last. If None or too short, rate_of_rise = 0.
rms_history: List of recent vibration RMS values (same interval as temp_history).
Used to compute temperature-vibration correlation (Claim 10).
Returns:
Dict with 6 temperature features.
"""
if not math.isfinite(surface_temp_c):
logger.warning("surface_temp_c=%s is not finite — possible sensor failure", surface_temp_c)
surface_temp_c = 0.0
if not math.isfinite(ambient_temp_c):
logger.warning("ambient_temp_c=%s is not finite — possible sensor failure", ambient_temp_c)
ambient_temp_c = 25.0
delta_t = surface_temp_c - ambient_temp_c
# Rate of rise: linear regression slope over temp_history (degC/min)
temp_rate_of_rise = 0.0
if temp_history and len(temp_history) >= 2:
n = len(temp_history)
x = np.arange(n, dtype=np.float64)
y = np.array(temp_history, dtype=np.float64)
x_mean = np.mean(x)
y_mean = np.mean(y)
cov_xy = np.sum((x - x_mean) * (y - y_mean))
var_x = np.sum((x - x_mean) ** 2)
if var_x > 0:
temp_rate_of_rise = float(cov_xy / var_x)
# Temperature-vibration correlation (Claim 10)
# Concurrent positive correlation indicates friction-driven degradation
# as distinguished from environmental temperature confounders
temp_vibration_correlation = 0.0
if (temp_history and rms_history
and len(temp_history) >= 3 and len(rms_history) >= 3):
min_len = min(len(temp_history), len(rms_history))
t_arr = np.array(temp_history[-min_len:], dtype=np.float64)
r_arr = np.array(rms_history[-min_len:], dtype=np.float64)
if np.std(t_arr) > 1e-10 and np.std(r_arr) > 1e-10:
temp_vibration_correlation = float(np.corrcoef(t_arr, r_arr)[0, 1])
if not math.isfinite(temp_vibration_correlation):
temp_vibration_correlation = 0.0
return {
"surface_temp_c": float(surface_temp_c),
"ambient_temp_c": float(ambient_temp_c),
"delta_t": float(delta_t),
"humidity_pct": float(humidity_pct),
"temp_rate_of_rise": float(temp_rate_of_rise),
"temp_vibration_correlation": float(temp_vibration_correlation),
}