oeeil 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. oeeil/__init__.py +96 -0
  2. oeeil/modules/__init__.py +0 -0
  3. oeeil/modules/analyse/__init__.py +0 -0
  4. oeeil/modules/analyse/classify_exposure_level.py +273 -0
  5. oeeil/modules/analyse/compute_daily_exposure.py +273 -0
  6. oeeil/modules/analyse/compute_exposure_indicators.py +284 -0
  7. oeeil/modules/analyse/identify_critical_locations.py +310 -0
  8. oeeil/modules/analyse/stratify_life_rhythm.py +249 -0
  9. oeeil/modules/pretraitement/__init__.py +0 -0
  10. oeeil/modules/pretraitement/convert_mv_to_concentration.py +355 -0
  11. oeeil/modules/pretraitement/detect_extinction_periods.py +390 -0
  12. oeeil/modules/pretraitement/detect_pollution_events.py +815 -0
  13. oeeil/modules/pretraitement/filter_pollutant_outliers.py +456 -0
  14. oeeil/modules/pretraitement/flag_environmental_conditions.py +206 -0
  15. oeeil/modules/pretraitement/imputation_valeurs_manquantes.py +534 -0
  16. oeeil/modules/pretraitement/manage_missing_suppression.py +252 -0
  17. oeeil/modules/pretraitement/normalize_gas_percentage.py +976 -0
  18. oeeil/modules/reporting/__init__.py +0 -0
  19. oeeil/modules/reporting/generate_report.py +520 -0
  20. oeeil/modules/reporting/statistical_summary_report.py +270 -0
  21. oeeil/modules/standardisation/__init__.py +0 -0
  22. oeeil/modules/standardisation/aggregate_close_gps_points.py +274 -0
  23. oeeil/modules/standardisation/convert_timezone.py +293 -0
  24. oeeil/modules/standardisation/identify_calibration_pairs.py +337 -0
  25. oeeil/modules/standardisation/standardize_db.py +545 -0
  26. oeeil/modules/standardisation/synchronize_oeeil_campaign.py +582 -0
  27. oeeil/modules/standardisation/synchronize_with_reference.py +399 -0
  28. oeeil/modules/traitement/__init__.py +0 -0
  29. oeeil/modules/traitement/classify_indoor_outdoor_co2.py +265 -0
  30. oeeil/modules/traitement/classify_indoor_outdoor_cov.py +259 -0
  31. oeeil/modules/traitement/classify_transport_mode.py +237 -0
  32. oeeil/modules/traitement/compute_displacement_speed.py +227 -0
  33. oeeil/modules/traitement/correlate_transport_environment.py +140 -0
  34. oeeil/modules/traitement/detect_cov_outliers.py +254 -0
  35. oeeil/modules/traitement/interpolate_gps_coordinates.py +264 -0
  36. oeeil/modules/traitement/normalize_cov_percentage.py +235 -0
  37. oeeil/modules/visualisation/__init__.py +0 -0
  38. oeeil/modules/visualisation/calendar_heatmap.py +318 -0
  39. oeeil/modules/visualisation/statistical_summary_visual.py +346 -0
  40. oeeil/modules/visualisation/visualize_exposure.py +366 -0
  41. oeeil-0.1.0.dist-info/METADATA +343 -0
  42. oeeil-0.1.0.dist-info/RECORD +46 -0
  43. oeeil-0.1.0.dist-info/WHEEL +5 -0
  44. oeeil-0.1.0.dist-info/licenses/LICENSE +202 -0
  45. oeeil-0.1.0.dist-info/licenses/NOTICE +15 -0
  46. oeeil-0.1.0.dist-info/top_level.txt +1 -0
oeeil/__init__.py ADDED
@@ -0,0 +1,96 @@
1
+ __version__ = "0.1.0"
2
+
3
+ # Analyse
4
+ from .modules.analyse.classify_exposure_level import classify_exposure_level
5
+ from .modules.analyse.compute_daily_exposure import compute_daily_exposure
6
+ from .modules.analyse.compute_exposure_indicators import compute_exposure_indicators
7
+ from .modules.analyse.identify_critical_locations import identify_critical_locations
8
+ from .modules.analyse.stratify_life_rhythm import stratify_life_rhythm
9
+
10
+ # Pretraitement
11
+ from .modules.pretraitement.convert_mv_to_concentration import convert_mv_to_concentration
12
+ from .modules.pretraitement.detect_extinction_periods import detect_extinction_periods
13
+ from .modules.pretraitement.detect_pollution_events import detect_pollution_events
14
+ from .modules.pretraitement.filter_pollutant_outliers import filter_pollutant_outliers
15
+ from .modules.pretraitement.flag_environmental_conditions import flag_environmental_conditions
16
+ from .modules.pretraitement.imputation_valeurs_manquantes import (
17
+ impute_missing_values,
18
+ print_imputation_report,
19
+ )
20
+ from .modules.pretraitement.manage_missing_suppression import manage_missing_suppression
21
+ from .modules.pretraitement.normalize_gas_percentage import normalize_gas_percentage
22
+
23
+ # Reporting
24
+ from .modules.reporting.generate_report import generate_report
25
+ from .modules.reporting.statistical_summary_report import statistical_summary_report
26
+
27
+ # Standardisation
28
+ from .modules.standardisation.aggregate_close_gps_points import aggregate_close_gps_points
29
+ from .modules.standardisation.convert_timezone import convert_timezone
30
+ from .modules.standardisation.identify_calibration_pairs import identify_calibration_pairs
31
+ from .modules.standardisation.standardize_db import standardize_db
32
+ from .modules.standardisation.synchronize_oeeil_campaign import synchronize_oeeil_campaign
33
+ from .modules.standardisation.synchronize_with_reference import synchronize_with_reference
34
+
35
+ # Traitement
36
+ from .modules.traitement.classify_indoor_outdoor_co2 import classify_indoor_outdoor_co2
37
+ from .modules.traitement.classify_indoor_outdoor_cov import classify_indoor_outdoor_cov
38
+ from .modules.traitement.classify_transport_mode import classify_transport_mode
39
+ from .modules.traitement.compute_displacement_speed import compute_displacement_speed
40
+ from .modules.traitement.correlate_transport_environment import correlate_transport_environment
41
+ from .modules.traitement.detect_cov_outliers import detect_cov_outliers
42
+ from .modules.traitement.interpolate_gps_coordinates import interpolate_gps_coordinates
43
+ from .modules.traitement.normalize_cov_percentage import normalize_cov_percentage
44
+
45
+ # Visualisation
46
+ from .modules.visualisation.calendar_heatmap import calendar_heatmap
47
+ from .modules.visualisation.statistical_summary_visual import statistical_summary_visual
48
+ from .modules.visualisation.visualize_exposure import visualize_exposure
49
+
50
+
51
+ __all__ = [
52
+ # Analyse
53
+ "classify_exposure_level",
54
+ "compute_daily_exposure",
55
+ "compute_exposure_indicators",
56
+ "identify_critical_locations",
57
+ "stratify_life_rhythm",
58
+
59
+ # Pretraitement
60
+ "convert_mv_to_concentration",
61
+ "detect_extinction_periods",
62
+ "detect_pollution_events",
63
+ "filter_pollutant_outliers",
64
+ "flag_environmental_conditions",
65
+ "impute_missing_values",
66
+ "print_imputation_report",
67
+ "manage_missing_suppression",
68
+ "normalize_gas_percentage",
69
+
70
+ # Reporting
71
+ "generate_report",
72
+ "statistical_summary_report",
73
+
74
+ # Standardisation
75
+ "aggregate_close_gps_points",
76
+ "convert_timezone",
77
+ "identify_calibration_pairs",
78
+ "standardize_db",
79
+ "synchronize_oeeil_campaign",
80
+ "synchronize_with_reference",
81
+
82
+ # Traitement
83
+ "classify_indoor_outdoor_co2",
84
+ "classify_indoor_outdoor_cov",
85
+ "classify_transport_mode",
86
+ "compute_displacement_speed",
87
+ "correlate_transport_environment",
88
+ "detect_cov_outliers",
89
+ "interpolate_gps_coordinates",
90
+ "normalize_cov_percentage",
91
+
92
+ # Visualisation
93
+ "calendar_heatmap",
94
+ "statistical_summary_visual",
95
+ "visualize_exposure",
96
+ ]
File without changes
File without changes
@@ -0,0 +1,273 @@
1
+ """
2
+ Fonction 20 — classify_exposure_level
3
+ Classifie le niveau d'exposition à chaque polluant selon des seuils
4
+ paramétrables (OMS 2021 par défaut pour PM2.5/PM10 ; ANSES/ASHRAE pour CO₂).
5
+ """
6
+
7
+ import pandas as pd
8
+ import numpy as np
9
+ from typing import Optional
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Seuils OMS 2021 et ANSES/ASHRAE par défaut
14
+ # Format : {col: [seuil1, seuil2, seuil3]}
15
+ # → niveau 0 (faible) si valeur < seuil1
16
+ # → niveau 1 (modéré) si seuil1 ≤ valeur < seuil2
17
+ # → niveau 2 (élevé) si seuil2 ≤ valeur < seuil3
18
+ # → niveau 3 (très élevé) si valeur ≥ seuil3
19
+ # ---------------------------------------------------------------------------
20
+ DEFAULT_THRESHOLDS = {
21
+ "pm25": [5.0, 15.0, 25.0], # µg/m³ — OMS 2021
22
+ "pm10": [15.0, 45.0, 75.0], # µg/m³ — OMS 2021
23
+ "co2_ppm": [800.0, 1000.0, 2000.0], # ppm — ANSES / ASHRAE 62.1
24
+ # no2_mv / o3_mv : non calibrés → non inclus par défaut
25
+ }
26
+
27
+ LEVEL_LABELS_DEFAULT = ["faible", "modéré", "élevé", "très élevé"]
28
+ LEVEL_ORDER = {lbl: i for i, lbl in enumerate(LEVEL_LABELS_DEFAULT)}
29
+
30
+
31
+ def _classify_column(
32
+ series: pd.Series,
33
+ thresholds: list,
34
+ labels: list,
35
+ ) -> pd.Series:
36
+ """
37
+ Applique une classification par seuils sur une Series numérique.
38
+
39
+ Parameters
40
+ ----------
41
+ series : pd.Series
42
+ Valeurs à classifier.
43
+ thresholds : list of 3 float
44
+ [seuil1, seuil2, seuil3] — bornes de séparation entre les 4 niveaux.
45
+ labels : list of 4 str
46
+ Étiquettes des niveaux [niveau0, niveau1, niveau2, niveau3].
47
+
48
+ Returns
49
+ -------
50
+ pd.Series de str (dtype object), NaN pour les valeurs manquantes.
51
+ """
52
+ if len(thresholds) != 3:
53
+ raise ValueError(
54
+ f"'thresholds' doit contenir exactement 3 valeurs, "
55
+ f"reçu {len(thresholds)}."
56
+ )
57
+ if len(labels) != 4:
58
+ raise ValueError(
59
+ f"'level_labels' doit contenir exactement 4 étiquettes, "
60
+ f"reçu {len(labels)}."
61
+ )
62
+
63
+ s1, s2, s3 = thresholds
64
+ if not (s1 < s2 < s3):
65
+ raise ValueError(
66
+ f"Les seuils doivent être strictement croissants : "
67
+ f"seuil1={s1} < seuil2={s2} < seuil3={s3} non vérifié."
68
+ )
69
+
70
+ conditions = [
71
+ series < s1,
72
+ (series >= s1) & (series < s2),
73
+ (series >= s2) & (series < s3),
74
+ series >= s3,
75
+ ]
76
+ result = np.select(conditions, labels, default="__nan__")
77
+ result = pd.Series(result, index=series.index, dtype=object)
78
+ result[series.isna() | (result == "__nan__")] = np.nan
79
+ return result
80
+
81
+
82
+ def classify_exposure_level(
83
+ df: pd.DataFrame,
84
+ pollutant_thresholds: Optional[dict] = None,
85
+ level_labels: Optional[list] = None,
86
+ overall_method: str = "max",
87
+ keep_intermediate: bool = True,
88
+ ) -> tuple:
89
+ """
90
+ Fonction 20 — Classification du niveau d'exposition par polluant.
91
+
92
+ Attribue à chaque mesure un niveau d'exposition (faible / modéré /
93
+ élevé / très élevé) pour chaque polluant disposant de seuils définis,
94
+ puis calcule un niveau d'exposition global par ligne.
95
+
96
+ Parameters
97
+ ----------
98
+ df : pd.DataFrame
99
+ DataFrame OEEIL standardisé. Doit contenir au moins une colonne
100
+ correspondant à un polluant présent dans `pollutant_thresholds`.
101
+ pollutant_thresholds : dict, optional
102
+ Dictionnaire {nom_colonne: [seuil1, seuil2, seuil3]} définissant
103
+ les bornes de classification pour chaque polluant.
104
+ Si None, les seuils OMS 2021 (PM2.5, PM10) et ANSES/ASHRAE (CO₂)
105
+ sont utilisés pour les colonnes présentes dans le DataFrame.
106
+ level_labels : list of 4 str, optional
107
+ Étiquettes des 4 niveaux, du plus faible au plus élevé.
108
+ Par défaut : ["faible", "modéré", "élevé", "très élevé"].
109
+ overall_method : str
110
+ Méthode de calcul du niveau global par ligne :
111
+ - "max" : niveau le plus défavorable parmi les polluants classifiés
112
+ (approche précautionneuse, recommandée).
113
+ - "mean" : niveau moyen arrondi (approche descriptive).
114
+ Par défaut : "max".
115
+ keep_intermediate : bool
116
+ Si True, les colonnes `{col}_exposure_level` par polluant sont
117
+ conservées dans le DataFrame de sortie.
118
+ Si False, seule la colonne `exposure_level_overall` est ajoutée.
119
+ Par défaut : True.
120
+
121
+ Returns
122
+ -------
123
+ df_out : pd.DataFrame
124
+ DataFrame enrichi avec :
125
+ - `{col}_exposure_level` (str) pour chaque polluant classifié
126
+ (si keep_intermediate=True).
127
+ - `exposure_level_overall` (str) : niveau global par ligne.
128
+ - `exposure_score_overall` (int, 0-3) : version ordinale du niveau
129
+ global (0 = faible, 3 = très élevé), utile pour les calculs aval
130
+ (AUC, indicateurs d'exposition cumulée).
131
+ meta : dict
132
+ Dictionnaire récapitulatif contenant :
133
+ - "polluants_classifiés" : liste des colonnes effectivement traitées.
134
+ - "polluants_absents" : colonnes demandées mais absentes du DataFrame.
135
+ - "seuils_appliqués" : seuils utilisés par polluant.
136
+ - "distribution_par_polluant" : {col: {niveau: count}} par polluant.
137
+ - "distribution_globale" : {niveau: count} pour exposure_level_overall.
138
+ - "taux_très_élevé_global" : fraction de lignes au niveau le plus élevé.
139
+
140
+ Notes
141
+ -----
142
+ - Les colonnes `no2_mv` et `o3_mv` sont en millivolts bruts et ne
143
+ disposent pas de seuils OMS dans leur unité native : elles sont
144
+ ignorées par défaut. Fournir des seuils calibrés via
145
+ `pollutant_thresholds` si une calibration a été appliquée en amont.
146
+ - `exposure_score_overall` encode les niveaux comme entiers (0–3) et
147
+ peut être utilisé directement par la fonction 21 (calcul d'AUC et
148
+ d'indicateurs d'exposition critique).
149
+ - Les lignes entièrement NaN sur tous les polluants classifiés reçoivent
150
+ `exposure_level_overall` = "inconnu" et `exposure_score_overall` = -1.
151
+
152
+ Examples
153
+ --------
154
+ >>> df_exp, meta = classify_exposure_level(df_oeeil)
155
+ >>> df_exp[["pm25", "pm25_exposure_level", "exposure_level_overall"]].head()
156
+
157
+ >>> # Avec seuils personnalisés (ex. NO₂ calibré en µg/m³)
158
+ >>> custom = {"pm25": [5, 15, 25], "no2_ug_m3": [10, 25, 200]}
159
+ >>> df_exp, meta = classify_exposure_level(df_calibrated,
160
+ ... pollutant_thresholds=custom)
161
+ """
162
+
163
+ # --- Validation des paramètres ---
164
+ if overall_method not in ("max", "mean"):
165
+ raise ValueError(
166
+ "Paramètre 'overall_method' invalide. Choisir 'max' ou 'mean'."
167
+ )
168
+
169
+ labels = level_labels if level_labels is not None else LEVEL_LABELS_DEFAULT
170
+ if len(labels) != 4:
171
+ raise ValueError("'level_labels' doit contenir exactement 4 étiquettes.")
172
+
173
+ # Table ordinale : label → score entier
174
+ label_to_score = {lbl: i for i, lbl in enumerate(labels)}
175
+
176
+ # Seuils : utiliser ceux fournis, ou les défauts filtrés sur les colonnes présentes
177
+ if pollutant_thresholds is not None:
178
+ thresholds = pollutant_thresholds
179
+ else:
180
+ thresholds = {
181
+ col: seuils
182
+ for col, seuils in DEFAULT_THRESHOLDS.items()
183
+ if col in df.columns
184
+ }
185
+
186
+ if not thresholds:
187
+ raise ValueError(
188
+ "Aucun polluant classifiable trouvé dans le DataFrame avec les "
189
+ "seuils disponibles. Fournir 'pollutant_thresholds' explicitement "
190
+ "ou vérifier que le DataFrame contient pm25, pm10 ou co2_ppm."
191
+ )
192
+
193
+ df_out = df.copy()
194
+ classified_cols = []
195
+ absent_cols = []
196
+ distributions = {}
197
+ seuils_uses = {}
198
+
199
+ # --- Classification par polluant ---
200
+ for col, seuils in thresholds.items():
201
+ if col not in df_out.columns:
202
+ absent_cols.append(col)
203
+ continue
204
+
205
+ level_col = f"{col}_exposure_level"
206
+ df_out[level_col] = _classify_column(df_out[col], seuils, labels)
207
+ classified_cols.append(col)
208
+ seuils_uses[col] = seuils
209
+
210
+ # Distribution pour le rapport
211
+ counts = df_out[level_col].value_counts(dropna=False).to_dict()
212
+ distributions[col] = {str(k): int(v) for k, v in counts.items()}
213
+
214
+ if not classified_cols:
215
+ raise ValueError(
216
+ f"Aucune colonne classifiable présente dans le DataFrame. "
217
+ f"Colonnes demandées : {list(thresholds.keys())}. "
218
+ f"Colonnes du DataFrame : {list(df.columns)}."
219
+ )
220
+
221
+ # --- Calcul du niveau global par ligne ---
222
+ level_cols = [f"{c}_exposure_level" for c in classified_cols]
223
+
224
+ # Convertir les étiquettes en scores pour pouvoir comparer
225
+ score_matrix = df_out[level_cols].apply(
226
+ lambda col: col.map(label_to_score)
227
+ )
228
+
229
+ if overall_method == "max":
230
+ overall_score = score_matrix.max(axis=1)
231
+ else: # mean
232
+ overall_score = score_matrix.mean(axis=1).round().astype("Int64")
233
+
234
+ # Reconvertir les scores en étiquettes
235
+ score_to_label = {i: lbl for i, lbl in enumerate(labels)}
236
+ overall_label = overall_score.map(score_to_label).fillna("inconnu")
237
+
238
+ # Lignes où tous les polluants sont NaN → "inconnu" / score -1
239
+ all_nan_mask = score_matrix.isna().all(axis=1)
240
+ overall_score = overall_score.where(~all_nan_mask, other=-1).astype(int)
241
+ overall_label = overall_label.where(~all_nan_mask, other="inconnu")
242
+
243
+ df_out["exposure_level_overall"] = overall_label
244
+ df_out["exposure_score_overall"] = overall_score
245
+
246
+ # Supprimer les colonnes intermédiaires si demandé
247
+ if not keep_intermediate:
248
+ df_out.drop(columns=level_cols, inplace=True)
249
+
250
+ # --- Rapport ---
251
+ dist_globale = (
252
+ df_out["exposure_level_overall"].value_counts(dropna=False).to_dict()
253
+ )
254
+ dist_globale = {str(k): int(v) for k, v in dist_globale.items()}
255
+
256
+ n_tres_eleve = int(
257
+ (df_out["exposure_level_overall"] == labels[3]).sum()
258
+ )
259
+ taux_tres_eleve = round(n_tres_eleve / max(len(df_out), 1), 4)
260
+
261
+ meta = {
262
+ "polluants_classifiés": classified_cols,
263
+ "polluants_absents": absent_cols,
264
+ "seuils_appliqués": seuils_uses,
265
+ "labels_niveaux": labels,
266
+ "methode_global": overall_method,
267
+ "distribution_par_polluant": distributions,
268
+ "distribution_globale": dist_globale,
269
+ "taux_très_élevé_global": taux_tres_eleve,
270
+ "n_lignes_inconnues": int(all_nan_mask.sum()),
271
+ }
272
+
273
+ return df_out, meta
@@ -0,0 +1,273 @@
1
+ """
2
+ Fonction 23 — compute_daily_exposure
3
+ Calcule l'exposition cumulée journalière par polluant : AUC par jour,
4
+ moyenne journalière, dépassements de seuils OMS, et agrégats sur la période.
5
+ """
6
+
7
+ import pandas as pd
8
+ import numpy as np
9
+ from typing import Optional
10
+
11
+
12
+ DEFAULT_THRESHOLDS = {
13
+ "pm25": [5.0, 15.0, 25.0],
14
+ "pm10": [15.0, 45.0, 75.0],
15
+ "co2_ppm": [800.0, 1000.0, 2000.0],
16
+ }
17
+
18
+ LEVEL_LABELS = ["faible", "modéré", "élevé", "très élevé"]
19
+
20
+
21
+ def compute_daily_exposure(
22
+ df: pd.DataFrame,
23
+ timestamp_col: str = "timestamp_local",
24
+ pollutant_cols: Optional[list] = None,
25
+ pollutant_thresholds: Optional[dict] = None,
26
+ who_daily_limits: Optional[dict] = None,
27
+ min_coverage_pct: float = 0.5,
28
+ stratification_col: Optional[str] = None,
29
+ ) -> tuple:
30
+ """
31
+ Fonction 23 — Exposition cumulée journalière par polluant.
32
+
33
+ Agrège les données OEEIL à l'échelle journalière pour chaque polluant :
34
+ calcule la moyenne journalière, l'AUC du jour (intégrale trapézoïdale),
35
+ la durée de dépassement des seuils OMS, et le niveau d'exposition
36
+ dominant sur la journée.
37
+
38
+ Parameters
39
+ ----------
40
+ df : pd.DataFrame
41
+ DataFrame OEEIL enrichi (peut contenir les colonnes issues de F20/F22).
42
+ timestamp_col : str
43
+ Colonne horodatage (datetime64 ou convertible). Par défaut :
44
+ "timestamp_local".
45
+ pollutant_cols : list of str, optional
46
+ Colonnes polluants à analyser. Si None, les colonnes correspondant
47
+ aux seuils disponibles et présentes dans le DataFrame sont utilisées.
48
+ pollutant_thresholds : dict, optional
49
+ {col: [seuil1, seuil2, seuil3]} — seuils OMS par polluant.
50
+ Si None, les seuils par défaut (pm25, pm10, co2_ppm) sont utilisés.
51
+ who_daily_limits : dict, optional
52
+ {col: valeur_limite_journalière} — valeur guide OMS 24h (µg/m³ ou
53
+ ppm). Si le moyenne journalière dépasse cette valeur, un flag
54
+ `{col}_oms_exceeded` est levé.
55
+ Par défaut : {"pm25": 15.0, "pm10": 45.0}.
56
+ min_coverage_pct : float
57
+ Fraction minimale de la journée devant être couverte par des mesures
58
+ valides pour qu'un agrégat journalier soit calculé (entre 0 et 1).
59
+ Les jours sous ce seuil reçoivent NaN. Par défaut : 0.5 (50 %).
60
+ stratification_col : str, optional
61
+ Si fourni (ex. "time_period" ou "segment_travail"), les AUC et
62
+ durées sont également calculées par strate au sein de chaque journée.
63
+
64
+ Returns
65
+ -------
66
+ df_daily : pd.DataFrame
67
+ DataFrame indexé par date (une ligne par jour × polluant), avec :
68
+ - `date` : date (YYYY-MM-DD).
69
+ - `polluant` : nom de la colonne polluant.
70
+ - `n_mesures` : nombre de mesures valides ce jour.
71
+ - `couverture_pct` : fraction de la journée couverte.
72
+ - `moyenne_journaliere` : moyenne des concentrations (unité brute).
73
+ - `auc_journaliere` : AUC trapézoïdale (unité × min).
74
+ - `max_journalier` : maximum de la journée.
75
+ - `niveau_dominant` : niveau d'exposition le plus fréquent.
76
+ - `duree_critique_min` : durée en niveaux élevé/très élevé (min).
77
+ - `pct_temps_critique` : fraction du temps en niveaux critiques.
78
+ - `oms_depasse` : bool — moyenne > limite OMS journalière.
79
+ meta : dict
80
+ Métadonnées globales :
81
+ - "polluants_traités" : liste des polluants analysés.
82
+ - "n_jours_total" : nombre de jours distincts dans les données.
83
+ - "n_jours_valides" : jours avec couverture ≥ min_coverage_pct.
84
+ - "n_jours_oms_depasse" : {col: nombre de jours en dépassement OMS}.
85
+ - "moyenne_periode" : {col: moyenne sur toute la période}.
86
+ - "jour_le_plus_critique" : {col: date avec le % critique le plus élevé}.
87
+
88
+ Notes
89
+ -----
90
+ - Un jour est considéré comme "valide" si le nombre de mesures valides
91
+ représente au moins `min_coverage_pct` × 86 400 s / pas_médian_s
92
+ mesures attendues.
93
+ - L'AUC journalière est en unité_polluant × minutes sur 24h maximum.
94
+ - Si `stratification_col` est fournie, la colonne doit être présente dans
95
+ `df` (typiquement issue de F22).
96
+
97
+ Examples
98
+ --------
99
+ >>> df_daily, meta = compute_daily_exposure(
100
+ ... df_strat, timestamp_col="datetime_utc"
101
+ ... )
102
+ >>> meta["n_jours_oms_depasse"]
103
+ >>> df_daily[df_daily["polluant"] == "co2_ppm"]
104
+ """
105
+
106
+ # --- Validation ---
107
+ if timestamp_col not in df.columns:
108
+ raise ValueError(
109
+ f"Colonne horodatage '{timestamp_col}' absente du DataFrame."
110
+ )
111
+ if not (0.0 < min_coverage_pct <= 1.0):
112
+ raise ValueError(
113
+ "'min_coverage_pct' doit être compris entre 0 (exclus) et 1."
114
+ )
115
+
116
+ thresholds = pollutant_thresholds or DEFAULT_THRESHOLDS
117
+ oms_limits = who_daily_limits or {"pm25": 15.0, "pm10": 45.0, "co2_ppm": 1000.0}
118
+
119
+ if pollutant_cols is None:
120
+ pollutant_cols = [c for c in thresholds if c in df.columns]
121
+
122
+ if not pollutant_cols:
123
+ raise ValueError(
124
+ "Aucun polluant classifiable trouvé. Fournir 'pollutant_cols' "
125
+ "ou vérifier que le DataFrame contient pm25, pm10 ou co2_ppm."
126
+ )
127
+
128
+ df_work = df.copy()
129
+ ts = pd.to_datetime(df_work[timestamp_col], dayfirst=True)
130
+ df_work["_date"] = ts.dt.date
131
+ df_work["_ts"] = ts
132
+
133
+ ts_sorted = ts.sort_values()
134
+ pas_median_min = ts_sorted.diff().dt.total_seconds().median() / 60
135
+
136
+ if pd.isna(pas_median_min) or pas_median_min <= 0:
137
+ pas_median_min = 1.0
138
+
139
+ n_mesures_journee_complete = (24 * 60) / pas_median_min
140
+
141
+ records = []
142
+
143
+ for col in pollutant_cols:
144
+ if col not in df_work.columns:
145
+ continue
146
+
147
+ values = pd.to_numeric(df_work[col], errors="coerce")
148
+ seuils = thresholds.get(col, [np.inf, np.inf, np.inf])
149
+ s1, s2, s3 = seuils
150
+ oms_limite = oms_limits.get(col, None)
151
+
152
+ # Colonne niveau (F20 si disponible, sinon recalcul)
153
+ level_col = f"{col}_exposure_level"
154
+ if level_col in df_work.columns:
155
+ levels = df_work[level_col]
156
+ else:
157
+ cond = [values < s1,
158
+ (values >= s1) & (values < s2),
159
+ (values >= s2) & (values < s3),
160
+ values >= s3]
161
+ res = np.select(cond, LEVEL_LABELS, default="__nan__")
162
+ levels = pd.Series(res, index=df_work.index, dtype=object)
163
+ levels[values.isna() | (levels == "__nan__")] = np.nan
164
+
165
+ for date, grp_idx in df_work.groupby("_date").groups.items():
166
+ grp = df_work.loc[grp_idx].sort_values("_ts")
167
+ v_grp = values.loc[grp_idx].sort_values()
168
+ v_grp = values.loc[grp.index]
169
+ t_grp = ts.loc[grp.index]
170
+ l_grp = levels.loc[grp.index]
171
+
172
+ valid = v_grp.notna()
173
+ n_valid = int(valid.sum())
174
+ couv = min(n_valid / n_mesures_journee_complete, 1.0)
175
+
176
+ if couv < min_coverage_pct or n_valid == 0:
177
+ records.append({
178
+ "date": date, "polluant": col,
179
+ "n_mesures": n_valid,
180
+ "couverture_pct": round(couv, 4),
181
+ "moyenne_journaliere": np.nan,
182
+ "auc_journaliere": np.nan,
183
+ "max_journalier": np.nan,
184
+ "niveau_dominant": np.nan,
185
+ "duree_critique_min": np.nan,
186
+ "pct_temps_critique": np.nan,
187
+ "oms_depasse": np.nan,
188
+ })
189
+ continue
190
+
191
+ # AUC trapézoïdale
192
+ dt_min = t_grp.diff().dt.total_seconds().div(60)
193
+ auc = 0.0
194
+ for i in range(1, len(grp)):
195
+ vp = v_grp.iloc[i - 1]
196
+ vc = v_grp.iloc[i]
197
+ dt = dt_min.iloc[i]
198
+ if pd.notna(vp) and pd.notna(vc) and pd.notna(dt) and dt > 0:
199
+ auc += 0.5 * (vp + vc) * dt
200
+
201
+ # Durée par niveau
202
+ duree_critique = 0.0
203
+ duree_totale = 0.0
204
+ for i in range(1, len(grp)):
205
+ dt = dt_min.iloc[i]
206
+ if pd.isna(dt) or dt <= 0:
207
+ continue
208
+ lv = l_grp.iloc[i - 1]
209
+ duree_totale += dt
210
+ if lv in ("élevé", "très élevé"):
211
+ duree_critique += dt
212
+
213
+ pct_critique = duree_critique / max(duree_totale, 1e-9)
214
+
215
+ # Niveau dominant
216
+ lvl_counts = l_grp[l_grp.notna()].value_counts()
217
+ niveau_dom = lvl_counts.index[0] if len(lvl_counts) > 0 else np.nan
218
+
219
+ moy = float(v_grp[valid].mean())
220
+ mx = float(v_grp[valid].max())
221
+ oms_dep = (moy > oms_limite) if oms_limite is not None else np.nan
222
+
223
+ records.append({
224
+ "date": date,
225
+ "polluant": col,
226
+ "n_mesures": n_valid,
227
+ "couverture_pct": round(couv, 4),
228
+ "moyenne_journaliere": round(moy, 4),
229
+ "auc_journaliere": round(auc, 2),
230
+ "max_journalier": round(mx, 4),
231
+ "niveau_dominant": niveau_dom,
232
+ "duree_critique_min": round(duree_critique, 2),
233
+ "pct_temps_critique": round(pct_critique, 4),
234
+ "oms_depasse": oms_dep,
235
+ })
236
+
237
+ df_daily = pd.DataFrame(records)
238
+
239
+ # --- Méta ---
240
+ n_jours_total = df_work["_date"].nunique()
241
+ n_jours_valides = int(df_daily["moyenne_journaliere"].notna().sum()) \
242
+ // max(len(pollutant_cols), 1) if len(df_daily) > 0 else 0
243
+
244
+ n_jours_oms = {}
245
+ moyenne_periode = {}
246
+ jour_critique = {}
247
+
248
+ for col in pollutant_cols:
249
+ if len(df_daily) == 0 or "polluant" not in df_daily.columns:
250
+ continue
251
+ sub = df_daily[df_daily["polluant"] == col]
252
+ if len(sub) == 0:
253
+ continue
254
+ n_jours_oms[col] = int(sub["oms_depasse"].sum()) \
255
+ if sub["oms_depasse"].notna().any() else 0
256
+ moyenne_periode[col] = round(float(sub["moyenne_journaliere"].mean(skipna=True)), 4) \
257
+ if sub["moyenne_journaliere"].notna().any() else np.nan
258
+ idx_max = sub["pct_temps_critique"].idxmax() \
259
+ if sub["pct_temps_critique"].notna().any() else None
260
+ jour_critique[col] = str(sub.loc[idx_max, "date"]) if idx_max is not None else None
261
+
262
+ meta = {
263
+ "polluants_traités": pollutant_cols,
264
+ "n_jours_total": n_jours_total,
265
+ "n_jours_valides": n_jours_valides,
266
+ "n_jours_oms_depasse": n_jours_oms,
267
+ "moyenne_periode": moyenne_periode,
268
+ "jour_le_plus_critique": jour_critique,
269
+ "min_coverage_pct": min_coverage_pct,
270
+ "pas_median_min": round(pas_median_min, 2),
271
+ }
272
+
273
+ return df_daily, meta