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.
- oeeil/__init__.py +96 -0
- oeeil/modules/__init__.py +0 -0
- oeeil/modules/analyse/__init__.py +0 -0
- oeeil/modules/analyse/classify_exposure_level.py +273 -0
- oeeil/modules/analyse/compute_daily_exposure.py +273 -0
- oeeil/modules/analyse/compute_exposure_indicators.py +284 -0
- oeeil/modules/analyse/identify_critical_locations.py +310 -0
- oeeil/modules/analyse/stratify_life_rhythm.py +249 -0
- oeeil/modules/pretraitement/__init__.py +0 -0
- oeeil/modules/pretraitement/convert_mv_to_concentration.py +355 -0
- oeeil/modules/pretraitement/detect_extinction_periods.py +390 -0
- oeeil/modules/pretraitement/detect_pollution_events.py +815 -0
- oeeil/modules/pretraitement/filter_pollutant_outliers.py +456 -0
- oeeil/modules/pretraitement/flag_environmental_conditions.py +206 -0
- oeeil/modules/pretraitement/imputation_valeurs_manquantes.py +534 -0
- oeeil/modules/pretraitement/manage_missing_suppression.py +252 -0
- oeeil/modules/pretraitement/normalize_gas_percentage.py +976 -0
- oeeil/modules/reporting/__init__.py +0 -0
- oeeil/modules/reporting/generate_report.py +520 -0
- oeeil/modules/reporting/statistical_summary_report.py +270 -0
- oeeil/modules/standardisation/__init__.py +0 -0
- oeeil/modules/standardisation/aggregate_close_gps_points.py +274 -0
- oeeil/modules/standardisation/convert_timezone.py +293 -0
- oeeil/modules/standardisation/identify_calibration_pairs.py +337 -0
- oeeil/modules/standardisation/standardize_db.py +545 -0
- oeeil/modules/standardisation/synchronize_oeeil_campaign.py +582 -0
- oeeil/modules/standardisation/synchronize_with_reference.py +399 -0
- oeeil/modules/traitement/__init__.py +0 -0
- oeeil/modules/traitement/classify_indoor_outdoor_co2.py +265 -0
- oeeil/modules/traitement/classify_indoor_outdoor_cov.py +259 -0
- oeeil/modules/traitement/classify_transport_mode.py +237 -0
- oeeil/modules/traitement/compute_displacement_speed.py +227 -0
- oeeil/modules/traitement/correlate_transport_environment.py +140 -0
- oeeil/modules/traitement/detect_cov_outliers.py +254 -0
- oeeil/modules/traitement/interpolate_gps_coordinates.py +264 -0
- oeeil/modules/traitement/normalize_cov_percentage.py +235 -0
- oeeil/modules/visualisation/__init__.py +0 -0
- oeeil/modules/visualisation/calendar_heatmap.py +318 -0
- oeeil/modules/visualisation/statistical_summary_visual.py +346 -0
- oeeil/modules/visualisation/visualize_exposure.py +366 -0
- oeeil-0.1.0.dist-info/METADATA +343 -0
- oeeil-0.1.0.dist-info/RECORD +46 -0
- oeeil-0.1.0.dist-info/WHEEL +5 -0
- oeeil-0.1.0.dist-info/licenses/LICENSE +202 -0
- oeeil-0.1.0.dist-info/licenses/NOTICE +15 -0
- 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
|