Spaces:
Running
fix: proper v3 implementation - auto-detect n_feat, fix simulate URL, fix model_count
Browse files- model_registry: add _detect_n_feat() to read input dim from PyTorch checkpoint
so v3 deep models (18 features) load without size-mismatch errors
- model_registry: _build_pytorch_model() now accepts n_feat param (was hardcoded to 12)
- model_registry: store n_feat in model_meta after successful PyTorch load
- model_registry: _build_sequence_array/_build_sequence_tensor accept n_feat and
zero-pad the 12-feature input to match each model's expected feature count
- model_registry: predict() reads stored n_feat for deep_pytorch, derives it from
model.input_shape[-1] for deep_keras
- model_registry: model_count now returns len(self.models) (loaded only, not failed)
- api.ts: simulateBatteries() now calls /api/v3/simulate (was /api/v2/simulate which
returned 405 because the simulate router lives under /api/v3)
- api/model_registry.py +56 -20
- frontend/src/api.ts +1 -1
|
@@ -223,37 +223,60 @@ class ModelRegistry:
|
|
| 223 |
except Exception as exc:
|
| 224 |
log.warning("Failed to load %s: %s", p.name, exc)
|
| 225 |
|
| 226 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 232 |
if name == "bidirectional_lstm":
|
| 233 |
from src.models.deep.lstm import BidirectionalLSTM
|
| 234 |
-
return BidirectionalLSTM(
|
| 235 |
if name == "gru":
|
| 236 |
from src.models.deep.lstm import GRUModel
|
| 237 |
-
return GRUModel(
|
| 238 |
if name == "attention_lstm":
|
| 239 |
from src.models.deep.lstm import AttentionLSTM
|
| 240 |
-
return AttentionLSTM(
|
| 241 |
if name == "batterygpt":
|
| 242 |
from src.models.deep.transformer import BatteryGPT
|
| 243 |
return BatteryGPT(
|
| 244 |
-
input_dim=
|
| 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=
|
| 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=
|
| 257 |
hidden_dim=_HIDDEN, latent_dim=16,
|
| 258 |
n_layers=_LSTM_LAYERS, dropout=_DROPOUT,
|
| 259 |
)
|
|
@@ -273,7 +296,8 @@ class ModelRegistry:
|
|
| 273 |
return
|
| 274 |
for p in sorted(ddir.glob("*.pt")):
|
| 275 |
name = p.stem
|
| 276 |
-
|
|
|
|
| 277 |
if model is None:
|
| 278 |
self.model_meta[name] = {
|
| 279 |
**MODEL_CATALOG.get(name, {}),
|
|
@@ -290,7 +314,7 @@ class ModelRegistry:
|
|
| 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:
|
|
@@ -523,12 +547,13 @@ class ModelRegistry:
|
|
| 523 |
return x
|
| 524 |
|
| 525 |
def _build_sequence_array(
|
| 526 |
-
self, x: np.ndarray, seq_len: int = _SEQ_LEN
|
| 527 |
) -> np.ndarray:
|
| 528 |
-
"""Convert single-cycle feature row β scaled (1, seq_len,
|
| 529 |
|
| 530 |
Tile the current feature vector across *seq_len* timesteps and apply
|
| 531 |
the sequence scaler so values match the training distribution.
|
|
|
|
| 532 |
"""
|
| 533 |
if self.sequence_scaler is not None:
|
| 534 |
try:
|
|
@@ -537,15 +562,19 @@ class ModelRegistry:
|
|
| 537 |
x_sc = x
|
| 538 |
else:
|
| 539 |
x_sc = x
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
# Tile to (1, seq_len, F)
|
| 541 |
return np.tile(x_sc[:, np.newaxis, :], (1, seq_len, 1)).astype(np.float32)
|
| 542 |
|
| 543 |
def _build_sequence_tensor(
|
| 544 |
-
self, x: np.ndarray, seq_len: int = _SEQ_LEN
|
| 545 |
) -> Any:
|
| 546 |
"""Same as :meth:`_build_sequence_array` but returns a PyTorch tensor."""
|
| 547 |
import torch
|
| 548 |
-
return torch.tensor(self._build_sequence_array(x, seq_len), dtype=torch.float32)
|
| 549 |
|
| 550 |
def _predict_ensemble(self, x: np.ndarray) -> tuple[float, str]:
|
| 551 |
"""Weighted-average SOH prediction from BestEnsemble component models.
|
|
@@ -613,8 +642,10 @@ class ModelRegistry:
|
|
| 613 |
try:
|
| 614 |
import torch
|
| 615 |
with torch.no_grad():
|
| 616 |
-
# Build scaled (1, seq_len,
|
| 617 |
-
|
|
|
|
|
|
|
| 618 |
out = model(t)
|
| 619 |
# VAE-LSTM returns a dict; all others return a tensor
|
| 620 |
if isinstance(out, dict):
|
|
@@ -625,8 +656,13 @@ class ModelRegistry:
|
|
| 625 |
raise
|
| 626 |
elif family == "deep_keras":
|
| 627 |
try:
|
| 628 |
-
# Build scaled (1, seq_len,
|
| 629 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
out = model.predict(seq_np, verbose=0)
|
| 631 |
# Physics-Informed model returns a dict with multiple heads
|
| 632 |
if isinstance(out, dict):
|
|
@@ -775,8 +811,8 @@ class ModelRegistry:
|
|
| 775 |
# ββ Info helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 776 |
@property
|
| 777 |
def model_count(self) -> int:
|
| 778 |
-
"""
|
| 779 |
-
return len(
|
| 780 |
|
| 781 |
def list_models(self) -> list[dict[str, Any]]:
|
| 782 |
"""Return full model listing with versioning, metrics, and load status."""
|
|
|
|
| 223 |
except Exception as exc:
|
| 224 |
log.warning("Failed to load %s: %s", p.name, exc)
|
| 225 |
|
| 226 |
+
def _detect_n_feat(self, p: Path) -> int:
|
| 227 |
+
"""Infer input feature dimension from a PyTorch checkpoint.
|
| 228 |
+
|
| 229 |
+
Checks common first-layer weight keys to determine n_features,
|
| 230 |
+
handling models trained with a different feature count (e.g. v3=18).
|
| 231 |
+
"""
|
| 232 |
+
try:
|
| 233 |
+
import torch
|
| 234 |
+
state = torch.load(p, map_location="cpu", weights_only=True)
|
| 235 |
+
# LSTM / BiLSTM / GRU: weight_ih_l0 shape is (gates*hidden, n_feat)
|
| 236 |
+
for key in ("lstm.weight_ih_l0", "encoder_lstm.weight_ih_l0", "gru.weight_ih_l0"):
|
| 237 |
+
if key in state:
|
| 238 |
+
return int(state[key].shape[-1])
|
| 239 |
+
# BatteryGPT: input_proj.weight shape is (d_model, n_feat)
|
| 240 |
+
if "input_proj.weight" in state:
|
| 241 |
+
return int(state["input_proj.weight"].shape[-1])
|
| 242 |
+
# TFT: softmax_proj.bias shape is (n_features,)
|
| 243 |
+
if "var_selection.softmax_proj.bias" in state:
|
| 244 |
+
return int(state["var_selection.softmax_proj.bias"].shape[0])
|
| 245 |
+
except Exception:
|
| 246 |
+
pass
|
| 247 |
+
return _N_FEAT
|
| 248 |
+
|
| 249 |
+
def _build_pytorch_model(self, name: str, n_feat: int = _N_FEAT) -> Any | None:
|
| 250 |
"""Instantiate a PyTorch module with the architecture used during training."""
|
| 251 |
try:
|
| 252 |
if name == "vanilla_lstm":
|
| 253 |
from src.models.deep.lstm import VanillaLSTM
|
| 254 |
+
return VanillaLSTM(n_feat, _HIDDEN, _LSTM_LAYERS, _DROPOUT)
|
| 255 |
if name == "bidirectional_lstm":
|
| 256 |
from src.models.deep.lstm import BidirectionalLSTM
|
| 257 |
+
return BidirectionalLSTM(n_feat, _HIDDEN, _LSTM_LAYERS, _DROPOUT)
|
| 258 |
if name == "gru":
|
| 259 |
from src.models.deep.lstm import GRUModel
|
| 260 |
+
return GRUModel(n_feat, _HIDDEN, _LSTM_LAYERS, _DROPOUT)
|
| 261 |
if name == "attention_lstm":
|
| 262 |
from src.models.deep.lstm import AttentionLSTM
|
| 263 |
+
return AttentionLSTM(n_feat, _HIDDEN, _ATTN_LAYERS, _DROPOUT)
|
| 264 |
if name == "batterygpt":
|
| 265 |
from src.models.deep.transformer import BatteryGPT
|
| 266 |
return BatteryGPT(
|
| 267 |
+
input_dim=n_feat, d_model=_D_MODEL, n_heads=_N_HEADS,
|
| 268 |
n_layers=_TF_LAYERS, dropout=_DROPOUT, max_len=64,
|
| 269 |
)
|
| 270 |
if name == "tft":
|
| 271 |
from src.models.deep.transformer import TemporalFusionTransformer
|
| 272 |
return TemporalFusionTransformer(
|
| 273 |
+
n_features=n_feat, d_model=_D_MODEL, n_heads=_N_HEADS,
|
| 274 |
n_layers=_TF_LAYERS, dropout=_DROPOUT,
|
| 275 |
)
|
| 276 |
if name == "vae_lstm":
|
| 277 |
from src.models.deep.vae_lstm import VAE_LSTM
|
| 278 |
return VAE_LSTM(
|
| 279 |
+
input_dim=n_feat, seq_len=_SEQ_LEN,
|
| 280 |
hidden_dim=_HIDDEN, latent_dim=16,
|
| 281 |
n_layers=_LSTM_LAYERS, dropout=_DROPOUT,
|
| 282 |
)
|
|
|
|
| 296 |
return
|
| 297 |
for p in sorted(ddir.glob("*.pt")):
|
| 298 |
name = p.stem
|
| 299 |
+
n_feat = self._detect_n_feat(p)
|
| 300 |
+
model = self._build_pytorch_model(name, n_feat=n_feat)
|
| 301 |
if model is None:
|
| 302 |
self.model_meta[name] = {
|
| 303 |
**MODEL_CATALOG.get(name, {}),
|
|
|
|
| 314 |
catalog = MODEL_CATALOG.get(name, {})
|
| 315 |
self.model_meta[name] = {
|
| 316 |
**catalog, "family": "deep_pytorch",
|
| 317 |
+
"loaded": True, "path": str(p), "n_feat": n_feat,
|
| 318 |
}
|
| 319 |
log.info("Loaded PyTorch: %-22s v%s", name, catalog.get("version", "?"))
|
| 320 |
except Exception as exc:
|
|
|
|
| 547 |
return x
|
| 548 |
|
| 549 |
def _build_sequence_array(
|
| 550 |
+
self, x: np.ndarray, seq_len: int = _SEQ_LEN, n_feat: int | None = None
|
| 551 |
) -> np.ndarray:
|
| 552 |
+
"""Convert single-cycle feature row β scaled (1, seq_len, n_feat) numpy array.
|
| 553 |
|
| 554 |
Tile the current feature vector across *seq_len* timesteps and apply
|
| 555 |
the sequence scaler so values match the training distribution.
|
| 556 |
+
If *n_feat* > x.shape[-1] the input is zero-padded to match the model.
|
| 557 |
"""
|
| 558 |
if self.sequence_scaler is not None:
|
| 559 |
try:
|
|
|
|
| 562 |
x_sc = x
|
| 563 |
else:
|
| 564 |
x_sc = x
|
| 565 |
+
# Pad to model's expected feature count if necessary (e.g. v3 deep = 18)
|
| 566 |
+
if n_feat and n_feat > x_sc.shape[-1]:
|
| 567 |
+
pad_width = n_feat - x_sc.shape[-1]
|
| 568 |
+
x_sc = np.pad(x_sc, ((0, 0), (0, pad_width)))
|
| 569 |
# Tile to (1, seq_len, F)
|
| 570 |
return np.tile(x_sc[:, np.newaxis, :], (1, seq_len, 1)).astype(np.float32)
|
| 571 |
|
| 572 |
def _build_sequence_tensor(
|
| 573 |
+
self, x: np.ndarray, seq_len: int = _SEQ_LEN, n_feat: int | None = None
|
| 574 |
) -> Any:
|
| 575 |
"""Same as :meth:`_build_sequence_array` but returns a PyTorch tensor."""
|
| 576 |
import torch
|
| 577 |
+
return torch.tensor(self._build_sequence_array(x, seq_len, n_feat), dtype=torch.float32)
|
| 578 |
|
| 579 |
def _predict_ensemble(self, x: np.ndarray) -> tuple[float, str]:
|
| 580 |
"""Weighted-average SOH prediction from BestEnsemble component models.
|
|
|
|
| 642 |
try:
|
| 643 |
import torch
|
| 644 |
with torch.no_grad():
|
| 645 |
+
# Build scaled (1, seq_len, n_feat) sequence tensor
|
| 646 |
+
# n_feat may be > 12 for models trained with extra features (e.g. v3)
|
| 647 |
+
model_n_feat = self.model_meta.get(name, {}).get("n_feat", _N_FEAT)
|
| 648 |
+
t = self._build_sequence_tensor(x, n_feat=model_n_feat).to(self.device)
|
| 649 |
out = model(t)
|
| 650 |
# VAE-LSTM returns a dict; all others return a tensor
|
| 651 |
if isinstance(out, dict):
|
|
|
|
| 656 |
raise
|
| 657 |
elif family == "deep_keras":
|
| 658 |
try:
|
| 659 |
+
# Build scaled (1, seq_len, n_feat) numpy array for Keras
|
| 660 |
+
# Derive n_feat from model input shape: (None, seq_len, n_feat)
|
| 661 |
+
try:
|
| 662 |
+
keras_n_feat = int(model.input_shape[-1])
|
| 663 |
+
except Exception:
|
| 664 |
+
keras_n_feat = None
|
| 665 |
+
seq_np = self._build_sequence_array(x, n_feat=keras_n_feat)
|
| 666 |
out = model.predict(seq_np, verbose=0)
|
| 667 |
# Physics-Informed model returns a dict with multiple heads
|
| 668 |
if isinstance(out, dict):
|
|
|
|
| 811 |
# ββ Info helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 812 |
@property
|
| 813 |
def model_count(self) -> int:
|
| 814 |
+
"""Number of successfully loaded models."""
|
| 815 |
+
return len(self.models)
|
| 816 |
|
| 817 |
def list_models(self) -> list[dict[str, Any]]:
|
| 818 |
"""Return full model listing with versioning, metrics, and load status."""
|
|
@@ -210,4 +210,4 @@ export interface SimulateResponse {
|
|
| 210 |
}
|
| 211 |
|
| 212 |
export const simulateBatteries = (req: SimulateRequest) =>
|
| 213 |
-
|
|
|
|
| 210 |
}
|
| 211 |
|
| 212 |
export const simulateBatteries = (req: SimulateRequest) =>
|
| 213 |
+
baseApi.post<SimulateResponse>("/api/v3/simulate", req).then((r) => r.data);
|