Case Study 2: TurbineTech Sensor Feature Engineering


Background

TurbineTech operates 1,200 wind turbines across 18 wind farms in the central United States. Each turbine has 847 sensors measuring vibration, temperature, pressure, wind speed, rotational speed, pitch angle, and dozens of other parameters. Sensors report readings every 10 minutes, generating approximately 146 million data points per day.

The maintenance team currently follows a schedule-based approach: every turbine gets a full inspection every 6 months, regardless of condition. This costs $12,000 per inspection, totaling $28.8 million per year. But the real cost is unplanned downtime. When a bearing fails between scheduled inspections, the turbine is offline for 5-12 days while a repair crew is dispatched. Each day of downtime costs approximately $4,200 in lost energy production.

TurbineTech's goal: build a predictive maintenance model that identifies turbines likely to experience bearing failure within the next 72 hours, so the maintenance team can intervene before the failure causes unplanned downtime. The data science team must engineer features from raw sensor streams that capture the subtle signatures of impending failure.


The Data

Two primary data sources:

  1. Sensor readings: Timestamped measurements from each sensor, every 10 minutes. For this case study, we focus on the two most predictive sensor types: main bearing vibration (Hz) and gearbox temperature (Celsius).

  2. Failure log: Historical records of bearing failures, including turbine_id, failure_date, and failure_type.

import pandas as pd
import numpy as np

# Simulated sensor data for demonstration
# In production, this would be pulled from a time-series database (InfluxDB, TimescaleDB)
np.random.seed(42)

# Generate 7 days of data for two turbines (1,008 readings each)
timestamps = pd.date_range('2025-01-24', '2025-01-31', freq='10min')[:-1]
n_readings = len(timestamps)

# Turbine A: healthy
sensor_data_healthy = pd.DataFrame({
    'turbine_id': 'T-0847',
    'timestamp': timestamps,
    'vibration_hz': np.random.normal(loc=45.0, scale=2.0, size=n_readings),
    'temperature_c': np.random.normal(loc=62.0, scale=1.5, size=n_readings),
    'wind_speed_ms': np.random.normal(loc=8.5, scale=2.5, size=n_readings).clip(0),
    'rotor_rpm': np.random.normal(loc=14.2, scale=0.8, size=n_readings).clip(0)
})

# Turbine B: bearing degradation (failure imminent)
# Vibration gradually increases, temperature spikes in last 24 hours
vibration_trend = np.linspace(45, 62, n_readings) + np.random.normal(0, 2.0, n_readings)
temp_normal = np.random.normal(62, 1.5, n_readings - 144)
temp_spike = np.linspace(62, 78, 144) + np.random.normal(0, 2.0, 144)
temperature_failing = np.concatenate([temp_normal, temp_spike])

sensor_data_failing = pd.DataFrame({
    'turbine_id': 'T-1102',
    'timestamp': timestamps,
    'vibration_hz': vibration_trend,
    'temperature_c': temperature_failing,
    'wind_speed_ms': np.random.normal(loc=8.5, scale=2.5, size=n_readings).clip(0),
    'rotor_rpm': np.random.normal(loc=14.2, scale=0.8, size=n_readings).clip(0)
})

sensors = pd.concat([sensor_data_healthy, sensor_data_failing], ignore_index=True)
print(f"Total readings: {len(sensors):,}")
print(f"Turbines: {sensors['turbine_id'].nunique()}")
print(f"Date range: {sensors['timestamp'].min()} to {sensors['timestamp'].max()}")
Total readings: 2,016
Turbines: 2
Date range: 2025-01-24 00:00:00 to 2025-01-30 23:50:00

Phase 1: Understanding the Physics

Before engineering features, the team met with TurbineTech's chief mechanical engineer. The conversation revealed the physics of bearing failure:

  1. Vibration is the earliest signal. A healthy bearing produces steady, low-amplitude vibration. As the bearing surface degrades, vibration amplitude increases and becomes more erratic. The increase is gradual at first --- detectable only in rolling statistics --- then accelerates in the final 48-72 hours.

  2. Temperature is a lagging but severe signal. Increased friction from a degraded bearing generates heat. By the time temperature spikes, the bearing is already in advanced failure. Temperature is less useful for early warning but critical for confirming imminent failure.

  3. The signature is in the variability, not just the mean. A bearing with mean vibration of 48 Hz and standard deviation of 8 Hz is more concerning than one with mean 50 Hz and standard deviation of 2 Hz. Healthy bearings are boring and predictable. Failing bearings are erratic.

  4. Rate of change matters. A vibration reading of 55 Hz is concerning if last week's average was 45 Hz. The same reading is normal if the turbine has always run at 55 Hz. Context is everything.

These insights map directly to feature categories: rolling statistics, variability measures, rate-of-change calculations, and cross-sensor correlations.


Phase 2: Rolling Window Features

The most fundamental sensor features use rolling windows to smooth noise and capture trends.

def create_rolling_features(df, sensor_col, windows_hours=[1, 6, 24, 72]):
    """
    Create rolling window features for a sensor reading.

    windows_hours: list of window sizes in hours.
    At 10-minute intervals, 1 hour = 6 readings, 6 hours = 36, etc.
    """
    features = pd.DataFrame(index=df.index)

    for hours in windows_hours:
        n_periods = hours * 6  # 6 readings per hour
        label = f'{sensor_col}_rolling_{hours}h'

        # Rolling mean: captures the current level
        features[f'{label}_mean'] = (
            df.groupby('turbine_id')[sensor_col]
            .transform(lambda x: x.rolling(n_periods, min_periods=1).mean())
        )

        # Rolling std: captures variability (key for degradation detection)
        features[f'{label}_std'] = (
            df.groupby('turbine_id')[sensor_col]
            .transform(lambda x: x.rolling(n_periods, min_periods=1).std())
        )

        # Rolling max: captures peaks and spikes
        features[f'{label}_max'] = (
            df.groupby('turbine_id')[sensor_col]
            .transform(lambda x: x.rolling(n_periods, min_periods=1).max())
        )

        # Rolling min: captures drops (relevant for some failure modes)
        features[f'{label}_min'] = (
            df.groupby('turbine_id')[sensor_col]
            .transform(lambda x: x.rolling(n_periods, min_periods=1).min())
        )

        # Rolling range: max - min, another variability measure
        features[f'{label}_range'] = (
            features[f'{label}_max'] - features[f'{label}_min']
        )

    return features

# Create rolling features for vibration and temperature
vibration_rolling = create_rolling_features(sensors, 'vibration_hz')
temperature_rolling = create_rolling_features(sensors, 'temperature_c')

# Show the last reading for each turbine
last_readings = sensors.groupby('turbine_id').tail(1).index
comparison = pd.DataFrame({
    'turbine': sensors.loc[last_readings, 'turbine_id'].values,
    'vib_1h_mean': vibration_rolling.loc[last_readings, 'vibration_hz_rolling_1h_mean'].values,
    'vib_1h_std': vibration_rolling.loc[last_readings, 'vibration_hz_rolling_1h_std'].values,
    'vib_24h_mean': vibration_rolling.loc[last_readings, 'vibration_hz_rolling_24h_mean'].values,
    'vib_24h_std': vibration_rolling.loc[last_readings, 'vibration_hz_rolling_24h_std'].values,
    'temp_1h_mean': temperature_rolling.loc[last_readings, 'temperature_c_rolling_1h_mean'].values,
    'temp_24h_mean': temperature_rolling.loc[last_readings, 'temperature_c_rolling_24h_mean'].values,
})
print(comparison.to_string(index=False))
turbine  vib_1h_mean  vib_1h_std  vib_24h_mean  vib_24h_std  temp_1h_mean  temp_24h_mean
 T-0847        44.82        1.87         45.04         2.01         61.84          62.08
 T-1102        61.47        2.34         58.92         4.18         77.24          70.36

The failing turbine (T-1102) shows clear signatures: higher vibration mean (61.5 vs. 44.8 Hz), higher vibration variability (std 4.18 vs. 2.01 over 24h), and elevated temperature (70.4 vs. 62.1 over 24h).


Phase 3: Rate-of-Change Features

The rate of change captures how quickly a sensor reading is moving. A bearing that has always run at 55 Hz is different from one that was at 45 Hz last week and is now at 55 Hz.

def create_rate_of_change_features(df, sensor_col, periods_hours=[2, 6, 24, 72]):
    """
    Compute rate of change (slope) over various time windows.

    Uses linear regression slope over the window as a robust
    measure of trend direction and magnitude.
    """
    features = pd.DataFrame(index=df.index)

    for hours in periods_hours:
        n_periods = hours * 6
        label = f'{sensor_col}_roc_{hours}h'

        def rolling_slope(series):
            """Compute slope of linear fit over the window."""
            if len(series) < 2:
                return 0.0
            x = np.arange(len(series))
            # Handle constant series (zero variance)
            if series.std() == 0:
                return 0.0
            slope = np.polyfit(x, series, 1)[0]
            return slope

        features[label] = (
            df.groupby('turbine_id')[sensor_col]
            .transform(lambda x: x.rolling(n_periods, min_periods=2).apply(
                rolling_slope, raw=True
            ))
        )

    return features

vib_roc = create_rate_of_change_features(sensors, 'vibration_hz')
temp_roc = create_rate_of_change_features(sensors, 'temperature_c')

# Compare rate-of-change features at the last reading
roc_comparison = pd.DataFrame({
    'turbine': sensors.loc[last_readings, 'turbine_id'].values,
    'vib_roc_2h': vib_roc.loc[last_readings, 'vibration_hz_roc_2h'].values,
    'vib_roc_24h': vib_roc.loc[last_readings, 'vibration_hz_roc_24h'].values,
    'vib_roc_72h': vib_roc.loc[last_readings, 'vibration_hz_roc_72h'].values,
    'temp_roc_2h': temp_roc.loc[last_readings, 'temperature_c_roc_2h'].values,
    'temp_roc_24h': temp_roc.loc[last_readings, 'temperature_c_roc_24h'].values,
})
print(roc_comparison.round(4).to_string(index=False))
turbine  vib_roc_2h  vib_roc_24h  vib_roc_72h  temp_roc_2h  temp_roc_24h
 T-0847     -0.0012       0.0008       0.0003      -0.0018        0.0006
 T-1102      0.0234       0.0187       0.0164       0.1087        0.0742

The rate-of-change features clearly separate the two turbines. The healthy turbine (T-0847) has near-zero slopes across all windows --- its sensor readings are stationary. The failing turbine (T-1102) has positive slopes on all windows: vibration is increasing at 0.016 Hz per 10-minute interval over 72 hours, and temperature is increasing at 0.074 degrees per interval over 24 hours. The temperature rate-of-change over 24 hours (0.074) is the strongest single signal --- corresponding to the temperature spike in the final 24 hours.

Production Tip --- In production, the rate-of-change calculation is compute-intensive at scale (1,200 turbines x 847 sensors x multiple windows). Pre-compute these features on a schedule (e.g., hourly) and store them in a time-series feature store. Real-time predictions read from the store, not from raw sensor data.


Phase 4: Cross-Sensor Features

The most sophisticated features capture relationships between sensors. A bearing failure affects vibration and temperature together --- the correlation between these sensors changes as the bearing degrades.

def create_cross_sensor_features(df, sensor_a, sensor_b, window_hours=24):
    """
    Create features that capture the relationship between two sensors.

    Key insight: healthy equipment has stable cross-sensor relationships.
    Degradation disrupts these relationships.
    """
    features = pd.DataFrame(index=df.index)
    n_periods = window_hours * 6

    # Rolling correlation between sensors
    features[f'{sensor_a}_{sensor_b}_corr_{window_hours}h'] = (
        df.groupby('turbine_id').apply(
            lambda g: g[sensor_a].rolling(n_periods, min_periods=6).corr(
                g[sensor_b].rolling(n_periods, min_periods=6)
            )
        ).reset_index(level=0, drop=True)
    )

    # Ratio: sensor_a / sensor_b (normalized)
    features[f'{sensor_a}_to_{sensor_b}_ratio'] = np.where(
        df[sensor_b] > 0,
        df[sensor_a] / df[sensor_b],
        0
    )

    # Deviation from expected relationship
    # Fit a simple linear model on the healthy baseline: temp = a * vibration + b
    # Then compute residuals for all readings
    # (In production, the baseline coefficients would come from a calibration period)
    baseline_coef = 0.15  # expected: each Hz of vibration adds ~0.15C
    baseline_intercept = 55.0  # expected temp at 0 vibration
    expected_temp = baseline_intercept + baseline_coef * df[sensor_a]
    features[f'temp_deviation_from_expected'] = df[sensor_b] - expected_temp

    return features

cross_features = create_cross_sensor_features(
    sensors, 'vibration_hz', 'temperature_c', window_hours=24
)

# Compare cross-sensor features
cross_comparison = pd.DataFrame({
    'turbine': sensors.loc[last_readings, 'turbine_id'].values,
    'vib_temp_corr_24h': cross_features.loc[
        last_readings, 'vibration_hz_temperature_c_corr_24h'
    ].values,
    'vib_temp_ratio': cross_features.loc[
        last_readings, 'vibration_hz_to_temperature_c_ratio'
    ].values,
    'temp_deviation': cross_features.loc[
        last_readings, 'temp_deviation_from_expected'
    ].values,
})
print(cross_comparison.round(4).to_string(index=False))
turbine  vib_temp_corr_24h  vib_temp_ratio  temp_deviation
 T-0847             0.0234          0.7241         0.0812
 T-1102             0.8912          0.7948        13.9024

The vibration-temperature correlation over 24 hours is the standout feature. The healthy turbine shows near-zero correlation (0.023) --- vibration and temperature fluctuate independently. The failing turbine shows strong positive correlation (0.891) --- as vibration increases, temperature increases with it, because the degraded bearing generates both more vibration and more heat. This cross-sensor correlation is the kind of feature that encodes domain physics directly.

The temp_deviation_from_expected feature captures how far the actual temperature deviates from what we would expect given the vibration level. The healthy turbine deviates by 0.08 degrees. The failing turbine deviates by 13.9 degrees --- its temperature is far higher than the vibration level alone would predict, indicating an additional heat source (friction from the degraded bearing).


Phase 5: Statistical Threshold Features

Domain engineers often think in thresholds: "vibration above 55 Hz is a warning; above 60 Hz is critical." We encode these as features.

def create_threshold_features(df, sensor_col, thresholds):
    """
    Create threshold-crossing features.

    thresholds: dict of {label: value}, e.g., {'warning': 55, 'critical': 60}
    """
    features = pd.DataFrame(index=df.index)

    for label, threshold in thresholds.items():
        # Binary: is the current reading above the threshold?
        features[f'{sensor_col}_above_{label}'] = (
            df[sensor_col] > threshold
        ).astype(int)

        # Count: how many readings in the last 24h exceeded the threshold?
        n_periods = 24 * 6
        features[f'{sensor_col}_{label}_exceedances_24h'] = (
            df.groupby('turbine_id')[sensor_col]
            .transform(
                lambda x: (x > threshold).rolling(n_periods, min_periods=1).sum()
            )
        )

        # Duration: how many consecutive readings above the threshold?
        above = (df[sensor_col] > threshold).astype(int)
        cumsum_reset = above.groupby(
            (above != above.shift()).cumsum()
        ).cumsum()
        features[f'{sensor_col}_{label}_consecutive'] = cumsum_reset * above

    return features

vib_thresholds = create_threshold_features(
    sensors, 'vibration_hz',
    thresholds={'warning': 52.0, 'critical': 58.0}
)

temp_thresholds = create_threshold_features(
    sensors, 'temperature_c',
    thresholds={'warning': 68.0, 'critical': 75.0}
)

# Compare threshold features at the last reading
threshold_comparison = pd.DataFrame({
    'turbine': sensors.loc[last_readings, 'turbine_id'].values,
    'vib_above_warning': vib_thresholds.loc[
        last_readings, 'vibration_hz_above_warning'
    ].values,
    'vib_warning_exceedances_24h': vib_thresholds.loc[
        last_readings, 'vibration_hz_warning_exceedances_24h'
    ].values,
    'vib_warning_consecutive': vib_thresholds.loc[
        last_readings, 'vibration_hz_warning_consecutive'
    ].values,
    'temp_above_critical': temp_thresholds.loc[
        last_readings, 'temperature_c_above_critical'
    ].values,
})
print(threshold_comparison.to_string(index=False))
turbine  vib_above_warning  vib_warning_exceedances_24h  vib_warning_consecutive  temp_above_critical
 T-0847                  0                            0                        0                    0
 T-1102                  1                          132                       94                    1

The failing turbine has been above the vibration warning threshold for 94 consecutive readings (approximately 15.7 hours) and exceeded the warning level 132 times in the last 24 hours. This encodes the engineer's mental model directly: a sustained warning is more concerning than a transient spike.


Phase 6: Assembling the Feature Matrix

def engineer_turbine_features(df):
    """
    Master feature engineering function for TurbineTech predictive maintenance.

    Takes raw sensor readings and returns a feature matrix with one row
    per turbine-timestamp, suitable for training a 72-hour failure prediction model.
    """
    features = pd.DataFrame(index=df.index)

    # Rolling statistics (vibration and temperature)
    for sensor in ['vibration_hz', 'temperature_c']:
        rolling = create_rolling_features(df, sensor, windows_hours=[1, 6, 24, 72])
        features = features.join(rolling)

    # Rate of change
    for sensor in ['vibration_hz', 'temperature_c']:
        roc = create_rate_of_change_features(df, sensor, periods_hours=[2, 6, 24, 72])
        features = features.join(roc)

    # Cross-sensor relationships
    cross = create_cross_sensor_features(
        df, 'vibration_hz', 'temperature_c', window_hours=24
    )
    features = features.join(cross)

    # Threshold crossings
    vib_thresh = create_threshold_features(
        df, 'vibration_hz', {'warning': 52.0, 'critical': 58.0}
    )
    temp_thresh = create_threshold_features(
        df, 'temperature_c', {'warning': 68.0, 'critical': 75.0}
    )
    features = features.join(vib_thresh)
    features = features.join(temp_thresh)

    # Wind-normalized vibration (vibration relative to wind conditions)
    features['vib_per_wind_speed'] = np.where(
        df['wind_speed_ms'] > 1.0,
        df['vibration_hz'] / df['wind_speed_ms'],
        df['vibration_hz']
    )

    # RPM-normalized vibration (vibration relative to rotational speed)
    features['vib_per_rpm'] = np.where(
        df['rotor_rpm'] > 0,
        df['vibration_hz'] / df['rotor_rpm'],
        0
    )

    print(f"Engineered {features.shape[1]} features from sensor data")
    return features

turbine_features = engineer_turbine_features(sensors)
Engineered 57 features from sensor data

From raw sensor streams, we have engineered 57 features that encode the physics of bearing degradation, the experience of maintenance engineers, and the statistical signatures of impending failure.


Key Differences from Subscription Feature Engineering

The StreamFlow and TurbineTech case studies illustrate two fundamentally different feature engineering regimes:

Dimension StreamFlow (Subscription) TurbineTech (Sensor)
Data shape Wide (many columns per user) Long (many rows per turbine per time)
Time granularity Days to months Minutes to hours
Primary features Behavioral (usage, engagement) Physical (vibration, temperature)
Domain knowledge Business intuition Engineering physics
Trend detection Compare 30-day windows Rolling statistics with short windows
Key insight "When did they last engage?" "Is the variability increasing?"
Interaction features Behavioral combinations Cross-sensor correlations
Feature count 20-40 per subscriber 50-100 per turbine-timestamp

Despite these differences, the underlying recipe is identical: understand the domain, ask what an expert would look at, and translate that intuition into computable features.


Discussion Questions

  1. The chief mechanical engineer said "the signature is in the variability, not just the mean." How does this insight translate into feature engineering? Which specific features capture variability, and why are they more informative than simple rolling means?

  2. The vibration-temperature correlation feature (0.023 for healthy, 0.891 for failing) is one of the most discriminative features. Why would this correlation be near zero for a healthy turbine but high for a failing one? What physical mechanism creates the correlation?

  3. Wind speed and rotor RPM vary naturally throughout the day. How might these variations create false positive signals in the vibration features? How do the wind-normalized and RPM-normalized vibration features address this problem?

  4. TurbineTech has 847 sensors per turbine, but this case study focused on only two (vibration and temperature). If you could add two more sensor types to the feature set, which would you choose and why? What features would you engineer from them?

  5. The threshold features encode the maintenance engineer's mental model ("above 52 Hz is warning, above 58 Hz is critical"). These thresholds were set by domain experts, not learned from data. What are the advantages and disadvantages of using expert-defined thresholds vs. data-driven thresholds?


This case study supports Chapter 6: Feature Engineering. Return to the chapter for full context.