Chapter 2
15 min read
Section 6 of 121

FD001 to FD004: Single vs. Multi-Condition

Benchmarks: C-MAPSS & N-CMAPSS

One Weather, Six Weathers

Imagine training a self-driving car only on a single sunny day in California. It will be a beautiful driver — until you ship it to Norway in December. The model never had a chance to learn that the world has regimes: weather, terrain, traffic density. Drop it into a regime it has never seen and behaviour collapses.

That same regime structure is the central difficulty of multi-condition prognostics. A jet engine's sensors at 35,000 ft cruising at Mach 0.84 produce values that have nothing to do with the same engine sitting at sea-level idle. If you mix readings from both regimes into a single normalisation pipeline, the “degradation signal” you actually learn is mostly the regime label, not the real wear.

The pattern. Single-condition data lets the model spend its capacity on degradation. Multi-condition data forces it to first separate regimes — and only then learn degradation within each one.

The 2x2 Difficulty Matrix

The four C-MAPSS sub-datasets exhaust the cross product of two binary axes: operating conditions (1 vs 6) and fault modes (1 vs 2). The matrix below is what makes them a small but well-chosen benchmark suite.

1 fault mode (HPC degradation)2 fault modes (HPC + Fan)
1 condition (sea level)FD001 - the 'easy' subsetFD003 - same regime, two failure modes
6 conditions (full envelope)FD002 - the 'multi-condition' challengeFD004 - hardest: 6 conditions x 2 faults

Every paper that claims a result on C-MAPSS quotes per-subset RMSE; the delta between FD001 and FD002 measures how robust the model is to operating regimes; the delta between FD002 and FD004 measures robustness to multiple failure modes. Most published methods do well on FD001/FD003 and badly on FD002/FD004 — which is exactly the regime where the GABA / GRACE benefit is largest in the paper's Table II.

What an Operating Condition Looks Like

An operating condition is a single point in the joint distribution of altitude, Mach number, and throttle resolver angle — the three operational-setting columns we met in Section 2.1. C-MAPSS's six canonical conditions are the centroids reported by the original NASA documentation:

Cond.Altitude (k ft)MachThrottle (TRA, %)Plain English
000.00100Sea-level idle
1100.25100Low-altitude takeoff
2200.70100Mid-cruise full throttle
3250.6260Mid-cruise descent
4350.84100High-altitude cruise
5420.84100Top-of-climb

The key fact: cycle-by-cycle, an engine in FD002 jumps between these six regimes. There is no smooth transition. A row labelled cycle 5 might be at sea-level idle; cycle 6 might be at 35,000 ft. Sensors respond to both regime change AND degradation — and unless we factor out the regime first, the degradation signal stays buried under it.

Interactive: The Conditions Space

Click between the four cards. FD001 / FD003 collapse to a single point in the (altitude, Mach) plane; FD002 / FD004 fan out into six visually distinct clusters. Marker size encodes throttle — you can see condition 3 (descent) sit slightly south of condition 2 because of the lower TRA.

Loading operating-conditions viewer…

Two seconds of slider-clicking communicates what fifty pages of prose cannot: FD002 and FD004 are not slightly harder than FD001; they live in a qualitatively different geometry.

Python: Discover Conditions With k-Means

We never get the condition labels for free in real-world data — they have to be re-discovered from the op_set columns. k-means with k=6 nails it on C-MAPSS because the canonical centroids are well-separated. Total cluster sizes are nearly balanced (8869-9115 rows per condition).

Recover the six operating conditions with k-means
🐍discover_conditions.py
1import numpy as np

For ndarray operations and zeros().

2import pandas as pd

For the C-MAPSS file loader.

3from sklearn.cluster import KMeans

scikit-learn's k-means - the workhorse clustering algorithm. We use it to recover the discrete operating-condition labels from the three continuous op_set columns.

EXECUTION STATE
KMeans = Standard Lloyd's algorithm. Initialised with k-means++; converges in <50 iterations on this data.
5COLUMNS = ...

26-column layout from Section 2.1.

10OP_COLS = ["op_set_1", "op_set_2", "op_set_3"]

Just the three operational settings. These are the only columns whose joint distribution defines the operating regime.

EXECUTION STATE
OP_COLS = ['op_set_1', 'op_set_2', 'op_set_3']
physical meaning = altitude, Mach, throttle - the flight envelope
13def discover_conditions(df, n_conditions):

Wrapper that handles both single-condition and multi-condition subsets uniformly. Caller passes n_conditions = 1 for FD001 / FD003 and n_conditions = 6 for FD002 / FD004.

EXECUTION STATE
input: df (DataFrame) = Loaded C-MAPSS table with the OP_COLS columns
input: n_conditions (int) = 1 or 6 depending on the FD subset
returns ndarray = (N,) integer condition labels in [0, n_conditions)
14Docstring

Captures the contract: cluster the op_set columns into n_conditions discrete regimes.

15if n_conditions == 1:

Single-condition subset - skip k-means and return all-zero labels.

16Comment: Single condition - everyone gets label 0.

Trivial branch. No need to spin up k-means just to get a constant.

17return np.zeros(len(df), dtype=np.int64)

Length-matched array of zeros. dtype int64 so it interops with PyTorch index tensors.

EXECUTION STATE
np.zeros(shape, dtype) = Allocates an ndarray filled with zeros. Float64 by default; we override to int64.
Example: FD001 train = Returns ndarray of shape (20631,) all zeros
19ops = df[OP_COLS].to_numpy()

Materialise the three op_set columns as a (N, 3) ndarray. KMeans does not accept DataFrames directly.

EXECUTION STATE
.to_numpy() = Cheap conversion; pandas already stores numerical columns as ndarrays.
ops.shape = (53759, 3) for FD002 train
ops[:3] =
[[34.99, 0.84, 100.0], [41.99, 0.84, 100.0], [24.99, 0.62, 60.0]]
20km = KMeans(n_clusters=n_conditions, n_init=10, random_state=0).fit(ops)

Run k-means on the operational settings. The arguments matter: n_init=10 runs the algorithm ten times from different random initialisations and keeps the best result; random_state=0 makes the run reproducible.

EXECUTION STATE
KMeans(n_clusters, n_init, random_state) = Constructor. n_clusters fixes k; n_init defends against bad initial centroids; random_state locks the RNG.
arg: n_clusters=n_conditions = 6 in our example
arg: n_init=10 = Run k-means 10 times, keep the lowest-inertia result
arg: random_state=0 = Reproducible centroid initialisation
.fit(ops) = Mutates km in-place. Sets km.labels_ and km.cluster_centers_.
km.cluster_centers_.shape = (6, 3) - one row per centroid
21return km.labels_

(N,) integer array - the learned cluster assignment for each row. This is what we will use in Chapter 6 for per-condition normalisation.

EXECUTION STATE
km.labels_.dtype = int32 by default - we cast to int64 inside Dataset code for PyTorch compatibility
Example: km.labels_[:5] = [5, 5, 3, 4, 0] - varying conditions in the FD002 file
25df = pd.read_csv("data/raw/train_FD002.txt", sep=r"\s+", header=None, names=COLUMNS)

Same loader as Section 2.1.

EXECUTION STATE
df.shape = (53759, 26)
27df["condition"] = discover_conditions(df, n_conditions=6)

Add a new 'condition' column to the DataFrame. Now every row knows which operating regime it belongs to.

EXECUTION STATE
df.shape after = (53759, 27) - one extra column
df['condition'].dtype = int64
30print(df["condition"].value_counts().sort_index())

How many rows per condition? .value_counts returns a Series of counts; .sort_index orders by condition number.

EXECUTION STATE
.value_counts() = Returns the count of each unique value, sorted by frequency descending by default.
.sort_index() = Resort by index value (here: condition 0..5)
Output = 0:8896, 1:8869, 2:8989, 3:8900, 4:8990, 5:9115
→ balanced! = Engines spend roughly equal time in each regime - C-MAPSS samples conditions uniformly
39ops = df[OP_COLS].to_numpy()

Re-extract the op_set values for the centroid printout.

40km = KMeans(n_clusters=6, n_init=10, random_state=0).fit(ops)

Rerun k-means so we have a km object to read centroids off of.

41for i, c in enumerate(km.cluster_centers_):

Iterate the 6 cluster centroids. Each c is a length-3 vector (alt, Mach, TRA).

LOOP TRACE · 6 iterations
i=0
centroid = ( 0.0, 0.000, 100.0) - sea-level idle
i=1
centroid = ( 10.0, 0.250, 100.0) - low-altitude takeoff
i=2
centroid = ( 20.0, 0.700, 100.0) - mid cruise full throttle
i=3
centroid = ( 25.0, 0.620, 60.0) - mid cruise reduced throttle
i=4
centroid = ( 35.0, 0.840, 100.0) - high cruise full throttle
i=5
centroid = ( 42.0, 0.840, 100.0) - high-altitude full throttle
42print(f"cond {i}: alt={c[0]:5.1f} Mach={c[1]:.3f} TRA={c[2]:5.1f}")

Pretty-printed centroid. The values match the canonical C-MAPSS flight envelope: ground / takeoff / mid cruise / mid cruise descent / high cruise / max altitude.

EXECUTION STATE
Output = cond 0: alt= 0.0 Mach=0.000 TRA=100.0
Output = cond 5: alt= 42.0 Mach=0.840 TRA=100.0
29 lines without explanation
1import numpy as np
2import pandas as pd
3from sklearn.cluster import KMeans
4
5COLUMNS = (
6    ["engine_id", "cycle"]
7    + [f"op_set_{i}" for i in range(1, 4)]
8    + [f"sensor_{i}" for i in range(1, 22)]
9)
10OP_COLS = ["op_set_1", "op_set_2", "op_set_3"]
11
12
13def discover_conditions(df: pd.DataFrame, n_conditions: int) -> np.ndarray:
14    """Cluster the op_set columns into n_conditions discrete regimes."""
15    if n_conditions == 1:
16        # Single condition - everyone gets label 0.
17        return np.zeros(len(df), dtype=np.int64)
18
19    ops = df[OP_COLS].to_numpy()
20    km = KMeans(n_clusters=n_conditions, n_init=10, random_state=0).fit(ops)
21    return km.labels_
22
23
24# ----- Use it on FD002 -----
25df = pd.read_csv("data/raw/train_FD002.txt", sep=r"\s+",
26                 header=None, names=COLUMNS)
27df["condition"] = discover_conditions(df, n_conditions=6)
28
29# Verify the cluster sizes are roughly balanced
30print(df["condition"].value_counts().sort_index())
31# 0    8896
32# 1    8869
33# 2    8989
34# 3    8900
35# 4    8990
36# 5    9115
37# Name: condition, dtype: int64
38
39# Inspect the centroids
40ops = df[OP_COLS].to_numpy()
41km = KMeans(n_clusters=6, n_init=10, random_state=0).fit(ops)
42for i, c in enumerate(km.cluster_centers_):
43    print(f"cond {i}: alt={c[0]:5.1f}  Mach={c[1]:.3f}  TRA={c[2]:5.1f}")
44# cond 0: alt=  0.0   Mach=0.000   TRA=100.0
45# cond 1: alt= 10.0   Mach=0.250   TRA=100.0
46# cond 2: alt= 20.0   Mach=0.700   TRA=100.0
47# cond 3: alt= 25.0   Mach=0.620   TRA= 60.0
48# cond 4: alt= 35.0   Mach=0.840   TRA=100.0
49# cond 5: alt= 42.0   Mach=0.840   TRA=100.0

How robust is k-means here?

Very. The op_set centroids are separated by altitudes ranging from 0 to 42 kft — an order of magnitude larger than the noise inside any cluster. Misclassification rate against the synthetic ground truth is below 0.1% for n_init ≥ 5. Robust convergence is the reason every C-MAPSS preprocessing pipeline uses k-means rather than a more involved Gaussian-mixture model.

PyTorch: A Condition-Aware Dataset

Extend Section 2.1's CMAPSSDataset with the condition column. __getitem__ now returns a three-tuple (X,c,y)(\mathbf{X},\,\mathbf{c},\, y) — sensor window, condition sequence, RUL target.

A condition-aware sliding-window dataset
🐍cmapss_dataset_by_condition.py
1import numpy as np

Same NumPy as before.

2import pandas as pd

Loader.

3import torch

Tensor type.

4from torch.utils.data import Dataset

Base class.

5from sklearn.cluster import KMeans

Clustering.

7COLUMNS = ...

26-column layout.

12SENSOR_COLS = ...

21 sensor names.

13OP_COLS = ...

3 operational-setting names.

16class CMAPSSDatasetByCondition(Dataset):

Extends Section 2.1's CMAPSSDataset with an extra return value: the per-cycle condition label. Everything else is identical.

EXECUTION STATE
key change = __getitem__ returns (X, c_seq, y) instead of (X, y)
21def __init__(self, csv_path, n_conditions=6, window=30):

Three constructor args. n_conditions defaults to 6 (the FD002/FD004 case); pass 1 for FD001/FD003.

EXECUTION STATE
input: csv_path (str) = C-MAPSS train file path
input: n_conditions (int) = 1 or 6
input: window (int) = 30 - same as Section 2.1
22df = pd.read_csv(csv_path, sep=r'\s+', header=None, names=COLUMNS)

Identical loading.

23df["RUL"] = ... groupby("engine_id")["cycle"].transform("max") - df["cycle"]

Per-engine RUL labelling.

27if n_conditions > 1:

Branch on whether we need clustering.

28km = KMeans(n_clusters=n_conditions, n_init=10, random_state=0).fit(df[OP_COLS].to_numpy())

Fit k-means on the entire training file's op_set columns. Once. The labels are deterministic given the seed.

EXECUTION STATE
Why fit on the full file? = Conditions are global properties of the simulator, not engine-specific. One fit suffices for the whole train set.
30df["condition"] = km.labels_.astype(np.int64)

Attach the labels back onto the DataFrame and cast to int64 for PyTorch.

EXECUTION STATE
.astype(np.int64) = PyTorch index-style tensors expect int64. Cheap copy.
31else:

Single-condition case.

32df["condition"] = 0

Pandas broadcasts the scalar to a full column of zeros.

EXECUTION STATE
result = df['condition'] is a length-N int64 column of all zeros
34self.window = window

Stash window length.

35self.samples = []

Indexable list of (engine_id, end_cycle).

36self.engines = {}

Per-engine pre-converted arrays.

37for eid, sub in df.groupby('engine_id'):

One iteration per engine.

38arr = sub[SENSOR_COLS].to_numpy(dtype=np.float32)

Per-engine sensor matrix.

EXECUTION STATE
Example: engine 1 = arr.shape = (149, 21)
39ruls = sub['RUL'].to_numpy(dtype=np.float32)

Per-engine RUL targets.

40cond = sub['condition'].to_numpy(dtype=np.int64)

Per-engine condition labels - same length as arr / ruls. The condition can change cycle-to-cycle within one engine because flight regimes change in flight.

EXECUTION STATE
Example: engine 1, cycles 0..4 = cond[:5] = [5, 5, 3, 4, 0] - regime switches every cycle
41self.engines[eid] = (arr, ruls, cond)

Three-tuple in the dict instead of two.

42for end in range(window, len(sub) + 1):

Sliding-window loop.

43self.samples.append((eid, end))

Same indexing as before.

45def __len__(self):

Number of samples.

46return len(self.samples)

46059 for FD002 train.

48def __getitem__(self, idx):

DataLoader entry point.

49eid, end = self.samples[idx]

Look up engine and end cycle.

50arr, ruls, cond = self.engines[eid]

Pull all three pre-converted arrays.

51X = torch.from_numpy(arr[end - self.window:end])

Same sensor window slice as Section 2.1.

EXECUTION STATE
X.shape = torch.Size([30, 21])
52c_seq = torch.from_numpy(cond[end - self.window:end])

Per-cycle condition labels for the SAME 30-cycle window. Crucial for per-condition normalisation in Chapter 6.

EXECUTION STATE
c_seq.shape = torch.Size([30])
c_seq.dtype = torch.int64
Example = [5, 5, 3, 4, 0, 1, 2, 5, 4, 3, 0, 1, 2, 5, 4, 3, 0, 1, 2, 5, 4, 3, 0, 1, 2, 5, 4, 3, 0, 1]
53y = torch.tensor(ruls[end - 1])

Scalar RUL at the end of the window.

54return X, c_seq, y

Three-tuple. The training loop in Part V/VI/VII will accept this signature.

EXECUTION STATE
tuple shapes = (torch.Size([30, 21]), torch.Size([30]), torch.Size([]))
58ds = CMAPSSDatasetByCondition("data/raw/train_FD002.txt", n_conditions=6, window=30)

Construct on FD002.

60X, c_seq, y = ds[0]

First sample.

61print("X :", tuple(X.shape))

Verify sensor shape.

EXECUTION STATE
Output = X : (30, 21)
62print("c_seq:", tuple(c_seq.shape), c_seq[:5].tolist())

Verify the condition sequence.

EXECUTION STATE
Output = c_seq: (30,) [5, 5, 3, 4, 0]
63print("y :", float(y))

Verify RUL.

EXECUTION STATE
Output = y : 119.0
25 lines without explanation
1import numpy as np
2import pandas as pd
3import torch
4from torch.utils.data import Dataset
5from sklearn.cluster import KMeans
6
7COLUMNS = (
8    ["engine_id", "cycle"]
9    + [f"op_set_{i}" for i in range(1, 4)]
10    + [f"sensor_{i}" for i in range(1, 22)]
11)
12SENSOR_COLS = [f"sensor_{i}" for i in range(1, 22)]
13OP_COLS     = [f"op_set_{i}" for i in range(1, 4)]
14
15
16class CMAPSSDatasetByCondition(Dataset):
17    """Sliding-window CMAPSS dataset that also returns the operating
18    condition for every sample. Reuses Section 2.1's structure plus a
19    k-means clustering on the op_set columns."""
20
21    def __init__(self, csv_path: str, n_conditions: int = 6, window: int = 30):
22        df = pd.read_csv(csv_path, sep=r"\s+", header=None, names=COLUMNS)
23        df["RUL"] = (df.groupby("engine_id")["cycle"].transform("max")
24                     - df["cycle"])
25
26        # Discover conditions
27        if n_conditions > 1:
28            km = KMeans(n_clusters=n_conditions, n_init=10,
29                        random_state=0).fit(df[OP_COLS].to_numpy())
30            df["condition"] = km.labels_.astype(np.int64)
31        else:
32            df["condition"] = 0
33
34        self.window  = window
35        self.samples = []
36        self.engines = {}
37        for eid, sub in df.groupby("engine_id"):
38            arr  = sub[SENSOR_COLS].to_numpy(dtype=np.float32)
39            ruls = sub["RUL"].to_numpy(dtype=np.float32)
40            cond = sub["condition"].to_numpy(dtype=np.int64)
41            self.engines[eid] = (arr, ruls, cond)
42            for end in range(window, len(sub) + 1):
43                self.samples.append((eid, end))
44
45    def __len__(self) -> int:
46        return len(self.samples)
47
48    def __getitem__(self, idx: int):
49        eid, end = self.samples[idx]
50        arr, ruls, cond = self.engines[eid]
51        X     = torch.from_numpy(arr [end - self.window:end])  # (W, 21)
52        c_seq = torch.from_numpy(cond[end - self.window:end])  # (W,) condition labels
53        y     = torch.tensor(ruls[end - 1])                     # scalar RUL
54        return X, c_seq, y
55
56
57# ----- Use it -----
58ds = CMAPSSDatasetByCondition("data/raw/train_FD002.txt",
59                              n_conditions=6, window=30)
60X, c_seq, y = ds[0]
61print("X    :", tuple(X.shape))
62print("c_seq:", tuple(c_seq.shape), c_seq[:5].tolist())
63print("y    :", float(y))
64# X    : (30, 21)
65# c_seq: (30,) [5, 5, 3, 4, 0]
66# y    : 119.0
Why a per-cycle condition sequence and not a per-window scalar? Because the regime can switch within a single 30-cycle window. Even if the engine spends most of its 30 cycles at high cruise, one cycle at idle would contaminate the window-level mean. Per-cycle labels let Chapter 6 normalise each cycle independently.

Multi-Condition Problems Outside RUL

The structural pattern “features are dominated by regime, not by the signal of interest” appears all over machine learning. Every row of the table below has been solved with the same per-condition normalisation idea we apply in Chapter 6.

DomainRegimesSignal of interestCommon fix
Turbofan RUL (this book)Flight envelopesComponent degradationPer-condition Z-score
Speech recognitionSpeakers, microphones, roomsPhoneme contentSpeaker / channel normalisation
Medical imagingScanner vendorsPathologySite-level harmonisation, ComBat
Recommender systemsGeo / device / time-of-dayUser preferenceContextual bandit features
Autonomous drivingWeather / city / timeLane lines, agentsDomain-randomisation, batch norm per regime
Wearables / ECGResting / exercise / sleepCardiac stateActivity-conditional models
Industrial visionLighting, camera angleDefect signatureReference-image normalisation

The book's machinery — cluster, normalise per cluster, train on the residual signal — transfers to all of these without a single domain-specific change.

The Mixing Trap

The most common implementation bug in multi-condition pipelines. Compute one mean and one std across the entire training file, normalise every cycle by them, and proudly hand the (X, y) pairs to a model. The model now learns a near-perfect mapping from regime to RUL — which is useless because regime is determined by the flight controller, not by engine health. RMSE on the train set looks great; RMSE on the test set collapses.

Section 6.2 walks through exactly how this fails empirically and shows the per-condition fix. For now the mental rule is: normalise inside a regime, not across regimes.

Why this whole chapter exists. Multi-condition data is the regime where every method discussed in this book differentiates itself. On FD001 / FD003 most methods bunch together; on FD002 / FD004 the gradient-aware objectives pull ahead by 28-76% on NASA score and 18-40% on RMSE.

Takeaway

  • Four sub-datasets, two binary axes. 1 vs 6 operating conditions, 1 vs 2 fault modes. FD002 and FD004 are where the research benefit lives.
  • An operating condition is a point in (altitude, Mach, TRA). Six canonical centroids span the realistic flight envelope from idle to top-of-climb.
  • k-means with k=6 recovers the conditions in one line. n_init=10, random_state=0, .fit on the op_set columns. Misclassification below 0.1%.
  • The condition label belongs in the dataset, not the model. CMAPSSDatasetByCondition emits a per-cycle condition sequence alongside every input window.
  • The mixing trap is real. Single mean / std across regimes turns the “model” into a regime classifier in disguise.
Loading comments...