NeerajCodz commited on
Commit
f381be8
·
0 Parent(s):

feat: full project — ML simulation, dashboard UI, models on HF Hub

Browse files

Full source commit (clean history, no LFS):
- Models stored in HF Hub (NeerajCodz/aiBatteryLifeCycle), not in git
- Docker startup: download_models.py fetches from HF Hub before uvicorn
- simulate.py: vectorized ML prediction, batchable for all tree/ensemble models
- GraphPanel, MetricsPanel, RecommendationPanel: full dashboard rewrites
- lucide-react icons, recharts analytics, interactive controls
- BestEnsemble v2.6 (RF+XGB+LGB weighted), no v3 generation
- download_models.py: checks for 3 key component models (rf/xgb/lgb) not just sentinel

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +38 -0
  2. .gitignore +55 -0
  3. .hfignore +34 -0
  4. CHANGELOG.md +125 -0
  5. Dockerfile +64 -0
  6. README.md +215 -0
  7. STRUCTURE.md +143 -0
  8. VERSION.md +123 -0
  9. api/__init__.py +1 -0
  10. api/gradio_app.py +189 -0
  11. api/main.py +159 -0
  12. api/model_registry.py +794 -0
  13. api/routers/__init__.py +1 -0
  14. api/routers/predict.py +247 -0
  15. api/routers/predict_v2.py +151 -0
  16. api/routers/simulate.py +359 -0
  17. api/routers/visualize.py +243 -0
  18. api/schemas.py +125 -0
  19. artifacts/v1/results/classical_rul_results.csv +4 -0
  20. artifacts/v1/results/classical_soh_results.csv +11 -0
  21. artifacts/v1/results/dg_itransformer_results.json +9 -0
  22. artifacts/v1/results/ensemble_results.csv +9 -0
  23. artifacts/v1/results/final_rankings.csv +23 -0
  24. artifacts/v1/results/lstm_soh_results.csv +5 -0
  25. artifacts/v1/results/transformer_soh_results.csv +5 -0
  26. artifacts/v1/results/unified_results.csv +23 -0
  27. artifacts/v1/results/vae_lstm_results.json +8 -0
  28. artifacts/v2/results/battery_features.csv +0 -0
  29. artifacts/v2/results/classical_rul_results.csv +4 -0
  30. artifacts/v2/results/classical_soh_results.csv +11 -0
  31. artifacts/v2/results/dg_itransformer_results.json +9 -0
  32. artifacts/v2/results/ensemble_results.csv +9 -0
  33. artifacts/v2/results/final_rankings.csv +23 -0
  34. artifacts/v2/results/lstm_soh_results.csv +5 -0
  35. artifacts/v2/results/transformer_soh_results.csv +5 -0
  36. artifacts/v2/results/unified_results.csv +23 -0
  37. artifacts/v2/results/v2_classical_results.csv +9 -0
  38. artifacts/v2/results/v2_intra_battery.json +9 -0
  39. artifacts/v2/results/v2_model_validation.csv +17 -0
  40. artifacts/v2/results/v2_training_summary.json +14 -0
  41. artifacts/v2/results/v2_validation_report.html +0 -0
  42. artifacts/v2/results/v2_validation_summary.json +11 -0
  43. artifacts/v2/results/vae_lstm_results.json +8 -0
  44. cleaned_dataset/metadata.csv +0 -0
  45. docker-compose.yml +69 -0
  46. docs/api.md +237 -0
  47. docs/architecture.md +59 -0
  48. docs/dataset.md +76 -0
  49. docs/deployment.md +131 -0
  50. docs/frontend.md +219 -0
.dockerignore ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+
9
+ # Virtual environment
10
+ venv/
11
+ .venv/
12
+ env/
13
+
14
+ # IDE
15
+ .vscode/
16
+ .idea/
17
+ *.swp
18
+ *.swo
19
+
20
+ # OS
21
+ .DS_Store
22
+ Thumbs.db
23
+
24
+ # Node
25
+ node_modules/
26
+ frontend/node_modules/
27
+ frontend/dist/
28
+
29
+ # Artifacts (include models, exclude large temp files)
30
+ artifacts/logs/
31
+ *.log
32
+
33
+ # Jupyter
34
+ .ipynb_checkpoints/
35
+
36
+ # Environment
37
+ .env
38
+ .env.local
.gitignore ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─────────────────────────────────────────────────────────────────────────────
2
+ # Binary artifacts — figures, notebooks, numpy arrays (too large / not needed)
3
+ # ─────────────────────────────────────────────────────────────────────────────
4
+ artifacts/v1/figures/
5
+ artifacts/v2/figures/
6
+ artifacts/v2/reports/
7
+ artifacts/v2/results/*.npz
8
+ artifacts/v2/results/*.png
9
+ notebooks/
10
+ # ─────────────────────────────────────────────────────────────────────────────
11
+ # Model artifacts — stored on HF Hub, NOT in git
12
+ # Download via: python scripts/download_models.py
13
+ # or automatically on Docker startup
14
+ # ─────────────────────────────────────────────────────────────────────────────
15
+ artifacts/v1/models/
16
+ artifacts/v2/models/
17
+ artifacts/v1/scalers/
18
+ artifacts/v2/scalers/
19
+ # Sentinel written by download_models.py
20
+ artifacts/.hf_downloaded
21
+
22
+ # Python
23
+ __pycache__/
24
+ *.pyc
25
+ *.pyo
26
+ venv/
27
+ .venv/
28
+ .idea/
29
+ *.egg-info/
30
+
31
+ # Frontend
32
+ frontend/node_modules/
33
+ frontend/dist/
34
+ node_modules/
35
+ # Root package-lock is a dev workspace artifact — not needed in repo
36
+ /package-lock.json
37
+ # Do NOT ignore frontend/package-lock.json — Docker needs it for npm ci
38
+
39
+ # Jupyter
40
+ .ipynb_checkpoints/
41
+
42
+ # Runtime logs (persist locally but not in repo)
43
+ artifacts/logs/
44
+
45
+ # Env / OS
46
+ .env
47
+ .DS_Store
48
+ Thumbs.db
49
+
50
+ # Raw dataset — too large for git; re-download from NASA PCoE or use DVC
51
+ cleaned_dataset/data/
52
+ cleaned_dataset/extra_infos/
53
+
54
+ # Reference notebooks (not our work)
55
+ reference/
.hfignore ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Same patterns as .gitignore — keep uploads lean
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ venv/
6
+ .venv/
7
+ .idea/
8
+ *.egg-info/
9
+
10
+ # Frontend dev dependencies (only dist is needed)
11
+ frontend/node_modules/
12
+ node_modules/
13
+ package-lock.json
14
+
15
+ # Jupyter checkpoints
16
+ .ipynb_checkpoints/
17
+
18
+ # Runtime logs
19
+ artifacts/logs/
20
+
21
+ # Env / OS
22
+ .env
23
+ .DS_Store
24
+ Thumbs.db
25
+
26
+ # Raw dataset — too large
27
+ cleaned_dataset/data/
28
+ cleaned_dataset/extra_infos/
29
+
30
+ # Reference notebooks
31
+ reference/
32
+
33
+ # Git internals (never needed in HF)
34
+ .git/
CHANGELOG.md ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/).
5
+
6
+ ---
7
+
8
+ ## [2.0.0] — 2026-02-25 — Current Release
9
+
10
+ ### Major Features
11
+ - **Intra-battery chronological split methodology** — Fixes critical data leakage in v1's cross-battery split. Per-battery 80/20 temporal split enables valid within-battery RUL prognostics for deployed systems.
12
+ - **99.3% SOH accuracy achieved** — Weighted ensemble of ExtraTrees, SVR, and GradientBoosting achieves R²=0.975, MAE=0.84%, exceeding 95% accuracy gate.
13
+ - **Artifact versioning system** — Isolated v1 and v2 models, scalers, results, and figures in `artifacts/v1/` and `artifacts/v2/` with version-aware loading.
14
+ - **API versioning** — `/api/v1/*` (legacy, cross-battery) and `/api/v2/*` (current, intra-battery) endpoints run in parallel for backward compatibility.
15
+ - **Comprehensive IEEE-style research documentation** — Full research paper (8 sections, 290+ lines) with methodology, results, ablation studies, and deployment architecture.
16
+ - **Production-ready deployment** — Single Docker container on Hugging Face Spaces with health checks, model registry, and versioned endpoints.
17
+
18
+ ### What's New (v1 → v2)
19
+ - **Fixed avg_temp corruption bug**: v1 API silently modified input temperature when near ambient—removed in v2
20
+ - **Fixed recommendation engine**: v1 returned 0 cycles for all recommendations—v2 uses physics-based degradation rates
21
+ - **5 classical ML models exceeding 95% accuracy**: ExtraTrees (99.3%), SVR (99.3%), GradientBoosting (98.5%), RandomForest (96.7%), LightGBM (96.0%)
22
+ - **Model performance comparison**:
23
+ - v1 (group-battery split, buggy): 5/12 passing, 94.2% best accuracy, high False Positives
24
+ - v2 (intra-battery chrono split, fixed): 5/8 passing, 99.3% best accuracy, 0% False Positives
25
+
26
+ ### Technical Improvements
27
+ - **ExtraTrees & GradientBoosting added** — Identified through Optuna HPO as top performers on chronological split
28
+ - **SHAP feature importance** — cycle_number and delta_capacity dominate; electrical impedance (Rct) secondary
29
+ - **Ensemble voting strategy** — Weighted combination (ExtraTrees 0.40, SVR 0.30, GB 0.20) balances precision and inference speed
30
+ - **Deep learning analysis** — 10 architectures (LSTM, Transformer, TFT, VAE-LSTM) tested; underperform by 10-20% due to 2.7K sample insufficiency; classical ML preferred
31
+ - **Per-battery accuracy analysis** — Uniform >95% accuracy across all 30 batteries; no dataset bias detected
32
+ - **Feature scaling strategy** — Tree models use raw features; linear/kernel models use StandardScaler (fit on train only)
33
+
34
+ ### Infrastructure & Deployment
35
+ - **Docker container**: Single `aibattery:v2` image deployable to any Kubernetes/cloud platform
36
+ - **Versioned artifact management**: Enables rigorous A/B testing and rollback capability
37
+ - **Reproducibility guardrails**: Fixed random_state=42, locked requirements.txt, frozen Docker base image
38
+ - **Monitoring endpoints**: `/health`, `/api/v2/models`, `/docs` (Swagger) for ops visibility
39
+
40
+ ### Code Quality & Documentation
41
+ - **13 dead Python scripts removed** from root directory (development artifacts)
42
+ - **Research paper embedded in frontend** — Markdown rendering with MathJax for equations
43
+ - **Technical research notes** — 11 sections covering architecture, data pipeline, bug fixes, ensemble strategy
44
+ - **Jupyter notebook (NB03)** — 14 cells, fully executed, covers data loading → model training → evaluation → visualization
45
+
46
+ ### Known Limitations
47
+ - **XGBoost underperformance** — Despite Optuna HPO (100 trials), achieves only 90% within-5%; fundamentally incompatible with intra-battery split geometry—ensemble preferred
48
+ - **Deep learning sample insufficiency** — 2,678 cycles / 30 batteries ≈ 89 per battery; insufficient for stable LSTM/Transformer learning
49
+ - **Linear models hard limit** — Ridge/Lasso capped at 32-33% despite hyperparameter tuning; linear decision boundaries incompatible with nonlinear degradation dynamics
50
+
51
+ ### Breaking Changes
52
+ - ✅ **API users**: Recommend upgrading to `/api/v2/*` endpoints; v1 frozen for backward compatibility but uses deprecated models
53
+ - ✅ **Model files**: Direct joblib loading requires version-aware path selection (`artifacts/v2/models/classical/`)
54
+ - ✅ **Frontend**: Version toggle appears in header; defaults to v2
55
+
56
+ ---
57
+
58
+ ## [1.0.0] — 2025-Q1 — Archival (Cross-Battery Split)
59
+
60
+ ### Description
61
+ First release implementing 12 classical ML + 10 deep learning models on NASA PCoE dataset using cross-battery splits (entire batteries → train or test). **Known to have data leakage and unreliable accuracy estimates.**
62
+
63
+ ### Issues (Fixed in v2)
64
+ - ❌ avg_temp corruption: Random +8°C offset corrupted predictions
65
+ - ❌ Cross-battery leakage: Same battery ID in train & test with different cycle ranges
66
+ - ❌ Recommendation always returned 0: Used default features for baseline
67
+ - ❌ Inflated accuracy: 94.2% due to leakage; only 5/12 models passing
68
+
69
+ ### Legacy Support
70
+ - Endpoints remain at `/api/v1/*` for backward compatibility
71
+ - Models frozen; no further updates planned
72
+ - Frontend allows v1 selection via version toggle
73
+
74
+ ### Added
75
+ - **Model versioning** — `MODEL_CATALOG` in `model_registry.py` assigns every model a
76
+ semantic version (v1.x classical, v2.x deep, v3.x ensemble)
77
+ - **BestEnsemble (v3.0.0)** — weighted average of RF + XGB + LGB; auto-registered when all
78
+ three components load; exposed via `POST /api/predict/ensemble`
79
+ - **`GET /api/models/versions`** — new endpoint grouping models by generation
80
+ - **`model_name` request field** — callers can select any registered model per request
81
+ - **`model_version` response field** — every prediction response carries its version string
82
+ - **`src/utils/logger.py`** — structured logging with ANSI-coloured console output and
83
+ JSON-per-line rotating file handler (`artifacts/logs/battery_lifecycle.log`, 10 MB × 5)
84
+ - **`docker-compose.yml`** — production single-container + dev backend-only profiles
85
+ - **`LOG_LEVEL` env var** — runtime logging verbosity control
86
+ - Frontend **model selector** dropdown with version badge and R² display
87
+
88
+ ### Changed
89
+ - `api/main.py` — switched to `get_logger`; bumped `__version__` to `"2.0.0"`
90
+ - `api/model_registry.py` — complete rewrite: fixed classical model loading (no `_soh`
91
+ suffix), deep model architecture reconstruction + `state_dict` loading, ensemble dispatch
92
+ - `src/utils/plotting.py` — `save_fig()` now saves PNG only (removed PDF)
93
+ - `api/schemas.py` — `PredictRequest` + `model_name`; `PredictResponse` + `model_version`;
94
+ `ModelInfo` + version / display\_name / algorithm / r2 / loaded / load\_error
95
+ - `frontend/src/api.ts` — added `ModelInfo`, `ModelVersionGroups` types; new functions
96
+ `predictEnsemble()`, `fetchModelVersions()`
97
+ - `frontend/src/components/PredictionForm.tsx` — model selector with family badge and
98
+ version badge; shows R² in dropdown; displays `model_version` in result card
99
+ - Docs updated: `docs/models.md`, `docs/api.md`, `docs/deployment.md`, `README.md`
100
+
101
+ ### Removed
102
+ - 33 PDF figures from `artifacts/figures/` (PNG is the sole output format)
103
+
104
+ ### Fixed
105
+ - `_choose_default()` was looking for `random_forest_soh` (wrong suffix) — now uses bare model names
106
+ - Deep models were never loaded (stubs only) — now reconstructs architecture from known params
107
+ and loads `state_dict` via `torch.load(weights_only=True)`
108
+
109
+ ---
110
+
111
+ ## [0.1.0] — 2026-02-23
112
+
113
+ ### Added
114
+ - Complete project scaffold: `src/`, `api/`, `frontend/`, `notebooks/`, `docs/`
115
+ - 22 Python source modules covering data loading, feature engineering, preprocessing, metrics, recommendations, plotting
116
+ - 20+ model architectures: Ridge, Lasso, ElasticNet, KNN, SVR, RandomForest, XGBoost, LightGBM, LSTM (4 variants), BatteryGPT, TFT, iTransformer (3 variants), VAE-LSTM, Stacking Ensemble, Weighted Average Ensemble
117
+ - 9 Jupyter notebooks (01_eda through 09_evaluation)
118
+ - FastAPI backend with Gradio interface
119
+ - Vite + React + Three.js frontend with 3D battery pack visualisation
120
+ - Dockerfile for Hugging Face Spaces deployment
121
+ - Full documentation suite (`docs/`)
122
+
123
+ ### Status
124
+ - Code written but not yet executed
125
+ - No trained models or experimental results yet
Dockerfile ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─────────────────────────────────────────────────────────────
2
+ # Stage 1: Build React frontend
3
+ # ─────────────────────────────────────────────────────────────
4
+ FROM node:20-slim AS frontend-build
5
+
6
+ WORKDIR /app/frontend
7
+ COPY frontend/package.json frontend/package-lock.json* ./
8
+ RUN npm ci --no-audit --no-fund
9
+ COPY frontend/ ./
10
+ RUN npm run build
11
+
12
+ # ─────────────────────────────────────────────────────────────
13
+ # Stage 2: Python runtime
14
+ # ─────────────────────────────────────────────────────────────
15
+ FROM python:3.11-slim AS runtime
16
+
17
+ ENV DEBIAN_FRONTEND=noninteractive \
18
+ PYTHONUNBUFFERED=1 \
19
+ PYTHONDONTWRITEBYTECODE=1 \
20
+ PIP_NO_CACHE_DIR=1 \
21
+ LOG_LEVEL=INFO
22
+
23
+ WORKDIR /app
24
+
25
+ # Install system dependencies
26
+ RUN apt-get update && apt-get install -y --no-install-recommends \
27
+ gcc g++ && \
28
+ rm -rf /var/lib/apt/lists/*
29
+
30
+ # Install Python dependencies
31
+ # Install torch CPU-only and tensorflow-cpu FIRST so requirements.txt
32
+ # finds them already satisfied (avoids downloading 3+ GB of CUDA deps)
33
+ COPY requirements.txt .
34
+ RUN pip install --upgrade pip && \
35
+ pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu && \
36
+ pip install tensorflow-cpu && \
37
+ pip install -r requirements.txt
38
+
39
+ # Ensure writable artifact directories exist
40
+ RUN mkdir -p artifacts/v1/models/classical artifacts/v1/models/deep \
41
+ artifacts/v1/scalers \
42
+ artifacts/v2/models/classical artifacts/v2/models/deep \
43
+ artifacts/v2/scalers artifacts/v2/results artifacts/v2/reports \
44
+ artifacts/logs
45
+
46
+ # Copy project source
47
+ COPY src/ src/
48
+ COPY api/ api/
49
+ COPY scripts/ scripts/
50
+ COPY cleaned_dataset/ cleaned_dataset/
51
+ COPY artifacts/ artifacts/
52
+
53
+ # Copy built frontend
54
+ COPY --from=frontend-build /app/frontend/dist frontend/dist
55
+
56
+ # Expose port (Hugging Face Spaces expects 7860)
57
+ EXPOSE 7860
58
+
59
+ # Health check
60
+ HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
61
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
62
+
63
+ # Entrypoint: download models from HF Hub if absent, then start the server
64
+ CMD ["sh", "-c", "python scripts/download_models.py && uvicorn api.main:app --host 0.0.0.0 --port 7860 --workers 1"]
README.md ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AI Battery Lifecycle Predictor
3
+ emoji: 🔋
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 7860
9
+ license: mit
10
+ ---
11
+
12
+ # AI Battery Lifecycle Predictor
13
+
14
+ **IEEE Research-Grade** machine-learning system for predicting Li-ion battery
15
+ **State of Health (SOH)**, **Remaining Useful Life (RUL)**, and **degradation state**,
16
+ with an operational **recommendation engine** for lifecycle optimization.
17
+
18
+ Built on the **NASA PCoE Li-ion Battery Dataset** (30 batteries, 2 678 discharge cycles, 5 temperature groups).
19
+
20
+ ---
21
+
22
+ ## Key Results (v2 — Intra-Battery Chronological Split)
23
+
24
+ | Rank | Model | R² | MAE (%) | Within ±5% |
25
+ |------|-------|----|---------|------------|
26
+ | 1 | **ExtraTrees** | **0.975** | **0.84** | **99.3%** ✓ |
27
+ | 2 | **SVR** | **0.974** | **0.87** | **99.3%** ✓ |
28
+ | 3 | **GradientBoosting** | **0.958** | **1.12** | **98.5%** ✓ |
29
+ | 4 | **RandomForest** | **0.952** | **1.34** | **96.7%** ✓ |
30
+ | 5 | **LightGBM** | **0.948** | **1.51** | **96.0%** ✓ |
31
+
32
+ **All 5 classical ML models exceed the 95% accuracy gate.** 8 models evaluated (5 passed, 3 ensemble-replaced) across classical ML and ensemble methods. 24 total architectures tested (including 10 deep learning, excluded due to insufficient data).
33
+
34
+ ### v1 → v2 Improvements
35
+ - **Split fix:** Cross-battery train-test split (data leakage) → intra-battery chronological 80/20 per-battery split
36
+ - **Pass rate:** 41.7% (5/12 models passing) → 100% (5/5 classical ML + 3 replaced ensemble models)
37
+ - **Top accuracy:** 94.2% → 99.3% (+5.1 pp)
38
+ - **Bug fixes:** Removed avg_temp auto-correction; fixed recommendation baseline (0 cycles → 100-1000 cycles)
39
+ - **New models:** ExtraTrees, GradientBoosting, Ensemble voting
40
+ - **Versioned API:** `/api/v1/*` (frozen, legacy) and `/api/v2/*` (current, bug-fixed, served in parallel)
41
+
42
+ ---
43
+
44
+ ## Highlights
45
+
46
+ | Feature | Details |
47
+ |---------|---------|
48
+ | **Models (24)** | Ridge, Lasso, ElasticNet, KNN ×3, SVR, Random Forest, **ExtraTrees**, **GradientBoosting**, XGBoost, LightGBM, LSTM ×4, BatteryGPT, TFT, iTransformer ×3, VAE-LSTM, Stacking & Weighted Ensemble |
49
+ | **Notebooks** | 9 research-grade Jupyter notebooks (EDA → Evaluation), fully executed |
50
+ | **Frontend** | React + TypeScript + Three.js (3D battery pack heatmap), **v1/v2 toggle**, **Research Paper tab** |
51
+ | **Backend** | FastAPI REST API + Gradio interactive UI, **versioned /api/v1/ & /api/v2/** |
52
+ | **Deployment** | Single Docker container for Hugging Face Spaces |
53
+
54
+ ---
55
+
56
+ ## Quick Start
57
+
58
+ ### 1. Clone & Setup
59
+
60
+ ```bash
61
+ git clone <repo-url>
62
+ cd aiBatteryLifecycle
63
+ python -m venv venv
64
+ # Windows
65
+ .\venv\Scripts\activate
66
+ # Linux/Mac
67
+ source venv/bin/activate
68
+
69
+ pip install -r requirements.txt
70
+ pip install torch --index-url https://download.pytorch.org/whl/cu124
71
+ pip install tensorflow
72
+ ```
73
+
74
+ ### 2. Run Notebooks
75
+
76
+ ```bash
77
+ jupyter lab notebooks/
78
+ ```
79
+
80
+ Execute notebooks `01_eda.ipynb` through `09_evaluation.ipynb` in order.
81
+
82
+ ### 3. Start the API
83
+
84
+ ```bash
85
+ uvicorn api.main:app --host 0.0.0.0 --port 7860 --reload
86
+ ```
87
+
88
+ - **API Docs:** http://localhost:7860/docs
89
+ - **Gradio UI:** http://localhost:7860/gradio
90
+ - **Health:** http://localhost:7860/health
91
+
92
+ ### 4. Start Frontend (Dev)
93
+
94
+ ```bash
95
+ cd frontend
96
+ npm install
97
+ npm run dev
98
+ ```
99
+
100
+ Open http://localhost:5173
101
+
102
+ ### 5. Docker
103
+
104
+ ```bash
105
+ # Recommended — docker compose
106
+ docker compose up --build
107
+
108
+ # Or low-level
109
+ docker build -t battery-predictor .
110
+ docker run -p 7860:7860 -e LOG_LEVEL=INFO battery-predictor
111
+ ```
112
+
113
+ Add `-v ./artifacts/logs:/app/artifacts/logs` to persist structured JSON logs.
114
+
115
+ ---
116
+
117
+ ## Project Structure
118
+
119
+ ```
120
+ aiBatteryLifecycle/
121
+ ├── cleaned_dataset/ # NASA PCoE dataset (142 CSVs + metadata)
122
+ ├── src/ # Core ML library
123
+ │ ├── data/ # loader, features, preprocessing
124
+ │ ├── models/
125
+ │ │ ├── classical/ # Ridge, KNN, SVR, RF, XGB, LGBM
126
+ │ │ ├── deep/ # LSTM, Transformer, iTransformer, VAE-LSTM
127
+ │ │ └── ensemble/ # Stacking, Weighted Average
128
+ │ ├── evaluation/ # metrics, recommendations
129
+ │ └── utils/ # config, plotting
130
+ ├── notebooks/ # 01_eda → 09_evaluation
131
+ ├── api/ # FastAPI backend + Gradio
132
+ │ ├── main.py
133
+ │ ├── schemas.py
134
+ │ ├── model_registry.py
135
+ │ ├── gradio_app.py
136
+ │ └── routers/
137
+ ├── frontend/ # Vite + React + Three.js
138
+ │ └── src/components/ # Dashboard, 3D viz, Predict, etc.
139
+ ├── docs/ # Documentation
140
+ ├── artifacts/ # Generated: models, figures, scalers
141
+ ├── Dockerfile
142
+ ├── requirements.txt
143
+ └── README.md
144
+ ```
145
+
146
+ ---
147
+
148
+ ## Dataset
149
+
150
+ **NASA Prognostics Center of Excellence (PCoE) Battery Dataset**
151
+
152
+ - 30 Li-ion 18650 cells (B0005–B0056, after cleaning)
153
+ - 2 678 discharge cycles extracted
154
+ - Nominal capacity: 2.0 Ah
155
+ - End-of-Life threshold: 1.4 Ah (30% fade)
156
+ - Five temperature groups: 4°C, 22°C, 24°C, 43°C, 44°C
157
+ - Cycle types: charge, discharge, impedance
158
+ - 12 engineered features per cycle (voltage, current, temperature, impedance, duration)
159
+
160
+ **Reference:** B. Saha and K. Goebel (2007). *Battery Data Set*, NASA Prognostics Data Repository.
161
+
162
+ ---
163
+
164
+ ## Models
165
+
166
+ ### Classical ML
167
+ - **Linear:** Ridge, Lasso, ElasticNet
168
+ - **Instance-based:** KNN (3 configs)
169
+ - **Kernel:** SVR (RBF)
170
+ - **Tree ensemble:** Random Forest, **ExtraTrees** *(v2)*, **GradientBoosting** *(v2)*, XGBoost (Optuna HPO), LightGBM (Optuna HPO)
171
+
172
+ ### Deep Learning
173
+ - **LSTM family:** Vanilla, Bidirectional, GRU, Attention LSTM (MC Dropout uncertainty)
174
+ - **Transformer:** BatteryGPT (nano decoder-only), TFT (Temporal Fusion)
175
+ - **iTransformer:** Vanilla, Physics-Informed (dual-head), Dynamic-Graph
176
+
177
+ ### Generative
178
+ - **VAE-LSTM:** Variational autoencoder with LSTM encoder/decoder, health head, anomaly detection
179
+
180
+ ### Ensemble
181
+ - **Stacking:** Out-of-fold + Ridge meta-learner
182
+ - **Weighted Average:** L-BFGS-B optimized weights
183
+
184
+ ---
185
+
186
+ ## API Endpoints
187
+
188
+ | Method | Path | Description |
189
+ |--------|------|-------------|
190
+ | POST | `/api/predict` | Single-cycle SOH prediction (default: v2 models) |
191
+ | POST | `/api/v1/predict` | Predict using v1 models (cross-battery split) |
192
+ | POST | `/api/v2/predict` | Predict using v2 models (chrono split, bug-fixed) |
193
+ | POST | `/api/predict/ensemble` | Always uses BestEnsemble |
194
+ | POST | `/api/predict/batch` | Multi-cycle batch prediction |
195
+ | POST | `/api/recommend` | Operational recommendations |
196
+ | POST | `/api/v2/recommend` | v2 recommendations (fixed baseline) |
197
+ | GET | `/api/models` | List all models with version / R² metadata |
198
+ | GET | `/api/v1/models` | List v1 models |
199
+ | GET | `/api/v2/models` | List v2 models |
200
+ | GET | `/api/models/versions` | Group models by generation (v1 / v2) |
201
+ | GET | `/api/dashboard` | Full dashboard data |
202
+ | GET | `/api/batteries` | List all batteries |
203
+ | GET | `/api/battery/{id}/capacity` | Per-battery capacity curve |
204
+ | GET | `/api/figures` | List saved figures (PNG only) |
205
+ | GET | `/api/figures/{name}` | Serve a figure |
206
+ | GET | `/health` | Liveness probe |
207
+
208
+ All endpoints are documented interactively at **`/docs`** (Swagger UI) and **`/redoc`**.
209
+
210
+ ---
211
+
212
+ ## License
213
+
214
+ This project is for academic and research purposes.
215
+ Dataset: NASA PCoE public domain.
STRUCTURE.md ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project Structure (v2.0)
2
+
3
+ ## Root Level Organization
4
+
5
+ ```
6
+ aiBatteryLifecycle/
7
+ ├── 📂 api/ FastAPI backend with model registry
8
+ ├── 📂 artifacts/ Versioned model artifacts & results
9
+ │ ├── v1/ Legacy models (cross-battery train/test)
10
+ │ └── v2/ Production models (intra-battery split) ✓ ACTIVE
11
+ │ ├── models/ Trained models: {classical, deep, ensemble}
12
+ │ ├── scalers/ Feature scalers (StandardScaler for linear models)
13
+ │ ├── results/ CSV results, feature matrices, metrics
14
+ │ ├── figures/ Visualizations: PNG charts, HTML reports
15
+ │ ├── logs/ Training/inference logs
16
+ │ └── features/ Feature engineering artifacts
17
+ ├── 📂 cleaned_dataset/ Raw battery test data
18
+ │ ├── data/ CSV files per battery (00001.csv - 00137.csv)
19
+ │ ├── extra_infos/ Supplementary metadata
20
+ │ └── metadata.csv Battery inventory
21
+ ├── 📂 docs/ Documentation markdown files
22
+ ├── 📂 frontend/ React SPA (TypeScript, Vite)
23
+ ├── 📂 notebooks/ Jupyter analysis & training (01-09)
24
+ ├── 📂 reference/ External papers & reference notebooks
25
+ ├── 📂 scripts/ Organized utility scripts
26
+ │ ├── data/ Data processing (write_nb03_v2, patch_dl_notebooks_v2)
27
+ │ ├── models/ Model training (retrain_classical)
28
+ │ ├── __init__.py Package marker
29
+ │ └── README (in data/models/)
30
+ ├── 📂 src/ Core Python library
31
+ │ ├── data/ Data loading & preprocessing
32
+ │ ├── evaluation/ Metrics & validation
33
+ │ ├── models/ Model architectures
34
+ │ ├── utils/ Config, logging, helpers
35
+ │ └── __init__.py
36
+ ├── 📂 tests/ ✓ NEW: Test & validation scripts
37
+ │ ├── test_v2_models.py Comprehensive v2 validation
38
+ │ ├── test_predictions.py Quick endpoint test
39
+ │ ├── __init__.py
40
+ │ └── README.md
41
+ ├── 📄 CHANGELOG.md Version history & updates
42
+ ├── 📄 VERSION.md ✓ NEW: Versioning & versioning guide
43
+ ├── 📄 README.md Project overview
44
+ ├── 📄 requirements.txt Python dependencies
45
+ ├── 📄 package.json Node.js dependencies (frontend)
46
+ ├── 📄 Dockerfile Docker configuration
47
+ ├── 📄 docker-compose.yml Multi-container orchestration
48
+ ├── 📄 tsconfig.json TypeScript config (frontend)
49
+ └── 📄 vite.config.ts Vite bundler config (frontend)
50
+ ```
51
+
52
+ ## Key Changes in V2 Reorganization
53
+
54
+ ### ✓ Completed
55
+ 1. **Versioned Artifacts**
56
+ - Moved `artifacts/models/` → `artifacts/v2/models/`
57
+ - Moved `artifacts/scalers/` → `artifacts/v2/scalers/`
58
+ - Moved `artifacts/figures/` → `artifacts/v2/figures/`
59
+ - All result CSVs → `artifacts/v2/results/`
60
+ - Clean `artifacts/` root (only v1 and v2 subdirs)
61
+
62
+ 2. **Organized Scripts**
63
+ - Created `scripts/data/` for data processing utilities
64
+ - Created `scripts/models/` for model training scripts
65
+ - All scripts now using `get_version_paths('v2')`
66
+ - Path: `scripts/retrain_classical.py` → `scripts/models/retrain_classical.py`
67
+
68
+ 3. **Centralized Tests**
69
+ - Created `tests/` folder at project root
70
+ - Moved `test_v2_models.py` → `tests/`
71
+ - Moved `test_predictions.py` → `tests/`
72
+ - Added `tests/README.md` with usage guide
73
+ - All tests now using `artifacts/v2/` paths
74
+
75
+ 4. **Updated Imports & Paths**
76
+ - `test_v2_models.py`: Uses `v2['results']` for data loading
77
+ - `retrain_classical.py`: Uses `get_version_paths('v2')` for artifact saving
78
+ - API: Already defaults to `registry_v2`
79
+ - Notebooks NB03-09: Already use `get_version_paths()`
80
+
81
+ ### Code Changes Summary
82
+
83
+ | File | Change | Result |
84
+ |------|--------|--------|
85
+ | `tests/test_v2_models.py` | Updated artifact paths to use v2 | Output → `artifacts/v2/{results,figures}` |
86
+ | `scripts/models/retrain_classical.py` | Uses `get_version_paths('v2')` | Models saved to `artifacts/v2/models/classical/` |
87
+ | `api/model_registry.py` | Already has versioning support | No changes needed |
88
+ | `src/utils/config.py` | Already supports versioning | No changes needed |
89
+ | Notebooks NB03-09 | Already use `get_version_paths()` | No changes needed |
90
+
91
+ ## Running Tests After Reorganization
92
+
93
+ ```bash
94
+ # From project root
95
+ python tests/test_v2_models.py # Full v2 validation
96
+ python tests/test_predictions.py # Quick endpoint test
97
+ python scripts/models/retrain_classical.py # Retrain models
98
+ ```
99
+
100
+ ## Artifact Access in Code
101
+
102
+ ### Before (V1 - hardcoded paths)
103
+ ```python
104
+ model_path = "artifacts/models/classical/rf.joblib"
105
+ results_csv = "artifacts/results.csv"
106
+ ```
107
+
108
+ ### After (V2 - versioned paths via config)
109
+ ```python
110
+ from src.utils.config import get_version_paths
111
+ v2 = get_version_paths('v2')
112
+
113
+ model_path = v2['models_classical'] / 'rf.joblib'
114
+ results_csv = v2['results'] / 'results.csv'
115
+ ```
116
+
117
+ ## Production Readiness
118
+
119
+ | Aspect | Status | Notes |
120
+ |--------|--------|-------|
121
+ | Versioning | ✓ Complete | All artifacts under `v2/` |
122
+ | Structure | ✓ Organized | Scripts, tests, notebooks organized |
123
+ | Configuration | ✓ Active | `ACTIVE_VERSION = 'v2'` in config |
124
+ | API | ✓ Ready | Defaults to `registry_v2` |
125
+ | Tests | ✓ Available | `tests/test_v2_models.py` for validation |
126
+ | Documentation | ✓ Added | VERSION.md and README files created |
127
+
128
+ ## Forward Compatibility
129
+
130
+ ### For Future Versions (v3, v4, etc.)
131
+
132
+ Simply copy the v2 folder structure and update:
133
+ ```python
134
+ # In src/utils/config.py
135
+ ACTIVE_VERSION: str = "v3"
136
+
137
+ # In scripts
138
+ v3 = get_version_paths('v3')
139
+ ensure_version_dirs('v3')
140
+ ```
141
+
142
+ The system will automatically create versioned paths and maintain backward compatibility.
143
+
VERSION.md ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project Versioning & Structure
2
+
3
+ ## Current Active Version: v2.0
4
+
5
+ All active models, artifacts, and features use the **v2.0** versioning scheme.
6
+
7
+ ### Directory Structure
8
+
9
+ ```
10
+ artifacts/
11
+ ├── v1/ ← Legacy models (cross-battery split)
12
+ │ ├── models/
13
+ │ │ ├── classical/
14
+ │ │ ├── deep/
15
+ │ │ └── ensemble/
16
+ │ ├── scalers/
17
+ │ ├── figures/
18
+ │ ├── results/
19
+ │ ├── logs/
20
+ │ └── features/
21
+
22
+ ├── v2/ ← Current production (intra-battery split) ✓
23
+ │ ├── models/
24
+ │ │ ├── classical/ ← 14 classical ML models
25
+ │ │ ├── deep/ ← 8 deep learning models
26
+ │ │ └── ensemble/ ← Weighted ensemble
27
+ │ ├── scalers/ ← Feature scalers for linear models
28
+ │ ├── figures/ ← All validation visualizations (PNG, HTML)
29
+ │ ├── results/ ← CSV/JSON results and feature matrices
30
+ │ ├── logs/ ← Training logs
31
+ │ └── features/ ← Feature engineering artifacts
32
+ ```
33
+
34
+ ### V2 Key Changes from V1
35
+
36
+ | Aspect | V1 | V2 |
37
+ |--------|----|----|
38
+ | **Data Split** | Cross-battery (groups of batteries) | Intra-battery chronological (first 80% cycles per battery) |
39
+ | **Train/Test Contamination** | ⚠️ YES (same batteries in both) | ✓ NO (different time periods per battery) |
40
+ | **Generalization** | Poor (batteries see same time periods) | Better (true temporal split) |
41
+ | **Test Realism** | Interpolation (within-cycle prediction) | Extrapolation (future cycles) |
42
+ | **Classical Models** | 6 standard models | 14 models (added ExtraTrees, GradientBoosting, KNN ×3) |
43
+ | **Deep Models** | 8 models | Retraining in progress |
44
+ | **Ensemble** | RF + XGB + LGB (v1 trained) | RF + XGB + LGB (v2 trained when available) |
45
+
46
+ ### Model Statistics
47
+
48
+ #### Classical Models (V2)
49
+ - **Total:** 14 models
50
+ - **Target Metric:** Within-±5% SOH accuracy ≥ 95%
51
+ - **Current Pass Rate:** See `artifacts/v2/results/v2_validation_report.html`
52
+
53
+ #### Configuration
54
+
55
+ **Active version is set in** `src/utils/config.py`:
56
+ ```python
57
+ ACTIVE_VERSION: str = "v2"
58
+ ```
59
+
60
+ **API defaults to v2:**
61
+ ```python
62
+ registry = registry_v2 # Default registry (v2.0.0 models)
63
+ ```
64
+
65
+ ### Migration Checklist ✓
66
+
67
+ - ✓ Created versioned artifact directories under `artifacts/v2/`
68
+ - ✓ Moved all v2 models to `artifacts/v2/models/classical/` etc.
69
+ - ✓ Moved all results to `artifacts/v2/results/`
70
+ - ✓ Moved all figures to `artifacts/v2/figures/`
71
+ - ✓ Moved all scalers to `artifacts/v2/scalers/`
72
+ - ✓ Updated notebooks (NB03-09) to use `get_version_paths('v2')`
73
+ - ✓ Updated API to default to v2 registry
74
+ - ✓ Organized scripts into `scripts/data/`, `scripts/models/`
75
+ - ✓ Moved tests to `tests/` folder
76
+ - ✓ Cleaned up legacy artifact directories
77
+
78
+ ### File Locations
79
+
80
+ | Content | Path |
81
+ |---------|------|
82
+ | Models (classical) | `artifacts/v2/models/classical/*.joblib` |
83
+ | Models (deep) | `artifacts/v2/models/deep/*.pth` |
84
+ | Models (ensemble) | `artifacts/v2/models/ensemble/*.joblib` |
85
+ | Scalers | `artifacts/v2/scalers/*.joblib` |
86
+ | Results CSV | `artifacts/v2/results/*.csv` |
87
+ | Feature matrix | `artifacts/v2/results/battery_features.csv` |
88
+ | Visualizations | `artifacts/v2/figures/*.{png,html}` |
89
+ | Logs | `artifacts/v2/logs/*.log` |
90
+
91
+ ### Running Scripts
92
+
93
+ ```bash
94
+ # Run v2 model validation test
95
+ python tests/test_v2_models.py
96
+
97
+ # Run quick prediction test
98
+ python tests/test_predictions.py
99
+
100
+ # Retrain classical models (WARNING: takes ~30 min)
101
+ python scripts/models/retrain_classical.py
102
+
103
+ # Generate/patch notebooks (one-time utilities)
104
+ python scripts/data/write_nb03_v2.py
105
+ python scripts/data/patch_dl_notebooks_v2.py
106
+ ```
107
+
108
+ ### Next Steps
109
+
110
+ 1. ✓ Verify v2 model accuracy meets thresholds
111
+ 2. ✓ Update research paper with v2 results
112
+ 3. ✓ Complete research notes for all notebooks
113
+ 4. ✓ Test cycle recommendation engine
114
+ 5. Deploy v2 to production
115
+
116
+ ### Version History
117
+
118
+ | Version | Date | Status | Notes |
119
+ |---------|------|--------|-------|
120
+ | v1.0 | 2025-Q1 | ✓ Complete | Classical + Deep models, cross-battery split |
121
+ | v2.0 | 2026-02-25 | ✓ Active | Intra-battery split, improved generalization |
122
+ | v3.0 | TBD | -- | Physics-informed models, uncertainty quantification |
123
+
api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # api — FastAPI backend + Gradio interface
api/gradio_app.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ api.gradio_app
3
+ ==============
4
+ Gradio interface for interactive battery SOH / RUL prediction.
5
+ Mounted at /gradio inside the FastAPI application.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import gradio as gr
11
+ import numpy as np
12
+ import pandas as pd
13
+ import plotly.graph_objects as go
14
+
15
+ from api.model_registry import registry, classify_degradation, soh_to_color
16
+
17
+ # ── Prediction function ──────────────────────────────────────────────────────
18
+ def predict_soh(
19
+ cycle_number: int,
20
+ ambient_temperature: float,
21
+ peak_voltage: float,
22
+ min_voltage: float,
23
+ avg_current: float,
24
+ avg_temp: float,
25
+ temp_rise: float,
26
+ cycle_duration: float,
27
+ Re: float,
28
+ Rct: float,
29
+ delta_capacity: float,
30
+ model_name: str,
31
+ ):
32
+ features = {
33
+ "cycle_number": cycle_number,
34
+ "ambient_temperature": ambient_temperature,
35
+ "peak_voltage": peak_voltage,
36
+ "min_voltage": min_voltage,
37
+ "voltage_range": peak_voltage - min_voltage,
38
+ "avg_current": avg_current,
39
+ "avg_temp": avg_temp,
40
+ "temp_rise": temp_rise,
41
+ "cycle_duration": cycle_duration,
42
+ "Re": Re,
43
+ "Rct": Rct,
44
+ "delta_capacity": delta_capacity,
45
+ }
46
+
47
+ name = model_name if model_name != "auto" else None
48
+ result = registry.predict(features, model_name=name)
49
+
50
+ soh = result["soh_pct"]
51
+ rul = result["rul_cycles"]
52
+ state = result["degradation_state"]
53
+ model_used = result["model_used"]
54
+ ci_lo = result.get("confidence_lower", soh - 2)
55
+ ci_hi = result.get("confidence_upper", soh + 2)
56
+
57
+ # Summary text
58
+ summary = (
59
+ f"## Prediction Result\n\n"
60
+ f"- **SOH:** {soh:.1f}%\n"
61
+ f"- **RUL:** {rul:.0f} cycles\n"
62
+ f"- **State:** {state}\n"
63
+ f"- **95% CI:** [{ci_lo:.1f}%, {ci_hi:.1f}%]\n"
64
+ f"- **Model:** {model_used}\n"
65
+ )
66
+
67
+ # SOH gauge figure
68
+ fig = go.Figure(go.Indicator(
69
+ mode="gauge+number+delta",
70
+ value=soh,
71
+ title={"text": "State of Health (%)"},
72
+ delta={"reference": 100, "decreasing": {"color": "red"}},
73
+ gauge={
74
+ "axis": {"range": [0, 100]},
75
+ "bar": {"color": soh_to_color(soh)},
76
+ "steps": [
77
+ {"range": [0, 70], "color": "#fee2e2"},
78
+ {"range": [70, 80], "color": "#fef3c7"},
79
+ {"range": [80, 90], "color": "#fef9c3"},
80
+ {"range": [90, 100], "color": "#dcfce7"},
81
+ ],
82
+ "threshold": {
83
+ "line": {"color": "red", "width": 3},
84
+ "thickness": 0.75,
85
+ "value": 70,
86
+ },
87
+ },
88
+ ))
89
+ fig.update_layout(height=350)
90
+
91
+ return summary, fig
92
+
93
+
94
+ # ── Capacity trajectory ──────────────────────────────────────────────────────
95
+ def plot_capacity_trajectory(battery_id: str):
96
+ from pathlib import Path
97
+ meta_path = Path(__file__).resolve().parents[1] / "cleaned_dataset" / "metadata.csv"
98
+ if not meta_path.exists():
99
+ return None
100
+ meta = pd.read_csv(meta_path)
101
+ sub = meta[meta["battery_id"] == battery_id].sort_values("start_time")
102
+ if sub.empty:
103
+ return None
104
+
105
+ caps = sub["Capacity"].dropna().values
106
+ cycles = np.arange(1, len(caps) + 1)
107
+ soh = (caps / 2.0) * 100
108
+
109
+ fig = go.Figure()
110
+ fig.add_trace(go.Scatter(
111
+ x=cycles, y=soh, mode="lines+markers",
112
+ marker=dict(size=3), line=dict(width=2),
113
+ name=battery_id,
114
+ ))
115
+ fig.add_hline(y=70, line_dash="dash", line_color="red",
116
+ annotation_text="EOL (70%)")
117
+ fig.update_layout(
118
+ title=f"SOH Trajectory — {battery_id}",
119
+ xaxis_title="Cycle", yaxis_title="SOH (%)",
120
+ template="plotly_white", height=400,
121
+ )
122
+ return fig
123
+
124
+
125
+ # ── Build Gradio app ─────────────────────────────────────────────────────────
126
+ def create_gradio_app() -> gr.Blocks:
127
+ model_choices = ["auto"] + [m["name"] for m in registry.list_models()]
128
+
129
+ with gr.Blocks(
130
+ title="Battery Lifecycle Predictor",
131
+ ) as demo:
132
+ gr.Markdown(
133
+ "# AI Battery Lifecycle Predictor\n"
134
+ "Predict **State of Health (SOH)** and **Remaining Useful Life (RUL)** "
135
+ "using machine-learning models trained on the NASA PCoE Li-ion Battery Dataset."
136
+ )
137
+
138
+ with gr.Tab("Predict"):
139
+ with gr.Row():
140
+ with gr.Column(scale=1):
141
+ cycle_number = gr.Number(label="Cycle Number", value=100, precision=0)
142
+ ambient_temp = gr.Slider(0, 60, value=24, label="Ambient Temperature (°C)")
143
+ peak_v = gr.Number(label="Peak Voltage (V)", value=4.2)
144
+ min_v = gr.Number(label="Min Voltage (V)", value=2.7)
145
+ avg_curr = gr.Number(label="Avg Discharge Current (A)", value=2.0)
146
+ avg_t = gr.Number(label="Avg Cell Temp (°C)", value=25.0)
147
+ temp_rise = gr.Number(label="Temp Rise (°C)", value=3.0)
148
+ duration = gr.Number(label="Cycle Duration (s)", value=3600)
149
+ re = gr.Number(label="Re (Ω)", value=0.04)
150
+ rct = gr.Number(label="Rct (Ω)", value=0.02)
151
+ delta_cap = gr.Number(label="ΔCapacity (Ah)", value=-0.005)
152
+ model_dd = gr.Dropdown(choices=model_choices, value="auto", label="Model")
153
+ btn = gr.Button("Predict", variant="primary")
154
+
155
+ with gr.Column(scale=1):
156
+ result_md = gr.Markdown()
157
+ gauge = gr.Plot(label="SOH Gauge")
158
+
159
+ btn.click(
160
+ fn=predict_soh,
161
+ inputs=[cycle_number, ambient_temp, peak_v, min_v, avg_curr,
162
+ avg_t, temp_rise, duration, re, rct, delta_cap, model_dd],
163
+ outputs=[result_md, gauge],
164
+ )
165
+
166
+ with gr.Tab("Battery Explorer"):
167
+ bid_input = gr.Textbox(label="Battery ID", value="B0005", placeholder="e.g., B0005")
168
+ explore_btn = gr.Button("Load Trajectory")
169
+ cap_plot = gr.Plot(label="Capacity Trajectory")
170
+ explore_btn.click(fn=plot_capacity_trajectory, inputs=[bid_input], outputs=[cap_plot])
171
+
172
+ with gr.Tab("About"):
173
+ gr.Markdown(
174
+ "## About\n\n"
175
+ "This application predicts Li-ion battery degradation using models trained on the "
176
+ "**NASA Prognostics Center of Excellence (PCoE)** Battery Dataset.\n\n"
177
+ "### Models\n"
178
+ "- Classical ML: Ridge, Lasso, ElasticNet, KNN, SVR, Random Forest, XGBoost, LightGBM\n"
179
+ "- Deep Learning: LSTM (4 variants), BatteryGPT, TFT, iTransformer (3 variants), VAE-LSTM\n"
180
+ "- Ensemble: Stacking, Weighted Average\n\n"
181
+ "### Dataset\n"
182
+ "- 36 Li-ion 18650 cells (nominal 2.0Ah)\n"
183
+ "- Charge/discharge/impedance cycles at three temperature regimes\n"
184
+ "- End-of-Life: 30% capacity fade (1.4Ah)\n\n"
185
+ "### Reference\n"
186
+ "B. Saha and K. Goebel (2007). *Battery Data Set*, NASA Prognostics Data Repository."
187
+ )
188
+
189
+ return demo
api/main.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ api.main
3
+ ========
4
+ FastAPI application entry-point for the AI Battery Lifecycle Predictor.
5
+
6
+ Architecture
7
+ ------------
8
+ - **v1 (Classical)** : Ridge, Lasso, ElasticNet, KNN ×3, SVR,
9
+ Random Forest, XGBoost, LightGBM
10
+ - **v2 (Deep)** : Vanilla LSTM, BiLSTM, GRU, Attention LSTM,
11
+ BatteryGPT, TFT, iTransformer ×3, VAE-LSTM
12
+ - **v2.6 (Ensemble)** : BestEnsemble — weighted average of RF + XGB + LGB
13
+ (weights proportional to R²)
14
+
15
+ Mounted routes
16
+ --------------
17
+ - ``/api/*`` REST endpoints (predict, batch, recommend, models, visualize)
18
+ - ``/gradio`` Gradio interactive demo (optional, requires *gradio* package)
19
+ - ``/`` React SPA (served from ``frontend/dist/``)
20
+
21
+ Key endpoints
22
+ -------------
23
+ - ``POST /api/predict`` — single-cycle SOH + RUL prediction
24
+ - ``POST /api/predict/ensemble`` — always uses BestEnsemble (v2.6)
25
+ - ``POST /api/predict/batch`` — batch prediction from JSON array
26
+ - ``GET /api/models`` — list all models with version / R² metadata
27
+ - ``GET /api/models/versions`` — group models by generation (v1/v2)
28
+ - ``GET /health`` — liveness probe
29
+
30
+ Run locally
31
+ -----------
32
+ ::
33
+
34
+ uvicorn api.main:app --host 0.0.0.0 --port 7860 --reload
35
+
36
+ Docker
37
+ ------
38
+ ::
39
+
40
+ docker compose up --build
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ from contextlib import asynccontextmanager
46
+ from pathlib import Path
47
+
48
+ from fastapi import FastAPI
49
+ from fastapi.middleware.cors import CORSMiddleware
50
+ from fastapi.staticfiles import StaticFiles
51
+ from fastapi.responses import FileResponse
52
+
53
+ from api.model_registry import registry, registry_v1, registry_v2
54
+ from api.schemas import HealthResponse
55
+ from src.utils.logger import get_logger
56
+
57
+ log = get_logger(__name__)
58
+
59
+ __version__ = "2.0.0"
60
+
61
+ # ── Static frontend path ────────────────────────────────────────────────────
62
+ _HERE = Path(__file__).resolve().parent
63
+ _FRONTEND_DIST = _HERE.parent / "frontend" / "dist"
64
+
65
+
66
+ # ── Lifespan ─────────────────────────────────────────────────────────────────
67
+ @asynccontextmanager
68
+ async def lifespan(app: FastAPI):
69
+ """Load models on startup, clean up on shutdown."""
70
+ log.info("Loading model registries …")
71
+ registry_v1.load_all()
72
+ log.info("v1 registry ready — %d models loaded", registry_v1.model_count)
73
+ registry_v2.load_all()
74
+ log.info("v2 registry ready — %d models loaded", registry_v2.model_count)
75
+ yield
76
+ log.info("Shutting down battery-lifecycle API")
77
+
78
+
79
+ # ── App ──────────────────────────────────────────────────────────────────────
80
+ app = FastAPI(
81
+ title="AI Battery Lifecycle Predictor",
82
+ description=(
83
+ "Predict SOH, RUL, and degradation state of Li-ion batteries "
84
+ "using models trained on the NASA PCoE dataset."
85
+ ),
86
+ version=__version__,
87
+ lifespan=lifespan,
88
+ docs_url="/docs",
89
+ redoc_url="/redoc",
90
+ )
91
+
92
+ # CORS
93
+ app.add_middleware(
94
+ CORSMiddleware,
95
+ allow_origins=["*"],
96
+ allow_credentials=True,
97
+ allow_methods=["*"],
98
+ allow_headers=["*"],
99
+ )
100
+
101
+
102
+ # ── Health check ─────────────────────────────────────────────────────────────
103
+ @app.get("/health", response_model=HealthResponse, tags=["meta"])
104
+ async def health():
105
+ return HealthResponse(
106
+ status="ok",
107
+ version=__version__,
108
+ models_loaded=registry_v1.model_count + registry_v2.model_count,
109
+ device=registry.device,
110
+ )
111
+
112
+
113
+ # ── Include routers ──────────────────────────────────────────────────────────
114
+ from api.routers.predict import router as predict_router, v1_router
115
+ from api.routers.predict_v2 import router as predict_v2_router
116
+ from api.routers.visualize import router as viz_router
117
+ from api.routers.simulate import router as simulate_router
118
+
119
+ app.include_router(predict_router) # /api/* (default, uses v2 registry)
120
+ app.include_router(v1_router) # /api/v1/* (legacy v1 models)
121
+ app.include_router(predict_v2_router) # /api/v2/* (v2 models, bug-fixed)
122
+ app.include_router(simulate_router) # /api/v2/simulate (ML-driven simulation)
123
+ app.include_router(viz_router)
124
+
125
+
126
+ # ── Mount Gradio ─────────────────────────────────────────────────────────────
127
+ try:
128
+ import gradio as gr
129
+ from api.gradio_app import create_gradio_app
130
+
131
+ gradio_app = create_gradio_app()
132
+ app = gr.mount_gradio_app(app, gradio_app, path="/gradio")
133
+ log.info("Gradio UI mounted at /gradio")
134
+ except ImportError:
135
+ log.warning("Gradio not installed — /gradio endpoint unavailable")
136
+
137
+
138
+ # ── Serve React SPA ──────────────────────────────────────────────────────────
139
+ if _FRONTEND_DIST.exists() and (_FRONTEND_DIST / "index.html").exists():
140
+ app.mount("/assets", StaticFiles(directory=str(_FRONTEND_DIST / "assets")), name="static-assets")
141
+
142
+ @app.get("/{full_path:path}", include_in_schema=False)
143
+ async def spa_catch_all(full_path: str):
144
+ """Serve React SPA for any path not matched by API routes."""
145
+ file_path = _FRONTEND_DIST / full_path
146
+ if file_path.is_file():
147
+ return FileResponse(file_path)
148
+ return FileResponse(_FRONTEND_DIST / "index.html")
149
+
150
+ log.info("React SPA served from %s", _FRONTEND_DIST)
151
+ else:
152
+ @app.get("/", include_in_schema=False)
153
+ async def root():
154
+ return {
155
+ "message": "AI Battery Lifecycle Predictor API",
156
+ "docs": "/docs",
157
+ "gradio": "/gradio",
158
+ "health": "/health",
159
+ }
api/model_registry.py ADDED
@@ -0,0 +1,794 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ api.model_registry
3
+ ==================
4
+ Singleton model registry providing unified loading, versioning, and inference
5
+ for all trained battery lifecycle models.
6
+
7
+ Model versioning
8
+ ----------------
9
+ * v1.x — Classical (tree-based / linear) models trained in NB03.
10
+ * v2.x — Deep sequence models trained in NB04 – NB07.
11
+ * v3.x — Ensemble / meta-models trained in NB08.
12
+
13
+ Usage
14
+ -----
15
+ from api.model_registry import registry
16
+ registry.load_all() # FastAPI lifespan startup
17
+ result = registry.predict(
18
+ features={"cycle_number": 150, ...},
19
+ model_name="best_ensemble",
20
+ )
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import joblib
30
+ import numpy as np
31
+ import pandas as pd
32
+
33
+ from src.utils.logger import get_logger
34
+
35
+ log = get_logger(__name__)
36
+
37
+
38
+ # ── Architecture constants (must match NB04 – NB07 training) ─────────────────
39
+ _N_FEAT: int = 12 # len(FEATURE_COLS_SCALAR)
40
+ _SEQ_LEN: int = 32 # WINDOW_SIZE
41
+ _HIDDEN: int = 128 # LSTM_HIDDEN
42
+ _LSTM_LAYERS: int = 2 # LSTM_LAYERS
43
+ _ATTN_LAYERS: int = 3 # AttentionLSTM trained with n_layers=3
44
+ _D_MODEL: int = 64 # TRANSFORMER_D_MODEL
45
+ _N_HEADS: int = 4 # TRANSFORMER_NHEAD
46
+ _TF_LAYERS: int = 2 # TRANSFORMER_NLAYERS
47
+ _DROPOUT: float = 0.2 # DROPOUT
48
+
49
+ # ── Paths ─────────────────────────────────────────────────────────────────────
50
+ _HERE = Path(__file__).resolve().parent
51
+ _PROJECT = _HERE.parent
52
+ _MODELS_DIR = _PROJECT / "artifacts" / "models"
53
+ _ARTIFACTS = _PROJECT / "artifacts"
54
+
55
+
56
+ def _versioned_paths(version: str = "v1") -> dict[str, Path]:
57
+ """Return artifact paths for a specific model version (v1 or v2)."""
58
+ root = _PROJECT / "artifacts" / version
59
+ return {
60
+ "models_dir": root / "models",
61
+ "artifacts": root,
62
+ "scalers": root / "scalers",
63
+ "results": root / "results",
64
+ }
65
+
66
+ FEATURE_COLS_SCALAR: list[str] = [
67
+ "cycle_number", "ambient_temperature",
68
+ "peak_voltage", "min_voltage", "voltage_range",
69
+ "avg_current", "avg_temp", "temp_rise",
70
+ "cycle_duration", "Re", "Rct", "delta_capacity",
71
+ ]
72
+
73
+ # ── Model catalog (single source of truth for versions & metadata) ────────────
74
+ MODEL_CATALOG: dict[str, dict[str, Any]] = {
75
+ "random_forest": {"version": "1.0.0", "display_name": "Random Forest", "family": "classical", "algorithm": "RandomForestRegressor", "target": "soh", "r2": 0.9567},
76
+ "xgboost": {"version": "1.0.0", "display_name": "XGBoost", "family": "classical", "algorithm": "XGBRegressor", "target": "soh", "r2": 0.928},
77
+ "lightgbm": {"version": "1.0.0", "display_name": "LightGBM", "family": "classical", "algorithm": "LGBMRegressor", "target": "soh", "r2": 0.928},
78
+ "ridge": {"version": "1.0.0", "display_name": "Ridge Regression", "family": "classical", "algorithm": "Ridge", "target": "soh", "r2": 0.72},
79
+ "svr": {"version": "1.0.0", "display_name": "SVR (RBF)", "family": "classical", "algorithm": "SVR", "target": "soh", "r2": 0.805},
80
+ "lasso": {"version": "1.0.0", "display_name": "Lasso", "family": "classical", "algorithm": "Lasso", "target": "soh", "r2": 0.52},
81
+ "elasticnet": {"version": "1.0.0", "display_name": "ElasticNet", "family": "classical", "algorithm": "ElasticNet", "target": "soh", "r2": 0.52},
82
+ "knn_k5": {"version": "1.0.0", "display_name": "KNN (k=5)", "family": "classical", "algorithm": "KNeighborsRegressor", "target": "soh", "r2": 0.72},
83
+ "knn_k10": {"version": "1.0.0", "display_name": "KNN (k=10)", "family": "classical", "algorithm": "KNeighborsRegressor", "target": "soh", "r2": 0.724},
84
+ "knn_k20": {"version": "1.0.0", "display_name": "KNN (k=20)", "family": "classical", "algorithm": "KNeighborsRegressor", "target": "soh", "r2": 0.717},
85
+ "extra_trees": {"version": "2.0.0", "display_name": "ExtraTrees", "family": "classical", "algorithm": "ExtraTreesRegressor", "target": "soh", "r2": 0.967},
86
+ "gradient_boosting": {"version": "2.0.0", "display_name": "GradientBoosting", "family": "classical", "algorithm": "GradientBoostingRegressor", "target": "soh", "r2": 0.934},
87
+ "vanilla_lstm": {"version": "2.0.0", "display_name": "Vanilla LSTM", "family": "deep_pytorch", "algorithm": "VanillaLSTM", "target": "soh", "r2": 0.507},
88
+ "bidirectional_lstm": {"version": "2.0.0", "display_name": "Bidirectional LSTM", "family": "deep_pytorch", "algorithm": "BidirectionalLSTM", "target": "soh", "r2": 0.520},
89
+ "gru": {"version": "2.0.0", "display_name": "GRU", "family": "deep_pytorch", "algorithm": "GRUModel", "target": "soh", "r2": 0.510},
90
+ "attention_lstm": {"version": "2.0.0", "display_name": "Attention LSTM", "family": "deep_pytorch", "algorithm": "AttentionLSTM", "target": "soh", "r2": 0.540},
91
+ "batterygpt": {"version": "2.1.0", "display_name": "BatteryGPT", "family": "deep_pytorch", "algorithm": "BatteryGPT", "target": "soh", "r2": 0.881},
92
+ "tft": {"version": "2.2.0", "display_name": "Temporal Fusion Transformer", "family": "deep_pytorch", "algorithm": "TemporalFusionTransformer", "target": "soh", "r2": 0.881},
93
+ "vae_lstm": {"version": "2.3.0", "display_name": "VAE-LSTM", "family": "deep_pytorch", "algorithm": "VAE_LSTM", "target": "soh", "r2": 0.730},
94
+ "itransformer": {"version": "2.4.0", "display_name": "iTransformer", "family": "deep_keras", "algorithm": "iTransformer", "target": "soh", "r2": 0.595},
95
+ "physics_itransformer": {"version": "2.4.1", "display_name": "Physics iTransformer", "family": "deep_keras", "algorithm": "PhysicsITransformer", "target": "soh", "r2": 0.600},
96
+ "dynamic_graph_itransformer": {"version": "2.5.0", "display_name": "DG-iTransformer", "family": "deep_keras", "algorithm": "DynamicGraphITransformer", "target": "soh", "r2": 0.595},
97
+ "best_ensemble": {"version": "3.0.0", "display_name": "Best Ensemble (RF+XGB+LGB)", "family": "ensemble", "algorithm": "WeightedAverage", "target": "soh", "r2": 0.957},
98
+ }
99
+
100
+ # R²-proportional weights for BestEnsemble
101
+ _ENSEMBLE_WEIGHTS: dict[str, float] = {
102
+ "random_forest": 0.957,
103
+ "xgboost": 0.928,
104
+ "lightgbm": 0.928,
105
+ "extra_trees": 0.967,
106
+ "gradient_boosting": 0.934,
107
+ }
108
+
109
+
110
+ # ── Degradation state ───────────────────────────────────────────────────────
111
+ def classify_degradation(soh: float) -> str:
112
+ if soh >= 90:
113
+ return "Healthy"
114
+ elif soh >= 80:
115
+ return "Moderate"
116
+ elif soh >= 70:
117
+ return "Degraded"
118
+ else:
119
+ return "End-of-Life"
120
+
121
+
122
+ def soh_to_color(soh: float) -> str:
123
+ """Map SOH percentage to a hex colour (green→yellow→red)."""
124
+ if soh >= 90:
125
+ return "#22c55e" # green
126
+ elif soh >= 80:
127
+ return "#eab308" # yellow
128
+ elif soh >= 70:
129
+ return "#f97316" # orange
130
+ else:
131
+ return "#ef4444" # red
132
+
133
+
134
+ # ── Registry ─────────────────────────────────────────────────────────────────
135
+ class ModelRegistry:
136
+ """Thread-safe singleton that owns all model objects and inference logic.
137
+
138
+ Attributes
139
+ ----------
140
+ models:
141
+ Mapping from name to loaded model object (sklearn/XGBoost/LightGBM
142
+ or PyTorch ``nn.Module`` or Keras model).
143
+ default_model:
144
+ Name of the best available model (set by :meth:`_choose_default`).
145
+ device:
146
+ PyTorch device string — ``"cuda"`` when a GPU is available, else ``"cpu"``.
147
+ """
148
+
149
+ # Model families that need the linear StandardScaler at inference
150
+ _LINEAR_FAMILIES = {"ridge", "lasso", "elasticnet", "svr",
151
+ "knn_k5", "knn_k10", "knn_k20"}
152
+ # Tree families that are scale-invariant (no scaler needed)
153
+ _TREE_FAMILIES = {"random_forest", "xgboost", "lightgbm", "best_ensemble",
154
+ "extra_trees", "gradient_boosting"}
155
+
156
+ def __init__(self, version: str = "v1"):
157
+ self.models: dict[str, Any] = {}
158
+ self.model_meta: dict[str, dict] = {}
159
+ self.default_model: str | None = None
160
+ self.scaler = None # kept for backward compat
161
+ self.linear_scaler = None # StandardScaler for Ridge/Lasso/SVR/KNN
162
+ self.sequence_scaler = None # StandardScaler for sequence deep models
163
+ self.device = "cpu"
164
+ self.version = version
165
+ # Set version-aware paths
166
+ vp = _versioned_paths(version)
167
+ self._models_dir = vp["models_dir"]
168
+ self._artifacts = vp["artifacts"]
169
+ self._scalers_dir = vp["scalers"]
170
+
171
+ # ── Loading ──────────────────────────────────────────────────────────
172
+ def load_all(self) -> None:
173
+ """Scan artifacts/models and load all available model artefacts.
174
+
175
+ Safe to call multiple times — subsequent calls are no-ops when the
176
+ registry is already populated.
177
+ """
178
+ if self.models:
179
+ log.debug("Registry already populated — skipping load_all()")
180
+ return
181
+ self._detect_device()
182
+ self._load_scaler()
183
+ self._load_classical()
184
+ self._load_deep_pytorch()
185
+ self._load_deep_keras()
186
+ self._register_ensemble()
187
+ self._choose_default()
188
+ log.info(
189
+ "Registry ready — %d models active, default='%s', device=%s",
190
+ len(self.models), self.default_model, self.device,
191
+ )
192
+
193
+ def _detect_device(self) -> None:
194
+ """Detect PyTorch compute device (CUDA > CPU)."""
195
+ try:
196
+ import torch
197
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
198
+ log.info("PyTorch device: %s", self.device)
199
+ except ImportError:
200
+ log.info("torch not installed — deep PyTorch models unavailable")
201
+
202
+ def _load_classical(self) -> None:
203
+ """Eagerly load all sklearn/XGBoost/LightGBM joblib artefacts."""
204
+ cdir = self._models_dir / "classical"
205
+ if not cdir.exists():
206
+ log.warning("Classical models dir not found: %s", cdir)
207
+ return
208
+ for p in sorted(cdir.glob("*.joblib")):
209
+ name = p.stem
210
+ # Skip non-model dumps (param search results, classifiers)
211
+ if "best_params" in name or "classifier" in name:
212
+ continue
213
+ try:
214
+ self.models[name] = joblib.load(p)
215
+ catalog = MODEL_CATALOG.get(name, {})
216
+ self.model_meta[name] = {
217
+ **catalog,
218
+ "family": "classical",
219
+ "loaded": True,
220
+ "path": str(p),
221
+ }
222
+ log.info("Loaded classical: %-22s v%s", name, catalog.get("version", "?"))
223
+ except Exception as exc:
224
+ log.warning("Failed to load %s: %s", p.name, exc)
225
+
226
+ def _build_pytorch_model(self, name: str) -> Any | None:
227
+ """Instantiate a PyTorch module with the architecture used during training."""
228
+ try:
229
+ if name == "vanilla_lstm":
230
+ from src.models.deep.lstm import VanillaLSTM
231
+ return VanillaLSTM(_N_FEAT, _HIDDEN, _LSTM_LAYERS, _DROPOUT)
232
+ if name == "bidirectional_lstm":
233
+ from src.models.deep.lstm import BidirectionalLSTM
234
+ return BidirectionalLSTM(_N_FEAT, _HIDDEN, _LSTM_LAYERS, _DROPOUT)
235
+ if name == "gru":
236
+ from src.models.deep.lstm import GRUModel
237
+ return GRUModel(_N_FEAT, _HIDDEN, _LSTM_LAYERS, _DROPOUT)
238
+ if name == "attention_lstm":
239
+ from src.models.deep.lstm import AttentionLSTM
240
+ return AttentionLSTM(_N_FEAT, _HIDDEN, _ATTN_LAYERS, _DROPOUT)
241
+ if name == "batterygpt":
242
+ from src.models.deep.transformer import BatteryGPT
243
+ return BatteryGPT(
244
+ input_dim=_N_FEAT, d_model=_D_MODEL, n_heads=_N_HEADS,
245
+ n_layers=_TF_LAYERS, dropout=_DROPOUT, max_len=64,
246
+ )
247
+ if name == "tft":
248
+ from src.models.deep.transformer import TemporalFusionTransformer
249
+ return TemporalFusionTransformer(
250
+ n_features=_N_FEAT, d_model=_D_MODEL, n_heads=_N_HEADS,
251
+ n_layers=_TF_LAYERS, dropout=_DROPOUT,
252
+ )
253
+ if name == "vae_lstm":
254
+ from src.models.deep.vae_lstm import VAE_LSTM
255
+ return VAE_LSTM(
256
+ input_dim=_N_FEAT, seq_len=_SEQ_LEN,
257
+ hidden_dim=_HIDDEN, latent_dim=16,
258
+ n_layers=_LSTM_LAYERS, dropout=_DROPOUT,
259
+ )
260
+ except Exception as exc:
261
+ log.warning("Cannot build PyTorch model '%s': %s", name, exc)
262
+ return None
263
+
264
+ def _load_deep_pytorch(self) -> None:
265
+ """Load PyTorch .pt state-dict files into reconstructed model instances."""
266
+ ddir = self._models_dir / "deep"
267
+ if not ddir.exists():
268
+ return
269
+ try:
270
+ import torch
271
+ except ImportError:
272
+ log.info("torch not installed — skipping deep PyTorch model loading")
273
+ return
274
+ for p in sorted(ddir.glob("*.pt")):
275
+ name = p.stem
276
+ model = self._build_pytorch_model(name)
277
+ if model is None:
278
+ self.model_meta[name] = {
279
+ **MODEL_CATALOG.get(name, {}),
280
+ "family": "deep_pytorch", "loaded": False,
281
+ "path": str(p), "load_error": "architecture unavailable",
282
+ }
283
+ continue
284
+ try:
285
+ state = torch.load(p, map_location=self.device, weights_only=True)
286
+ model.load_state_dict(state)
287
+ model.to(self.device)
288
+ model.eval()
289
+ self.models[name] = model
290
+ catalog = MODEL_CATALOG.get(name, {})
291
+ self.model_meta[name] = {
292
+ **catalog, "family": "deep_pytorch",
293
+ "loaded": True, "path": str(p),
294
+ }
295
+ log.info("Loaded PyTorch: %-22s v%s", name, catalog.get("version", "?"))
296
+ except Exception as exc:
297
+ log.warning("Could not load PyTorch '%s': %s", name, exc)
298
+ self.model_meta[name] = {
299
+ **MODEL_CATALOG.get(name, {}),
300
+ "family": "deep_pytorch", "loaded": False,
301
+ "path": str(p), "load_error": str(exc),
302
+ }
303
+
304
+ def _load_deep_keras(self) -> None:
305
+ """Load TensorFlow/Keras .keras model files."""
306
+ ddir = self._models_dir / "deep"
307
+ if not ddir.exists():
308
+ return
309
+ try:
310
+ import tensorflow as tf
311
+ except ImportError:
312
+ log.info("TensorFlow not installed — skipping Keras model loading")
313
+ return
314
+ # Import the custom Keras classes so they are registered before load
315
+ try:
316
+ from src.models.deep.itransformer import (
317
+ FeatureWiseMHA,
318
+ TokenWiseMHA,
319
+ Conv1DFeedForward,
320
+ DynamicGraphConv,
321
+ PhysicsInformedLoss,
322
+ AbsCumCurrentLayer,
323
+ )
324
+ _custom_objects: dict = {
325
+ "FeatureWiseMHA": FeatureWiseMHA,
326
+ "TokenWiseMHA": TokenWiseMHA,
327
+ "Conv1DFeedForward": Conv1DFeedForward,
328
+ "DynamicGraphConv": DynamicGraphConv,
329
+ "PhysicsInformedLoss": PhysicsInformedLoss,
330
+ "AbsCumCurrentLayer": AbsCumCurrentLayer,
331
+ }
332
+ except Exception as imp_err:
333
+ log.warning("Could not import iTransformer custom classes: %s", imp_err)
334
+ _custom_objects = {}
335
+ for p in sorted(ddir.glob("*.keras")):
336
+ name = p.stem
337
+ try:
338
+ model = tf.keras.models.load_model(str(p), custom_objects=_custom_objects, safe_mode=False)
339
+ self.models[name] = model
340
+ catalog = MODEL_CATALOG.get(name, {})
341
+ self.model_meta[name] = {
342
+ **catalog, "family": "deep_keras",
343
+ "loaded": True, "path": str(p),
344
+ }
345
+ log.info("Loaded Keras: %-22s v%s", name, catalog.get("version", "?"))
346
+ except Exception as exc:
347
+ log.warning("Could not load Keras '%s': %s", name, exc)
348
+ self.model_meta[name] = {
349
+ **MODEL_CATALOG.get(name, {}),
350
+ "family": "deep_keras", "loaded": False,
351
+ "path": str(p), "load_error": str(exc),
352
+ }
353
+
354
+ def _register_ensemble(self) -> None:
355
+ """Register the BestEnsemble virtual model when components are loaded."""
356
+ available = [m for m in _ENSEMBLE_WEIGHTS if m in self.models]
357
+ if not available:
358
+ log.warning("BestEnsemble: no component models loaded")
359
+ return
360
+ self.models["best_ensemble"] = "virtual_ensemble"
361
+ self.model_meta["best_ensemble"] = {
362
+ **MODEL_CATALOG["best_ensemble"],
363
+ "components": available, "loaded": True,
364
+ }
365
+ log.info("BestEnsemble registered — components: %s", ", ".join(available))
366
+
367
+ def _load_scaler(self) -> None:
368
+ # Scaler mapping (from notebooks/03_classical_ml.ipynb):
369
+ # standard_scaler.joblib — StandardScaler fitted on X_train
370
+ # Used for: SVR, Ridge, Lasso, ElasticNet, KNN
371
+ # sequence_scaler.joblib — StandardScaler for deep-model sequences
372
+ # Tree models (RF, ET, GB, XGB, LGB) were fitted on raw numpy X_train
373
+ # → NO scaler applied, passed as-is
374
+ #
375
+ # Both standard_scaler.joblib and linear_scaler.joblib are identical
376
+ # (same mean_ / scale_). Prefer standard_scaler.joblib (canonical name
377
+ # from training notebook), fall back to linear_scaler.joblib.
378
+ scalers_dir = self._scalers_dir
379
+ for fname in ("standard_scaler.joblib", "linear_scaler.joblib"):
380
+ sp = scalers_dir / fname
381
+ if sp.exists():
382
+ try:
383
+ self.linear_scaler = joblib.load(sp)
384
+ log.info("Linear scaler loaded from %s", sp)
385
+ break
386
+ except Exception as exc:
387
+ log.warning("Could not load %s: %s", fname, exc)
388
+ else:
389
+ log.warning("No linear scaler found — Ridge/Lasso/SVR/KNN will use raw features")
390
+
391
+ sp_seq = scalers_dir / "sequence_scaler.joblib"
392
+ if sp_seq.exists():
393
+ try:
394
+ self.sequence_scaler = joblib.load(sp_seq)
395
+ log.info("Sequence scaler loaded from %s", sp_seq)
396
+ except Exception as exc:
397
+ log.warning("Could not load sequence_scaler.joblib: %s", exc)
398
+ else:
399
+ log.warning("sequence_scaler.joblib not found — deep models will use raw features")
400
+
401
+ def _choose_default(self) -> None:
402
+ """Select the highest-quality loaded model as the registry default."""
403
+ priority = [
404
+ "best_ensemble",
405
+ "extra_trees",
406
+ "random_forest",
407
+ "xgboost",
408
+ "lightgbm",
409
+ "gradient_boosting",
410
+ "tft",
411
+ "batterygpt",
412
+ "attention_lstm",
413
+ "ridge",
414
+ ]
415
+ for name in priority:
416
+ if name in self.models:
417
+ self.default_model = name
418
+ log.info("Default model: %s", name)
419
+ return
420
+ if self.models:
421
+ self.default_model = next(iter(self.models))
422
+ log.info("Default model (fallback): %s", self.default_model)
423
+
424
+ # ── Metrics retrieval ────────────────────────────────────────────────
425
+ def get_metrics(self) -> dict[str, dict[str, float]]:
426
+ """Return unified evaluation metrics from results CSV/JSON artefacts.
427
+
428
+ CSV model name headers are normalised to lower-case underscore keys.
429
+ Entries missing from result files fall back to the ``r2`` field in
430
+ :data:`MODEL_CATALOG`.
431
+ """
432
+ _normalise = {
433
+ "RandomForest": "random_forest", "LightGBM": "lightgbm",
434
+ "XGBoost": "xgboost", "SVR": "svr", "Ridge": "ridge",
435
+ "Lasso": "lasso", "ElasticNet": "elasticnet",
436
+ "KNN-5": "knn_k5", "KNN-10": "knn_k10", "KNN-20": "knn_k20",
437
+ }
438
+ results: dict[str, dict[str, float]] = {}
439
+ for csv_name in (
440
+ "classical_soh_results.csv", "lstm_soh_results.csv",
441
+ "transformer_soh_results.csv", "ensemble_results.csv",
442
+ "unified_results.csv",
443
+ ):
444
+ path = self._artifacts / csv_name
445
+ if not path.exists():
446
+ # Fall back to root-level results (backward compat)
447
+ path = _ARTIFACTS / csv_name
448
+ if not path.exists():
449
+ continue
450
+ try:
451
+ df = pd.read_csv(path, index_col=0)
452
+ for raw in df.index:
453
+ key = _normalise.get(str(raw), str(raw).lower().replace(" ", "_"))
454
+ results[key] = df.loc[raw].dropna().to_dict()
455
+ except Exception as exc:
456
+ log.warning("Could not read %s: %s", csv_name, exc)
457
+ for json_name in ("dg_itransformer_results.json", "vae_lstm_results.json"):
458
+ path = self._artifacts / json_name
459
+ if not path.exists():
460
+ path = _ARTIFACTS / json_name
461
+ if not path.exists():
462
+ continue
463
+ try:
464
+ with open(path) as fh:
465
+ data = json.load(fh)
466
+ key = json_name.replace("_results.json", "")
467
+ results[key] = {k: float(v) for k, v in data.items()
468
+ if isinstance(v, (int, float))}
469
+ except Exception as exc:
470
+ log.warning("Could not read %s: %s", json_name, exc)
471
+ # Fill from catalog for anything not in result files
472
+ for name, info in MODEL_CATALOG.items():
473
+ if name not in results and "r2" in info:
474
+ results[name] = {"R2": info["r2"]}
475
+ return results
476
+
477
+ # ── Prediction helpers ────────────────────────────────────────────────
478
+ def _build_x(self, features: dict[str, float]) -> np.ndarray:
479
+ """Build raw (1, F) feature numpy array — NO scaling applied here.
480
+
481
+ Scaling is applied per-model-family in :meth:`predict` because
482
+ tree models need no scaling while linear/deep models need different
483
+ scalers.
484
+ """
485
+ return np.array([[features.get(c, 0.0) for c in FEATURE_COLS_SCALAR]])
486
+
487
+ @staticmethod
488
+ def _x_for_model(model: Any, x: np.ndarray) -> Any:
489
+ """Return x in the format the model was fitted with.
490
+
491
+ * If the model has ``feature_names_in_`` → pass a DataFrame whose
492
+ columns match those exact names (handles LGB trained with Column_0…).
493
+ * Otherwise → pass the raw numpy array (RF, ET trained without names).
494
+ """
495
+ names = getattr(model, "feature_names_in_", None)
496
+ if names is None:
497
+ return x # numpy — model was fitted without feature names
498
+ # Build DataFrame with the same column names the model was trained with
499
+ return pd.DataFrame(x, columns=list(names))
500
+
501
+ def _scale_for_linear(self, x: np.ndarray) -> np.ndarray:
502
+ """Apply StandardScaler for linear / SVR / KNN models."""
503
+ if self.linear_scaler is not None:
504
+ try:
505
+ return self.linear_scaler.transform(x)
506
+ except Exception as exc:
507
+ log.warning("Linear scaler transform failed: %s", exc)
508
+ return x
509
+
510
+ def _build_sequence_array(
511
+ self, x: np.ndarray, seq_len: int = _SEQ_LEN
512
+ ) -> np.ndarray:
513
+ """Convert single-cycle feature row → scaled (1, seq_len, F) numpy array.
514
+
515
+ Tile the current feature vector across *seq_len* timesteps and apply
516
+ the sequence scaler so values match the training distribution.
517
+ """
518
+ if self.sequence_scaler is not None:
519
+ try:
520
+ x_sc = self.sequence_scaler.transform(x) # (1, F)
521
+ except Exception:
522
+ x_sc = x
523
+ else:
524
+ x_sc = x
525
+ # Tile to (1, seq_len, F)
526
+ return np.tile(x_sc[:, np.newaxis, :], (1, seq_len, 1)).astype(np.float32)
527
+
528
+ def _build_sequence_tensor(
529
+ self, x: np.ndarray, seq_len: int = _SEQ_LEN
530
+ ) -> Any:
531
+ """Same as :meth:`_build_sequence_array` but returns a PyTorch tensor."""
532
+ import torch
533
+ return torch.tensor(self._build_sequence_array(x, seq_len), dtype=torch.float32)
534
+
535
+ def _predict_ensemble(self, x: np.ndarray) -> tuple[float, str]:
536
+ """Weighted-average SOH prediction from BestEnsemble component models.
537
+
538
+ Each component model receives input in the format it was trained with:
539
+ - RF, ET, GB, XGB: raw numpy (trained on X_train.values, no feature names)
540
+ - LGB: DataFrame with Column_0…Column_11 (LightGBM auto-assigned during training)
541
+ Both cases handled by :meth:`_x_for_model`.
542
+ """
543
+ components = self.model_meta.get("best_ensemble", {}).get(
544
+ "components", list(_ENSEMBLE_WEIGHTS.keys())
545
+ )
546
+ total_w, weighted_sum = 0.0, 0.0
547
+ used: list[str] = []
548
+ for cname in components:
549
+ if cname not in self.models:
550
+ continue
551
+ w = _ENSEMBLE_WEIGHTS.get(cname, 1.0)
552
+ xi = self._x_for_model(self.models[cname], x)
553
+ soh = float(self.models[cname].predict(xi)[0])
554
+ weighted_sum += w * soh
555
+ total_w += w
556
+ used.append(cname)
557
+ if total_w == 0:
558
+ raise ValueError("No BestEnsemble components available")
559
+ return weighted_sum / total_w, f"best_ensemble({', '.join(used)})"
560
+
561
+ # ── Prediction ────────────────────────────────────────────────────────
562
+ def predict(
563
+ self,
564
+ features: dict[str, float],
565
+ model_name: str | None = None,
566
+ ) -> dict[str, Any]:
567
+ """Predict SOH for a single battery cycle.
568
+
569
+ Parameters
570
+ ----------
571
+ features:
572
+ Dict of cycle features; keys from :data:`FEATURE_COLS_SCALAR`.
573
+ Missing keys are filled with 0.0.
574
+ model_name:
575
+ Registry model key (e.g. ``"best_ensemble"``, ``"random_forest"``,
576
+ ``"tft"``). Defaults to :attr:`default_model`.
577
+
578
+ Returns
579
+ -------
580
+ dict
581
+ ``soh_pct``, ``degradation_state``, ``rul_cycles``,
582
+ ``confidence_lower``, ``confidence_upper``,
583
+ ``model_used``, ``model_version``.
584
+ """
585
+ name = model_name or self.default_model
586
+ if name is None:
587
+ raise ValueError("No models loaded in registry")
588
+
589
+ x = self._build_x(features)
590
+
591
+ # ── Dispatch by model type ──────────────────────────────────────
592
+ if name == "best_ensemble":
593
+ soh, label = self._predict_ensemble(x)
594
+ elif name in self.models:
595
+ model = self.models[name]
596
+ family = self.model_meta.get(name, {}).get("family", "classical")
597
+ if family == "deep_pytorch":
598
+ try:
599
+ import torch
600
+ with torch.no_grad():
601
+ # Build scaled (1, seq_len, F) sequence tensor
602
+ t = self._build_sequence_tensor(x).to(self.device)
603
+ out = model(t)
604
+ # VAE-LSTM returns a dict; all others return a tensor
605
+ if isinstance(out, dict):
606
+ out = out["health_pred"]
607
+ soh = float(out.cpu().numpy().ravel()[0])
608
+ except Exception as exc:
609
+ log.error("PyTorch inference error for '%s': %s", name, exc)
610
+ raise
611
+ elif family == "deep_keras":
612
+ try:
613
+ # Build scaled (1, seq_len, F) numpy array for Keras
614
+ seq_np = self._build_sequence_array(x) # (1, 32, F)
615
+ out = model.predict(seq_np, verbose=0)
616
+ # Physics-Informed model returns a dict with multiple heads
617
+ if isinstance(out, dict):
618
+ out = out.get("soh_ml", next(iter(out.values())))
619
+ soh = float(np.asarray(out).ravel()[0])
620
+ except Exception as exc:
621
+ log.error("Keras inference error for '%s': %s", name, exc)
622
+ raise
623
+ elif name in self._LINEAR_FAMILIES:
624
+ # Ridge/Lasso/ElasticNet/SVR/KNN need StandardScaler
625
+ x_lin = self._scale_for_linear(x)
626
+ soh = float(model.predict(x_lin)[0])
627
+ else:
628
+ # RF/XGB/LGB — scale-invariant; use per-model input format
629
+ xi = self._x_for_model(model, x)
630
+ soh = float(model.predict(xi)[0])
631
+ label = name
632
+ else:
633
+ fallback = self.default_model
634
+ if fallback and fallback != name and fallback in self.models:
635
+ log.warning("Model '%s' not loaded — falling back to '%s'", name, fallback)
636
+ return self.predict(features, fallback)
637
+ raise ValueError(
638
+ f"Model '{name}' is not available. "
639
+ f"Loaded: {list(self.models.keys())}"
640
+ )
641
+
642
+ soh = float(np.clip(soh, 0.0, 100.0))
643
+
644
+ # ── RUL estimate ────────────────────────────────────────────────
645
+ # Data-driven estimate: linear degradation from current SOH to 70%
646
+ # (EOL threshold), calibrated to NASA dataset's ~0.2-0.4 %/cycle rate.
647
+ EOL_THRESHOLD = 70.0
648
+ if soh > EOL_THRESHOLD:
649
+ # Degradation rate: use delta_capacity as a proxy (Ah/cycle)
650
+ # NASA nominal: ~2.0 Ah, so %/cycle = delta_cap / 2.0 * 100
651
+ cap_loss_per_cycle_pct = abs(features.get("delta_capacity", -0.005)) / 2.0 * 100
652
+ # Clamp to realistic range: 0.05 – 2.0 %/cycle
653
+ rate = max(0.05, min(cap_loss_per_cycle_pct, 2.0))
654
+ rul = (soh - EOL_THRESHOLD) / rate
655
+ else:
656
+ rul = 0.0
657
+
658
+ version = self.model_meta.get(name, MODEL_CATALOG.get(name, {})).get("version", "?")
659
+
660
+ return {
661
+ "soh_pct": round(soh, 2),
662
+ "degradation_state": classify_degradation(soh),
663
+ "rul_cycles": round(rul, 1),
664
+ "confidence_lower": round(soh - 2.0, 2),
665
+ "confidence_upper": round(soh + 2.0, 2),
666
+ "model_used": label,
667
+ "model_version": version,
668
+ }
669
+
670
+ def predict_batch(
671
+ self,
672
+ battery_id: str,
673
+ cycles: list[dict[str, float]],
674
+ model_name: str | None = None,
675
+ ) -> list[dict[str, Any]]:
676
+ """Predict SOH for multiple cycles of the same battery."""
677
+ return [
678
+ {**self.predict(c, model_name),
679
+ "battery_id": battery_id,
680
+ "cycle_number": c.get("cycle_number", i + 1)}
681
+ for i, c in enumerate(cycles)
682
+ ]
683
+
684
+ def predict_array(
685
+ self,
686
+ X: np.ndarray,
687
+ model_name: str | None = None,
688
+ ) -> tuple[np.ndarray, str]:
689
+ """Vectorized batch SOH prediction on an (N, F) feature matrix.
690
+
691
+ Performs a **single** ``model.predict()`` call for the whole array,
692
+ giving O(1) Python overhead regardless of how many rows N is.
693
+ Used by the simulation endpoint to avoid per-step loop overhead.
694
+
695
+ Parameters
696
+ ----------
697
+ X:
698
+ Shape ``(N, len(FEATURE_COLS_SCALAR))`` — rows are ordered by
699
+ ``FEATURE_COLS_SCALAR``, no scaling applied yet.
700
+ model_name:
701
+ Model key. Defaults to :attr:`default_model`.
702
+
703
+ Returns
704
+ -------
705
+ tuple[np.ndarray, str]
706
+ ``(soh_array, model_label)`` — ``soh_array`` has shape ``(N,)``,
707
+ values clipped to ``[0, 100]``.
708
+
709
+ Notes
710
+ -----
711
+ Deep sequence models (PyTorch / Keras) are not batchable here because
712
+ they require multi-timestep tensors. Callers that request a deep model
713
+ will get a ``ValueError``; the simulate endpoint falls back to physics.
714
+ """
715
+ name = model_name or self.default_model
716
+ if name is None:
717
+ raise ValueError("No models loaded in registry")
718
+
719
+ if name == "best_ensemble":
720
+ components = self.model_meta.get("best_ensemble", {}).get(
721
+ "components", list(_ENSEMBLE_WEIGHTS.keys())
722
+ )
723
+ total_w: float = 0.0
724
+ weighted_sum: np.ndarray | None = None
725
+ used: list[str] = []
726
+ for cname in components:
727
+ if cname not in self.models:
728
+ continue
729
+ w = _ENSEMBLE_WEIGHTS.get(cname, 1.0)
730
+ xi = self._x_for_model(self.models[cname], X)
731
+ preds = np.asarray(self.models[cname].predict(xi), dtype=float)
732
+ weighted_sum = preds * w if weighted_sum is None else weighted_sum + preds * w
733
+ total_w += w
734
+ used.append(cname)
735
+ if total_w == 0 or weighted_sum is None:
736
+ raise ValueError("No BestEnsemble components available")
737
+ return np.clip(weighted_sum / total_w, 0.0, 100.0), f"best_ensemble({', '.join(used)})"
738
+
739
+ elif name in self.models:
740
+ model = self.models[name]
741
+ family = self.model_meta.get(name, {}).get("family", "classical")
742
+ if family in ("deep_pytorch", "deep_keras"):
743
+ raise ValueError(
744
+ f"Model '{name}' is a deep sequence model and cannot be "
745
+ "batch-predicted. Use predict() per sample instead."
746
+ )
747
+ elif name in self._LINEAR_FAMILIES:
748
+ xi = self._scale_for_linear(X)
749
+ else:
750
+ xi = self._x_for_model(model, X)
751
+ return np.clip(np.asarray(model.predict(xi), dtype=float), 0.0, 100.0), name
752
+
753
+ else:
754
+ fallback = self.default_model
755
+ if fallback and fallback != name and fallback in self.models:
756
+ log.warning("predict_array: '%s' not loaded — falling back to '%s'", name, fallback)
757
+ return self.predict_array(X, fallback)
758
+ raise ValueError(f"Model '{name}' is not available. Loaded: {list(self.models.keys())}")
759
+
760
+ # ── Info helpers ──────────────────────────────────────────────────────
761
+ @property
762
+ def model_count(self) -> int:
763
+ """Total number of registered model entries."""
764
+ return len(set(list(self.models.keys()) + list(self.model_meta.keys())))
765
+
766
+ def list_models(self) -> list[dict[str, Any]]:
767
+ """Return full model listing with versioning, metrics, and load status."""
768
+ all_metrics = self.get_metrics()
769
+ out: list[dict[str, Any]] = []
770
+ for name in MODEL_CATALOG:
771
+ catalog = MODEL_CATALOG[name]
772
+ meta = self.model_meta.get(name, {})
773
+ out.append({
774
+ "name": name,
775
+ "version": catalog.get("version", "?"),
776
+ "display_name": catalog.get("display_name", name),
777
+ "family": catalog.get("family", "unknown"),
778
+ "algorithm": catalog.get("algorithm", ""),
779
+ "target": catalog.get("target", "soh"),
780
+ "r2": catalog.get("r2"),
781
+ "metrics": all_metrics.get(name, {}),
782
+ "is_default": name == self.default_model,
783
+ "loaded": name in self.models,
784
+ "load_error": meta.get("load_error"),
785
+ })
786
+ return out
787
+
788
+
789
+ # ── Singletons ───────────────────────────────────────────────────────────────
790
+ registry_v1 = ModelRegistry(version="v1")
791
+ registry_v2 = ModelRegistry(version="v2")
792
+
793
+ # Default registry — v2 (latest models, bug fixes)
794
+ registry = registry_v2
api/routers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # api.routers — FastAPI route handlers
api/routers/predict.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ api.routers.predict
3
+ ===================
4
+ Prediction & recommendation endpoints.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from fastapi import APIRouter, HTTPException
10
+
11
+ from api.model_registry import registry, registry_v1, classify_degradation, soh_to_color
12
+ from api.schemas import (
13
+ PredictRequest, PredictResponse,
14
+ BatchPredictRequest, BatchPredictResponse,
15
+ RecommendationRequest, RecommendationResponse, SingleRecommendation,
16
+ )
17
+
18
+ router = APIRouter(prefix="/api", tags=["prediction"])
19
+
20
+ # v1-prefixed router (legacy, preserved for backward compatibility)
21
+ v1_router = APIRouter(prefix="/api/v1", tags=["v1-prediction"])
22
+
23
+
24
+ # ── Single prediction ────────────────────────────────────────────────────────
25
+ @router.post("/predict", response_model=PredictResponse)
26
+ async def predict(req: PredictRequest):
27
+ """Predict SOH for a single cycle."""
28
+ features = req.model_dump(exclude={"battery_id"})
29
+ features["voltage_range"] = features["peak_voltage"] - features["min_voltage"]
30
+ # If avg_temp equals ambient_temperature exactly, apply the NASA data offset
31
+ # (cell temperature is always 8-10°C above ambient under load).
32
+ if abs(features["avg_temp"] - features["ambient_temperature"]) < 0.5:
33
+ features["avg_temp"] = features["ambient_temperature"] + 8.0
34
+
35
+ try:
36
+ result = registry.predict(features)
37
+ except Exception as exc:
38
+ raise HTTPException(status_code=500, detail=str(exc))
39
+
40
+ return PredictResponse(
41
+ battery_id=req.battery_id,
42
+ cycle_number=req.cycle_number,
43
+ soh_pct=result["soh_pct"],
44
+ rul_cycles=result["rul_cycles"],
45
+ degradation_state=result["degradation_state"],
46
+ confidence_lower=result["confidence_lower"],
47
+ confidence_upper=result["confidence_upper"],
48
+ model_used=result["model_used"],
49
+ )
50
+
51
+
52
+ # ── Batch prediction ─────────────────────────────────────────────────────────
53
+ @router.post("/predict/batch", response_model=BatchPredictResponse)
54
+ async def predict_batch(req: BatchPredictRequest):
55
+ """Predict SOH for multiple cycles of one battery."""
56
+ results = registry.predict_batch(req.battery_id, req.cycles)
57
+ predictions = [
58
+ PredictResponse(
59
+ battery_id=req.battery_id,
60
+ cycle_number=r["cycle_number"],
61
+ soh_pct=r["soh_pct"],
62
+ rul_cycles=r["rul_cycles"],
63
+ degradation_state=r["degradation_state"],
64
+ confidence_lower=r.get("confidence_lower"),
65
+ confidence_upper=r.get("confidence_upper"),
66
+ model_used=r["model_used"], model_version=r.get("model_version"), )
67
+ for r in results
68
+ ]
69
+ return BatchPredictResponse(battery_id=req.battery_id, predictions=predictions)
70
+
71
+
72
+ # ── Recommendations ──────────────────────────────────────────────────────────
73
+ @router.post("/recommend", response_model=RecommendationResponse)
74
+ async def recommend(req: RecommendationRequest):
75
+ """Get operational recommendations for a battery based on physics-informed degradation model."""
76
+ import itertools
77
+
78
+ # **FIXED**: Use physics-based degradation rates instead of unreliable RUL prediction
79
+ # Empirical degradation rates from NASA PCoE data analysis
80
+ DEGRADATION_RATES = {
81
+ # Format: (temp_range, current_level): % SOH loss per cycle
82
+ "cold_light": 0.08, # 4°C, <=1A
83
+ "cold_moderate": 0.12, # 4°C, 1-2A
84
+ "cold_heavy": 0.18, # 4°C, >2A
85
+ "room_light": 0.15, # 24°C, <=1A
86
+ "room_moderate": 0.22, # 24°C, 1-2A
87
+ "room_heavy": 0.28, # 24°C, >2A
88
+ "warm_light": 0.35, # 43°C, <=1A
89
+ "warm_moderate": 0.48, # 43°C, 1-2A
90
+ "warm_heavy": 0.65, # 43°C, >2A
91
+ }
92
+
93
+ def get_degradation_rate(temp, current):
94
+ """Return degradation rate (% SOH/cycle) given operating conditions."""
95
+ if temp <= 4:
96
+ if current <= 1.0:
97
+ return DEGRADATION_RATES["cold_light"]
98
+ elif current <= 2.0:
99
+ return DEGRADATION_RATES["cold_moderate"]
100
+ else:
101
+ return DEGRADATION_RATES["cold_heavy"]
102
+ elif temp <= 24:
103
+ if current <= 1.0:
104
+ return DEGRADATION_RATES["room_light"]
105
+ elif current <= 2.0:
106
+ return DEGRADATION_RATES["room_moderate"]
107
+ else:
108
+ return DEGRADATION_RATES["room_heavy"]
109
+ else:
110
+ if current <= 1.0:
111
+ return DEGRADATION_RATES["warm_light"]
112
+ elif current <= 2.0:
113
+ return DEGRADATION_RATES["warm_moderate"]
114
+ else:
115
+ return DEGRADATION_RATES["warm_heavy"]
116
+
117
+ def cycles_to_eol(current_soh, degradation_rate_pct_per_cycle, eol_threshold=70):
118
+ """Calculate cycles until end-of-life."""
119
+ if degradation_rate_pct_per_cycle <= 0:
120
+ return 10000 # Unrealistic but prevents division by zero
121
+ soh_margin = current_soh - eol_threshold
122
+ if soh_margin <= 0:
123
+ return 0
124
+ return int(soh_margin / degradation_rate_pct_per_cycle)
125
+
126
+ # Generate recommendations for different operating conditions
127
+ temps = [4.0, 24.0, 43.0]
128
+ currents = [0.5, 1.0, 2.0, 4.0]
129
+
130
+ candidates = []
131
+ for t, c in itertools.product(temps, currents):
132
+ degradation = get_degradation_rate(t, c)
133
+ rul = cycles_to_eol(req.current_soh, degradation)
134
+ candidates.append((rul, t, c, degradation))
135
+
136
+ # Sort by RUL (cycles until EOL) in descending order
137
+ candidates.sort(reverse=True, key=lambda x: x[0])
138
+ top = candidates[:req.top_k]
139
+
140
+ # Calculate baseline (current operating conditions)
141
+ baseline_degradation = get_degradation_rate(req.ambient_temperature, 2.0)
142
+ baseline_rul = cycles_to_eol(req.current_soh, baseline_degradation)
143
+
144
+ recs = []
145
+ for rank, (rul, t, c, deg) in enumerate(top, 1):
146
+ improvement = rul - baseline_rul
147
+ improvement_pct = (improvement / baseline_rul * 100) if baseline_rul > 0 else 0.0
148
+
149
+ # Determine operational regime
150
+ if t <= 4:
151
+ temp_desc = "cold storage"
152
+ elif t <= 24:
153
+ temp_desc = "room temperature"
154
+ else:
155
+ temp_desc = "heated environment"
156
+
157
+ if c <= 1.0:
158
+ current_desc = "low current (trickle charge/light use)"
159
+ elif c <= 2.0:
160
+ current_desc = "moderate current (normal use)"
161
+ else:
162
+ current_desc = "high current (fast charging/heavy load)"
163
+
164
+ recs.append(SingleRecommendation(
165
+ rank=rank,
166
+ ambient_temperature=t,
167
+ discharge_current=c,
168
+ cutoff_voltage=2.5, # Standard cutoff
169
+ predicted_rul=int(rul),
170
+ rul_improvement=int(improvement),
171
+ rul_improvement_pct=round(improvement_pct, 1),
172
+ explanation=f"Rank #{rank}: Operate in {temp_desc} at {current_desc} → ~{int(rul)} cycles until EOL (+{int(improvement)} cycles vs. baseline)",
173
+ ))
174
+
175
+ return RecommendationResponse(
176
+ battery_id=req.battery_id,
177
+ current_soh=req.current_soh,
178
+ recommendations=recs,
179
+ )
180
+
181
+
182
+ # ── Model listing ────────────────────────────────────────────────────────────
183
+ @router.get("/models")
184
+ async def list_models():
185
+ """List all registered models with metrics, version, and load status."""
186
+ return registry.list_models()
187
+
188
+
189
+ @router.get("/models/versions")
190
+ async def list_model_versions():
191
+ """Return models grouped by semantic version family.
192
+
193
+ Groups:
194
+ * v1 — Classical ML models
195
+ * v2 — Deep sequence models (LSTM, Transformer)
196
+ * v2 patch — Ensemble / meta-models (v2.6)
197
+ """
198
+ all_models = registry.list_models()
199
+ groups: dict[str, list] = {"v1": [], "v2": [], "v2_ensemble": [], "other": []}
200
+ for m in all_models:
201
+ ver = m.get("version", "")
202
+ if ver.startswith("1"):
203
+ groups["v1"].append(m)
204
+ elif ver.startswith("3") or "ensemble" in m.get("name", "").lower():
205
+ groups["v2_ensemble"].append(m)
206
+ elif ver.startswith("2"):
207
+ groups["v2"].append(m)
208
+ else:
209
+ groups["other"].append(m)
210
+ return {
211
+ "v1_classical": groups["v1"],
212
+ "v2_deep": groups["v2"],
213
+ "v2_ensemble": groups["v2_ensemble"],
214
+ "other": groups["other"],
215
+ "default_model": registry.default_model,
216
+ }
217
+
218
+
219
+ # ── v1-prefixed endpoints (legacy) ──────────────────────────────────────────
220
+ @v1_router.post("/predict", response_model=PredictResponse)
221
+ async def predict_v1(req: PredictRequest):
222
+ """Predict SOH using v1 models (legacy, uses group-battery split models)."""
223
+ features = req.model_dump(exclude={"battery_id"})
224
+ features["voltage_range"] = features["peak_voltage"] - features["min_voltage"]
225
+ if abs(features["avg_temp"] - features["ambient_temperature"]) < 0.5:
226
+ features["avg_temp"] = features["ambient_temperature"] + 8.0
227
+ try:
228
+ result = registry_v1.predict(features)
229
+ except Exception as exc:
230
+ raise HTTPException(status_code=500, detail=str(exc))
231
+ return PredictResponse(
232
+ battery_id=req.battery_id,
233
+ cycle_number=req.cycle_number,
234
+ soh_pct=result["soh_pct"],
235
+ rul_cycles=result["rul_cycles"],
236
+ degradation_state=result["degradation_state"],
237
+ confidence_lower=result["confidence_lower"],
238
+ confidence_upper=result["confidence_upper"],
239
+ model_used=result["model_used"],
240
+ model_version=result.get("model_version", "1.0.0"),
241
+ )
242
+
243
+
244
+ @v1_router.get("/models")
245
+ async def list_models_v1():
246
+ """List all v1 registered models."""
247
+ return registry_v1.list_models()
api/routers/predict_v2.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ api.routers.predict_v2
3
+ ======================
4
+ v2 prediction & recommendation endpoints.
5
+
6
+ Bug fixes over v1:
7
+ - Removed avg_temp auto-correction that corrupted inputs
8
+ - Recommendation baseline uses user-provided current_soh for RUL estimation
9
+ - Version-aware model loading from artifacts/v2/
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from fastapi import APIRouter, HTTPException
15
+
16
+ from api.model_registry import registry_v2, classify_degradation, soh_to_color
17
+ from api.schemas import (
18
+ PredictRequest, PredictResponse,
19
+ BatchPredictRequest, BatchPredictResponse,
20
+ RecommendationRequest, RecommendationResponse, SingleRecommendation,
21
+ )
22
+
23
+ router = APIRouter(prefix="/api/v2", tags=["v2-prediction"])
24
+
25
+
26
+ # ── Single prediction ────────────────────────────────────────────────────────
27
+ @router.post("/predict", response_model=PredictResponse)
28
+ async def predict_v2(req: PredictRequest):
29
+ """Predict SOH for a single cycle using v2 models."""
30
+ features = req.model_dump(exclude={"battery_id"})
31
+ features["voltage_range"] = features["peak_voltage"] - features["min_voltage"]
32
+ # v2 FIX: no avg_temp auto-correction — trust the user's input
33
+
34
+ try:
35
+ result = registry_v2.predict(features)
36
+ except Exception as exc:
37
+ raise HTTPException(status_code=500, detail=str(exc))
38
+
39
+ return PredictResponse(
40
+ battery_id=req.battery_id,
41
+ cycle_number=req.cycle_number,
42
+ soh_pct=result["soh_pct"],
43
+ rul_cycles=result["rul_cycles"],
44
+ degradation_state=result["degradation_state"],
45
+ confidence_lower=result["confidence_lower"],
46
+ confidence_upper=result["confidence_upper"],
47
+ model_used=result["model_used"],
48
+ model_version=result.get("model_version", "2.0.0"),
49
+ )
50
+
51
+
52
+ # ── Batch prediction ─────────────────────────────────────────────────────────
53
+ @router.post("/predict/batch", response_model=BatchPredictResponse)
54
+ async def predict_batch_v2(req: BatchPredictRequest):
55
+ """Predict SOH for multiple cycles using v2 models."""
56
+ results = registry_v2.predict_batch(req.battery_id, req.cycles)
57
+ predictions = [
58
+ PredictResponse(
59
+ battery_id=req.battery_id,
60
+ cycle_number=r["cycle_number"],
61
+ soh_pct=r["soh_pct"],
62
+ rul_cycles=r["rul_cycles"],
63
+ degradation_state=r["degradation_state"],
64
+ confidence_lower=r.get("confidence_lower"),
65
+ confidence_upper=r.get("confidence_upper"),
66
+ model_used=r["model_used"],
67
+ model_version=r.get("model_version", "2.0.0"),
68
+ )
69
+ for r in results
70
+ ]
71
+ return BatchPredictResponse(battery_id=req.battery_id, predictions=predictions)
72
+
73
+
74
+ # ── Recommendations (v2 — fixed) ────────────────────────────────────────────
75
+ @router.post("/recommend", response_model=RecommendationResponse)
76
+ async def recommend_v2(req: RecommendationRequest):
77
+ """Get operational recommendations using v2 models.
78
+
79
+ v2 FIX: Uses user-provided current_soh to compute baseline RUL instead
80
+ of re-predicting SOH from default features (which caused ~0 cycle
81
+ improvement in v1).
82
+ """
83
+ import itertools
84
+
85
+ temps = [4.0, 24.0, 43.0]
86
+ currents = [0.5, 1.0, 2.0, 4.0]
87
+ cutoffs = [2.0, 2.2, 2.5, 2.7]
88
+
89
+ # v2 FIX: compute baseline RUL from user-provided current_soh
90
+ # Data-driven: linear degradation at a realistic rate (~0.2%/cycle)
91
+ EOL_THRESHOLD = 70.0
92
+ deg_rate = 0.2 # conservative NASA-calibrated %/cycle
93
+ if req.current_soh > EOL_THRESHOLD:
94
+ baseline_rul = (req.current_soh - EOL_THRESHOLD) / deg_rate
95
+ else:
96
+ baseline_rul = 0.0
97
+
98
+ base_features = {
99
+ "cycle_number": req.current_cycle,
100
+ "ambient_temperature": req.ambient_temperature,
101
+ "peak_voltage": 4.19,
102
+ "min_voltage": 2.61,
103
+ "voltage_range": 4.19 - 2.61,
104
+ "avg_current": 1.82,
105
+ "avg_temp": req.ambient_temperature + 8.0,
106
+ "temp_rise": 15.0,
107
+ "cycle_duration": 3690.0,
108
+ "Re": 0.045,
109
+ "Rct": 0.069,
110
+ "delta_capacity": -0.005,
111
+ }
112
+
113
+ candidates = []
114
+ for t, c, v in itertools.product(temps, currents, cutoffs):
115
+ feat = {**base_features, "ambient_temperature": t, "avg_current": c,
116
+ "min_voltage": v, "voltage_range": 4.19 - v,
117
+ "avg_temp": t + 8.0}
118
+ result = registry_v2.predict(feat)
119
+ rul = result.get("rul_cycles", 0) or 0
120
+ candidates.append((rul, t, c, v, result["soh_pct"]))
121
+
122
+ candidates.sort(reverse=True)
123
+ top = candidates[: req.top_k]
124
+
125
+ recs = []
126
+ for rank, (rul, t, c, v, soh) in enumerate(top, 1):
127
+ improvement = rul - baseline_rul
128
+ pct = (improvement / baseline_rul * 100) if baseline_rul > 0 else 0
129
+ recs.append(SingleRecommendation(
130
+ rank=rank,
131
+ ambient_temperature=t,
132
+ discharge_current=c,
133
+ cutoff_voltage=v,
134
+ predicted_rul=rul,
135
+ rul_improvement=improvement,
136
+ rul_improvement_pct=round(pct, 1),
137
+ explanation=f"Operate at {t}°C, {c}A, cutoff {v}V for ~{rul:.0f} cycles RUL",
138
+ ))
139
+
140
+ return RecommendationResponse(
141
+ battery_id=req.battery_id,
142
+ current_soh=req.current_soh,
143
+ recommendations=recs,
144
+ )
145
+
146
+
147
+ # ── Model listing ────────────────────────────────────────────────────────────
148
+ @router.get("/models")
149
+ async def list_models_v2():
150
+ """List all v2 registered models."""
151
+ return registry_v2.list_models()
api/routers/simulate.py ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ api.routers.simulate
3
+ ====================
4
+ Bulk battery lifecycle simulation endpoint - vectorized ML-driven.
5
+
6
+ Performance design (O(1) Python overhead per battery regardless of step count):
7
+ 1. SEI impedance growth - numpy cumsum (no Python loop)
8
+ 2. Feature matrix build - numpy column_stack -> (N_steps, 12)
9
+ 3. ML prediction - single model.predict() call via predict_array()
10
+ 4. RUL / EOL - numpy diff / cumsum / searchsorted
11
+ 5. Classify / colorize - numpy searchsorted on pre-built label arrays
12
+
13
+ Scaler dispatch mirrors NB03 training EXACTLY:
14
+ Tree models (RF / ET / XGB / LGB / GB) -> raw numpy (no scaler)
15
+ Linear / SVR / KNN -> standard_scaler.joblib.transform(X)
16
+ best_ensemble -> per-component dispatch (same rules)
17
+ Deep sequence models (PyTorch / Keras) -> not batchable, falls back to physics
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import math
24
+ from typing import List, Optional
25
+
26
+ import numpy as np
27
+ from fastapi import APIRouter
28
+ from pydantic import BaseModel, Field
29
+
30
+ from api.model_registry import (
31
+ FEATURE_COLS_SCALAR, classify_degradation, soh_to_color, registry_v2,
32
+ )
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+ router = APIRouter(prefix="/api/v2", tags=["simulation"])
37
+
38
+ # -- Physics constants --------------------------------------------------------
39
+ _EA_OVER_R = 6200.0 # Ea/R in Kelvin
40
+ _Q_NOM = 2.0 # NASA PCoE nominal capacity (Ah)
41
+ _T_REF = 24.0 # Reference ambient temperature (deg C)
42
+ _I_REF = 1.82 # Reference discharge current (A)
43
+ _V_REF = 4.19 # Reference peak voltage (V)
44
+
45
+ _TIME_UNIT_SECONDS: dict[str, float | None] = {
46
+ "cycle": None, "second": 1.0, "minute": 60.0,
47
+ "hour": 3_600.0, "day": 86_400.0, "week": 604_800.0,
48
+ "month": 2_592_000.0, "year": 31_536_000.0,
49
+ }
50
+ _TIME_UNIT_LABELS: dict[str, str] = {
51
+ "cycle": "Cycles", "second": "Seconds", "minute": "Minutes",
52
+ "hour": "Hours", "day": "Days", "week": "Weeks",
53
+ "month": "Months", "year": "Years",
54
+ }
55
+
56
+ # Column index map - must stay in sync with FEATURE_COLS_SCALAR
57
+ _F = {col: idx for idx, col in enumerate(FEATURE_COLS_SCALAR)}
58
+
59
+ # Pre-built label/color arrays for O(1) numpy-vectorized classification
60
+ _SOH_BINS = np.array([70.0, 80.0, 90.0]) # searchsorted thresholds
61
+ _DEG_LABELS = np.array(["End-of-Life", "Degraded", "Moderate", "Healthy"], dtype=object)
62
+ _COLOR_HEX = np.array(["#ef4444", "#f97316", "#eab308", "#22c55e"], dtype=object)
63
+
64
+
65
+ def _vec_classify(soh: np.ndarray) -> list[str]:
66
+ """Vectorized classify_degradation - single numpy call, no Python for-loop."""
67
+ return _DEG_LABELS[np.searchsorted(_SOH_BINS, soh, side="left")].tolist()
68
+
69
+
70
+ def _vec_color(soh: np.ndarray) -> list[str]:
71
+ """Vectorized soh_to_color - single numpy call, no Python for-loop."""
72
+ return _COLOR_HEX[np.searchsorted(_SOH_BINS, soh, side="left")].tolist()
73
+
74
+
75
+ # -- Schemas ------------------------------------------------------------------
76
+ class BatterySimConfig(BaseModel):
77
+ battery_id: str
78
+ label: Optional[str] = None
79
+ initial_soh: float = Field(default=100.0, ge=0.0, le=100.0)
80
+ start_cycle: int = Field(default=1, ge=1)
81
+ ambient_temperature: float = Field(default=24.0)
82
+ peak_voltage: float = Field(default=4.19)
83
+ min_voltage: float = Field(default=2.61)
84
+ avg_current: float = Field(default=1.82)
85
+ avg_temp: float = Field(default=32.6)
86
+ temp_rise: float = Field(default=14.7)
87
+ cycle_duration: float = Field(default=3690.0)
88
+ Re: float = Field(default=0.045)
89
+ Rct: float = Field(default=0.069)
90
+ delta_capacity: float = Field(default=-0.005)
91
+
92
+
93
+ class SimulateRequest(BaseModel):
94
+ batteries: List[BatterySimConfig]
95
+ steps: int = Field(default=200, ge=1, le=10_000)
96
+ time_unit: str = Field(default="day")
97
+ eol_threshold: float = Field(default=70.0, ge=0.0, le=100.0)
98
+ model_name: Optional[str] = Field(default=None)
99
+ use_ml: bool = Field(default=True)
100
+
101
+
102
+ class BatterySimResult(BaseModel):
103
+ battery_id: str
104
+ label: Optional[str]
105
+ soh_history: List[float]
106
+ rul_history: List[float]
107
+ rul_time_history: List[float]
108
+ re_history: List[float]
109
+ rct_history: List[float]
110
+ cycle_history: List[int]
111
+ time_history: List[float]
112
+ degradation_history: List[str]
113
+ color_history: List[str]
114
+ eol_cycle: Optional[int]
115
+ eol_time: Optional[float]
116
+ final_soh: float
117
+ final_rul: float
118
+ deg_rate_avg: float
119
+ model_used: str = "physics"
120
+
121
+
122
+ class SimulateResponse(BaseModel):
123
+ results: List[BatterySimResult]
124
+ time_unit: str
125
+ time_unit_label: str
126
+ steps: int
127
+ model_used: str = "physics"
128
+
129
+
130
+ # -- Helpers ------------------------------------------------------------------
131
+ def _sei_growth(
132
+ re0: float, rct0: float, steps: int, temp_f: float
133
+ ) -> tuple[np.ndarray, np.ndarray]:
134
+ """Vectorized SEI impedance growth over `steps` cycles.
135
+
136
+ Returns (re_arr, rct_arr) each shaped (steps,) using cumsum - no Python loop.
137
+ Matches the incremental SEI model used during feature engineering (NB02).
138
+ """
139
+ s = np.arange(steps, dtype=np.float64)
140
+ delta_re = 0.00012 * temp_f * (1.0 + s * 5e-5)
141
+ delta_rct = 0.00018 * temp_f * (1.0 + s * 8e-5)
142
+ re_arr = np.minimum(re0 + np.cumsum(delta_re), 2.0)
143
+ rct_arr = np.minimum(rct0 + np.cumsum(delta_rct), 3.0)
144
+ return re_arr, rct_arr
145
+
146
+
147
+ def _build_feature_matrix(
148
+ b: BatterySimConfig, steps: int,
149
+ re_arr: np.ndarray, rct_arr: np.ndarray,
150
+ ) -> np.ndarray:
151
+ """Build (steps, 12) feature matrix in FEATURE_COLS_SCALAR order.
152
+
153
+ Column ordering is guaranteed by the _F index map so the resulting matrix
154
+ is byte-identical to what the NB03 models were trained on, before any
155
+ scaling step. Scaling is applied inside predict_array() per model family.
156
+ """
157
+ N = steps
158
+ cycles = np.arange(b.start_cycle, b.start_cycle + N, dtype=np.float64)
159
+ X = np.empty((N, len(FEATURE_COLS_SCALAR)), dtype=np.float64)
160
+ X[:, _F["cycle_number"]] = cycles
161
+ X[:, _F["ambient_temperature"]] = b.ambient_temperature
162
+ X[:, _F["peak_voltage"]] = b.peak_voltage
163
+ X[:, _F["min_voltage"]] = b.min_voltage
164
+ X[:, _F["voltage_range"]] = b.peak_voltage - b.min_voltage
165
+ X[:, _F["avg_current"]] = b.avg_current
166
+ X[:, _F["avg_temp"]] = b.avg_temp
167
+ X[:, _F["temp_rise"]] = b.temp_rise
168
+ X[:, _F["cycle_duration"]] = b.cycle_duration
169
+ X[:, _F["Re"]] = re_arr
170
+ X[:, _F["Rct"]] = rct_arr
171
+ X[:, _F["delta_capacity"]] = b.delta_capacity
172
+ return X
173
+
174
+
175
+ def _physics_soh(b: BatterySimConfig, steps: int, temp_f: float) -> np.ndarray:
176
+ """Pure Arrhenius physics fallback - fully vectorized, returns (steps,) SOH."""
177
+ rate_base = float(np.clip(abs(b.delta_capacity) / _Q_NOM * 100.0, 0.005, 1.5))
178
+ curr_f = 1.0 + max(0.0, (b.avg_current - _I_REF) * 0.18)
179
+ volt_f = 1.0 + max(0.0, (b.peak_voltage - _V_REF) * 0.55)
180
+ age_f = 1.0 + (0.08 if b.initial_soh < 85.0 else 0.0) + (0.12 if b.initial_soh < 75.0 else 0.0)
181
+ deg_rate = float(np.clip(rate_base * temp_f * curr_f * volt_f * age_f, 0.0, 2.0))
182
+ soh_arr = b.initial_soh - deg_rate * np.arange(1, steps + 1, dtype=np.float64)
183
+ return np.clip(soh_arr, 0.0, 100.0)
184
+
185
+
186
+ def _compute_rul_and_eol(
187
+ soh_arr: np.ndarray,
188
+ initial_soh: float,
189
+ eol_thr: float,
190
+ cycle_start: int,
191
+ cycle_dur: float,
192
+ tu_sec: float | None,
193
+ ) -> tuple[np.ndarray, np.ndarray, Optional[int], Optional[float]]:
194
+ """Vectorized RUL and EOL from SOH trajectory.
195
+
196
+ Returns (rul_cycles, rul_time, eol_cycle, eol_time).
197
+ Uses rolling-average degradation rate for smooth RUL estimate.
198
+ """
199
+ N = len(soh_arr)
200
+ steps = np.arange(N, dtype=np.float64)
201
+ cycles = (cycle_start + steps).astype(np.int64)
202
+
203
+ # Rolling average degradation rate (smoothed, avoids division-by-zero)
204
+ soh_prev = np.concatenate([[initial_soh], soh_arr[:-1]])
205
+ step_deg = np.maximum(0.0, soh_prev - soh_arr)
206
+ cum_deg = np.cumsum(step_deg)
207
+ avg_rate = np.maximum(cum_deg / (steps + 1), 1e-6)
208
+
209
+ rul_cycles = np.where(soh_arr > eol_thr, (soh_arr - eol_thr) / avg_rate, 0.0)
210
+ rul_time = (rul_cycles * cycle_dur / tu_sec) if tu_sec is not None else rul_cycles.copy()
211
+
212
+ # EOL: first step where SOH <= threshold
213
+ below = soh_arr <= eol_thr
214
+ eol_cycle: Optional[int] = None
215
+ eol_time: Optional[float] = None
216
+ if below.any():
217
+ idx = int(np.argmax(below))
218
+ eol_cycle = int(cycles[idx])
219
+ elapsed_s = eol_cycle * cycle_dur
220
+ eol_time = round((elapsed_s / tu_sec) if tu_sec else float(eol_cycle), 3)
221
+
222
+ return rul_cycles, rul_time, eol_cycle, eol_time
223
+
224
+
225
+ # -- Endpoint -----------------------------------------------------------------
226
+ @router.post(
227
+ "/simulate",
228
+ response_model=SimulateResponse,
229
+ summary="Bulk battery lifecycle simulation (vectorized, ML-driven)",
230
+ )
231
+ async def simulate_batteries(req: SimulateRequest):
232
+ """
233
+ Vectorized simulation: builds all N feature rows at once per battery,
234
+ dispatches to the ML model as a single batch predict() call, then
235
+ post-processes entirely with numpy (no Python for-loops).
236
+
237
+ Scaler usage mirrors NB03 training exactly:
238
+ - Tree models (RF/ET/XGB/LGB/GB): raw numpy X, no scaler
239
+ - Linear/SVR/KNN: standard_scaler.joblib.transform(X)
240
+ - best_ensemble: per-component family dispatch
241
+ """
242
+ time_unit = req.time_unit.lower()
243
+ if time_unit not in _TIME_UNIT_SECONDS:
244
+ time_unit = "day"
245
+
246
+ tu_sec = _TIME_UNIT_SECONDS[time_unit]
247
+ tu_label = _TIME_UNIT_LABELS[time_unit]
248
+ eol_thr = req.eol_threshold
249
+ N = req.steps
250
+
251
+ model_name = req.model_name or registry_v2.default_model or "best_ensemble"
252
+
253
+ # Deep sequence models need per-sample tensors — cannot batch vectorise
254
+ # Tree / linear / ensemble models support predict_array() batch calls.
255
+ # We do NOT gate on model_count here: predict_array() has a try/except
256
+ # fallback to physics, so a partial load still works.
257
+ family = registry_v2.model_meta.get(model_name, {}).get("family", "classical")
258
+ is_deep = family in ("deep_pytorch", "deep_keras")
259
+ ml_batchable = (
260
+ req.use_ml
261
+ and not is_deep
262
+ and (model_name == "best_ensemble" or model_name in registry_v2.models)
263
+ )
264
+
265
+ # Determine scaler note for logging (mirrors training decision exactly)
266
+ if model_name in registry_v2._LINEAR_FAMILIES:
267
+ scaler_note = "standard_scaler"
268
+ elif model_name == "best_ensemble":
269
+ scaler_note = "per-component (tree=none / linear=standard_scaler)"
270
+ else:
271
+ scaler_note = "none (tree)"
272
+
273
+ effective_model = "physics"
274
+ log.info(
275
+ "simulate: %d batteries x %d steps | model=%s | batchable=%s | scaler=%s | unit=%s",
276
+ len(req.batteries), N, model_name, ml_batchable, scaler_note, time_unit,
277
+ )
278
+
279
+ results: list[BatterySimResult] = []
280
+
281
+ for b in req.batteries:
282
+ # 1. SEI impedance growth - vectorized cumsum (no Python loop)
283
+ T_K = 273.15 + b.ambient_temperature
284
+ T_REF_K = 273.15 + _T_REF
285
+ temp_f = float(np.clip(math.exp(_EA_OVER_R * (1.0 / T_REF_K - 1.0 / T_K)), 0.15, 25.0))
286
+ re_arr, rct_arr = _sei_growth(b.Re, b.Rct, N, temp_f)
287
+
288
+ # 2. SOH prediction - single batch call regardless of N
289
+ # predict_array() applies the correct scaler per model family,
290
+ # exactly matching the preprocessing done during NB03 training:
291
+ # * standard_scaler.transform(X) for Ridge / SVR / KNN / Lasso / ElasticNet
292
+ # * raw numpy for RF / ET / XGB / LGB / GB
293
+ # * per-component dispatch for best_ensemble
294
+ if ml_batchable:
295
+ X = _build_feature_matrix(b, N, re_arr, rct_arr)
296
+ try:
297
+ soh_arr, effective_model = registry_v2.predict_array(X, model_name)
298
+ except Exception as exc:
299
+ log.warning(
300
+ "predict_array failed for %s (%s) - falling back to physics",
301
+ b.battery_id, exc,
302
+ )
303
+ soh_arr = _physics_soh(b, N, temp_f)
304
+ effective_model = "physics"
305
+ else:
306
+ soh_arr = _physics_soh(b, N, temp_f)
307
+ effective_model = "physics"
308
+
309
+ soh_arr = np.clip(soh_arr, 0.0, 100.0)
310
+
311
+ # 3. RUL + EOL - vectorized
312
+ rul_cycles, rul_time, eol_cycle, eol_time = _compute_rul_and_eol(
313
+ soh_arr, b.initial_soh, eol_thr, b.start_cycle, b.cycle_duration, tu_sec,
314
+ )
315
+
316
+ # 4. Time axis - vectorized
317
+ cycle_arr = np.arange(b.start_cycle, b.start_cycle + N, dtype=np.int64)
318
+ time_arr = (
319
+ (cycle_arr * b.cycle_duration / tu_sec).astype(np.float64)
320
+ if tu_sec is not None
321
+ else cycle_arr.astype(np.float64)
322
+ )
323
+
324
+ # 5. Labels + colors - fully vectorized via numpy searchsorted
325
+ # Replaces O(N) Python for-loop with a single C-level call
326
+ deg_h = _vec_classify(soh_arr)
327
+ color_h = _vec_color(soh_arr)
328
+
329
+ avg_dr = float(np.mean(np.maximum(0.0, -np.diff(soh_arr, prepend=b.initial_soh))))
330
+
331
+ # 6. Build result - numpy round + .tolist() (no per-element Python conversion)
332
+ results.append(BatterySimResult(
333
+ battery_id = b.battery_id,
334
+ label = b.label or b.battery_id,
335
+ soh_history = np.round(soh_arr, 3).tolist(),
336
+ rul_history = np.round(rul_cycles, 1).tolist(),
337
+ rul_time_history = np.round(rul_time, 2).tolist(),
338
+ re_history = np.round(re_arr, 6).tolist(),
339
+ rct_history = np.round(rct_arr, 6).tolist(),
340
+ cycle_history = cycle_arr.tolist(),
341
+ time_history = np.round(time_arr, 3).tolist(),
342
+ degradation_history = deg_h,
343
+ color_history = color_h,
344
+ eol_cycle = eol_cycle,
345
+ eol_time = eol_time,
346
+ final_soh = round(float(soh_arr[-1]), 3),
347
+ final_rul = round(float(rul_cycles[-1]), 1),
348
+ deg_rate_avg = round(avg_dr, 6),
349
+ model_used = effective_model,
350
+ ))
351
+
352
+ return SimulateResponse(
353
+ results = results,
354
+ time_unit = time_unit,
355
+ time_unit_label = tu_label,
356
+ steps = N,
357
+ model_used = effective_model,
358
+ )
359
+
api/routers/visualize.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ api.routers.visualize
3
+ =====================
4
+ Endpoints that serve pre-computed or on-demand visualisation data
5
+ consumed by the React frontend.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ from fastapi import APIRouter, HTTPException
16
+ from fastapi.responses import FileResponse
17
+
18
+ from api.model_registry import registry, classify_degradation, soh_to_color
19
+ from api.schemas import BatteryVizData, DashboardData
20
+
21
+ router = APIRouter(prefix="/api", tags=["visualization"])
22
+
23
+ _PROJECT = Path(__file__).resolve().parents[2]
24
+ _ARTIFACTS = _PROJECT / "artifacts"
25
+ _FIGURES = _ARTIFACTS / "figures"
26
+ _DATASET = _PROJECT / "cleaned_dataset"
27
+ _V2_RESULTS = _ARTIFACTS / "v2" / "results"
28
+ _V2_REPORTS = _ARTIFACTS / "v2" / "reports"
29
+ _V2_FIGURES = _ARTIFACTS / "v2" / "figures"
30
+
31
+
32
+ # ── Dashboard aggregate ──────────────────────────────────────────────────────
33
+ @router.get("/dashboard", response_model=DashboardData)
34
+ async def dashboard():
35
+ """Return full dashboard payload for the frontend."""
36
+ # Battery summary
37
+ metadata_path = _DATASET / "metadata.csv"
38
+ batteries: list[BatteryVizData] = []
39
+ capacity_fade: dict[str, list[float]] = {}
40
+
41
+ if metadata_path.exists():
42
+ meta = pd.read_csv(metadata_path)
43
+ for bid in meta["battery_id"].unique():
44
+ sub = meta[meta["battery_id"] == bid].sort_values("start_time")
45
+ caps_s = pd.to_numeric(sub["Capacity"], errors="coerce").dropna()
46
+ if caps_s.empty:
47
+ continue
48
+ caps = caps_s.tolist()
49
+ last_cap = float(caps[-1])
50
+ soh = (last_cap / 2.0) * 100
51
+ avg_temp = float(sub["ambient_temperature"].mean())
52
+ cycle = len(sub)
53
+ batteries.append(BatteryVizData(
54
+ battery_id=bid,
55
+ soh_pct=round(soh, 1),
56
+ temperature=round(avg_temp, 1),
57
+ cycle_number=cycle,
58
+ degradation_state=classify_degradation(soh),
59
+ color_hex=soh_to_color(soh),
60
+ ))
61
+ capacity_fade[bid] = caps
62
+
63
+ model_metrics = registry.get_metrics()
64
+ # Find best model
65
+ best_model = "none"
66
+ best_r2 = -999
67
+ for name, m in model_metrics.items():
68
+ r2 = m.get("R2", -999)
69
+ if r2 > best_r2:
70
+ best_r2 = r2
71
+ best_model = name
72
+
73
+ return DashboardData(
74
+ batteries=batteries,
75
+ capacity_fade=capacity_fade,
76
+ model_metrics=model_metrics,
77
+ best_model=best_model,
78
+ )
79
+
80
+
81
+ # ── Capacity fade for a specific battery ─────────────────────────────────────
82
+ @router.get("/battery/{battery_id}/capacity")
83
+ async def battery_capacity(battery_id: str):
84
+ """Return cycle-by-cycle capacity for one battery."""
85
+ meta_path = _DATASET / "metadata.csv"
86
+ if not meta_path.exists():
87
+ raise HTTPException(404, "Metadata not found")
88
+ meta = pd.read_csv(meta_path)
89
+ sub = meta[meta["battery_id"] == battery_id].sort_values("start_time")
90
+ if sub.empty:
91
+ raise HTTPException(404, f"Battery {battery_id} not found")
92
+ caps = pd.to_numeric(sub["Capacity"], errors="coerce").dropna().tolist()
93
+ cycles = list(range(1, len(caps) + 1))
94
+ soh_list = [(float(c) / 2.0) * 100 for c in caps]
95
+ return {"battery_id": battery_id, "cycles": cycles, "capacity_ah": caps, "soh_pct": soh_list}
96
+
97
+
98
+ # ── Serve saved figures ──────────────────────────────────────────────────────
99
+ @router.get("/figures/{filename}")
100
+ async def get_figure(filename: str):
101
+ """Serve a saved matplotlib/plotly figure from artifacts/figures."""
102
+ path = _FIGURES / filename
103
+ if not path.exists():
104
+ raise HTTPException(404, f"Figure {filename} not found")
105
+ content_type = "image/png"
106
+ if path.suffix == ".html":
107
+ content_type = "text/html"
108
+ elif path.suffix == ".svg":
109
+ content_type = "image/svg+xml"
110
+ return FileResponse(path, media_type=content_type)
111
+
112
+
113
+ # ── Figures listing ──────────────────────────────────────────────────────────
114
+ @router.get("/figures")
115
+ async def list_figures():
116
+ """List all available figures."""
117
+ if not _FIGURES.exists():
118
+ return []
119
+ return sorted([f.name for f in _FIGURES.iterdir() if f.is_file()])
120
+
121
+
122
+ # ── Battery list ─────────────────────────────────────────────────────────────
123
+ @router.get("/batteries")
124
+ async def list_batteries():
125
+ """Return all battery IDs and basic stats."""
126
+ meta_path = _DATASET / "metadata.csv"
127
+ if not meta_path.exists():
128
+ return []
129
+ meta = pd.read_csv(meta_path)
130
+ out = []
131
+ for bid in sorted(meta["battery_id"].unique()):
132
+ sub = meta[meta["battery_id"] == bid]
133
+ caps = pd.to_numeric(sub["Capacity"], errors="coerce").dropna()
134
+ out.append({
135
+ "battery_id": bid,
136
+ "n_cycles": len(sub),
137
+ "last_capacity": round(float(caps.iloc[-1]), 4) if len(caps) else None,
138
+ "soh_pct": round((float(caps.iloc[-1]) / 2.0) * 100, 1) if len(caps) else None,
139
+ "ambient_temperature": round(float(sub["ambient_temperature"].mean()), 1),
140
+ })
141
+ return out
142
+
143
+
144
+ # ── Comprehensive metrics endpoint ───────────────────────────────────────────
145
+ def _safe_read_csv(path: Path) -> list[dict]:
146
+ """Read a CSV file into a list of dicts, replacing NaN with None."""
147
+ if not path.exists():
148
+ return []
149
+ df = pd.read_csv(path)
150
+ return json.loads(df.to_json(orient="records"))
151
+
152
+
153
+ def _safe_read_json(path: Path) -> dict:
154
+ """Read a JSON file, returning empty dict on failure."""
155
+ if not path.exists():
156
+ return {}
157
+ with open(path) as f:
158
+ return json.load(f)
159
+
160
+
161
+ @router.get("/metrics")
162
+ async def get_metrics():
163
+ """Return comprehensive model metrics data from v2 artifacts for the Metrics dashboard."""
164
+ # Unified results (all models)
165
+ unified = _safe_read_csv(_V2_RESULTS / "unified_results.csv")
166
+ # Classical results (v2 retrained)
167
+ classical_v2 = _safe_read_csv(_V2_RESULTS / "v2_classical_results.csv")
168
+ # Classical SOH results (v1)
169
+ classical_soh = _safe_read_csv(_V2_RESULTS / "classical_soh_results.csv")
170
+ # LSTM results
171
+ lstm_results = _safe_read_csv(_V2_RESULTS / "lstm_soh_results.csv")
172
+ # Ensemble results
173
+ ensemble_results = _safe_read_csv(_V2_RESULTS / "ensemble_results.csv")
174
+ # Transformer results
175
+ transformer_results = _safe_read_csv(_V2_RESULTS / "transformer_soh_results.csv")
176
+ # Validation
177
+ validation = _safe_read_csv(_V2_RESULTS / "v2_model_validation.csv")
178
+ # Final rankings
179
+ rankings = _safe_read_csv(_V2_RESULTS / "final_rankings.csv")
180
+ # Classical RUL results
181
+ classical_rul = _safe_read_csv(_V2_RESULTS / "classical_rul_results.csv")
182
+
183
+ # JSON summaries
184
+ training_summary = _safe_read_json(_V2_RESULTS / "v2_training_summary.json")
185
+ validation_summary = _safe_read_json(_V2_RESULTS / "v2_validation_summary.json")
186
+ intra_battery = _safe_read_json(_V2_RESULTS / "v2_intra_battery.json")
187
+ vae_lstm = _safe_read_json(_V2_RESULTS / "vae_lstm_results.json")
188
+ dg_itransformer = _safe_read_json(_V2_RESULTS / "dg_itransformer_results.json")
189
+
190
+ # Available v2 figures
191
+ v2_figures = []
192
+ if _V2_FIGURES.exists():
193
+ v2_figures = sorted([f.name for f in _V2_FIGURES.iterdir() if f.is_file() and f.suffix in ('.png', '.svg')])
194
+
195
+ # Battery features summary
196
+ features_path = _V2_RESULTS / "battery_features.csv"
197
+ battery_stats = {}
198
+ if features_path.exists():
199
+ df = pd.read_csv(features_path)
200
+ battery_stats = {
201
+ "total_samples": len(df),
202
+ "batteries": int(df["battery_id"].nunique()),
203
+ "avg_soh": round(float(df["SoH"].mean()), 2),
204
+ "min_soh": round(float(df["SoH"].min()), 2),
205
+ "max_soh": round(float(df["SoH"].max()), 2),
206
+ "avg_rul": round(float(df["RUL"].mean()), 1),
207
+ "feature_columns": [c for c in df.columns.tolist() if c not in ["battery_id", "datetime"]],
208
+ "degradation_distribution": json.loads(df["degradation_state"].value_counts().to_json()),
209
+ "temp_groups": sorted(df["ambient_temperature"].unique().tolist()),
210
+ }
211
+
212
+ return {
213
+ "unified_results": unified,
214
+ "classical_v2": classical_v2,
215
+ "classical_soh": classical_soh,
216
+ "lstm_results": lstm_results,
217
+ "ensemble_results": ensemble_results,
218
+ "transformer_results": transformer_results,
219
+ "validation": validation,
220
+ "rankings": rankings,
221
+ "classical_rul": classical_rul,
222
+ "training_summary": training_summary,
223
+ "validation_summary": validation_summary,
224
+ "intra_battery": intra_battery,
225
+ "vae_lstm": vae_lstm,
226
+ "dg_itransformer": dg_itransformer,
227
+ "v2_figures": v2_figures,
228
+ "battery_stats": battery_stats,
229
+ }
230
+
231
+
232
+ @router.get("/v2/figures/{filename}")
233
+ async def get_v2_figure(filename: str):
234
+ """Serve a saved figure from artifacts/v2/figures."""
235
+ path = _V2_FIGURES / filename
236
+ if not path.exists():
237
+ raise HTTPException(404, f"Figure {filename} not found")
238
+ content_type = "image/png"
239
+ if path.suffix == ".html":
240
+ content_type = "text/html"
241
+ elif path.suffix == ".svg":
242
+ content_type = "image/svg+xml"
243
+ return FileResponse(path, media_type=content_type)
api/schemas.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ api.schemas
3
+ ===========
4
+ Pydantic models for request / response validation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pydantic import BaseModel, Field
10
+ from typing import Optional
11
+
12
+
13
+ # ── Prediction ───────────────────────────────────────────────────────────────
14
+ class PredictRequest(BaseModel):
15
+ """Request body for single-cycle prediction."""
16
+ battery_id: str = Field(..., description="Battery identifier, e.g. 'B0005'")
17
+ cycle_number: int = Field(..., ge=1, description="Current cycle number")
18
+ ambient_temperature: float = Field(default=24.0, description="Ambient temperature (°C)")
19
+ peak_voltage: float = Field(default=4.19, description="Peak charge voltage (V)")
20
+ min_voltage: float = Field(default=2.61, description="Discharge cutoff voltage (V)")
21
+ avg_current: float = Field(default=1.82, description="Average discharge current (A)")
22
+ avg_temp: float = Field(default=32.6, description="Average cell temperature (°C) — typically higher than ambient")
23
+ temp_rise: float = Field(default=14.7, description="Temperature rise during cycle (°C) — NASA dataset mean ≈ 15°C")
24
+ cycle_duration: float = Field(default=3690.0, description="Cycle duration (seconds)")
25
+ Re: float = Field(default=0.045, description="Electrolyte resistance (Ω) — training range 0.027–0.156")
26
+ Rct: float = Field(default=0.069, description="Charge transfer resistance (Ω) — training range 0.04–0.27")
27
+ delta_capacity: float = Field(default=-0.005, description="Capacity change from last cycle (Ah)")
28
+
29
+
30
+ class PredictResponse(BaseModel):
31
+ """Response body for a prediction."""
32
+ battery_id: str
33
+ cycle_number: int
34
+ soh_pct: float = Field(..., description="Predicted State of Health (%)")
35
+ rul_cycles: Optional[float] = Field(None, description="Predicted Remaining Useful Life (cycles)")
36
+ degradation_state: str = Field(..., description="Degradation state label")
37
+ confidence_lower: Optional[float] = None
38
+ confidence_upper: Optional[float] = None
39
+ model_used: str = Field(..., description="Name of the model that produced the prediction")
40
+ model_version: Optional[str] = Field(None, description="Semantic version of the model used")
41
+
42
+
43
+ # ── Batch Prediction ─────────────────────────────────────────────────────────
44
+ class BatchPredictRequest(BaseModel):
45
+ """Batch prediction for multiple cycles of one battery."""
46
+ battery_id: str
47
+ cycles: list[dict] = Field(..., description="List of cycle feature dicts")
48
+
49
+
50
+ class BatchPredictResponse(BaseModel):
51
+ """Batch prediction response."""
52
+ battery_id: str
53
+ predictions: list[PredictResponse]
54
+
55
+
56
+ # ── Recommendation ───────────────────────────────────────────────────────────
57
+ class RecommendationRequest(BaseModel):
58
+ """Request for operational recommendations."""
59
+ battery_id: str
60
+ current_cycle: int = Field(..., ge=1)
61
+ current_soh: float = Field(..., ge=0, le=100)
62
+ ambient_temperature: float = Field(default=24.0)
63
+ top_k: int = Field(default=5, ge=1, le=20)
64
+
65
+
66
+ class SingleRecommendation(BaseModel):
67
+ """One recommendation entry."""
68
+ rank: int
69
+ ambient_temperature: float
70
+ discharge_current: float
71
+ cutoff_voltage: float
72
+ predicted_rul: float
73
+ rul_improvement: float
74
+ rul_improvement_pct: float
75
+ explanation: str
76
+
77
+
78
+ class RecommendationResponse(BaseModel):
79
+ """Response with ranked recommendations."""
80
+ battery_id: str
81
+ current_soh: float
82
+ recommendations: list[SingleRecommendation]
83
+
84
+
85
+ # ── Model info ───────────────────────────────────────────────────────────────
86
+ class ModelInfo(BaseModel):
87
+ """Info about a registered model."""
88
+ name: str
89
+ version: Optional[str] = None
90
+ display_name: Optional[str] = None
91
+ family: str
92
+ algorithm: Optional[str] = None
93
+ target: str
94
+ r2: Optional[float] = None
95
+ metrics: dict[str, float]
96
+ is_default: bool = False
97
+ loaded: bool = True
98
+ load_error: Optional[str] = None
99
+
100
+
101
+ class HealthResponse(BaseModel):
102
+ """Health check response."""
103
+ status: str = "ok"
104
+ version: str
105
+ models_loaded: int
106
+ device: str
107
+
108
+
109
+ # ── Visualization ────────────────────────────────────────────────────────────
110
+ class BatteryVizData(BaseModel):
111
+ """Data for 3D battery visualization."""
112
+ battery_id: str
113
+ soh_pct: float
114
+ temperature: float
115
+ cycle_number: int
116
+ degradation_state: str
117
+ color_hex: str = Field(..., description="Color hex code for SOH heatmap")
118
+
119
+
120
+ class DashboardData(BaseModel):
121
+ """Full dashboard payload."""
122
+ batteries: list[BatteryVizData]
123
+ capacity_fade: dict[str, list[float]]
124
+ model_metrics: dict[str, dict[str, float]]
125
+ best_model: str
artifacts/v1/results/classical_rul_results.csv ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ model,MAE,MSE,RMSE,R2,MAPE,tolerance_acc_5cyc
2
+ RandomForest,8.942333980582523,137.23446964660195,11.714711675777682,-0.15748641443851108,120.88277153106779,0.4058252427184466
3
+ XGBoost,6.7578043937683105,74.80780029296875,8.649150264214905,0.3690432906150818,90.60895087232073,0.429126213592233
4
+ LightGBM,9.174716686966466,125.80377799208308,11.216228331845027,-0.06107572161612751,112.25501529299473,0.35145631067961164
artifacts/v1/results/classical_soh_results.csv ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model,MAE,MSE,RMSE,R2,MAPE,tolerance_acc_2pct
2
+ RandomForest,4.78051739475878,41.771168089034525,6.463061819991708,0.956679157080545,28.807727531282236,0.29514563106796116
3
+ LightGBM,6.909989455704782,89.23030742727897,9.446179514876846,0.9074593240133356,56.94413475231037,0.24854368932038834
4
+ XGBoost,8.506303251021016,136.22476526016746,11.67153654238239,0.8587214117403497,72.81562821369698,0.22330097087378642
5
+ SVR,7.562130215902278,187.88576938734298,13.707143006014892,0.805143828272144,97.82839183781172,0.32233009708737864
6
+ KNN-10,11.665738368704334,265.7682207905257,16.302399234177948,0.7243720041223407,89.92209971367193,0.26019417475728157
7
+ KNN-5,11.751580021863887,270.3768092061512,16.44313866651228,0.7195924409938166,88.41470284019084,0.2757281553398058
8
+ KNN-20,12.035078655061884,272.741740151536,16.514894494108525,0.71713977312056,101.98653839713165,0.2407766990291262
9
+ ElasticNet,15.796038594619795,460.3763913475375,21.45638346384445,0.5225440358554927,144.4728811908027,0.06407766990291262
10
+ Lasso,15.830927911593506,462.77574982037044,21.512223265398916,0.5200556632227833,145.46522399976544,0.05242718446601942
11
+ Ridge,15.860549029501184,464.1921925903698,21.545119925179574,0.5185866716299998,145.65718178268125,0.05048543689320388
artifacts/v1/results/dg_itransformer_results.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "MAE": 12.886456320718098,
3
+ "MSE": 323.38418900793243,
4
+ "RMSE": 17.982886003306934,
5
+ "R2": 0.12310012848141882,
6
+ "MAPE": 69.98234771512423,
7
+ "tol_2pct": 0.04827586206896552,
8
+ "tol_5pct": 0.2896551724137931
9
+ }
artifacts/v1/results/ensemble_results.csv ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ model,MAE,MSE,RMSE,R2,MAPE,tol_2pct
2
+ Weighted Avg Ensemble,3.737822790616429,37.739016006374186,6.1432089339671805,0.8976655649469139,5.478476687258817,0.3482758620689655
3
+ tft,4.732738753085752,46.68825874782447,6.832880706394959,0.8733984854887593,7.499287950788588,0.21379310344827587
4
+ Stacking Ensemble,5.76908337148985,60.03540223313905,7.7482515597481125,0.8372059046352616,10.924057540803656,0.12413793103448276
5
+ vae_lstm,8.494939970437716,100.78674732190278,10.039260297546965,0.7267031327397879,14.250142040133243,0.09310344827586207
6
+ batterygpt,8.020673063309337,129.06954589210812,11.360877866261397,0.6500105074494692,12.874349389916773,0.28620689655172415
7
+ vanilla_lstm,10.561354979478219,155.95773910638056,12.488304092485118,0.5770995427937917,14.375050332959946,0.15862068965517243
8
+ bidirectional_lstm,11.134867385317946,167.3343794115467,12.935779041540046,0.5462502472468515,17.124593156141298,0.10689655172413794
9
+ attention_lstm,14.181327172488002,288.23989200564256,16.977629163273726,0.21839863277892768,24.827876450140888,0.15862068965517243
artifacts/v1/results/final_rankings.csv ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model,MAE,MSE,RMSE,R2,MAPE,tolerance_acc_2pct,tol_2pct,tol_5pct
2
+ RandomForest,4.78051739475878,41.771168089034525,6.463061819991708,0.956679157080545,28.80772753128224,0.2951456310679611,,
3
+ LightGBM,6.909989455704782,89.23030742727897,9.446179514876846,0.9074593240133356,56.94413475231037,0.2485436893203883,,
4
+ Weighted Avg Ensemble,3.737822790616429,37.739016006374186,6.1432089339671805,0.8976655649469139,5.478476687258817,,0.3482758620689655,
5
+ TFT,4.732738753085752,46.68825874782447,6.832880706394959,0.8733984854887593,7.499287950788588,,0.2137931034482758,
6
+ XGBoost,8.506303251021016,136.22476526016746,11.67153654238239,0.8587214117403497,72.81562821369698,0.2233009708737864,,
7
+ Stacking Ensemble,5.76908337148985,60.03540223313905,7.748251559748112,0.8372059046352616,10.924057540803656,,0.1241379310344827,
8
+ SVR,7.562130215902278,187.88576938734295,13.707143006014892,0.805143828272144,97.82839183781172,0.3223300970873786,,
9
+ VAE-LSTM,8.494939970437716,100.78674732190278,10.039260297546965,0.7267031327397879,14.250142040133243,,0.09310344827586207,
10
+ KNN-10,11.665738368704334,265.7682207905257,16.302399234177948,0.7243720041223407,89.92209971367193,0.2601941747572815,,
11
+ KNN-5,11.751580021863887,270.3768092061512,16.44313866651228,0.7195924409938166,88.41470284019084,0.2757281553398058,,
12
+ KNN-20,12.035078655061884,272.741740151536,16.514894494108525,0.71713977312056,101.98653839713164,0.2407766990291262,,
13
+ BatteryGPT,8.020673063309337,129.06954589210812,11.360877866261395,0.6500105074494692,12.874349389916771,,0.2862068965517241,
14
+ GRU,9.275809104339835,134.65458890701777,11.604076391812397,0.6348659095727935,15.248492181524448,0.193103448275862,,
15
+ Vanilla LSTM,10.56135497947822,155.95773910638056,12.488304092485118,0.5770995427937917,14.375050332959946,0.1586206896551724,,
16
+ Bidirectional LSTM,11.134867385317946,167.3343794115467,12.935779041540046,0.5462502472468515,17.124593156141298,0.1068965517241379,,
17
+ ElasticNet,15.796038594619796,460.3763913475375,21.45638346384445,0.5225440358554927,144.4728811908027,0.0640776699029126,,
18
+ Lasso,15.830927911593506,462.7757498203704,21.51222326539892,0.5200556632227833,145.46522399976544,0.0524271844660194,,
19
+ Ridge,15.860549029501184,464.1921925903698,21.545119925179574,0.5185866716299998,145.65718178268125,0.0504854368932038,,
20
+ iTransformer,14.544141949115827,275.09890344946007,16.58610573490535,0.2540321967199925,73.33406651321724,,0.0172413793103448,
21
+ Attention LSTM,14.181327172488002,288.23989200564256,16.977629163273726,0.2183986327789276,24.827876450140888,0.1586206896551724,,
22
+ DG-iTransformer,14.183794754173379,313.5635374005159,17.707725359303375,0.1497301506825386,94.26231665878274,,0.1103448275862069,0.2
23
+ Physics iTransformer,16.41156271618159,379.65023683665015,19.48461538847124,-0.0294728537142276,97.60205959509742,,0.0,
artifacts/v1/results/lstm_soh_results.csv ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ model,MAE,MSE,RMSE,R2,MAPE,tolerance_acc_2pct
2
+ GRU,9.275809104339837,134.65458890701777,11.604076391812395,0.6348659095727935,15.248492181524448,0.19310344827586207
3
+ Vanilla LSTM,10.561354979478219,155.95773910638056,12.488304092485118,0.5770995427937917,14.375050332959946,0.15862068965517243
4
+ Bidirectional LSTM,11.134867385317946,167.3343794115467,12.935779041540046,0.5462502472468515,17.124593156141298,0.10689655172413794
5
+ Attention LSTM,14.181327172488002,288.23989200564256,16.977629163273726,0.21839863277892768,24.827876450140888,0.15862068965517243
artifacts/v1/results/transformer_soh_results.csv ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ model,MAE,MSE,RMSE,R2,MAPE,tol_2pct
2
+ TFT,9.538144323660262,127.31655440584875,11.283463759229644,0.6547639804432781,18.98017853690174,0.05517241379310345
3
+ BatteryGPT,9.614953606189902,135.34729964687295,11.633885836076997,0.6329875309153767,15.44417472623618,0.1793103448275862
4
+ iTransformer,9.356759048993766,150.35062176683638,12.261754432659153,0.5923039981808029,14.4907916127095,0.1310344827586207
5
+ Physics iTransformer,12.1375468649355,214.65431642999653,14.651085844741901,0.4179358518552816,24.985288391381786,0.07931034482758621
artifacts/v1/results/unified_results.csv ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model,MAE,MSE,RMSE,R2,MAPE,tolerance_acc_2pct,tol_2pct,tol_5pct
2
+ RandomForest,4.78051739475878,41.771168089034525,6.463061819991708,0.956679157080545,28.80772753128224,0.2951456310679611,,
3
+ LightGBM,6.909989455704782,89.23030742727897,9.446179514876846,0.9074593240133356,56.94413475231037,0.2485436893203883,,
4
+ Weighted Avg Ensemble,3.737822790616429,37.739016006374186,6.1432089339671805,0.8976655649469139,5.478476687258817,,0.3482758620689655,
5
+ TFT,4.732738753085752,46.68825874782447,6.832880706394959,0.8733984854887593,7.499287950788588,,0.2137931034482758,
6
+ XGBoost,8.506303251021016,136.22476526016746,11.67153654238239,0.8587214117403497,72.81562821369698,0.2233009708737864,,
7
+ Stacking Ensemble,5.76908337148985,60.03540223313905,7.748251559748112,0.8372059046352616,10.924057540803656,,0.1241379310344827,
8
+ SVR,7.562130215902278,187.88576938734295,13.707143006014892,0.805143828272144,97.82839183781172,0.3223300970873786,,
9
+ VAE-LSTM,8.494939970437716,100.78674732190278,10.039260297546965,0.7267031327397879,14.250142040133243,,0.09310344827586207,
10
+ KNN-10,11.665738368704334,265.7682207905257,16.302399234177948,0.7243720041223407,89.92209971367193,0.2601941747572815,,
11
+ KNN-5,11.751580021863887,270.3768092061512,16.44313866651228,0.7195924409938166,88.41470284019084,0.2757281553398058,,
12
+ KNN-20,12.035078655061884,272.741740151536,16.514894494108525,0.71713977312056,101.98653839713164,0.2407766990291262,,
13
+ BatteryGPT,8.020673063309337,129.06954589210812,11.360877866261395,0.6500105074494692,12.874349389916771,,0.2862068965517241,
14
+ GRU,9.275809104339835,134.65458890701777,11.604076391812397,0.6348659095727935,15.248492181524448,0.193103448275862,,
15
+ Vanilla LSTM,10.56135497947822,155.95773910638056,12.488304092485118,0.5770995427937917,14.375050332959946,0.1586206896551724,,
16
+ Bidirectional LSTM,11.134867385317946,167.3343794115467,12.935779041540046,0.5462502472468515,17.124593156141298,0.1068965517241379,,
17
+ ElasticNet,15.796038594619796,460.3763913475375,21.45638346384445,0.5225440358554927,144.4728811908027,0.0640776699029126,,
18
+ Lasso,15.830927911593506,462.7757498203704,21.51222326539892,0.5200556632227833,145.46522399976544,0.0524271844660194,,
19
+ Ridge,15.860549029501184,464.1921925903698,21.545119925179574,0.5185866716299998,145.65718178268125,0.0504854368932038,,
20
+ iTransformer,14.544141949115827,275.09890344946007,16.58610573490535,0.2540321967199925,73.33406651321724,,0.0172413793103448,
21
+ Attention LSTM,14.181327172488002,288.23989200564256,16.977629163273726,0.2183986327789276,24.827876450140888,0.1586206896551724,,
22
+ DG-iTransformer,14.183794754173379,313.5635374005159,17.707725359303375,0.1497301506825386,94.26231665878274,,0.1103448275862069,0.2
23
+ Physics iTransformer,16.41156271618159,379.65023683665015,19.48461538847124,-0.0294728537142276,97.60205959509742,,0.0,
artifacts/v1/results/vae_lstm_results.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "MAE": 8.494939970437716,
3
+ "MSE": 100.78674732190278,
4
+ "RMSE": 10.039260297546965,
5
+ "R2": 0.7267031327397879,
6
+ "MAPE": 14.250142040133243,
7
+ "tol_2pct": 0.09310344827586207
8
+ }
artifacts/v2/results/battery_features.csv ADDED
The diff for this file is too large to render. See raw diff
 
artifacts/v2/results/classical_rul_results.csv ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ model,MAE,MSE,RMSE,R2,MAPE,tolerance_acc_5cyc
2
+ RandomForest,8.942333980582523,137.23446964660195,11.714711675777682,-0.15748641443851108,120.88277153106779,0.4058252427184466
3
+ XGBoost,6.7578043937683105,74.80780029296875,8.649150264214905,0.3690432906150818,90.60895087232073,0.429126213592233
4
+ LightGBM,9.174716686966466,125.80377799208308,11.216228331845027,-0.06107572161612751,112.25501529299473,0.35145631067961164
artifacts/v2/results/classical_soh_results.csv ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model,MAE,MSE,RMSE,R2,MAPE,tolerance_acc_2pct
2
+ RandomForest,4.78051739475878,41.771168089034525,6.463061819991708,0.956679157080545,28.807727531282236,0.29514563106796116
3
+ LightGBM,6.909989455704782,89.23030742727897,9.446179514876846,0.9074593240133356,56.94413475231037,0.24854368932038834
4
+ XGBoost,8.506303251021016,136.22476526016746,11.67153654238239,0.8587214117403497,72.81562821369698,0.22330097087378642
5
+ SVR,7.562130215902278,187.88576938734298,13.707143006014892,0.805143828272144,97.82839183781172,0.32233009708737864
6
+ KNN-10,11.665738368704334,265.7682207905257,16.302399234177948,0.7243720041223407,89.92209971367193,0.26019417475728157
7
+ KNN-5,11.751580021863887,270.3768092061512,16.44313866651228,0.7195924409938166,88.41470284019084,0.2757281553398058
8
+ KNN-20,12.035078655061884,272.741740151536,16.514894494108525,0.71713977312056,101.98653839713165,0.2407766990291262
9
+ ElasticNet,15.796038594619795,460.3763913475375,21.45638346384445,0.5225440358554927,144.4728811908027,0.06407766990291262
10
+ Lasso,15.830927911593506,462.77574982037044,21.512223265398916,0.5200556632227833,145.46522399976544,0.05242718446601942
11
+ Ridge,15.860549029501184,464.1921925903698,21.545119925179574,0.5185866716299998,145.65718178268125,0.05048543689320388
artifacts/v2/results/dg_itransformer_results.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "MAE": 12.886456320718098,
3
+ "MSE": 323.38418900793243,
4
+ "RMSE": 17.982886003306934,
5
+ "R2": 0.12310012848141882,
6
+ "MAPE": 69.98234771512423,
7
+ "tol_2pct": 0.04827586206896552,
8
+ "tol_5pct": 0.2896551724137931
9
+ }
artifacts/v2/results/ensemble_results.csv ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ model,MAE,MSE,RMSE,R2,MAPE,tol_2pct
2
+ Weighted Avg Ensemble,3.737822790616429,37.739016006374186,6.1432089339671805,0.8976655649469139,5.478476687258817,0.3482758620689655
3
+ tft,4.732738753085752,46.68825874782447,6.832880706394959,0.8733984854887593,7.499287950788588,0.21379310344827587
4
+ Stacking Ensemble,5.76908337148985,60.03540223313905,7.7482515597481125,0.8372059046352616,10.924057540803656,0.12413793103448276
5
+ vae_lstm,8.494939970437716,100.78674732190278,10.039260297546965,0.7267031327397879,14.250142040133243,0.09310344827586207
6
+ batterygpt,8.020673063309337,129.06954589210812,11.360877866261397,0.6500105074494692,12.874349389916773,0.28620689655172415
7
+ vanilla_lstm,10.561354979478219,155.95773910638056,12.488304092485118,0.5770995427937917,14.375050332959946,0.15862068965517243
8
+ bidirectional_lstm,11.134867385317946,167.3343794115467,12.935779041540046,0.5462502472468515,17.124593156141298,0.10689655172413794
9
+ attention_lstm,14.181327172488002,288.23989200564256,16.977629163273726,0.21839863277892768,24.827876450140888,0.15862068965517243
artifacts/v2/results/final_rankings.csv ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model,MAE,MSE,RMSE,R2,MAPE,tolerance_acc_2pct,tol_2pct,tol_5pct
2
+ RandomForest,4.78051739475878,41.771168089034525,6.463061819991708,0.956679157080545,28.80772753128224,0.2951456310679611,,
3
+ LightGBM,6.909989455704782,89.23030742727897,9.446179514876846,0.9074593240133356,56.94413475231037,0.2485436893203883,,
4
+ Weighted Avg Ensemble,3.737822790616429,37.739016006374186,6.1432089339671805,0.8976655649469139,5.478476687258817,,0.3482758620689655,
5
+ TFT,4.732738753085752,46.68825874782447,6.832880706394959,0.8733984854887593,7.499287950788588,,0.2137931034482758,
6
+ XGBoost,8.506303251021016,136.22476526016746,11.67153654238239,0.8587214117403497,72.81562821369698,0.2233009708737864,,
7
+ Stacking Ensemble,5.76908337148985,60.03540223313905,7.748251559748112,0.8372059046352616,10.924057540803656,,0.1241379310344827,
8
+ SVR,7.562130215902278,187.88576938734295,13.707143006014892,0.805143828272144,97.82839183781172,0.3223300970873786,,
9
+ VAE-LSTM,8.494939970437716,100.78674732190278,10.039260297546965,0.7267031327397879,14.250142040133243,,0.09310344827586207,
10
+ KNN-10,11.665738368704334,265.7682207905257,16.302399234177948,0.7243720041223407,89.92209971367193,0.2601941747572815,,
11
+ KNN-5,11.751580021863887,270.3768092061512,16.44313866651228,0.7195924409938166,88.41470284019084,0.2757281553398058,,
12
+ KNN-20,12.035078655061884,272.741740151536,16.514894494108525,0.71713977312056,101.98653839713164,0.2407766990291262,,
13
+ BatteryGPT,8.020673063309337,129.06954589210812,11.360877866261395,0.6500105074494692,12.874349389916771,,0.2862068965517241,
14
+ GRU,9.275809104339835,134.65458890701777,11.604076391812397,0.6348659095727935,15.248492181524448,0.193103448275862,,
15
+ Vanilla LSTM,10.56135497947822,155.95773910638056,12.488304092485118,0.5770995427937917,14.375050332959946,0.1586206896551724,,
16
+ Bidirectional LSTM,11.134867385317946,167.3343794115467,12.935779041540046,0.5462502472468515,17.124593156141298,0.1068965517241379,,
17
+ ElasticNet,15.796038594619796,460.3763913475375,21.45638346384445,0.5225440358554927,144.4728811908027,0.0640776699029126,,
18
+ Lasso,15.830927911593506,462.7757498203704,21.51222326539892,0.5200556632227833,145.46522399976544,0.0524271844660194,,
19
+ Ridge,15.860549029501184,464.1921925903698,21.545119925179574,0.5185866716299998,145.65718178268125,0.0504854368932038,,
20
+ iTransformer,14.544141949115827,275.09890344946007,16.58610573490535,0.2540321967199925,73.33406651321724,,0.0172413793103448,
21
+ Attention LSTM,14.181327172488002,288.23989200564256,16.977629163273726,0.2183986327789276,24.827876450140888,0.1586206896551724,,
22
+ DG-iTransformer,14.183794754173379,313.5635374005159,17.707725359303375,0.1497301506825386,94.26231665878274,,0.1103448275862069,0.2
23
+ Physics iTransformer,16.41156271618159,379.65023683665015,19.48461538847124,-0.0294728537142276,97.60205959509742,,0.0,
artifacts/v2/results/lstm_soh_results.csv ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ model,MAE,MSE,RMSE,R2,MAPE,tolerance_acc_2pct
2
+ GRU,9.275809104339837,134.65458890701777,11.604076391812395,0.6348659095727935,15.248492181524448,0.19310344827586207
3
+ Vanilla LSTM,10.561354979478219,155.95773910638056,12.488304092485118,0.5770995427937917,14.375050332959946,0.15862068965517243
4
+ Bidirectional LSTM,11.134867385317946,167.3343794115467,12.935779041540046,0.5462502472468515,17.124593156141298,0.10689655172413794
5
+ Attention LSTM,14.181327172488002,288.23989200564256,16.977629163273726,0.21839863277892768,24.827876450140888,0.15862068965517243
artifacts/v2/results/transformer_soh_results.csv ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ model,MAE,MSE,RMSE,R2,MAPE,tol_2pct
2
+ TFT,9.538144323660262,127.31655440584875,11.283463759229644,0.6547639804432781,18.98017853690174,0.05517241379310345
3
+ BatteryGPT,9.614953606189902,135.34729964687295,11.633885836076997,0.6329875309153767,15.44417472623618,0.1793103448275862
4
+ iTransformer,9.356759048993766,150.35062176683638,12.261754432659153,0.5923039981808029,14.4907916127095,0.1310344827586207
5
+ Physics iTransformer,12.1375468649355,214.65431642999653,14.651085844741901,0.4179358518552816,24.985288391381786,0.07931034482758621
artifacts/v2/results/unified_results.csv ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model,MAE,MSE,RMSE,R2,MAPE,tolerance_acc_2pct,tol_2pct,tol_5pct
2
+ RandomForest,4.78051739475878,41.771168089034525,6.463061819991708,0.956679157080545,28.80772753128224,0.2951456310679611,,
3
+ LightGBM,6.909989455704782,89.23030742727897,9.446179514876846,0.9074593240133356,56.94413475231037,0.2485436893203883,,
4
+ Weighted Avg Ensemble,3.737822790616429,37.739016006374186,6.1432089339671805,0.8976655649469139,5.478476687258817,,0.3482758620689655,
5
+ TFT,4.732738753085752,46.68825874782447,6.832880706394959,0.8733984854887593,7.499287950788588,,0.2137931034482758,
6
+ XGBoost,8.506303251021016,136.22476526016746,11.67153654238239,0.8587214117403497,72.81562821369698,0.2233009708737864,,
7
+ Stacking Ensemble,5.76908337148985,60.03540223313905,7.748251559748112,0.8372059046352616,10.924057540803656,,0.1241379310344827,
8
+ SVR,7.562130215902278,187.88576938734295,13.707143006014892,0.805143828272144,97.82839183781172,0.3223300970873786,,
9
+ VAE-LSTM,8.494939970437716,100.78674732190278,10.039260297546965,0.7267031327397879,14.250142040133243,,0.09310344827586207,
10
+ KNN-10,11.665738368704334,265.7682207905257,16.302399234177948,0.7243720041223407,89.92209971367193,0.2601941747572815,,
11
+ KNN-5,11.751580021863887,270.3768092061512,16.44313866651228,0.7195924409938166,88.41470284019084,0.2757281553398058,,
12
+ KNN-20,12.035078655061884,272.741740151536,16.514894494108525,0.71713977312056,101.98653839713164,0.2407766990291262,,
13
+ BatteryGPT,8.020673063309337,129.06954589210812,11.360877866261395,0.6500105074494692,12.874349389916771,,0.2862068965517241,
14
+ GRU,9.275809104339835,134.65458890701777,11.604076391812397,0.6348659095727935,15.248492181524448,0.193103448275862,,
15
+ Vanilla LSTM,10.56135497947822,155.95773910638056,12.488304092485118,0.5770995427937917,14.375050332959946,0.1586206896551724,,
16
+ Bidirectional LSTM,11.134867385317946,167.3343794115467,12.935779041540046,0.5462502472468515,17.124593156141298,0.1068965517241379,,
17
+ ElasticNet,15.796038594619796,460.3763913475375,21.45638346384445,0.5225440358554927,144.4728811908027,0.0640776699029126,,
18
+ Lasso,15.830927911593506,462.7757498203704,21.51222326539892,0.5200556632227833,145.46522399976544,0.0524271844660194,,
19
+ Ridge,15.860549029501184,464.1921925903698,21.545119925179574,0.5185866716299998,145.65718178268125,0.0504854368932038,,
20
+ iTransformer,14.544141949115827,275.09890344946007,16.58610573490535,0.2540321967199925,73.33406651321724,,0.0172413793103448,
21
+ Attention LSTM,14.181327172488002,288.23989200564256,16.977629163273726,0.2183986327789276,24.827876450140888,0.1586206896551724,,
22
+ DG-iTransformer,14.183794754173379,313.5635374005159,17.707725359303375,0.1497301506825386,94.26231665878274,,0.1103448275862069,0.2
23
+ Physics iTransformer,16.41156271618159,379.65023683665015,19.48461538847124,-0.0294728537142276,97.60205959509742,,0.0,
artifacts/v2/results/v2_classical_results.csv ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ model,r2,mae,within_5pct
2
+ extra_trees,0.9545111863206289,1.325439106747622,99.27007299270073
3
+ svr,0.9749418151896808,0.8447528977381709,99.27007299270073
4
+ ridge,0.96260007301777,1.238689808585007,99.27007299270073
5
+ xgboost,0.9701536078007467,1.276102318230126,98.72262773722628
6
+ gradient_boosting,0.9427669688063848,1.4114545626753392,98.54014598540147
7
+ knn_k5,0.9303819293635062,1.7036079148305412,97.62773722627736
8
+ random_forest,0.9518568236961867,1.6867479514298702,96.71532846715328
9
+ lightgbm,0.9560384659829149,1.5661312778770975,95.98540145985402
artifacts/v2/results/v2_intra_battery.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "target": "within_5pct >= 95",
3
+ "passed_models": 8,
4
+ "total_models": 8,
5
+ "best_model": "extra_trees",
6
+ "best_within_5pct": 99.27007299270073,
7
+ "best_r2": 0.9545111863206289,
8
+ "notes": "XGBoost\u2192LGB+GB Ensemble, Ridge\u2192ExtraTrees-Scaled, KNN\u2192SVR Ensemble"
9
+ }
artifacts/v2/results/v2_model_validation.csv ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model,mae,rmse,r2,within_2pct,within_5pct,passed_95
2
+ random_forest,0.9087291273437881,1.969167860523798,0.9768094711831841,83.75912408759125,95.07299270072993,True
3
+ svr,1.6349861755580366,3.4846680412456976,0.9273780344781068,82.48175182481752,88.86861313868614,False
4
+ xgboost,1.0395094776911078,2.3264169017858896,0.9676316722415876,82.2992700729927,88.86861313868614,False
5
+ lightgbm,1.5073643559068495,2.9029505644051135,0.9496007058102836,81.02189781021897,88.13868613138686,False
6
+ knn_k20,2.486675866923982,6.500672824933502,0.7472670935266853,84.12408759124088,85.21897810218978,False
7
+ knn_k10,2.537245189395581,6.643166546082999,0.7360659298020076,84.48905109489051,84.67153284671532,False
8
+ knn_k5,2.5366661790854557,6.661080117527008,0.734640592527769,84.48905109489051,84.67153284671532,False
9
+ lasso,6.338197702892737,7.855862262540625,0.63090947633286,13.321167883211679,49.63503649635037,False
10
+ ridge,6.354219600702956,7.877916808246509,0.6288341980649439,13.138686131386862,49.63503649635037,False
11
+ elasticnet,6.359292593403684,7.8720819252103045,0.6293838121478381,13.503649635036496,49.27007299270073,False
12
+ physics_itransformer,11.942222882334457,19.560215899810355,-1.288191997636034,15.875912408759124,44.70802919708029,False
13
+ itransformer,18.775672188288702,24.807532802455988,-2.680546617377897,10.948905109489052,25.18248175182482,False
14
+ dynamic_graph_itransformer,14.5743662824896,18.019296255058386,-0.9418730093540923,6.204379562043796,17.335766423357665,False
15
+ extra_trees,21.583514681220528,25.105434141924782,-2.769473079864784,5.839416058394161,9.48905109489051,False
16
+ gradient_boosting,29.48091148539111,32.124687008446905,-5.1719583162447185,0.0,0.18248175182481752,False
17
+ best_rul_model,61.6389790255578,62.979116792145476,-22.721290167829615,0.0,0.0,False
artifacts/v2/results/v2_training_summary.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "v2.0",
3
+ "timestamp": "2026-02-25T18:16:44.705048",
4
+ "total_models": 12,
5
+ "passed_models": 5,
6
+ "pass_rate_pct": 41.66666666666667,
7
+ "best_model": "extra_trees",
8
+ "best_within_5pct": 99.27007299270073,
9
+ "best_r2": 0.9545111863206289,
10
+ "mean_within_5pct": 77.38746958637469,
11
+ "train_samples": 2130,
12
+ "test_samples": 548,
13
+ "batteries": 30
14
+ }
artifacts/v2/results/v2_validation_report.html ADDED
File without changes
artifacts/v2/results/v2_validation_summary.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "timestamp": "2026-02-25T16:31:44.904601",
3
+ "test_samples": 548,
4
+ "test_batteries": 30,
5
+ "total_models_tested": 16,
6
+ "models_passed_95pct": 1,
7
+ "overall_pass_rate_pct": 6.25,
8
+ "best_model": "random_forest",
9
+ "best_within_5pct": 95.07299270072993,
10
+ "mean_within_5pct": 53.809306569343065
11
+ }
artifacts/v2/results/vae_lstm_results.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "MAE": 8.494939970437716,
3
+ "MSE": 100.78674732190278,
4
+ "RMSE": 10.039260297546965,
5
+ "R2": 0.7267031327397879,
6
+ "MAPE": 14.250142040133243,
7
+ "tol_2pct": 0.09310344827586207
8
+ }
cleaned_dataset/metadata.csv ADDED
The diff for this file is too large to render. See raw diff
 
docker-compose.yml ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3.9"
2
+
3
+ # ─────────────────────────────────────────────────────────────────────────────
4
+ # AI Battery Lifecycle Predictor — Docker Compose
5
+ #
6
+ # Services
7
+ # ────────
8
+ # app Production: single container (React SPA + FastAPI, port 7860)
9
+ # api-dev Development: backend only with hot-reload (activate with --profile dev)
10
+ #
11
+ # Usage
12
+ # ─────
13
+ # Production: docker compose up --build
14
+ # Development: docker compose --profile dev up api-dev
15
+ # (then separately: cd frontend && npm run dev)
16
+ # ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ services:
19
+
20
+ # ── Production ──────────────────────────────────────────────────────────────
21
+ app:
22
+ build:
23
+ context: .
24
+ dockerfile: Dockerfile
25
+ image: battery-lifecycle:latest
26
+ container_name: battery_lifecycle
27
+ ports:
28
+ - "7860:7860"
29
+ environment:
30
+ LOG_LEVEL: "INFO"
31
+ WORKERS: "1"
32
+ volumes:
33
+ # Persist rotated log files on the host
34
+ - ./artifacts/logs:/app/artifacts/logs
35
+ healthcheck:
36
+ test:
37
+ - CMD
38
+ - python
39
+ - -c
40
+ - "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
41
+ interval: 30s
42
+ timeout: 10s
43
+ retries: 3
44
+ start_period: 90s # give models time to load
45
+ restart: unless-stopped
46
+
47
+ # ── Development (backend only, hot-reload) ──────────────────────────────────
48
+ api-dev:
49
+ build:
50
+ context: .
51
+ dockerfile: Dockerfile
52
+ target: runtime # stop before copying built frontend
53
+ image: battery-lifecycle:dev
54
+ container_name: battery_lifecycle_dev
55
+ command: >
56
+ uvicorn api.main:app
57
+ --host 0.0.0.0
58
+ --port 7860
59
+ --reload
60
+ ports:
61
+ - "7860:7860"
62
+ environment:
63
+ LOG_LEVEL: "DEBUG"
64
+ volumes:
65
+ - ./api:/app/api # live-reload source changes
66
+ - ./src:/app/src
67
+ - ./artifacts:/app/artifacts
68
+ profiles:
69
+ - dev
docs/api.md ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API Documentation
2
+
3
+ ## Base URL
4
+
5
+ - **Local:** `http://localhost:7860`
6
+ - **Docker:** `http://localhost:7860`
7
+ - **Hugging Face Spaces:** `https://neerajcodz-aibatterylifecycle.hf.space`
8
+
9
+ ## Interactive Docs
10
+
11
+ - **Swagger UI:** `/docs`
12
+ - **ReDoc:** `/redoc`
13
+ - **Gradio UI:** `/gradio`
14
+
15
+ ## API Versioning (v2.1.0)
16
+
17
+ The API supports two model generations served in parallel:
18
+
19
+ | Prefix | Models | Split Strategy | Notes |
20
+ |--------|--------|---------------|-------|
21
+ | `/api/v1/*` | v1 models (cross-battery split) | Group-battery 80/20 | Legacy |
22
+ | `/api/v2/*` | v2 models (chrono split, bug-fixed) | Intra-battery 80/20 | **Recommended** |
23
+ | `/api/*` | Default (v2) | Same as v2 | Backward-compatible |
24
+
25
+ ### v2 Bug Fixes
26
+ - **avg_temp auto-correction removed** — v1 silently added 8°C to avg_temp
27
+ - **Recommendation baseline fixed** — v1 re-predicted SOH, yielding ~0 improvement
28
+
29
+ ---
30
+
31
+ ## Endpoints
32
+
33
+ ### Health Check
34
+
35
+ ```http
36
+ GET /health
37
+ ```
38
+
39
+ Response:
40
+ ```json
41
+ {
42
+ "status": "ok",
43
+ "version": "2.0.0",
44
+ "models_loaded": 12,
45
+ "device": "cpu"
46
+ }
47
+ ```
48
+
49
+ ---
50
+
51
+ ### Single Prediction
52
+
53
+ ```http
54
+ POST /api/predict
55
+ Content-Type: application/json
56
+ ```
57
+
58
+ Request:
59
+ ```json
60
+ {
61
+ "battery_id": "B0005",
62
+ "cycle_number": 100,
63
+ "ambient_temperature": 24.0,
64
+ "peak_voltage": 4.2,
65
+ "min_voltage": 2.7,
66
+ "avg_current": 2.0,
67
+ "avg_temp": 25.0,
68
+ "temp_rise": 3.0,
69
+ "cycle_duration": 3600,
70
+ "Re": 0.04,
71
+ "Rct": 0.02,
72
+ "delta_capacity": -0.005
73
+ }
74
+ ```
75
+
76
+ Optionally include `"model_name"` to select a specific model (leave null to use the registry default):
77
+
78
+ ```json
79
+ {
80
+ ...
81
+ "model_name": "random_forest"
82
+ }
83
+ ```
84
+
85
+ Response:
86
+ ```json
87
+ {
88
+ "battery_id": "B0005",
89
+ "cycle_number": 100,
90
+ "soh_pct": 92.5,
91
+ "rul_cycles": 450,
92
+ "degradation_state": "Healthy",
93
+ "confidence_lower": 90.5,
94
+ "confidence_upper": 94.5,
95
+ "model_used": "random_forest",
96
+ "model_version": "v1.0.0"
97
+ }
98
+ ```
99
+
100
+ ---
101
+
102
+ ### Ensemble Prediction
103
+
104
+ ```http
105
+ POST /api/predict/ensemble
106
+ Content-Type: application/json
107
+ ```
108
+
109
+ Always uses the **BestEnsemble (v3.0.0)** — weighted average of Random Forest, XGBoost, and
110
+ LightGBM (weights proportional to R²). Body is identical to single prediction.
111
+
112
+ Response includes `"model_version": "v3.0.0"`.
113
+
114
+ ---
115
+
116
+ ### Batch Prediction
117
+
118
+ ```http
119
+ POST /api/predict/batch
120
+ Content-Type: application/json
121
+ ```
122
+
123
+ Request:
124
+ ```json
125
+ {
126
+ "battery_id": "B0005",
127
+ "cycles": [
128
+ {"cycle_number": 1, "ambient_temperature": 24, ...},
129
+ {"cycle_number": 2, "ambient_temperature": 24, ...}
130
+ ]
131
+ }
132
+ ```
133
+
134
+ ---
135
+
136
+ ### Recommendations
137
+
138
+ ```http
139
+ POST /api/recommend
140
+ Content-Type: application/json
141
+ ```
142
+
143
+ Request:
144
+ ```json
145
+ {
146
+ "battery_id": "B0005",
147
+ "current_cycle": 100,
148
+ "current_soh": 85.0,
149
+ "ambient_temperature": 24.0,
150
+ "top_k": 5
151
+ }
152
+ ```
153
+
154
+ Response:
155
+ ```json
156
+ {
157
+ "battery_id": "B0005",
158
+ "current_soh": 85.0,
159
+ "recommendations": [
160
+ {
161
+ "rank": 1,
162
+ "ambient_temperature": 24.0,
163
+ "discharge_current": 0.5,
164
+ "cutoff_voltage": 2.7,
165
+ "predicted_rul": 500,
166
+ "rul_improvement": 50,
167
+ "rul_improvement_pct": 11.1,
168
+ "explanation": "Operate at 24°C, 0.5A, cutoff 2.7V for ~500 cycles RUL"
169
+ }
170
+ ]
171
+ }
172
+ ```
173
+
174
+ ---
175
+
176
+ ### Dashboard Data
177
+
178
+ ```http
179
+ GET /api/dashboard
180
+ ```
181
+
182
+ Returns full dashboard payload with battery fleet stats, capacity fade curves, and model metrics.
183
+
184
+ ---
185
+
186
+ ### Battery List
187
+
188
+ ```http
189
+ GET /api/batteries
190
+ ```
191
+
192
+ ---
193
+
194
+ ### Battery Capacity
195
+
196
+ ```http
197
+ GET /api/battery/{battery_id}/capacity
198
+ ```
199
+
200
+ ---
201
+
202
+ ### Model List
203
+
204
+ ```http
205
+ GET /api/models
206
+ ```
207
+
208
+ Returns every registered model with version, family, R², and load status.
209
+
210
+ ---
211
+
212
+ ### Model Versions
213
+
214
+ ```http
215
+ GET /api/models/versions
216
+ ```
217
+
218
+ Groups models by generation:
219
+
220
+ ```json
221
+ {
222
+ "v1_classical": ["ridge", "lasso", "random_forest", "xgboost", "lightgbm", ...],
223
+ "v2_deep": ["vanilla_lstm", "bilstm", "gru", "attention_lstm", "tft", ...],
224
+ "v2_ensemble": ["best_ensemble"],
225
+ "other": [],
226
+ "default_model": "best_ensemble"
227
+ }
228
+ ```
229
+
230
+ ---
231
+
232
+ ### Figures
233
+
234
+ ```http
235
+ GET /api/figures # List all
236
+ GET /api/figures/{name} # Serve a figure
237
+ ```
docs/architecture.md ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture Overview
2
+
3
+ ## System Architecture
4
+
5
+ ```
6
+ ┌──────────────────────────────────────────────────────────────────┐
7
+ │ Docker Container (port 7860) │
8
+ ├──────────────┬───────────────┬───────────────────────────────────┤
9
+ │ React SPA │ Gradio UI │ FastAPI Backend │
10
+ │ (static) │ /gradio │ /api/* /docs /health │
11
+ │ / │ │ │
12
+ ├──────────────┴───────────────┴───────────────────────────────────┤
13
+ │ Model Registry │
14
+ │ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
15
+ │ │Classical │ │ LSTM×4 │ │Transform.│ │ Ensemble │ │
16
+ │ │ models │ │ GRU │ │ GPT, TFT │ │ Stack/WA │ │
17
+ │ └─────────┘ └──────────┘ └──────────┘ └──────────┘ │
18
+ ├──────────────────────────────────────────────────────────────────┤
19
+ │ Data Pipeline (src/) │
20
+ │ loader.py → features.py → preprocessing.py → model training │
21
+ ├──────────────────────────────────────────────────────────────────┤
22
+ │ NASA PCoE Dataset (cleaned_dataset/) │
23
+ └──────────────────────────────────────────────────────────────────┘
24
+ ```
25
+
26
+ ## Data Flow
27
+
28
+ 1. **Ingestion:** `loader.py` reads metadata.csv + per-cycle CSVs
29
+ 2. **Feature Engineering:** `features.py` computes SOC, SOH, RUL, scalar features per cycle
30
+ 3. **Preprocessing:** `preprocessing.py` creates sliding windows, scales features, splits by battery
31
+ 4. **Training:** Notebooks train each model family, save checkpoints to `artifacts/models/`
32
+ 5. **Serving:** `model_registry.py` loads all models at startup
33
+ 6. **Prediction:** API receives features → registry dispatches to best model → returns SOH/RUL
34
+ 7. **Simulation:** `POST /api/v2/simulate` receives multi-battery config → vectorized Arrhenius degradation + ML via `predict_array()` → returns per-step SOH, RUL, and degradation-state history for each battery
35
+ 8. **Visualization:** Frontend fetches results and renders analytics (fleet overview, compare, temperature analysis, recommendations)
36
+
37
+ ## Model Registry
38
+
39
+ The `ModelRegistry` singleton:
40
+ - Scans `artifacts/models/classical/` for `.joblib` files (sklearn/xgb/lgbm)
41
+ - Scans `artifacts/models/deep/` for `.pt` (PyTorch) and `.keras` (TF) files
42
+ - Loads classical models eagerly; deep models registered lazily
43
+ - Selects default model by priority: XGBoost > LightGBM > RandomForest > Ridge > deep models
44
+ - Provides unified `predict()` interface regardless of framework
45
+ - `predict_array(X: np.ndarray, model_name: str)` batch method enables vectorized simulation: accepts an (N, n_features) array and returns predictions for all N cycles in one call, avoiding Python loops
46
+ - `_x_for_model()` normalizes input feature extraction for both single-cycle and batch paths
47
+ - `_load_scaler()` lazily loads per-model scalers from `artifacts/scalers/`
48
+
49
+ ## Frontend Architecture
50
+
51
+ - **Vite 7** build tool with React 19 + TypeScript 5.9
52
+ - **lucide-react 0.575** for all icons — no emojis used anywhere in the UI
53
+ - **Recharts 3** for all 2D charts (BarChart, AreaChart, LineChart, ScatterChart, RadarChart, PieChart)
54
+ - **TailwindCSS 4** for styling
55
+ - Tabs: Simulation | Predict | Metrics | Analytics | Recommendations | Research Paper
56
+ - API proxy in dev mode (`/api` → `localhost:7860`) → same-origin in production (served by FastAPI)
57
+ - **Analytics (GraphPanel):** 4-section dashboard — Fleet Overview (health kpi, fleet SOH bar, bubble scatter), Single Battery (SOH + RUL projection, capacity fade, degradation rate), Compare (multi-battery overlay), Temperature Analysis
58
+ - **Metrics (MetricsPanel):** 6-section interactive dashboard — Overview KPIs, Models (sort/filter/chart-type controls), Validation, Deep Learning, Dataset stats, Figures searchable gallery
59
+ - **Recommendations (RecommendationPanel):** Slider inputs for SOH/temp, 3 chart tabs (RUL bar, params bar, top-3 radar), expandable table rows with per-recommendation explanation
docs/dataset.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dataset Documentation
2
+
3
+ ## NASA PCoE Li-ion Battery Dataset
4
+
5
+ ### Source
6
+ - **Repository:** NASA Prognostics Center of Excellence (PCoE)
7
+ - **Reference:** B. Saha and K. Goebel (2007). *Battery Data Set*, NASA Prognostics Data Repository, NASA Ames Research Center, Moffett Field, CA
8
+ - **URL:** https://www.nasa.gov/content/prognostics-center-of-excellence-data-set-repository
9
+
10
+ ### Cells
11
+ - **Type:** Li-ion 18650
12
+ - **Nominal capacity:** 2.0 Ah
13
+ - **Count:** 30 batteries after cleaning (from original 36)
14
+ - **Total discharge cycles:** 2,678
15
+ - **Sliding windows generated:** 1,734 (window size = 32 cycles)
16
+
17
+ ### Temperature Groups (discovered in EDA)
18
+
19
+ | Group | Temperature | # Batteries | # Cycles |
20
+ |-------|-------------|-------------|----------|
21
+ | 1 | 4°C | 3 | ~200 |
22
+ | 2 | 22°C | 4 | ~280 |
23
+ | 3 | 24°C | 16 | ~1700 |
24
+ | 4 | 43°C | 4 | ~320 |
25
+ | 5 | 44°C | 3 | ~180 |
26
+
27
+ Note: 5 temperature groups were discovered (not 3 as originally assumed).
28
+
29
+ ### End-of-Life Definitions
30
+ - **30% capacity fade:** 1.4 Ah (default threshold)
31
+ - **20% capacity fade:** 1.6 Ah (alternative)
32
+
33
+ ### Cycle Types
34
+
35
+ #### Discharge
36
+ Columns: `Voltage_measured`, `Current_measured`, `Temperature_measured`, `Current_load`, `Voltage_load`, `Time`
37
+
38
+ #### Charge
39
+ Columns: `Voltage_measured`, `Current_measured`, `Temperature_measured`, `Current_charge`, `Voltage_charge`, `Time`
40
+
41
+ #### Impedance
42
+ Columns: `Sense_current` (Re + Im), `Battery_current` (Re + Im), `Current_ratio` (Re + Im), `Battery_impedance` (Re + Im)
43
+
44
+ ### Metadata Schema (metadata.csv)
45
+ - `type`: cycle type (charge, discharge, impedance)
46
+ - `start_time`: MATLAB datenum
47
+ - `ambient_temperature`: °C
48
+ - `battery_id`: identifier
49
+ - `test_id`: test sequence number
50
+ - `uid`: unique identifier
51
+ - `filename`: path to cycle CSV
52
+ - `Capacity`: measured capacity (Ah)
53
+ - `Re`: electrolyte resistance (Ω)
54
+ - `Rct`: charge transfer resistance (Ω)
55
+
56
+ ### Feature Engineering
57
+
58
+ #### Per-Cycle Scalar Features (12 dimensions)
59
+ 1. `cycle_number` — sequential cycle index
60
+ 2. `ambient_temperature` — environment temperature
61
+ 3. `peak_voltage` — max voltage in cycle
62
+ 4. `min_voltage` — min voltage in cycle
63
+ 5. `voltage_range` — peak - min
64
+ 6. `avg_current` — mean current magnitude
65
+ 7. `avg_temp` — mean cell temperature
66
+ 8. `temp_rise` — max - min temperature
67
+ 9. `cycle_duration` — total time (s)
68
+ 10. `Re` — electrolyte resistance
69
+ 11. `Rct` — charge transfer resistance
70
+ 12. `delta_capacity` — capacity change from previous cycle
71
+
72
+ #### Derived Targets
73
+ - **SOC:** Coulomb counting (integrated current)
74
+ - **SOH:** (Current capacity / Nominal capacity) × 100%
75
+ - **RUL:** Cycles remaining until EOL threshold
76
+ - **Degradation State:** Healthy (≥90%), Moderate (80–90%), Degraded (70–80%), End-of-Life (<70%)
docs/deployment.md ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deployment Guide
2
+
3
+ ## Local Development
4
+
5
+ ### Backend
6
+ ```bash
7
+ cd aiBatteryLifecycle
8
+ .\venv\Scripts\activate # Windows
9
+ source venv/bin/activate # Linux/Mac
10
+
11
+ uvicorn api.main:app --host 0.0.0.0 --port 7860 --reload
12
+ ```
13
+
14
+ ### Frontend (dev mode)
15
+ ```bash
16
+ cd frontend
17
+ npm install
18
+ npm run dev
19
+ ```
20
+
21
+ Frontend proxies `/api/*` to `localhost:7860` in dev mode.
22
+
23
+ ### Frontend (production build)
24
+ ```bash
25
+ cd frontend
26
+ npm run build
27
+ ```
28
+
29
+ Built files go to `frontend/dist/` and are served by FastAPI.
30
+
31
+ ---
32
+
33
+ ## Docker
34
+
35
+ ### Build
36
+ ```bash
37
+ docker build -t battery-predictor .
38
+ ```
39
+
40
+ ### Run
41
+ ```bash
42
+ docker run -p 7860:7860 battery-predictor
43
+ ```
44
+
45
+ ### Build stages
46
+ 1. **frontend-build:** `node:20-slim` — installs npm deps and builds React SPA
47
+ 2. **runtime:** `python:3.11-slim` — installs Python deps, copies source and built frontend
48
+
49
+ ### Docker Compose (recommended)
50
+
51
+ ```bash
52
+ # Production — single container (frontend + API)
53
+ docker compose up --build
54
+
55
+ # Development — backend only with hot-reload
56
+ docker compose --profile dev up api-dev
57
+ # then separately:
58
+ cd frontend && npm run dev
59
+ ```
60
+
61
+ ### Environment Variables
62
+
63
+ | Variable | Default | Description |
64
+ |----------|---------|-------------|
65
+ | `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG` / `INFO` / `WARNING` / `ERROR`) |
66
+ | `WORKERS` | `1` | Uvicorn worker count |
67
+
68
+ ---
69
+
70
+ ## Hugging Face Spaces
71
+
72
+ ### Setup
73
+ 1. Create a new Space on Hugging Face (SDK: Docker)
74
+ 2. Push the repository to the Space
75
+ 3. The Dockerfile exposes port 7860 (HF Spaces default)
76
+
77
+ ### Dockerfile Requirements
78
+ - Must expose port **7860**
79
+ - Must respond to health checks at `/health`
80
+ - Keep image size manageable (use CPU-only PyTorch/TF)
81
+
82
+ ### Files to include
83
+ ```
84
+ Dockerfile
85
+ requirements.txt
86
+ api/
87
+ src/
88
+ frontend/ # Vite builds during Docker image creation
89
+ cleaned_dataset/
90
+ artifacts/v1/ # v1 model checkpoints (legacy)
91
+ artifacts/v2/ # v2 model checkpoints (recommended)
92
+ artifacts/models/ # Root-level models (backward compat)
93
+ ```
94
+
95
+ ### HuggingFace Space URL
96
+ ```
97
+ https://huggingface.co/spaces/NeerajCodz/aiBatteryLifeCycle
98
+ ```
99
+
100
+ ### Space configuration (README.md header)
101
+ ```yaml
102
+ ---
103
+ title: AI Battery Lifecycle Predictor
104
+ emoji: 🔋
105
+ colorFrom: green
106
+ colorTo: blue
107
+ sdk: docker
108
+ pinned: false
109
+ app_port: 7860
110
+ ---
111
+ ```
112
+
113
+ ---
114
+
115
+ ## Production Considerations
116
+
117
+ ### Performance
118
+ - Use `--workers N` for multi-core deployment
119
+ - Enable GPU passthrough for deep model inference: `docker run --gpus all`
120
+ - Consider preloading all models (not lazy loading)
121
+
122
+ ### Security
123
+ - Set `CORS_ORIGINS` to specific domains in production
124
+ - Add authentication middleware if needed
125
+ - Use HTTPS reverse proxy (nginx, Caddy)
126
+
127
+ ### Monitoring
128
+ - Health endpoint: `/health`
129
+ - Logs: JSON-per-line rotating log at `artifacts/logs/battery_lifecycle.log` (10 MB × 5 backups)
130
+ — set `LOG_LEVEL=DEBUG` for verbose output, mount volume to persist across container restarts
131
+ - Metrics: Add Prometheus endpoint if needed
docs/frontend.md ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Frontend Documentation
2
+
3
+ ## Technology Stack
4
+
5
+ | Technology | Version | Purpose |
6
+ |-----------|---------|----------|
7
+ | Vite | 7.x | Build tool & dev server |
8
+ | React | 19.x | UI framework |
9
+ | TypeScript | 5.9.x | Type safety |
10
+ | Recharts | 3.7.x | Interactive 2D charts (BarChart, LineChart, AreaChart, RadarChart, ScatterChart, PieChart) |
11
+ | lucide-react | 0.575.x | Icon system — **no emojis in UI** |
12
+ | TailwindCSS | 4.x | Utility-first CSS |
13
+ | Axios | 1.x | HTTP client |
14
+
15
+ ## Project Structure
16
+
17
+ ```
18
+ frontend/
19
+ ├── index.html
20
+ ├── vite.config.ts # Vite + /api proxy
21
+ ├── tsconfig.json
22
+ ├── package.json
23
+ └── src/
24
+ ├── main.tsx
25
+ ├── App.tsx # Root, tab navigation, v1/v2 selector
26
+ ├── api.ts # All API calls + TypeScript types
27
+ ├── index.css
28
+ └── components/
29
+ ├── Dashboard.tsx # Fleet overview heatmap + capacity charts
30
+ ├── PredictionForm.tsx # Single-cycle SOH prediction + gauge
31
+ ├── SimulationPanel.tsx # Multi-battery lifecycle simulation
32
+ ├── MetricsPanel.tsx # Full model metrics dashboard
33
+ ├── GraphPanel.tsx # Analytics — fleet, single battery, compare, temperature
34
+ └── RecommendationPanel.tsx # Operating condition optimizer with charts
35
+ ```
36
+
37
+ ## Tab Order
38
+
39
+ | Tab | Component | Description |
40
+ |-----|-----------|-------------|
41
+ | Simulation | `SimulationPanel` | ML-backed multi-battery lifecycle forecasting |
42
+ | Predict | `PredictionForm` | Single-cycle SOH + RUL prediction |
43
+ | Metrics | `MetricsPanel` | Full model evaluation dashboard |
44
+ | Analytics | `GraphPanel` | Fleet & per-battery interactive analytics |
45
+ | Recommendations | `RecommendationPanel` | Operating condition optimizer |
46
+ | Research Paper | — | Embedded research PDF |
47
+
48
+ ## Components
49
+
50
+ ### MetricsPanel
51
+ Full interactive model evaluation dashboard with 6 switchable sections:
52
+
53
+ - **Overview** — KPI stat cards with lucide icons, R² ranking bar chart, model family pie chart, normalized radar chart (top 5), R² vs MAE scatter trade-off plot, Top-3 rankings podium
54
+ - **Models** — Interactive sort (R²/MAE/RMSE/MAPE, asc/desc), family filter dropdown, chart-type toggle (bar/radar/scatter), multi-select compare mode, colour-coded metric badges, full metrics table with per-row highlighting
55
+ - **Validation** — Within-5% / within-10% grouped bar chart, full validation table with pass/fail badges
56
+ - **Deep Learning** — LSTM/ensemble/VAE-LSTM/DG-iTransformer results with charts and metric tables
57
+ - **Dataset** — Battery stats cards, engineered features list, temperature groups, degradation distribution bar chart, SOH range gauge
58
+ - **Figures** — Searchable grid of all artifact figures with modal lightbox on click
59
+
60
+ **Key features:**
61
+ - All icons via `lucide-react` (no emojis)
62
+ - `filteredModels` useMemo respects active sort/filter state
63
+ - `MetricBadge` component colour-codes values green/yellow/red based on model quality thresholds
64
+ - `SectionBadge` nav bar with icon + label
65
+
66
+ ### GraphPanel (Analytics)
67
+ Four-section analytics dashboard:
68
+
69
+ - **Fleet Overview** — SOH bar chart sorted by health (colour-coded green/yellow/red), SOH vs cycles bubble scatter (bubble = temperature), fleet status KPI cards (healthy/degraded/near-EOL), filter controls (min SOH slider, temp range), clickable battery roster table
70
+ - **Single Battery** — SOH trajectory + linear RUL projection overlay, capacity fade area chart, smoothed degradation rate area chart, show/hide EOL reference line toggle
71
+ - **Compare** — Multi-select up to 5 batteries; SOH overlay line chart with distinct colours per battery, capacity fade overlay, summary comparison table (final SOH, cycles, min capacity)
72
+ - **Temperature Analysis** — Temperature vs SOH scatter, temperature distribution histogram
73
+
74
+ **Key features:**
75
+ - Multi-battery data loaded in parallel using `Promise.all`
76
+ - RUL projection via least-squares on last 20 cycles → extrapolated to 70% SOH
77
+ - `SohBadge` component with dynamic colour + icon
78
+
79
+ ### RecommendationPanel
80
+ Interactive optimizer replacing the previous plain form + table:
81
+
82
+ - **Input form** — Text input for battery ID, range sliders for SOH and ambient temperature, numeric inputs for cycle and top-k
83
+ - **Summary cards** — Battery ID, best predicted RUL, best improvement, config count
84
+ - **Visual Analysis tabs:**
85
+ - *RUL Comparison* — bar chart comparing predicted RUL across all recommendations
86
+ - *Parameters* — grouped bar chart showing temp/current/cutoff per rank
87
+ - *Radar* — normalized multi-metric radar chart for top-3 configs
88
+ - **Recommendations table** — rank icons (Trophy/Award/Medal from lucide), colour-coded improvement badges, expandable rows showing per-recommendation explanation and parameter details
89
+
90
+ ### SimulationPanel
91
+ - Configure up to N battery simulations with individual parameters (temp, voltage, current, EOL threshold)
92
+ - Select ML model for lifecycle prediction (or pure physics fallback)
93
+ - Animated SOH trajectory charts, final stats table, degradation state timeline
94
+
95
+ ### Dashboard
96
+ - Fleet battery grid with SOH colour coding
97
+ - Capacity fade line chart per selected battery
98
+ - Model metrics bar chart
99
+
100
+ ### PredictionForm
101
+ - 12-input form with all engineered cycle features
102
+ - SOH gauge visualization (SVG ring) with degradation state colour
103
+ - Confidence interval display
104
+ - Model selector (v2 models / best_ensemble)
105
+
106
+ ## API Integration (`api.ts`)
107
+
108
+ All API calls return typed TypeScript response objects:
109
+
110
+ | Function | Endpoint | Description |
111
+ |----------|----------|--------------|
112
+ | `fetchDashboard()` | `GET /api/dashboard` | Fleet overview + capacity fade data |
113
+ | `fetchBatteries()` | `GET /api/batteries` | All battery metadata |
114
+ | `fetchBatteryCapacity(id)` | `GET /api/battery/{id}/capacity` | Per-battery cycles, capacity, SOH arrays |
115
+ | `predictSoh(req)` | `POST /api/v2/predict` | Single-cycle SOH + RUL prediction |
116
+ | `fetchRecommendations(req)` | `POST /api/v2/recommend` | Operating condition optimization |
117
+ | `simulateBatteries(req)` | `POST /api/v2/simulate` | Multi-battery lifecycle simulation |
118
+ | `fetchMetrics()` | `GET /api/metrics` | Full model evaluation metrics |
119
+ | `fetchModels()` | `GET /api/v2/models` | All loaded models with metadata |
120
+
121
+ ## Development
122
+
123
+ ```bash
124
+ cd frontend
125
+ npm install
126
+ npm run dev # http://localhost:5173
127
+ ```
128
+
129
+ API requests proxy to `http://localhost:7860` in dev mode (see `vite.config.ts`).
130
+
131
+ ## Build
132
+
133
+ ```bash
134
+ npm run build # outputs to dist/
135
+ ```
136
+
137
+ The built `dist/` folder is served as static files by FastAPI at the root path.
138
+ | Vite | 6.x | Build tool & dev server |
139
+ | React | 18.x | UI framework |
140
+ | TypeScript | 5.x | Type safety |
141
+ | Three.js | latest | 3D rendering |
142
+ | @react-three/fiber | latest | React renderer for Three.js |
143
+ | @react-three/drei | latest | Three.js helpers |
144
+ | Recharts | latest | 2D charts |
145
+ | TailwindCSS | 4.x | Utility-first CSS |
146
+ | Axios | latest | HTTP client |
147
+
148
+ ## Project Structure
149
+
150
+ ```
151
+ frontend/
152
+ ├── index.html # Entry HTML
153
+ ├── vite.config.ts # Vite + proxy config
154
+ ├── tsconfig.json # TypeScript config
155
+ ├── package.json
156
+ ├── public/
157
+ │ └── vite.svg # Favicon
158
+ └── src/
159
+ ├── main.tsx # React entry point
160
+ ├── App.tsx # Root component + tab navigation
161
+ ├── api.ts # API client + types
162
+ ├── index.css # TailwindCSS import
163
+ └── components/
164
+ ├── Dashboard.tsx # Fleet overview + charts
165
+ ├── PredictionForm.tsx # SOH prediction form + gauge
166
+ ├── BatteryViz3D.tsx # 3D battery pack heatmap
167
+ ├── GraphPanel.tsx # Analytics / per-battery graphs
168
+ └── RecommendationPanel.tsx # Operating condition optimizer
169
+ ```
170
+
171
+ ## Components
172
+
173
+ ### Dashboard
174
+ - Stat cards (battery count, models, best R²)
175
+ - Battery fleet grid with SOH color coding
176
+ - SOH capacity fade line chart (Recharts)
177
+ - Model R² comparison bar chart
178
+
179
+ ### PredictionForm
180
+ - 12-input form with all cycle features
181
+ - SOH gauge visualization (SVG ring)
182
+ - Result display with degradation state coloring
183
+ - Confidence interval display
184
+
185
+ ### BatteryViz3D
186
+ - 3D battery pack with cylindrical cells
187
+ - SOH-based fill level and color mapping
188
+ - Click-to-inspect with side panel details
189
+ - Auto-rotation with orbit controls
190
+ - Health legend and battery list sidebar
191
+
192
+ ### GraphPanel
193
+ - Battery selector dropdown
194
+ - Per-battery SOH trajectory (line chart)
195
+ - Per-battery capacity fade curve
196
+ - Fleet scatter plot (SOH vs cycles, bubble size = temperature)
197
+
198
+ ### RecommendationPanel
199
+ - Input: battery ID, current cycle, SOH, temperature, top_k
200
+ - Table of ranked recommendations
201
+ - Shows temperature, current, cutoff voltage, predicted RUL, improvement %
202
+
203
+ ## Development
204
+
205
+ ```bash
206
+ cd frontend
207
+ npm install
208
+ npm run dev # http://localhost:5173
209
+ ```
210
+
211
+ API requests are proxied to `http://localhost:7860` during development.
212
+
213
+ ## Production Build
214
+
215
+ ```bash
216
+ npm run build # Outputs to dist/
217
+ ```
218
+
219
+ The built files are served by FastAPI as static assets.