jaimalklyde commited on
Commit
42b023d
·
verified ·
1 Parent(s): d802c2d

Update src/module/Dashboard.py

Browse files
Files changed (1) hide show
  1. src/module/Dashboard.py +151 -83
src/module/Dashboard.py CHANGED
@@ -23,59 +23,60 @@ class HospitalDashboard:
23
  def __init__(self, client, sheet_name):
24
  self.client = client
25
  self.sheet_name = sheet_name
26
- # --- INITIALISATION ---
27
- # On définit les attributs à None pour éviter les erreurs "AttributeError"
28
  self.df_ressources = None
29
  self.df_admissions = None
30
  self.df_stock = None
31
 
32
  def load_data(self):
33
  """Charge les données via le cache ou l'ETL"""
34
- # 1. Tenter de récupérer depuis le session_state (Cache ETL déjà existant)
35
- if 'data_explorer' in st.session_state and hasattr(st.session_state.data_explorer, 'dataframes'):
36
- dfs = st.session_state.data_explorer.dataframes
37
  # Mapping sécurisé
38
  self.df_ressources = dfs.get('ressources_jour')
39
  self.df_admissions = dfs.get('admissions_jour')
40
  self.df_stock = dfs.get('stock_materiel_jour')
41
  return True
42
 
43
- # 2. Sinon, lancer l'ETL silencieusement (Premier chargement)
44
  etl = ETL_Engine(self.client, self.sheet_name)
45
  success, result = etl.run_etl()
46
  if success:
47
- # Mise à jour du state global
48
  st.session_state.data_explorer = result
49
- # Mapping local
50
- self.df_ressources = result.dataframes.get('ressources_jour')
51
- self.df_admissions = result.dataframes.get('admissions_jour')
52
- self.df_stock = result.dataframes.get('stock_materiel_jour')
53
  return True
54
 
55
  return False
56
 
57
  def _get_kpis(self):
58
- """Calcul des KPIs globaux avec typage forcé pour éviter les erreurs"""
59
  # Sécurité : DataFrames vides si None
60
  df_res = self.df_ressources.copy() if self.df_ressources is not None else pd.DataFrame()
61
  df_adm = self.df_admissions.copy() if self.df_admissions is not None else pd.DataFrame()
62
  df_stock = self.df_stock.copy() if self.df_stock is not None else pd.DataFrame()
63
 
64
- # --- 1. SÉCURITÉ TYPES (Int vs Str) ---
65
- # On s'assure que les colonnes vitales sont bien des nombres
66
- if 'lits_occupes' in df_res.columns:
67
- df_res['lits_occupes'] = pd.to_numeric(df_res['lits_occupes'], errors='coerce').fillna(0)
68
- if 'lits_total' in df_res.columns:
69
- df_res['lits_total'] = pd.to_numeric(df_res['lits_total'], errors='coerce').fillna(0)
70
- if 'personnel_disponible' in df_res.columns:
71
- df_res['personnel_disponible'] = pd.to_numeric(df_res['personnel_disponible'], errors='coerce').fillna(0)
72
- if 'personnel_requis' in df_res.columns:
73
- df_res['personnel_requis'] = pd.to_numeric(df_res['personnel_requis'], errors='coerce').fillna(0)
74
-
75
- # --- 2. CALCULS KPI ---
 
 
 
 
76
  # A. Occupation
77
- total_lits = df_res['lits_total'].sum()
78
- total_occupes = df_res['lits_occupes'].sum()
79
 
80
  if total_lits > 0:
81
  taux_global = (total_occupes / total_lits) * 100
@@ -83,50 +84,51 @@ class HospitalDashboard:
83
  taux_global = 0
84
 
85
  # B. Tension RH
86
- total_staff_dispo = df_res['personnel_disponible'].sum()
87
- total_staff_req = df_res['personnel_requis'].sum()
88
 
89
  tension_rh = 0
90
  if total_staff_req > 0:
91
  tension_rh = (1 - (total_staff_dispo / total_staff_req)) * 100
92
 
93
- # C. Admissions du jour (On fait la somme de la colonne)
94
- nb_adm = 0
95
- if not df_adm.empty and 'nb_admissions' in df_adm.columns:
96
- nb_adm = pd.to_numeric(df_adm['nb_admissions'], errors='coerce').sum()
97
 
98
- # D. Services en Tension (Combien de services > 85% ?)
 
99
  nb_tension = 0
100
  if not df_res.empty and 'taux_occupation' in df_res.columns:
101
- nb_tension = df_res[df_res['taux_occupation'] > 0.85].shape[0]
102
 
103
- # E. Ruptures Stock (On cherche les mots clés 'Rupture' ou 'Critique')
104
  nb_ruptures = 0
105
  if not df_stock.empty and 'alerte_stock' in df_stock.columns:
106
- nb_ruptures = df_stock[df_stock['alerte_stock'].str.contains('Rupture|Critique', case=False, na=False)].shape[0]
107
 
108
  return {
109
  'lits_total': int(total_lits),
110
  'lits_occupes': int(total_occupes),
111
- 'taux_global': taux_global,
112
- 'tension_rh': tension_rh,
113
  'nb_adm': int(nb_adm),
114
  'nb_tension': int(nb_tension),
115
  'nb_ruptures': int(nb_ruptures)
116
  }
117
 
118
  def plot_main_chart(self):
119
- """Graphique Hybride : Historique + Prédiction Standard"""
120
  fig = go.Figure()
121
 
122
  # Historique
123
  if self.df_ressources is not None and not self.df_ressources.empty:
124
  df_hist = self.df_ressources.copy()
125
  if 'date' in df_hist.columns:
126
- df_hist['date'] = pd.to_datetime(df_hist['date'])
127
- # On moyenne l'occupation par jour
 
 
 
128
  daily_occ = df_hist.groupby('date')['taux_occupation'].mean() * 100
129
- # On garde les 30 derniers jours pour lisibilité
130
  daily_occ = daily_occ.tail(30)
131
 
132
  fig.add_trace(go.Scatter(
@@ -136,16 +138,16 @@ class HospitalDashboard:
136
  fill='tozeroy', fillcolor='rgba(88, 166, 255, 0.1)'
137
  ))
138
 
139
- # Prédiction IA (si le module forecasting a tourné)
140
  has_forecast = False
141
  if 'forecaster' in st.session_state:
142
  forecaster = st.session_state['forecaster']
143
  try:
144
- # --- CORRECTION FORECASTING (On ignore la 3ème valeur '_') ---
145
  f_dates, f_values, _ = forecaster.generate_future_forecast_recursive(months=1)
146
 
147
- # Adaptation d'échelle si le modèle sort des taux (0.8) au lieu de pourcentages (80)
148
- if np.mean(f_values) < 2: f_values = f_values * 100
 
149
 
150
  fig.add_trace(go.Scatter(
151
  x=f_dates, y=f_values,
@@ -156,36 +158,51 @@ class HospitalDashboard:
156
  except Exception:
157
  pass
158
 
159
- # Ligne rouge de saturation
160
  fig.add_hline(y=100, line_dash="dash", line_color="#FF5252", annotation_text="Saturation")
161
 
162
  title = "Taux d'Occupation : Réalité vs IA" if has_forecast else "Taux d'Occupation (Historique)"
163
- fig.update_layout(title=title, template="plotly_dark", height=350, margin=dict(l=20, r=20, t=40, b=20))
 
 
 
 
 
164
  return fig
165
 
166
  def plot_service_tension(self):
167
- """Top Flop des services (Graphique en barres)"""
168
  if self.df_ressources is None or self.df_ressources.empty:
169
  return go.Figure()
170
 
171
  df = self.df_ressources.copy()
172
- df['date'] = pd.to_datetime(df['date'])
173
- last_date = df['date'].max()
174
 
175
- # On filtre sur la dernière date connue
176
  df_today = df[df['date'] == last_date].copy()
177
  df_today['taux_pct'] = df_today['taux_occupation'] * 100
178
  df_sorted = df_today.sort_values('taux_pct', ascending=True)
179
 
180
  # Couleurs conditionnelles
181
- colors = ['#FF5252' if x >= 100 else '#FFA726' if x >= 85 else '#66BB6A' for x in df_sorted['taux_pct']]
 
182
 
183
  fig = go.Figure(go.Bar(
184
- x=df_sorted['taux_pct'], y=df_sorted['service_code'],
185
- orientation='h', marker_color=colors,
186
- text=df_sorted['taux_pct'].round(1).astype(str) + '%', textposition='auto'
 
 
 
187
  ))
188
- fig.update_layout(title=f"État Services ({last_date.strftime('%d/%m')})", template="plotly_dark", height=350, margin=dict(l=20, r=20, t=40, b=20))
 
 
 
 
 
 
189
  return fig
190
 
191
  def run_simulation(self, impact_admission, impact_lits):
@@ -196,15 +213,15 @@ class HospitalDashboard:
196
  forecaster = st.session_state['forecaster']
197
 
198
  try:
199
- # --- CORRECTION FORECASTING (On ignore la 3ème valeur '_') ---
200
  f_dates, f_values, _ = forecaster.generate_future_forecast_recursive(months=3)
201
  except:
202
  return None, None, None
203
 
204
- # Si le modèle prédit un taux (0-1), on met en %
205
- if np.mean(f_values) < 2: f_values = f_values * 100
 
206
 
207
- # Application des modificateurs (Simulateur)
208
  factor_adm = 1 + (impact_admission / 100)
209
  factor_capa = 1 + (impact_lits / 100)
210
 
@@ -220,39 +237,55 @@ def show_dashboard(client, sheet_name):
220
  # Instanciation
221
  dashboard = HospitalDashboard(client, sheet_name)
222
 
223
- # Chargement discret (appel de la méthode corrigée)
224
  if not dashboard.load_data():
225
- st.error("Données indisponibles. Lancez l'ETL d'abord.")
226
  return
227
 
228
- # --- BANDEAU KPIs (Toujours visible) ---
229
  kpis = dashboard._get_kpis()
230
  k1, k2, k3, k4 = st.columns(4)
231
 
232
- k1.metric("Taux Occupation", f"{kpis['taux_global']:.1f}%", delta="Critique" if kpis['taux_global'] > 90 else "OK", delta_color="inverse")
 
 
 
 
 
233
  k2.metric("Admissions (Total)", f"{kpis['nb_adm']:,}")
234
- k3.metric("Services Tendus", f"{kpis['nb_tension']}", delta="Alertes", delta_color="inverse")
235
- k4.metric("Ruptures Stock", f"{kpis['nb_ruptures']}", delta="Alertes", delta_color="inverse")
 
 
 
 
 
 
 
 
 
 
236
 
237
  st.divider()
238
 
239
- # --- ONGLETS : SUPERVISION vs SIMULATION ---
240
  tab_live, tab_sim = st.tabs(["📡 Supervision Temps Réel", "🎛️ Simulateur de Crise"])
241
 
242
- # === ONGLET 1 : SUPERVISION ===
243
  with tab_live:
244
  col_main, col_side = st.columns([2, 1])
 
245
  with col_main:
246
  st.plotly_chart(dashboard.plot_main_chart(), use_container_width=True)
 
247
  with col_side:
248
  st.plotly_chart(dashboard.plot_service_tension(), use_container_width=True)
249
 
250
- # --- FLASH REPORT DYNAMIQUE ---
251
  st.markdown("### ⚡ Flash Report & Recommandations")
252
  col_msg, col_act = st.columns([3, 1])
253
 
254
  with col_msg:
255
- # Logique conditionnelle simple
256
  if kpis['taux_global'] > 95:
257
  msg_status = "🔴 **CRITIQUE :** L'hôpital est proche de la saturation totale."
258
  msg_action = "👉 **ACTION REQUISE :** Activer le Plan Blanc et délester les services d'Urgence."
@@ -271,14 +304,13 @@ def show_dashboard(client, sheet_name):
271
  if st.button("📊 Export PDF"):
272
  st.toast("Rapport généré.", icon="📄")
273
 
274
- # === ONGLET 2 : SIMULATEUR ===
275
  with tab_sim:
276
  st.markdown("### 🧪 Bac à Sable (What-If Analysis)")
277
 
278
  if 'forecaster' not in st.session_state:
279
  st.warning("⚠️ Le module 'Simulateur' nécessite que l'IA soit activée.")
280
  if st.button("Activer l'IA maintenant"):
281
- # Essayer de rediriger ou afficher un message
282
  st.info("Allez dans le menu 'Forecasting' pour entraîner le modèle.")
283
  else:
284
  col_params, col_viz = st.columns([1, 3])
@@ -287,8 +319,18 @@ def show_dashboard(client, sheet_name):
287
  st.markdown("#### 1. Paramètres")
288
  st.caption("Modifiez les variables pour voir l'impact futur.")
289
 
290
- choc_adm = st.slider("🌊 Choc d'Admissions", -20, +50, 0, format="%+d%%", help="Ex: Grippe, Plan Blanc (+30%)")
291
- choc_lits = st.slider("🛏️ Ouverture Lits", -20, +20, 0, format="%+d%%", help="Ex: Ouverture d'une aile (+10%)")
 
 
 
 
 
 
 
 
 
 
292
 
293
  st.markdown("---")
294
 
@@ -297,31 +339,57 @@ def show_dashboard(client, sheet_name):
297
 
298
  if simu is not None:
299
  delta_max = np.max(simu) - np.max(base)
300
- st.metric("Impact Peak", f"{delta_max:+.1f} pts", delta="Risque" if delta_max > 0 else "Gain", delta_color="inverse")
 
 
 
 
 
301
 
302
  with col_viz:
303
  st.markdown("#### 2. Projection d'Impact (3 Mois)")
 
304
  if simu is not None:
305
  fig_sim = go.Figure()
306
 
307
  # Courbe de base
308
- fig_sim.add_trace(go.Scatter(x=dates, y=base, name="Scénario Actuel", line=dict(color='gray', dash='dot')))
 
 
 
 
309
 
310
  # Courbe simulée
311
  color_sim = '#FF5252' if np.max(simu) > 100 else '#00E676'
312
- fig_sim.add_trace(go.Scatter(x=dates, y=simu, name="Scénario Simulé", line=dict(color=color_sim, width=3), fill='tonexty'))
 
 
 
 
 
313
 
314
  # Seuil
315
- fig_sim.add_hline(y=100, line_color="white", annotation_text="Capacité Max")
 
 
 
 
316
 
317
- fig_sim.update_layout(template="plotly_dark", height=400, hovermode="x unified", title="Impact sur le Taux d'Occupation")
 
 
 
 
 
318
  st.plotly_chart(fig_sim, use_container_width=True)
319
 
320
- # Conclusion automatique de simulation
321
  max_val = np.max(simu)
322
  if max_val > 100:
323
- st.error(f"🚨 **ALERTE CRITIQUE :** Ce scénario entraîne une saturation à **{max_val:.1f}%**. Il faut augmenter la capacité d'au moins **{max_val-100:.1f}%**.")
 
324
  elif max_val > 85:
325
- st.warning(f"⚠️ **TENSION :** Ce scénario provoque une tension forte ({max_val:.1f}%). Surveillance requise.")
 
326
  else:
327
  st.success(f"�� **ROBUSTE :** L'hôpital tient le choc ({max_val:.1f}% max).")
 
23
  def __init__(self, client, sheet_name):
24
  self.client = client
25
  self.sheet_name = sheet_name
26
+ # Initialisation des attributs
 
27
  self.df_ressources = None
28
  self.df_admissions = None
29
  self.df_stock = None
30
 
31
  def load_data(self):
32
  """Charge les données via le cache ou l'ETL"""
33
+ # 1. Tenter de récupérer depuis le session_state
34
+ if 'data_explorer' in st.session_state and hasattr(st.session_state.data_explorer, 'dataframes_clean'):
35
+ dfs = st.session_state.data_explorer.dataframes_clean
36
  # Mapping sécurisé
37
  self.df_ressources = dfs.get('ressources_jour')
38
  self.df_admissions = dfs.get('admissions_jour')
39
  self.df_stock = dfs.get('stock_materiel_jour')
40
  return True
41
 
42
+ # 2. Sinon, lancer l'ETL
43
  etl = ETL_Engine(self.client, self.sheet_name)
44
  success, result = etl.run_etl()
45
  if success:
 
46
  st.session_state.data_explorer = result
47
+ self.df_ressources = result.dataframes_clean.get('ressources_jour')
48
+ self.df_admissions = result.dataframes_clean.get('admissions_jour')
49
+ self.df_stock = result.dataframes_clean.get('stock_materiel_jour')
 
50
  return True
51
 
52
  return False
53
 
54
  def _get_kpis(self):
55
+ """Calcul des KPIs globaux avec conversion forcée"""
56
  # Sécurité : DataFrames vides si None
57
  df_res = self.df_ressources.copy() if self.df_ressources is not None else pd.DataFrame()
58
  df_adm = self.df_admissions.copy() if self.df_admissions is not None else pd.DataFrame()
59
  df_stock = self.df_stock.copy() if self.df_stock is not None else pd.DataFrame()
60
 
61
+ # === FIX CRITIQUE : CONVERSION FORCÉE DE TOUTES LES COLONNES NUMÉRIQUES ===
62
+
63
+ # 1. Ressources
64
+ if not df_res.empty:
65
+ numeric_cols_res = ['lits_occupes', 'lits_total', 'taux_occupation',
66
+ 'personnel_disponible', 'personnel_requis']
67
+ for col in numeric_cols_res:
68
+ if col in df_res.columns:
69
+ df_res[col] = pd.to_numeric(df_res[col], errors='coerce').fillna(0)
70
+
71
+ # 2. Admissions
72
+ if not df_adm.empty and 'nb_admissions' in df_adm.columns:
73
+ df_adm['nb_admissions'] = pd.to_numeric(df_adm['nb_admissions'], errors='coerce').fillna(0)
74
+
75
+ # === CALCULS KPI ===
76
+
77
  # A. Occupation
78
+ total_lits = df_res['lits_total'].sum() if not df_res.empty else 0
79
+ total_occupes = df_res['lits_occupes'].sum() if not df_res.empty else 0
80
 
81
  if total_lits > 0:
82
  taux_global = (total_occupes / total_lits) * 100
 
84
  taux_global = 0
85
 
86
  # B. Tension RH
87
+ total_staff_dispo = df_res['personnel_disponible'].sum() if not df_res.empty else 0
88
+ total_staff_req = df_res['personnel_requis'].sum() if not df_res.empty else 0
89
 
90
  tension_rh = 0
91
  if total_staff_req > 0:
92
  tension_rh = (1 - (total_staff_dispo / total_staff_req)) * 100
93
 
94
+ # C. Admissions totales
95
+ nb_adm = df_adm['nb_admissions'].sum() if not df_adm.empty else 0
 
 
96
 
97
+ # D. Services en Tension (>85%)
98
+ # FIX : Maintenant taux_occupation est bien un float
99
  nb_tension = 0
100
  if not df_res.empty and 'taux_occupation' in df_res.columns:
101
+ nb_tension = int((df_res['taux_occupation'] > 0.85).sum())
102
 
103
+ # E. Ruptures Stock
104
  nb_ruptures = 0
105
  if not df_stock.empty and 'alerte_stock' in df_stock.columns:
106
+ nb_ruptures = int(df_stock['alerte_stock'].astype(str).str.contains('Rupture|Critique', case=False, na=False).sum())
107
 
108
  return {
109
  'lits_total': int(total_lits),
110
  'lits_occupes': int(total_occupes),
111
+ 'taux_global': float(taux_global),
112
+ 'tension_rh': float(tension_rh),
113
  'nb_adm': int(nb_adm),
114
  'nb_tension': int(nb_tension),
115
  'nb_ruptures': int(nb_ruptures)
116
  }
117
 
118
  def plot_main_chart(self):
119
+ """Graphique Hybride : Historique + Prédiction"""
120
  fig = go.Figure()
121
 
122
  # Historique
123
  if self.df_ressources is not None and not self.df_ressources.empty:
124
  df_hist = self.df_ressources.copy()
125
  if 'date' in df_hist.columns:
126
+ df_hist['date'] = pd.to_datetime(df_hist['date'], errors='coerce')
127
+ # Conversion forcée
128
+ df_hist['taux_occupation'] = pd.to_numeric(df_hist['taux_occupation'], errors='coerce').fillna(0)
129
+
130
+ # Moyenne par jour
131
  daily_occ = df_hist.groupby('date')['taux_occupation'].mean() * 100
 
132
  daily_occ = daily_occ.tail(30)
133
 
134
  fig.add_trace(go.Scatter(
 
138
  fill='tozeroy', fillcolor='rgba(88, 166, 255, 0.1)'
139
  ))
140
 
141
+ # Prédiction IA
142
  has_forecast = False
143
  if 'forecaster' in st.session_state:
144
  forecaster = st.session_state['forecaster']
145
  try:
 
146
  f_dates, f_values, _ = forecaster.generate_future_forecast_recursive(months=1)
147
 
148
+ # Adaptation échelle
149
+ if np.mean(f_values) < 2:
150
+ f_values = f_values * 100
151
 
152
  fig.add_trace(go.Scatter(
153
  x=f_dates, y=f_values,
 
158
  except Exception:
159
  pass
160
 
161
+ # Ligne saturation
162
  fig.add_hline(y=100, line_dash="dash", line_color="#FF5252", annotation_text="Saturation")
163
 
164
  title = "Taux d'Occupation : Réalité vs IA" if has_forecast else "Taux d'Occupation (Historique)"
165
+ fig.update_layout(
166
+ title=title,
167
+ template="plotly_dark",
168
+ height=350,
169
+ margin=dict(l=20, r=20, t=40, b=20)
170
+ )
171
  return fig
172
 
173
  def plot_service_tension(self):
174
+ """Top Flop des services"""
175
  if self.df_ressources is None or self.df_ressources.empty:
176
  return go.Figure()
177
 
178
  df = self.df_ressources.copy()
179
+ df['date'] = pd.to_datetime(df['date'], errors='coerce')
180
+ df['taux_occupation'] = pd.to_numeric(df['taux_occupation'], errors='coerce').fillna(0)
181
 
182
+ last_date = df['date'].max()
183
  df_today = df[df['date'] == last_date].copy()
184
  df_today['taux_pct'] = df_today['taux_occupation'] * 100
185
  df_sorted = df_today.sort_values('taux_pct', ascending=True)
186
 
187
  # Couleurs conditionnelles
188
+ colors = ['#FF5252' if x >= 100 else '#FFA726' if x >= 85 else '#66BB6A'
189
+ for x in df_sorted['taux_pct']]
190
 
191
  fig = go.Figure(go.Bar(
192
+ x=df_sorted['taux_pct'],
193
+ y=df_sorted['service_code'],
194
+ orientation='h',
195
+ marker_color=colors,
196
+ text=[f"{x:.1f}%" for x in df_sorted['taux_pct']],
197
+ textposition='auto'
198
  ))
199
+
200
+ fig.update_layout(
201
+ title=f"État Services ({last_date.strftime('%d/%m')})",
202
+ template="plotly_dark",
203
+ height=350,
204
+ margin=dict(l=20, r=20, t=40, b=20)
205
+ )
206
  return fig
207
 
208
  def run_simulation(self, impact_admission, impact_lits):
 
213
  forecaster = st.session_state['forecaster']
214
 
215
  try:
 
216
  f_dates, f_values, _ = forecaster.generate_future_forecast_recursive(months=3)
217
  except:
218
  return None, None, None
219
 
220
+ # Adaptation échelle
221
+ if np.mean(f_values) < 2:
222
+ f_values = f_values * 100
223
 
224
+ # Application des modificateurs
225
  factor_adm = 1 + (impact_admission / 100)
226
  factor_capa = 1 + (impact_lits / 100)
227
 
 
237
  # Instanciation
238
  dashboard = HospitalDashboard(client, sheet_name)
239
 
240
+ # Chargement
241
  if not dashboard.load_data():
242
+ st.error("❌ Données indisponibles. Lancez l'ETL d'abord.")
243
  return
244
 
245
+ # Bandeau KPIs
246
  kpis = dashboard._get_kpis()
247
  k1, k2, k3, k4 = st.columns(4)
248
 
249
+ k1.metric(
250
+ "Taux Occupation",
251
+ f"{kpis['taux_global']:.1f}%",
252
+ delta="Critique" if kpis['taux_global'] > 90 else "OK",
253
+ delta_color="inverse"
254
+ )
255
  k2.metric("Admissions (Total)", f"{kpis['nb_adm']:,}")
256
+ k3.metric(
257
+ "Services Tendus",
258
+ f"{kpis['nb_tension']}",
259
+ delta="Alertes" if kpis['nb_tension'] > 0 else "OK",
260
+ delta_color="inverse"
261
+ )
262
+ k4.metric(
263
+ "Ruptures Stock",
264
+ f"{kpis['nb_ruptures']}",
265
+ delta="Alertes" if kpis['nb_ruptures'] > 0 else "OK",
266
+ delta_color="inverse"
267
+ )
268
 
269
  st.divider()
270
 
271
+ # Onglets
272
  tab_live, tab_sim = st.tabs(["📡 Supervision Temps Réel", "🎛️ Simulateur de Crise"])
273
 
274
+ # ONGLET 1 : SUPERVISION
275
  with tab_live:
276
  col_main, col_side = st.columns([2, 1])
277
+
278
  with col_main:
279
  st.plotly_chart(dashboard.plot_main_chart(), use_container_width=True)
280
+
281
  with col_side:
282
  st.plotly_chart(dashboard.plot_service_tension(), use_container_width=True)
283
 
284
+ # Flash Report
285
  st.markdown("### ⚡ Flash Report & Recommandations")
286
  col_msg, col_act = st.columns([3, 1])
287
 
288
  with col_msg:
 
289
  if kpis['taux_global'] > 95:
290
  msg_status = "🔴 **CRITIQUE :** L'hôpital est proche de la saturation totale."
291
  msg_action = "👉 **ACTION REQUISE :** Activer le Plan Blanc et délester les services d'Urgence."
 
304
  if st.button("📊 Export PDF"):
305
  st.toast("Rapport généré.", icon="📄")
306
 
307
+ # ONGLET 2 : SIMULATEUR
308
  with tab_sim:
309
  st.markdown("### 🧪 Bac à Sable (What-If Analysis)")
310
 
311
  if 'forecaster' not in st.session_state:
312
  st.warning("⚠️ Le module 'Simulateur' nécessite que l'IA soit activée.")
313
  if st.button("Activer l'IA maintenant"):
 
314
  st.info("Allez dans le menu 'Forecasting' pour entraîner le modèle.")
315
  else:
316
  col_params, col_viz = st.columns([1, 3])
 
319
  st.markdown("#### 1. Paramètres")
320
  st.caption("Modifiez les variables pour voir l'impact futur.")
321
 
322
+ choc_adm = st.slider(
323
+ "🌊 Choc d'Admissions",
324
+ -20, +50, 0,
325
+ format="%+d%%",
326
+ help="Ex: Grippe, Plan Blanc (+30%)"
327
+ )
328
+ choc_lits = st.slider(
329
+ "🛏️ Ouverture Lits",
330
+ -20, +20, 0,
331
+ format="%+d%%",
332
+ help="Ex: Ouverture d'une aile (+10%)"
333
+ )
334
 
335
  st.markdown("---")
336
 
 
339
 
340
  if simu is not None:
341
  delta_max = np.max(simu) - np.max(base)
342
+ st.metric(
343
+ "Impact Peak",
344
+ f"{delta_max:+.1f} pts",
345
+ delta="Risque" if delta_max > 0 else "Gain",
346
+ delta_color="inverse"
347
+ )
348
 
349
  with col_viz:
350
  st.markdown("#### 2. Projection d'Impact (3 Mois)")
351
+
352
  if simu is not None:
353
  fig_sim = go.Figure()
354
 
355
  # Courbe de base
356
+ fig_sim.add_trace(go.Scatter(
357
+ x=dates, y=base,
358
+ name="Scénario Actuel",
359
+ line=dict(color='gray', dash='dot')
360
+ ))
361
 
362
  # Courbe simulée
363
  color_sim = '#FF5252' if np.max(simu) > 100 else '#00E676'
364
+ fig_sim.add_trace(go.Scatter(
365
+ x=dates, y=simu,
366
+ name="Scénario Simulé",
367
+ line=dict(color=color_sim, width=3),
368
+ fill='tonexty'
369
+ ))
370
 
371
  # Seuil
372
+ fig_sim.add_hline(
373
+ y=100,
374
+ line_color="white",
375
+ annotation_text="Capacité Max"
376
+ )
377
 
378
+ fig_sim.update_layout(
379
+ template="plotly_dark",
380
+ height=400,
381
+ hovermode="x unified",
382
+ title="Impact sur le Taux d'Occupation"
383
+ )
384
  st.plotly_chart(fig_sim, use_container_width=True)
385
 
386
+ # Conclusion automatique
387
  max_val = np.max(simu)
388
  if max_val > 100:
389
+ st.error(f"🚨 **ALERTE CRITIQUE :** Ce scénario entraîne une saturation à **{max_val:.1f}%**. "
390
+ f"Il faut augmenter la capacité d'au moins **{max_val-100:.1f}%**.")
391
  elif max_val > 85:
392
+ st.warning(f"⚠️ **TENSION :** Ce scénario provoque une tension forte ({max_val:.1f}%). "
393
+ f"Surveillance requise.")
394
  else:
395
  st.success(f"�� **ROBUSTE :** L'hôpital tient le choc ({max_val:.1f}% max).")