AnubhaParashar/Predictive_Preventive_Maintenance_for_Generator_Reliability
0
1import os2import io3import math4import zipfile5from pathlib import Path6from typing import Dict, Optional, Tuple7from urllib.parse import quote8 9import numpy as np10import pandas as pd11import plotly.express as px12import plotly.graph_objects as go13import streamlit as st14from sklearn.compose import ColumnTransformer15from sklearn.pipeline import Pipeline16from sklearn.preprocessing import OneHotEncoder, StandardScaler17from sklearn.impute import SimpleImputer18from sklearn.model_selection import train_test_split19from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score, confusion_matrix20from sklearn.linear_model import LogisticRegression21from sklearn.ensemble import RandomForestClassifier22 23 24APP_DIR = Path(__file__).resolve().parent25DEFAULT_DATA_DIR = APP_DIR / "data" / "processed"26OUTPUT_FIG_DIR = APP_DIR / "outputs" / "figures"27OUTPUT_TABLE_DIR = APP_DIR / "outputs" / "tables"28RAW_DATA_DIR = APP_DIR / "data" / "raw"29DEFAULT_RAW_TELEMETRY_PATH = RAW_DATA_DIR / "generator_telemetry_with_labels.csv"30 31# Hugging Face Spaces can store larger files through Xet/LFS. In some Docker builds,32# the local file may appear as a tiny pointer file instead of the real CSV.33# This fallback lets the app fetch the resolved file from the Space repo when needed.34HF_SPACE_ID = os.getenv(35 "HF_SPACE_ID",36 "AnubhaParashar/Predictive_Preventive_Maintenance_for_Generator_Reliability",37)38HF_RESOLVE_BASE = f"https://huggingface.co/spaces/{HF_SPACE_ID}/resolve/main"39EXTERNAL_RAW_TELEMETRY_PATH = DEFAULT_RAW_TELEMETRY_PATH40 41 42# Snowflake Streamlit does not support page_title/page_icon in st.set_page_config.43# Keep layout only and continue safely if the runtime ignores this call.44try:45 st.set_page_config(layout="wide", initial_sidebar_state="expanded")46except Exception:47 pass48 49 50# -----------------------------51# Utility helpers52# -----------------------------53def money(x):54 try:55 if pd.isna(x):56 return "$0"57 return f"${float(x):,.0f}"58 except Exception:59 return "$0"60 61 62def num(x, decimals=0):63 try:64 if pd.isna(x):65 return "0"66 return f"{float(x):,.{decimals}f}"67 except Exception:68 return "0"69 70 71def pct(x, decimals=1):72 try:73 if pd.isna(x):74 return "0%"75 return f"{float(x) * 100:.{decimals}f}%"76 except Exception:77 return "0%"78 79 80def _looks_like_xet_or_lfs_pointer(df: pd.DataFrame) -> bool:81 """Detect a Git LFS / Hugging Face Xet pointer that was read as a tiny CSV."""82 if df is None or df.empty:83 return False84 if df.shape[0] > 5 or df.shape[1] > 2:85 return False86 text = " ".join([str(x) for x in list(df.columns) + df.astype(str).values.flatten().tolist()])87 text = text.lower()88 return (89 "git-lfs.github.com/spec" in text90 or "version https://git-lfs" in text91 or "xet" in text and "sha256" in text92 or "oid sha256" in text93 )94 95 96def _hf_resolve_url_for_path(path: Path) -> str:97 """Build a Hugging Face /resolve/main URL for a local repo-relative path."""98 try:99 rel = path.resolve().relative_to(APP_DIR.resolve()).as_posix()100 except Exception:101 rel = path.as_posix().lstrip("/")102 return f"{HF_RESOLVE_BASE}/{quote(rel)}"103 104 105def read_csv_safely(path_or_buffer) -> pd.DataFrame:106 """Read CSV and recover automatically if Hugging Face gives a Xet/LFS pointer file."""107 df = pd.read_csv(path_or_buffer)108 109 # If a local repo file is only a pointer, fetch the actual resolved file from Hugging Face.110 if isinstance(path_or_buffer, (str, Path)):111 path = Path(path_or_buffer)112 if _looks_like_xet_or_lfs_pointer(df):113 resolved_url = _hf_resolve_url_for_path(path)114 return pd.read_csv(resolved_url)115 116 return df117 118 119def parse_date_cols(df: pd.DataFrame, cols) -> pd.DataFrame:120 out = df.copy()121 for c in cols:122 if c in out.columns:123 out[c] = pd.to_datetime(out[c], errors="coerce")124 return out125 126 127@st.cache_data(show_spinner=False)128def load_default_tables() -> Dict[str, pd.DataFrame]:129 tables = {}130 files = {131 "asset_master": "asset_master.csv",132 "pm_events": "pm_events.csv",133 "failure_events": "failure_events.csv",134 "business_impact": "business_impact.csv",135 "pm_failure_linked": "pm_failure_linked.csv",136 "telemetry_weekly": "telemetry_weekly.csv",137 }138 for key, filename in files.items():139 path = DEFAULT_DATA_DIR / filename140 if path.exists():141 tables[key] = read_csv_safely(path)142 else:143 tables[key] = pd.DataFrame()144 145 tables["asset_master"] = parse_date_cols(tables["asset_master"], ["install_date"])146 tables["pm_events"] = parse_date_cols(tables["pm_events"], ["scheduled_date", "completed_date", "pm_date"])147 tables["failure_events"] = parse_date_cols(148 tables["failure_events"], ["failure_date", "ticket_open_date", "ticket_close_date"]149 )150 tables["business_impact"] = parse_date_cols(tables["business_impact"], ["event_date"])151 tables["pm_failure_linked"] = parse_date_cols(tables["pm_failure_linked"], ["pm_date", "next_failure_date"])152 tables["telemetry_weekly"] = parse_date_cols(tables["telemetry_weekly"], ["timestamp"])153 return tables154 155 156@st.cache_data(show_spinner=False)157def load_default_raw_telemetry() -> Tuple[pd.DataFrame, str]:158 """Load the packaged raw telemetry file used by the demo app."""159 if DEFAULT_RAW_TELEMETRY_PATH.exists():160 df = read_csv_safely(DEFAULT_RAW_TELEMETRY_PATH)161 return parse_date_cols(df, ["timestamp"]), "Packaged demo file: data/raw/generator_telemetry_with_labels.csv"162 return pd.DataFrame(), "No packaged raw telemetry file found"163 164 165def dataframe_profile(df: pd.DataFrame, source_path: str) -> pd.DataFrame:166 if df.empty:167 return pd.DataFrame({"Metric": ["Source path", "Rows", "Columns"], "Value": [source_path, "0", "0"]})168 values = {169 "Source path": source_path,170 "Rows": f"{len(df):,}",171 "Columns": f"{len(df.columns):,}",172 "Date range": "Not available",173 "Duplicate rows": f"{int(df.duplicated().sum()):,}",174 "Missing values": f"{int(df.isna().sum().sum()):,}",175 }176 if "timestamp" in df.columns:177 ts = pd.to_datetime(df["timestamp"], errors="coerce")178 if ts.notna().any():179 values["Date range"] = f"{ts.min().date()} to {ts.max().date()}"180 return pd.DataFrame({"Metric": list(values.keys()), "Value": list(values.values())})181 182 183def enrich_algorithm_output(raw_df: pd.DataFrame) -> pd.DataFrame:184 """Create a transparent demo algorithm output from raw telemetry columns."""185 if raw_df.empty:186 return pd.DataFrame()187 out = raw_df.copy()188 if "timestamp" in out.columns:189 out["timestamp"] = pd.to_datetime(out["timestamp"], errors="coerce")190 for c in ["anomaly_score", "days_since_last_pm", "failure_within_30d", "failure_within_14d"]:191 if c in out.columns:192 out[c] = pd.to_numeric(out[c], errors="coerce")193 if "anomaly_score" not in out.columns:194 numeric_cols = out.select_dtypes(include=[np.number]).columns.tolist()195 if numeric_cols:196 z = out[numeric_cols].fillna(out[numeric_cols].median(numeric_only=True))197 out["anomaly_score"] = ((z - z.mean()) / z.std(ddof=0)).abs().mean(axis=1).rank(pct=True) * 100198 else:199 out["anomaly_score"] = 0200 conditions = [201 out["anomaly_score"] >= 80,202 out["anomaly_score"] >= 60,203 out["anomaly_score"] >= 40,204 ]205 choices = ["Critical", "High", "Watch"]206 out["algorithm_risk_band"] = np.select(conditions, choices, default="Low")207 pm_age = pd.to_numeric(out.get("days_since_last_pm", pd.Series(0, index=out.index)), errors="coerce").fillna(0)208 out["algorithm_failure_risk_score"] = np.clip((out["anomaly_score"].fillna(0) * 0.75) + np.minimum(pm_age, 180) / 180 * 25, 0, 100).round(2)209 out["algorithm_predicted_failure_within_30d"] = (out["algorithm_failure_risk_score"] >= 70).astype(int)210 out["recommended_action"] = np.select(211 [out["algorithm_risk_band"].eq("Critical"), out["algorithm_risk_band"].eq("High"), out["algorithm_risk_band"].eq("Watch")],212 ["Immediate inspection / corrective work order", "Schedule PM within 7 days", "Monitor and plan PM",],213 default="Normal monitoring",214 )215 sort_cols = [c for c in ["timestamp", "algorithm_failure_risk_score"] if c in out.columns]216 if sort_cols:217 out = out.sort_values(sort_cols, ascending=[False, False] if len(sort_cols) == 2 else False)218 return out219 220 221def summarize_algorithm_by_asset(algo_df: pd.DataFrame) -> pd.DataFrame:222 if algo_df.empty or "asset_id" not in algo_df.columns:223 return pd.DataFrame()224 df = algo_df.copy()225 if "timestamp" in df.columns:226 df = df.sort_values("timestamp")227 latest = df.groupby("asset_id", as_index=False).tail(1).copy()228 keep_cols = [c for c in ["asset_id", "timestamp", "model", "region", "criticality", "environment_type", "days_since_last_pm", "anomaly_score", "algorithm_failure_risk_score", "algorithm_risk_band", "algorithm_predicted_failure_within_30d", "recommended_action"] if c in latest.columns]229 return latest[keep_cols].sort_values("algorithm_failure_risk_score", ascending=False)230 231 232 233# -----------------------------234# Machine Learning helpers235# -----------------------------236@st.cache_data(show_spinner=False)237def prepare_ml_frame(raw_df: pd.DataFrame, target_col: str = "failure_within_30d", max_rows: int = 90000):238 """Prepare telemetry data for supervised failure-risk modeling."""239 if raw_df.empty or target_col not in raw_df.columns:240 return pd.DataFrame(), [], []241 242 use_cols = [243 "asset_id", "age_years", "days_since_last_pm", "runtime_hours_week", "avg_load_pct", "oil_temp_c",244 "coolant_temp_c", "battery_voltage", "vibration_mm_s", "fuel_rate_lph", "alarm_count", "anomaly_score",245 "model", "region", "criticality", "environment_type", target_col, "timestamp"246 ]247 use_cols = [c for c in use_cols if c in raw_df.columns]248 df = raw_df[use_cols].copy().dropna(subset=[target_col])249 if "timestamp" in df.columns:250 df["timestamp"] = pd.to_datetime(df["timestamp"], errors="coerce")251 df = df.sort_values("timestamp")252 253 # Keep the app fast in Snowflake by training on a balanced sample.254 if len(df) > max_rows:255 pos = df[df[target_col] == 1]256 neg = df[df[target_col] == 0]257 n_pos = min(len(pos), max_rows // 3)258 n_neg = max_rows - n_pos259 df = pd.concat([260 pos.sample(n=n_pos, random_state=42) if len(pos) > n_pos else pos,261 neg.sample(n=n_neg, random_state=42) if len(neg) > n_neg else neg,262 ], axis=0).sample(frac=1, random_state=42)263 264 feature_cols = [c for c in df.columns if c not in [target_col, "timestamp"]]265 numeric_cols = [c for c in feature_cols if pd.api.types.is_numeric_dtype(df[c])]266 categorical_cols = [c for c in feature_cols if c not in numeric_cols]267 return df, numeric_cols, categorical_cols268 269 270@st.cache_resource(show_spinner=False)271def train_failure_models(df: pd.DataFrame, numeric_cols, categorical_cols, target_col: str = "failure_within_30d"):272 """Train baseline and advanced ML classifiers for near-term failure prediction."""273 X = df[numeric_cols + categorical_cols]274 y = df[target_col].astype(int)275 X_train, X_test, y_train, y_test = train_test_split(276 X, y, test_size=0.25, random_state=42, stratify=y277 )278 279 numeric_transformer = Pipeline(steps=[280 ("imputer", SimpleImputer(strategy="median")),281 ("scaler", StandardScaler()),282 ])283 categorical_transformer = Pipeline(steps=[284 ("imputer", SimpleImputer(strategy="most_frequent")),285 ("onehot", OneHotEncoder(handle_unknown="ignore")),286 ])287 preprocessor = ColumnTransformer(288 transformers=[289 ("num", numeric_transformer, numeric_cols),290 ("cat", categorical_transformer, categorical_cols),291 ]292 )293 294 models = {295 "Logistic Regression baseline": LogisticRegression(max_iter=500, class_weight="balanced"),296 "Random Forest advanced model": RandomForestClassifier(297 n_estimators=160,298 max_depth=12,299 min_samples_leaf=5,300 random_state=42,301 n_jobs=-1,302 class_weight="balanced_subsample",303 ),304 }305 306 results = {}307 for name, model in models.items():308 pipe = Pipeline(steps=[("preprocessor", preprocessor), ("model", model)])309 pipe.fit(X_train, y_train)310 proba = pipe.predict_proba(X_test)[:, 1]311 pred = (proba >= 0.5).astype(int)312 results[name] = {313 "pipeline": pipe,314 "accuracy": accuracy_score(y_test, pred),315 "precision": precision_score(y_test, pred, zero_division=0),316 "recall": recall_score(y_test, pred, zero_division=0),317 "f1": f1_score(y_test, pred, zero_division=0),318 "roc_auc": roc_auc_score(y_test, proba),319 "confusion_matrix": confusion_matrix(y_test, pred),320 }321 return results322 323 324def get_ml_feature_importance(pipeline, model_name: str, top_n: int = 15) -> pd.DataFrame:325 preprocessor = pipeline.named_steps["preprocessor"]326 feature_names = preprocessor.get_feature_names_out()327 model = pipeline.named_steps["model"]328 if "Logistic" in model_name:329 values = np.abs(model.coef_[0])330 else:331 values = model.feature_importances_332 out = pd.DataFrame({"feature": feature_names, "importance": values})333 out["feature"] = out["feature"].str.replace("num__", "", regex=False).str.replace("cat__", "", regex=False)334 return out.sort_values("importance", ascending=False).head(top_n)335 336 337def score_latest_asset_risk(telemetry_df: pd.DataFrame, pipeline) -> pd.DataFrame:338 latest = telemetry_df.copy()339 if latest.empty or "asset_id" not in latest.columns:340 return pd.DataFrame()341 if "timestamp" in latest.columns:342 latest["timestamp"] = pd.to_datetime(latest["timestamp"], errors="coerce")343 latest = latest.sort_values("timestamp").groupby("asset_id", as_index=False).tail(1)344 345 feature_cols = [346 "asset_id", "age_years", "days_since_last_pm", "runtime_hours_week", "avg_load_pct", "oil_temp_c",347 "coolant_temp_c", "battery_voltage", "vibration_mm_s", "fuel_rate_lph", "alarm_count", "anomaly_score",348 "model", "region", "criticality", "environment_type"349 ]350 feature_cols = [c for c in feature_cols if c in latest.columns]351 latest = latest.copy()352 latest["ml_predicted_failure_risk"] = pipeline.predict_proba(latest[feature_cols])[:, 1]353 latest["ml_risk_band"] = pd.cut(354 latest["ml_predicted_failure_risk"],355 bins=[-0.01, 0.30, 0.60, 0.80, 1.0],356 labels=["Low", "Medium", "High", "Critical"],357 )358 latest["ml_recommended_action"] = np.select(359 [360 latest["ml_predicted_failure_risk"] >= 0.80,361 latest["ml_predicted_failure_risk"] >= 0.60,362 latest["ml_predicted_failure_risk"] >= 0.30,363 ],364 [365 "Immediate inspection / PM within 3 days",366 "Schedule PM within 7 days",367 "Watch list / inspect soon",368 ],369 default="Normal monitoring",370 )371 keep = [c for c in [372 "asset_id", "timestamp", "model", "region", "criticality", "days_since_last_pm",373 "anomaly_score", "battery_voltage", "vibration_mm_s", "alarm_count",374 "ml_predicted_failure_risk", "ml_risk_band", "ml_recommended_action"375 ] if c in latest.columns]376 return latest[keep].sort_values("ml_predicted_failure_risk", ascending=False)377 378 379def build_prescriptive_pm_plan(ml_risk_df: pd.DataFrame, linked_df: pd.DataFrame, business_df: pd.DataFrame) -> pd.DataFrame:380 if ml_risk_df.empty:381 return pd.DataFrame()382 plan = ml_risk_df.copy()383 if not linked_df.empty and {"asset_id", "days_to_next_failure"}.issubset(linked_df.columns):384 hist = linked_df.copy()385 hist["days_to_next_failure"] = pd.to_numeric(hist["days_to_next_failure"], errors="coerce")386 hist = hist.groupby("asset_id", as_index=False)["days_to_next_failure"].median()387 hist = hist.rename(columns={"days_to_next_failure": "historical_median_days_to_failure"})388 plan = plan.merge(hist, on="asset_id", how="left")389 390 avg_failure_cost = 0.0391 if not business_df.empty:392 if "repair_cost" in business_df.columns:393 avg_failure_cost += pd.to_numeric(business_df["repair_cost"], errors="coerce").fillna(0).mean()394 if "truck_roll_cost" in business_df.columns:395 avg_failure_cost += pd.to_numeric(business_df["truck_roll_cost"], errors="coerce").fillna(0).mean()396 plan["estimated_failure_cost_exposure"] = avg_failure_cost397 398 plan["prescriptive_pm_action"] = np.select(399 [400 plan["ml_predicted_failure_risk"] >= 0.80,401 (plan["ml_predicted_failure_risk"] >= 0.60) | (pd.to_numeric(plan.get("days_since_last_pm", 0), errors="coerce").fillna(0) > 120),402 plan["ml_predicted_failure_risk"] >= 0.30,403 ],404 [405 "Create urgent work order and inspect immediately",406 "Plan PM in next 7 days and review alarms / vibration",407 "Increase monitoring and inspect in next PM window",408 ],409 default="Maintain current PM cycle",410 )411 plan["priority_rank"] = plan["ml_predicted_failure_risk"].rank(method="dense", ascending=False).astype(int)412 return plan.sort_values("priority_rank")413 414def identify_uploaded_zip(uploaded_zip) -> Dict[str, pd.DataFrame]:415 """Accepts a zip containing CSVs with expected names."""416 tables = {}417 if uploaded_zip is None:418 return tables419 420 expected = {421 "asset_master": "asset_master.csv",422 "pm_events": "pm_events.csv",423 "failure_events": "failure_events.csv",424 "business_impact": "business_impact.csv",425 "pm_failure_linked": "pm_failure_linked.csv",426 "telemetry_weekly": "telemetry_weekly.csv",427 }428 429 with zipfile.ZipFile(uploaded_zip) as z:430 names = z.namelist()431 for key, filename in expected.items():432 match = [n for n in names if n.endswith(filename)]433 if match:434 with z.open(match[0]) as f:435 tables[key] = pd.read_csv(f)436 return tables437 438 439def build_pm_failure_linked(pm_df: pd.DataFrame, failure_df: pd.DataFrame) -> pd.DataFrame:440 required_pm = {"pm_event_id", "asset_id", "pm_date"}441 required_fail = {"failure_event_id", "asset_id", "failure_date"}442 443 if pm_df.empty or failure_df.empty:444 return pd.DataFrame()445 if not required_pm.issubset(pm_df.columns) or not required_fail.issubset(failure_df.columns):446 return pd.DataFrame()447 448 pm = parse_date_cols(pm_df, ["pm_date"]).copy()449 failures = parse_date_cols(failure_df, ["failure_date"]).copy()450 451 failure_groups = {}452 for asset_id, g in failures.dropna(subset=["failure_date"]).sort_values("failure_date").groupby("asset_id"):453 failure_groups[asset_id] = g.reset_index(drop=True)454 455 rows = []456 for _, row in pm.dropna(subset=["pm_date"]).iterrows():457 asset_id = row["asset_id"]458 pm_date = row["pm_date"]459 next_fail = None460 if asset_id in failure_groups:461 g = failure_groups[asset_id]462 pos = np.searchsorted(g["failure_date"].values.astype("datetime64[ns]"), np.datetime64(pm_date), side="right")463 if pos < len(g):464 next_fail = g.iloc[int(pos)]465 466 if next_fail is not None:467 days = (next_fail["failure_date"] - pm_date).days468 rows.append({469 "pm_event_id": row.get("pm_event_id"),470 "asset_id": asset_id,471 "pm_date": pm_date,472 "next_failure_event_id": next_fail.get("failure_event_id"),473 "next_failure_date": next_fail.get("failure_date"),474 "days_to_next_failure": days,475 "failure_found_flag": 1,476 "ontime_flag": row.get("ontime_flag", np.nan),477 "delay_days": row.get("delay_days", np.nan),478 "pm_total_cost": row.get("total_cost", np.nan),479 "failure_total_cost": next_fail.get("total_cost", np.nan),480 "failure_category": next_fail.get("failure_category", "Unknown"),481 })482 else:483 rows.append({484 "pm_event_id": row.get("pm_event_id"),485 "asset_id": asset_id,486 "pm_date": pm_date,487 "next_failure_event_id": None,488 "next_failure_date": pd.NaT,489 "days_to_next_failure": np.nan,490 "failure_found_flag": 0,491 "ontime_flag": row.get("ontime_flag", np.nan),492 "delay_days": row.get("delay_days", np.nan),493 "pm_total_cost": row.get("total_cost", np.nan),494 "failure_total_cost": np.nan,495 "failure_category": None,496 })497 498 return pd.DataFrame(rows)499 500 501def apply_asset_filters(tables: Dict[str, pd.DataFrame], regions, models, criticalities) -> Dict[str, pd.DataFrame]:502 assets = tables.get("asset_master", pd.DataFrame()).copy()503 if assets.empty:504 return tables505 506 mask = pd.Series(True, index=assets.index)507 if regions and "region" in assets.columns:508 mask &= assets["region"].isin(regions)509 if models and "model" in assets.columns:510 mask &= assets["model"].isin(models)511 if criticalities and "criticality" in assets.columns:512 mask &= assets["criticality"].isin(criticalities)513 514 keep_assets = set(assets.loc[mask, "asset_id"].astype(str))515 out = {}516 for key, df in tables.items():517 if isinstance(df, pd.DataFrame) and not df.empty and "asset_id" in df.columns:518 out[key] = df[df["asset_id"].astype(str).isin(keep_assets)].copy()519 else:520 out[key] = df.copy() if isinstance(df, pd.DataFrame) else df521 return out522 523 524def survival_curve(linked: pd.DataFrame) -> pd.DataFrame:525 if linked.empty or "days_to_next_failure" not in linked.columns:526 return pd.DataFrame(columns=["days", "failure_free_probability"])527 d = linked.loc[linked["days_to_next_failure"].notna(), "days_to_next_failure"].astype(float)528 if d.empty:529 return pd.DataFrame(columns=["days", "failure_free_probability"])530 days_grid = np.arange(0, max(30, int(d.max()) + 30), 30)531 n = len(d)532 vals = []533 for day in days_grid:534 vals.append({535 "days": int(day),536 "failure_free_probability": float((d > day).sum() / n)537 })538 return pd.DataFrame(vals)539 540 541def make_download_zip(tables: Dict[str, pd.DataFrame]) -> bytes:542 mem = io.BytesIO()543 with zipfile.ZipFile(mem, "w", compression=zipfile.ZIP_DEFLATED) as z:544 for name, df in tables.items():545 if isinstance(df, pd.DataFrame) and not df.empty:546 z.writestr(f"{name}.csv", df.to_csv(index=False))547 mem.seek(0)548 return mem.read()549 550 551def metric_card(label, value, help_text=None):552 st.metric(label, value, help=help_text)553 554 555# -----------------------------556# Sidebar: data loading557# -----------------------------558st.sidebar.title("⚙️ Dashboard Controls")559 560st.sidebar.markdown(561 """562 **Data mode**563 - Default mode loads the included generator PM demo dataset.564 - Upload mode lets you override one or more CSVs.565 """566)567 568data_mode = st.sidebar.radio(569 "Choose data source",570 ["Use included default dataset", "Upload my own CSVs / ZIP"],571 index=0,572)573 574tables = load_default_tables()575raw_telemetry_df, raw_telemetry_source_path = load_default_raw_telemetry()576 577if data_mode == "Upload my own CSVs / ZIP":578 st.sidebar.info("Upload a ZIP with expected CSV names, or upload individual CSVs below. Missing files fall back to default data.")579 580 uploaded_zip = st.sidebar.file_uploader(581 "Optional: upload full data ZIP",582 type=["zip"],583 help="ZIP may contain asset_master.csv, pm_events.csv, failure_events.csv, telemetry_weekly.csv, business_impact.csv, pm_failure_linked.csv",584 )585 try:586 zip_tables = identify_uploaded_zip(uploaded_zip)587 for k, v in zip_tables.items():588 tables[k] = v589 except Exception as e:590 st.sidebar.error(f"Could not read ZIP: {e}")591 592 upload_specs = {593 "asset_master": "asset_master.csv",594 "pm_events": "pm_events.csv",595 "failure_events": "failure_events.csv",596 "business_impact": "business_impact.csv",597 "pm_failure_linked": "pm_failure_linked.csv",598 "telemetry_weekly": "telemetry_weekly.csv",599 }600 601 with st.sidebar.expander("Upload individual CSVs"):602 for key, label in upload_specs.items():603 f = st.file_uploader(label, type=["csv"], key=f"upload_{key}")604 if f is not None:605 try:606 tables[key] = pd.read_csv(f)607 except Exception as e:608 st.error(f"Could not read {label}: {e}")609 610 with st.sidebar.expander("Upload raw telemetry CSV"):611 raw_file = st.file_uploader(612 "generator_telemetry_with_labels.csv",613 type=["csv"],614 key="upload_raw_telemetry",615 help="Optional raw telemetry file before algorithm enrichment. If omitted, dashboard uses the WSL path when found, otherwise the included package file.",616 )617 if raw_file is not None:618 try:619 raw_telemetry_df = parse_date_cols(pd.read_csv(raw_file), ["timestamp"])620 raw_telemetry_source_path = "Uploaded file: generator_telemetry_with_labels.csv"621 except Exception as e:622 st.error(f"Could not read raw telemetry CSV: {e}")623# Parse dates after custom upload.624tables["asset_master"] = parse_date_cols(tables.get("asset_master", pd.DataFrame()), ["install_date"])625tables["pm_events"] = parse_date_cols(tables.get("pm_events", pd.DataFrame()), ["scheduled_date", "completed_date", "pm_date"])626tables["failure_events"] = parse_date_cols(tables.get("failure_events", pd.DataFrame()), ["failure_date", "ticket_open_date", "ticket_close_date"])627tables["business_impact"] = parse_date_cols(tables.get("business_impact", pd.DataFrame()), ["event_date"])628tables["pm_failure_linked"] = parse_date_cols(tables.get("pm_failure_linked", pd.DataFrame()), ["pm_date", "next_failure_date"])629tables["telemetry_weekly"] = parse_date_cols(tables.get("telemetry_weekly", pd.DataFrame()), ["timestamp"])630raw_telemetry_df = parse_date_cols(raw_telemetry_df, ["timestamp"])631algorithm_output_df = enrich_algorithm_output(raw_telemetry_df)632algorithm_asset_summary_df = summarize_algorithm_by_asset(algorithm_output_df)633 634# If the user uploaded PM/failure but not linked, rebuild it.635if tables.get("pm_failure_linked", pd.DataFrame()).empty and not tables.get("pm_events", pd.DataFrame()).empty and not tables.get("failure_events", pd.DataFrame()).empty:636 tables["pm_failure_linked"] = build_pm_failure_linked(tables["pm_events"], tables["failure_events"])637 638# Also allow forced rebuild.639if st.sidebar.button("Rebuild PM → Failure Links"):640 tables["pm_failure_linked"] = build_pm_failure_linked(tables["pm_events"], tables["failure_events"])641 st.sidebar.success("Rebuilt pm_failure_linked from PM and failure tables.")642 643assets = tables.get("asset_master", pd.DataFrame())644regions = []645models = []646criticalities = []647if not assets.empty:648 if "region" in assets.columns:649 all_regions = sorted([x for x in assets["region"].dropna().unique().tolist()])650 regions = st.sidebar.multiselect("Filter region", all_regions, default=all_regions)651 if "model" in assets.columns:652 all_models = sorted([x for x in assets["model"].dropna().unique().tolist()])653 models = st.sidebar.multiselect("Filter model", all_models, default=all_models)654 if "criticality" in assets.columns:655 all_criticality = sorted([x for x in assets["criticality"].dropna().unique().tolist()])656 criticalities = st.sidebar.multiselect("Filter criticality", all_criticality, default=all_criticality)657 658sample_limit = st.sidebar.slider("Telemetry chart sample size", 1_000, 50_000, 10_000, step=1_000)659 660filtered = apply_asset_filters(tables, regions, models, criticalities)661 662asset_df = filtered.get("asset_master", pd.DataFrame())663pm_df = filtered.get("pm_events", pd.DataFrame())664failure_df = filtered.get("failure_events", pd.DataFrame())665business_df = filtered.get("business_impact", pd.DataFrame())666linked_df = filtered.get("pm_failure_linked", pd.DataFrame())667telemetry_df = filtered.get("telemetry_weekly", pd.DataFrame())668 669# -----------------------------670# Header671# -----------------------------672st.title("⚙️ Generator Preventive Maintenance Reliability Dashboard")673st.caption("Upload your own maintenance data or use the included demo dataset to analyze PM effectiveness, failure risk, and business impact.")674 675with st.expander("Expected CSV inputs", expanded=False):676 st.markdown(677 """678 **Recommended CSVs**679 - `asset_master.csv`: one row per generator/asset680 - `pm_events.csv`: preventive maintenance events681 - `failure_events.csv`: unplanned repair/failure tickets682 - `pm_failure_linked.csv`: optional; dashboard can rebuild it683 - `telemetry_weekly.csv`: optional sensor/risk history684 - `generator_telemetry_with_labels.csv`: optional ML training table with `failure_within_14d` and `failure_within_30d`685 - `business_impact.csv`: optional downtime, repair cost, customer impact686 687 **Minimum required for PM-to-failure analysis**688 - `pm_events.csv` with `pm_event_id`, `asset_id`, `pm_date`689 - `failure_events.csv` with `failure_event_id`, `asset_id`, `failure_date`690 """691 )692 693# -----------------------------694# Tabs695# -----------------------------696tabs = st.tabs([697 "Problem & Solution",698 "Dataset & Raw → Algorithm",699 "Executive KPIs",700 "PM Effectiveness",701 "Failures & Cost",702 "Telemetry Risk",703 "Predictive ML & PM Strategy",704 "Saved Outputs",705 "Data Explorer & Export",706])707 708 709# -----------------------------710# Problem & Solution tab711# -----------------------------712with tabs[0]:713 st.header("Problem Statement")714 st.markdown(715 """716 Telecom and field-service teams need to know whether **preventive maintenance is actually preventing generator failures**.717 718 The key business question is:719 720 > After a PM is completed, how long does a generator typically run before the next failure or repair ticket?721 722 Without this, teams cannot confidently answer:723 - Which PMs are effective?724 - Which assets are becoming risky?725 - How much downtime and cost can be avoided?726 - Which regions, models, or environments need more attention?727 """728 )729 730 st.header("Solution Provided")731 st.markdown(732 """733 This dashboard converts maintenance records into a reliability workflow:734 735 1. **Load data** 736 Use the included generator demo dataset or upload your own CSVs.737 738 2. **Link PM to next failure** 739 For every PM event, the dashboard finds the first later failure ticket for the same asset.740 741 3. **Measure PM effectiveness** 742 It calculates `days_to_next_failure`, failure-free rate, and on-time vs delayed PM comparisons.743 744 4. **Analyze failure cost and downtime** 745 It summarizes repair cost, truck-roll cost, downtime, SLA impact, and customer impact.746 747 5. **Train ML failure-risk models** 748 Logistic Regression and Random Forest predict whether a generator may fail within 14 or 30 days.749 750 6. **Prescribe PM actions** 751 ML risk scores are converted into priority bands and recommended maintenance actions.752 753 7. **Export outputs** 754 KPI tables and processed datasets can be downloaded for Power BI, Excel, Streamlit sharing, or a client deck.755 """756 )757 758 st.subheader("Architecture")759 st.code(760 """761PM Events + Failure Tickets + Asset Master762 │763 ▼764PM → Next Failure Linker765 │766 ├── PM effectiveness KPIs767 ├── Failure-free survival curve768 ├── On-time vs delayed PM comparison769 ├── Failure cost and downtime view770 └── Telemetry risk overlay771 """,772 language="text",773 )774 775 st.subheader("Dataset currently loaded")776 c1, c2, c3, c4, c5 = st.columns(5)777 c1.metric("Assets", f"{len(asset_df):,}")778 c2.metric("PM events", f"{len(pm_df):,}")779 c3.metric("Failure tickets", f"{len(failure_df):,}")780 c4.metric("Telemetry rows", f"{len(telemetry_df):,}")781 c5.metric("Linked PM rows", f"{len(linked_df):,}")782 783 784# -----------------------------785# -----------------------------786# Dataset and Raw → Algorithm tab787# -----------------------------788with tabs[1]:789 st.header("Dataset Used: Raw → Algorithm Output")790 st.markdown(791 """792 This page explains exactly what data is being used, where it is expected in WSL, what the raw file looks like before the algorithm, and what the algorithm produces after enrichment.793 794 **Packaged raw dataset used by the app:**795 """796 )797 st.code("data/raw/generator_telemetry_with_labels.csv", language="text")798 799 st.info(800 f"Currently loaded raw telemetry source: {raw_telemetry_source_path}\n\n"801 "The dashboard uses the packaged demo raw file. You can replace it by uploading your own raw telemetry CSV from the sidebar."802 )803 804 st.subheader("1) Raw dataset profile before algorithm")805 st.dataframe(dataframe_profile(raw_telemetry_df, raw_telemetry_source_path), use_container_width=True)806 807 if raw_telemetry_df.empty:808 st.warning("Raw telemetry dataset is not available. Place generator_telemetry_with_labels.csv at the WSL path above or upload it from the sidebar.")809 else:810 c1, c2 = st.columns([2, 1])811 with c1:812 st.markdown("**Raw telemetry preview**")813 st.dataframe(raw_telemetry_df.head(1000), use_container_width=True)814 with c2:815 missing = raw_telemetry_df.isna().sum().sort_values(ascending=False).head(12).reset_index()816 missing.columns = ["column", "missing_count"]817 fig = px.bar(missing, x="missing_count", y="column", orientation="h", title="Raw missing values by column")818 st.plotly_chart(fig, use_container_width=True)819 820 st.markdown("**Raw signal visualizations before algorithm**")821 c3, c4 = st.columns(2)822 with c3:823 if "anomaly_score" in raw_telemetry_df.columns:824 fig = px.histogram(raw_telemetry_df, x="anomaly_score", nbins=50, title="Raw anomaly score distribution")825 st.plotly_chart(fig, use_container_width=True)826 elif "runtime_hours_week" in raw_telemetry_df.columns:827 fig = px.histogram(raw_telemetry_df, x="runtime_hours_week", nbins=50, title="Raw weekly runtime distribution")828 st.plotly_chart(fig, use_container_width=True)829 with c4:830 signal_cols = [c for c in ["oil_temp_c", "coolant_temp_c", "battery_voltage", "vibration_mm_s", "fuel_rate_lph", "runtime_hours_week"] if c in raw_telemetry_df.columns]831 if signal_cols and "timestamp" in raw_telemetry_df.columns:832 sample_asset = raw_telemetry_df["asset_id"].iloc[0] if "asset_id" in raw_telemetry_df.columns else None833 signal = signal_cols[0]834 plot_df = raw_telemetry_df[raw_telemetry_df["asset_id"].eq(sample_asset)].copy() if sample_asset and "asset_id" in raw_telemetry_df.columns else raw_telemetry_df.head(200)835 fig = px.line(plot_df.sort_values("timestamp"), x="timestamp", y=signal, title=f"Raw signal trend example: {signal}")836 st.plotly_chart(fig, use_container_width=True)837 838 st.subheader("2) Algorithm applied")839 st.markdown(840 """841 The dashboard creates a transparent demo algorithm layer from the raw telemetry:842 843 - Uses `anomaly_score` as the main health signal.844 - Adds PM-age pressure using `days_since_last_pm`.845 - Produces `algorithm_failure_risk_score` on a 0–100 scale.846 - Converts risk score into `Low`, `Watch`, `High`, or `Critical` bands.847 - Adds `algorithm_predicted_failure_within_30d` and a recommended maintenance action.848 849 This is intentionally explainable for a business demo. When real client data is available, this layer can be replaced by a trained classifier, survival model, or remaining-useful-life model.850 """851 )852 853 st.code(854 """855algorithm_failure_risk_score = 0.75 * anomaly_score + 0.25 * normalized_days_since_last_pm856risk bands:857 Low < 40858 Watch 40–59859 High 60–79860 Critical >= 80861prediction:862 predicted_failure_within_30d = 1 when risk_score >= 70863 """.strip(),864 language="text",865 )866 867 st.subheader("3) After algorithm: enriched output")868 if algorithm_output_df.empty:869 st.warning("Algorithm output is empty because no raw telemetry was loaded.")870 else:871 c5, c6, c7, c8 = st.columns(4)872 c5.metric("Raw rows processed", f"{len(raw_telemetry_df):,}")873 c6.metric("Algorithm output rows", f"{len(algorithm_output_df):,}")874 c7.metric("Assets scored", f"{algorithm_output_df['asset_id'].nunique():,}" if "asset_id" in algorithm_output_df.columns else "0")875 c8.metric("Predicted 30-day failures", f"{int(algorithm_output_df.get('algorithm_predicted_failure_within_30d', pd.Series(dtype=int)).sum()):,}")876 877 c9, c10 = st.columns(2)878 with c9:879 risk_counts = algorithm_output_df["algorithm_risk_band"].value_counts().reindex(["Low", "Watch", "High", "Critical"]).dropna().reset_index()880 risk_counts.columns = ["risk_band", "row_count"]881 fig = px.bar(risk_counts, x="risk_band", y="row_count", title="After algorithm: risk-band distribution")882 st.plotly_chart(fig, use_container_width=True)883 with c10:884 if {"region", "algorithm_predicted_failure_within_30d"}.issubset(algorithm_output_df.columns):885 region_risk = algorithm_output_df.groupby("region", as_index=False)["algorithm_predicted_failure_within_30d"].sum()886 fig = px.bar(region_risk, x="region", y="algorithm_predicted_failure_within_30d", title="Predicted 30-day failures by region")887 st.plotly_chart(fig, use_container_width=True)888 889 st.markdown("**Algorithm output preview**")890 output_cols = [c for c in ["asset_id", "timestamp", "model", "region", "criticality", "days_since_last_pm", "anomaly_score", "algorithm_failure_risk_score", "algorithm_risk_band", "algorithm_predicted_failure_within_30d", "recommended_action"] if c in algorithm_output_df.columns]891 st.dataframe(algorithm_output_df[output_cols].head(2000), use_container_width=True)892 893 st.markdown("**Latest asset-level output after algorithm**")894 st.dataframe(algorithm_asset_summary_df.head(500), use_container_width=True)895 896 st.download_button(897 "Download algorithm_output.csv",898 data=algorithm_output_df.to_csv(index=False),899 file_name="algorithm_output.csv",900 mime="text/csv",901 )902 st.download_button(903 "Download latest_asset_risk_summary.csv",904 data=algorithm_asset_summary_df.to_csv(index=False),905 file_name="latest_asset_risk_summary.csv",906 mime="text/csv",907 )908 909 st.subheader("4) WSL commands to run dashboard")910 st.code(911 """912cd "local-user-path-hidden/OneDrive - Pearce Services, LLC/onedrive_ubuntu/project/Predictive_Preventive_Maintenance_for_Generator_Reliability"913pip install -r requirements.txt914streamlit run app.py915 """.strip(),916 language="bash",917 )918 919# Executive KPIs tab920# -----------------------------921with tabs[2]:922 st.header("Executive KPIs")923 924 valid_days = pd.Series(dtype=float)925 if not linked_df.empty and "days_to_next_failure" in linked_df.columns:926 valid_days = pd.to_numeric(linked_df["days_to_next_failure"], errors="coerce").dropna()927 928 avg_days = valid_days.mean() if not valid_days.empty else np.nan929 median_days = valid_days.median() if not valid_days.empty else np.nan930 fail_30 = (valid_days <= 30).mean() if not valid_days.empty else np.nan931 fail_90 = (valid_days <= 90).mean() if not valid_days.empty else np.nan932 933 total_downtime = pd.to_numeric(failure_df.get("downtime_hours", pd.Series(dtype=float)), errors="coerce").sum()934 total_failure_cost = pd.to_numeric(failure_df.get("total_cost", pd.Series(dtype=float)), errors="coerce").sum()935 936 total_revenue_loss = 0937 total_customers = 0938 if not business_df.empty:939 total_revenue_loss = pd.to_numeric(business_df.get("estimated_revenue_loss", pd.Series(dtype=float)), errors="coerce").sum()940 total_customers = pd.to_numeric(business_df.get("estimated_customers_impacted", pd.Series(dtype=float)), errors="coerce").sum()941 942 c1, c2, c3, c4 = st.columns(4)943 c1.metric("Avg days PM → failure", num(avg_days, 1))944 c2.metric("Median days PM → failure", num(median_days, 1))945 c3.metric("Failure within 30 days", pct(fail_30))946 c4.metric("Failure within 90 days", pct(fail_90))947 948 c5, c6, c7, c8 = st.columns(4)949 c5.metric("Total downtime hours", num(total_downtime, 1))950 c6.metric("Failure repair cost", money(total_failure_cost))951 c7.metric("Estimated revenue loss", money(total_revenue_loss))952 c8.metric("Customer impact count", num(total_customers, 0))953 954 st.divider()955 956 st.subheader("Executive interpretation")957 st.markdown(958 f"""959 - The loaded data contains **{len(asset_df):,} assets**, **{len(pm_df):,} PM events**, and **{len(failure_df):,} failure/repair tickets**.960 - The average observed time from PM to the next failure is **{num(avg_days, 1)} days**.961 - **{pct(fail_30)}** of linked PM events are followed by a failure within 30 days.962 - The failure records represent approximately **{num(total_downtime, 1)} downtime hours** and **{money(total_failure_cost)}** in repair cost.963 """964 )965 966 if not linked_df.empty and "failure_found_flag" in linked_df.columns:967 status_counts = linked_df["failure_found_flag"].value_counts().rename(index={0: "No later failure observed", 1: "Later failure observed"})968 fig = px.pie(969 values=status_counts.values,970 names=status_counts.index,971 title="Linked PM events with later failure observed",972 hole=0.35,973 )974 st.plotly_chart(fig, use_container_width=True)975 976 977# -----------------------------978# PM Effectiveness tab979# -----------------------------980with tabs[3]:981 st.header("Preventive Maintenance Effectiveness")982 983 if linked_df.empty or "days_to_next_failure" not in linked_df.columns:984 st.warning("No PM-to-failure linked data found. Upload PM and failure CSVs, then click 'Rebuild PM → Failure Links'.")985 else:986 linked_plot = linked_df.copy()987 linked_plot["days_to_next_failure"] = pd.to_numeric(linked_plot["days_to_next_failure"], errors="coerce")988 989 c1, c2 = st.columns(2)990 991 with c1:992 fig = px.histogram(993 linked_plot.dropna(subset=["days_to_next_failure"]),994 x="days_to_next_failure",995 nbins=50,996 title="Distribution: Days from PM to next failure",997 labels={"days_to_next_failure": "Days to next failure"},998 )999 st.plotly_chart(fig, use_container_width=True)1000 1001 with c2:1002 surv = survival_curve(linked_plot)1003 if not surv.empty:1004 fig = px.line(1005 surv,1006 x="days",1007 y="failure_free_probability",1008 markers=True,1009 title="Failure-free probability after PM",1010 labels={"days": "Days after PM", "failure_free_probability": "Failure-free probability"},1011 )1012 fig.update_yaxes(tickformat=".0%")1013 st.plotly_chart(fig, use_container_width=True)1014 1015 c3, c4 = st.columns(2)1016 with c3:1017 if "ontime_flag" in linked_plot.columns:1018 temp = linked_plot.dropna(subset=["ontime_flag", "days_to_next_failure"]).copy()1019 if not temp.empty:1020 temp["PM status"] = temp["ontime_flag"].map({1: "On-time PM", 0: "Delayed PM"}).fillna("Unknown")1021 fig = px.box(1022 temp,1023 x="PM status",1024 y="days_to_next_failure",1025 points="outliers",1026 title="On-time vs delayed PM: Days to next failure",1027 )1028 st.plotly_chart(fig, use_container_width=True)1029 1030 with c4:1031 if "delay_days" in linked_plot.columns:1032 temp = linked_plot.dropna(subset=["delay_days", "days_to_next_failure"]).copy()1033 if not temp.empty:1034 fig = px.scatter(1035 temp.sample(min(len(temp), 5000), random_state=42),1036 x="delay_days",1037 y="days_to_next_failure",1038 trendline="ols",1039 title="PM delay vs days to next failure",1040 labels={"delay_days": "PM delay days", "days_to_next_failure": "Days to next failure"},1041 )1042 st.plotly_chart(fig, use_container_width=True)1043 1044 st.subheader("PM effectiveness data")1045 st.dataframe(linked_plot.head(1000), use_container_width=True)1046 1047 1048# -----------------------------1049# Failures & Cost tab1050# -----------------------------1051with tabs[4]:1052 st.header("Failure Patterns, Cost, and Downtime")1053 1054 if failure_df.empty:1055 st.warning("No failure_events.csv data found.")1056 else:1057 c1, c2 = st.columns(2)1058 1059 with c1:1060 if "failure_category" in failure_df.columns:1061 cat = failure_df["failure_category"].fillna("Unknown").value_counts().reset_index()1062 cat.columns = ["failure_category", "count"]1063 fig = px.bar(cat, x="failure_category", y="count", title="Failures by category")1064 st.plotly_chart(fig, use_container_width=True)1065 1066 with c2:1067 if "severity" in failure_df.columns:1068 sev = failure_df["severity"].fillna("Unknown").value_counts().reset_index()1069 sev.columns = ["severity", "count"]1070 fig = px.bar(sev, x="severity", y="count", title="Failures by severity")1071 st.plotly_chart(fig, use_container_width=True)1072 1073 c3, c4 = st.columns(2)1074 1075 with c3:1076 if {"region", "total_cost"}.issubset(failure_df.columns):1077 temp = failure_df.copy()1078 temp["total_cost"] = pd.to_numeric(temp["total_cost"], errors="coerce")1079 reg = temp.groupby("region", as_index=False)["total_cost"].sum()1080 fig = px.bar(reg, x="region", y="total_cost", title="Repair cost by region")1081 st.plotly_chart(fig, use_container_width=True)1082 1083 with c4:1084 if {"model", "downtime_hours"}.issubset(failure_df.columns):1085 temp = failure_df.copy()1086 temp["downtime_hours"] = pd.to_numeric(temp["downtime_hours"], errors="coerce")1087 mod = temp.groupby("model", as_index=False)["downtime_hours"].sum().sort_values("downtime_hours", ascending=False)1088 fig = px.bar(mod, x="model", y="downtime_hours", title="Downtime hours by model")1089 st.plotly_chart(fig, use_container_width=True)1090 1091 if not business_df.empty:1092 st.subheader("Business impact")1093 b1, b2, b3 = st.columns(3)1094 b1.metric("Truck-roll cost", money(pd.to_numeric(business_df.get("truck_roll_cost", pd.Series(dtype=float)), errors="coerce").sum()))1095 b2.metric("Revenue loss", money(pd.to_numeric(business_df.get("estimated_revenue_loss", pd.Series(dtype=float)), errors="coerce").sum()))1096 b3.metric("SLA breach events", num(pd.to_numeric(business_df.get("sla_breach_flag", pd.Series(dtype=float)), errors="coerce").sum(), 0))1097 1098 if {"event_date", "estimated_revenue_loss"}.issubset(business_df.columns):1099 temp = business_df.copy()1100 temp["event_month"] = pd.to_datetime(temp["event_date"], errors="coerce").dt.to_period("M").astype(str)1101 temp["estimated_revenue_loss"] = pd.to_numeric(temp["estimated_revenue_loss"], errors="coerce")1102 month = temp.groupby("event_month", as_index=False)["estimated_revenue_loss"].sum()1103 fig = px.line(month, x="event_month", y="estimated_revenue_loss", markers=True, title="Estimated revenue loss over time")1104 st.plotly_chart(fig, use_container_width=True)1105 1106 1107# -----------------------------1108# Telemetry Risk tab1109# -----------------------------1110with tabs[5]:1111 st.header("Telemetry and Failure Risk")1112 1113 if telemetry_df.empty:1114 st.warning("No telemetry_weekly.csv data found.")1115 else:1116 tele = telemetry_df.copy()1117 for col in ["anomaly_score", "days_since_last_pm", "days_to_next_failure", "failure_within_30d"]:1118 if col in tele.columns:1119 tele[col] = pd.to_numeric(tele[col], errors="coerce")1120 1121 high_risk = pd.DataFrame()1122 if "anomaly_score" in tele.columns:1123 latest = tele.sort_values("timestamp").groupby("asset_id", as_index=False).tail(1)1124 high_risk = latest.sort_values("anomaly_score", ascending=False).head(25)1125 1126 c1, c2, c3 = st.columns(3)1127 c1.metric("Latest high-risk assets shown", f"{len(high_risk):,}")1128 c2.metric("Avg anomaly score", num(tele["anomaly_score"].mean(), 1))1129 if "failure_within_30d" in tele.columns:1130 c3.metric("Rows labeled failure within 30d", f"{int(tele['failure_within_30d'].sum()):,}")1131 1132 c1, c2 = st.columns(2)1133 1134 with c1:1135 if {"days_since_last_pm", "anomaly_score"}.issubset(tele.columns):1136 sample = tele.dropna(subset=["days_since_last_pm", "anomaly_score"])1137 sample = sample.sample(min(len(sample), sample_limit), random_state=42) if len(sample) > sample_limit else sample1138 color_col = "failure_within_30d" if "failure_within_30d" in sample.columns else None1139 fig = px.scatter(1140 sample,1141 x="days_since_last_pm",1142 y="anomaly_score",1143 color=color_col,1144 hover_data=["asset_id"] if "asset_id" in sample.columns else None,1145 title="Anomaly score vs days since last PM",1146 )1147 st.plotly_chart(fig, use_container_width=True)1148 1149 with c2:1150 if {"days_since_last_pm", "anomaly_score"}.issubset(tele.columns):1151 temp = tele.dropna(subset=["days_since_last_pm", "anomaly_score"]).copy()1152 temp["pm_age_bucket"] = pd.cut(temp["days_since_last_pm"], bins=[0, 30, 60, 90, 120, 180, 365, 10000])1153 bucket = temp.groupby("pm_age_bucket", observed=True, as_index=False)["anomaly_score"].mean()1154 bucket["pm_age_bucket"] = bucket["pm_age_bucket"].astype(str)1155 fig = px.bar(bucket, x="pm_age_bucket", y="anomaly_score", title="Average anomaly score by PM age bucket")1156 st.plotly_chart(fig, use_container_width=True)1157 1158 st.subheader("Top risky assets by latest anomaly score")1159 if not high_risk.empty:1160 st.dataframe(high_risk, use_container_width=True)1161 1162 1163 1164# -----------------------------1165# Predictive ML & PM Strategy tab1166# -----------------------------1167with tabs[6]:1168 st.header("Predictive ML and Prescriptive PM Strategy")1169 st.markdown(1170 """1171 This is the **machine-learning section** of the dashboard. It trains real supervised ML models on telemetry records,1172 compares their performance, scores each asset, and converts the score into maintenance actions.1173 1174 **Three-phase flow:**1175 1. **Phase 1 - Reliability analytics:** PM completed → next failure → days to next failure.1176 2. **Phase 2 - Predictive ML:** telemetry + PM recency → failure probability within 14 or 30 days.1177 3. **Phase 3 - Prescriptive PM:** failure probability + business impact → recommended maintenance action.1178 """1179 )1180 1181 # Prefer full raw telemetry with model/region/criticality if available. Otherwise use telemetry_weekly.1182 ml_source_df = raw_telemetry_df.copy() if not raw_telemetry_df.empty else telemetry_df.copy()1183 if ml_source_df.empty:1184 st.warning("No telemetry dataset found for ML training.")1185 else:1186 c1, c2, c3 = st.columns(3)1187 target_col = c1.selectbox("ML prediction target", ["failure_within_30d", "failure_within_14d"], index=0)1188 max_rows = c2.slider("Training sample size", min_value=20000, max_value=120000, value=90000, step=10000)1189 c3.metric("ML source rows", f"{len(ml_source_df):,}")1190 1191 st.subheader("ML inputs")1192 st.markdown(1193 """1194 The model uses telemetry and asset-context features including:1195 `days_since_last_pm`, `runtime_hours_week`, `avg_load_pct`, `oil_temp_c`, `coolant_temp_c`,1196 `battery_voltage`, `vibration_mm_s`, `fuel_rate_lph`, `alarm_count`, `anomaly_score`,1197 `model`, `region`, `criticality`, and `environment_type`.1198 """1199 )1200 