Chapter 2
14 min read
Section 5 of 121

NASA C-MAPSS Overview

Benchmarks: C-MAPSS & N-CMAPSS

The ImageNet of Prognostics

ImageNet did not just give vision researchers a benchmark; it gave them a common language. Once a million labelled images existed, an entire generation of architectures became comparable, reproducible, and rankable on a single leaderboard. In prognostics that role belongs to NASA C-MAPSS — a public dataset of simulated turbofan run-to-failure trajectories released by the NASA Prognostic Center of Excellence in 2008. Almost every RUL paper of the past fifteen years quotes a number from one of its four sub-datasets.

C-MAPSS is short for Commercial Modular Aero-Propulsion System Simulation. NASA built it as a thermo-mechanical model of a high-bypass turbofan with five rotating components (fan, LPC, HPC, HPT, LPT), then ran thousands of simulated lifetimes with health-margin parameters that drift until something breaks. The result: a perfectly censoring-free supervised dataset where every engine runs all the way to failure and the failure cycle is known exactly.

Why simulation matters for benchmarking. Real fleet data almost never has clean run-to-failure labels (Section 1.2's censoring trap). C-MAPSS gives the community an idealised dataset where the modelling question is cleanly separable from the labelling question.

What's in the Box: Files, Columns, Conventions

Each of the four sub-datasets ships as three plain-text files:

FileContentsFormat
train_FD00x.txtRun-to-failure trajectories for ALL engines26 cols, space-separated
test_FD00x.txtTruncated trajectories - the model must extrapolate26 cols, space-separated
RUL_FD00x.txtGround-truth RUL at the last test cycle of each engine1 col

The 26 columns are always the same: 2 identifiers (engine id, cycle), 3 operational settings (altitude, Mach, throttle angle), and 21 sensors (temperatures, pressures, speeds, bleed flows). No header row, no missing values, no timestamps — just space-separated floats. Loading is a one-liner.

Labelling the training data with RUL is also a one-liner because the file is fully run-to-failure: the maximum cycle observed for each engine is its failure cycle. The training-time RUL at any earlier cycle is just RULt=maxccyclec(this engine)t\text{RUL}_t = \max_{c}\,\text{cycle}_{c}^{(\text{this engine})} - t.

Interactive: The Dataset at a Glance

Hover any value in the column anatomy to see what physical quantity it represents. Click an FD card to switch the histogram — FD003 and FD004 have noticeably longer tails than FD001/FD002 because their two-fault populations include slowly-degrading engines that survive 350+ cycles.

Loading C-MAPSS browser…

Two structural takeaways. First, the four sub-datasets are notidentical — they vary in operating conditions (1 vs 6) and fault modes (1 vs 2), which is exactly what the rest of Chapter 2 is about. Second, the failure-cycle distributions are wide enough that any model has to handle engines that fail at cycle 110 and engines that survive past 350.

Python: Parse the Raw Files in 20 Lines

Loading C-MAPSS is no harder than reading a CSV. The only quirk is that the delimiter is whitespace and the file has no header row.

Read a C-MAPSS train file and label every cycle
🐍cmapss_loader.py
1import numpy as np

Used implicitly by pandas for dtypes.

2import pandas as pd

Pandas - the lingua franca for tabular data in scientific Python. We use it because the C-MAPSS files are essentially well-formatted CSVs (just space-separated instead of comma-separated).

EXECUTION STATE
pandas = Tabular data library. DataFrame is its central type - a labelled 2D array.
as pd = Universal alias.
5COLUMNS = [...]

The README ships without a header row, so we have to declare the column names ourselves. Layout: 2 identifiers, 3 operational settings, 21 sensors = 26 columns total.

EXECUTION STATE
len(COLUMNS) = 26 - matches the C-MAPSS file format exactly
COLUMNS[:5] = ['engine_id', 'cycle', 'op_set_1', 'op_set_2', 'op_set_3']
COLUMNS[-3:] = ['sensor_19', 'sensor_20', 'sensor_21']
6["engine_id", "cycle"]

First two columns: which engine, what cycle within its life. The (engine_id, cycle) pair uniquely identifies any row.

EXECUTION STATE
engine_id = 1..N depending on the FD subset (100 for FD001, 260 for FD002, ...)
cycle = 1..failure_cycle for each engine. Resets to 1 when engine_id increments.
7+ [f"op_set_{i}" for i in range(1, 4)]

Three operational-setting columns: altitude, Mach number, and throttle resolver angle. These define the engine's operating regime; their joint distribution is what makes FD002/FD004 multi-condition.

EXECUTION STATE
f-string list comp = ['op_set_1', 'op_set_2', 'op_set_3']
physical meaning = 1 = altitude (k ft), 2 = Mach, 3 = TRA (% throttle)
8+ [f"sensor_{i}" for i in range(1, 22)]

Twenty-one engine sensors numbered 1..21 in the README. Of these, only ~14 carry useful information once the constant ones are filtered out (Chapter 5).

EXECUTION STATE
list comp = ['sensor_1', 'sensor_2', ..., 'sensor_21']
informative subset = Sensors 2, 3, 4, 7, 8, 9, 11, 12, 13, 14, 15, 17, 20, 21
12def load_train(path: str) -> pd.DataFrame:

Single-purpose helper. Reads a train_FD00x.txt file, returns a DataFrame with the canonical column names plus a computed RUL column.

EXECUTION STATE
input: path (str) = e.g. 'data/raw/train_FD002.txt'
returns DataFrame = Shape (rows, 27): 26 raw columns + 1 RUL column
14df = pd.read_csv(path, sep=r"\s+", header=None, names=COLUMNS)

pandas.read_csv reads a delimited text file into a DataFrame. The args matter: sep is the column delimiter (whitespace, regex `\s+` collapses multiple spaces into one), header=None tells pandas there is no header row, names supplies the column labels.

EXECUTION STATE
pd.read_csv(path, sep, header, names, ...) = The Swiss-army knife of tabular IO. Defaults: sep=',', header=0 (first row is labels). C-MAPSS needs both overridden.
arg: path = filesystem path to the .txt file
arg: sep=r'\s+' = Regex 'one or more whitespace chars'. Handles double-spaces gracefully.
arg: header=None = Tells pandas not to treat the first row as labels - C-MAPSS files have data in row 0.
arg: names=COLUMNS = Apply our 26 column names manually.
df.shape = (53759, 26) for FD002 train
16df["RUL"] = df.groupby("engine_id")["cycle"].transform("max") - df["cycle"]

Compute the ground-truth RUL. For each engine, the maximum cycle observed in the training file IS the failure cycle (engines run to failure). Subtracting the current cycle yields RUL.

EXECUTION STATE
df.groupby('engine_id') = Splits the DataFrame by engine_id - subsequent ops apply per engine.
['cycle'].transform('max') = Computes max(cycle) PER engine, then broadcasts back to a column with the same length as df. transform is the magic: same shape as the original.
Example: engine 1, cycle 1, max=149 = RUL = 149 - 1 = 148
Example: engine 1, cycle 149, max=149 = RUL = 149 - 149 = 0 (last cycle before failure)
17return df

Hand the DataFrame back; the caller decides how to slice / split / batch it.

21df = load_train("data/raw/train_FD002.txt")

Load the multi-condition FD002 training file. Path assumes you ran the project from its repo root.

EXECUTION STATE
df.shape = (53759, 27) - 26 raw + 1 computed RUL
df.dtypes (sample) = engine_id int64, cycle int64, op_set_1 float64, sensor_2 float64, RUL int64
23print("rows :", len(df))

Total cycle-rows across all 260 training engines.

EXECUTION STATE
Output = rows : 53759
24print("engines :", df["engine_id"].nunique())

.nunique() counts unique values. Sanity-check that we read the expected 260 engines.

EXECUTION STATE
.nunique() = Counts distinct values. O(N).
Output = engines : 260
25print("cycles min/max:", df["cycle"].min(), df["cycle"].max())

Cycle-1 always exists (every engine starts there); the max is the longest-living engine.

EXECUTION STATE
Output = cycles min/max: 1 357
→ interpretation = Shortest engine ran ≥ 1 cycle (every engine does). Longest ran 357 cycles before failure.
26print(df[[...]].head(3))

Head of the DataFrame after RUL has been added. Shows that engine 1 is at cycle 1 with 148 cycles to go.

EXECUTION STATE
Output row 0 = engine 1, cycle 1, op_set_1=34.99, op_set_2=0.84, sensor_2=642.55, RUL=148
→ meaning = Engine 1 was sampled at cycle 1 of its life. Total life = 149 cycles. Currently 148 cycles from failure.
20 lines without explanation
1import numpy as np
2import pandas as pd
3
4# Column layout straight from the README. 26 columns, no header.
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)
10
11
12def load_train(path: str) -> pd.DataFrame:
13    """Load a C-MAPSS train_FD00x.txt into a pandas DataFrame."""
14    df = pd.read_csv(path, sep=r"\s+", header=None, names=COLUMNS)
15    # Each engine ends at its failure cycle - true RUL is trivial to label.
16    df["RUL"] = df.groupby("engine_id")["cycle"].transform("max") - df["cycle"]
17    return df
18
19
20# ----- Use it -----
21df = load_train("data/raw/train_FD002.txt")
22
23print("rows         :", len(df))
24print("engines      :", df["engine_id"].nunique())
25print("cycles min/max:", df["cycle"].min(), df["cycle"].max())
26print(df[["engine_id", "cycle", "op_set_1", "op_set_2",
27          "sensor_2", "sensor_4", "RUL"]].head(3))
28
29# rows         : 53759
30# engines      : 260
31# cycles min/max: 1 357
32#    engine_id  cycle  op_set_1  op_set_2  sensor_2  sensor_4   RUL
33# 0          1      1   34.9983    0.8400   642.55   1581.57   148
34# 1          1      2   41.9982    0.8408   642.83   1583.49   147
35# 2          1      3   24.9988    0.6218   645.89   1584.79   146

Why pandas, not pure NumPy?

We could call np.loadtxt and skip pandas entirely — it would work. We use pandas because the groupby + transform idiom turns the per-engine RUL labelling into one expressive line. The downstream PyTorch code converts back to NumPy float32 anyway.

PyTorch: A Reusable CMAPSSDataset

For training we need a Dataset that yields (X,y)(\mathbf{X}, y) pairs as PyTorch tensors. The class below is a thin wrapper around the NumPy logic above — same windows, same labels, same order; what changes is that DataLoader can now batch and shuffle for free.

A reusable CMAPSSDataset for any FD subset
🐍cmapss_dataset.py
1import numpy as np

Needed for dtype conversion in the Dataset.

2import pandas as pd

Same loader as above.

3import torch

Tensor type.

4from torch.utils.data import Dataset

Dataset abstract base class.

7COLUMNS = [...]

26-column layout, identical to the NumPy code.

12SENSOR_COLS = [f"sensor_{i}" for i in range(1, 22)]

Just the 21 sensor names. The model input window is built from these columns only - identifiers and operational settings are kept separately for normalisation by condition (Chapter 6).

EXECUTION STATE
len(SENSOR_COLS) = 21
15class CMAPSSDataset(Dataset):

Reusable across all four FD subsets. The constructor does ALL the work (load + label + index); __getitem__ is O(1) tensor slicing.

EXECUTION STATE
Inherits from = torch.utils.data.Dataset
18def __init__(self, csv_path: str, window: int = 30):

Two arguments: where the file lives and how many past cycles each input window covers.

EXECUTION STATE
input: csv_path = e.g. 'data/raw/train_FD002.txt'
input: window = 30 - the standard sliding-window length
19df = pd.read_csv(csv_path, sep=r'\s+', header=None, names=COLUMNS)

Same one-liner as the NumPy version.

EXECUTION STATE
df.shape = (53759, 26) for FD002 train
20df["RUL"] = df.groupby("engine_id")["cycle"].transform("max") - df["cycle"]

Per-engine RUL labelling - identical to before.

24self.window = window

Stash the window length on the instance.

EXECUTION STATE
self.window = 30
25self.samples = []

List of (engine_id, end_cycle) tuples - each tuple is one (X, y) training sample. Built up in the loop below.

EXECUTION STATE
self.samples (post-init) = List of length 46059 for FD002 train (30-cycle window)
26self.engines = {}

Dict mapping engine_id -> (sensor_array, rul_array). Pre-converted to NumPy float32 once so __getitem__ can slice without paying pandas overhead per call.

EXECUTION STATE
self.engines = {1: (arr_1, rul_1), 2: (arr_2, rul_2), ..., 260: (arr_260, rul_260)}
27for eid, sub in df.groupby('engine_id'):

Iterate over each engine in the file. eid is the engine id, sub is a DataFrame slice for just that engine.

LOOP TRACE · 4 iterations
eid = 1
len(sub) = 149 - engine 1 ran 149 cycles
eid = 2
len(sub) = 269 cycles
...
(257 more engines) = Each with its own length
eid = 260
len(sub) = ≈ 200 cycles
28arr = sub[SENSOR_COLS].to_numpy(dtype=np.float32)

Pull the 21 sensor columns into a NumPy array of shape (n_cycles, 21). float32 because GPUs prefer it.

EXECUTION STATE
.to_numpy(dtype=...) = Materialises a DataFrame slice as a contiguous ndarray. Cheap because pandas already stores numerical columns as ndarrays.
Example: engine 1 = arr.shape = (149, 21)
29ruls = sub['RUL'].to_numpy(dtype=np.float32)

Same trick on the RUL column.

EXECUTION STATE
Example: engine 1 = ruls.shape = (149,) - one RUL per cycle
Example: ruls[0] = 148 (first cycle, 148 cycles to go)
Example: ruls[-1] = 0 (last cycle before failure)
30self.engines[eid] = (arr, ruls)

Store both arrays in the dict.

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

Same sliding-window logic from section 1.2. For engine 1 with 149 cycles and window 30, this yields end = 30, 31, ..., 149 - 120 valid windows.

LOOP TRACE · 4 iterations
end = 30
window = cycles [0, 30) - first valid window
end = 31
window = cycles [1, 31)
...
(118 more iterations for engine 1) =
end = 149
window = cycles [119, 149) - last window of engine 1, RUL = 0
32self.samples.append((eid, end))

Record the (engine_id, end_cycle) pair. This is all we need to look up the corresponding (X, y) later.

EXECUTION STATE
self.samples[0] = (1, 30) - engine 1, end of first window
len(self.samples) (post-loop) = 46059 across all 260 engines
34def __len__(self) -> int:

Total samples across the whole training file.

EXECUTION STATE
returns = 46059 for FD002 train
35return len(self.samples)

46059.

37def __getitem__(self, idx):

DataLoader hands us an integer; we return the corresponding (X, y) tuple.

EXECUTION STATE
input: idx = Index in [0, 46059)
returns (X, y) = X: torch.Size([30, 21]), y: 0-D tensor
38eid, end = self.samples[idx]

Look up the precomputed (engine_id, end_cycle) pair.

EXECUTION STATE
Example: idx=0 = eid=1, end=30
39arr, ruls = self.engines[eid]

Pull the engine's pre-converted arrays (no copy).

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

Slice a (window, 21) view from the engine's full array; wrap as a torch.Tensor (no copy). Float32 already.

EXECUTION STATE
Example: idx=0 = X = torch.from_numpy(arr[0:30]) - shape (30, 21)
torch.from_numpy(arr) = Zero-copy bridge from ndarray to Tensor. Same memory; modifying one modifies the other.
41y = torch.tensor(ruls[end - 1])

RUL at the LAST cycle of the window. end - 1 because end is the exclusive upper bound.

EXECUTION STATE
Example: idx=0, end=30 = ruls[29] = 119 → y = tensor(119.0)
42return X, y

(X, y) tuple - DataLoader collates these into batches.

46ds = CMAPSSDataset("data/raw/train_FD002.txt", window=30)

Construct the dataset on the FD002 train file.

EXECUTION STATE
len(ds) = 46059
47print("samples:", len(ds))

Sanity-check.

EXECUTION STATE
Output = samples: 46059
48X, y = ds[0]

Pull the first sample.

EXECUTION STATE
X.shape = torch.Size([30, 21])
y = tensor(119.0) - engine 1, cycle 30, 119 cycles to failure
49print("X shape:", tuple(X.shape), " y:", float(y))

Verify the shapes match the problem statement.

EXECUTION STATE
Output = X shape: (30, 21) y: 119.0
20 lines without explanation
1import numpy as np
2import pandas as pd
3import torch
4from torch.utils.data import Dataset
5
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)]
13
14
15class CMAPSSDataset(Dataset):
16    """Sliding-window (X, y) Dataset over an entire C-MAPSS train file."""
17
18    def __init__(self, csv_path: str, window: int = 30):
19        df = pd.read_csv(csv_path, sep=r"\s+", header=None, names=COLUMNS)
20        df["RUL"] = (df.groupby("engine_id")["cycle"].transform("max")
21                     - df["cycle"])
22
23        # Pre-compute the (engine_id, end_cycle) pairs that yield valid windows.
24        self.window  = window
25        self.samples = []
26        self.engines = {}
27        for eid, sub in df.groupby("engine_id"):
28            arr = sub[SENSOR_COLS].to_numpy(dtype=np.float32)
29            ruls = sub["RUL"].to_numpy(dtype=np.float32)
30            self.engines[eid] = (arr, ruls)
31            for end in range(window, len(sub) + 1):
32                self.samples.append((eid, end))
33
34    def __len__(self) -> int:
35        return len(self.samples)
36
37    def __getitem__(self, idx: int):
38        eid, end = self.samples[idx]
39        arr, ruls = self.engines[eid]
40        X = torch.from_numpy(arr[end - self.window:end])     # (W, 21)
41        y = torch.tensor(ruls[end - 1])                      # scalar RUL
42        return X, y
43
44
45# ----- Use it -----
46ds = CMAPSSDataset("data/raw/train_FD002.txt", window=30)
47print("samples:", len(ds))
48X, y = ds[0]
49print("X shape:", tuple(X.shape), "  y:", float(y))
50# samples: 46059
51# X shape: (30, 21)   y: 119.0
This is the dataset class we will use for the rest of the book. Chapter 6 adds per-condition normalisation; Chapter 7 adds the RUL cap and health labels; Chapter 15 adds shuffling and split logic. The skeleton stays this small.

Other Public Prognostic Benchmarks

C-MAPSS is dominant but not alone. The benchmarks below cover similar territory in adjacent industries; everything in this book transfers to them with at most a renamed loader.

BenchmarkDomainSizeDistinctive feature
NASA C-MAPSS (this book)Turbofan engine100-260 engines per subsetCensoring-free, multi-condition variants
N-CMAPSS DS01-DS08Turbofan engineUp to 5,000 hours of flightRealistic flight envelopes (Section 2.3)
NASA BatteryLithium-ion cell~30 cellsCharge/discharge cycle health
MIT/Stanford Severson 2019Lithium-ion cell124 cellsEarly-cycle prediction of full lifetime
PRONOSTIA / FEMTORolling-element bearing17 bearingsVibration spectra to failure
IMS BearingRolling-element bearing4 bearings, 3 runsContinuous run-to-failure on test rigs
Backblaze drivesHard-disk drive~250k drives, quarterlyReal-world censored failure logs
EDP Open WindWind-turbine gearboxMultiple turbinesSCADA + maintenance logs
Most of the value in this book is the method, not the dataset. AMNL, GABA, and GRACE plug into any of the benchmarks above with a different loader and a re-tuned per-condition normaliser.

The Simulation-vs-Reality Gap

The trap. Numbers achieved on C-MAPSS do not translate one-to-one to a real fleet. Real engines have censored trajectories, missing sensors, drift in calibration, and operating regimes the simulation does not cover. Treat C-MAPSS leaderboard numbers as a comparable-research metric, not a deployment-ready accuracy estimate.

N-CMAPSS DS02 (Section 2.3) closes part of this gap by simulating real flight envelopes; the remaining gap to deployment is what motivates the Limitations chapter at the end of this book (Chapter 29).

What the benchmark gives us. A single, public, perfectly labelled dataset that lets ten different research groups directly compare their methods. That is enough to drive a decade of progress — even if the numbers do not transfer literally to a real airline.

Takeaway

  • Four files, 26 columns, no surprises. 2 identifiers + 3 operational settings + 21 sensors per row, space-separated, no header.
  • Labels are free. Training engines run all the way to failure, so RUL at any cycle is max(cyclee)cyclet\max(\text{cycle}_e) - \text{cycle}_t per engine.
  • The four sub-datasets vary in difficulty. 1 vs 6 operating conditions, 1 vs 2 fault modes — the topic of Section 2.2.
  • Loading is two helpers. load_train() for analysis and CMAPSSDataset for training. We will reuse the latter in every chapter from Part III onward.
  • The simulation-reality gap is real but not the point of this book. C-MAPSS is the standardised arena. The methods built here generalise; the exact numbers do not.
Loading comments...