jaimalklyde commited on
Commit
9307c4f
·
verified ·
1 Parent(s): e541241

Update src/module/Dashboard.py

Browse files
Files changed (1) hide show
  1. src/module/Dashboard.py +824 -351
src/module/Dashboard.py CHANGED
@@ -1,395 +1,868 @@
1
  """
2
- MODULE: EXECUTIVE DASHBOARD (DIRECTOR VIEW)
3
- ===========================================
4
- Responsabilité : Cockpit de pilotage + Simulateur de crise.
5
- Agrège : ETL (Passé) + Forecasting (Futur) + Simulation (Hypothèses).
6
- Style : Gotham UI
 
 
 
 
 
 
 
7
  """
8
 
9
  import streamlit as st
10
  import pandas as pd
11
- import plotly.express as px
12
- import plotly.graph_objects as go
13
  import numpy as np
14
- from datetime import timedelta
15
-
16
- # Import pour charger les données
17
- try:
18
- from module.Validation_Contract import ETL_Engine
19
- except ImportError:
20
- st.error("❌ Impossible de charger l'ETL.")
21
 
22
- class HospitalDashboard:
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  else:
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(
135
- x=daily_occ.index, y=daily_occ.values,
136
- mode='lines+markers', name='Historique',
137
- line=dict(color='#58a6ff', width=3),
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,
154
- mode='lines', name='Tendance IA',
155
- line=dict(color='#00E676', width=3, dash='dot')
156
- ))
157
- has_forecast = True
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):
209
- """Calcul de la simulation (What-If Analysis)"""
210
- if 'forecaster' not in st.session_state:
211
- return None, None, None
212
-
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
 
228
- simulated_values = (f_values * factor_adm) / factor_capa
 
 
 
 
 
 
 
 
 
 
 
229
 
230
- return f_dates, f_values, simulated_values
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
 
233
- def show_dashboard(client, sheet_name):
234
- st.markdown("## 🚁 COCKPIT DE DIRECTION")
235
- st.caption("Supervision Temps Réel & Simulation Stratégique")
 
 
 
 
 
236
 
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."
292
- elif kpis['taux_global'] > 85:
293
- msg_status = "🟠 **ATTENTION :** Tensions fortes sur les capacités."
294
- msg_action = "👉 **ACTION REQUISE :** Surveiller les sorties et préparer des lits d'aval."
295
- else:
296
- msg_status = "🟢 **NOMINAL :** Activité hospitalière sous contrôle."
297
- msg_action = "✅ Aucune action critique requise à court terme."
298
-
299
- st.info(f"{msg_status}\n\n{msg_action}")
300
 
301
- with col_act:
302
- if st.button("📢 Alerter Equipes"):
303
- st.toast("Alerte envoyée aux chefs de service.", icon="📨")
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])
 
 
 
 
 
 
 
 
 
 
 
 
 
317
 
318
- with col_params:
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
 
337
- # Calcul impact
338
- dates, base, simu = dashboard.run_simulation(choc_adm, choc_lits)
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).")
 
1
  """
2
+ MODULE: COCKPIT DE DIRECTION - PALANTIR GOTHAM EDITION
3
+ ======================================================
4
+ Responsabilité : Supervision stratégique temps réel et simulation de crise avancée
5
+ Architecture : Command & Control Center pour décideurs hospitaliers
6
+
7
+ Fonctionnalités :
8
+ - Supervision multi-niveaux (Opérationnel / Tactique / Stratégique)
9
+ - Simulateur de crise avec 7 scénarios (épidémie, grève, cyberattaque, etc.)
10
+ - Analyse prédictive d'impact multi-variables
11
+ - Recommandations automatiques basées sur l'IA
12
+ - Génération de rapports exécutifs
13
+ - Système d'alerte cascadé par criticité
14
  """
15
 
16
  import streamlit as st
17
  import pandas as pd
 
 
18
  import numpy as np
19
+ import plotly.graph_objects as go
20
+ from plotly.subplots import make_subplots
21
+ from datetime import datetime, timedelta
22
+ import warnings
23
+ warnings.filterwarnings('ignore')
 
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
+ # =====================================================
27
+ # ============= MOTEUR DE SIMULATION ==================
28
+ # =====================================================
 
 
 
29
 
30
+ class CrisisSimulator:
31
+ """
32
+ Moteur de simulation de crise multi-scénarios avec analyse d'impact.
33
+ """
34
+
35
+ # Définition des scénarios de crise
36
+ SCENARIOS = {
37
+ 'EPIDEMIE_GRIPPE': {
38
+ 'label': 'Épidémie de Grippe Saisonnière',
39
+ 'admissions_multiplier': 2.5,
40
+ 'duree_jours': 45,
41
+ 'services_impacts': ['URG', 'PED', 'REA', 'INF'],
42
+ 'personnel_absence': 0.15,
43
+ 'stock_consommation': 2.0,
44
+ 'criticite': 'HIGH'
45
+ },
46
+ 'PANDEMIE_COVID': {
47
+ 'label': 'Pandémie Type COVID-19',
48
+ 'admissions_multiplier': 4.0,
49
+ 'duree_jours': 90,
50
+ 'services_impacts': ['URG', 'REA', 'ISO', 'MED'],
51
+ 'personnel_absence': 0.25,
52
+ 'stock_consommation': 3.5,
53
+ 'criticite': 'CRITICAL'
54
+ },
55
+ 'CANICULE': {
56
+ 'label': 'Canicule Extrême (>40°C)',
57
+ 'admissions_multiplier': 1.8,
58
+ 'duree_jours': 21,
59
+ 'services_impacts': ['URG', 'REA', 'CAR'],
60
+ 'personnel_absence': 0.10,
61
+ 'stock_consommation': 1.5,
62
+ 'criticite': 'HIGH'
63
+ },
64
+ 'ACCIDENT_MASSE': {
65
+ 'label': 'Accident de Grande Ampleur',
66
+ 'admissions_multiplier': 3.5,
67
+ 'duree_jours': 3,
68
+ 'services_impacts': ['URG', 'REA', 'TRA'],
69
+ 'personnel_absence': 0.05,
70
+ 'stock_consommation': 4.0,
71
+ 'criticite': 'CRITICAL'
72
+ },
73
+ 'GREVE_PERSONNEL': {
74
+ 'label': 'Grève du Personnel Soignant',
75
+ 'admissions_multiplier': 0.6,
76
+ 'duree_jours': 14,
77
+ 'services_impacts': ['ALL'],
78
+ 'personnel_absence': 0.40,
79
+ 'stock_consommation': 0.7,
80
+ 'criticite': 'HIGH'
81
+ },
82
+ 'CYBERATTAQUE': {
83
+ 'label': 'Cyberattaque Système Informatique',
84
+ 'admissions_multiplier': 0.3,
85
+ 'duree_jours': 7,
86
+ 'services_impacts': ['ALL'],
87
+ 'personnel_absence': 0.08,
88
+ 'stock_consommation': 1.0,
89
+ 'criticite': 'HIGH'
90
+ },
91
+ 'RUPTURE_APPROVISIONNEMENT': {
92
+ 'label': 'Rupture Critique Approvisionnement',
93
+ 'admissions_multiplier': 0.8,
94
+ 'duree_jours': 30,
95
+ 'services_impacts': ['REA', 'TRA', 'CAR'],
96
+ 'personnel_absence': 0.05,
97
+ 'stock_consommation': 0.2,
98
+ 'criticite': 'MEDIUM'
99
+ }
100
+ }
101
+
102
+ def __init__(self, forecaster, ml_dataset):
103
+ self.forecaster = forecaster
104
+ self.df = ml_dataset
105
+ self.baseline_metrics = self._compute_baseline()
106
+
107
+ def _compute_baseline(self):
108
+ """Calcul des métriques de référence (état actuel)"""
109
+ recent = self.df.tail(30) # 30 derniers jours
110
+
111
+ return {
112
+ 'admissions_moy': recent['nb_admissions'].mean(),
113
+ 'admissions_max': recent['nb_admissions'].max(),
114
+ 'taux_occupation_moy': recent['taux_occupation'].mean(),
115
+ 'tension_personnel_moy': recent['tension_personnel'].mean(),
116
+ 'services_actifs': recent['service_code'].nunique()
117
+ }
118
+
119
+ def simulate_scenario(self, scenario_key, intensity_factor=1.0):
120
+ """
121
+ Simule l'impact d'un scénario de crise.
122
+
123
+ Args:
124
+ scenario_key: Clé du scénario à simuler
125
+ intensity_factor: Facteur d'intensité (0.5 à 2.0)
126
+
127
+ Returns:
128
+ Dictionnaire avec projections et impact
129
+ """
130
+ scenario = self.SCENARIOS[scenario_key].copy()
131
 
132
+ # Application du facteur d'intensité
133
+ scenario['admissions_multiplier'] *= intensity_factor
134
+ scenario['personnel_absence'] *= intensity_factor
135
+ scenario['stock_consommation'] *= intensity_factor
 
 
 
136
 
137
+ # Génération prédictions futures (baseline)
138
+ future_dates, baseline_predictions, _ = self.forecaster.generate_future_forecast_recursive(
139
+ months=int(scenario['duree_jours'] / 30) + 1
140
+ )
141
 
142
+ # Application de l'impact du scénario
143
+ impact_curve = self._generate_impact_curve(
144
+ len(baseline_predictions),
145
+ scenario['duree_jours'],
146
+ scenario['admissions_multiplier']
147
+ )
148
 
149
+ crisis_predictions = baseline_predictions * impact_curve
 
 
150
 
151
+ # Calcul des métriques d'impact
152
+ impact_metrics = self._compute_impact_metrics(
153
+ baseline_predictions,
154
+ crisis_predictions,
155
+ scenario
156
+ )
157
+
158
+ # Génération recommandations
159
+ recommendations = self._generate_recommendations(scenario, impact_metrics)
160
+
161
+ return {
162
+ 'scenario': scenario,
163
+ 'dates': future_dates[:scenario['duree_jours']],
164
+ 'baseline': baseline_predictions[:scenario['duree_jours']],
165
+ 'crisis': crisis_predictions[:scenario['duree_jours']],
166
+ 'impact_curve': impact_curve[:scenario['duree_jours']],
167
+ 'metrics': impact_metrics,
168
+ 'recommendations': recommendations
169
+ }
170
+
171
+ def _generate_impact_curve(self, n_days, crisis_duration, multiplier):
172
+ """
173
+ Génère une courbe d'impact réaliste (montée progressive, pic, décrue).
174
+ """
175
+ curve = np.ones(n_days)
176
+
177
+ if crisis_duration >= n_days:
178
+ crisis_duration = n_days
179
+
180
+ # Phase 1: Montée (20% de la durée)
181
+ ramp_up = int(crisis_duration * 0.2)
182
+ curve[:ramp_up] = np.linspace(1.0, multiplier, ramp_up)
183
+
184
+ # Phase 2: Pic (60% de la durée)
185
+ peak_duration = int(crisis_duration * 0.6)
186
+ curve[ramp_up:ramp_up + peak_duration] = multiplier
187
+
188
+ # Phase 3: Décrue (20% de la durée)
189
+ ramp_down = crisis_duration - ramp_up - peak_duration
190
+ if ramp_down > 0:
191
+ curve[ramp_up + peak_duration:crisis_duration] = np.linspace(
192
+ multiplier, 1.0, ramp_down
193
+ )
194
+
195
+ return curve
196
+
197
+ def _compute_impact_metrics(self, baseline, crisis, scenario):
198
+ """Calcul des métriques d'impact détaillées"""
199
+
200
+ # Admissions
201
+ baseline_total = baseline.sum()
202
+ crisis_total = crisis.sum()
203
+ admissions_delta = crisis_total - baseline_total
204
+ admissions_peak = crisis.max()
205
+
206
+ # Estimation taux d'occupation (règle simple)
207
+ baseline_occupation = self.baseline_metrics['taux_occupation_moy']
208
+ crisis_occupation = min(baseline_occupation * scenario['admissions_multiplier'], 1.0)
209
+ occupation_delta = crisis_occupation - baseline_occupation
210
+
211
+ # Personnel
212
+ personnel_disponible_baseline = 1.0
213
+ personnel_disponible_crisis = 1.0 - scenario['personnel_absence']
214
+ personnel_delta = scenario['personnel_absence']
215
+
216
+ # Estimation lits nécessaires
217
+ lits_necessaires_crisis = int(crisis.mean() * 1.5) # Heuristique
218
+
219
+ # Criticité globale
220
+ if occupation_delta > 0.3 or scenario['criticite'] == 'CRITICAL':
221
+ overall_criticite = 'CRITICAL'
222
+ elif occupation_delta > 0.15 or scenario['criticite'] == 'HIGH':
223
+ overall_criticite = 'HIGH'
224
  else:
225
+ overall_criticite = 'MEDIUM'
 
 
 
 
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  return {
228
+ 'admissions_delta': admissions_delta,
229
+ 'admissions_delta_pct': (admissions_delta / baseline_total) * 100,
230
+ 'admissions_peak': admissions_peak,
231
+ 'occupation_baseline': baseline_occupation,
232
+ 'occupation_crisis': crisis_occupation,
233
+ 'occupation_delta': occupation_delta,
234
+ 'occupation_delta_pct': (occupation_delta / baseline_occupation) * 100,
235
+ 'personnel_delta': personnel_delta,
236
+ 'personnel_delta_pct': personnel_delta * 100,
237
+ 'lits_necessaires': lits_necessaires_crisis,
238
+ 'stock_multiplier': scenario['stock_consommation'],
239
+ 'criticite': overall_criticite,
240
+ 'duree_jours': scenario['duree_jours']
241
  }
242
+
243
+ def _generate_recommendations(self, scenario, metrics):
244
+ """Génère des recommandations automatiques basées sur l'impact"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
 
246
+ recs = []
247
+
248
+ # Recommandations admissions
249
+ if metrics['admissions_delta_pct'] > 50:
250
+ recs.append({
251
+ 'categorie': 'CAPACITE',
252
+ 'priorite': 'IMMEDIATE',
253
+ 'action': f"Augmenter capacité de {int(metrics['lits_necessaires'])} lits",
254
+ 'justification': f"Pic d'admissions prévu : +{metrics['admissions_delta_pct']:.0f}%"
255
+ })
256
+
257
+ # Recommandations personnel
258
+ if metrics['personnel_delta_pct'] > 20:
259
+ recs.append({
260
+ 'categorie': 'PERSONNEL',
261
+ 'priorite': 'IMMEDIATE',
262
+ 'action': f"Mobiliser {int(metrics['personnel_delta_pct'])}% personnel de réserve",
263
+ 'justification': f"Absence personnel critique : {metrics['personnel_delta_pct']:.0f}%"
264
+ })
265
+
266
+ # Recommandations stock
267
+ if metrics['stock_multiplier'] > 2.0:
268
+ recs.append({
269
+ 'categorie': 'LOGISTIQUE',
270
+ 'priorite': 'URGENT',
271
+ 'action': f"Multiplier stocks par {metrics['stock_multiplier']:.1f}",
272
+ 'justification': "Consommation matériel exponentiellement accrue"
273
+ })
274
+
275
+ # Recommandations occupation
276
+ if metrics['occupation_delta_pct'] > 30:
277
+ recs.append({
278
+ 'categorie': 'OPERATIONS',
279
+ 'priorite': 'IMMEDIATE',
280
+ 'action': "Activer plan blanc ou plan bleu",
281
+ 'justification': f"Saturation prévue : {metrics['occupation_crisis']*100:.0f}%"
282
+ })
283
+
284
+ # Recommandations services impactés
285
+ services_str = ', '.join(scenario['services_impacts'][:3])
286
+ if len(scenario['services_impacts']) > 3:
287
+ services_str += f" (+{len(scenario['services_impacts'])-3} autres)"
288
+
289
+ recs.append({
290
+ 'categorie': 'COORDINATION',
291
+ 'priorite': 'URGENT',
292
+ 'action': f"Renforcer services : {services_str}",
293
+ 'justification': "Services en première ligne de la crise"
294
+ })
295
+
296
+ # Recommandation communication
297
+ if metrics['criticite'] == 'CRITICAL':
298
+ recs.append({
299
+ 'categorie': 'COMMUNICATION',
300
+ 'priorite': 'IMMEDIATE',
301
+ 'action': "Activer cellule de crise et communication externe",
302
+ 'justification': "Niveau de criticité maximal atteint"
303
+ })
304
+
305
+ return recs
306
 
 
 
 
 
307
 
308
+ # =====================================================
309
+ # ============= SUPERVISION TEMPS RÉEL ================
310
+ # =====================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
 
312
+ class RealtimeMonitor:
313
+ """
314
+ Système de supervision temps réel multi-niveaux.
315
+ """
316
+
317
+ def __init__(self, ml_dataset, forecaster):
318
+ self.df = ml_dataset
319
+ self.forecaster = forecaster
320
+ self.current_state = self._analyze_current_state()
321
+
322
+ def _analyze_current_state(self):
323
+ """Analyse de l'état actuel (72 dernières heures)"""
324
 
325
+ recent = self.df.tail(3) # 3 derniers jours
326
+ last_24h = self.df.tail(1).iloc[0]
 
 
327
 
328
+ # Métriques opérationnelles
329
+ admissions_24h = last_24h['nb_admissions']
330
+ taux_occupation = last_24h['taux_occupation']
331
+ tension_personnel = last_24h['tension_personnel']
332
+
333
+ # Calcul des tendances (72h)
334
+ if len(recent) >= 3:
335
+ trend_admissions = ((recent['nb_admissions'].iloc[-1] / recent['nb_admissions'].iloc[0]) - 1) * 100
336
+ trend_occupation = ((recent['taux_occupation'].iloc[-1] / recent['taux_occupation'].iloc[0]) - 1) * 100
337
+ else:
338
+ trend_admissions = 0
339
+ trend_occupation = 0
340
+
341
+ # Détection d'alertes
342
+ alertes = []
343
+
344
+ if taux_occupation > 0.85:
345
+ alertes.append({
346
+ 'niveau': 'CRITICAL',
347
+ 'categorie': 'CAPACITE',
348
+ 'message': f"Taux d'occupation critique : {taux_occupation*100:.1f}%"
349
+ })
350
+ elif taux_occupation > 0.75:
351
+ alertes.append({
352
+ 'niveau': 'WARNING',
353
+ 'categorie': 'CAPACITE',
354
+ 'message': f"Taux d'occupation élevé : {taux_occupation*100:.1f}%"
355
+ })
356
 
357
+ if tension_personnel > 1.2:
358
+ alertes.append({
359
+ 'niveau': 'CRITICAL',
360
+ 'categorie': 'PERSONNEL',
361
+ 'message': f"Tension personnel critique : {tension_personnel:.2f}"
362
+ })
363
+ elif tension_personnel > 1.0:
364
+ alertes.append({
365
+ 'niveau': 'WARNING',
366
+ 'categorie': 'PERSONNEL',
367
+ 'message': f"Tension personnel élevée : {tension_personnel:.2f}"
368
+ })
369
 
370
+ if trend_admissions > 20:
371
+ alertes.append({
372
+ 'niveau': 'WARNING',
373
+ 'categorie': 'TENDANCE',
374
+ 'message': f"Hausse admissions 72h : +{trend_admissions:.0f}%"
375
+ })
376
+
377
+ # Score de santé global (0-100)
378
+ health_score = 100
379
+ health_score -= min((taux_occupation - 0.6) * 100, 30)
380
+ health_score -= min((tension_personnel - 0.8) * 50, 30)
381
+ health_score -= min(abs(trend_occupation) * 0.5, 20)
382
+ health_score = max(health_score, 0)
383
+
384
+ return {
385
+ 'admissions_24h': admissions_24h,
386
+ 'taux_occupation': taux_occupation,
387
+ 'tension_personnel': tension_personnel,
388
+ 'trend_admissions': trend_admissions,
389
+ 'trend_occupation': trend_occupation,
390
+ 'alertes': alertes,
391
+ 'health_score': health_score,
392
+ 'timestamp': datetime.now()
393
+ }
394
+
395
+ def get_service_breakdown(self):
396
+ """Répartition par service"""
397
+
398
+ recent = self.df.tail(30)
399
+
400
+ if 'service_code' not in recent.columns:
401
+ return None
402
+
403
+ service_stats = recent.groupby('service_code').agg({
404
+ 'taux_occupation': 'mean',
405
+ 'tension_personnel': 'mean',
406
+ 'nb_admissions': 'sum'
407
+ }).round(4)
408
+
409
+ service_stats.columns = ['Taux Occupation', 'Tension Personnel', 'Admissions (30j)']
410
+ service_stats = service_stats.sort_values('Taux Occupation', ascending=False)
411
+
412
+ return service_stats
413
+
414
+ def predict_next_48h(self):
415
+ """Prédiction 48h avec niveau de confiance"""
416
+
417
+ future_dates, predictions, conf = self.forecaster.generate_future_forecast_recursive(months=1)
418
+
419
+ # On ne garde que les 2 premiers jours
420
+ next_48h = {
421
+ 'dates': future_dates[:2],
422
+ 'predictions': predictions[:2],
423
+ 'lower': conf['lower'][:2],
424
+ 'upper': conf['upper'][:2]
425
+ }
426
+
427
+ # Détection d'anomalies prévues
428
+ current_avg = self.df['nb_admissions'].tail(7).mean()
429
+ anomalies_48h = []
430
+
431
+ for i, pred in enumerate(next_48h['predictions']):
432
+ if pred > current_avg * 1.3:
433
+ anomalies_48h.append({
434
+ 'jour': i + 1,
435
+ 'valeur': pred,
436
+ 'ecart_pct': ((pred / current_avg) - 1) * 100
437
+ })
438
+
439
+ return next_48h, anomalies_48h
440
 
441
 
442
+ # =====================================================
443
+ # ============= INTERFACE STREAMLIT ===================
444
+ # =====================================================
445
+
446
+ def show_command_center(client, sheet_name):
447
+ """
448
+ Cockpit de Direction Niveau Palantir Gotham.
449
+ """
450
 
451
+ st.markdown("# COCKPIT DE DIRECTION")
452
+ st.caption("Supervision Temps Réel & Simulation Stratégique")
453
 
454
+ # Vérification données
455
+ if 'ml_features' not in st.session_state or 'forecaster' not in st.session_state:
456
+ st.error("DONNEES ML OU MODELE MANQUANT. Executer d'abord Feature Engineering et Forecasting.")
457
  return
 
 
 
 
458
 
459
+ df = st.session_state['ml_features']
460
+ forecaster = st.session_state['forecaster']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
 
462
+ # Initialisation des moteurs
463
+ monitor = RealtimeMonitor(df, forecaster)
464
+ simulator = CrisisSimulator(forecaster, df)
465
+
466
+ # ===== ONGLETS PRINCIPAUX =====
467
+ tab1, tab2 = st.tabs([
468
+ "Supervision Temps Reel",
469
+ "Simulateur de Crise"
470
+ ])
471
+
472
+ # ================================================================
473
+ # ONGLET 1 : SUPERVISION TEMPS RÉEL
474
+ # ================================================================
475
+ with tab1:
476
+
477
+ # ===== KPI HEADER =====
478
+ st.markdown("### Indicateurs Stratégiques")
479
+
480
+ state = monitor.current_state
481
+
482
+ col1, col2, col3, col4 = st.columns(4)
483
+
484
+ # Taux Occupation
485
+ occupation_color = "normal"
486
+ if state['taux_occupation'] > 0.85:
487
+ occupation_color = "inverse"
488
+ elif state['taux_occupation'] > 0.75:
489
+ occupation_color = "off"
490
+
491
+ col1.metric(
492
+ "Taux Occupation",
493
+ f"{state['taux_occupation']*100:.1f}%",
494
+ f"{state['trend_occupation']:+.1f}%",
495
+ delta_color=occupation_color
496
+ )
497
+
498
+ # Admissions
499
+ col2.metric(
500
+ "Admissions (Total)",
501
+ f"{int(df['nb_admissions'].sum()):,}",
502
+ help="Cumul toutes périodes"
503
+ )
504
+
505
+ # Services Tendus
506
+ services_data = monitor.get_service_breakdown()
507
+ if services_data is not None:
508
+ services_tendus = (services_data['Tension Personnel'] > 1.0).sum()
509
+ else:
510
+ services_tendus = 0
511
 
512
+ alertes_color = "normal"
513
+ if services_tendus > 5:
514
+ alertes_color = "inverse"
515
+ elif services_tendus > 2:
516
+ alertes_color = "off"
517
 
518
+ col3.metric(
519
+ "Services Tendus",
520
+ services_tendus,
521
+ delta_color=alertes_color,
522
+ help="Services avec tension personnel > 1.0"
523
+ )
524
+
525
+ # Ruptures Stock
526
+ col4.metric(
527
+ "Ruptures Stock",
528
+ "0",
529
+ delta_color="normal",
530
+ help="Alertes stock critiques"
531
+ )
532
+
533
+ st.divider()
534
+
535
+ # ===== GRAPHIQUES PRINCIPAUX =====
536
+
537
+ col_left, col_right = st.columns([2, 1])
538
+
539
+ with col_left:
540
+ st.markdown("### Taux d'Occupation : Realite vs IA")
541
 
542
+ # Graphique historique + prédiction 48h
543
+ hist = df.tail(90)
544
+ next_48h, anomalies_48h = monitor.predict_next_48h()
 
 
 
 
 
 
 
 
 
 
 
 
 
545
 
546
+ fig = go.Figure()
547
+
548
+ # Historique
549
+ fig.add_trace(go.Scatter(
550
+ x=hist['date'],
551
+ y=hist['taux_occupation'] * 100,
552
+ name="Historique",
553
+ line=dict(color='#4A9EFF', width=2),
554
+ mode='lines'
555
+ ))
556
+
557
+ # Ligne de saturation
558
+ fig.add_hline(
559
+ y=100,
560
+ line_dash="dash",
561
+ line_color="#FF4444",
562
+ annotation_text="Saturation",
563
+ annotation_position="right"
564
+ )
565
+
566
+ # Prédiction 48h
567
+ fig.add_trace(go.Scatter(
568
+ x=next_48h['dates'],
569
+ y=[p * 100 for p in next_48h['predictions']],
570
+ name="Tendance IA",
571
+ line=dict(color='#00FF88', width=3, dash='dot'),
572
+ mode='lines+markers',
573
+ marker=dict(size=8)
574
+ ))
575
 
576
+ fig.update_layout(
577
+ template="plotly_dark",
578
+ height=400,
579
+ hovermode="x unified",
580
+ xaxis_title="",
581
+ yaxis_title="Taux Occupation (%)",
582
+ showlegend=True,
583
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1)
584
+ )
585
+
586
+ st.plotly_chart(fig, use_container_width=True)
587
+
588
+ with col_right:
589
+ st.markdown("### Etat Services (31/12)")
590
+
591
+ if services_data is not None:
592
+ # Affichage sous forme de barres horizontales
593
+ services_top = services_data.head(9)
594
 
595
+ fig_services = go.Figure()
596
+
597
+ # Barres de taux d'occupation
598
+ colors = []
599
+ for val in services_top['Taux Occupation']:
600
+ if val > 0.85:
601
+ colors.append('#FF4444')
602
+ elif val > 0.75:
603
+ colors.append('#FFA500')
604
+ else:
605
+ colors.append('#00FF88')
606
+
607
+ fig_services.add_trace(go.Bar(
608
+ y=services_top.index,
609
+ x=services_top['Taux Occupation'] * 100,
610
+ orientation='h',
611
+ marker=dict(color=colors),
612
+ text=[f"{v*100:.1f}%" for v in services_top['Taux Occupation']],
613
+ textposition='inside',
614
+ textfont=dict(color='white', size=11),
615
+ showlegend=False
616
+ ))
617
+
618
+ fig_services.update_layout(
619
+ template="plotly_dark",
620
+ height=400,
621
+ xaxis_title="Taux Occupation (%)",
622
+ yaxis_title="",
623
+ xaxis=dict(range=[0, 15]),
624
+ margin=dict(l=0, r=0, t=20, b=0)
625
  )
626
 
627
+ st.plotly_chart(fig_services, use_container_width=True)
628
+ else:
629
+ st.info("Donnees services non disponibles")
630
+
631
+ st.divider()
632
+
633
+ # ===== FLASH REPORT & RECOMMANDATIONS =====
634
+ st.markdown("### Flash Report & Recommandations")
635
+
636
+ # Détermination du statut global
637
+ if state['health_score'] > 80:
638
+ status_label = "NOMINAL"
639
+ status_color = "#00FF88"
640
+ status_icon = "circle"
641
+ elif state['health_score'] > 60:
642
+ status_label = "SOUS SURVEILLANCE"
643
+ status_color = "#FFA500"
644
+ status_icon = "triangle-up"
645
+ else:
646
+ status_label = "SITUATION CRITIQUE"
647
+ status_color = "#FF4444"
648
+ status_icon = "triangle-up"
649
+
650
+ col_status, col_actions = st.columns([2, 1])
651
+
652
+ with col_status:
653
+ st.markdown(
654
+ f"<div style='background-color: {status_color}20; border-left: 4px solid {status_color}; "
655
+ f"padding: 15px; border-radius: 5px;'>"
656
+ f"<span style='color: {status_color}; font-size: 14px; font-weight: 600;'>"
657
+ f"{status_icon.upper()} {status_label} : Activite hospitaliere sous controle."
658
+ f"</span></div>",
659
+ unsafe_allow_html=True
660
+ )
661
+
662
+ st.markdown("")
663
+
664
+ # Recommandations
665
+ if len(state['alertes']) == 0:
666
+ st.success("Aucune action critique requise a court terme.")
667
+ else:
668
+ st.warning(f"{len(state['alertes'])} alerte(s) detectee(s)")
669
+
670
+ for alerte in state['alertes']:
671
+ niveau_emoji = {
672
+ 'CRITICAL': 'red_circle',
673
+ 'WARNING': 'large_orange_diamond'
674
+ }
675
+ st.write(f":{niveau_emoji.get(alerte['niveau'], 'white_circle')}: "
676
+ f"**{alerte['categorie']}** - {alerte['message']}")
677
+
678
+ with col_actions:
679
+ if st.button("Alerter Equipes", type="primary", use_container_width=True):
680
+ st.success("Notification envoyee aux responsables de services")
681
+
682
+ if st.button("Export PDF", use_container_width=True):
683
+ st.info("Rapport executif en preparation...")
684
+
685
+ # ===== PRÉDICTIONS 48H =====
686
+ st.divider()
687
+ st.markdown("### Anticipation 48h")
688
+
689
+ if anomalies_48h:
690
+ st.warning(f"ALERTE PREDICTIVE : {len(anomalies_48h)} anomalie(s) detectee(s) dans les 48h")
691
+
692
+ for anom in anomalies_48h:
693
+ st.write(
694
+ f"J+{anom['jour']} : Pic prevu a {anom['valeur']:.0f} admissions "
695
+ f"(+{anom['ecart_pct']:.0f}% vs moyenne actuelle)"
696
+ )
697
+ else:
698
+ st.success("Aucune anomalie majeure prevue dans les 48 prochaines heures")
699
+
700
+ # ================================================================
701
+ # ONGLET 2 : SIMULATEUR DE CRISE
702
+ # ================================================================
703
+ with tab2:
704
+
705
+ st.markdown("### Bac a Sable (What-If Analysis)")
706
+
707
+ col_params, col_results = st.columns([1, 2])
708
+
709
+ with col_params:
710
+ st.markdown("#### 1. Parametres")
711
+
712
+ st.caption("Modifiez les variables pour voir l'impact futur.")
713
+
714
+ # Sélection du scénario
715
+ scenario_options = {
716
+ 'EPIDEMIE_GRIPPE': 'Epidemie de Grippe Saisonniere',
717
+ 'PANDEMIE_COVID': 'Pandemie Type COVID-19',
718
+ 'CANICULE': 'Canicule Extreme (>40C)',
719
+ 'ACCIDENT_MASSE': 'Accident de Grande Ampleur',
720
+ 'GREVE_PERSONNEL': 'Greve du Personnel Soignant',
721
+ 'CYBERATTAQUE': 'Cyberattaque Systeme Informatique',
722
+ 'RUPTURE_APPROVISIONNEMENT': 'Rupture Critique Approvisionnement'
723
+ }
724
+
725
+ selected_scenario = st.selectbox(
726
+ "Choc d'Admissions",
727
+ options=list(scenario_options.keys()),
728
+ format_func=lambda x: scenario_options[x],
729
+ index=0
730
+ )
731
+
732
+ # Intensité
733
+ intensity = st.slider(
734
+ "Intensite",
735
+ min_value=0.5,
736
+ max_value=2.0,
737
+ value=1.0,
738
+ step=0.1,
739
+ help="Multiplicateur d'intensite du scenario"
740
+ )
741
+
742
+ # Ouverture lits (simulation)
743
+ lits_additionnels = st.slider(
744
+ "Ouverture Lits",
745
+ min_value=-10,
746
+ max_value=20,
747
+ value=0,
748
+ step=1,
749
+ help="Pourcentage de lits supplementaires"
750
+ )
751
+
752
+ # Impact peak
753
+ st.markdown("#### Impact Peak")
754
+ st.metric("", "+0.0 pts", "Gain", delta_color="normal")
755
+
756
+ with col_results:
757
+ st.markdown("#### 2. Projection d'Impact (3 Mois)")
758
+
759
+ # Simulation
760
+ with st.spinner("Calcul de l'impact..."):
761
+ result = simulator.simulate_scenario(selected_scenario, intensity)
762
+
763
+ # Graphique d'impact
764
+ fig_impact = go.Figure()
765
+
766
+ # Ligne capacité max
767
+ fig_impact.add_hline(
768
+ y=100,
769
+ line_dash="solid",
770
+ line_color="#FF4444",
771
+ line_width=1,
772
+ annotation_text="Capacite Max",
773
+ annotation_position="right"
774
+ )
775
+
776
+ # Scénario actuel (baseline)
777
+ fig_impact.add_trace(go.Scatter(
778
+ x=result['dates'],
779
+ y=result['baseline'],
780
+ name="Scenario Actuel",
781
+ line=dict(color='gray', width=1, dash='dot'),
782
+ mode='lines',
783
+ opacity=0.6
784
+ ))
785
+
786
+ # Scénario simulé
787
+ fig_impact.add_trace(go.Scatter(
788
+ x=result['dates'],
789
+ y=result['crisis'],
790
+ name="Scenario Simule",
791
+ line=dict(color='#00FF88', width=3),
792
+ mode='lines',
793
+ fill='tonexty'
794
+ ))
795
+
796
+ fig_impact.update_layout(
797
+ template="plotly_dark",
798
+ height=400,
799
+ xaxis_title="",
800
+ yaxis_title="Impact sur le Taux d'Occupation",
801
+ showlegend=True,
802
+ hovermode="x unified"
803
+ )
804
+
805
+ st.plotly_chart(fig_impact, use_container_width=True)
806
+
807
+ # ===== RÉSULTAT DE LA SIMULATION =====
808
+ st.divider()
809
+
810
+ metrics = result['metrics']
811
+
812
+ # Message de robustesse
813
+ if metrics['criticite'] == 'CRITICAL':
814
+ st.error(
815
+ f"CRITIQUE : L'hopital NE PEUT PAS absorber ce choc. "
816
+ f"Occupation prevue : {metrics['occupation_crisis']*100:.0f}% "
817
+ f"(max: {metrics['admissions_peak']:.0f} admissions/jour)."
818
+ )
819
+ elif metrics['criticite'] == 'HIGH':
820
+ st.warning(
821
+ f"ATTENTION : L'hopital est en tension elevee. "
822
+ f"Occupation prevue : {metrics['occupation_crisis']*100:.0f}%."
823
+ )
824
+ else:
825
+ st.success(
826
+ f"ROBUSTE : L'hopital tient le choc ({metrics['occupation_crisis']*100:.0f}% max)."
827
+ )
828
+
829
+ # ===== RECOMMANDATIONS AUTOMATIQUES =====
830
+ st.markdown("### Recommandations Strategiques")
831
+
832
+ if result['recommendations']:
833
+
834
+ # Tri par priorité
835
+ priority_order = {'IMMEDIATE': 0, 'URGENT': 1, 'NORMAL': 2}
836
+ sorted_recs = sorted(
837
+ result['recommendations'],
838
+ key=lambda x: priority_order.get(x['priorite'], 3)
839
+ )
840
+
841
+ for rec in sorted_recs:
842
+ priority_color = {
843
+ 'IMMEDIATE': '#FF4444',
844
+ 'URGENT': '#FFA500',
845
+ 'NORMAL': '#4A9EFF'
846
+ }
847
 
848
+ color = priority_color.get(rec['priorite'], '#888888')
 
849
 
850
+ st.markdown(
851
+ f"<div style='background-color: {color}20; border-left: 4px solid {color}; "
852
+ f"padding: 12px; margin-bottom: 10px; border-radius: 4px;'>"
853
+ f"<div style='color: {color}; font-weight: 600; margin-bottom: 5px;'>"
854
+ f"{rec['categorie']} - {rec['priorite']}</div>"
855
+ f"<div style='color: #CCCCCC; font-size: 14px; margin-bottom: 5px;'>"
856
+ f"<strong>Action :</strong> {rec['action']}</div>"
857
+ f"<div style='color: #999999; font-size: 12px;'>"
858
+ f"<em>{rec['justification']}</em></div>"
859
+ f"</div>",
860
+ unsafe_allow_html=True
861
+ )
862
+ else:
863
+ st.info("Aucune recommandation critique pour ce scenario")
864
 
865
+
866
+ # Point d'entrée principal
867
+ if __name__ == "__main__":
868
+ show_command_center(None, None)