NeerajCodz commited on
Commit
ffb4c4b
Β·
1 Parent(s): cac5a02

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)

Files changed (2) hide show
  1. api/model_registry.py +56 -20
  2. frontend/src/api.ts +1 -1
api/model_registry.py CHANGED
@@ -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 _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
  )
@@ -273,7 +296,8 @@ class ModelRegistry:
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, {}),
@@ -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, F) numpy array.
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, F) sequence tensor
617
- t = self._build_sequence_tensor(x).to(self.device)
 
 
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, F) numpy array for Keras
629
- seq_np = self._build_sequence_array(x) # (1, 32, F)
 
 
 
 
 
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
- """Total number of registered model entries."""
779
- return len(set(list(self.models.keys()) + list(self.model_meta.keys())))
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."""
frontend/src/api.ts CHANGED
@@ -210,4 +210,4 @@ export interface SimulateResponse {
210
  }
211
 
212
  export const simulateBatteries = (req: SimulateRequest) =>
213
- axios.post<SimulateResponse>("/api/v2/simulate", req).then((r) => r.data);
 
210
  }
211
 
212
  export const simulateBatteries = (req: SimulateRequest) =>
213
+ baseApi.post<SimulateResponse>("/api/v3/simulate", req).then((r) => r.data);