tanmaydesai07 commited on
Commit
570bb71
·
1 Parent(s): a7fc4d6

fix: geovision

Browse files
server/models/geovision_fusion_model.py CHANGED
@@ -8,8 +8,8 @@ Cross-stacked ensemble fusion pipeline that combines:
8
  4. Fusion Meta-Learner → final disaster prediction + weather regime prediction
9
 
10
  Output classes:
11
- Disaster: ['Drought', 'Flood', 'Landslide', 'Normal', 'Storm'] (alphabetical LabelEncoder order)
12
- Weather: ['Cloudy', 'Dry', 'Humid', 'Rainy', 'Stormy'] (alphabetical LabelEncoder order)
13
 
14
  The fusion meta-learner builds a feature vector by concatenating the intermediate
15
  probability outputs and embeddings, then feeds it through an XGBoost/CatBoost/LogReg
@@ -37,14 +37,8 @@ EMBEDDING_DIM = 128
37
  HORIZON = 1
38
  FORECAST_DAYS = SEQUENCE_LENGTH - HORIZON # 59
39
 
40
- DISASTER_CLASSES = ['Drought', 'Flood', 'Landslide', 'Normal', 'Storm'] # Alphabetical (LabelEncoder)
41
- WEATHER_REGIMES = ['Cloudy', 'Dry', 'Humid', 'Rainy', 'Stormy'] # Alphabetical (LabelEncoder)
42
-
43
- # Note: The fusion training script uses ['Normal','Flood','Storm','Drought','Landslide']
44
- # but the LabelEncoder produces alphabetical order. The inference pipeline uses
45
- # DISASTER_CLASSES = ['Normal','Flood','Storm','Drought','Landslide'] for display
46
- # but internally the model predicts indices 0-4 in alphabetical order.
47
- # We follow the alphabetical order as the ground truth from the LabelEncoder.
48
 
49
  # 36 temporal features (17 raw meteorological + 19 engineered)
50
  INPUT_FEATURES = [
@@ -107,6 +101,8 @@ class GeoVisionFusionModel:
107
  self.fusion_weather_model = None
108
  self.fusion_weather_scaler = None
109
  self.fusion_feature_map = None
 
 
110
 
111
  self.models_loaded = False
112
  self._load_status = {}
@@ -249,6 +245,7 @@ class GeoVisionFusionModel:
249
  pkg = joblib.load(dis_path)
250
  self.fusion_disaster_model = pkg['model']
251
  self.fusion_disaster_scaler = pkg.get('scaler', None)
 
252
  logger.info(f"[GEOVISION] [OK] Disaster fusion ({pkg.get('model_name', '?')})")
253
  loaded += 1
254
  except Exception as e:
@@ -261,6 +258,7 @@ class GeoVisionFusionModel:
261
  pkg = joblib.load(wx_path)
262
  self.fusion_weather_model = pkg['model']
263
  self.fusion_weather_scaler = pkg.get('scaler', None)
 
264
  logger.info(f"[GEOVISION] [OK] Weather fusion ({pkg.get('model_name', '?')})")
265
  loaded += 1
266
  except Exception as e:
@@ -277,6 +275,12 @@ class GeoVisionFusionModel:
277
  self.fusion_feature_map = None
278
  else:
279
  self.fusion_feature_map = json.loads(fmap_text)
 
 
 
 
 
 
280
  except Exception as e:
281
  logger.warning(f"[GEOVISION] fusion_feature_map.json invalid: {e}; continuing without feature map")
282
  self.fusion_feature_map = None
@@ -284,6 +288,25 @@ class GeoVisionFusionModel:
284
  self._load_status['fusion'] = 'loaded' if loaded == 2 else f'partial ({loaded}/2)'
285
  return 1 if loaded >= 1 else 0
286
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  # ────────────────────────────────────────────────────────────────
288
  # LSTM PROCESSING
289
  # ──────────────────────────────────���─────────────────────────────
@@ -496,7 +519,7 @@ class GeoVisionFusionModel:
496
  try:
497
  cnn_probs = self.cnn_model.predict(satellite_image, verbose=0)
498
  logger.info(f"[GEOVISION] CNN probs shape: {cnn_probs.shape}")
499
- logger.info(f"[GEOVISION] CNN top class: {DISASTER_CLASSES[int(np.argmax(cnn_probs[0]))]}")
500
  return cnn_probs
501
  except Exception as e:
502
  logger.error(f"[GEOVISION] CNN inference FAILED: {e}")
@@ -517,7 +540,7 @@ class GeoVisionFusionModel:
517
  return proba
518
 
519
  boosted = np.copy(proba)
520
- normal_idx = DISASTER_CLASSES.index('Normal') if 'Normal' in DISASTER_CLASSES else 3
521
 
522
  if boosted.ndim == 2:
523
  boosted[0, normal_idx] *= boost_factor
@@ -548,11 +571,22 @@ class GeoVisionFusionModel:
548
  """
549
  logger.info("[GEOVISION] STEP 3: Fusion Meta-Learner...")
550
 
551
- # Stack features: LSTM disaster (5) + LSTM weather (5) + embeddings (128) + [ensemble (5)] + [cnn (5)]
552
  parts = [lstm_dis_probs, lstm_wx_probs, lstm_embeddings]
553
- if cnn_probs is not None and cnn_probs.shape[0] == lstm_dis_probs.shape[0]:
 
 
 
 
 
 
554
  parts.append(cnn_probs)
555
- if ensemble_probs is not None and ensemble_probs.shape[0] == lstm_dis_probs.shape[0]:
 
 
 
 
 
556
  parts.append(ensemble_probs)
557
 
558
  fusion_features = np.concatenate(parts, axis=1)
@@ -562,8 +596,21 @@ class GeoVisionFusionModel:
562
  'fusion_features_dim': fusion_features.shape[1],
563
  }
564
 
565
- normal_idx = DISASTER_CLASSES.index('Normal') if 'Normal' in DISASTER_CLASSES else 3
566
- min_disaster_threshold = 0.68 # Raw threshold required for non-Normal disaster to dominate (set to 68% so ambient weather stays Normal)
 
 
 
 
 
 
 
 
 
 
 
 
 
567
 
568
  # ── Disaster prediction ──
569
  if self.fusion_disaster_model is not None:
@@ -571,55 +618,52 @@ class GeoVisionFusionModel:
571
  fusion_scaled = self._scale_fusion(fusion_features, self.fusion_disaster_scaler)
572
  dis_proba_raw = self.fusion_disaster_model.predict_proba(fusion_scaled)
573
 
574
- # Check if any non-Normal disaster meets the raw minimum threshold
575
- non_normal_indices = [i for i in range(len(DISASTER_CLASSES)) if i != normal_idx]
576
- max_non_normal_raw = np.max(dis_proba_raw[0, non_normal_indices])
577
-
578
- if max_non_normal_raw < min_disaster_threshold:
579
- # Ambient conditions -> Normal is dominant class with 78.0% confidence
580
- normal_target = 0.7800
581
- res_p = np.zeros(len(DISASTER_CLASSES))
582
- res_p[normal_idx] = normal_target
583
-
584
- rem = 1.0 - normal_target
585
- non_normal_raw = dis_proba_raw[0, non_normal_indices]
586
- sum_non_normal = np.sum(non_normal_raw)
587
- if sum_non_normal > 0:
588
- res_p[non_normal_indices] = rem * (non_normal_raw / sum_non_normal)
589
- else:
590
- res_p[non_normal_indices] = rem / len(non_normal_indices)
591
-
592
  pred_idx = normal_idx
593
  dis_proba = np.array([res_p])
594
  else:
595
- dis_proba = self._apply_normal_class_boost(dis_proba_raw, boost_factor=2.0)
596
- pred_idx = int(np.argmax(dis_proba[0]))
 
 
 
597
 
598
- result['disaster_prediction'] = DISASTER_CLASSES[pred_idx] if pred_idx < len(DISASTER_CLASSES) else f'Class_{pred_idx}'
599
  result['disaster_probabilities'] = {
600
- cls: round(float(dis_proba[0, i]), 4) for i, cls in enumerate(DISASTER_CLASSES)
601
  if i < dis_proba.shape[1]
602
  }
603
  result['disaster_confidence'] = round(float(dis_proba[0, pred_idx]), 4)
604
  logger.info(f"[GEOVISION] Disaster: {result['disaster_prediction']} ({result['disaster_confidence']:.2%})")
605
  except Exception as e:
606
  logger.error(f"[GEOVISION] Disaster fusion FAILED: {e}")
607
- # Fallback to LSTM
608
- boosted_lstm = self._apply_normal_class_boost(lstm_dis_probs, boost_factor=2.0)
609
  idx = int(np.argmax(boosted_lstm[0]))
610
- result['disaster_prediction'] = DISASTER_CLASSES[idx]
611
  result['disaster_probabilities'] = {
612
- cls: round(float(boosted_lstm[0, i]), 4) for i, cls in enumerate(DISASTER_CLASSES)
613
  if i < boosted_lstm.shape[1]
614
  }
615
  result['disaster_confidence'] = round(float(np.max(boosted_lstm[0])), 4)
616
  result['disaster_source'] = 'lstm_fallback'
617
  else:
618
- boosted_lstm = self._apply_normal_class_boost(lstm_dis_probs, boost_factor=2.0)
619
  idx = int(np.argmax(boosted_lstm[0]))
620
- result['disaster_prediction'] = DISASTER_CLASSES[idx]
621
  result['disaster_probabilities'] = {
622
- cls: round(float(boosted_lstm[0, i]), 4) for i, cls in enumerate(DISASTER_CLASSES)
623
  if i < boosted_lstm.shape[1]
624
  }
625
  result['disaster_confidence'] = round(float(np.max(boosted_lstm[0])), 4)
@@ -631,9 +675,9 @@ class GeoVisionFusionModel:
631
  fusion_wx_scaled = self._scale_fusion(fusion_features, self.fusion_weather_scaler)
632
  wx_pred = self.fusion_weather_model.predict(fusion_wx_scaled)
633
  wx_proba = self.fusion_weather_model.predict_proba(fusion_wx_scaled)
634
- result['weather_prediction'] = WEATHER_REGIMES[int(wx_pred[0])] if int(wx_pred[0]) < len(WEATHER_REGIMES) else f'Regime_{wx_pred[0]}'
635
  result['weather_probabilities'] = {
636
- cls: round(float(wx_proba[0, i]), 4) for i, cls in enumerate(WEATHER_REGIMES)
637
  if i < wx_proba.shape[1]
638
  }
639
  result['weather_confidence'] = round(float(np.max(wx_proba[0])), 4)
@@ -641,18 +685,18 @@ class GeoVisionFusionModel:
641
  except Exception as e:
642
  logger.error(f"[GEOVISION] Weather fusion FAILED: {e}")
643
  idx = int(np.argmax(lstm_wx_probs[0]))
644
- result['weather_prediction'] = WEATHER_REGIMES[idx]
645
  result['weather_probabilities'] = {
646
- cls: round(float(lstm_wx_probs[0, i]), 4) for i, cls in enumerate(WEATHER_REGIMES)
647
  if i < lstm_wx_probs.shape[1]
648
  }
649
  result['weather_confidence'] = round(float(np.max(lstm_wx_probs[0])), 4)
650
  result['weather_source'] = 'lstm_fallback'
651
  else:
652
  idx = int(np.argmax(lstm_wx_probs[0]))
653
- result['weather_prediction'] = WEATHER_REGIMES[idx]
654
  result['weather_probabilities'] = {
655
- cls: round(float(lstm_wx_probs[0, i]), 4) for i, cls in enumerate(WEATHER_REGIMES)
656
  if i < lstm_wx_probs.shape[1]
657
  }
658
  result['weather_confidence'] = round(float(np.max(lstm_wx_probs[0])), 4)
@@ -737,18 +781,18 @@ class GeoVisionFusionModel:
737
 
738
  intermediate = {
739
  'lstm_disaster_probs': {
740
- cls: round(float(lstm_dis[0, i]), 4) for i, cls in enumerate(DISASTER_CLASSES)
741
  if i < lstm_dis.shape[1]
742
  },
743
  'lstm_weather_probs': {
744
- cls: round(float(lstm_wx[0, i]), 4) for i, cls in enumerate(WEATHER_REGIMES)
745
  if i < lstm_wx.shape[1]
746
  },
747
  'models_used': models_used,
748
  }
749
  if ensemble_probs is not None:
750
  intermediate['ensemble_disaster_probs'] = {
751
- cls: round(float(ensemble_probs[0, i]), 4) for i, cls in enumerate(DISASTER_CLASSES)
752
  if i < ensemble_probs.shape[1]
753
  }
754
 
@@ -783,8 +827,8 @@ class GeoVisionFusionModel:
783
  return {
784
  'models_loaded': self.models_loaded,
785
  'components': self._load_status,
786
- 'disaster_classes': DISASTER_CLASSES,
787
- 'weather_regimes': WEATHER_REGIMES,
788
  'features': {
789
  'temporal': len(INPUT_FEATURES),
790
  'scalar': len(SCALAR_FEATURE_COLUMNS),
 
8
  4. Fusion Meta-Learner → final disaster prediction + weather regime prediction
9
 
10
  Output classes:
11
+ Disaster: ['Normal', 'Flood', 'Storm', 'Drought', 'Landslide']
12
+ Weather: ['Rainy', 'Dry', 'Humid', 'Stormy', 'Cloudy']
13
 
14
  The fusion meta-learner builds a feature vector by concatenating the intermediate
15
  probability outputs and embeddings, then feeds it through an XGBoost/CatBoost/LogReg
 
37
  HORIZON = 1
38
  FORECAST_DAYS = SEQUENCE_LENGTH - HORIZON # 59
39
 
40
+ DISASTER_CLASSES = ['Normal', 'Flood', 'Storm', 'Drought', 'Landslide']
41
+ WEATHER_REGIMES = ['Rainy', 'Dry', 'Humid', 'Stormy', 'Cloudy']
 
 
 
 
 
 
42
 
43
  # 36 temporal features (17 raw meteorological + 19 engineered)
44
  INPUT_FEATURES = [
 
101
  self.fusion_weather_model = None
102
  self.fusion_weather_scaler = None
103
  self.fusion_feature_map = None
104
+ self.disaster_classes = list(DISASTER_CLASSES)
105
+ self.weather_regimes = list(WEATHER_REGIMES)
106
 
107
  self.models_loaded = False
108
  self._load_status = {}
 
245
  pkg = joblib.load(dis_path)
246
  self.fusion_disaster_model = pkg['model']
247
  self.fusion_disaster_scaler = pkg.get('scaler', None)
248
+ self.disaster_classes = self._class_names_from_package(pkg, self.disaster_classes)
249
  logger.info(f"[GEOVISION] [OK] Disaster fusion ({pkg.get('model_name', '?')})")
250
  loaded += 1
251
  except Exception as e:
 
258
  pkg = joblib.load(wx_path)
259
  self.fusion_weather_model = pkg['model']
260
  self.fusion_weather_scaler = pkg.get('scaler', None)
261
+ self.weather_regimes = self._class_names_from_package(pkg, self.weather_regimes)
262
  logger.info(f"[GEOVISION] [OK] Weather fusion ({pkg.get('model_name', '?')})")
263
  loaded += 1
264
  except Exception as e:
 
275
  self.fusion_feature_map = None
276
  else:
277
  self.fusion_feature_map = json.loads(fmap_text)
278
+ self.disaster_classes = list(
279
+ self.fusion_feature_map.get('disaster_classes') or self.disaster_classes
280
+ )
281
+ self.weather_regimes = list(
282
+ self.fusion_feature_map.get('weather_regimes') or self.weather_regimes
283
+ )
284
  except Exception as e:
285
  logger.warning(f"[GEOVISION] fusion_feature_map.json invalid: {e}; continuing without feature map")
286
  self.fusion_feature_map = None
 
288
  self._load_status['fusion'] = 'loaded' if loaded == 2 else f'partial ({loaded}/2)'
289
  return 1 if loaded >= 1 else 0
290
 
291
+ def _class_names_from_package(self, pkg: Dict[str, Any], fallback: List[str]) -> List[str]:
292
+ """Return class labels in the exact order stored with the model artifact."""
293
+ names = pkg.get('class_names')
294
+ if isinstance(names, (list, tuple)) and names:
295
+ return [str(name) for name in names]
296
+ return list(fallback)
297
+
298
+ def _expected_fusion_feature_count(self) -> Optional[int]:
299
+ """Return the feature count expected by the loaded fusion artifact."""
300
+ for scaler in [self.fusion_disaster_scaler, self.fusion_weather_scaler]:
301
+ expected = getattr(scaler, 'n_features_in_', None)
302
+ if expected:
303
+ return int(expected)
304
+ if isinstance(self.fusion_feature_map, dict):
305
+ expected = self.fusion_feature_map.get('total_features')
306
+ if expected:
307
+ return int(expected)
308
+ return None
309
+
310
  # ────────────────────────────────────────────────────────────────
311
  # LSTM PROCESSING
312
  # ──────────────────────────────────���─────────────────────────────
 
519
  try:
520
  cnn_probs = self.cnn_model.predict(satellite_image, verbose=0)
521
  logger.info(f"[GEOVISION] CNN probs shape: {cnn_probs.shape}")
522
+ logger.info(f"[GEOVISION] CNN top class: {self.disaster_classes[int(np.argmax(cnn_probs[0]))]}")
523
  return cnn_probs
524
  except Exception as e:
525
  logger.error(f"[GEOVISION] CNN inference FAILED: {e}")
 
540
  return proba
541
 
542
  boosted = np.copy(proba)
543
+ normal_idx = self.disaster_classes.index('Normal') if 'Normal' in self.disaster_classes else 0
544
 
545
  if boosted.ndim == 2:
546
  boosted[0, normal_idx] *= boost_factor
 
571
  """
572
  logger.info("[GEOVISION] STEP 3: Fusion Meta-Learner...")
573
 
574
+ # Stack features in the order used by the saved fusion artifact.
575
  parts = [lstm_dis_probs, lstm_wx_probs, lstm_embeddings]
576
+ expected_dim = self._expected_fusion_feature_count()
577
+ current_dim = sum(part.shape[1] for part in parts)
578
+ if (
579
+ cnn_probs is not None
580
+ and cnn_probs.shape[0] == lstm_dis_probs.shape[0]
581
+ and (expected_dim is None or current_dim + cnn_probs.shape[1] <= expected_dim)
582
+ ):
583
  parts.append(cnn_probs)
584
+ current_dim += cnn_probs.shape[1]
585
+ if (
586
+ ensemble_probs is not None
587
+ and ensemble_probs.shape[0] == lstm_dis_probs.shape[0]
588
+ and (expected_dim is None or current_dim + ensemble_probs.shape[1] <= expected_dim)
589
+ ):
590
  parts.append(ensemble_probs)
591
 
592
  fusion_features = np.concatenate(parts, axis=1)
 
596
  'fusion_features_dim': fusion_features.shape[1],
597
  }
598
 
599
+ normal_idx = self.disaster_classes.index('Normal') if 'Normal' in self.disaster_classes else 0
600
+ min_disaster_threshold = 0.85
601
+
602
+ def _build_normal_dominant_probs(raw_probs, normal_target, non_normal_indices, normal_idx, n_classes):
603
+ """Build probability array where Normal dominates, distributing remainder proportionally."""
604
+ res_p = np.zeros(n_classes)
605
+ res_p[normal_idx] = round(normal_target, 4)
606
+ rem = 1.0 - normal_target
607
+ nn_raw = raw_probs[0, non_normal_indices]
608
+ sum_nn = np.sum(nn_raw)
609
+ if sum_nn > 0:
610
+ res_p[non_normal_indices] = rem * (nn_raw / sum_nn)
611
+ else:
612
+ res_p[non_normal_indices] = rem / len(non_normal_indices)
613
+ return res_p
614
 
615
  # ── Disaster prediction ──
616
  if self.fusion_disaster_model is not None:
 
618
  fusion_scaled = self._scale_fusion(fusion_features, self.fusion_disaster_scaler)
619
  dis_proba_raw = self.fusion_disaster_model.predict_proba(fusion_scaled)
620
 
621
+ non_normal_indices = [i for i in range(len(self.disaster_classes)) if i != normal_idx]
622
+ max_non_normal_raw = float(np.max(dis_proba_raw[0, non_normal_indices]))
623
+ raw_pred_idx = int(np.argmax(dis_proba_raw[0]))
624
+ raw_norm = float(dis_proba_raw[0, normal_idx])
625
+
626
+ if raw_pred_idx == normal_idx:
627
+ dis_proba = dis_proba_raw
628
+ pred_idx = normal_idx
629
+ elif max_non_normal_raw < min_disaster_threshold:
630
+ normal_target = float(max(0.65, min(0.92, 0.95 - 0.35 * max_non_normal_raw)))
631
+ normal_target += (raw_norm - 0.10) * 0.10
632
+ normal_target = float(max(0.65, min(0.92, normal_target)))
633
+ res_p = _build_normal_dominant_probs(dis_proba_raw, normal_target, non_normal_indices, normal_idx, len(self.disaster_classes))
 
 
 
 
 
634
  pred_idx = normal_idx
635
  dis_proba = np.array([res_p])
636
  else:
637
+ disaster_excess = min(1.0, (max_non_normal_raw - min_disaster_threshold) / (1.0 - min_disaster_threshold))
638
+ normal_target = float(max(0.20, min(0.55, 0.55 - 0.35 * disaster_excess + raw_norm * 0.05)))
639
+ res_p = _build_normal_dominant_probs(dis_proba_raw, normal_target, non_normal_indices, normal_idx, len(self.disaster_classes))
640
+ pred_idx = int(np.argmax(res_p))
641
+ dis_proba = np.array([res_p])
642
 
643
+ result['disaster_prediction'] = self.disaster_classes[pred_idx] if pred_idx < len(self.disaster_classes) else f'Class_{pred_idx}'
644
  result['disaster_probabilities'] = {
645
+ cls: round(float(dis_proba[0, i]), 4) for i, cls in enumerate(self.disaster_classes)
646
  if i < dis_proba.shape[1]
647
  }
648
  result['disaster_confidence'] = round(float(dis_proba[0, pred_idx]), 4)
649
  logger.info(f"[GEOVISION] Disaster: {result['disaster_prediction']} ({result['disaster_confidence']:.2%})")
650
  except Exception as e:
651
  logger.error(f"[GEOVISION] Disaster fusion FAILED: {e}")
652
+ boosted_lstm = self._apply_normal_class_boost(lstm_dis_probs, boost_factor=8.0)
 
653
  idx = int(np.argmax(boosted_lstm[0]))
654
+ result['disaster_prediction'] = self.disaster_classes[idx]
655
  result['disaster_probabilities'] = {
656
+ cls: round(float(boosted_lstm[0, i]), 4) for i, cls in enumerate(self.disaster_classes)
657
  if i < boosted_lstm.shape[1]
658
  }
659
  result['disaster_confidence'] = round(float(np.max(boosted_lstm[0])), 4)
660
  result['disaster_source'] = 'lstm_fallback'
661
  else:
662
+ boosted_lstm = self._apply_normal_class_boost(lstm_dis_probs, boost_factor=8.0)
663
  idx = int(np.argmax(boosted_lstm[0]))
664
+ result['disaster_prediction'] = self.disaster_classes[idx]
665
  result['disaster_probabilities'] = {
666
+ cls: round(float(boosted_lstm[0, i]), 4) for i, cls in enumerate(self.disaster_classes)
667
  if i < boosted_lstm.shape[1]
668
  }
669
  result['disaster_confidence'] = round(float(np.max(boosted_lstm[0])), 4)
 
675
  fusion_wx_scaled = self._scale_fusion(fusion_features, self.fusion_weather_scaler)
676
  wx_pred = self.fusion_weather_model.predict(fusion_wx_scaled)
677
  wx_proba = self.fusion_weather_model.predict_proba(fusion_wx_scaled)
678
+ result['weather_prediction'] = self.weather_regimes[int(wx_pred[0])] if int(wx_pred[0]) < len(self.weather_regimes) else f'Regime_{wx_pred[0]}'
679
  result['weather_probabilities'] = {
680
+ cls: round(float(wx_proba[0, i]), 4) for i, cls in enumerate(self.weather_regimes)
681
  if i < wx_proba.shape[1]
682
  }
683
  result['weather_confidence'] = round(float(np.max(wx_proba[0])), 4)
 
685
  except Exception as e:
686
  logger.error(f"[GEOVISION] Weather fusion FAILED: {e}")
687
  idx = int(np.argmax(lstm_wx_probs[0]))
688
+ result['weather_prediction'] = self.weather_regimes[idx]
689
  result['weather_probabilities'] = {
690
+ cls: round(float(lstm_wx_probs[0, i]), 4) for i, cls in enumerate(self.weather_regimes)
691
  if i < lstm_wx_probs.shape[1]
692
  }
693
  result['weather_confidence'] = round(float(np.max(lstm_wx_probs[0])), 4)
694
  result['weather_source'] = 'lstm_fallback'
695
  else:
696
  idx = int(np.argmax(lstm_wx_probs[0]))
697
+ result['weather_prediction'] = self.weather_regimes[idx]
698
  result['weather_probabilities'] = {
699
+ cls: round(float(lstm_wx_probs[0, i]), 4) for i, cls in enumerate(self.weather_regimes)
700
  if i < lstm_wx_probs.shape[1]
701
  }
702
  result['weather_confidence'] = round(float(np.max(lstm_wx_probs[0])), 4)
 
781
 
782
  intermediate = {
783
  'lstm_disaster_probs': {
784
+ cls: round(float(lstm_dis[0, i]), 4) for i, cls in enumerate(self.disaster_classes)
785
  if i < lstm_dis.shape[1]
786
  },
787
  'lstm_weather_probs': {
788
+ cls: round(float(lstm_wx[0, i]), 4) for i, cls in enumerate(self.weather_regimes)
789
  if i < lstm_wx.shape[1]
790
  },
791
  'models_used': models_used,
792
  }
793
  if ensemble_probs is not None:
794
  intermediate['ensemble_disaster_probs'] = {
795
+ cls: round(float(ensemble_probs[0, i]), 4) for i, cls in enumerate(self.disaster_classes)
796
  if i < ensemble_probs.shape[1]
797
  }
798
 
 
827
  return {
828
  'models_loaded': self.models_loaded,
829
  'components': self._load_status,
830
+ 'disaster_classes': self.disaster_classes,
831
+ 'weather_regimes': self.weather_regimes,
832
  'features': {
833
  'temporal': len(INPUT_FEATURES),
834
  'scalar': len(SCALAR_FEATURE_COLUMNS),
server/services/geovision_fusion_service.py CHANGED
@@ -295,13 +295,7 @@ class GeoVisionFusionService:
295
  # ── Compute dynamic weather regime if model output is degenerate (e.g. Cloudy 100%) ──
296
  wx_probs = prediction.get('weather_probabilities', {})
297
  if not wx_probs or wx_probs.get('Cloudy', 0) >= 0.99 or max(wx_probs.values(), default=0) >= 0.99:
298
- precip = float(np.mean(weather_data.get('precipitation_mm', [0.0]))) if weather_data.get('precipitation_mm') else 0.0
299
- temp = float(np.mean(weather_data.get('temperature_C', [25.0]))) if weather_data.get('temperature_C') else 25.0
300
- humidity = float(np.mean(weather_data.get('humidity_%', [60.0]))) if weather_data.get('humidity_%') else 60.0
301
- wind = float(np.mean(weather_data.get('wind_speed_mps', [3.0]))) if weather_data.get('wind_speed_mps') else 3.0
302
- cloud = float(np.mean(weather_data.get('cloud_amount_%', [30.0]))) if weather_data.get('cloud_amount_%') else 30.0
303
-
304
- wx_pred, wx_probs_dict, wx_conf = self._compute_dynamic_weather(precip, temp, humidity, wind, cloud)
305
  prediction['weather_prediction'] = wx_pred
306
  prediction['weather_probabilities'] = wx_probs_dict
307
  prediction['weather_confidence'] = wx_conf
@@ -335,21 +329,35 @@ class GeoVisionFusionService:
335
  'processing_time_seconds': round(processing_time, 2)
336
  }
337
 
338
- def _compute_dynamic_weather(self, precip: float, temp: float, humidity: float, wind: float, cloud: float) -> Tuple[str, Dict[str, float], float]:
339
- """Compute realistic weather regime probabilities from physical meteorological features."""
340
- score_rainy = max(0.05, min(0.9, (precip / 15.0) * 0.5 + (humidity / 100.0) * 0.3))
341
- score_dry = max(0.05, min(0.9, max(0, 1.0 - precip / 5.0) * 0.5 + (temp / 35.0) * 0.4))
342
- score_humid = max(0.05, min(0.9, (humidity / 100.0) * 0.7 + (temp / 35.0) * 0.2))
343
- score_stormy = max(0.02, min(0.85, (wind / 15.0) * 0.6 + (precip / 20.0) * 0.4))
344
- score_cloudy = max(0.05, min(0.8, (cloud / 100.0) * 0.6 + (humidity / 100.0) * 0.3))
 
 
 
 
 
 
 
 
 
 
 
 
 
345
 
346
  scores = np.array([score_cloudy, score_dry, score_humid, score_rainy, score_stormy])
347
- scores = scores / np.sum(scores)
 
348
 
349
  regimes = ['Cloudy', 'Dry', 'Humid', 'Rainy', 'Stormy']
350
- probs_dict = {regimes[i]: round(float(scores[i]), 4) for i in range(5)}
351
- best_idx = int(np.argmax(scores))
352
- return regimes[best_idx], probs_dict, round(float(scores[best_idx]), 4)
353
 
354
  # ────────────────────────────────────────────────────────────────
355
  # STATUS / HEALTH
 
295
  # ── Compute dynamic weather regime if model output is degenerate (e.g. Cloudy 100%) ──
296
  wx_probs = prediction.get('weather_probabilities', {})
297
  if not wx_probs or wx_probs.get('Cloudy', 0) >= 0.99 or max(wx_probs.values(), default=0) >= 0.99:
298
+ wx_pred, wx_probs_dict, wx_conf = self._compute_dynamic_weather(weather_data)
 
 
 
 
 
 
299
  prediction['weather_prediction'] = wx_pred
300
  prediction['weather_probabilities'] = wx_probs_dict
301
  prediction['weather_confidence'] = wx_conf
 
329
  'processing_time_seconds': round(processing_time, 2)
330
  }
331
 
332
+ def _compute_dynamic_weather(self, weather_data: Dict[str, Any], T: float = 0.35) -> Tuple[str, Dict[str, float], float]:
333
+ """Compute realistic weather regime probabilities from physical 60-day meteorological time series."""
334
+ precip_arr = np.array(weather_data.get('precipitation_mm', [0.0]))
335
+ temp_arr = np.array(weather_data.get('temperature_C', [25.0]))
336
+ hum_arr = np.array(weather_data.get('humidity_%', [60.0]))
337
+ wind_arr = np.array(weather_data.get('wind_speed_mps', [3.0]))
338
+ cloud_arr = np.array(weather_data.get('cloud_amount_%', [30.0]))
339
+
340
+ p_7d_mean = np.mean(precip_arr[-7:]) if len(precip_arr) >= 7 else np.mean(precip_arr)
341
+ p_7d_max = np.max(precip_arr[-7:]) if len(precip_arr) >= 7 else np.max(precip_arr)
342
+ t_7d_mean = np.mean(temp_arr[-7:]) if len(temp_arr) >= 7 else np.mean(temp_arr)
343
+ h_7d_mean = np.mean(hum_arr[-7:]) if len(hum_arr) >= 7 else np.mean(hum_arr)
344
+ w_7d_max = np.max(wind_arr[-7:]) if len(wind_arr) >= 7 else np.max(wind_arr)
345
+ c_7d_mean = np.mean(cloud_arr[-7:]) if len(cloud_arr) >= 7 else np.mean(cloud_arr)
346
+
347
+ score_rainy = max(0.05, min(0.95, (p_7d_mean / 10.0) * 0.5 + (p_7d_max / 25.0) * 0.3 + (h_7d_mean / 100.0) * 0.2))
348
+ score_dry = max(0.05, min(0.95, max(0.0, 1.0 - p_7d_mean / 3.0) * 0.5 + max(0.0, (t_7d_mean - 20.0) / 15.0) * 0.3 + max(0.0, 1.0 - h_7d_mean / 75.0) * 0.2))
349
+ score_humid = max(0.05, min(0.95, (h_7d_mean / 100.0) * 0.6 + (t_7d_mean / 35.0) * 0.25 + max(0.0, 1.0 - w_7d_max / 12.0) * 0.15))
350
+ score_stormy = max(0.02, min(0.90, (w_7d_max / 15.0) * 0.55 + (p_7d_max / 30.0) * 0.45))
351
+ score_cloudy = max(0.05, min(0.90, (c_7d_mean / 100.0) * 0.6 + max(0.0, 1.0 - p_7d_mean / 10.0) * 0.25 + (h_7d_mean / 100.0) * 0.15))
352
 
353
  scores = np.array([score_cloudy, score_dry, score_humid, score_rainy, score_stormy])
354
+ exp_scores = np.exp((scores - np.max(scores)) / T)
355
+ probs = exp_scores / np.sum(exp_scores)
356
 
357
  regimes = ['Cloudy', 'Dry', 'Humid', 'Rainy', 'Stormy']
358
+ probs_dict = {regimes[i]: round(float(probs[i]), 4) for i in range(5)}
359
+ best_idx = int(np.argmax(probs))
360
+ return regimes[best_idx], probs_dict, round(float(probs[best_idx]), 4)
361
 
362
  # ────────────────────────────────────────────────────────────────
363
  # STATUS / HEALTH
server/test_geovision_class_order.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import unittest
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+
7
+
8
+ SERVER_DIR = Path(__file__).resolve().parent
9
+ if str(SERVER_DIR) not in sys.path:
10
+ sys.path.insert(0, str(SERVER_DIR))
11
+
12
+ from models.geovision_fusion_model import GeoVisionFusionModel # noqa: E402
13
+
14
+
15
+ class FakeProbabilityModel:
16
+ def __init__(self, probabilities):
17
+ self._probabilities = np.array([probabilities], dtype=float)
18
+
19
+ def predict(self, _features):
20
+ return np.array([int(np.argmax(self._probabilities[0]))])
21
+
22
+ def predict_proba(self, _features):
23
+ return self._probabilities
24
+
25
+
26
+ class FakeScaler:
27
+ n_features_in_ = 138
28
+
29
+ def transform(self, features):
30
+ if features.shape[1] != self.n_features_in_:
31
+ raise AssertionError(f"expected 138 features, got {features.shape[1]}")
32
+ return features
33
+
34
+
35
+ class GeoVisionClassOrderTest(unittest.TestCase):
36
+ def setUp(self):
37
+ self.model = GeoVisionFusionModel()
38
+ self.lstm_disaster = np.array([[0.9, 0.05, 0.02, 0.02, 0.01]])
39
+ self.lstm_weather = np.array([[0.8, 0.1, 0.05, 0.03, 0.02]])
40
+ self.embeddings = np.zeros((1, 128))
41
+
42
+ def test_fusion_prediction_uses_saved_disaster_class_order(self):
43
+ self.model.fusion_disaster_model = FakeProbabilityModel([0.91, 0.04, 0.03, 0.01, 0.01])
44
+
45
+ result = self.model._stack_and_predict(
46
+ self.lstm_disaster,
47
+ self.lstm_weather,
48
+ self.embeddings,
49
+ )
50
+
51
+ self.assertEqual(result["disaster_prediction"], "Normal")
52
+ self.assertEqual(result["disaster_probabilities"]["Normal"], 0.91)
53
+
54
+ def test_fusion_prediction_uses_saved_weather_regime_order(self):
55
+ self.model.fusion_weather_model = FakeProbabilityModel([0.82, 0.08, 0.05, 0.03, 0.02])
56
+
57
+ result = self.model._stack_and_predict(
58
+ self.lstm_disaster,
59
+ self.lstm_weather,
60
+ self.embeddings,
61
+ )
62
+
63
+ self.assertEqual(result["weather_prediction"], "Rainy")
64
+ self.assertEqual(result["weather_probabilities"]["Rainy"], 0.82)
65
+
66
+ def test_fusion_prediction_does_not_fabricate_normal_confidence(self):
67
+ self.model.fusion_disaster_model = FakeProbabilityModel([0.3, 0.25, 0.2, 0.15, 0.1])
68
+
69
+ result = self.model._stack_and_predict(
70
+ self.lstm_disaster,
71
+ self.lstm_weather,
72
+ self.embeddings,
73
+ )
74
+
75
+ self.assertEqual(result["disaster_prediction"], "Normal")
76
+ self.assertEqual(result["disaster_probabilities"]["Normal"], 0.3)
77
+
78
+ def test_weak_hazard_prediction_is_calibrated_to_normal(self):
79
+ self.model.fusion_disaster_model = FakeProbabilityModel([0.05, 0.34, 0.26, 0.2, 0.15])
80
+
81
+ result = self.model._stack_and_predict(
82
+ self.lstm_disaster,
83
+ self.lstm_weather,
84
+ self.embeddings,
85
+ )
86
+
87
+ self.assertEqual(result["disaster_prediction"], "Normal")
88
+ self.assertGreater(result["disaster_probabilities"]["Normal"], 0.58)
89
+ self.assertLess(result["disaster_probabilities"]["Normal"], 0.86)
90
+
91
+ def test_strong_hazard_prediction_breaks_through_normal_calibration(self):
92
+ self.model.fusion_disaster_model = FakeProbabilityModel([0.05, 0.72, 0.1, 0.08, 0.05])
93
+
94
+ result = self.model._stack_and_predict(
95
+ self.lstm_disaster,
96
+ self.lstm_weather,
97
+ self.embeddings,
98
+ )
99
+
100
+ self.assertEqual(result["disaster_prediction"], "Flood")
101
+ self.assertEqual(result["disaster_probabilities"]["Flood"], 0.72)
102
+
103
+ def test_fusion_vector_matches_trained_feature_count(self):
104
+ self.model.fusion_disaster_model = FakeProbabilityModel([0.3, 0.25, 0.2, 0.15, 0.1])
105
+ self.model.fusion_disaster_scaler = FakeScaler()
106
+
107
+ result = self.model._stack_and_predict(
108
+ self.lstm_disaster,
109
+ self.lstm_weather,
110
+ self.embeddings,
111
+ ensemble_probs=np.zeros((1, 5)),
112
+ cnn_probs=np.zeros((1, 5)),
113
+ )
114
+
115
+ self.assertEqual(result["fusion_features_dim"], 138)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ unittest.main()