| """ |
| FaultSense — LightGBM + Random Forest Fault Prediction App |
| Both models trained at startup; UI lets user switch between them. |
| """ |
|
|
| import os |
| import warnings |
| import numpy as np |
| import pandas as pd |
|
|
| warnings.filterwarnings("ignore") |
|
|
| from sklearn.model_selection import train_test_split |
| from sklearn.metrics import ( |
| roc_auc_score, accuracy_score, precision_score, |
| recall_score, f1_score, log_loss, confusion_matrix |
| ) |
| from sklearn.preprocessing import OneHotEncoder |
| from sklearn.compose import ColumnTransformer |
| from sklearn.pipeline import Pipeline |
| from sklearn.ensemble import RandomForestClassifier |
| import joblib |
| from lightgbm import LGBMClassifier |
| from flask import Flask, request, jsonify, render_template_string |
|
|
| |
| |
| |
|
|
| DATA_PATH = "synthetic_nim_parallel_10000.csv" |
| LGBM_PATH = "/tmp/faultsense_lgbm.joblib" |
| RF_PATH = "/tmp/faultsense_rf.joblib" |
|
|
| DROP_COLS = ["location"] |
| TARGET = "faulty" |
| CAT_COLS = ["equipment"] |
| NUM_COLS = ["temperature", "pressure", "vibration", "humidity"] |
|
|
| RANDOM_STATE = 42 |
| THRESHOLD = 0.5 |
|
|
| LGBM_PARAMS = dict( |
| max_depth=8, |
| num_leaves=50, |
| min_child_samples=20, |
| subsample=0.8, |
| colsample_bytree=0.8, |
| class_weight="balanced", |
| random_state=RANDOM_STATE, |
| verbose=-1, |
| learning_rate=0.05, |
| n_estimators=165, |
| ) |
|
|
| RF_PARAMS = dict( |
| n_estimators=165, |
| max_depth=10, |
| min_samples_split=10, |
| min_samples_leaf=5, |
| class_weight="balanced", |
| random_state=RANDOM_STATE, |
| n_jobs=-1, |
| ) |
|
|
| BEST_CONFIG = { |
| "train_ratio": 0.80, |
| "val_ratio": 0.10, |
| "test_ratio": 0.10, |
| } |
|
|
| EQUIPMENT_OPTIONS = ["pump", "compressor", "motor", "valve", "sensor"] |
|
|
| |
| |
| |
|
|
| def make_preprocessor(): |
| return ColumnTransformer([ |
| ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), CAT_COLS), |
| ("num", "passthrough", NUM_COLS), |
| ]) |
|
|
| def load_data(cfg): |
| df_raw = pd.read_csv(DATA_PATH) |
| df_raw = df_raw.drop(columns=DROP_COLS, errors="ignore") |
| X = df_raw.drop(columns=[TARGET]) |
| y = df_raw[TARGET] |
| train_r, val_r, test_r = cfg["train_ratio"], cfg["val_ratio"], cfg["test_ratio"] |
| X_trainval, X_test, y_trainval, y_test = train_test_split( |
| X, y, test_size=test_r, stratify=y, random_state=RANDOM_STATE |
| ) |
| val_relative = val_r / (train_r + val_r) |
| X_train, X_val, y_train, y_val = train_test_split( |
| X_trainval, y_trainval, test_size=val_relative, |
| stratify=y_trainval, random_state=RANDOM_STATE |
| ) |
| return X_train, X_val, X_test, y_train, y_val, y_test |
|
|
| def compute_metrics(pipeline, X_test, y_test): |
| y_prob = pipeline.predict_proba(X_test)[:, 1] |
| y_pred = (y_prob >= THRESHOLD).astype(int) |
| return { |
| "test_auc": round(roc_auc_score(y_test, y_prob), 4), |
| "test_accuracy": round(accuracy_score(y_test, y_pred), 4), |
| "test_precision": round(precision_score(y_test, y_pred, zero_division=0), 4), |
| "test_recall": round(recall_score(y_test, y_pred, zero_division=0), 4), |
| "test_f1": round(f1_score(y_test, y_pred, zero_division=0), 4), |
| "test_logloss": round(log_loss(y_test, y_prob), 4), |
| }, confusion_matrix(y_test, y_pred).tolist() |
|
|
| def train_lgbm(X_train, X_test, y_train, y_test): |
| print("Training LightGBM...") |
| pipeline = Pipeline([ |
| ("pre", make_preprocessor()), |
| ("clf", LGBMClassifier(**LGBM_PARAMS)) |
| ]) |
| pipeline.fit(X_train, y_train) |
| metrics, cm = compute_metrics(pipeline, X_test, y_test) |
| print(f"LGBM AUC={metrics['test_auc']} F1={metrics['test_f1']}") |
| return {"pipeline": pipeline, "test_metrics": metrics, "cm": cm, |
| "config": {**BEST_CONFIG, "model": "LightGBM", |
| "learning_rate": LGBM_PARAMS["learning_rate"], |
| "n_estimators": LGBM_PARAMS["n_estimators"]}} |
|
|
| def train_rf(X_train, X_test, y_train, y_test): |
| print("Training Random Forest...") |
| pipeline = Pipeline([ |
| ("pre", make_preprocessor()), |
| ("clf", RandomForestClassifier(**RF_PARAMS)) |
| ]) |
| pipeline.fit(X_train, y_train) |
| metrics, cm = compute_metrics(pipeline, X_test, y_test) |
| print(f"RF AUC={metrics['test_auc']} F1={metrics['test_f1']}") |
| return {"pipeline": pipeline, "test_metrics": metrics, "cm": cm, |
| "config": {**BEST_CONFIG, "model": "Random Forest", |
| "n_estimators": RF_PARAMS["n_estimators"], |
| "max_depth": RF_PARAMS["max_depth"]}} |
|
|
| def load_or_train_all(): |
| X_train, X_val, X_test, y_train, y_val, y_test = load_data(BEST_CONFIG) |
| if os.path.exists(LGBM_PATH): |
| print(f"Loading LGBM from {LGBM_PATH}") |
| lgbm_artifact = joblib.load(LGBM_PATH) |
| else: |
| lgbm_artifact = train_lgbm(X_train, X_test, y_train, y_test) |
| joblib.dump(lgbm_artifact, LGBM_PATH) |
|
|
| if os.path.exists(RF_PATH): |
| print(f"Loading RF from {RF_PATH}") |
| rf_artifact = joblib.load(RF_PATH) |
| else: |
| rf_artifact = train_rf(X_train, X_test, y_train, y_test) |
| joblib.dump(rf_artifact, RF_PATH) |
|
|
| return {"lgbm": lgbm_artifact, "rf": rf_artifact} |
|
|
| |
| |
| |
| ARTIFACTS = load_or_train_all() |
|
|
| |
| |
| |
|
|
| app = Flask(__name__) |
|
|
| HTML = r"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>FaultSense — Equipment Fault Predictor</title> |
| <link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:wght@300;500;700&display=swap" rel="stylesheet"> |
| <style> |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| :root { |
| --bg: #0a0c10; --surface: #111318; --surface2: #181c24; |
| --border: #232838; --accent: #00e5a0; --accent2: #ff4d6d; |
| --accent3: #4d9fff; --accent4: #f59e0b; |
| --text: #e8eaf0; --muted: #6b7280; |
| --mono: 'Space Mono', monospace; --sans: 'DM Sans', sans-serif; |
| } |
| html { font-size: 16px; } |
| body { background: var(--bg); color: var(--text); font-family: var(--sans); min-height: 100vh; overflow-x: hidden; } |
| body::before { |
| content: ''; position: fixed; inset: 0; |
| background-image: linear-gradient(rgba(0,229,160,.04) 1px, transparent 1px), linear-gradient(90deg, rgba(0,229,160,.04) 1px, transparent 1px); |
| background-size: 40px 40px; pointer-events: none; z-index: 0; |
| } |
| .blob { position: fixed; width: 600px; height: 600px; border-radius: 50%; filter: blur(120px); opacity: .15; pointer-events: none; animation: drift 12s ease-in-out infinite alternate; z-index: 0; } |
| .blob-1 { background: var(--accent); top: -200px; left: -200px; } |
| .blob-2 { background: var(--accent3); bottom: -200px; right: -100px; animation-delay: -6s; } |
| @keyframes drift { from { transform: translate(0,0) scale(1); } to { transform: translate(40px,30px) scale(1.05); } } |
| .wrapper { position: relative; z-index: 1; max-width: 1200px; margin: 0 auto; padding: 40px 24px 80px; } |
| header { display: flex; align-items: center; gap: 16px; margin-bottom: 32px; border-bottom: 1px solid var(--border); padding-bottom: 24px; } |
| .logo-mark { width: 44px; height: 44px; background: var(--accent); border-radius: 10px; display: grid; place-items: center; font-family: var(--mono); font-weight: 700; font-size: 18px; color: var(--bg); flex-shrink: 0; } |
| header h1 { font-family: var(--mono); font-size: 1.5rem; letter-spacing: -.5px; } |
| header p { font-size: .85rem; color: var(--muted); margin-top: 2px; } |
| .badge { margin-left: auto; font-family: var(--mono); font-size: .7rem; background: rgba(0,229,160,.12); color: var(--accent); border: 1px solid rgba(0,229,160,.3); border-radius: 6px; padding: 4px 10px; white-space: nowrap; } |
| |
| /* MODEL SELECTOR TABS */ |
| .model-tabs { display: flex; gap: 10px; margin-bottom: 24px; } |
| .model-tab { |
| flex: 1; padding: 14px 20px; border-radius: 12px; border: 1px solid var(--border); |
| background: var(--surface); cursor: pointer; font-family: var(--mono); |
| font-size: .8rem; color: var(--muted); transition: all .2s; text-align: center; |
| display: flex; flex-direction: column; gap: 4px; align-items: center; |
| } |
| .model-tab:hover { border-color: var(--accent); color: var(--text); } |
| .model-tab.active.lgbm { border-color: var(--accent); background: rgba(0,229,160,.08); color: var(--accent); } |
| .model-tab.active.rf { border-color: var(--accent4); background: rgba(245,158,11,.08); color: var(--accent4); } |
| .model-tab .tab-name { font-size: .95rem; font-weight: 700; } |
| .model-tab .tab-desc { font-size: .65rem; color: inherit; opacity: .7; } |
| .tab-auc { font-size: .7rem; opacity: .85; margin-top: 2px; } |
| |
| .main-grid { display: grid; grid-template-columns: 1fr 400px; gap: 24px; align-items: start; } |
| @media (max-width: 900px) { .main-grid { grid-template-columns: 1fr; } } |
| .card { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 28px; } |
| .card-title { font-family: var(--mono); font-size: .75rem; letter-spacing: 1.5px; text-transform: uppercase; color: var(--muted); margin-bottom: 20px; display: flex; align-items: center; gap: 8px; } |
| .card-title::before { content: ''; display: inline-block; width: 6px; height: 6px; background: var(--accent); border-radius: 50%; } |
| .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } |
| @media (max-width: 560px) { .form-grid { grid-template-columns: 1fr; } } |
| .field { display: flex; flex-direction: column; gap: 8px; } |
| .field label { font-size: .78rem; font-family: var(--mono); color: var(--muted); letter-spacing: .5px; } |
| |
| /* CUSTOM DROPDOWN */ |
| .custom-select { position: relative; width: 100%; } |
| .cs-trigger { |
| background: var(--surface2); border: 1px solid var(--border); |
| border-radius: 10px; color: var(--text); font-family: var(--mono); |
| font-size: .9rem; padding: 11px 14px; width: 100%; cursor: pointer; |
| display: flex; justify-content: space-between; align-items: center; |
| transition: border-color .2s; |
| } |
| .cs-trigger:hover, .cs-trigger.open { border-color: var(--accent); } |
| .cs-arrow { font-size: 10px; color: var(--muted); transition: transform .2s; } |
| .cs-trigger.open .cs-arrow { transform: rotate(180deg); } |
| .cs-options { |
| position: absolute; top: calc(100% + 4px); left: 0; right: 0; |
| background: #1a1e2a; border: 1px solid var(--accent); |
| border-radius: 10px; z-index: 999; overflow: hidden; |
| display: none; flex-direction: column; |
| } |
| .cs-options.open { display: flex; } |
| .cs-option { |
| padding: 11px 14px; font-family: var(--mono); font-size: .9rem; |
| color: var(--text); cursor: pointer; transition: background .15s; |
| } |
| .cs-option:hover { background: rgba(0,229,160,.15); color: var(--accent); } |
| .cs-option.selected { color: var(--accent); background: rgba(0,229,160,.08); } |
| |
| .slider-wrap { display: flex; flex-direction: column; gap: 6px; } |
| .slider-row { display: flex; align-items: center; gap: 10px; } |
| input[type=range] { flex: 1; -webkit-appearance: none; height: 4px; border-radius: 4px; background: var(--border); outline: none; cursor: pointer; } |
| input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--accent); transition: transform .15s; } |
| input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.3); } |
| input[type=range]::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: var(--accent); border: none; } |
| .slider-val { font-family: var(--mono); font-size: .85rem; color: var(--accent); min-width: 60px; text-align: right; } |
| |
| .btn-predict { margin-top: 24px; width: 100%; padding: 14px; background: var(--accent); color: var(--bg); border: none; border-radius: 12px; font-family: var(--mono); font-size: 1rem; font-weight: 700; letter-spacing: 1px; cursor: pointer; transition: transform .15s, box-shadow .2s; } |
| .btn-predict:hover { transform: translateY(-2px); box-shadow: 0 0 32px rgba(0,229,160,.5); } |
| .btn-predict.rf-active { background: var(--accent4); } |
| .btn-predict.rf-active:hover { box-shadow: 0 0 32px rgba(245,158,11,.5); } |
| .btn-predict:disabled { background: var(--muted); cursor: not-allowed; transform: none; box-shadow: none; } |
| |
| .result-card { border-radius: 16px; padding: 28px; border: 1px solid var(--border); background: var(--surface); transition: border-color .4s; } |
| .result-card.faulty { border-color: var(--accent2); background: rgba(255,77,109,.06); } |
| .result-card.healthy { border-color: var(--accent); background: rgba(0,229,160,.06); } |
| .verdict { font-family: var(--mono); font-size: 2rem; font-weight: 700; letter-spacing: -1px; margin-bottom: 6px; } |
| .verdict.faulty { color: var(--accent2); } |
| .verdict.healthy { color: var(--accent); } |
| .verdict-sub { font-size: .85rem; color: var(--muted); margin-bottom: 8px; } |
| .model-used-tag { display: inline-block; font-family: var(--mono); font-size: .65rem; padding: 3px 8px; border-radius: 6px; margin-bottom: 20px; } |
| .model-used-tag.lgbm { background: rgba(0,229,160,.12); color: var(--accent); border: 1px solid rgba(0,229,160,.3); } |
| .model-used-tag.rf { background: rgba(245,158,11,.12); color: var(--accent4); border: 1px solid rgba(245,158,11,.3); } |
| .prob-bar-wrap { margin-bottom: 24px; } |
| .prob-label { font-family: var(--mono); font-size: .72rem; color: var(--muted); margin-bottom: 6px; display: flex; justify-content: space-between; } |
| .prob-track { height: 10px; background: var(--border); border-radius: 10px; overflow: hidden; } |
| .prob-fill { height: 100%; border-radius: 10px; transition: width .6s cubic-bezier(.4,0,.2,1); } |
| .prob-fill.faulty { background: linear-gradient(90deg, #ff4d6d, #ff8fa3); } |
| .prob-fill.healthy { background: linear-gradient(90deg, #00e5a0, #5eead4); } |
| .mini-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } |
| .mini-metric { background: var(--surface2); border-radius: 10px; padding: 12px; border: 1px solid var(--border); } |
| .mini-metric .mm-val { font-family: var(--mono); font-size: 1.1rem; font-weight: 700; color: var(--accent3); } |
| .mini-metric .mm-key { font-size: .7rem; color: var(--muted); margin-top: 2px; font-family: var(--mono); } |
| |
| /* METRICS COMPARISON TABLE */ |
| .compare-table { width: 100%; border-collapse: collapse; } |
| .compare-table th { font-family: var(--mono); font-size: .65rem; letter-spacing: 1px; text-transform: uppercase; color: var(--muted); padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border); } |
| .compare-table th.lgbm-col { color: var(--accent); } |
| .compare-table th.rf-col { color: var(--accent4); } |
| .compare-table td { font-family: var(--mono); font-size: .78rem; padding: 9px 10px; border-bottom: 1px solid var(--border); } |
| .compare-table tr:last-child td { border-bottom: none; } |
| .compare-table td.metric-name { color: var(--muted); font-size: .7rem; } |
| .compare-table td.win { font-weight: 700; } |
| .compare-table td.win.lgbm { color: var(--accent); } |
| .compare-table td.win.rf { color: var(--accent4); } |
| .win-tag { font-size: .55rem; padding: 1px 5px; border-radius: 4px; margin-left: 5px; vertical-align: middle; } |
| .win-tag.lgbm { background: rgba(0,229,160,.15); color: var(--accent); } |
| .win-tag.rf { background: rgba(245,158,11,.15); color: var(--accent4); } |
| |
| .info-row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--border); font-size: .82rem; } |
| .info-row:last-child { border-bottom: none; } |
| .info-key { color: var(--muted); font-family: var(--mono); font-size: .72rem; } |
| .info-val { font-family: var(--mono); color: var(--text); font-weight: 700; } |
| .info-val.green { color: var(--accent); } |
| .info-val.amber { color: var(--accent4); } |
| |
| .history-list { max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; } |
| .hist-item { background: var(--surface2); border: 1px solid var(--border); border-radius: 10px; padding: 10px 14px; display: flex; justify-content: space-between; align-items: center; font-size: .78rem; } |
| .hist-equip { color: var(--muted); font-family: var(--mono); font-size: .7rem; } |
| .hist-badge { font-family: var(--mono); font-size: .68rem; padding: 3px 8px; border-radius: 6px; font-weight: 700; } |
| .hist-badge.faulty { background: rgba(255,77,109,.2); color: var(--accent2); } |
| .hist-badge.healthy { background: rgba(0,229,160,.2); color: var(--accent); } |
| .hist-model-tag { font-family: var(--mono); font-size: .58rem; padding: 2px 6px; border-radius: 4px; margin-top: 3px; display: inline-block; } |
| .hist-model-tag.lgbm { background: rgba(0,229,160,.1); color: var(--accent); } |
| .hist-model-tag.rf { background: rgba(245,158,11,.1); color: var(--accent4); } |
| |
| .spinner { display: none; width: 20px; height: 20px; border: 2px solid rgba(10,12,16,.3); border-top-color: var(--bg); border-radius: 50%; animation: spin .6s linear infinite; margin: 0 auto; } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| .btn-predict.loading .btn-text { display: none; } |
| .btn-predict.loading .spinner { display: block; } |
| .idle-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 32px 0; color: var(--muted); text-align: center; } |
| .idle-icon { font-size: 2.5rem; opacity: .4; } |
| .toast { position: fixed; bottom: 24px; right: 24px; background: var(--surface2); border: 1px solid var(--accent2); color: var(--accent2); border-radius: 10px; padding: 12px 18px; font-family: var(--mono); font-size: .8rem; transform: translateY(80px); opacity: 0; transition: all .3s; z-index: 999; } |
| .toast.show { transform: translateY(0); opacity: 1; } |
| </style> |
| </head> |
| <body> |
| <div class="blob blob-1"></div> |
| <div class="blob blob-2"></div> |
| <div class="wrapper"> |
| <header> |
| <div class="logo-mark">FS</div> |
| <div> |
| <h1>FaultSense</h1> |
| <p>Multi-Model Equipment Fault Predictor</p> |
| </div> |
| <div class="badge" id="model-badge">Loading Models…</div> |
| </header> |
| |
| <!-- MODEL SELECTOR TABS --> |
| <div class="model-tabs" id="model-tabs"> |
| <div class="model-tab active lgbm" id="tab-lgbm" onclick="selectModel('lgbm')"> |
| <span class="tab-name">⚡ LightGBM</span> |
| <span class="tab-desc">Gradient Boosting</span> |
| <span class="tab-auc" id="lgbm-tab-auc">Loading…</span> |
| </div> |
| <div class="model-tab rf" id="tab-rf" onclick="selectModel('rf')"> |
| <span class="tab-name">🌲 Random Forest</span> |
| <span class="tab-desc">Ensemble Trees</span> |
| <span class="tab-auc" id="rf-tab-auc">Loading…</span> |
| </div> |
| </div> |
| |
| <div class="main-grid"> |
| <div style="display:flex;flex-direction:column;gap:20px;"> |
| <div class="card"> |
| <div class="card-title">Sensor Readings</div> |
| <div class="form-grid"> |
| |
| <div class="field" style="grid-column:1/-1"> |
| <label>Equipment Type</label> |
| <div class="custom-select" id="equipment-wrapper"> |
| <div class="cs-trigger" id="cs-trigger" onclick="toggleDropdown()"> |
| <span id="cs-selected">Pump</span> |
| <span class="cs-arrow">▼</span> |
| </div> |
| <div class="cs-options" id="cs-options"> |
| <div class="cs-option selected" onclick="selectOption('pump','Pump')">Pump</div> |
| <div class="cs-option" onclick="selectOption('compressor','Compressor')">Compressor</div> |
| <div class="cs-option" onclick="selectOption('motor','Motor')">Motor</div> |
| <div class="cs-option" onclick="selectOption('valve','Valve')">Valve</div> |
| <div class="cs-option" onclick="selectOption('sensor','Sensor')">Sensor</div> |
| </div> |
| </div> |
| <input type="hidden" id="equipment" value="pump"> |
| </div> |
| |
| <div class="field slider-wrap"> |
| <label>Temperature (°C)</label> |
| <div class="slider-row"> |
| <input type="range" id="temperature" min="-20" max="120" step="0.5" value="40" |
| oninput="document.getElementById('temperature-val').textContent=parseFloat(this.value).toFixed(1)+'°C'"> |
| <span class="slider-val" id="temperature-val">40.0°C</span> |
| </div> |
| </div> |
| |
| <div class="field slider-wrap"> |
| <label>Pressure (bar)</label> |
| <div class="slider-row"> |
| <input type="range" id="pressure" min="0" max="20" step="0.1" value="5" |
| oninput="document.getElementById('pressure-val').textContent=parseFloat(this.value).toFixed(1)+' bar'"> |
| <span class="slider-val" id="pressure-val">5.0 bar</span> |
| </div> |
| </div> |
| |
| <div class="field slider-wrap"> |
| <label>Vibration (mm/s)</label> |
| <div class="slider-row"> |
| <input type="range" id="vibration" min="0" max="50" step="0.1" value="5" |
| oninput="document.getElementById('vibration-val').textContent=parseFloat(this.value).toFixed(1)+' mm/s'"> |
| <span class="slider-val" id="vibration-val">5.0 mm/s</span> |
| </div> |
| </div> |
| |
| <div class="field slider-wrap"> |
| <label>Humidity (%)</label> |
| <div class="slider-row"> |
| <input type="range" id="humidity" min="0" max="100" step="1" value="50" |
| oninput="document.getElementById('humidity-val').textContent=parseInt(this.value)+'%'"> |
| <span class="slider-val" id="humidity-val">50%</span> |
| </div> |
| </div> |
| |
| </div> |
| <button class="btn-predict lgbm-active" id="predict-btn" onclick="runPredict()"> |
| <span class="btn-text">⚡ Run Prediction</span> |
| <div class="spinner"></div> |
| </button> |
| </div> |
| |
| <!-- METRICS COMPARISON --> |
| <div class="card"> |
| <div class="card-title">Model Comparison</div> |
| <div id="compare-content"> |
| <div style="color:var(--muted);font-size:.8rem;font-family:var(--mono);text-align:center;padding:12px 0;">Loading…</div> |
| </div> |
| </div> |
| |
| <div class="card"> |
| <div class="card-title">Prediction History</div> |
| <div class="history-list" id="history-list"> |
| <div style="color:var(--muted);font-size:.8rem;font-family:var(--mono);text-align:center;padding:16px 0;">No predictions yet</div> |
| </div> |
| </div> |
| </div> |
| |
| <div style="display:flex;flex-direction:column;gap:20px;"> |
| <div class="result-card" id="result-card"> |
| <div class="idle-state" id="idle-state"> |
| <div class="idle-icon">🔬</div> |
| <p>Select a model, enter sensor<br>readings, and run a prediction<br>to see results here.</p> |
| </div> |
| <div id="result-content" style="display:none;"> |
| <div class="verdict" id="verdict-text"></div> |
| <div class="verdict-sub" id="verdict-sub"></div> |
| <div id="model-used-tag" class="model-used-tag lgbm"></div> |
| <div class="prob-bar-wrap"> |
| <div class="prob-label"><span>Fault Probability</span><span id="prob-pct"></span></div> |
| <div class="prob-track"><div class="prob-fill" id="prob-fill" style="width:0%"></div></div> |
| </div> |
| <div class="mini-metrics" id="mini-metrics"></div> |
| </div> |
| </div> |
| |
| <div class="card"> |
| <div class="card-title" id="model-config-title">Active Model Config</div> |
| <div id="model-info"> |
| <div style="color:var(--muted);font-size:.8rem;font-family:var(--mono);text-align:center;padding:12px 0;">Loading…</div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| <div class="toast" id="toast"></div> |
| |
| <script> |
| let csOpen = false; |
| let activeModel = 'lgbm'; |
| let modelData = {}; |
| |
| function selectModel(model) { |
| activeModel = model; |
| document.getElementById('tab-lgbm').className = 'model-tab' + (model === 'lgbm' ? ' active lgbm' : ''); |
| document.getElementById('tab-rf').className = 'model-tab' + (model === 'rf' ? ' active rf' : ''); |
| const btn = document.getElementById('predict-btn'); |
| btn.className = model === 'lgbm' ? 'btn-predict lgbm-active' : 'btn-predict rf-active'; |
| btn.querySelector('.btn-text').textContent = model === 'lgbm' ? '⚡ Run Prediction' : '🌲 Run Prediction'; |
| updateModelInfo(model); |
| } |
| |
| function toggleDropdown() { |
| csOpen = !csOpen; |
| document.getElementById('cs-trigger').classList.toggle('open', csOpen); |
| document.getElementById('cs-options').classList.toggle('open', csOpen); |
| } |
| function selectOption(value, label) { |
| document.getElementById('equipment').value = value; |
| document.getElementById('cs-selected').textContent = label; |
| document.querySelectorAll('.cs-option').forEach(o => o.classList.remove('selected')); |
| event.target.classList.add('selected'); |
| csOpen = false; |
| document.getElementById('cs-trigger').classList.remove('open'); |
| document.getElementById('cs-options').classList.remove('open'); |
| } |
| document.addEventListener('click', function(e) { |
| if (!document.getElementById('equipment-wrapper').contains(e.target)) { |
| csOpen = false; |
| document.getElementById('cs-trigger').classList.remove('open'); |
| document.getElementById('cs-options').classList.remove('open'); |
| } |
| }); |
| |
| async function loadModelInfo() { |
| try { |
| const res = await fetch('/model_info'); |
| modelData = await res.json(); |
| if (modelData.error) { showToast('Model error: ' + modelData.error); return; } |
| |
| const lg = modelData.lgbm, rf = modelData.rf; |
| document.getElementById('lgbm-tab-auc').textContent = 'AUC ' + (lg.test_metrics.test_auc * 100).toFixed(1) + '%'; |
| document.getElementById('rf-tab-auc').textContent = 'AUC ' + (rf.test_metrics.test_auc * 100).toFixed(1) + '%'; |
| document.getElementById('model-badge').textContent = '2 Models Ready'; |
| |
| // Build comparison table |
| const metrics = [ |
| ['AUC', 'test_auc'], |
| ['Accuracy', 'test_accuracy'], |
| ['Precision', 'test_precision'], |
| ['Recall', 'test_recall'], |
| ['F1 Score', 'test_f1'], |
| ['Log Loss', 'test_logloss'], |
| ]; |
| const rows = metrics.map(([label, key]) => { |
| const lv = lg.test_metrics[key], rv = rf.test_metrics[key]; |
| const higherBetter = key !== 'test_logloss'; |
| const lgWins = higherBetter ? lv > rv : lv < rv; |
| const rfWins = higherBetter ? rv > lv : rv < lv; |
| const fmt = v => key === 'test_logloss' ? v.toFixed(4) : (v * 100).toFixed(2) + '%'; |
| return `<tr> |
| <td class="metric-name">${label}</td> |
| <td class="${lgWins ? 'win lgbm' : ''}">${fmt(lv)}${lgWins ? '<span class="win-tag lgbm">▲</span>' : ''}</td> |
| <td class="${rfWins ? 'win rf' : ''}">${fmt(rv)}${rfWins ? '<span class="win-tag rf">▲</span>' : ''}</td> |
| </tr>`; |
| }).join(''); |
| document.getElementById('compare-content').innerHTML = ` |
| <table class="compare-table"> |
| <thead><tr> |
| <th>Metric</th> |
| <th class="lgbm-col">⚡ LightGBM</th> |
| <th class="rf-col">🌲 Random Forest</th> |
| </tr></thead> |
| <tbody>${rows}</tbody> |
| </table>`; |
| |
| updateModelInfo('lgbm'); |
| } catch(e) { |
| document.getElementById('model-badge').textContent = 'Load Error'; |
| } |
| } |
| |
| function updateModelInfo(model) { |
| if (!modelData[model]) return; |
| const d = modelData[model]; |
| const isLgbm = model === 'lgbm'; |
| const color = isLgbm ? 'green' : 'amber'; |
| |
| const rows = isLgbm ? [ |
| ['Model', 'LightGBM'], |
| ['Learning Rate', d.config.learning_rate], |
| ['N Estimators', d.config.n_estimators], |
| ['Split', d.config.train_ratio + '/' + d.config.val_ratio + '/' + d.config.test_ratio], |
| ] : [ |
| ['Model', 'Random Forest'], |
| ['N Estimators', d.config.n_estimators], |
| ['Max Depth', d.config.max_depth], |
| ['Split', d.config.train_ratio + '/' + d.config.val_ratio + '/' + d.config.test_ratio], |
| ]; |
| |
| const metricRows = [ |
| ['Test AUC', (d.test_metrics.test_auc * 100).toFixed(2) + '%'], |
| ['Test F1', (d.test_metrics.test_f1 * 100).toFixed(2) + '%'], |
| ['Test Accuracy', (d.test_metrics.test_accuracy * 100).toFixed(2) + '%'], |
| ['Precision', (d.test_metrics.test_precision * 100).toFixed(2) + '%'], |
| ['Recall', (d.test_metrics.test_recall * 100).toFixed(2) + '%'], |
| ]; |
| |
| document.getElementById('model-info').innerHTML = |
| [...rows, ...metricRows].map(([k, v], i) => |
| '<div class="info-row"><span class="info-key">' + k + '</span>' + |
| '<span class="info-val' + (i >= rows.length ? ' ' + color : '') + '">' + v + '</span></div>' |
| ).join(''); |
| } |
| |
| async function runPredict() { |
| const btn = document.getElementById('predict-btn'); |
| btn.classList.add('loading'); |
| btn.disabled = true; |
| const payload = { |
| model: activeModel, |
| equipment: document.getElementById('equipment').value, |
| temperature: parseFloat(document.getElementById('temperature').value), |
| pressure: parseFloat(document.getElementById('pressure').value), |
| vibration: parseFloat(document.getElementById('vibration').value), |
| humidity: parseFloat(document.getElementById('humidity').value), |
| }; |
| try { |
| const res = await fetch('/predict', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify(payload) |
| }); |
| const data = await res.json(); |
| if (data.error) { showToast('Error: ' + data.error); return; } |
| showResult(data, payload); |
| addHistory(data, payload); |
| } catch(e) { |
| showToast('Network error — please try again.'); |
| } finally { |
| btn.classList.remove('loading'); |
| btn.disabled = false; |
| } |
| } |
| |
| function showResult(data, payload) { |
| const isFaulty = data.prediction === 1; |
| const prob = (data.probability * 100).toFixed(1); |
| const cls = isFaulty ? 'faulty' : 'healthy'; |
| const isLgbm = data.model === 'lgbm'; |
| document.getElementById('result-card').className = 'result-card ' + cls; |
| document.getElementById('idle-state').style.display = 'none'; |
| document.getElementById('result-content').style.display = 'block'; |
| const vt = document.getElementById('verdict-text'); |
| vt.className = 'verdict ' + cls; |
| vt.textContent = isFaulty ? '⚠ FAULT DETECTED' : '✓ HEALTHY'; |
| document.getElementById('verdict-sub').textContent = isFaulty |
| ? 'High fault probability — immediate inspection recommended.' |
| : 'Equipment readings within normal operating range.'; |
| const tag = document.getElementById('model-used-tag'); |
| tag.className = 'model-used-tag ' + (isLgbm ? 'lgbm' : 'rf'); |
| tag.textContent = isLgbm ? '⚡ LightGBM' : '🌲 Random Forest'; |
| document.getElementById('prob-pct').textContent = prob + '%'; |
| const fill = document.getElementById('prob-fill'); |
| fill.className = 'prob-fill ' + cls; |
| setTimeout(() => fill.style.width = prob + '%', 50); |
| document.getElementById('mini-metrics').innerHTML = [ |
| ['Probability', prob + '%'], |
| ['Confidence', data.confidence], |
| ['Equipment', payload.equipment], |
| ['Threshold', (data.threshold * 100).toFixed(0) + '%'], |
| ].map(([k,v]) => |
| '<div class="mini-metric"><div class="mm-val">' + v + '</div><div class="mm-key">' + k + '</div></div>' |
| ).join(''); |
| } |
| |
| function addHistory(data, payload) { |
| const isFaulty = data.prediction === 1; |
| const cls = isFaulty ? 'faulty' : 'healthy'; |
| const isLgbm = data.model === 'lgbm'; |
| const list = document.getElementById('history-list'); |
| if (list.children.length === 1 && list.firstElementChild.style.color !== undefined |
| && list.firstElementChild.querySelector) list.innerHTML = ''; |
| if (list.children.length === 1 && !list.firstElementChild.classList.contains('hist-item')) list.innerHTML = ''; |
| const item = document.createElement('div'); |
| item.className = 'hist-item'; |
| item.innerHTML = |
| '<div><div style="font-family:var(--mono);font-size:.78rem;">' + payload.equipment + '</div>' + |
| '<div class="hist-equip">T=' + payload.temperature + '° P=' + payload.pressure + 'bar V=' + payload.vibration + '</div>' + |
| '<span class="hist-model-tag ' + (isLgbm ? 'lgbm' : 'rf') + '">' + (isLgbm ? '⚡ LGBM' : '🌲 RF') + '</span></div>' + |
| '<span class="hist-badge ' + cls + '">' + (isFaulty ? 'FAULT' : 'OK') + ' · ' + (data.probability*100).toFixed(1) + '%</span>'; |
| list.prepend(item); |
| if (list.children.length > 20) list.removeChild(list.lastChild); |
| } |
| |
| function showToast(msg) { |
| const t = document.getElementById('toast'); |
| t.textContent = msg; |
| t.classList.add('show'); |
| setTimeout(() => t.classList.remove('show'), 3500); |
| } |
| |
| loadModelInfo(); |
| </script> |
| </body> |
| </html>""" |
|
|
|
|
| |
| |
| |
|
|
| @app.route("/") |
| def index(): |
| return render_template_string(HTML) |
|
|
| @app.route("/model_info") |
| def model_info(): |
| return jsonify({ |
| "lgbm": { |
| "config": ARTIFACTS["lgbm"]["config"], |
| "test_metrics": ARTIFACTS["lgbm"]["test_metrics"], |
| "cm": ARTIFACTS["lgbm"]["cm"], |
| }, |
| "rf": { |
| "config": ARTIFACTS["rf"]["config"], |
| "test_metrics": ARTIFACTS["rf"]["test_metrics"], |
| "cm": ARTIFACTS["rf"]["cm"], |
| }, |
| }) |
|
|
| @app.route("/predict", methods=["POST"]) |
| def predict(): |
| body = request.get_json(force=True) |
| model_key = body.get("model", "lgbm") |
| if model_key not in ARTIFACTS: |
| return jsonify({"error": f"Unknown model '{model_key}'. Use 'lgbm' or 'rf'."}), 400 |
|
|
| try: |
| row = pd.DataFrame([{ |
| "equipment": body["equipment"], |
| "temperature": float(body["temperature"]), |
| "pressure": float(body["pressure"]), |
| "vibration": float(body["vibration"]), |
| "humidity": float(body["humidity"]), |
| }]) |
| except (KeyError, ValueError) as e: |
| return jsonify({"error": f"Bad input: {e}"}), 400 |
|
|
| artifact = ARTIFACTS[model_key] |
| prob = float(artifact["pipeline"].predict_proba(row)[0, 1]) |
| pred = int(prob >= THRESHOLD) |
| confidence = "HIGH" if prob > 0.85 or prob < 0.15 else "MEDIUM" if prob > 0.65 or prob < 0.35 else "LOW" |
|
|
| return jsonify({ |
| "model": model_key, |
| "prediction": pred, |
| "probability": round(prob, 4), |
| "confidence": confidence, |
| "threshold": THRESHOLD, |
| "label": "FAULTY" if pred == 1 else "HEALTHY", |
| }) |
|
|
|
|
| if __name__ == "__main__": |
| app.run(debug=False, host="0.0.0.0", port=7860) |