Dakoro's picture
feat: create dashboard ui
faa0372
Raw
History Blame Contribute Delete
16.9 kB
"""
CMAPSS FD001/FD002/FD004 — Reliability & RUL dashboard.
Run:
python app_multi.py
Expects the 9 CMAPSS files in DATA_DIR (default ./data):
train_FDxxx.txt, test_FDxxx.txt, RUL_FDxxx.txt for xxx in {001, 002, 004}
On first run per dataset, trains CatBoost and caches it to disk.
"""
from pathlib import Path
from dataclasses import dataclass
from typing import Optional
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from scipy import stats as sstats
from scipy.special import gammaln
from sklearn.cluster import KMeans
from catboost import CatBoostRegressor
import gradio as gr
from utils import (load as load_raw, build_features, select_informative_sensors, fit_regime_stats, em_weibull_mixture)
HERE = Path(__file__).parent
DATA_DIR = HERE / "data"
CACHE_DIR = HERE / "cache"
CACHE_DIR.mkdir(exist_ok=True)
CAP = 150 # Pareto-optimum from the cap sweep
N_REGIMES = {"FD001": 1, "FD002": 6, "FD004": 6}
@dataclass
class DatasetModel:
ds: str
train: pd.DataFrame
test: pd.DataFrame
rul_true: np.ndarray
km: Optional[KMeans]
sensors: list
regime_stats: dict
model: CatBoostRegressor
feature_cols: list
test_feats: pd.DataFrame
# Reliability
life_all: np.ndarray
beta: float
eta: float
mixture: Optional[dict] # Only for FD004
def prepare_dataset(ds: str) -> DatasetModel:
cache = CACHE_DIR / f"{ds}.cbm"
tr, te, rul_true = load_raw(ds)
K = N_REGIMES[ds]
if K == 1:
tr = tr.assign(regime=0)
te = te.assign(regime=0)
km = None
else:
km = KMeans(n_clusters=K, n_init=10, random_state=0).fit(
tr[["op_1", "op_2", "op_3"]].values)
tr = tr.assign(regime=km.labels_)
te = te.assign(regime=km.predict(te[["op_1", "op_2", "op_3"]].values))
sensors = select_informative_sensors(tr, "regime")
regime_stats = fit_regime_stats(tr, sensors, "regime")
feats_tr = build_features(tr, sensors, normalized=True, stats=regime_stats)
feats_tr["regime"] = feats_tr["regime"].astype(int)
y = (tr.groupby("unit").cycle.transform("max") - tr.cycle).clip(upper=CAP).values
feature_cols = [c for c in feats_tr.columns if c not in ("unit", "cycle")]
if cache.exists():
model = CatBoostRegressor()
model.load_model(str(cache))
else:
print(f"Training CatBoost for {ds} (first run)...")
rng = np.random.default_rng(0)
all_u = feats_tr["unit"].unique()
val_u = rng.choice(all_u, size=max(20, len(all_u) // 5), replace=False)
is_val = feats_tr["unit"].isin(val_u).values
model = CatBoostRegressor(
iterations=1500, learning_rate=0.05, depth=6,
loss_function="RMSE", eval_metric="RMSE",
early_stopping_rounds=50, random_seed=42, verbose=False,
cat_features=["regime"],
)
model.fit(feats_tr.loc[~is_val, feature_cols], y[~is_val],
eval_set=(feats_tr.loc[is_val, feature_cols], y[is_val]))
model.save_model(str(cache))
feats_te = build_features(te, sensors, normalized=True, stats=regime_stats)
feats_te["regime"] = feats_te["regime"].astype(int)
life_tr = tr.groupby("unit").cycle.max().values.astype(float)
life_te = (te.groupby("unit").cycle.max().values + rul_true).astype(float)
life_all = np.concatenate([life_tr, life_te])
beta, _, eta = sstats.weibull_min.fit(life_all, floc=0)
mixture = None
if ds == "FD004":
mixture = em_weibull_mixture(life_all, K=2, n_iter=200)
return DatasetModel(ds=ds, train=tr, test=te, rul_true=rul_true, km=km,
sensors=sensors, regime_stats=regime_stats, model=model,
feature_cols=feature_cols, test_feats=feats_te,
life_all=life_all, beta=beta, eta=eta, mixture=mixture)
MODELS = {ds: prepare_dataset(ds) for ds in ["FD001", "FD002", "FD004"]}
print("All datasets ready:", list(MODELS.keys()))
# --------------------------- Callbacks ---------------------------
def fleet_overview(ds: str):
m = MODELS[ds]
last = m.test_feats.groupby("unit").tail(1).sort_values("unit")
preds = np.clip(m.model.predict(last[m.feature_cols]), 0, None)
rmse = float(np.sqrt(((preds - m.rul_true) ** 2).mean()))
mae = float(np.mean(np.abs(preds - m.rul_true)))
fig = make_subplots(
rows=1, cols=2,
subplot_titles=(f"{ds} — fleet survival (n={len(m.life_all)})",
f"{ds} — test set, RMSE = {rmse:.1f}, MAE = {mae:.1f}"),
horizontal_spacing=0.12,
)
life = np.sort(m.life_all)
S_emp = 1 - np.arange(1, len(life) + 1) / len(life)
fig.add_trace(go.Scatter(x=life, y=S_emp, mode="lines", line_shape="hv",
name="empirical", line=dict(color="#1f77b4", width=2)),
row=1, col=1)
t = np.linspace(1, life.max() + 30, 500)
fig.add_trace(go.Scatter(x=t, y=sstats.weibull_min.sf(t, m.beta, 0, m.eta),
mode="lines",
name=f"Weibull β={m.beta:.2f} η={m.eta:.0f}",
line=dict(color="crimson", width=2)),
row=1, col=1)
if m.mixture is not None:
mx = m.mixture
S_mix = (mx["pi"][0] * sstats.weibull_min.sf(t, mx["beta"][0], 0, mx["eta"][0])
+ mx["pi"][1] * sstats.weibull_min.sf(t, mx["beta"][1], 0, mx["eta"][1]))
fig.add_trace(go.Scatter(x=t, y=S_mix, mode="lines", name="2-comp mixture",
line=dict(color="green", width=2, dash="dash")),
row=1, col=1)
fig.add_trace(go.Scatter(
x=m.rul_true, y=preds, mode="markers",
marker=dict(size=7, color="#1f77b4", opacity=0.65,
line=dict(color="black", width=0.5)),
name="test units",
hovertemplate="true=%{x:.0f}<br>pred=%{y:.1f}<extra></extra>",
), row=1, col=2)
lim = max(m.rul_true.max(), preds.max()) + 5
fig.add_trace(go.Scatter(x=[0, lim], y=[0, lim], mode="lines",
line=dict(color="black", width=1, dash="dash"),
showlegend=False, hoverinfo="skip"),
row=1, col=2)
fig.update_xaxes(title_text="Cycles", row=1, col=1)
fig.update_yaxes(title_text="S(t)", row=1, col=1)
fig.update_xaxes(title_text="True RUL", row=1, col=2)
fig.update_yaxes(title_text="Predicted RUL", row=1, col=2)
fig.update_layout(height=440, margin=dict(t=60, b=50, l=50, r=20),
legend=dict(orientation="h", yanchor="bottom", y=-0.25,
xanchor="left", x=0))
summary = [
f"### {ds} model card",
"| Metric | Value |",
"|---|---|",
f"| Units (train / test) | {m.train.unit.nunique()} / {m.test.unit.nunique()} |",
f"| Operational regimes | {N_REGIMES[ds]} |",
f"| Informative sensors | {len(m.sensors)} |",
f"| Weibull β / η | {m.beta:.2f} / {m.eta:.0f} |",
f"| Weibull MTTF | {m.eta * np.exp(gammaln(1 + 1/m.beta)):.0f} cycles |",
f"| CatBoost test RMSE | **{rmse:.1f}** |",
f"| CatBoost test MAE | {mae:.1f} |",
f"| Training RUL cap | {CAP} |",
]
if m.mixture is not None:
mx = m.mixture
summary += [
"",
"**FD004 failure-mode mixture:**",
f"- Component 1 (short-life): π={mx['pi'][0]:.2f}, β={mx['beta'][0]:.2f}, η={mx['eta'][0]:.0f}",
f"- Component 2 (long-life): π={mx['pi'][1]:.2f}, β={mx['beta'][1]:.2f}, η={mx['eta'][1]:.0f}",
]
return fig, "\n".join(summary)
def unit_choices(ds: str):
m = MODELS[ds]
units = sorted(m.test.unit.unique().tolist())
return gr.update(choices=units, value=units[0])
def inspect_unit(ds: str, unit_id: int):
if unit_id is None:
return None, ""
m = MODELS[ds]
unit_id = int(unit_id)
sub = m.test_feats[m.test_feats.unit == unit_id].sort_values("cycle").reset_index(drop=True)
if len(sub) == 0:
return None, f"Unit #{unit_id} not in {ds} test set."
preds = np.clip(m.model.predict(sub[m.feature_cols]), 0, None)
last_cycle = int(sub["cycle"].max())
pred_now = float(preds[-1])
true_now = float(m.rul_true[unit_id - 1])
true_total_life = last_cycle + true_now
# Top 4 sensors most correlated with capped RUL on train
sub_train = m.train.copy()
sub_train["RUL"] = (sub_train.groupby("unit").cycle.transform("max")
- sub_train.cycle).clip(upper=CAP)
corrs = (sub_train[m.sensors + ["RUL"]].corr()["RUL"]
.drop("RUL").abs().sort_values(ascending=False))
top_sensors = corrs.head(4).index.tolist()
raw = m.test[m.test.unit == unit_id].sort_values("cycle")
S_now = float(sstats.weibull_min.sf(last_cycle, m.beta, 0, m.eta))
has_regimes = m.km is not None
titles = [
f"Unit #{unit_id} — RUL trajectory",
f"Position on fleet survival (S={S_now:.1%})",
"Top-correlated sensors — raw",
"Degradation signal (rolling slope, regime-normalized)",
]
if has_regimes:
titles.append(
f"Operational regime timeline (visited: "
f"{sorted(raw.regime.unique().tolist())})"
)
specs = [[{}, {}], [{}, {}], [{"colspan": 2}, None]]
rows, height = 3, 900
else:
specs = [[{}, {}], [{}, {}]]
rows, height = 2, 700
fig = make_subplots(
rows=rows, cols=2, specs=specs, subplot_titles=titles,
vertical_spacing=0.10, horizontal_spacing=0.10,
)
# 1. RUL trajectory
true_rul_traj = true_now + (last_cycle - sub["cycle"].values)
fig.add_trace(go.Scatter(x=sub["cycle"], y=true_rul_traj, mode="lines",
name="true RUL", line=dict(color="black", width=2)),
row=1, col=1)
fig.add_trace(go.Scatter(x=sub["cycle"], y=preds, mode="lines",
name="CatBoost prediction",
line=dict(color="crimson", width=2)),
row=1, col=1)
fig.add_hline(y=CAP, line=dict(color="gray", width=1, dash="dot"),
annotation_text=f"training cap={CAP}",
annotation_position="top right",
row=1, col=1)
# 2. Fleet Weibull + position
t = np.linspace(1, m.life_all.max() + 20, 400)
fig.add_trace(go.Scatter(x=t, y=sstats.weibull_min.sf(t, m.beta, 0, m.eta),
mode="lines", name=f"Weibull β={m.beta:.2f}",
line=dict(color="crimson", width=2)),
row=1, col=2)
if m.mixture is not None:
mx = m.mixture
S_mix = (mx["pi"][0] * sstats.weibull_min.sf(t, mx["beta"][0], 0, mx["eta"][0])
+ mx["pi"][1] * sstats.weibull_min.sf(t, mx["beta"][1], 0, mx["eta"][1]))
fig.add_trace(go.Scatter(x=t, y=S_mix, mode="lines", name="mixture",
line=dict(color="green", width=2, dash="dash")),
row=1, col=2)
fig.add_vline(x=last_cycle, line=dict(color="#1f77b4", width=2),
annotation_text=f"age = {last_cycle}",
annotation_position="top right",
row=1, col=2)
fig.add_vline(x=true_total_life,
line=dict(color="black", width=1.5, dash="dash"),
annotation_text=f"failure @ {true_total_life:.0f}",
annotation_position="bottom right",
row=1, col=2)
fig.add_trace(go.Scatter(x=[last_cycle], y=[S_now], mode="markers",
marker=dict(color="#1f77b4", size=11,
line=dict(color="black", width=1)),
name="current state", showlegend=False,
hovertemplate=f"S={S_now:.1%}<extra></extra>"),
row=1, col=2)
# 3 & 4. Top sensors — raw + rolling slope
palette = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"]
for s, color in zip(top_sensors, palette):
fig.add_trace(go.Scatter(x=raw["cycle"], y=raw[s], mode="lines",
name=s, line=dict(color=color, width=1.5),
legendgroup=s),
row=2, col=1)
fig.add_trace(go.Scatter(x=sub["cycle"], y=sub[f"{s}_n_sl"], mode="lines",
name=f"slope({s})",
line=dict(color=color, width=1.5, dash="dot"),
legendgroup=s, showlegend=False),
row=2, col=2)
fig.add_hline(y=0, line=dict(color="black", width=0.8), row=2, col=2)
# 5. Regime timeline (full-width on row 3)
if has_regimes:
fig.add_trace(go.Scatter(x=raw["cycle"], y=raw["regime"], mode="lines",
line_shape="hv",
line=dict(color="#9467bd", width=2),
name="regime", showlegend=False),
row=3, col=1)
fig.update_yaxes(tickmode="array",
tickvals=list(range(N_REGIMES[ds])),
row=3, col=1)
fig.update_xaxes(title_text="Cycle", row=1, col=1)
fig.update_yaxes(title_text="RUL (cycles)", row=1, col=1)
fig.update_xaxes(title_text="Cycles", row=1, col=2)
fig.update_yaxes(title_text="S(t)", row=1, col=2)
fig.update_xaxes(title_text="Cycle", row=2, col=1)
fig.update_yaxes(title_text="Sensor value", row=2, col=1)
fig.update_xaxes(title_text="Cycle", row=2, col=2)
fig.update_yaxes(title_text="Rolling slope", row=2, col=2)
if has_regimes:
fig.update_xaxes(title_text="Cycle", row=3, col=1)
fig.update_yaxes(title_text="Regime", row=3, col=1)
fig.update_layout(height=height, margin=dict(t=50, b=40, l=50, r=20),
legend=dict(orientation="h", yanchor="bottom", y=-0.08,
xanchor="left", x=0))
verdict = ("CRITICAL — act now" if pred_now < 20
else "WARNING — plan maintenance" if pred_now < 50
else "HEALTHY")
md = f"""### Unit #{unit_id}{ds}
| Metric | Value |
|---|---|
| Dataset | {ds} ({N_REGIMES[ds]} regime{'s' if N_REGIMES[ds] > 1 else ''}) |
| Current age | **{last_cycle}** cycles |
| Predicted RUL | **{pred_now:.1f}** cycles |
| True RUL | {true_now:.0f} cycles |
| |Pred − True| | {abs(pred_now - true_now):.1f} cycles |
| Weibull S(age) | {S_now:.1%} |
| Status | **{verdict}** |
"""
return fig, md
# --------------------------- UI ---------------------------
with gr.Blocks(title="CMAPSS Multi-Dataset Reliability Dashboard") as demo:
gr.Markdown("# CMAPSS Reliability & RUL Dashboard — FD001 / FD002 / FD004")
gr.Markdown(
"Each dataset is handled end-to-end: regime clustering · per-regime "
f"sensor normalization · Weibull fleet model · CatBoost RUL prediction (cap={CAP})."
)
ds_selector = gr.Radio(choices=["FD001", "FD002", "FD004"],
value="FD001", label="Dataset")
with gr.Tab("Fleet overview"):
gr.Markdown(
"Survival of the fleet (empirical vs Weibull, and 2-component "
"mixture on FD004) + test-set RUL predictions."
)
with gr.Row():
with gr.Column(scale=3):
plot_fleet = gr.Plot()
with gr.Column(scale=1):
md_fleet = gr.Markdown()
with gr.Tab("Unit inspection"):
gr.Markdown(
"Pick a test unit to see RUL prediction, position on the fleet "
"survival curve, sensor trajectories, and operational regime timeline."
)
unit_dd = gr.Dropdown(choices=sorted(MODELS["FD001"].test.unit.unique().tolist()),
value=1, label="Test unit", allow_custom_value=False)
with gr.Row():
with gr.Column(scale=3):
plot_unit = gr.Plot()
with gr.Column(scale=1):
md_unit = gr.Markdown()
# Wire events
ds_selector.change(fleet_overview, inputs=ds_selector, outputs=[plot_fleet, md_fleet])
ds_selector.change(unit_choices, inputs=ds_selector, outputs=unit_dd)
ds_selector.change(inspect_unit, inputs=[ds_selector, unit_dd], outputs=[plot_unit, md_unit])
unit_dd.change(inspect_unit, inputs=[ds_selector, unit_dd], outputs=[plot_unit, md_unit])
demo.load(fleet_overview, inputs=ds_selector, outputs=[plot_fleet, md_fleet])
demo.load(inspect_unit, inputs=[ds_selector, unit_dd], outputs=[plot_unit, md_unit])
if __name__ == "__main__":
demo.launch()