KALTECH V7 Feature Catalog

353 features (100 per-axis × 3 + 30 cross-axis + 17 ultrasonic + 6 temperature) — the signal-processing feature set computed on-device and consumed by the KALTECH V7 detection engine. MAFAULDA-computable: 330.

Try it — compute all 353 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.

RPM
Bearing
fs in
fs out
seg N
Idle. Pick a preset above to populate every feature's value column.
X (radial) Y (axial) Z (tangential) All formulas and code below are static — the values fill in after compute.

Contents

Time-domain statistics

11 feature definitions

Statistical 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.

Concept

Shape numbers: what averaging can and cannot see

A bearing pit announces itself as a 1 ms tick, once per pass — a sliver of energy in a sea of normal vibration. RMS divides that sliver over thousands of samples and reports almost nothing. The shape statistics don't average; they weigh the tails: kurtosis raises each sample to the 4th power, so one spike counts like ten thousand ordinary samples, and crest factor compares the single worst sample to the average.

Healthy machine vibration is near-Gaussian — kurtosis ≈ 3.0 (Pearson convention), crest ≈ 4. The engine's tier gates read kurtosis against the 4.0 / 4.5 / 8.0 thresholds and crest factor above ~6 as the first, cheapest evidence that something is striking rather than rubbing.

Question it answersIs the vibration spiky in a way that energy metrics cannot see?
Blind spotA number, not a name — it says "impulsive", never which part. And in late-stage damage the spikes multiply into a carpet: kurtosis slides back toward 3 while the machine gets worse (the animation's "late wear" example).
animated · illustrative signals
Both traces are scaled to identical RMS. Watch the kurtosis and crest chips — only the shape numbers separate them. Illustrative synthetic signals.
rms Root Mean Square per-axis (×3)
Effective amplitude of the acceleration waveform — the single most important time-domain scalar in vibration monitoring. Mathematically the L2-norm divided by √N, physically the equivalent steady amplitude that would deliver the same energy as the actual signal. RMS is the input to ISO 10816 broadband zoning after one integration (velocity) and the headline 'overall vibration level' reading on every legacy vibration meter. Increases with BOTH fault energy AND overall operating intensity (more load, faster shaft, more structural transmission), so RMS by itself cannot diagnose a fault — it is interpreted alongside shape factors (crest, kurtosis) and trends against the machine's own baseline. Typical industrial ranges at the bearing housing: 0.1–0.5 g healthy, 0.5–2 g elevated, >2 g severe.
$$ \text{RMS} = \sqrt{\dfrac{1}{N}\sum_{i=1}^{N} x_i^{\,2}} $$
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:960–965  ::  extract_features        _signal_for_env, _med_filter, _med_info = med(signal)
        signal_env = _signal_for_env
        _med_aborted = _med_info["aborted"]
        _med_converged = float(_med_info["converged"])
        _med_kurt_before = float(_med_info["kurtosis_before"])
        _med_kurt_after = float(_med_info["kurtosis_after"])
peak Peak amplitude per-axis (×3)
Maximum absolute acceleration sample in the window. Captures the single most violent instant of the recording — sensitive to isolated impulsive events (a single bearing race spall passing through the load zone, an electromechanical transient, a structural impact). Used as the numerator in crest, impulse, and clearance factors. Note that peak is a single-sample statistic and therefore very sensitive to sampling rate (a brief impact whose duration is below the sample period will be under-counted) and to anti-alias filter cutoff. Healthy machines typically show peak/RMS ratios near √2 (sinusoidal dominant) up to ~4 (Gaussian-like); a peak value > 8× RMS strongly suggests an impulsive defect.
$$ \text{peak} = \max_i |x_i| $$
Units
g
Textbook
Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:960–965  ::  extract_features        _signal_for_env, _med_filter, _med_info = med(signal)
        signal_env = _signal_for_env
        _med_aborted = _med_info["aborted"]
        _med_converged = float(_med_info["converged"])
        _med_kurt_before = float(_med_info["kurtosis_before"])
        _med_kurt_after = float(_med_info["kurtosis_after"])
mean Mean (DC offset) per-axis (×3)
Arithmetic mean of the acceleration samples. For an AC-coupled accelerometer (every modern piezoelectric or MEMS sensor with high-pass filtering) the mean should be close to zero — a non-zero mean indicates one of three things: (1) the sensor has a DC bias drift that needs calibration, (2) gravity is leaking onto the axis because of poor sensor alignment with the gravity vector, (3) the signal contains a quasi-static load component (slow process vibration). Diagnostic value is limited: mean is reported mostly to flag bad sensor mounting. Healthy installation: |mean| < 0.01 g for a free-running bearing; > 0.1 g warrants checking the mount.
$$ \bar{x} = \dfrac{1}{N}\sum_i x_i $$
Units
g
Textbook
Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:967–973  ::  extract_features    else:
        signal_env = signal
        # F7 Wave-2: explicit "disabled" sentinel so the (aborted, converged)
        # pair is unambiguous. Pre-fix: aborted="" + converged=0.0 in the
        # disabled branch collided with the THEORETICAL "MED ran cleanly but
        # converged=0" combination — downstream consumers checking
        # `if features["med_aborted"]:` to detect "MED skipped" would
std_dev Standard deviation per-axis (×3)
Population standard deviation of the acceleration waveform (ddof=0, NumPy default — divisor N, not N−1). Algebraically equivalent to RMS once the mean has been subtracted; the difference RMS − std_dev is exactly the DC offset's contribution to the effective amplitude. For an AC-coupled sensor with near-zero mean (the normal case) std_dev ≈ RMS to within 0.1%. The ML model sees both because they encode different things on a DC-biased signal: RMS tells you the total energy including the bias, std_dev tells you the energy AROUND the operating point.
$$ \sigma = \sqrt{\dfrac{1}{N}\sum_i (x_i - \bar{x})^2} $$
Units
g
Textbook
Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:967–973  ::  extract_features    else:
        signal_env = signal
        # F7 Wave-2: explicit "disabled" sentinel so the (aborted, converged)
        # pair is unambiguous. Pre-fix: aborted="" + converged=0.0 in the
        # disabled branch collided with the THEORETICAL "MED ran cleanly but
        # converged=0" combination — downstream consumers checking
        # `if features["med_aborted"]:` to detect "MED skipped" would
variance Variance per-axis (×3)
Second central moment of the signal — the square of std_dev. Independently reported as a feature because the ML model's feature-scaler treats unbounded inputs differently depending on their numerical scale: variance has units g² and tends to dominate in linear-combinations of features, while std_dev (units g) is on the same scale as RMS / peak. Both are kept in the per-axis feature vector to give the downstream classifier latitude in weighting amplitude features against impulsiveness features.
$$ \sigma^2 = \dfrac{1}{N}\sum_i (x_i - \bar{x})^2 $$
Units
Textbook
Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:967–973  ::  extract_features    else:
        signal_env = signal
        # F7 Wave-2: explicit "disabled" sentinel so the (aborted, converged)
        # pair is unambiguous. Pre-fix: aborted="" + converged=0.0 in the
        # disabled branch collided with the THEORETICAL "MED ran cleanly but
        # converged=0" combination — downstream consumers checking
        # `if features["med_aborted"]:` to detect "MED skipped" would
kurtosis Kurtosis (Pearson) per-axis (×3)
Fourth standardised moment. A pristine signal is Gaussian → kurtosis ≈ 3.0; impulsive bearing faults push kurtosis ≫ 4.5 because periodic impacts produce heavy-tailed distributions. KALTECH tier thresholds: 4.5 (DEVELOPING), 8.0 (CRITICAL).
$$ K = \dfrac{\frac{1}{N}\sum_i (x_i - \bar{x})^4}{\sigma^4} \;\; \text{(Pearson; Gaussian} = 3.0) $$
Units
dimensionless
Textbook
Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
Notes: V4 chip uses Pearson convention (not Fisher excess). Verified bit-exact across Python, WASM, x86-FP32, Xtensa LX7 chip.
v5/lib/features_v5.py:967–973  ::  extract_features    else:
        signal_env = signal
        # F7 Wave-2: explicit "disabled" sentinel so the (aborted, converged)
        # pair is unambiguous. Pre-fix: aborted="" + converged=0.0 in the
        # disabled branch collided with the THEORETICAL "MED ran cleanly but
        # converged=0" combination — downstream consumers checking
        # `if features["med_aborted"]:` to detect "MED skipped" would
skewness Skewness per-axis (×3)
Third standardised moment. Measures the asymmetry of the acceleration distribution around its mean: positive skew means the right tail (positive accelerations) is heavier than the left, negative skew the opposite. A perfectly symmetric vibration source (a balanced rotating mass, gear-mesh modulation, Gaussian background noise) produces skewness ≈ 0. Bearing impacts on the LOADED side of the contact path break the symmetry and produce small positive skew (~0.1–0.4 typical); large |skew| > 1 is unusual and warrants checking for clipping, sensor saturation, or one-sided structural rub.
$$ S = \dfrac{\frac{1}{N}\sum_i (x_i - \bar{x})^3}{\sigma^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:967–973  ::  extract_features    else:
        signal_env = signal
        # F7 Wave-2: explicit "disabled" sentinel so the (aborted, converged)
        # pair is unambiguous. Pre-fix: aborted="" + converged=0.0 in the
        # disabled branch collided with the THEORETICAL "MED ran cleanly but
        # converged=0" combination — downstream consumers checking
        # `if features["med_aborted"]:` to detect "MED skipped" would
crest_factor Crest factor per-axis (×3)
Peak-to-RMS ratio. Increases as the signal becomes more impulsive — an early bearing defect produces brief high-amplitude spikes that raise peak much faster than they raise RMS, so the ratio climbs. Reference points: a pure sine wave has crest = √2 ≈ 1.414; Gaussian random noise sits around 3–4 (sample-size dependent); a spalled bearing in the early stage of pitting typically lands between 6 and 10. Crest factor is the classic 'first warning' scalar because it responds to fault impulses LONG before they raise the broadband RMS by enough to trip an ISO zone alarm — decades of CBM practice put crest > 6 as the threshold for investigation. KALTECH tier thresholds: 6 (DEVELOPING).
$$ \text{CF} = \dfrac{\text{peak}}{\text{RMS}} $$
Units
dimensionless
Textbook
Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:960–965  ::  extract_features        _signal_for_env, _med_filter, _med_info = med(signal)
        signal_env = _signal_for_env
        _med_aborted = _med_info["aborted"]
        _med_converged = float(_med_info["converged"])
        _med_kurt_before = float(_med_info["kurtosis_before"])
        _med_kurt_after = float(_med_info["kurtosis_after"])
shape_factor Shape factor per-axis (×3)
RMS divided by the mean of the absolute value of the signal. A pure descriptor of waveform shape, independent of amplitude or DC offset. Calibration points: a sine wave has shape factor = π/(2√2) ≈ 1.111; a square wave = 1.0; a triangular wave ≈ 1.155; a sparse pulse train > 2. Shape factor is monotone in the 'peakedness' of a signal but less aggressive than crest factor (no Peak/RMS ratio amplification). Used as an auxiliary impulsiveness measure that the ML model can combine with the more dynamic-range-sensitive crest, impulse, and clearance factors.
$$ \text{SF} = \dfrac{\text{RMS}}{\frac{1}{N}\sum_i |x_i|} $$
Units
dimensionless
Textbook
Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:975–978  ::  extract_features        # produced a usable result.
        _med_aborted = "disabled"
        _med_converged = 0.0
        _med_kurt_before = 0.0
impulse_factor Impulse factor per-axis (×3)
Peak divided by the mean of the absolute signal. More sensitive to isolated impulses than crest factor: replacing the RMS denominator with mean|x| means an occasional brief spike has even less effect on the denominator than on the RMS (the absolute-value averaging is less perturbed by single tail samples than the squared averaging). In practice the impulse factor is the textbook 'second derivative' early-warning scalar for bearing damage — it rises before crest factor on slow-developing pitting. Calibration: sine = π/√2 ≈ 2.22; Gaussian noise = π/2 · √(π/2) ≈ 1.96. Healthy bearing typically 3–5; impulsive defect > 8.
$$ \text{IF} = \dfrac{\text{peak}}{\frac{1}{N}\sum_i |x_i|} $$
Units
dimensionless
Textbook
Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:975–978  ::  extract_features        # produced a usable result.
        _med_aborted = "disabled"
        _med_converged = 0.0
        _med_kurt_before = 0.0
clearance_factor Clearance factor per-axis (×3)
Peak divided by the square of the mean of the square-root of the absolute amplitude. The most aggressive of the four shape ratios (crest < shape factor < impulse factor < clearance factor in terms of how strongly the denominator suppresses small samples relative to large ones). Randall §2.4.2 reports empirically that clearance factor responds earliest of all four to incipient bearing pitting because the √(|x|) averaging weighs the bulk of the recording — which is dominated by low-amplitude noise floor on a healthy bearing — even less than mean|x|. Calibration: sine = π²/8 ≈ 1.234 (smaller than the other ratios because the denominator is huge); a worn bearing in early defect stage typically reaches 4–8.
$$ \text{CL} = \dfrac{\text{peak}}{\left(\frac{1}{N}\sum_i \sqrt{|x_i|}\right)^2} $$
Units
dimensionless
Textbook
Randall 2011, §2.4, p. 31–38 — Vibration-based Condition Monitoring of Machinery; time-domain stats
v5/lib/features_v5.py:978–983  ::  extract_features        _med_kurt_before = 0.0
        _med_kurt_after = 0.0
        _med_n_iter = 0.0

    # -----------------------------------------------------------------------
    # TIME-DOMAIN STATISTICAL (11 features)

Frequency-domain statistics

10 feature definitions

Bulk 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.

Concept

Spectrum shape: the distribution, before the diagnosis

Before asking "is there a tone at BPFO", it pays to ask "what does the energy distribution look like at all?" These ten descriptors summarise the whole FFT as a shape: where its centre of mass sits (centroid), how spread it is (bandwidth), where 85% of the energy has accumulated (rolloff), and whether it is tonal or noise-like (flatness, entropy), plus four coarse band energies.

None of them names a fault — by design. They are the priors the ML head consumes and the gating context for the verdict engine: developing damage drags the centroid toward the high bands and whitens the floor long before any single line is unambiguous.

Question it answersHow is vibration energy distributed across the band — and is that distribution drifting scan over scan?
Blind spotShape without names: a shifted centroid says "something changed", never "which part". Diagnosis belongs to the defect-frequency groups.
animated · illustrative signals
Markers are computed live from the drawn spectrum — watch the centroid slide as high-band energy appears. Illustrative synthetic spectra.
dominant_freq_hz Dominant frequency per-axis (×3)
Frequency of the largest FFT magnitude bin, excluding DC. On a healthy machine this is typically the shaft rotation rate (a balanced rotor still produces a small 1× tone from residual unbalance) or — on pumps, compressors, fans — the blade-pass / vane-pass frequency. Under fault the dominant peak can migrate to: 2× shaft (misalignment), the bearing defect frequency itself (BPFO/BPFI/BSF/FTF) once the defect is mature enough to produce stronger tones than imbalance, or a structural resonance excited by random impacts. The value is most informative when read alongside the bearing's expected defect frequencies and the known shaft RPM — a stand-alone reading carries less diagnostic weight.
$$ f_\text{dom} = \arg\max_{k \ge 1} |X[k]| $$
Units
Hz
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Visualisation
spectrum
v5/lib/features_v5.py:990–994  ::  extract_features    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
spectral_centroid Spectral centroid per-axis (×3)
Energy-weighted mean frequency — the 'centre of mass' of the power spectrum. Conceptually equivalent to the expectation of frequency under the power-spectrum-as-PDF interpretation. The centroid migrates UPWARD as a bearing develops impulsive damage — the impacts excite the bearing's housing resonance (typically several kHz) and add high-frequency energy to a spectrum that was previously dominated by shaft-rate content. It is the single best 1-scalar early-warning of 'something is changing in the spectrum', complementary to the time-domain kurtosis / crest scalars. A healthy machine's centroid stays within ±10% of its baseline; a 30%+ shift warrants investigation.
$$ f_c = \dfrac{\sum_k f_k |X[k]|^2}{\sum_k |X[k]|^2} $$
Units
Hz
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Visualisation
spectrum
v5/lib/features_v5.py:998–1001  ::  extract_features    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
spectral_bandwidth Spectral bandwidth per-axis (×3)
Energy-weighted standard deviation of frequency around the spectral centroid — the 'spread' of the power spectrum. Broad, random bearing impacts contribute energy across a wide band and INCREASE the bandwidth; tonal mechanical signatures (perfectly aligned shaft, dominant gear-mesh, dominant 1× imbalance) concentrate energy at a few frequencies and DECREASE it. Used together with spectral_centroid as a (μ, σ) pair describing the spectrum's shape. The ratio bandwidth / centroid is a useful normalised 'how-broadband-is-this' scalar — values near 0 mean a tonal signal, values approaching the reciprocal of the noise-floor mean a flat spectrum.
$$ f_\text{bw} = \sqrt{\dfrac{\sum_k (f_k - f_c)^2 |X[k]|^2}{\sum_k |X[k]|^2}} $$
Units
Hz
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Visualisation
spectrum
v5/lib/features_v5.py:998–1001  ::  extract_features    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
spectral_rolloff Spectral rolloff (85%) per-axis (×3)
Frequency below which 85% of the cumulative power-spectrum energy resides. A 'low-pass shape' descriptor: a low rolloff value means the signal is dominated by low-frequency content (rotor dynamics, slow mechanical resonances); a high rolloff value means significant energy extends into the bearing-impact frequency band. Sensitive in the same direction as spectral_centroid but with sharper-edged behaviour because it counts a hard 85% threshold rather than a smooth mean — useful when the impulsive content develops as a discrete band rather than a gradual high-frequency drift. The 85% threshold matches the convention used by the MIR (music information retrieval) community and is reasonable for bearing CM.
$$ f_\text{ro} = \min\,\{f_k \;|\; \sum_{j \le k} |X[j]|^2 \ge 0.85 \sum_j |X[j]|^2\} $$
Units
Hz
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Visualisation
spectrum
v5/lib/features_v5.py:1003–1006  ::  extract_features    clearance_factor = peak / (mean_sqrt_abs ** 2) if mean_sqrt_abs > 0 else 0.0

    # -----------------------------------------------------------------------
    # FREQUENCY-DOMAIN (7 features)
spectral_flatness Spectral flatness (Wiener entropy) per-axis (×3)
Ratio of the geometric mean of the power spectrum to its arithmetic mean. Bounded in [0, 1]: a perfectly tonal signal (single FFT bin carries all the energy) → 0, white noise (uniform power across all bins) → 1. The natural scalar discriminator between 'line-spectrum dominated' machinery states (healthy 1× rotor tone, gear-mesh, electrical line) and 'broadband dominated' states (impulsive bearing damage, random structural rub, broadband flow noise). On a healthy rotating machine flatness is typically 0.05–0.2; advanced bearing degradation can push it past 0.5 as the impact-band broadband swamp overwhelms the original line spectrum.
$$ \text{SFM} = \dfrac{\sqrt[N]{\prod_k |X[k]|^2}}{\frac{1}{N}\sum_k |X[k]|^2} $$
Units
dimensionless
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1008–1018  ::  extract_features    freqs, magnitudes = compute_fft(signal, fs)

    # Exclude DC bin (index 0) for spectral statistics
    freqs_ndc = freqs[1:]
    mags_ndc = magnitudes[1:]

    dominant_freq_hz = float(freqs_ndc[np.argmax(mags_ndc)]) if len(mags_ndc) > 0 else 0.0

    # Spectral power (for weighted statistics)
    power = mags_ndc ** 2
    total_power = float(np.sum(power))
spectral_entropy Spectral entropy per-axis (×3)
Shannon entropy of the normalised power spectrum, computed by treating the per-bin power fractions as a probability mass function and summing −p log p across bins. A near-tonal spectrum (all energy in one bin) has entropy near 0; a flat white-noise spectrum saturates at log(N) where N is the number of bins. Encodes the same broadband-vs-tonal axis as spectral_flatness but with different sensitivity to the shape of the tail — flatness penalises a single dominant bin hard via the geometric mean, entropy penalises it softly via the logarithm. The ML model sees both because they disagree on edge cases (a spectrum with two strong peaks is moderate-entropy but very low-flatness).
$$ H = -\sum_k p_k \log p_k \;\;\text{where}\;\; p_k = \dfrac{|X[k]|^2}{\sum_j |X[j]|^2} $$
Units
nats
Textbook
Brandt 2011, §3 + §6 — Noise and Vibration Analysis — DSP fundamentals
v5/lib/features_v5.py:1190–1199  ::  extract_features    # g·s → m/s for second integration
    vel_raw *= 9.81
    displacement_m = cumulative_trapezoid(vel_raw, dx=dt, initial=0.0)
    displacement_m -= np.linspace(displacement_m[0], displacement_m[-1], len(displacement_m))
    if nyquist > 100:
        b_d, a_d = butter(2, [max(2.0 / nyquist, 0.001), min(100.0 / nyquist, 0.99)], btype="band")
        disp_filtered = filtfilt(b_d, a_d, displacement_m)
        # ISO 20816-3: peak-to-peak displacement in µm
        pp_displacement_um = float(np.max(disp_filtered) - np.min(disp_filtered)) * 1e6
    else:
band_energy_low Band energy — low per-axis (×3)
Fraction of total broadband energy in [0, fs/6) Hz. The 'rotor-dynamics' band on most rotating machines — the 1× shaft tone (imbalance), 2× shaft (misalignment), 3× shaft (severe misalignment / looseness), low-order gear-mesh fundamentals, foundation resonances. A healthy installation typically concentrates 60–80% of broadband energy here. A dropping fraction means energy is migrating UP the spectrum, which is the textbook signature of developing bearing damage. Together with band_energy_mid and band_energy_high this feature gives the ML model a coarse 3-bin description of where the energy is sitting in the spectrum, independent of the absolute amplitude.
$$ E_\text{lo} = \dfrac{\sum_{k: f_k \in [0,\,f_s/6)} |X[k]|^2}{\sum_{k: f_k \in [0,\,f_s/2)} |X[k]|^2} $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1020–1037  ::  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)))

        # 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)])

        # 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
band_energy_mid Band energy — mid per-axis (×3)
Fraction of total broadband energy in [fs/6, fs/3) Hz. The 'transition' band that contains the bearing-defect frequency fundamentals (BPFO, BPFI, BSF) on most industrial bearings spinning at typical motor speeds (1500–3600 RPM), plus their early-stage sidebands and the lower harmonics of gear-mesh. An early bearing defect typically RAISES this band's fraction first — before the high-frequency resonance excitation kicks in. Read jointly with band_energy_low (which it steals from) and band_energy_high (which it precedes in time as the fault develops).
$$ E_\text{mid} = \dfrac{\sum_{k: f_k \in [f_s/6,\,f_s/3)} |X[k]|^2}{\sum_k |X[k]|^2} $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1020–1037  ::  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)))

        # 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)])

        # 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
band_energy_high Band energy — high per-axis (×3)
Fraction of total broadband energy in [fs/3, fs/2) Hz. The 'high-frequency-resonance' band — where bearing impact-excited housing resonances live, where the kurtogram typically picks its optimal demodulation band, and where the first sub-microscopic bearing defect produces its earliest detectable spectral footprint (decades before any of the rotor-dynamics band features respond). A healthy bearing keeps this fraction below ~5% of total energy; advanced damage can push it past 30%. Note that the actual Hz range depends on the sample rate: at fs = 12 kHz the band is 4–6 kHz, at fs = 50 kHz it is 16.7–25 kHz.
$$ E_\text{hi} = \dfrac{\sum_{k: f_k \in [f_s/3,\,f_s/2)} |X[k]|^2}{\sum_k |X[k]|^2} $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1020–1037  ::  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)))

        # 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)])

        # 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
band_energy_ultrasonic Band energy — ultrasonic per-axis (×3)
Fraction of total broadband energy above 10 kHz. Only meaningful when the accelerometer has bandwidth ≥ 20 kHz (modern MEMS like the IIS3DWB do; legacy ICP piezo sensors often roll off at 10–15 kHz). Acoustic-emission-style phenomena — sub-microscopic bearing defects emitting high-frequency stress waves, gear-mesh contact metal fatigue, lubrication starvation — produce ultrasonic-band energy DECADES before ISO 10816 broadband RMS responds. This makes the feature an extremely sensitive early-fault flag for installations where the sensor bandwidth supports it. On a sensor that doesn't have ≥ 20 kHz response, this field reads close to zero and conveys no information.
$$ E_\text{us} = \dfrac{\sum_{k: f_k \ge 10\,\text{kHz}} |X[k]|^2}{\sum_k |X[k]|^2} $$
Units
dimensionless (0–1)
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1180–1188  ::  extract_features    #
    # Effective band by fs is intentional — the firmware/common parity
    # contract requires this exact clamp to match the C implementation
    # (see firmware/common/dsp/kaltech_filter_coeffs.h
    # KAL_FILT_DISP_PP_COEFFS). Changing here would break design specification
    # bit-exact parity.
    #
    # Use unfiltered velocity for integration to avoid double-filtering.
    vel_raw = cumulative_trapezoid(signal, dx=dt, initial=0.0)

ISO 10816 / 20816 broadband severity

4 feature definitions

The 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.

Concept

ISO velocity: the plant-floor severity language

RMS velocity in the 10–1000 Hz band is how the entire industry agrees on "how bad is the shaking" — velocity, because a mm/s means roughly the same fatigue stress at 20 Hz as at 800 Hz. The engine zones it against the ISO 10816 Class IV thresholds baked into this group: 2.3 / 4.5 / 7.1 mm/s.

It is the headline number for structural faults — imbalance, misalignment, looseness pump smooth, sustained energy that RMS integrates faithfully. But it is an average: a 1 ms bearing tick divided over a full window contributes almost nothing, which is why a bearing can be visibly failing in the envelope groups below while this gauge still reads green.

Question it answersHow hard is the whole machine shaking, on the scale plant standards and acceptance tests understand?
Blind spotTicks vanish into the average — early rolling-element damage is invisible here. That's a feature, not a bug: this group anchors severity; the envelope groups anchor early detection.
animated · illustrative signals
Gauge zones: ISO 10816 Class IV — 2.3 / 4.5 / 7.1 mm/s. The faint "peak" mark shows what the average ignores. Illustrative synthetic signals.
rms_velocity_mm_s RMS velocity per-axis (×3)
Integrate acceleration (g·s) → m/s → drift-subtract via linear-trend removal → bandpass 10–1000 Hz (filtfilt) → RMS. Multiplied by 9.81 × 1000 for mm/s. **This is the headline ISO 10816 zone metric.**
$$ v_\text{rms} = 1000 \cdot g \cdot \sqrt{\dfrac{1}{N}\sum_i v_i^2} \;,\;\; v = \mathrm{filtfilt}\!\left(\int x\,dt - \text{trend}\right) $$
Units
mm/s
Textbook
ISO 10816-1/3 — Mechanical vibration — broadband velocity severity zones
Notes: Integration uses scipy.integrate.cumulative_trapezoid, drift-subtract is np.linspace(v[0], v[-1], N), bandpass is Butterworth-4 SOS. C path bit-exact within 1e-5 mm/s.
v5/lib/features_v5.py:793–821  ::  compute_rms_velocity        hop_length: Hop size between STFT frames.

    Returns:
        float32 array of shape (n_mels, time_frames) normalised to [0, 1].
    """
    _f, _t, Zxx = stft(segment, fs=fs, nperseg=n_fft, noverlap=n_fft - hop_length)
    power = np.abs(Zxx) ** 2

    mel_basis = _mel_filterbank(fs, n_fft, n_mels)
    mel_spec = mel_basis @ power[: n_fft // 2 + 1, :]

    log_mel = np.log(mel_spec + 1e-9)
    log_mel = (log_mel - log_mel.min()) / (log_mel.max() - log_mel.min() + 1e-9)

    return log_mel.astype(np.float32)


# ---------------------------------------------------------------------------
# RMS Velocity (ISO 10816)
# ---------------------------------------------------------------------------

def 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.
peak_velocity_mm_s Peak velocity per-axis (×3)
Maximum |velocity| in the analysis window after the same integrate → drift-subtract → 10–1000 Hz bandpass chain that feeds rms_velocity_mm_s. Sensitive to single transient events (an impulsive bearing impact, a startup/shutdown spike, a rotor-stator rub burst) that the RMS averages out across the window. Used in railway and large-machinery monitoring where API 670 prescribes peak velocity in addition to RMS — the ratio peak_velocity / rms_velocity above ~3 indicates the vibration is impulsive rather than steady-state and the machine may have a transient mechanical fault even when the ISO zone is GOOD on the RMS reading alone.
$$ v_\text{peak} = \max_i |v_i| $$
Units
mm/s
Textbook
ISO 10816-1/3 — Mechanical vibration — broadband velocity severity zones
v5/lib/features_v5.py:793–821  ::  compute_rms_velocity        hop_length: Hop size between STFT frames.

    Returns:
        float32 array of shape (n_mels, time_frames) normalised to [0, 1].
    """
    _f, _t, Zxx = stft(segment, fs=fs, nperseg=n_fft, noverlap=n_fft - hop_length)
    power = np.abs(Zxx) ** 2

    mel_basis = _mel_filterbank(fs, n_fft, n_mels)
    mel_spec = mel_basis @ power[: n_fft // 2 + 1, :]

    log_mel = np.log(mel_spec + 1e-9)
    log_mel = (log_mel - log_mel.min()) / (log_mel.max() - log_mel.min() + 1e-9)

    return log_mel.astype(np.float32)


# ---------------------------------------------------------------------------
# RMS Velocity (ISO 10816)
# ---------------------------------------------------------------------------

def 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.
rms_acceleration_g RMS acceleration (10–1000 Hz) per-axis (×3)
RMS of the bandpassed acceleration in the same 10–1000 Hz ISO 10816 band — without the velocity integration step. Used by API 670 as the alternative severity scalar when the machine has high stiffness.
$$ a_\text{rms} = \sqrt{\dfrac{1}{N}\sum_i a_i^2}\;,\;\; a = \mathrm{filtfilt}(x) $$
Units
g
Textbook
ISO 10816-1/3 — Mechanical vibration — broadband velocity severity zones
v5/lib/features_v5.py:1499–1623  ::  extract_randall_features    # Dominant order (excluding DC)
    non_dc = orders_trim > 0.5
    if np.any(non_dc):
        dominant_idx = np.argmax(mags_trim[non_dc])
        order_energies["dominant_order"] = float(orders_trim[non_dc][dominant_idx])
    else:
        order_energies["dominant_order"] = 0.0

    # 1x/2x ratio (imbalance indicator — high 1x relative to 2x)
    e1x = order_energies.get("order_1x_energy", 0.0)
    e2x = order_energies.get("order_2x_energy", 0.0)
    order_energies["order_1x_2x_ratio"] = min(e1x / (e2x + 1e-8), 1000.0)

    # Sub-synchronous energy (orders < 1.0) — looseness indicator
    mask_sub = (orders_trim > 0.1) & (orders_trim < 0.95)
    order_energies["order_subsync_energy"] = float(np.sum(mags_trim[mask_sub] ** 2))

    # Include the defect order values for reference
    order_energies.update(defect_orders)

    return order_energies


def 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
# … 45 more lines truncated …
pp_displacement_um Peak-to-peak displacement per-axis (×3)
Integrate acceleration twice → bandpass 2–100 Hz → peak-to-peak. Used at low shaft speeds (< 600 RPM) where velocity is insensitive. **API 670 alarm threshold: 50 µm pp for machinery > 3600 RPM; 100 µm pp for slow-speed.**
$$ d_\text{pp} = \max_i d_i - \min_i d_i \;,\;\; d = \mathrm{filtfilt}\!\left(\iint x\,dt\,dt - \text{trend}\right) $$
Units
µm
Textbook
ISO 10816-1/3 — Mechanical vibration — broadband velocity severity zones
Notes: Comment in code says '2–100 Hz' but the effective lower edge at fs=12 kHz is 6 Hz due to the `max(x/nyq, 0.001)` clamp. See parity-porting.md §1; firmware was designed against the effective band, not the comment.
v5/lib/features_v5.py:1499–1623  ::  extract_randall_features    # Dominant order (excluding DC)
    non_dc = orders_trim > 0.5
    if np.any(non_dc):
        dominant_idx = np.argmax(mags_trim[non_dc])
        order_energies["dominant_order"] = float(orders_trim[non_dc][dominant_idx])
    else:
        order_energies["dominant_order"] = 0.0

    # 1x/2x ratio (imbalance indicator — high 1x relative to 2x)
    e1x = order_energies.get("order_1x_energy", 0.0)
    e2x = order_energies.get("order_2x_energy", 0.0)
    order_energies["order_1x_2x_ratio"] = min(e1x / (e2x + 1e-8), 1000.0)

    # Sub-synchronous energy (orders < 1.0) — looseness indicator
    mask_sub = (orders_trim > 0.1) & (orders_trim < 0.95)
    order_energies["order_subsync_energy"] = float(np.sum(mags_trim[mask_sub] ** 2))

    # Include the defect order values for reference
    order_energies.update(defect_orders)

    return order_energies


def 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
# … 45 more lines truncated …

FFT defect energies (1× / 2× / 3×)

12 feature definitions

Direct 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.

Concept

Defect frequencies: geometry gives every part its own clock

Bearing geometry — ball count, pitch and ball diameter, contact angle — fixes the rate at which a rolling element passes any fixed point. So a pit on the outer ring is struck at exactly BPFO, the inner ring at BPFI, a ball defect at BSF, a cage fault at FTF. These features simply integrate spectrum energy in a ±5 Hz window at each rate and its 2× and 3× harmonics: is there energy exactly where this part's clock says it should be?

The sidebands carry a second story. An outer-ring pit sits still in the load zone — every impact equal, a clean tone. An inner-ring pit rides the shaft, loud once per revolution as it sweeps through the load zone: that amplitude modulation splits the BPFI tone into sidebands spaced at exactly 1× shaft. A ball defect gets modulated at the cage rate instead. The sideband spacing confirms the diagnosis.

Question it answersIs there spectral energy exactly at the rate a specific bearing element would generate — and do the sidebands confirm which element?
Blind spotThe fundamentals live at low frequency where shaft harmonics and structural noise spill over; a small pit's direct tone is often buried. The envelope groups below usually see the same defect earlier.
animated · illustrative signals
Top: impact strength per revolution (the modulation). Bottom: how that modulation prints sidebands around the defect tone. Illustrative geometry and synthetic signals.
energy_at_bpfo FFT energy at BPFO per-axis (×3)
Sum of squared FFT magnitudes within ±5 Hz of 1× BPFO. The fundamental of BPFO carries direct evidence of BPFO-class bearing damage; in practice the harmonics survive even when the fundamental is buried under shaft-rate energy.
$$ E^{\mathrm{FFT}}_{BPFO,\,1\times} = \sum_{k:\,|f_k - f_\text{BPFO}| \le 5\,\mathrm{Hz}} |X[k]|^2 $$
Units
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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
energy_at_bpfi FFT energy at BPFI per-axis (×3)
Sum of squared FFT magnitudes within ±5 Hz of 1× BPFI. The fundamental of BPFI carries direct evidence of BPFI-class bearing damage; in practice the harmonics survive even when the fundamental is buried under shaft-rate energy.
$$ E^{\mathrm{FFT}}_{BPFI,\,1\times} = \sum_{k:\,|f_k - f_\text{BPFI}| \le 5\,\mathrm{Hz}} |X[k]|^2 $$
Units
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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
energy_at_bsf FFT energy at BSF per-axis (×3)
Sum of squared FFT magnitudes within ±5 Hz of 1× BSF. The fundamental of BSF carries direct evidence of BSF-class bearing damage; in practice the harmonics survive even when the fundamental is buried under shaft-rate energy.
$$ E^{\mathrm{FFT}}_{BSF,\,1\times} = \sum_{k:\,|f_k - f_\text{BSF}| \le 5\,\mathrm{Hz}} |X[k]|^2 $$
Units
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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
energy_at_ftf FFT energy at FTF per-axis (×3)
Sum of squared FFT magnitudes within ±5 Hz of 1× FTF. The fundamental of FTF carries direct evidence of FTF-class bearing damage; in practice the harmonics survive even when the fundamental is buried under shaft-rate energy.
$$ E^{\mathrm{FFT}}_{FTF,\,1\times} = \sum_{k:\,|f_k - f_\text{FTF}| \le 5\,\mathrm{Hz}} |X[k]|^2 $$
Units
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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
energy_at_2x_bpfo FFT energy at 2× BPFO per-axis (×3)
Sum of squared FFT magnitudes within ±5 Hz of 2× BPFO. The 2× harmonic of BPFO carries direct evidence of BPFO-class bearing damage; in practice the harmonics survive even when the fundamental is buried under shaft-rate energy.
$$ E^{\mathrm{FFT}}_{BPFO,\,2\times} = \sum_{k:\,|f_k - 2\,f_\text{BPFO}| \le 5\,\mathrm{Hz}} |X[k]|^2 $$
Units
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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
energy_at_2x_bpfi FFT energy at 2× BPFI per-axis (×3)
Sum of squared FFT magnitudes within ±5 Hz of 2× BPFI. The 2× harmonic of BPFI carries direct evidence of BPFI-class bearing damage; in practice the harmonics survive even when the fundamental is buried under shaft-rate energy.
$$ E^{\mathrm{FFT}}_{BPFI,\,2\times} = \sum_{k:\,|f_k - 2\,f_\text{BPFI}| \le 5\,\mathrm{Hz}} |X[k]|^2 $$
Units
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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
energy_at_2x_bsf FFT energy at 2× BSF per-axis (×3)
Sum of squared FFT magnitudes within ±5 Hz of 2× BSF. The 2× harmonic of BSF carries direct evidence of BSF-class bearing damage; in practice the harmonics survive even when the fundamental is buried under shaft-rate energy.
$$ E^{\mathrm{FFT}}_{BSF,\,2\times} = \sum_{k:\,|f_k - 2\,f_\text{BSF}| \le 5\,\mathrm{Hz}} |X[k]|^2 $$
Units
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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
energy_at_2x_ftf FFT energy at 2× FTF per-axis (×3)
Sum of squared FFT magnitudes within ±5 Hz of 2× FTF. The 2× harmonic of FTF carries direct evidence of FTF-class bearing damage; in practice the harmonics survive even when the fundamental is buried under shaft-rate energy.
$$ E^{\mathrm{FFT}}_{FTF,\,2\times} = \sum_{k:\,|f_k - 2\,f_\text{FTF}| \le 5\,\mathrm{Hz}} |X[k]|^2 $$
Units
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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
energy_at_3x_bpfo FFT energy at 3× BPFO per-axis (×3)
Sum of squared FFT magnitudes within ±5 Hz of 3× BPFO. The 3× harmonic of BPFO carries direct evidence of BPFO-class bearing damage; in practice the harmonics survive even when the fundamental is buried under shaft-rate energy.
$$ E^{\mathrm{FFT}}_{BPFO,\,3\times} = \sum_{k:\,|f_k - 3\,f_\text{BPFO}| \le 5\,\mathrm{Hz}} |X[k]|^2 $$
Units
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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
energy_at_3x_bpfi FFT energy at 3× BPFI per-axis (×3)
Sum of squared FFT magnitudes within ±5 Hz of 3× BPFI. The 3× harmonic of BPFI carries direct evidence of BPFI-class bearing damage; in practice the harmonics survive even when the fundamental is buried under shaft-rate energy.
$$ E^{\mathrm{FFT}}_{BPFI,\,3\times} = \sum_{k:\,|f_k - 3\,f_\text{BPFI}| \le 5\,\mathrm{Hz}} |X[k]|^2 $$
Units
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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
energy_at_3x_bsf FFT energy at 3× BSF per-axis (×3)
Sum of squared FFT magnitudes within ±5 Hz of 3× BSF. The 3× harmonic of BSF carries direct evidence of BSF-class bearing damage; in practice the harmonics survive even when the fundamental is buried under shaft-rate energy.
$$ E^{\mathrm{FFT}}_{BSF,\,3\times} = \sum_{k:\,|f_k - 3\,f_\text{BSF}| \le 5\,\mathrm{Hz}} |X[k]|^2 $$
Units
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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
energy_at_3x_ftf FFT energy at 3× FTF per-axis (×3)
Sum of squared FFT magnitudes within ±5 Hz of 3× FTF. The 3× harmonic of FTF carries direct evidence of FTF-class bearing damage; in practice the harmonics survive even when the fundamental is buried under shaft-rate energy.
$$ E^{\mathrm{FFT}}_{FTF,\,3\times} = \sum_{k:\,|f_k - 3\,f_\text{FTF}| \le 5\,\mathrm{Hz}} |X[k]|^2 $$
Units
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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

Envelope-spectrum defect energies (1× / 2× / 3×)

12 feature definitions

Envelope 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.

Concept

Envelope analysis: demodulating the rhythm of the ticks

Bearing impacts are tiny ticks that excite the machine's high-frequency resonance — the structure rings like a struck bell at every impact. The engine demodulates a 2–5 kHz band by default: bandpass throws away shaft harmonics and keeps only the ringing; the Hilbert envelope strips away the resonance carrier and leaves just the rhythm of the ticks; the FFT of that envelope shows energy at whichever geometric rate is ticking — BPFO for a pit on the outer ring, BPFI with shaft-rate sidebands for the inner ring, BSF with cage modulation for a ball.

This is the textbook bearing-fault detector and the flagship of the feature set: it names the failing part and, trended over days, shows it worsening. The twelve features here are the envelope-spectrum energies at {1×, 2×, 3×} of each defect rate — the direct evidence the verdict engine cites for a bearing call.

Question it answersWHICH bearing element is ticking — outer ring, inner ring, ball or cage — and is its rhythm growing?
Blind spotIt assumes the resonance lives inside the fixed band. If this machine rings somewhere else, a fixed band demodulates noise — exactly what the kurtogram-selected band (two groups down) exists to fix.
animated · illustrative signals
The four stages animate top-to-bottom; cursors mark where each defect's rhythm would land in the envelope spectrum. Illustrative bearing geometry and synthetic signals.
envelope_energy_bpfo Envelope energy at BPFO per-axis (×3)
Sum of squared envelope-spectrum magnitudes within ±5 Hz of 1× BPFO. The envelope spectrum demodulates the high-frequency resonance band so the bearing-defect modulation rate appears at low frequency, where the discriminative signal is sharp and easy to integrate. **This is the textbook bearing-fault feature.**
$$ E^{\mathrm{env}}_{BPFO,\,1\times} = \sum_{k:\,|f_k - f_\text{BPFO}| \le 5\,\mathrm{Hz}} |\mathrm{FFT}(\mathrm{env}(x) - \overline{\mathrm{env}(x)})[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
Visualisation
envelope
v5/lib/features_v5.py:336–382  ::  envelope_spectrum

def 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 slr_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
envelope_energy_bpfi Envelope energy at BPFI per-axis (×3)
Sum of squared envelope-spectrum magnitudes within ±5 Hz of 1× BPFI. The envelope spectrum demodulates the high-frequency resonance band so the bearing-defect modulation rate appears at low frequency, where the discriminative signal is sharp and easy to integrate. **This is the textbook bearing-fault feature.**
$$ E^{\mathrm{env}}_{BPFI,\,1\times} = \sum_{k:\,|f_k - f_\text{BPFI}| \le 5\,\mathrm{Hz}} |\mathrm{FFT}(\mathrm{env}(x) - \overline{\mathrm{env}(x)})[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
Visualisation
envelope
v5/lib/features_v5.py:336–382  ::  envelope_spectrum

def 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 slr_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
envelope_energy_bsf Envelope energy at BSF per-axis (×3)
Sum of squared envelope-spectrum magnitudes within ±5 Hz of 1× BSF. The envelope spectrum demodulates the high-frequency resonance band so the bearing-defect modulation rate appears at low frequency, where the discriminative signal is sharp and easy to integrate. **This is the textbook bearing-fault feature.**
$$ E^{\mathrm{env}}_{BSF,\,1\times} = \sum_{k:\,|f_k - f_\text{BSF}| \le 5\,\mathrm{Hz}} |\mathrm{FFT}(\mathrm{env}(x) - \overline{\mathrm{env}(x)})[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
Visualisation
envelope
v5/lib/features_v5.py:336–382  ::  envelope_spectrum

def 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 slr_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
envelope_energy_ftf Envelope energy at FTF per-axis (×3)
Sum of squared envelope-spectrum magnitudes within ±5 Hz of 1× FTF. The envelope spectrum demodulates the high-frequency resonance band so the bearing-defect modulation rate appears at low frequency, where the discriminative signal is sharp and easy to integrate. **This is the textbook bearing-fault feature.**
$$ E^{\mathrm{env}}_{FTF,\,1\times} = \sum_{k:\,|f_k - f_\text{FTF}| \le 5\,\mathrm{Hz}} |\mathrm{FFT}(\mathrm{env}(x) - \overline{\mathrm{env}(x)})[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
Visualisation
envelope
v5/lib/features_v5.py:336–382  ::  envelope_spectrum

def 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 slr_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
envelope_energy_2x_bpfo Envelope energy at 2× BPFO per-axis (×3)
Sum of squared envelope-spectrum magnitudes within ±3 Hz of 2× BPFO. The envelope spectrum demodulates the high-frequency resonance band so the bearing-defect modulation rate appears at low frequency, where the discriminative signal is sharp and easy to integrate. **This is the textbook bearing-fault feature.**
$$ E^{\mathrm{env}}_{BPFO,\,2\times} = \sum_{k:\,|f_k - 2\,f_\text{BPFO}| \le 3\,\mathrm{Hz}} |\mathrm{FFT}(\mathrm{env}(x) - \overline{\mathrm{env}(x)})[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
Visualisation
envelope
v5/lib/features_v5.py:336–382  ::  envelope_spectrum

def 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 slr_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
envelope_energy_2x_bpfi Envelope energy at 2× BPFI per-axis (×3)
Sum of squared envelope-spectrum magnitudes within ±3 Hz of 2× BPFI. The envelope spectrum demodulates the high-frequency resonance band so the bearing-defect modulation rate appears at low frequency, where the discriminative signal is sharp and easy to integrate. **This is the textbook bearing-fault feature.**
$$ E^{\mathrm{env}}_{BPFI,\,2\times} = \sum_{k:\,|f_k - 2\,f_\text{BPFI}| \le 3\,\mathrm{Hz}} |\mathrm{FFT}(\mathrm{env}(x) - \overline{\mathrm{env}(x)})[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
Visualisation
envelope
v5/lib/features_v5.py:336–382  ::  envelope_spectrum

def 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 slr_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
envelope_energy_2x_bsf Envelope energy at 2× BSF per-axis (×3)
Sum of squared envelope-spectrum magnitudes within ±3 Hz of 2× BSF. The envelope spectrum demodulates the high-frequency resonance band so the bearing-defect modulation rate appears at low frequency, where the discriminative signal is sharp and easy to integrate. **This is the textbook bearing-fault feature.**
$$ E^{\mathrm{env}}_{BSF,\,2\times} = \sum_{k:\,|f_k - 2\,f_\text{BSF}| \le 3\,\mathrm{Hz}} |\mathrm{FFT}(\mathrm{env}(x) - \overline{\mathrm{env}(x)})[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
Visualisation
envelope
v5/lib/features_v5.py:336–382  ::  envelope_spectrum

def 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 slr_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
envelope_energy_2x_ftf Envelope energy at 2× FTF per-axis (×3)
Sum of squared envelope-spectrum magnitudes within ±3 Hz of 2× FTF. The envelope spectrum demodulates the high-frequency resonance band so the bearing-defect modulation rate appears at low frequency, where the discriminative signal is sharp and easy to integrate. **This is the textbook bearing-fault feature.**
$$ E^{\mathrm{env}}_{FTF,\,2\times} = \sum_{k:\,|f_k - 2\,f_\text{FTF}| \le 3\,\mathrm{Hz}} |\mathrm{FFT}(\mathrm{env}(x) - \overline{\mathrm{env}(x)})[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
Visualisation
envelope
v5/lib/features_v5.py:336–382  ::  envelope_spectrum

def 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 slr_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
envelope_energy_3x_bpfo Envelope energy at 3× BPFO per-axis (×3)
Sum of squared envelope-spectrum magnitudes within ±3 Hz of 3× BPFO. The envelope spectrum demodulates the high-frequency resonance band so the bearing-defect modulation rate appears at low frequency, where the discriminative signal is sharp and easy to integrate. **This is the textbook bearing-fault feature.**
$$ E^{\mathrm{env}}_{BPFO,\,3\times} = \sum_{k:\,|f_k - 3\,f_\text{BPFO}| \le 3\,\mathrm{Hz}} |\mathrm{FFT}(\mathrm{env}(x) - \overline{\mathrm{env}(x)})[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
Visualisation
envelope
v5/lib/features_v5.py:336–382  ::  envelope_spectrum

def 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 slr_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
envelope_energy_3x_bpfi Envelope energy at 3× BPFI per-axis (×3)
Sum of squared envelope-spectrum magnitudes within ±3 Hz of 3× BPFI. The envelope spectrum demodulates the high-frequency resonance band so the bearing-defect modulation rate appears at low frequency, where the discriminative signal is sharp and easy to integrate. **This is the textbook bearing-fault feature.**
$$ E^{\mathrm{env}}_{BPFI,\,3\times} = \sum_{k:\,|f_k - 3\,f_\text{BPFI}| \le 3\,\mathrm{Hz}} |\mathrm{FFT}(\mathrm{env}(x) - \overline{\mathrm{env}(x)})[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
Visualisation
envelope
v5/lib/features_v5.py:336–382  ::  envelope_spectrum

def 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 slr_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
envelope_energy_3x_bsf Envelope energy at 3× BSF per-axis (×3)
Sum of squared envelope-spectrum magnitudes within ±3 Hz of 3× BSF. The envelope spectrum demodulates the high-frequency resonance band so the bearing-defect modulation rate appears at low frequency, where the discriminative signal is sharp and easy to integrate. **This is the textbook bearing-fault feature.**
$$ E^{\mathrm{env}}_{BSF,\,3\times} = \sum_{k:\,|f_k - 3\,f_\text{BSF}| \le 3\,\mathrm{Hz}} |\mathrm{FFT}(\mathrm{env}(x) - \overline{\mathrm{env}(x)})[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
Visualisation
envelope
v5/lib/features_v5.py:336–382  ::  envelope_spectrum

def 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 slr_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
envelope_energy_3x_ftf Envelope energy at 3× FTF per-axis (×3)
Sum of squared envelope-spectrum magnitudes within ±3 Hz of 3× FTF. The envelope spectrum demodulates the high-frequency resonance band so the bearing-defect modulation rate appears at low frequency, where the discriminative signal is sharp and easy to integrate. **This is the textbook bearing-fault feature.**
$$ E^{\mathrm{env}}_{FTF,\,3\times} = \sum_{k:\,|f_k - 3\,f_\text{FTF}| \le 3\,\mathrm{Hz}} |\mathrm{FFT}(\mathrm{env}(x) - \overline{\mathrm{env}(x)})[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, eq. 5.27 — Hilbert envelope demodulation
Visualisation
envelope
v5/lib/features_v5.py:336–382  ::  envelope_spectrum

def 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 slr_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

Optimal-band envelope defect energies (1× / 2× / 3×)

12 feature definitions

Kurtogram-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.

Concept

Same detector, smarter band

The broadband envelope group assumes the bearing's impacts ring the structure inside one fixed band. Real machines disagree: the excited resonance shifts with mounting, load and temperature, and structural modes can pollute the default band. This group re-runs the exact same envelope pipeline, but inside the band the kurtogram chose — the (centre, bandwidth) pair with maximum spectral kurtosis from a 4-level scan (windows 64/128/256/512).

In the animation, note the grey dashed rectangle (the fixed band) versus the glowing one (the selected band): when the resonance moves, the fixed band demodulates the wrong part of the spectrum while the selected band follows the impulsive energy.

Question it answersDoes the bearing evidence survive when the machine's resonance isn't where the default band expects it?
Blind spotInherits the kurtogram's failure mode: a one-off random transient during the capture can win the band vote and point the demodulator at noise for that scan.
animated · illustrative signals
Grey dashes: fixed broadband band. Glowing rectangle: kurtogram-selected band. Illustrative synthetic spectra.
optimal_env_bpfo Optimal-band envelope energy at BPFO per-axis (×3)
Same as envelope_energy_bpfo, but the bandpass band is chosen by the Fast Kurtogram (Antoni 2007) rather than the broadband CWRU default. Improves SNR when the bearing resonance frequency drifts (mounting changes, temperature, end-of-life).
$$ E^{\mathrm{opt}}_{BPFO,\,1\times} = \sum_{k:\,|f_k - f_\text{BPFO}| \le \mathrm{bw}} |\mathrm{FFT}(\mathrm{env}(x_\text{kurt-band}))[k]|^2 $$
Units
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
Visualisation
kurtogram
v5/lib/features_v5.py:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,
optimal_env_bpfi Optimal-band envelope energy at BPFI per-axis (×3)
Same as envelope_energy_bpfi, but the bandpass band is chosen by the Fast Kurtogram (Antoni 2007) rather than the broadband CWRU default. Improves SNR when the bearing resonance frequency drifts (mounting changes, temperature, end-of-life).
$$ E^{\mathrm{opt}}_{BPFI,\,1\times} = \sum_{k:\,|f_k - f_\text{BPFI}| \le \mathrm{bw}} |\mathrm{FFT}(\mathrm{env}(x_\text{kurt-band}))[k]|^2 $$
Units
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
Visualisation
kurtogram
v5/lib/features_v5.py:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,
optimal_env_bsf Optimal-band envelope energy at BSF per-axis (×3)
Same as envelope_energy_bsf, but the bandpass band is chosen by the Fast Kurtogram (Antoni 2007) rather than the broadband CWRU default. Improves SNR when the bearing resonance frequency drifts (mounting changes, temperature, end-of-life).
$$ E^{\mathrm{opt}}_{BSF,\,1\times} = \sum_{k:\,|f_k - f_\text{BSF}| \le \mathrm{bw}} |\mathrm{FFT}(\mathrm{env}(x_\text{kurt-band}))[k]|^2 $$
Units
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
Visualisation
kurtogram
v5/lib/features_v5.py:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,
optimal_env_ftf Optimal-band envelope energy at FTF per-axis (×3)
Same as envelope_energy_ftf, but the bandpass band is chosen by the Fast Kurtogram (Antoni 2007) rather than the broadband CWRU default. Improves SNR when the bearing resonance frequency drifts (mounting changes, temperature, end-of-life).
$$ E^{\mathrm{opt}}_{FTF,\,1\times} = \sum_{k:\,|f_k - f_\text{FTF}| \le \mathrm{bw}} |\mathrm{FFT}(\mathrm{env}(x_\text{kurt-band}))[k]|^2 $$
Units
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
Visualisation
kurtogram
v5/lib/features_v5.py:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,
optimal_env_2x_bpfo Optimal-band envelope energy at 2× BPFO per-axis (×3)
Same as envelope_energy_2x_bpfo, but the bandpass band is chosen by the Fast Kurtogram (Antoni 2007) rather than the broadband CWRU default. Improves SNR when the bearing resonance frequency drifts (mounting changes, temperature, end-of-life).
$$ E^{\mathrm{opt}}_{BPFO,\,2\times} = \sum_{k:\,|f_k - 2\,f_\text{BPFO}| \le \mathrm{bw}} |\mathrm{FFT}(\mathrm{env}(x_\text{kurt-band}))[k]|^2 $$
Units
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
Visualisation
kurtogram
v5/lib/features_v5.py:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,
optimal_env_2x_bpfi Optimal-band envelope energy at 2× BPFI per-axis (×3)
Same as envelope_energy_2x_bpfi, but the bandpass band is chosen by the Fast Kurtogram (Antoni 2007) rather than the broadband CWRU default. Improves SNR when the bearing resonance frequency drifts (mounting changes, temperature, end-of-life).
$$ E^{\mathrm{opt}}_{BPFI,\,2\times} = \sum_{k:\,|f_k - 2\,f_\text{BPFI}| \le \mathrm{bw}} |\mathrm{FFT}(\mathrm{env}(x_\text{kurt-band}))[k]|^2 $$
Units
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
Visualisation
kurtogram
v5/lib/features_v5.py:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,
optimal_env_2x_bsf Optimal-band envelope energy at 2× BSF per-axis (×3)
Same as envelope_energy_2x_bsf, but the bandpass band is chosen by the Fast Kurtogram (Antoni 2007) rather than the broadband CWRU default. Improves SNR when the bearing resonance frequency drifts (mounting changes, temperature, end-of-life).
$$ E^{\mathrm{opt}}_{BSF,\,2\times} = \sum_{k:\,|f_k - 2\,f_\text{BSF}| \le \mathrm{bw}} |\mathrm{FFT}(\mathrm{env}(x_\text{kurt-band}))[k]|^2 $$
Units
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
Visualisation
kurtogram
v5/lib/features_v5.py:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,
optimal_env_2x_ftf Optimal-band envelope energy at 2× FTF per-axis (×3)
Same as envelope_energy_2x_ftf, but the bandpass band is chosen by the Fast Kurtogram (Antoni 2007) rather than the broadband CWRU default. Improves SNR when the bearing resonance frequency drifts (mounting changes, temperature, end-of-life).
$$ E^{\mathrm{opt}}_{FTF,\,2\times} = \sum_{k:\,|f_k - 2\,f_\text{FTF}| \le \mathrm{bw}} |\mathrm{FFT}(\mathrm{env}(x_\text{kurt-band}))[k]|^2 $$
Units
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
Visualisation
kurtogram
v5/lib/features_v5.py:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,
optimal_env_3x_bpfo Optimal-band envelope energy at 3× BPFO per-axis (×3)
Same as envelope_energy_3x_bpfo, but the bandpass band is chosen by the Fast Kurtogram (Antoni 2007) rather than the broadband CWRU default. Improves SNR when the bearing resonance frequency drifts (mounting changes, temperature, end-of-life).
$$ E^{\mathrm{opt}}_{BPFO,\,3\times} = \sum_{k:\,|f_k - 3\,f_\text{BPFO}| \le \mathrm{bw}} |\mathrm{FFT}(\mathrm{env}(x_\text{kurt-band}))[k]|^2 $$
Units
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
Visualisation
kurtogram
v5/lib/features_v5.py:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,
optimal_env_3x_bpfi Optimal-band envelope energy at 3× BPFI per-axis (×3)
Same as envelope_energy_3x_bpfi, but the bandpass band is chosen by the Fast Kurtogram (Antoni 2007) rather than the broadband CWRU default. Improves SNR when the bearing resonance frequency drifts (mounting changes, temperature, end-of-life).
$$ E^{\mathrm{opt}}_{BPFI,\,3\times} = \sum_{k:\,|f_k - 3\,f_\text{BPFI}| \le \mathrm{bw}} |\mathrm{FFT}(\mathrm{env}(x_\text{kurt-band}))[k]|^2 $$
Units
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
Visualisation
kurtogram
v5/lib/features_v5.py:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,
optimal_env_3x_bsf Optimal-band envelope energy at 3× BSF per-axis (×3)
Same as envelope_energy_3x_bsf, but the bandpass band is chosen by the Fast Kurtogram (Antoni 2007) rather than the broadband CWRU default. Improves SNR when the bearing resonance frequency drifts (mounting changes, temperature, end-of-life).
$$ E^{\mathrm{opt}}_{BSF,\,3\times} = \sum_{k:\,|f_k - 3\,f_\text{BSF}| \le \mathrm{bw}} |\mathrm{FFT}(\mathrm{env}(x_\text{kurt-band}))[k]|^2 $$
Units
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
Visualisation
kurtogram
v5/lib/features_v5.py:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,
optimal_env_3x_ftf Optimal-band envelope energy at 3× FTF per-axis (×3)
Same as envelope_energy_3x_ftf, but the bandpass band is chosen by the Fast Kurtogram (Antoni 2007) rather than the broadband CWRU default. Improves SNR when the bearing resonance frequency drifts (mounting changes, temperature, end-of-life).
$$ E^{\mathrm{opt}}_{FTF,\,3\times} = \sum_{k:\,|f_k - 3\,f_\text{FTF}| \le \mathrm{bw}} |\mathrm{FFT}(\mathrm{env}(x_\text{kurt-band}))[k]|^2 $$
Units
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
Visualisation
kurtogram
v5/lib/features_v5.py:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,

DRS + squared envelope, broadband (new)

4 feature definitions

Twelve recently-added 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.

Concept

DRS: subtract the predictable, keep the bearing

Gears and shafts are deterministic: mesh tones and shaft harmonics repeat identically every revolution, and on an industrial machine they usually dwarf the bearing signature. Rolling elements, though, slip — a bearing's impact train wanders by a percent or two, making it slightly random. DRS (discrete/random separation) exploits exactly that: it predicts the predictable part of the signal from its own past and subtracts it. Whatever survives is the random part — which is where the bearing lives.

The residual then gets the squared-envelope treatment, and these features read the defect-rate energies out of it. It's the bearing detector of choice on any machine with gear-mesh or strong shaft-harmonic structure — that is, most real installations.

Question it answersIs a bearing tone hiding underneath gear-mesh and shaft harmonics that dominate the ordinary spectrum?
Blind spotAnything phase-locked to the shaft is stripped along with the gears — a defect that IS deterministic (shaft crack, coupling fault) must be read from the harmonic and order groups instead.
animated · illustrative signals
The animation alternates raw spectrum ↔ DRS residual: the tall deterministic tones melt away, the small bearing comb stays. Illustrative synthetic spectra.
slr_sq_env_bpfo DRS squared-envelope energy at BPFO Newper-axis (×3)
new. Discrete/Random Separation strips gear-mesh and shaft-harmonic deterministic content before envelope analysis. The envelope is then squared (Randall Fig 5.39) which prevents aliasing of the magnitude operator and concentrates fault energy at modulation lines. Final energy is summed at 1× BPFO with bandwidth 5 Hz.
$$ x_\text{DRS} = \mathrm{DRS}(x)\;,\;\; e^2 = (\mathrm{env}(x_\text{DRS}))^2 - \overline{(\mathrm{env}(x_\text{DRS}))^2}\;,\;\;E^{\mathrm{DRS,sq}}_{BPFO,\,1\times} = \sum_k |\mathrm{FFT}(e^2)[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
Visualisation
drs
Notes: DRS algorithm: RFFT(x) → for each bin compare against local noise floor (median of ±11 neighbouring bins); bins exceeding τ=3.0× floor are scaled down to floor (kills line-spectrum content, preserves random + cyclostationary). IRFFT → residual. Per Randall Fig 5.52 this took kurtosis 8.4 → 64.9 on a real bearing recording dominated by gear-mesh.
v5/lib/features_v5.py:489–499  ::  drs_squared_envelope_features

def drs_squared_envelope_features(*args, **kwargs):
    """DEPRECATED alias for ``slr_squared_envelope_features``.

    The original name "DRS" misleadingly implied Sawalhi-Randall Discrete/Random
    Separation (2008). What this function actually applies is spectral line
    removal (median-of-neighbour-bins comb filter). The keys were renamed in
    ``drs_sq_*`` to ``slr_sq_*`` for honesty. Use the new name in new
    code. This alias kept temporarily to avoid breaking external callers; will
    be removed in V6.
slr_sq_env_bpfi DRS squared-envelope energy at BPFI Newper-axis (×3)
new. Discrete/Random Separation strips gear-mesh and shaft-harmonic deterministic content before envelope analysis. The envelope is then squared (Randall Fig 5.39) which prevents aliasing of the magnitude operator and concentrates fault energy at modulation lines. Final energy is summed at 1× BPFI with bandwidth 5 Hz.
$$ x_\text{DRS} = \mathrm{DRS}(x)\;,\;\; e^2 = (\mathrm{env}(x_\text{DRS}))^2 - \overline{(\mathrm{env}(x_\text{DRS}))^2}\;,\;\;E^{\mathrm{DRS,sq}}_{BPFI,\,1\times} = \sum_k |\mathrm{FFT}(e^2)[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
Visualisation
drs
Notes: DRS algorithm: RFFT(x) → for each bin compare against local noise floor (median of ±11 neighbouring bins); bins exceeding τ=3.0× floor are scaled down to floor (kills line-spectrum content, preserves random + cyclostationary). IRFFT → residual. Per Randall Fig 5.52 this took kurtosis 8.4 → 64.9 on a real bearing recording dominated by gear-mesh.
v5/lib/features_v5.py:489–499  ::  drs_squared_envelope_features

def drs_squared_envelope_features(*args, **kwargs):
    """DEPRECATED alias for ``slr_squared_envelope_features``.

    The original name "DRS" misleadingly implied Sawalhi-Randall Discrete/Random
    Separation (2008). What this function actually applies is spectral line
    removal (median-of-neighbour-bins comb filter). The keys were renamed in
    ``drs_sq_*`` to ``slr_sq_*`` for honesty. Use the new name in new
    code. This alias kept temporarily to avoid breaking external callers; will
    be removed in V6.
slr_sq_env_bsf DRS squared-envelope energy at BSF Newper-axis (×3)
new. Discrete/Random Separation strips gear-mesh and shaft-harmonic deterministic content before envelope analysis. The envelope is then squared (Randall Fig 5.39) which prevents aliasing of the magnitude operator and concentrates fault energy at modulation lines. Final energy is summed at 1× BSF with bandwidth 5 Hz.
$$ x_\text{DRS} = \mathrm{DRS}(x)\;,\;\; e^2 = (\mathrm{env}(x_\text{DRS}))^2 - \overline{(\mathrm{env}(x_\text{DRS}))^2}\;,\;\;E^{\mathrm{DRS,sq}}_{BSF,\,1\times} = \sum_k |\mathrm{FFT}(e^2)[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
Visualisation
drs
Notes: DRS algorithm: RFFT(x) → for each bin compare against local noise floor (median of ±11 neighbouring bins); bins exceeding τ=3.0× floor are scaled down to floor (kills line-spectrum content, preserves random + cyclostationary). IRFFT → residual. Per Randall Fig 5.52 this took kurtosis 8.4 → 64.9 on a real bearing recording dominated by gear-mesh.
v5/lib/features_v5.py:489–499  ::  drs_squared_envelope_features

def drs_squared_envelope_features(*args, **kwargs):
    """DEPRECATED alias for ``slr_squared_envelope_features``.

    The original name "DRS" misleadingly implied Sawalhi-Randall Discrete/Random
    Separation (2008). What this function actually applies is spectral line
    removal (median-of-neighbour-bins comb filter). The keys were renamed in
    ``drs_sq_*`` to ``slr_sq_*`` for honesty. Use the new name in new
    code. This alias kept temporarily to avoid breaking external callers; will
    be removed in V6.
slr_sq_env_ftf DRS squared-envelope energy at FTF Newper-axis (×3)
new. Discrete/Random Separation strips gear-mesh and shaft-harmonic deterministic content before envelope analysis. The envelope is then squared (Randall Fig 5.39) which prevents aliasing of the magnitude operator and concentrates fault energy at modulation lines. Final energy is summed at 1× FTF with bandwidth 5 Hz.
$$ x_\text{DRS} = \mathrm{DRS}(x)\;,\;\; e^2 = (\mathrm{env}(x_\text{DRS}))^2 - \overline{(\mathrm{env}(x_\text{DRS}))^2}\;,\;\;E^{\mathrm{DRS,sq}}_{FTF,\,1\times} = \sum_k |\mathrm{FFT}(e^2)[k]|^2 $$
Units
Textbook
Randall 2011, §5.5, p. 200–215, Fig 5.39 — Squared envelope (variant) prevents aliasing of |·| operator
Visualisation
drs
Notes: DRS algorithm: RFFT(x) → for each bin compare against local noise floor (median of ±11 neighbouring bins); bins exceeding τ=3.0× floor are scaled down to floor (kills line-spectrum content, preserves random + cyclostationary). IRFFT → residual. Per Randall Fig 5.52 this took kurtosis 8.4 → 64.9 on a real bearing recording dominated by gear-mesh.
v5/lib/features_v5.py:489–499  ::  drs_squared_envelope_features

def drs_squared_envelope_features(*args, **kwargs):
    """DEPRECATED alias for ``slr_squared_envelope_features``.

    The original name "DRS" misleadingly implied Sawalhi-Randall Discrete/Random
    Separation (2008). What this function actually applies is spectral line
    removal (median-of-neighbour-bins comb filter). The keys were renamed in
    ``drs_sq_*`` to ``slr_sq_*`` for honesty. Use the new name in new
    code. This alias kept temporarily to avoid breaking external callers; will
    be removed in V6.

Shaft harmonics

3 feature definitions

Energy at 1×, 2×, and 3× the shaft rotation rate — the three canonical rotor-dynamics-fault scalars. The relative magnitudes of the three harmonics encode the classical Eshleman diagnostic chart: 1× dominant → imbalance; 2× comparable to or greater than 1× → misalignment; 3× exceeding both → looseness. Computed by integrating |FFT|² in a ±3 Hz band around each integer multiple of the shaft frequency.

Brandt §6.4 + Eshleman 1999. The three canonical imbalance/misalignment/looseness indicators in rotating-machinery vibration, valid for any motor, pump, fan, or compressor with a known shaft RPM.

Concept

1× / 2× / 3×: the rotor-dynamics fingerprint

The lowest three multiples of shaft speed encode the classical rotating-machinery diagnosis chart. A rotating heavy spot pulls outward once per revolution — 1× dominant → imbalance. A misaligned coupling flexes twice per revolution as its jaws trade load — 2× rivalling 1× → misalignment. Loose fits let parts rattle and clip the waveform, spraying energy into 3× and a raised harmonic floor → looseness.

The engine integrates |FFT|² in a ±3 Hz band around each multiple and reads the pattern, not the absolute level — the ratios survive changes in mounting and sensor sensitivity that would fool a raw amplitude check.

Question it answersIs the rotor system itself — balance, alignment, fits — the problem, and which of the three classic causes fits the pattern?
Blind spotSays nothing about bearings (their rates are non-integer multiples), and the whole chart silently mis-reads if the shaft-rate estimate feeding it is wrong.
animated · illustrative signals
The Eshleman-chart patterns morph as you switch cases. Illustrative synthetic spectra.
harmonic_1x 1× shaft harmonic per-axis (×3)
Energy in a ±3 Hz band around 1× the shaft rotation rate — the rotor-imbalance signature. A perfectly balanced rotating mass centred on the shaft axis produces zero force on the bearings; any radial mass distribution offset (a chip on a blade, a worn coupling, a fan with dirty buildup) creates a sinusoidal radial force at the shaft frequency that transmits to the bearings as a clean 1× peak. A high 1× peak that ALSO has a low 2× peak (harmonic_1x ≫ harmonic_2x) is the textbook diagnosis of imbalance; if 2× is comparable to or larger than 1× the cause is more likely misalignment or looseness. ISO 10816 broadband velocity zoning will alarm on a severe 1× imbalance once it raises the integrated velocity past the Class II/IV threshold.
$$ E_{1\times} = \sum_{k: |f_k - 1\,f_\text{shaft}| \le \mathrm{bw}} |X[k]|^2 $$
Units
Textbook
Brandt 2011, §6.4 — Harmonic energy at integer multiples of shaft rotation rate
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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
harmonic_2x 2× shaft harmonic per-axis (×3)
Energy in a ±3 Hz band around 2× shaft — the misalignment signature. A misaligned coupling forces the shaft to traverse a non-circular path inside the bearing, producing a TWICE-PER-REVOLUTION radial force component. Read together with 1×: when harmonic_2x is comparable to or larger than harmonic_1x, misalignment is the diagnosis. The MIL-HDBK-2199 and Eshleman criteria suggest harmonic_2x / harmonic_1x > 0.5 as the threshold for parallel misalignment, > 1.0 for angular. Strong 2× also appears under bent-shaft and worn-coupling conditions, so the diagnosis is confirmed by phase analysis when available.
$$ E_{2\times} = \sum_{k: |f_k - 2\,f_\text{shaft}| \le \mathrm{bw}} |X[k]|^2 $$
Units
Textbook
Brandt 2011, §6.4 — Harmonic energy at integer multiples of shaft rotation rate
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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
harmonic_3x 3× shaft harmonic per-axis (×3)
Energy in a ±3 Hz band around 3× shaft. A 3× peak that exceeds 1× and 2× indicates mechanical LOOSENESS at the bearing or foundation interface — the loose component impacts the housing with each rotation but the timing is irregular enough that the spectrum smears across 1×, 2×, AND 3× rather than concentrating at one. Severe misalignment can also produce 3× alongside the dominant 2×. The diagnosis is reinforced when 3× is accompanied by a noise-floor lift around the harmonics (irregular looseness broadens each tone) and when band_energy_low is anomalously high.
$$ E_{3\times} = \sum_{k: |f_k - 3\,f_\text{shaft}| \le \mathrm{bw}} |X[k]|^2 $$
Units
Textbook
Brandt 2011, §6.4 — Harmonic energy at integer multiples of shaft rotation rate
Visualisation
spectrum
v5/lib/features_v5.py:828–836  ::  _energy_near    """
    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

Cepstrum analysis

4 feature definitions

The 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.

Concept

Cepstrum: a whole harmonic family in one number

A periodic defect never makes just one spectral line — it makes a comb: 1×, 2×, 3×… of its rate, often with sidebands around every tooth. Counting comb teeth by hand is analyst work. The cepstrum — the spectrum of the log-spectrum — does it in one transform: any evenly-spaced family in the spectrum, no matter how many teeth carry it, collapses into a single peak at the quefrency 1/f.

The peak's position names the strongest repeat rate in the machine; its height says how coherent the family is. The engine compares the derived rate against the bearing defect frequencies to produce the categorical match feature — one robust number that gets stronger as harmonics multiply, exactly when individual lines get harder to read.

Question it answersWhat is the strongest periodic family in the whole spectrum, expressed as one number — and does its rate match a bearing defect?
Blind spotIt reports the rate, not where in frequency the family lives; two families at similar rates blur together, and a single lonely tone (no harmonics) barely registers.
animated · illustrative signals
Left: harmonics appear one by one. Right: they all feed ONE cepstral peak. Sidebands add a second peak at their own spacing. Illustrative synthetic spectra.
cepstrum_peak_quefrency Cepstrum peak quefrency per-axis (×3)
Quefrency — the time-domain reciprocal-frequency variable of the cepstrum — at which the maximum cepstrum amplitude occurs, searched in the band 2 ms ≤ τ ≤ 100 ms (corresponding to modulation frequencies 10–500 Hz, the band where bearing defect impact trains live). The trivial near-DC peaks at τ → 0 are excluded by the lower bound. Conceptually, this is the period of the strongest harmonic-series structure in the log spectrum — for a healthy machine it tends to track 1/f_shaft (rotor harmonics) and for a bearing-defective machine it migrates toward 1/f_BPFO, 1/f_BPFI, etc.
$$ \tau_\text{peak} = \arg\max_{\tau > \tau_\text{min}} |\mathcal{C}(\tau)|\;,\;\; \mathcal{C} = \mathrm{IFFT}(\log|X|^2) $$
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:552–578  ::  compute_cepstrum        if len(signal) < DEFAULT_SUB_WINDOW + DEFAULT_HOP:
            kwargs = {"sub_window": SHORT_SUB_WINDOW, "hop": DEFAULT_HOP}
        result = tachless_cot(signal, float(fs),
                              approximate_rpm=float(approximate_rpm), **kwargs)
    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


def compute_cepstrum(
    signal: np.ndarray,
    fs: int = CWRU_FS,
) -> tuple[np.ndarray, np.ndarray]:
    """
    Compute the real cepstrum of a signal (Randall Ch. 8).
cepstrum_peak_magnitude Cepstrum peak magnitude per-axis (×3)
Amplitude of the cepstrum at the peak quefrency. Proportional to the strength of the periodic-impact modulation in the log magnitude spectrum: a strong cepstrum peak means the source spectrum contains a well-defined harmonic series with a common fundamental — characteristic of either a strong rotor-rate signature or a localised bearing impact train. Low values (< 0.05) mean the spectrum has no significant harmonic structure; high values (> 0.3) reliably indicate a periodic-impact source whose period is the corresponding peak_quefrency.
$$ |\mathcal{C}(\tau_\text{peak})| $$
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:552–578  ::  compute_cepstrum        if len(signal) < DEFAULT_SUB_WINDOW + DEFAULT_HOP:
            kwargs = {"sub_window": SHORT_SUB_WINDOW, "hop": DEFAULT_HOP}
        result = tachless_cot(signal, float(fs),
                              approximate_rpm=float(approximate_rpm), **kwargs)
    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


def compute_cepstrum(
    signal: np.ndarray,
    fs: int = CWRU_FS,
) -> tuple[np.ndarray, np.ndarray]:
    """
    Compute the real cepstrum of a signal (Randall Ch. 8).
cepstrum_peak_freq Cepstrum-derived frequency per-axis (×3)
Inverse of the peak quefrency, expressed back as a frequency in Hz. The 'fundamental frequency' the cepstrum has detected. For a bearing with a localised defect this should match the corresponding defect frequency (BPFO, BPFI, BSF, FTF) within ~10%; for a balanced healthy machine it usually matches the shaft rotation rate. When peak_freq doesn't line up with any known mechanical frequency the cepstrum has likely locked onto noise or onto a sub-harmonic of a defect — read together with cepstrum_defect_match for the categorised diagnosis.
$$ f_\text{peak} = 1\,/\,\tau_\text{peak} $$
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:552–578  ::  compute_cepstrum        if len(signal) < DEFAULT_SUB_WINDOW + DEFAULT_HOP:
            kwargs = {"sub_window": SHORT_SUB_WINDOW, "hop": DEFAULT_HOP}
        result = tachless_cot(signal, float(fs),
                              approximate_rpm=float(approximate_rpm), **kwargs)
    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


def compute_cepstrum(
    signal: np.ndarray,
    fs: int = CWRU_FS,
) -> tuple[np.ndarray, np.ndarray]:
    """
    Compute the real cepstrum of a signal (Randall Ch. 8).
cepstrum_defect_match Cepstrum defect match (categorical) per-axis (×3)
Integer in {0..4}: 0=none, 1=bpfo, 2=bpfi, 3=bsf, 4=ftf. Set when peak_quefrency × defect_freq is within ±10% of an integer K ∈ [1, 5] — i.e. the cepstrum found the K-th rahmonic of the defect-modulation train.
$$ \text{match} = \arg\min_d \min_{K \in [1,5]} \left|\dfrac{1}{\tau_\text{peak} \cdot f_d} - K\right|\;\;\text{if}\;\; \dots < 0.10,\;\text{else}\;0 $$
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
Notes: Categorical — _coerce_feature in build_v5_npz.py maps string→int identically across training and inference. Contract test guards this mapping.
v5/lib/features_v5.py:552–578  ::  compute_cepstrum        if len(signal) < DEFAULT_SUB_WINDOW + DEFAULT_HOP:
            kwargs = {"sub_window": SHORT_SUB_WINDOW, "hop": DEFAULT_HOP}
        result = tachless_cot(signal, float(fs),
                              approximate_rpm=float(approximate_rpm), **kwargs)
    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


def compute_cepstrum(
    signal: np.ndarray,
    fs: int = CWRU_FS,
) -> tuple[np.ndarray, np.ndarray]:
    """
    Compute the real cepstrum of a signal (Randall Ch. 8).

Spectral kurtosis + Kurtogram

5 feature definitions

Antoni'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.

Concept

Spectral kurtosis: WHERE in frequency to listen

Envelope analysis needs a band to demodulate, and the loudest band is usually the wrong answer — steady tones are loud but carry no ticks. Spectral kurtosis asks a sharper question of every band: is the energy here impulsive? A band that rings at every bearing impact flickers in time and scores high SK; a gear tone that hums steadily scores near zero no matter how loud it is.

The Fast Kurtogram scans window sizes 64/128/256/512 to trade frequency against time resolution, and outputs the (centre, bandwidth) pair with maximum SK. That pair becomes the demodulation band for the optimal-band envelope group — the engine's answer to "every machine rings in a different place".

Question it answersWHERE in the spectrum do the impacts live — which band should the envelope detector demodulate on this particular machine?
Blind spotImpulsiveness is not fault-ness: a one-off external knock during the capture scores high SK too, and a genuinely faulty but steady tone scores zero.
animated · illustrative signals
Top: the power spectrum (what's loud). Bottom: SK (what's impulsive). The band settles where SK peaks, not where power peaks. Illustrative synthetic spectra.
sk_max Max spectral kurtosis per-axis (×3)
Maximum spectral kurtosis across all frequency bins of the default-window STFT (window=256, overlap=192 i.e. 75%, hann), excluding bins below 50 Hz. SK measures how 'non-Gaussian' the STFT envelope is at each frequency: a stationary tone or random noise gives SK ≈ 0; impulsive energy at a particular frequency (the bearing's housing resonance, excited by repeated impacts) drives SK well above 0. sk_max tracks the strongest impulsive frequency in the spectrum. A healthy bearing typically sits at sk_max < 1; advanced damage with a well-defined resonance produces sk_max > 5 — Antoni's textbook threshold for 'kurtogram diagnostically useful'.
$$ \text{SK}_\text{max} = \max_{k: f_k \ge 50\,\text{Hz}}\left[\dfrac{\mathbb{E}\,[|X(t,f_k)|^4]}{\mathbb{E}\,[|X(t,f_k)|^2]^2} - 2\right] $$
Units
dimensionless
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
v5/lib/features_v5.py:581–623  ::  compute_spectral_kurtosis    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)


def 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 feed the complex
    STFT output (scipy.signal.stft) and take |Zxx|² as power. For a white-
sk_mean Mean spectral kurtosis per-axis (×3)
Mean spectral kurtosis across the same bins used for sk_max. Acts as a smoother, less-spiky counterpart: sk_mean rises when many bins are impulsive (broadband bearing damage), whereas sk_max can be high just because one narrow band is impulsive (e.g. a single housing resonance excited by an isolated impact). The ratio sk_max / sk_mean is informative — low ratio means the impulsiveness is broadband (advanced uniform pitting); high ratio means a single narrow band is dominant (early localised defect with a clear resonance).
$$ \overline{\text{SK}} = \dfrac{1}{|\mathcal{K}|}\sum_{k \in \mathcal{K}}\text{SK}[k] $$
Units
dimensionless
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
v5/lib/features_v5.py:581–623  ::  compute_spectral_kurtosis    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)


def 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 feed the complex
    STFT output (scipy.signal.stft) and take |Zxx|² as power. For a white-
optimal_band_center Optimal band — centre frequency per-axis (×3)
Centre frequency of the (centre, bandwidth) pair with maximum spectral kurtosis across a 4-level kurtogram scan (window sizes 64/128/256/512 → bandwidth grid). The chosen frequency is the bearing's housing resonance — the band where the machine's mechanical impedance amplifies bearing impact transients into a detectable signal. The envelope-spectrum demodulation in `defect_envelope_optimal` and the V5 DRS-squared-envelope features in `drs_sq_env_optimal` BOTH use this band rather than a fixed 2-5 kHz default, so the optimal_band_center value indirectly drives 24 downstream feature values per axis.
$$ f_\text{opt} = \mathrm{argmax}_{(f_c, \mathrm{bw})}\,\text{SK}(f_c, \mathrm{bw}) $$
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:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,
optimal_band_bw Optimal band — bandwidth per-axis (×3)
Bandwidth of the kurtogram-selected demodulation band. Smaller bandwidths are preferred when the bearing's housing resonance is sharp (high Q-factor, found on stiffer installations); larger bandwidths get picked when the resonance is broad (softer mounts, larger housings). The value is one of the 4 kurtogram scales (64-, 128-, 256-, 512-sample STFT → bandwidth = fs/64, fs/128, fs/256, fs/512). Together with optimal_band_center it defines the bandpass band that the downstream envelope-demodulation features integrate over.
$$ \mathrm{bw}_\text{opt} $$
Units
Hz
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
v5/lib/features_v5.py:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,
optimal_band_sk Optimal band — SK per-axis (×3)
Spectral kurtosis value AT the kurtogram-selected band. Acts as a confidence score for the demodulation choice: a high optimal_band_sk (> 5) means the kurtogram found a clear, well-defined impulsive band that justifies envelope demodulation in that range; a low value (< 1) means the kurtogram couldn't find a clearly impulsive band and the downstream optimal_env_* features are unreliable. The verdict engine reads this as a gate on the kurtogram-band-specific defect-frequency features.
$$ \text{SK}_\text{opt} = \max_{(f_c,\,\mathrm{bw})}\,\text{SK} $$
Units
dimensionless
Textbook
Antoni 2007, MSSP 21(1), p. 108–124 — Fast Kurtogram for optimal demodulation band selection
v5/lib/features_v5.py:736–749  ::  find_optimal_demod_band        # band-integrated kurtogram band width at this STFT level.
        bin_spacing = float(fs) / float(win)
        eff_bw = _SK_EFFECTIVE_BW_BINS * bin_spacing

        if peak_sk > best_sk:
            best_sk = peak_sk
            best_center = peak_freq
            best_bw = eff_bw

    band_low = max(best_center - best_bw / 2, 50.0)
    band_high = min(best_center + best_bw / 2, fs / 2 - 50.0)

    return {
        "center_freq": best_center,

Order spectrum analysis

23 feature definitions

Twenty-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).

Concept

Orders: divide out the speed, keep the physics

Every diagnostic rate in a machine scales with shaft speed — so on a variable-speed drive, spectral peaks wander and smear, and a ±3% speed drift can spread a defect tone across a dozen FFT bins until it disappears into the floor. Order analysis rescales the frequency axis by the shaft rate: the abscissa becomes "multiples of one revolution". On that axis the physics stands still — 1× is always at order 1, and each bearing defect sits at its fixed geometric order, whatever the speed is doing.

The 23 features here read shaft orders 1×–10×, the four defect orders, their sidebands, and summary scalars (dominant order, sub-synchronous energy, 1×/2× ratio) off that speed-normalised spectrum.

Question it answersDo the peaks sit at integer multiples of the shaft — and at the bearing defect orders — regardless of what the speed is doing?
Blind spotEverything hangs on the shaft-rate estimate: an error of a few percent mis-bins every order and the comparison quietly degrades.
animated · illustrative signals
Top: Hz axis — peaks smear across the shaded band as speed moves. Bottom: order axis — the same peaks locked in place. Illustrative synthetic spectra.
order_1x_energy Order spectrum energy — order 1 per-axis (×3)
Energy in a ±0.15-order band around order 1 of the order-tracked spectrum. The order spectrum is the FFT magnitude with frequency axis rescaled by 1/f_shaft, so the abscissa becomes 'orders of shaft' — independent of RPM drift. This is the order-domain counterpart of harmonic_1x and is the preferred feature for variable-speed machines (VFD-driven motors, wind turbines, locomotives) where the Hz value of the 1× peak would otherwise drift with the operating point. Order 1 = imbalance signature in the order domain. Equivalent to harmonic_1x but expressed in shaft revolutions per spectrum cycle, so the value is identical whether the machine is running at 1500 RPM or 3600 RPM — invariant to operating-point shifts.
$$ E^{\mathrm{ord}}_{1\times} = \sum_{k:|\,o_k - 1|\,\le \mathrm{bw}} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_2x_energy Order spectrum energy — order 2 per-axis (×3)
Energy in a ±0.15-order band around order 2 of the order-tracked spectrum. The order spectrum is the FFT magnitude with frequency axis rescaled by 1/f_shaft, so the abscissa becomes 'orders of shaft' — independent of RPM drift. This is the order-domain counterpart of harmonic_2x and is the preferred feature for variable-speed machines (VFD-driven motors, wind turbines, locomotives) where the Hz value of the 2× peak would otherwise drift with the operating point. Order 2 = misalignment signature. The order-tracked counterpart of harmonic_2x; high 2× energy relative to 1× (order_1x_2x_ratio < 1) indicates parallel or angular misalignment at the coupling.
$$ E^{\mathrm{ord}}_{2\times} = \sum_{k:|\,o_k - 2|\,\le \mathrm{bw}} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_3x_energy Order spectrum energy — order 3 per-axis (×3)
Energy in a ±0.15-order band around order 3 of the order-tracked spectrum. The order spectrum is the FFT magnitude with frequency axis rescaled by 1/f_shaft, so the abscissa becomes 'orders of shaft' — independent of RPM drift. This is the order-domain counterpart of harmonic_3x and is the preferred feature for variable-speed machines (VFD-driven motors, wind turbines, locomotives) where the Hz value of the 3× peak would otherwise drift with the operating point. Order 3 = severe misalignment or shaft looseness. Order 3 rising above order 2 typically means the misaligned coupling has progressed from elastic deformation into mechanical impact each rotation.
$$ E^{\mathrm{ord}}_{3\times} = \sum_{k:|\,o_k - 3|\,\le \mathrm{bw}} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_4x_energy Order spectrum energy — order 4 per-axis (×3)
Energy in a ±0.15-order band around order 4 of the order-tracked spectrum. The order spectrum is the FFT magnitude with frequency axis rescaled by 1/f_shaft, so the abscissa becomes 'orders of shaft' — independent of RPM drift. This is the order-domain counterpart of harmonic_4x and is the preferred feature for variable-speed machines (VFD-driven motors, wind turbines, locomotives) where the Hz value of the 4× peak would otherwise drift with the operating point. Order 4–10 carry sub-percent fractions of total energy on a healthy machine. Their values are reported individually so the ML model can detect harmonic-comb patterns — e.g. a strong order-4 alongside order-2 and -3 hints at a 4-vane impeller in distress.
$$ E^{\mathrm{ord}}_{4\times} = \sum_{k:|\,o_k - 4|\,\le \mathrm{bw}} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_5x_energy Order spectrum energy — order 5 per-axis (×3)
Energy in a ±0.15-order band around order 5 of the order-tracked spectrum. The order spectrum is the FFT magnitude with frequency axis rescaled by 1/f_shaft, so the abscissa becomes 'orders of shaft' — independent of RPM drift. This is the order-domain counterpart of harmonic_5x and is the preferred feature for variable-speed machines (VFD-driven motors, wind turbines, locomotives) where the Hz value of the 5× peak would otherwise drift with the operating point. Order 5 = nominal blade-pass on a 5-vane impeller, low-order diagnostic for fan/pump damage. On a non-bladed machine, order 5 rising without companions at 4× or 6× typically points to a manufacturing-tolerance harmonic of the gear-mesh.
$$ E^{\mathrm{ord}}_{5\times} = \sum_{k:|\,o_k - 5|\,\le \mathrm{bw}} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_6x_energy Order spectrum energy — order 6 per-axis (×3)
Energy in a ±0.15-order band around order 6 of the order-tracked spectrum. The order spectrum is the FFT magnitude with frequency axis rescaled by 1/f_shaft, so the abscissa becomes 'orders of shaft' — independent of RPM drift. This is the order-domain counterpart of harmonic_6x and is the preferred feature for variable-speed machines (VFD-driven motors, wind turbines, locomotives) where the Hz value of the 6× peak would otherwise drift with the operating point. Order 6 = typical blade-pass for a 6-vane impeller. On gearboxes with a 6:1 reduction ratio, order 6 reflects the meshing rate; on all other machines it is part of a higher-harmonic continuum.
$$ E^{\mathrm{ord}}_{6\times} = \sum_{k:|\,o_k - 6|\,\le \mathrm{bw}} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_7x_energy Order spectrum energy — order 7 per-axis (×3)
Energy in a ±0.15-order band around order 7 of the order-tracked spectrum. The order spectrum is the FFT magnitude with frequency axis rescaled by 1/f_shaft, so the abscissa becomes 'orders of shaft' — independent of RPM drift. This is the order-domain counterpart of harmonic_7x and is the preferred feature for variable-speed machines (VFD-driven motors, wind turbines, locomotives) where the Hz value of the 7× peak would otherwise drift with the operating point. Order 7 — interpreted in context with neighbouring orders. A strong order-7 alone usually means a 7-vane impeller; otherwise it is a high-frequency tail of the rotor-dynamics harmonic series.
$$ E^{\mathrm{ord}}_{7\times} = \sum_{k:|\,o_k - 7|\,\le \mathrm{bw}} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_8x_energy Order spectrum energy — order 8 per-axis (×3)
Energy in a ±0.15-order band around order 8 of the order-tracked spectrum. The order spectrum is the FFT magnitude with frequency axis rescaled by 1/f_shaft, so the abscissa becomes 'orders of shaft' — independent of RPM drift. This is the order-domain counterpart of harmonic_8x and is the preferred feature for variable-speed machines (VFD-driven motors, wind turbines, locomotives) where the Hz value of the 8× peak would otherwise drift with the operating point. Order 8 — same diagnostic logic as orders 5–7: usually a blade-pass for an 8-vane impeller, otherwise part of the rotor-dynamics tail.
$$ E^{\mathrm{ord}}_{8\times} = \sum_{k:|\,o_k - 8|\,\le \mathrm{bw}} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_9x_energy Order spectrum energy — order 9 per-axis (×3)
Energy in a ±0.15-order band around order 9 of the order-tracked spectrum. The order spectrum is the FFT magnitude with frequency axis rescaled by 1/f_shaft, so the abscissa becomes 'orders of shaft' — independent of RPM drift. This is the order-domain counterpart of harmonic_9x and is the preferred feature for variable-speed machines (VFD-driven motors, wind turbines, locomotives) where the Hz value of the 9× peak would otherwise drift with the operating point. Order 9 — high-order rotor-dynamics tail. Useful jointly with orders 1–10 as input features for the ML classifier; rarely diagnostic on its own.
$$ E^{\mathrm{ord}}_{9\times} = \sum_{k:|\,o_k - 9|\,\le \mathrm{bw}} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_10x_energy Order spectrum energy — order 10 per-axis (×3)
Energy in a ±0.15-order band around order 10 of the order-tracked spectrum. The order spectrum is the FFT magnitude with frequency axis rescaled by 1/f_shaft, so the abscissa becomes 'orders of shaft' — independent of RPM drift. This is the order-domain counterpart of harmonic_10x and is the preferred feature for variable-speed machines (VFD-driven motors, wind turbines, locomotives) where the Hz value of the 10× peak would otherwise drift with the operating point. Order 10 — boundary of the canonical order-track range. The ten orders 1–10 together describe the shaft-locked structure of the spectrum and feed the ML model as a 10-channel summary of the rotor-dynamics fingerprint.
$$ E^{\mathrm{ord}}_{10\times} = \sum_{k:|\,o_k - 10|\,\le \mathrm{bw}} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_energy_bpfo_order Order energy at BPFO order per-axis (×3)
Energy in a ±0.15-order band around the order-domain equivalent of BPFO: o_BPFO = f_BPFO/f_shaft. For a typical industrial bearing (6203 / 6205 / NU204 class) the defect-order values are geometry-fixed: BPFO ≈ 3.0–4.0, BPFI ≈ 4.0–5.0, BSF ≈ 1.9–3.0, FTF ≈ 0.4. A bearing defect of the corresponding type (BPFO) produces a peak at this order in the order-tracked spectrum, INDEPENDENT of the shaft's operating RPM — making this feature directly comparable across runs at different speeds. This is the preferred diagnostic scalar for variable-speed machines where the Hz-domain energy_at_bpfo value would otherwise smear across the RPM range.
$$ E^{\mathrm{ord}}_{BPFO} = \sum_{k:|o_k - o_BPFO| \le \mathrm{bw}} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_energy_bpfi_order Order energy at BPFI order per-axis (×3)
Energy in a ±0.15-order band around the order-domain equivalent of BPFI: o_BPFI = f_BPFI/f_shaft. For a typical industrial bearing (6203 / 6205 / NU204 class) the defect-order values are geometry-fixed: BPFO ≈ 3.0–4.0, BPFI ≈ 4.0–5.0, BSF ≈ 1.9–3.0, FTF ≈ 0.4. A bearing defect of the corresponding type (BPFI) produces a peak at this order in the order-tracked spectrum, INDEPENDENT of the shaft's operating RPM — making this feature directly comparable across runs at different speeds. This is the preferred diagnostic scalar for variable-speed machines where the Hz-domain energy_at_bpfi value would otherwise smear across the RPM range.
$$ E^{\mathrm{ord}}_{BPFI} = \sum_{k:|o_k - o_BPFI| \le \mathrm{bw}} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_energy_bsf_order Order energy at BSF order per-axis (×3)
Energy in a ±0.15-order band around the order-domain equivalent of BSF: o_BSF = f_BSF/f_shaft. For a typical industrial bearing (6203 / 6205 / NU204 class) the defect-order values are geometry-fixed: BPFO ≈ 3.0–4.0, BPFI ≈ 4.0–5.0, BSF ≈ 1.9–3.0, FTF ≈ 0.4. A bearing defect of the corresponding type (BSF) produces a peak at this order in the order-tracked spectrum, INDEPENDENT of the shaft's operating RPM — making this feature directly comparable across runs at different speeds. This is the preferred diagnostic scalar for variable-speed machines where the Hz-domain energy_at_bsf value would otherwise smear across the RPM range.
$$ E^{\mathrm{ord}}_{BSF} = \sum_{k:|o_k - o_BSF| \le \mathrm{bw}} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_energy_ftf_order Order energy at FTF order per-axis (×3)
Energy in a ±0.15-order band around the order-domain equivalent of FTF: o_FTF = f_FTF/f_shaft. For a typical industrial bearing (6203 / 6205 / NU204 class) the defect-order values are geometry-fixed: BPFO ≈ 3.0–4.0, BPFI ≈ 4.0–5.0, BSF ≈ 1.9–3.0, FTF ≈ 0.4. A bearing defect of the corresponding type (FTF) produces a peak at this order in the order-tracked spectrum, INDEPENDENT of the shaft's operating RPM — making this feature directly comparable across runs at different speeds. This is the preferred diagnostic scalar for variable-speed machines where the Hz-domain energy_at_ftf value would otherwise smear across the RPM range.
$$ E^{\mathrm{ord}}_{FTF} = \sum_{k:|o_k - o_FTF| \le \mathrm{bw}} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_sideband_bpfo Sideband energy around BPFO per-axis (×3)
Sum of energies at o_BPFO − o_FTF and o_BPFO + o_FTF — the cage-modulated sideband pattern that develops around the outer-race defect order when the cage motion modulates the BPFO impact train. A clean outer-race defect on a tightly-fitted bearing produces a SHARP peak at BPFO with negligible sidebands; as the bearing develops looseness in its housing fit (advanced wear, fretting corrosion at the seat) the cage motion couples into the impact timing and the sidebands grow. Strong order_sideband_bpfo relative to order_energy_bpfo is a 'late-stage' outer-race signal — the defect has compromised the housing-fit integrity.
$$ E^{\mathrm{sb}}_{\mathrm{BPFO}} = E(o_{\mathrm{BPFO}} - o_{\mathrm{FTF}}) + E(o_{\mathrm{BPFO}} + o_{\mathrm{FTF}}) $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_sideband_bpfi Sideband energy around BPFI per-axis (×3)
Sum of energies at o_BPFI − 1 and o_BPFI + 1 — the shaft-rate modulated sideband pattern that ALWAYS accompanies an inner-race defect. An inner-race defect rotates with the shaft and therefore passes through the load zone exactly once per shaft revolution: this amplitude-modulates the BPFI impact train at the shaft frequency, producing sidebands at BPFI ± 1×. The shaft-rate sidebands are diagnostically essential — a BPFI peak WITHOUT shaft-rate sidebands is unusual and suggests the defect is on an unloaded surface (or the load is purely axial), not on the rotating race itself.
$$ E^{\mathrm{sb}}_{\mathrm{BPFI}} = E(o_{\mathrm{BPFI}} - 1) + E(o_{\mathrm{BPFI}} + 1) $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
dominant_order Dominant order per-axis (×3)
Order of the maximum-magnitude bin in the order-tracked spectrum (excluding sub-synchronous bins). On a healthy rotating machine the dominant order sits near 1 (shaft imbalance signature). Under fault it migrates: 2 for misalignment, 3 for looseness/severe misalignment, the corresponding defect order (e.g. 3.5 for a 6203 BPFO) for a localised bearing defect once the defect amplitude exceeds the residual rotor-dynamics. dominant_order is the single most informative 1-scalar 'where does the biggest peak sit?' summary of the spectrum.
$$ o_\text{dom} = \arg\max_k |X^\text{ord}[k]| $$
Units
orders
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_1x_2x_ratio 1×/2× order ratio per-axis (×3)
Ratio of 1× order energy to 2× order energy. The textbook discriminator between imbalance and misalignment: a high ratio (≫ 1) means 1× dominates → imbalance is the primary fault; a low ratio (< 1) means 2× exceeds 1× → misalignment is dominant. Industry rules of thumb from Eshleman, Mobius, and Vibration Institute training materials place the imbalance-vs-misalignment boundary around ratio = 0.5 (imbalance for > 0.5, misalignment for < 0.5), with misalignment confirmed when the ratio drops below 0.3.
$$ R_{1/2} = E_{1\times}\,/\,E_{2\times} $$
Units
dimensionless
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
Notes: Clipped at 1e6 to prevent float overflow when 2× energy is near zero. Identical clipping in training-data prep and inference paths.
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
order_subsync_energy Sub-synchronous order energy per-axis (×3)
Total energy at orders strictly less than 1 (i.e. frequencies BELOW the shaft rotation rate). Sub-synchronous energy is diagnostically rich because nothing healthy should be at sub-shaft frequencies — but several distinct fault modes ALL live here: (1) cage frequency / FTF (~0.4× shaft) for cage defects or cage-train damage, (2) oil-whirl on fluid-film bearings at ~0.42–0.48×, (3) oil-whip at the lower of bearing resonance frequency or shaft resonance, (4) rotor-stator rub at ~0.5×. A non-zero sub-synchronous reading on a rolling-element bearing machine always warrants investigation.
$$ E_\text{sub} = \sum_{k: 0 < o_k < 1} |X^\text{ord}[k]|^2 $$
Units
Textbook
Randall 2011, §3.6.5, p. 85–90 — Order tracking and order spectrum
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
bpfo_order BPFO order multiplier per-axis (×3)
Geometric order-multiplier for the outer-race defect: o_BPFO = (N_b / 2) · (1 − (d/D) cos α), where N_b is the number of rolling elements, d is the rolling-element diameter, D is the pitch diameter, and α is the contact angle. Property of bearing geometry alone — does not vary with operating condition. Typical industrial bearings: 6203 ≈ 3.05, 6205 ≈ 3.57, NU204 ≈ 4.0. Reported as a feature so the ML model can condition its predictions on the bearing type without having to look it up out-of-band.
$$ o_{\mathrm{BPFO}} = \dfrac{N_b}{2}\,\left(1 - \dfrac{d}{D}\cos\alpha\right) $$
Units
orders
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
bpfi_order BPFI order multiplier per-axis (×3)
Geometric order-multiplier for the inner-race defect: o_BPFI = (N_b / 2) · (1 + (d/D) cos α). Sum of o_BPFO + o_BPFI = N_b (the number of rolling elements) for any bearing — a useful sanity-check identity. Typical industrial values: 6203 ≈ 4.95, 6205 ≈ 5.43, NU204 ≈ 5.0. The BPFO and BPFI orders are reciprocally arranged around N_b/2; the spacing between them widens with contact angle α (deep-groove ~ 0°, angular-contact 25–40°).
$$ o_{\mathrm{BPFI}} = \dfrac{N_b}{2}\,\left(1 + \dfrac{d}{D}\cos\alpha\right) $$
Units
orders
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
bsf_order BSF order multiplier per-axis (×3)
Geometric order-multiplier for the ball/roller spin frequency: o_BSF = (D/2d) · (1 − ((d/D) cos α)²). Unlike BPFO and BPFI, BSF can be sub-synchronous or super-synchronous depending on geometry — typical industrial values land between 1.9 and 3.0 orders. A ball-fault signature appears at o_BSF AND at o_2·BSF (the rolling element typically contacts both races, doubling the impact rate); reading order_energy_bsf alongside order_energy_2x_bsf catches both contact events. Note that BSF is the FRACTION-OF-A-REVOLUTION spin of the ball — the actual spin in shaft revolutions is BSF / FTF.
$$ o_{\mathrm{BSF}} = \dfrac{D}{2d}\left(1 - \left(\dfrac{d}{D}\cos\alpha\right)^2\right) $$
Units
orders
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …
ftf_order FTF order multiplier per-axis (×3)
Geometric order-multiplier for the cage (fundamental train frequency): o_FTF = ½ · (1 − (d/D) cos α). For single-row deep-groove ball bearings o_FTF settles near 0.4 (geometry-fixed slightly under ½). FTF energy is diagnostically distinct from rotor-dynamics features because nothing else healthy sits at sub-synchronous orders: a non-zero o_FTF reading reliably points to cage damage, cage looseness, or — rarely — rolling element kinematics anomalies. FTF also modulates BPFO and BPFI energy as the cage transports the rolling elements through the load zone (see order_sideband_bpfo).
$$ o_{\mathrm{FTF}} = \dfrac{1}{2}\left(1 - \dfrac{d}{D}\cos\alpha\right) $$
Units
orders
Textbook
Randall 2011, §5.4, p. 187–202 — Bearing fault diagnosis — defect frequencies
v5/lib/features_v5.py:1394–1496  ::  extract_order_features        if humidity_pct is None:
            logger.warning("humidity_pct not provided; defaulting to 50.0%%")
        temp_feats = extract_temperature_features(
            surface_temp_c=surface_temp_c,
            ambient_temp_c=ambient_temp_c if ambient_temp_c is not None else 25.0,
            humidity_pct=humidity_pct if humidity_pct is not None else 50.0,
            temp_history=temp_history,
        )
        features.update(temp_feats)

    # MED observability fields (T2 follow-up). NOT in SCALAR_FEATURE_KEYS_V5
    # contract — observability only. Downstream consumers (verdict engine,
    # dashboards, training scripts) can inspect med_aborted/med_converged
    # to know whether MED contributed to the envelope features this window.
    features["med_aborted"] = _med_aborted
    features["med_converged"] = _med_converged
    features["med_kurt_before"] = _med_kurt_before
    features["med_kurt_after"] = _med_kurt_after
    features["med_n_iter"] = _med_n_iter

    return features


def 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)

# … 23 more lines truncated …

Cross-axis features

30 feature definitions

Thirty 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 capture inter-axis modulation: magnitude-squared coherence at shaft + defect frequencies for all three axis pairs (15 features), the orbit ellipticity at the 1× shaft component, and the direction-of-maximum-vibration angle in the radial-tangential plane.

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.

Concept

Three axes: the shape of the motion, not just its size

A single accelerometer axis sees a one-dimensional shadow of a three-dimensional motion. With X (radial), Y (axial) and Z (tangential) captured together, the engine can reconstruct the orbit — the path the housing actually traces. Pure imbalance drives a near-circular 1× orbit; misalignment constrains the motion into a flattened ellipse with a 2× loop; looseness draws an orbit that never repeats.

Beyond the orbit, magnitude-squared coherence between axis pairs at the shaft and defect frequencies asks whether two axes are being moved by one mechanism or several — and the direction-of-maximum-vibration angle points toward where the force enters. Thirty features that turn three projections into one 3-D picture.

Question it answersWhat is the SHAPE and direction of the motion — one coherent mechanism or several independent ones?
Blind spotShape alone is not cause: a mounting resonance can flatten an orbit as convincingly as misalignment. The orbit narrows the hypothesis; the defect groups confirm it.
animated · illustrative signals
Left: X–Z orbit at 1× with trail. Right: the two axis waveforms that compose it. Illustrative synthetic signals.
cross_corr_xy Cross-correlation (X,Y)
Pearson correlation coefficient between the radial-X and axial-Y acceleration time series. High |ρ| ≈ 1 means the two axes share a strongly correlated waveform — a structural mode or a directional fault is forcing them together. Near-zero |ρ| means the axes vibrate independently (each axis responds to its own decoupled excitation, the healthy case for a well-aligned rotor). Negative ρ means the axes are anti-correlated, which is uncommon and usually points to a precessing rotor orbit or a foundation rocking mode.
$$ \rho_{XY} = \dfrac{\mathrm{cov}(x,y)}{\sigma_x \sigma_y} $$
Units
dimensionless (−1..1)
Textbook
Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
cross_axis_full_valid Cross-axis validity sentinel New
Validity flag for the cross-axis block: 1.0 when all three axes carried genuine independent signals, so every pairwise cross-axis feature was computed from real data; 0.0 when any axis was missing or tiled from a single-axis capture, in which case the cross-axis block is not physically meaningful.
$$ v=\begin{cases}1 & \text{all 3 axes valid}\\0 & \text{otherwise}\end{cases} $$
Units
boolean (0/1)
Textbook
Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
cross_corr_xz Cross-correlation (X,Z)
Pearson correlation between radial-X and tangential-Z acceleration time series. Same interpretation framework as cross_corr_xy: high |ρ| means a shared mode (most informative between the two radial axes — X and Z together describe the shaft's orbit in the plane perpendicular to its axis), near-zero means decoupled dynamics. On a balanced, well-aligned machine X and Z carry the same 1× shaft tone with a 90° phase offset, so the time-domain correlation is small even though the FFT magnitudes match.
$$ \rho_{XZ} = \dfrac{\mathrm{cov}(x,z)}{\sigma_x \sigma_z} $$
Units
dimensionless (−1..1)
Textbook
Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
cross_corr_yz Cross-correlation (Y,Z)
Pearson correlation between axial-Y and tangential-Z. The least-coupled axis pair for typical rotating machinery: axial vibration is forced primarily by thrust loads and angular misalignment, tangential is forced by torque transients and radial dynamics. A healthy machine shows |ρ_YZ| close to 0; a non-zero reading flags axial-radial coupling, which usually means a combined misalignment + imbalance fault or a bearing pre-load anomaly.
$$ \rho_{YZ} = \dfrac{\mathrm{cov}(y,z)}{\sigma_y \sigma_z} $$
Units
dimensionless (−1..1)
Textbook
Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
kurtosis_ratio_xy Kurtosis ratio (X/Y)
Pearson kurtosis on axis X divided by kurtosis on axis Y. Captures whether impulsive content is biased toward one axis. A bearing-race spall that contacts the load zone produces impulses biased toward the radial direction (X), raising K_x while K_y stays near 3 — so kurtosis_ratio_xy rises well above 1. Generalised wear or distributed looseness produces isotropic impulses (similar kurtosis on all axes) and the ratio stays near 1. Together with kurtosis_ratio_xz and kurtosis_ratio_yz this triplet tells the ML model the geometric orientation of any impulsive damage.
$$ K_{XY} = K(x)\,/\,K(y) $$
Units
dimensionless
Textbook
Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
kurtosis_ratio_xz Kurtosis ratio (X/Z)
Kurtosis on radial-X divided by kurtosis on tangential-Z. The two radial axes (X and Z) together form the plane perpendicular to the shaft. For an evenly-distributed outer-race defect (e.g. spall that has propagated around the race) the impulsive content is symmetric in this plane and K_x ≈ K_z → ratio ≈ 1. For a LOCALISED outer-race defect (a single pit or spall at a fixed circumferential position) the kurtosis is biased toward the axis aligned with the defect and the ratio diverges from 1.
$$ K_{XZ} = K(x)\,/\,K(z) $$
Units
dimensionless
Textbook
Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
kurtosis_ratio_yz Kurtosis ratio (Y/Z)
Kurtosis on axial-Y divided by kurtosis on tangential-Z. Predominantly axial impulses raise this ratio (a worn axial thrust bearing, an angularly-misaligned coupling under axial transient); predominantly radial impulses drop it (a radial-load-zone outer-race defect on a high-load bearing). For rolling-element bearings under primarily radial load, the axial kurtosis usually stays near 3 even under fault — so kurtosis_ratio_yz < 1 is the typical fault signature.
$$ K_{YZ} = K(y)\,/\,K(z) $$
Units
dimensionless
Textbook
Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
rms_vector_magnitude RMS vector magnitude
Vector RMS across all three axes — the Euclidean norm of the per-axis RMS values. Provides an installation-invariant 'overall vibration' scalar that does not depend on how the sensor was mounted (which axis ended up radial vs axial vs tangential). Equivalent to integrating the magnitude of the 3-D acceleration vector over time. Used as a baseline-comparison metric: a healthy machine's rms_vector_magnitude is reproducible at ~5% across remounts, whereas individual axis RMS values can shift 30%+ between mountings.
$$ \text{RMS}_\text{vec} = \sqrt{\text{RMS}_x^2 + \text{RMS}_y^2 + \text{RMS}_z^2} $$
Units
g
Textbook
Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
rms_ratio_xy RMS ratio (X/Y)
Ratio of X-axis RMS to Y-axis RMS. Distinguishes radial-dominated from axial-dominated faults. On a properly-mounted bearing housing under primarily radial loading (belt-driven motors, fan pulleys, pump impellers), RMS_x ≫ RMS_y → ratio > 1 even on healthy bearings. Angular misalignment shifts energy onto the axial axis and pushes the ratio toward 1; a thrust-bearing failure drops it below 1 (axial energy dominates). The absolute value of the ratio is less informative than its trend against the machine's own baseline.
$$ R_{XY} = \text{RMS}_x\,/\,\text{RMS}_y $$
Units
dimensionless
Textbook
Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
rms_ratio_xz RMS ratio (X/Z)
Ratio of radial-X RMS to tangential-Z RMS. Tracks asymmetry in the radial-plane orbit: a perfectly circular orbit gives ratio ≈ 1, an elliptical orbit aligned with X gives ratio > 1, aligned with Z gives ratio < 1. Asymmetric radial-plane loading (uneven sleeve-bearing oil-film, structural-mode coupling on one axis, a foundation crack on one side) is the most common cause of rms_ratio_xz ≠ 1 on a healthy rotor. Trend tracking is more diagnostic than absolute value.
$$ R_{XZ} = \text{RMS}_x\,/\,\text{RMS}_z $$
Units
dimensionless
Textbook
Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
crest_max_axis Max crest factor across axes
Maximum of (crest_x, crest_y, crest_z) — picks the most impulsive axis as the diagnostic indicator. Robust to sensor-mounting variations: if the impulsive content is biased to one axis (a directional race spall) you don't want a single-axis crest reading on the OTHER axis to miss it. crest_max_axis is the worst-case-axis early warning of impulsive damage, complementary to the rms_vector_magnitude 'overall energy' summary. Healthy bearings: < 5; advanced damage: > 8.
$$ \text{CF}_\text{max} = \max_{a \in \{x,y,z\}} \text{CF}_a $$
Units
dimensionless
Textbook
Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
energy_anisotropy Energy anisotropy
(largest-axis RMS − smallest-axis RMS) / RMS_vector. Bounded in [0, ~1.5]. Zero = isotropic vibration — same amplitude on every axis (cylindrical uniform wear, balanced rotor on a stiff mount). Values approaching 1 = one axis carries dramatically more energy than the others (severe misalignment along a single direction, a foundation crack that only loads one axis, a bent shaft with a fixed angle to the bearing axis). energy_anisotropy is the natural 'how-directional-is-this-fault' scalar and complements the kurtosis ratios (which capture directional IMPULSIVE-NESS rather than directional ENERGY).
$$ A = \dfrac{\max_a \text{RMS}_a - \min_a \text{RMS}_a}{\text{RMS}_\text{vec}} $$
Units
dimensionless (0–1)
Textbook
Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_mean Mean coherence across all pairs
Mean magnitude-squared coherence γ²(f) averaged over frequency AND over the three axis pairs (XY, XZ, YZ). The single-scalar summary of how much coherent inter-axis structure exists in the spectrum: a value near 1 means the three axes are jointly modulated by shared mechanical inputs (strong directional fault, low-order structural mode); near 0 means each axis responds to decoupled excitations (healthy independent dynamics). Used as a high-level gate for the 15 directional coherence-at-frequency features below — if mean coherence is near 0 those per-frequency values are typically also noise-floor regardless of the target frequency.
$$ \overline{\gamma^2} = \dfrac{1}{3}\sum_{(a,b)} \dfrac{1}{N_f}\sum_k \gamma^2_{ab}(f_k) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_shaft_xy Coherence at SHAFT (XY)
Welch magnitude-squared coherence γ²(f) between axes X and Y averaged in a narrow band around SHAFT. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{XY}(f_{SHAFT}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_shaft_xz Coherence at SHAFT (XZ)
Welch magnitude-squared coherence γ²(f) between axes X and Z averaged in a narrow band around SHAFT. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{XZ}(f_{SHAFT}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_shaft_yz Coherence at SHAFT (YZ)
Welch magnitude-squared coherence γ²(f) between axes Y and Z averaged in a narrow band around SHAFT. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{YZ}(f_{SHAFT}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_bpfo_xy Coherence at BPFO (XY)
Welch magnitude-squared coherence γ²(f) between axes X and Y averaged in a narrow band around BPFO. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{XY}(f_{BPFO}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_bpfo_xz Coherence at BPFO (XZ)
Welch magnitude-squared coherence γ²(f) between axes X and Z averaged in a narrow band around BPFO. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{XZ}(f_{BPFO}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_bpfo_yz Coherence at BPFO (YZ)
Welch magnitude-squared coherence γ²(f) between axes Y and Z averaged in a narrow band around BPFO. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{YZ}(f_{BPFO}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_bpfi_xy Coherence at BPFI (XY)
Welch magnitude-squared coherence γ²(f) between axes X and Y averaged in a narrow band around BPFI. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{XY}(f_{BPFI}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_bpfi_xz Coherence at BPFI (XZ)
Welch magnitude-squared coherence γ²(f) between axes X and Z averaged in a narrow band around BPFI. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{XZ}(f_{BPFI}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_bpfi_yz Coherence at BPFI (YZ)
Welch magnitude-squared coherence γ²(f) between axes Y and Z averaged in a narrow band around BPFI. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{YZ}(f_{BPFI}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_bsf_xy Coherence at BSF (XY)
Welch magnitude-squared coherence γ²(f) between axes X and Y averaged in a narrow band around BSF. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{XY}(f_{BSF}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_bsf_xz Coherence at BSF (XZ)
Welch magnitude-squared coherence γ²(f) between axes X and Z averaged in a narrow band around BSF. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{XZ}(f_{BSF}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_bsf_yz Coherence at BSF (YZ)
Welch magnitude-squared coherence γ²(f) between axes Y and Z averaged in a narrow band around BSF. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{YZ}(f_{BSF}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_ftf_xy Coherence at FTF (XY)
Welch magnitude-squared coherence γ²(f) between axes X and Y averaged in a narrow band around FTF. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{XY}(f_{FTF}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_ftf_xz Coherence at FTF (XZ)
Welch magnitude-squared coherence γ²(f) between axes X and Z averaged in a narrow band around FTF. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{XZ}(f_{FTF}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
coherence_at_ftf_yz Coherence at FTF (YZ)
Welch magnitude-squared coherence γ²(f) between axes Y and Z averaged in a narrow band around FTF. γ² near 1 means the two axes share strongly correlated energy at that frequency — useful as a directional signature for the fault. Defects with a clear orientation in the machine (race spalls on a specific axis, foundation looseness, shaft misalignment) produce HIGH coherence on the axis pair aligned with the defect and LOW coherence on the orthogonal pair; isotropic noise gives uniform low coherence across all pairs.
$$ \gamma^2_{YZ}(f_{FTF}) = \mathop{\overline{\,\cdot\,}}_{f \in [f^* - bw,\, f^* + bw]} \dfrac{|G_{ab}(f)|^2}{G_{aa}(f)\,G_{bb}(f)}\;,\;\; bw = \max(5\,\text{Hz},\,\Delta f_\text{Welch}) $$
Units
dimensionless (0–1)
Textbook
Randall 2011, §3.6, p. 78–95 — Frequency-domain analysis
Notes: Welch parameters: nperseg=256, noverlap=128, window='hann', detrend='constant' — matches scipy.signal.coherence defaults. Effective bandwidth auto-widens to ≥1 Welch bin (~47 Hz at fs=12 kHz) so the result is never NaN; if no bin lands in the band, the function snaps to the nearest bin's value.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
orbit_ellipticity Orbit ellipticity
Lissajous-figure semi-axis ratio of the X-Z orbit at the 1× shaft component. Computed from the magnitude and phase of the FFT bin nearest the shaft frequency in radial axis X and tangential axis Z. 1.0 = circular orbit (X and Z amplitudes match and they are π/2 out of phase — balanced rotor); ≈ 0 = collapsed to a line (in-phase or anti-phase, severe misalignment / rub).
$$ \epsilon = \dfrac{\min(|X_{1\times}|,\,|Z_{1\times}|) \cdot |\sin(\Delta\varphi)|}{\max(|X_{1\times}|,\,|Z_{1\times}|)}\;,\;\; \Delta\varphi = \angle X_{1\times} - \angle Z_{1\times} $$
Units
dimensionless (0–1)
Textbook
Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
Notes: Uses radial X and tangential Z axes (not X-Y); the 1× FFT bin is picked nearest shaft_hz × N / fs. Result clipped to ±100 by the trailing finite-check; values in practice land in [0, 1]. Degenerate guard: if both 1× amplitudes are below 1e-6 g (no detectable shaft component) the field reports 0.0 — pair with `energy_anisotropy == 0` to disambiguate 'no orbit measurable' from 'collapsed orbit / severe misalignment'.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …
direction_of_max_vibration Direction of max vibration
Angle (radians) in the X-Z measurement plane formed by the RMS amplitude ratio of radial X to tangential Z. Effectively atan2(RMS_z, RMS_x): the broadband energy direction in the radial/tangential plane. Stable angle across scans indicates a directional defect (race spall); a wandering angle indicates cage modulation or rotating unbalance.
$$ \theta = \arctan_2(\mathrm{RMS}_z,\,\mathrm{RMS}_x) $$
Units
radians (0..π/2 for non-negative RMS)
Textbook
Wowk 1991, §4 + §8 — Machinery Vibration: Measurement and Analysis — cross-axis orbit analysis
Notes: NOT the principal-axis angle (eigendecomposition of Cov(x,z)) — this is the amplitude-ratio direction in the X-Z plane. Documented at v5/lib/features_v5.py as `arctan2(rms_z, rms_x)`. Range under non-negative RMS = [0, π/2]. Zero-signal guard: if rms_x and rms_z are both below 1e-9 g, the field reports 0.0 (atan2(0,0) IEEE 754 default) but the accompanying `rms_vector_magnitude ≈ 0` distinguishes 'no signal' from 'pure radial-X direction'.
v5/lib/features_v5.py:1653–1868  ::  compute_cross_axis_features# Canonical cross-axis feature keys (29 total)
CROSS_AXIS_KEYS: list[str] = [
    # Existing 12
    "cross_corr_xy", "cross_corr_xz", "cross_corr_yz",
    "kurtosis_ratio_xy", "kurtosis_ratio_xz", "kurtosis_ratio_yz",
    "rms_vector_magnitude", "rms_ratio_xy", "rms_ratio_xz",
    "crest_max_axis", "energy_anisotropy", "coherence_mean",
    # New 17 — Design specification(b), 10, 15
    "coherence_at_shaft_xy", "coherence_at_shaft_xz", "coherence_at_shaft_yz",
    "coherence_at_bpfo_xy", "coherence_at_bpfo_xz", "coherence_at_bpfo_yz",
    "coherence_at_bpfi_xy", "coherence_at_bpfi_xz", "coherence_at_bpfi_yz",
    "coherence_at_bsf_xy", "coherence_at_bsf_xz", "coherence_at_bsf_yz",
    "coherence_at_ftf_xy", "coherence_at_ftf_xz", "coherence_at_ftf_yz",
    "orbit_ellipticity",
    "direction_of_max_vibration",
    # sentinel — 1.0 when triaxial AND RPM known (so coherence-at-defect-freq
    # and orbit features were actually computed), 0.0 otherwise. Lets the model
    # treat single-axis-tiled or no-RPM windows differently from real isotropic
    # signals. Per internal coding rule.
    "cross_axis_full_valid",
]


def 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 ──
# … 136 more lines truncated …

Ultrasonic features

17 feature definitions

Seventeen 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.

Concept

Ultrasound: hearing trouble before it shakes

Metal-on-metal friction, electrical discharge and turbulent leaks all emit energy in the tens of kilohertz — far above where vibration features look, and typically before the fault grows heavy enough to shake the structure. The microphone channel is banded by mechanism: bearing friction 20–60 kHz, leaks 40–100 kHz, electrical activity 80–100 kHz.

Two tricks make it usable. Peak-hold: a 2 ms crackle burst drowns in a one-second RMS, so the engine also keeps the maximum 100 ms RMS — it catches the burst and holds it. Steadiness: a leak is a continuous hiss, bearing crackle is intermittent — the steadiness flag plus the baseline-delta in dB separates "flow" from "impact" and "new" from "normal for this machine".

Question it answersIs something scraping, arcing or leaking — audible in ultrasound before it is visible in vibration?
Blind spotUltrasound attenuates fast with distance and mounting, and absolute levels mean little — every reading is only as good as the baseline it's compared against.
animated · illustrative signals
Bottom lane: RMS (teal) barely moves while peak-hold (coral) steps up on every burst and decays slowly. Illustrative synthetic signals.
us_rms_overall Overall ultrasonic RMS N/A on MAFAULDA
RMS of the ENTIRE ultrasonic signal across its full bandwidth (typically 20 Hz – 100 kHz on the IMP23ABSU MEMS microphone, decimated to ~100 kHz PCM), expressed in dBµV relative to 1 µV. The headline 'how loud is the ultrasonic channel overall' scalar. Trends against a calibrated healthy baseline are far more diagnostic than absolute values — different sensor mountings shift the floor by 6–10 dB. A rising us_rms_overall is the earliest possible mechanical-distress signal on any machine equipped with ultrasonic AE sensing.
$$ \text{RMS}_\text{us} = 20\,\log_{10}(\sqrt{\overline{u^2}} \,/\, 1\,\mu V) $$
Units
dBµV
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_rms_bearing Ultrasonic RMS — bearing band N/A on MAFAULDA
Bandpass RMS in the 20–60 kHz BEARING band, in dBµV. This band captures the acoustic-emission stress waves from sub-microscopic bearing-surface plastic deformation, lubrication film breakdown, and early-stage rolling contact fatigue — phenomena that produce ultrasonic energy DECADES before they raise the vibration-band RMS. The single most sensitive feature for early-warning bearing CM when the sensor is available.
$$ \text{RMS}_\text{bearing} = 20\log_{10}(\sqrt{\overline{u_\text{bp}^2}}\,/\,1\,\mu V) $$
Units
dBµV
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_rms_leak Ultrasonic RMS — leak band N/A on MAFAULDA
Bandpass RMS in the 40–100 kHz LEAK band, in dBµV. Pressurised gas leaks (compressed air, steam, refrigerant, hydraulic oil) generate broadband ultrasonic hiss centred at 60–80 kHz from turbulent flow through small apertures. Trends with leak rate. Useful on compressors, pneumatic actuators, refrigeration loops; less informative on rolling-element bearings (use us_rms_bearing).
$$ \text{RMS}_\text{leak} = 20\log_{10}(\sqrt{\overline{u_\text{bp}^2}}\,/\,1\,\mu V) $$
Units
dBµV
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_rms_electrical Ultrasonic RMS — electrical band N/A on MAFAULDA
Bandpass RMS in the 80–100 kHz ELECTRICAL band, in dBµV. Captures corona discharge, partial discharge in HV insulation, and arcing at brush/slip-ring contacts on motors. Useful on transformers, switchgear, motor windings — phenomena that are invisible to vibration sensors. A rising trend warrants electrical insulation testing.
$$ \text{RMS}_\text{electrical} = 20\log_{10}(\sqrt{\overline{u_\text{bp}^2}}\,/\,1\,\mu V) $$
Units
dBµV
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_peak Ultrasonic peak N/A on MAFAULDA
Maximum instantaneous amplitude in the ultrasonic signal, expressed in dBµV. Sensitive to single high-amplitude events: a partial-discharge arc on a transformer winding, a one-off bearing AE burst, a step-change in machine load. Read together with us_crest_factor (peak / RMS): when peak is high BUT crest factor is moderate, the signal is uniformly loud; when peak AND crest factor are both high, a discrete impulsive event has occurred.
$$ \text{peak}_\text{us} = 20\log_{10}(\max|u|\,/\,1\,\mu V) $$
Units
dBµV
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_crest_factor Ultrasonic crest factor N/A on MAFAULDA
Peak divided by RMS of the linear ultrasonic signal. The ultrasonic analogue of the vibration-band crest factor: rises when the signal develops impulsive content over its background level. A healthy machine's ultrasonic channel carries broadband background noise with crest factor ≈ 3–4 (Gaussian-like); early bearing AE bursts push it above 6; transient electrical arcing can reach 10+. The most informative single scalar for distinguishing 'steady-loud' (uniform AE from advanced damage) from 'spiky-loud' (localised acute events).
$$ \text{CF}_\text{us} = \max|u|\,/\,\sqrt{\overline{u^2}} $$
Units
dimensionless
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_baseline_delta_dB Baseline delta (dB) N/A on MAFAULDA
us_rms_overall minus the calibrated healthy baseline value for this specific sensor + mounting, in dB. Industry convention (SDT, UE Systems, Spectrum Energy Group): +8 dB above baseline → lubrication-deficit warning, +12 dB → bearing damage warning, +16 dB → bearing damage alarm. The absolute dBµV value of us_rms_overall is not comparable across installations; this delta IS, making it the most diagnostically useful single ultrasonic scalar for fleet-wide monitoring once baselines are established.
$$ \Delta_\text{baseline} = \text{RMS}_\text{us} - \text{RMS}_\text{baseline} $$
Units
dB
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_spectral_flatness Ultrasonic spectral flatness N/A on MAFAULDA
Geometric/arithmetic mean ratio of the ultrasonic power spectrum, bounded in [0, 1]. The ultrasonic counterpart to vibration-band spectral_flatness: distinguishes tonal mechanical AE (a worn bearing race rings at a specific resonance frequency → low flatness) from broadband leak hiss (compressed-air turbulence → high flatness near 1) and from electrical partial discharge (narrowband bursts → low to moderate flatness). The first-pass discriminator between leak and bearing/electrical sources when only the ultrasonic channel is available.
$$ \text{SFM}_\text{us} = \dfrac{\sqrt[N]{\prod |U[k]|^2}}{\frac{1}{N}\sum |U[k]|^2} $$
Units
dimensionless (0–1)
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_dominant_freq Ultrasonic dominant frequency N/A on MAFAULDA
Frequency of the maximum-magnitude bin in the ultrasonic spectrum. Different fault sources concentrate energy at characteristic frequency bands: bearing AE near 30–40 kHz (housing resonance dependent), leak hiss near 60–80 kHz (aperture-size dependent), electrical PD near 80–100 kHz (insulation-distance dependent). A migration in dominant_freq across consecutive measurements at constant operating point flags a change in the underlying physics — useful even without knowing the absolute interpretation.
$$ f_\text{dom}^\text{us} = \arg\max_k |U[k]| $$
Units
Hz
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_is_steady Ultrasonic steady flag N/A on MAFAULDA
Boolean (0.0 / 1.0): 1 if the short-window RMS variance is low compared to mean — i.e. the ultrasonic signal is STEADY across the recording window (consistent loudness). Compressed-air leaks produce extremely steady ultrasonic signatures (continuous turbulent hiss); mechanical bearing AE is BURSTY (impacts coupling stress-wave energy at discrete events). The flag is the fast triage between these two source classes when only the ultrasonic channel is available — pair with us_spectral_flatness for full discrimination.
$$ \mathbb{1}[\sigma(\text{RMS}_\text{window}) < \tau] $$
Units
boolean (0/1)
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_max_rms Max windowed RMS N/A on MAFAULDA
Maximum of the per-window (typically 100 ms) RMS values across the recording — the 'loudest moment' in dBµV. Distinct from us_peak (single sample maximum): us_max_rms is the RMS of the worst 100 ms slice, so it captures sustained burst events rather than single-sample spikes. Useful for detecting transient lubrication-failure events or bearing AE bursts that last several milliseconds.
$$ \max_w \text{RMS}_w $$
Units
dBµV
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_rms_bearing_band RMS — bearing band (linear) N/A on MAFAULDA
Linear-domain (not dB) RMS in the bearing band, in the same physical units as the raw ultrasonic signal (microphone-equivalent mic-equivalent g). Reported alongside the dB version (us_rms_bearing) so the ML model can do linear-math combinations (ratios, sums, weighted-averages of bands) without the implicit logarithm of dB units distorting feature gradients. Same band edges as the dB version: {'bearing':'20–60 kHz','leak':'40–100 kHz','electrical':'80–100 kHz'}["bearing"].
$$ \text{RMS}_\text{bearing}^{\text{lin}} $$
Units
g (mic equivalent)
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_rms_leak_band RMS — leak band (linear) N/A on MAFAULDA
Linear-domain (not dB) RMS in the leak band, in the same physical units as the raw ultrasonic signal (microphone-equivalent mic-equivalent g). Reported alongside the dB version (us_rms_leak) so the ML model can do linear-math combinations (ratios, sums, weighted-averages of bands) without the implicit logarithm of dB units distorting feature gradients. Same band edges as the dB version: {'bearing':'20–60 kHz','leak':'40–100 kHz','electrical':'80–100 kHz'}["leak"].
$$ \text{RMS}_\text{leak}^{\text{lin}} $$
Units
g (mic equivalent)
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_rms_electrical_band RMS — electrical band (linear) N/A on MAFAULDA
Linear-domain (not dB) RMS in the electrical band, in the same physical units as the raw ultrasonic signal (microphone-equivalent mic-equivalent g). Reported alongside the dB version (us_rms_electrical) so the ML model can do linear-math combinations (ratios, sums, weighted-averages of bands) without the implicit logarithm of dB units distorting feature gradients. Same band edges as the dB version: {'bearing':'20–60 kHz','leak':'40–100 kHz','electrical':'80–100 kHz'}["electrical"].
$$ \text{RMS}_\text{electrical}^{\text{lin}} $$
Units
g (mic equivalent)
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_kurtosis Ultrasonic kurtosis N/A on MAFAULDA
Pearson kurtosis of the ultrasonic time-series, Gaussian-reference convention (white ultrasonic noise = 3.0). Rising kurtosis means the ultrasonic channel is becoming MORE impulsive — discrete bearing AE bursts, partial-discharge events, or arc transients standing out from the broadband background. Highly diagnostic for early bearing damage: us_kurtosis trending up while us_rms_overall stays flat means the average loudness hasn't changed but the signal has acquired impulsive structure — the textbook earliest-warning pattern.
$$ K_\text{us} = \dfrac{\mathbb{E}[(u - \bar{u})^4]}{\sigma_u^4} $$
Units
dimensionless
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_steadiness Ultrasonic steadiness score N/A on MAFAULDA
Continuous 0–1 score derived from σ(RMS_window) / μ(RMS_window) — the coefficient of variation of the per-window RMS values, clipped and inverted so 1.0 = perfectly steady, 0.0 = wildly variable. A finer-grained counterpart to the binary us_is_steady flag: instead of answering 'is it steady?' it gives 'how steady is it on a 0–1 scale?' The ML model uses both because the threshold for 'steady enough to call it a leak' is fault-class-dependent and the model can learn its own cutoff from the continuous score.
$$ S_\text{us} = 1 - \mathrm{clip}\!\left(\dfrac{\sigma(\text{RMS}_w)}{\mu(\text{RMS}_w)}, 0, 1\right) $$
Units
dimensionless (0–1)
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …
us_max_rms_100ms Max 100 ms RMS N/A on MAFAULDA
Maximum RMS over fixed 100 ms windows, in dBµV. Identical in concept to us_max_rms but with a STANDARDISED window length so the value is comparable across recordings of different total duration. The 100 ms window matches the minimum integration time used by hand-held ultrasonic survey instruments and the SDT/UE Systems convention for ultrasonic burst-amplitude reporting. The ML model uses this as a comparison reference against fleet-wide baseline data.
$$ \max_{w \in 100\,\text{ms}} \text{RMS}_w $$
Units
dBµV
Textbook
ISO 18436-2/8 — Vibration analyst categories; ultrasonic AE practice
v5/lib/features_v5.py:1907–2118  ::  extract_ultrasonic_features    if high_hz >= nyq or low_hz >= nyq or low_hz >= high_hz:
        _logging.getLogger("kaltech.features").warning(
            "_bandpass_rms: band [%.0f, %.0f] Hz not representable at fs=%d Hz "
            "(Nyquist=%.0f Hz). Returning 0.0.",
            low_hz, high_hz, fs, nyq,
        )
        return 0.0
    low_n = max(low_hz / nyq, 0.001)
    high_n = min(high_hz / nyq, 0.999)
    if high_n <= low_n:
        return 0.0
    b, a = butter(4, [low_n, high_n], btype="band")
    filtered = filtfilt(b, a, signal)
    return float(np.sqrt(np.mean(filtered ** 2)))


def _rms_to_dbuv(rms: float, ref: float = 1e-6) -> float:
    """Convert RMS amplitude to dBuV (decibels relative to 1 microvolt)."""
    if not np.isfinite(rms) or rms <= 0:
        return -999.0
    return float(20.0 * np.log10(rms / ref))


def 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):
# … 132 more lines truncated …

Temperature features

6 feature definitions

Six 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.

Concept

Temperature: the late witness that closes the case

Friction ends as heat — always. Temperature is therefore the most trustworthy and the latest of all condition signals: by the time a bearing housing is hot, the vibration groups above have usually been arguing about it for weeks. The engine uses ΔT = surface − ambient (so a hot afternoon doesn't page anyone) against the converged industrial thresholds: 50 °C warn, 90 °C alarm, and escalates a tier when the rate-of-rise exceeds 2 °C/min — a lubrication collapse heats fast long before it crosses the absolute line.

Its diagnostic power is corroboration: vibration says "outer race", rising ΔT says "and it is now consuming energy" — together they justify urgency.

Question it answersIs the bearing already converting its remaining life into heat — and how fast?
Blind spotHeat is a LATE symptom with a slow thermal path from defect to sensor: normal ΔT proves nothing about early-stage damage. It corroborates; it does not lead.
animated · illustrative signals
Dashed lines: 50 °C warn / 90 °C alarm on ΔT. The "hot afternoon" case shows why ambient compensation matters. Illustrative synthetic trends.
surface_temp_c Surface temperature N/A on MAFAULDA
Bearing housing surface temperature in degrees Celsius, read from the on-sensor SHT40 (housing contact) or NTC thermistor. Provides the primary thermal-protection input to the verdict engine. Acceptable operating range for rolling-element bearings: typically ambient + 30–50 °C above ambient on healthy machines under normal load; anything above ambient + 90 °C indicates imminent failure. Read this together with `ambient_temp_c` to get the load-relevant `delta_t` — absolute surface_temp_c is less informative because the same machine can run 30 °C warmer in a hot plant during summer with no fault.
$$ T_\text{surf} $$
Units
°C
Textbook
ISO 14224 + API 670 + UIC 518 — Bearing thermal protection — multi-standard threshold convergence
v5/lib/features_v5.py:2125–2201  ::  extract_temperature_features        "us_rms_leak": us_rms_leak,
        "us_rms_electrical": us_rms_electrical,
        "us_peak": us_peak,
        "us_crest_factor": us_crest_factor,
        "us_baseline_delta_dB": us_baseline_delta_dB,
        "us_spectral_flatness": us_spectral_flatness,
        "us_dominant_freq": us_dominant_freq,
        "us_is_steady": us_is_steady,
        "us_max_rms": us_max_rms,
        # Additional features for 67-feature vector
        "us_rms_bearing_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 25000, 35000)),
        "us_rms_leak_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 36000, 42000)),
        "us_rms_electrical_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 43000, 60000)),
        "us_kurtosis": us_kurtosis,
        "us_steadiness": us_steadiness,
        "us_max_rms_100ms": _rms_to_dbuv(us_max_rms_raw),
    }


# ---------------------------------------------------------------------------
# Temperature Feature Extraction (STTS22H / SHT45 / NTC)
# ---------------------------------------------------------------------------

def 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)
ambient_temp_c Ambient temperature N/A on MAFAULDA
Ambient plant-air temperature in degrees Celsius. Used as the reference subtractor in `delta_t` (surface − ambient) so the thermal warning thresholds are operating-condition-invariant. The ambient reading should sit at the local plant air temperature; a sustained drift between the on-sensor ambient probe and the plant HVAC reading is a sign the sensor housing has become heat-soaked by its own mounting (close proximity to a hot motor, lack of ventilation) and the delta_t reading needs interpretation with care.
$$ T_\text{amb} $$
Units
°C
Textbook
ISO 14224 + API 670 + UIC 518 — Bearing thermal protection — multi-standard threshold convergence
v5/lib/features_v5.py:2125–2201  ::  extract_temperature_features        "us_rms_leak": us_rms_leak,
        "us_rms_electrical": us_rms_electrical,
        "us_peak": us_peak,
        "us_crest_factor": us_crest_factor,
        "us_baseline_delta_dB": us_baseline_delta_dB,
        "us_spectral_flatness": us_spectral_flatness,
        "us_dominant_freq": us_dominant_freq,
        "us_is_steady": us_is_steady,
        "us_max_rms": us_max_rms,
        # Additional features for 67-feature vector
        "us_rms_bearing_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 25000, 35000)),
        "us_rms_leak_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 36000, 42000)),
        "us_rms_electrical_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 43000, 60000)),
        "us_kurtosis": us_kurtosis,
        "us_steadiness": us_steadiness,
        "us_max_rms_100ms": _rms_to_dbuv(us_max_rms_raw),
    }


# ---------------------------------------------------------------------------
# Temperature Feature Extraction (STTS22H / SHT45 / NTC)
# ---------------------------------------------------------------------------

def 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)
delta_t Temperature excess N/A on MAFAULDA
Surface temperature minus ambient temperature — the universal bearing thermal-protection scalar, valid for any rolling-element bearing regardless of plant location, season, or operating duty. Multiple industry standards converge on the same thresholds: ISO 14224 (O&G), API 670 (machinery protection), UIC 518 (railway hot-box), and vendor-published CM guidance all place WARN at delta_t > 50 °C, ALARM at delta_t > 90 °C. These thresholds are conservative across machine types because the underlying lubrication physics is universal — bearing grease degrades, oil films thin, and bearings seize at the same temperature excess regardless of the machine they're installed in.
$$ \Delta T = T_\text{surf} - T_\text{amb} $$
Units
°C
Textbook
ISO 14224 + API 670 + UIC 518 — Bearing thermal protection — multi-standard threshold convergence
v5/lib/features_v5.py:2125–2201  ::  extract_temperature_features        "us_rms_leak": us_rms_leak,
        "us_rms_electrical": us_rms_electrical,
        "us_peak": us_peak,
        "us_crest_factor": us_crest_factor,
        "us_baseline_delta_dB": us_baseline_delta_dB,
        "us_spectral_flatness": us_spectral_flatness,
        "us_dominant_freq": us_dominant_freq,
        "us_is_steady": us_is_steady,
        "us_max_rms": us_max_rms,
        # Additional features for 67-feature vector
        "us_rms_bearing_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 25000, 35000)),
        "us_rms_leak_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 36000, 42000)),
        "us_rms_electrical_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 43000, 60000)),
        "us_kurtosis": us_kurtosis,
        "us_steadiness": us_steadiness,
        "us_max_rms_100ms": _rms_to_dbuv(us_max_rms_raw),
    }


# ---------------------------------------------------------------------------
# Temperature Feature Extraction (STTS22H / SHT45 / NTC)
# ---------------------------------------------------------------------------

def 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)
humidity_pct Relative humidity N/A on MAFAULDA
Relative humidity (0–100%) from the on-sensor SHT45 (or equivalent) probe. Reported as a feature because (1) it is a known confounder for the ultrasonic baseline — high humidity raises the broadband ultrasonic noise floor by several dB through air-coupling losses; (2) it affects the lubricant viscosity, especially for hygroscopic greases; (3) sustained > 80% RH in a bearing housing is a leading indicator of seal failure and water ingress into the lubrication. The ML model uses humidity_pct to condition its interpretation of the ultrasonic and vibration features.
$$ \text{RH} \in [0, 100] $$
Units
percent
Textbook
ISO 14224 + API 670 + UIC 518 — Bearing thermal protection — multi-standard threshold convergence
v5/lib/features_v5.py:2125–2201  ::  extract_temperature_features        "us_rms_leak": us_rms_leak,
        "us_rms_electrical": us_rms_electrical,
        "us_peak": us_peak,
        "us_crest_factor": us_crest_factor,
        "us_baseline_delta_dB": us_baseline_delta_dB,
        "us_spectral_flatness": us_spectral_flatness,
        "us_dominant_freq": us_dominant_freq,
        "us_is_steady": us_is_steady,
        "us_max_rms": us_max_rms,
        # Additional features for 67-feature vector
        "us_rms_bearing_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 25000, 35000)),
        "us_rms_leak_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 36000, 42000)),
        "us_rms_electrical_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 43000, 60000)),
        "us_kurtosis": us_kurtosis,
        "us_steadiness": us_steadiness,
        "us_max_rms_100ms": _rms_to_dbuv(us_max_rms_raw),
    }


# ---------------------------------------------------------------------------
# Temperature Feature Extraction (STTS22H / SHT45 / NTC)
# ---------------------------------------------------------------------------

def 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)
temp_rate_of_rise Temperature rate of rise N/A on MAFAULDA
First derivative of surface temperature with respect to time, evaluated over the most recent 1-minute window, in °C/min. The leading indicator of imminent bearing seizure: absolute temperatures can sit elevated for hours without the bearing failing, but a sustained rate of rise above 2 °C/min consistently precedes catastrophic failure by minutes to tens of minutes. UIC 518 (railway hot-box detection) and API 670 (machinery protection) both include rate-of-rise as a tier-escalation trigger that is INDEPENDENT of absolute temperature — i.e. the trip fires on rate even if delta_t hasn't reached its absolute threshold.
$$ \dfrac{dT_\text{surf}}{dt} $$
Units
°C/min
Textbook
ISO 14224 + API 670 + UIC 518 — Bearing thermal protection — multi-standard threshold convergence
v5/lib/features_v5.py:2125–2201  ::  extract_temperature_features        "us_rms_leak": us_rms_leak,
        "us_rms_electrical": us_rms_electrical,
        "us_peak": us_peak,
        "us_crest_factor": us_crest_factor,
        "us_baseline_delta_dB": us_baseline_delta_dB,
        "us_spectral_flatness": us_spectral_flatness,
        "us_dominant_freq": us_dominant_freq,
        "us_is_steady": us_is_steady,
        "us_max_rms": us_max_rms,
        # Additional features for 67-feature vector
        "us_rms_bearing_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 25000, 35000)),
        "us_rms_leak_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 36000, 42000)),
        "us_rms_electrical_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 43000, 60000)),
        "us_kurtosis": us_kurtosis,
        "us_steadiness": us_steadiness,
        "us_max_rms_100ms": _rms_to_dbuv(us_max_rms_raw),
    }


# ---------------------------------------------------------------------------
# Temperature Feature Extraction (STTS22H / SHT45 / NTC)
# ---------------------------------------------------------------------------

def 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)
temp_vibration_correlation Temperature–vibration correlation N/A on MAFAULDA
Pearson correlation coefficient between the recent surface temperature history and the recent RMS vibration history, both sampled at 1-minute intervals over the last ~30 minutes. Strong positive correlation (> 0.6) indicates COUPLED mechanical–thermal degradation: a worsening bearing produces more friction heat AND more mechanical vibration in lock-step, the textbook signature of progressive damage. Weak or negative correlation means vibration and temperature are responding to independent inputs — either healthy operation with separate disturbances, or two distinct fault modes developing in parallel.
$$ \rho_{T,v} = \dfrac{\mathrm{cov}(T_\text{surf}, v_\text{rms})}{\sigma_T\,\sigma_v} $$
Units
dimensionless (−1..1)
Textbook
ISO 14224 + API 670 + UIC 518 — Bearing thermal protection — multi-standard threshold convergence
v5/lib/features_v5.py:2125–2201  ::  extract_temperature_features        "us_rms_leak": us_rms_leak,
        "us_rms_electrical": us_rms_electrical,
        "us_peak": us_peak,
        "us_crest_factor": us_crest_factor,
        "us_baseline_delta_dB": us_baseline_delta_dB,
        "us_spectral_flatness": us_spectral_flatness,
        "us_dominant_freq": us_dominant_freq,
        "us_is_steady": us_is_steady,
        "us_max_rms": us_max_rms,
        # Additional features for 67-feature vector
        "us_rms_bearing_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 25000, 35000)),
        "us_rms_leak_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 36000, 42000)),
        "us_rms_electrical_band": _rms_to_dbuv(_bandpass_rms(us_signal, fs_us, 43000, 60000)),
        "us_kurtosis": us_kurtosis,
        "us_steadiness": us_steadiness,
        "us_max_rms_100ms": _rms_to_dbuv(us_max_rms_raw),
    }


# ---------------------------------------------------------------------------
# Temperature Feature Extraction (STTS22H / SHT45 / NTC)
# ---------------------------------------------------------------------------

def 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)