jaimalklyde commited on
Commit
6a16398
·
verified ·
1 Parent(s): c586e1e

Update src/Function/Validation_Contract.py

Browse files
Files changed (1) hide show
  1. src/Function/Validation_Contract.py +285 -49
src/Function/Validation_Contract.py CHANGED
@@ -2,105 +2,341 @@
2
  MODULE: VALIDATION CONTRACT (ETL ENGINE)
3
  ========================================
4
  Responsabilité : Centraliser le schéma, extraire et nettoyer les données.
 
5
  """
6
  import pandas as pd
7
  import numpy as np
8
  import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  class ETL_Engine:
11
- """Moteur ETL garantissant le Contrat de Données"""
12
 
13
  def __init__(self, gspread_client, sheet_name="bdd_mvp_faker"):
14
  self.client = gspread_client
15
  self.sheet_name = sheet_name
16
 
17
  # --- SCHEMA CONTRACTUEL ---
18
- # Définit ce qui DOIT être un nombre ou une date
 
19
  self.SCHEMA = {
20
- 'ressources': {
21
- 'numeric': ['lits_total', 'lits_occupes', 'taux_occupation', 'personnel_total', 'tension_personnel'],
22
- 'date': ['date']
 
 
 
 
 
 
 
 
 
 
 
 
23
  },
24
- 'stock': {
25
- 'numeric': ['stock_total', 'stock_disponible', 'tension_stock'],
26
- 'date': ['date']
 
 
 
 
 
27
  },
28
- 'admissions': {
29
  'numeric': ['nb_admissions', 'intensity'],
30
- 'date': ['date']
 
31
  },
32
- 'patients': {
33
- 'numeric': ['age'],
34
- 'date': ['date_naissance']
 
 
 
35
  },
36
- 'sejours': {
37
- 'numeric': ['duree_sejour_jours', 'gravite_1_5'],
38
- 'date': ['date_entree', 'date_sortie']
 
39
  }
40
  }
 
 
 
 
 
 
41
 
42
  def _clean_dataframe(self, name, df_raw):
43
- """Transform: Applique le schéma et nettoie les types"""
 
 
 
 
 
 
 
 
 
44
  df = df_raw.copy()
45
 
46
- # Identification du type de table basé sur le nom
47
- config_type = next((key for key in self.SCHEMA if key in name), None)
48
 
49
- if config_type:
50
- config = self.SCHEMA[config_type]
51
-
52
- # 1. Dates : Coercition stricte (les erreurs deviennent NaT)
53
- for col in config.get('date', []):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  if col in df.columns:
55
- df[col] = pd.to_datetime(df[col], errors='coerce')
 
 
 
 
56
 
57
- # 2. Nombres : Coercition stricte (les 'Full', 'Erreur' deviennent NaN)
58
- for col in config.get('numeric', []):
59
  if col in df.columns:
60
- # Gestion des virgules françaises
61
- if df[col].dtype == 'object':
62
- df[col] = df[col].astype(str).str.replace(',', '.')
63
- df[col] = pd.to_numeric(df[col], errors='coerce')
64
-
65
- # Nettoyage générique dates si non spécifié
66
- else:
67
- for col in df.columns:
68
- if 'date' in col.lower():
69
- df[col] = pd.to_datetime(df[col], errors='coerce')
70
 
71
- return df
72
 
73
  def run_etl(self):
74
- """Pipeline complet : Extract -> Transform -> Load"""
75
- data_store = {}
 
 
 
 
 
 
 
76
  try:
 
77
  spreadsheet = self.client.open(self.sheet_name)
78
  worksheets = spreadsheet.worksheets()
79
 
80
- # Barre de progression interne au module
81
- progress_bar = st.progress(0, text="ETL Engine: Extraction des données...")
 
 
82
 
83
- for i, ws in enumerate(worksheets):
 
 
 
84
  # EXTRACT
85
  raw_data = ws.get_all_values()
 
 
86
  if len(raw_data) > 1:
87
  headers = raw_data[0]
88
  rows = raw_data[1:]
89
  df_raw = pd.DataFrame(rows, columns=headers)
90
 
 
 
91
  # Nom standardisé
92
- std_name = ws.title.lower().strip().replace(' ', '_').replace('-', '_').split('.')[0]
93
 
94
  # TRANSFORM
 
 
95
  df_clean = self._clean_dataframe(std_name, df_raw)
96
 
97
- # LOAD
98
- data_store[std_name] = df_clean
99
 
100
- progress_bar.progress((i + 1) / len(worksheets))
 
 
 
 
 
 
 
 
 
 
101
 
102
  progress_bar.empty()
103
- return True, data_store
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  except Exception as e:
106
- return False, str(e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  MODULE: VALIDATION CONTRACT (ETL ENGINE)
3
  ========================================
4
  Responsabilité : Centraliser le schéma, extraire et nettoyer les données.
5
+ Pipeline: Extract → Transform → Validate → Load
6
  """
7
  import pandas as pd
8
  import numpy as np
9
  import streamlit as st
10
+ from datetime import datetime
11
+
12
+
13
+ class DataExplorer:
14
+ """Conteneur pour stocker les dataframes et métadonnées d'exploration"""
15
+
16
+ def __init__(self):
17
+ self.dataframes = {}
18
+ self.metadata = {}
19
+ self.load_timestamp = None
20
+
21
+ def add_dataframe(self, name, df, rows_before=None, rows_after=None):
22
+ """Ajoute un dataframe avec ses métadonnées"""
23
+ self.dataframes[name] = df
24
+ self.metadata[name] = {
25
+ 'rows': len(df),
26
+ 'columns': len(df.columns),
27
+ 'rows_before_cleaning': rows_before,
28
+ 'rows_after_cleaning': rows_after,
29
+ 'memory_usage': df.memory_usage(deep=True).sum() / 1024**2, # MB
30
+ 'dtypes': df.dtypes.to_dict(),
31
+ 'null_counts': df.isnull().sum().to_dict(),
32
+ 'numeric_columns': df.select_dtypes(include=[np.number]).columns.tolist(),
33
+ 'categorical_columns': df.select_dtypes(include=['object']).columns.tolist(),
34
+ 'datetime_columns': df.select_dtypes(include=['datetime64']).columns.tolist()
35
+ }
36
+
37
+ def get_summary(self):
38
+ """Retourne un résumé global des données chargées"""
39
+ total_rows = sum(meta['rows'] for meta in self.metadata.values())
40
+ total_memory = sum(meta['memory_usage'] for meta in self.metadata.values())
41
+
42
+ return {
43
+ 'tables_loaded': len(self.dataframes),
44
+ 'total_rows': total_rows,
45
+ 'total_memory_mb': round(total_memory, 2),
46
+ 'timestamp': self.load_timestamp
47
+ }
48
+
49
 
50
  class ETL_Engine:
51
+ """Moteur ETL garantissant le Contrat de Données pour l'EDA"""
52
 
53
  def __init__(self, gspread_client, sheet_name="bdd_mvp_faker"):
54
  self.client = gspread_client
55
  self.sheet_name = sheet_name
56
 
57
  # --- SCHEMA CONTRACTUEL ---
58
+ # Définit précisément ce qui DOIT être un nombre ou une date
59
+ # Basé sur l'analyse réelle du fichier bdd_mvp_faker.xlsx
60
  self.SCHEMA = {
61
+ 'patients': {
62
+ 'numeric': ['age', 'code_postal'],
63
+ 'date': ['date_naissance'],
64
+ 'categorical': ['patient_id', 'nom', 'prenom', 'sexe', 'ville',
65
+ 'num_carte_vitale_fictif', 'num_carte_vitale_masque',
66
+ 'pathologie_frequente_code'],
67
+ 'preserve_as_is': ['dq_is_anomaly', 'dq_anomaly_type', 'dq_anomaly_detail']
68
+ },
69
+ 'sejours': {
70
+ 'numeric': ['gravite_1_5', 'duree_sejour_jours', 'consommables_estimes_score'],
71
+ 'date': ['date_entree', 'date_sortie'],
72
+ 'categorical': ['stay_id', 'patient_id', 'service_code', 'mode_entree',
73
+ 'type_lit', 'pathologie_code', 'scenario_jour',
74
+ 'equipement_principal_code'],
75
+ 'preserve_as_is': ['dq_is_anomaly', 'dq_anomaly_type', 'dq_anomaly_detail']
76
  },
77
+ 'ressources_jour': {
78
+ 'numeric': ['intensity', 'lits_total', 'lits_occupes', 'lits_disponibles',
79
+ 'taux_occupation', 'personnel_total', 'taux_absence_conges',
80
+ 'taux_absence_scenario', 'est_periode_conges', 'personnel_disponible',
81
+ 'personnel_requis', 'tension_personnel'],
82
+ 'date': ['date'],
83
+ 'categorical': ['service_code', 'scenario', 'periode_conges_label'],
84
+ 'preserve_as_is': ['dq_is_anomaly', 'dq_anomaly_type', 'dq_anomaly_detail']
85
  },
86
+ 'admissions_jour': {
87
  'numeric': ['nb_admissions', 'intensity'],
88
+ 'date': ['date'],
89
+ 'categorical': ['service_code', 'scenario']
90
  },
91
+ 'stock_materiel_jour': {
92
+ 'numeric': ['stock_total', 'stock_disponible', 'consommation_estimee_jour',
93
+ 'tension_stock'],
94
+ 'date': ['date'],
95
+ 'categorical': ['service_code', 'materiel_code', 'alerte_stock'],
96
+ 'preserve_as_is': ['dq_is_anomaly', 'dq_anomaly_type', 'dq_anomaly_detail']
97
  },
98
+ 'scenarios_calendrier': {
99
+ 'numeric': ['intensity'],
100
+ 'date': ['date'],
101
+ 'categorical': ['scenario']
102
  }
103
  }
104
+
105
+ # Tables de référence (pas de nettoyage, juste chargement)
106
+ self.REFERENCE_TABLES = [
107
+ 'service_ref', 'pathologie_ref', 'materiel_ref',
108
+ 'mode_entree_ref', 'type_lit_ref', 'calendrier_conges', 'hypotheses'
109
+ ]
110
 
111
  def _clean_dataframe(self, name, df_raw):
112
+ """
113
+ Transform: Applique le schéma et nettoie les types
114
+
115
+ Args:
116
+ name: Nom de la feuille/table
117
+ df_raw: DataFrame brut
118
+
119
+ Returns:
120
+ df_clean: DataFrame nettoyé selon le schéma
121
+ """
122
  df = df_raw.copy()
123
 
124
+ # Normalisation du nom
125
+ name_normalized = name.lower().strip().replace(' ', '_').replace('-', '_')
126
 
127
+ # Si c'est une table de référence, on la retourne telle quelle
128
+ if name_normalized in self.REFERENCE_TABLES:
129
+ return df
130
+
131
+ # Recherche de la configuration correspondante
132
+ config = self.SCHEMA.get(name_normalized)
133
+
134
+ if not config:
135
+ # Si pas de config, nettoyage générique minimal
136
+ return self._generic_cleaning(df)
137
+
138
+ # --- 1. CONVERSION DES DATES ---
139
+ for col in config.get('date', []):
140
+ if col in df.columns:
141
+ # Conversion stricte : les erreurs deviennent NaT
142
+ df[col] = pd.to_datetime(df[col], errors='coerce')
143
+
144
+ # --- 2. CONVERSION DES NUMÉRIQUES ---
145
+ for col in config.get('numeric', []):
146
+ if col in df.columns:
147
+ # Gestion des formats français (virgules) et texte
148
+ if df[col].dtype == 'object':
149
+ # Remplacer virgules par points
150
+ df[col] = df[col].astype(str).str.replace(',', '.').str.replace(' ', '')
151
+ # Gérer les cas comme "Full", "Erreur", etc.
152
+ df[col] = df[col].replace(['Full', 'Erreur', 'full', 'erreur', ''], np.nan)
153
+
154
+ # Conversion numérique stricte
155
+ df[col] = pd.to_numeric(df[col], errors='coerce')
156
+
157
+ # --- 3. CATÉGORIELLES : Nettoyage basique ---
158
+ for col in config.get('categorical', []):
159
+ if col in df.columns and df[col].dtype == 'object':
160
+ # Strip des espaces, uniformisation
161
+ df[col] = df[col].astype(str).str.strip()
162
+ # Remplacer 'nan' string par vrai NaN
163
+ df[col] = df[col].replace(['nan', 'NaN', 'None', ''], np.nan)
164
+
165
+ # --- 4. COLONNES DQ : Préserver telles quelles ---
166
+ # Les colonnes dq_* ne sont pas modifiées car elles contiennent
167
+ # les informations d'anomalies du dataset
168
+
169
+ return df
170
+
171
+ def _generic_cleaning(self, df):
172
+ """Nettoyage générique pour les tables sans schéma défini"""
173
+ df_clean = df.copy()
174
+
175
+ # Détection et conversion automatique des dates
176
+ for col in df_clean.columns:
177
+ if 'date' in col.lower():
178
+ df_clean[col] = pd.to_datetime(df_clean[col], errors='coerce')
179
+
180
+ # Nettoyage des strings
181
+ for col in df_clean.select_dtypes(include=['object']).columns:
182
+ df_clean[col] = df_clean[col].astype(str).str.strip()
183
+ df_clean[col] = df_clean[col].replace(['nan', 'NaN', 'None', ''], np.nan)
184
+
185
+ return df_clean
186
+
187
+ def _validate_dataframe(self, name, df):
188
+ """
189
+ Validate: Vérifie la cohérence post-nettoyage
190
+
191
+ Returns:
192
+ dict: Rapport de validation avec warnings si nécessaire
193
+ """
194
+ validation_report = {
195
+ 'table': name,
196
+ 'valid': True,
197
+ 'warnings': [],
198
+ 'errors': []
199
+ }
200
+
201
+ # Vérification 1: Table vide
202
+ if len(df) == 0:
203
+ validation_report['errors'].append(f"Table {name} est vide après nettoyage")
204
+ validation_report['valid'] = False
205
+ return validation_report
206
+
207
+ # Vérification 2: Perte massive de données (>50% de NaN sur colonnes critiques)
208
+ name_normalized = name.lower().strip().replace(' ', '_')
209
+ config = self.SCHEMA.get(name_normalized)
210
+
211
+ if config:
212
+ # Vérifier les colonnes numériques critiques
213
+ for col in config.get('numeric', []):
214
  if col in df.columns:
215
+ null_pct = df[col].isnull().sum() / len(df) * 100
216
+ if null_pct > 50:
217
+ validation_report['warnings'].append(
218
+ f"Colonne {col}: {null_pct:.1f}% de valeurs manquantes"
219
+ )
220
 
221
+ # Vérifier les colonnes dates
222
+ for col in config.get('date', []):
223
  if col in df.columns:
224
+ null_pct = df[col].isnull().sum() / len(df) * 100
225
+ if null_pct > 50:
226
+ validation_report['warnings'].append(
227
+ f"Colonne date {col}: {null_pct:.1f}% de valeurs manquantes"
228
+ )
 
 
 
 
 
229
 
230
+ return validation_report
231
 
232
  def run_etl(self):
233
+ """
234
+ Pipeline complet : Extract → Transform → Validate → Load
235
+
236
+ Returns:
237
+ tuple: (success: bool, result: DataExplorer ou str d'erreur)
238
+ """
239
+ data_explorer = DataExplorer()
240
+ validation_reports = []
241
+
242
  try:
243
+ # EXTRACT: Connexion au spreadsheet
244
  spreadsheet = self.client.open(self.sheet_name)
245
  worksheets = spreadsheet.worksheets()
246
 
247
+ # Filtrer les feuilles pertinentes pour l'EDA
248
+ # On exclut les feuilles vides ou non pertinentes
249
+ relevant_sheets = [ws for ws in worksheets
250
+ if ws.title.lower() not in ['', 'sheet1', 'feuil1']]
251
 
252
+ # Barre de progression
253
+ progress_bar = st.progress(0, text="🔄 ETL Engine: Extraction des données...")
254
+
255
+ for i, ws in enumerate(relevant_sheets):
256
  # EXTRACT
257
  raw_data = ws.get_all_values()
258
+
259
+ # Vérifier qu'il y a au moins des headers + 1 ligne
260
  if len(raw_data) > 1:
261
  headers = raw_data[0]
262
  rows = raw_data[1:]
263
  df_raw = pd.DataFrame(rows, columns=headers)
264
 
265
+ rows_before = len(df_raw)
266
+
267
  # Nom standardisé
268
+ std_name = ws.title.lower().strip().replace(' ', '_').replace('-', '_')
269
 
270
  # TRANSFORM
271
+ progress_bar.progress((i + 0.5) / len(relevant_sheets),
272
+ text=f"🔧 Nettoyage: {std_name}...")
273
  df_clean = self._clean_dataframe(std_name, df_raw)
274
 
275
+ rows_after = len(df_clean)
 
276
 
277
+ # VALIDATE
278
+ validation = self._validate_dataframe(std_name, df_clean)
279
+ validation_reports.append(validation)
280
+
281
+ # LOAD dans le DataExplorer
282
+ data_explorer.add_dataframe(std_name, df_clean, rows_before, rows_after)
283
+
284
+ progress_bar.progress((i + 1) / len(relevant_sheets))
285
+
286
+ # Timestamp du chargement
287
+ data_explorer.load_timestamp = datetime.now()
288
 
289
  progress_bar.empty()
290
+
291
+ # Afficher les warnings de validation
292
+ has_warnings = any(report['warnings'] for report in validation_reports)
293
+ has_errors = any(report['errors'] for report in validation_reports)
294
+
295
+ if has_errors:
296
+ st.error("⚠️ Erreurs détectées lors du nettoyage ETL")
297
+ for report in validation_reports:
298
+ if report['errors']:
299
+ for error in report['errors']:
300
+ st.error(f" • {error}")
301
+
302
+ if has_warnings:
303
+ with st.expander("⚠️ Avertissements ETL", expanded=False):
304
+ for report in validation_reports:
305
+ if report['warnings']:
306
+ st.warning(f"**Table: {report['table']}**")
307
+ for warning in report['warnings']:
308
+ st.write(f" • {warning}")
309
+
310
+ # Stocker dans session_state pour réutilisation
311
+ st.session_state.data_explorer = data_explorer
312
+
313
+ # Message de succès
314
+ summary = data_explorer.get_summary()
315
+ st.success(f"✅ ETL terminé : {summary['tables_loaded']} tables chargées "
316
+ f"({summary['total_rows']:,} lignes, {summary['total_memory_mb']} MB)")
317
+
318
+ return True, data_explorer
319
 
320
  except Exception as e:
321
+ st.error(f"❌ Erreur ETL : {str(e)}")
322
+ return False, str(e)
323
+
324
+ def get_dataframe(self, table_name):
325
+ """
326
+ Récupère un dataframe spécifique depuis le session_state
327
+
328
+ Args:
329
+ table_name: Nom de la table
330
+
331
+ Returns:
332
+ DataFrame ou None
333
+ """
334
+ if 'data_explorer' in st.session_state:
335
+ return st.session_state.data_explorer.dataframes.get(table_name)
336
+ return None
337
+
338
+ def get_all_tables(self):
339
+ """Retourne la liste de toutes les tables disponibles"""
340
+ if 'data_explorer' in st.session_state:
341
+ return list(st.session_state.data_explorer.dataframes.keys())
342
+ return []