ncsdecoopman commited on
Commit
0ab0788
·
0 Parent(s):

Déploiement Docker depuis workflow (structure corrigée)

Browse files
.gitignore ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ignorer les fichiers de cache Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.parquet
5
+ *.zarr
6
+ *.nc
7
+ *.csv
8
+
9
+ # On garde les .keep
10
+ !data/**/.keep
11
+ # Ajouter les config streamlit au git
12
+ !/.streamlit/*
13
+
14
+ # Ignore aussi d'éventuels fichiers temporaires et artefacts
15
+ *.tmp
16
+ *.bak
17
+ *.DS_Store
18
+ __pycache__/
19
+ *.pyc
20
+ *.cache
21
+ *.log
22
+
23
+ # Ignorer les caches Jupyter
24
+ .jupyter_cache/
25
+ presentation_files/
26
+ assets/
.huggingface.yaml ADDED
@@ -0,0 +1 @@
 
 
1
+ sdk: docker
.streamlit/config.toml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # .streamlit/config.toml
2
+ [theme]
3
+ base = "light"
4
+ primaryColor = "#5A7BFF"
5
+ backgroundColor = "#EEF2FF"
6
+ secondaryBackgroundColor = "#FFFFFF"
7
+ textColor = "#1F2D3D"
8
+ font = "sans serif" # "serif" | "sans serif" | "monospace"
9
+
10
+ [server]
11
+ headless = true
12
+ runOnSave = true
13
+
14
+ [browser]
15
+ gatherUsageStats = false
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN apt-get update && apt-get install -y \
4
+ build-essential \
5
+ git \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ WORKDIR /app
9
+
10
+ COPY requirements.txt .
11
+ COPY download_data.py .
12
+
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+ RUN pip install huggingface_hub
15
+
16
+ RUN rm -rf ~/.cache/huggingface/hub && \
17
+ python download_data.py
18
+
19
+ COPY . .
20
+
21
+ EXPOSE 7860
22
+ ENV MPLCONFIGDIR=/tmp/matplotlib
23
+ ENV PYTHONPATH=/app
24
+
25
+ CMD ["streamlit", "run", "main.py", "--server.port=7860", "--server.address=0.0.0.0"]
README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ # Déploiement avec snapshot_download depuis le dataset Hugging Face
app/__init__.py ADDED
File without changes
app/config/config.yaml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ years:
2
+ min: 1959
3
+ max: 2022 # choix dans le menu
4
+ rupture: 1985
5
+
6
+ statisticals:
7
+ modelised: data/statisticals/modelised
8
+ observed: data/statisticals/observed
9
+
10
+ gev:
11
+ modelised: data/gev/modelised
12
+ observed: data/gev/observed
app/pipelines/import_config.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+
3
+ from app.utils.config_utils import load_config, menu_config_statisticals, menu_config_gev
4
+ from app.utils.menus_utils import menu_statisticals, menu_gev
5
+
6
+
7
+
8
+
9
+ import streamlit as st
10
+
11
+ from pathlib import Path
12
+ from functools import reduce
13
+
14
+ from app.utils.config_utils import *
15
+ from app.utils.menus_utils import *
16
+ from app.utils.data_utils import *
17
+ from app.utils.stats_utils import *
18
+ from app.utils.map_utils import *
19
+ from app.utils.legends_utils import *
20
+ from app.utils.hist_utils import *
21
+ from app.utils.scatter_plot_utils import *
22
+ from app.utils.show_info import show_info_metric
23
+ from app.utils.gev_utils import compute_return_levels_ns
24
+
25
+ import pydeck as pdk
26
+ import polars as pl
27
+ import numpy as np
28
+
29
+ from app.pipelines.import_data import pipeline_data
30
+ from app.pipelines.import_map import pipeline_map
31
+
32
+ from app.utils.data_utils import standardize_year, filter_nan
33
+
34
+
35
+
36
+ def pipeline_config(config_path: str, type: str, show_param: bool=False):
37
+ # Chargement de la configuration
38
+ config = load_config(config_path)
39
+
40
+ min_years = config["years"]["min"]
41
+ max_years = config["years"]["max"]
42
+
43
+ if type == "stat":
44
+ STATS, SEASON, SCALE = menu_config_statisticals()
45
+
46
+ params = menu_statisticals(
47
+ min_years,
48
+ max_years,
49
+ STATS,
50
+ SEASON
51
+ )
52
+
53
+ if params is None:
54
+ st.info("Les paramètres d’analyse ne sont pas encore définis. Merci de les configurer pour lancer l’analyse.")
55
+ st.stop()
56
+
57
+ stat_choice, quantile_choice, min_year_choice, max_year_choice, season_choice, scale_choice, missing_rate, show_relief, show_stations = params
58
+
59
+ return {
60
+ "config": config,
61
+ "stat_choice": stat_choice,
62
+ "season_choice": season_choice,
63
+ "stat_choice_key": STATS[stat_choice],
64
+ "scale_choice_key": SCALE[scale_choice],
65
+ "min_year_choice": min_year_choice,
66
+ "max_year_choice": max_year_choice,
67
+ "season_choice_key": SEASON[season_choice],
68
+ "missing_rate": missing_rate,
69
+ "quantile_choice": quantile_choice,
70
+ "scale_choice": scale_choice,
71
+ "show_relief": show_relief,
72
+ "show_stations": show_stations
73
+ }
74
+
75
+ elif type == "gev":
76
+ MODEL_PARAM, MODEL_NAME = menu_config_gev()
77
+ _, SEASON, _ = menu_config_statisticals()
78
+
79
+ params = menu_gev(
80
+ config,
81
+ MODEL_NAME,
82
+ MODEL_PARAM,
83
+ SEASON,
84
+ show_param=show_param
85
+ )
86
+
87
+ if params is None:
88
+ st.info("Les paramètres d’analyse ne sont pas encore définis. Merci de les configurer pour lancer l’analyse.")
89
+ st.stop()
90
+
91
+ return {
92
+ "config": config,
93
+ **params
94
+ }
app/pipelines/import_data.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+
3
+ from app.utils.data_utils import (
4
+ load_data,
5
+ cleaning_data_observed,
6
+ dont_show_extreme,
7
+ add_metadata,
8
+ get_column_load,
9
+ filter_nan
10
+ )
11
+ from app.utils.stats_utils import compute_statistic_per_point
12
+ from app.utils.gev_utils import safe_compute_return_df, compute_delta_qT, compute_delta_stat
13
+ from app.utils.legends_utils import get_stat_column_name
14
+
15
+ import polars as pl
16
+
17
+ def load_data_cached(use_cache: bool):
18
+ if use_cache:
19
+ return st.cache_data(load_data_inner) # Version cachée qui retourne un DataFrame pour la sérialisation.
20
+ else:
21
+ return load_data_inner
22
+
23
+ def load_data_inner(type_data: str, echelle: str, min_year: int, max_year: int, season_key: str, col_to_load: list, config) -> pl.DataFrame:
24
+ return load_data(type_data, echelle, min_year, max_year, season_key, col_to_load, config)
25
+
26
+
27
+ def pipeline_data(params, config, use_cache=False):
28
+
29
+ stat_choice_key, scale_choice_key, min_year_choice, max_year_choice, season_choice_key, missing_rate, quantile_choice, scale_choice = params
30
+ loader = load_data_cached(use_cache)
31
+
32
+ # Colonne de statistique nécessaire au chargement
33
+ col_to_load, col_important = get_column_load(stat_choice_key, scale_choice_key)
34
+
35
+ if scale_choice == "Journalière":
36
+ scale_choice = "quotidien"
37
+ elif scale_choice == "Horaire":
38
+ scale_choice = "horaire"
39
+
40
+ try:
41
+ modelised_load = loader(
42
+ 'modelised', scale_choice if scale_choice != "quotidien" else "horaire",
43
+ min_year_choice,
44
+ max_year_choice,
45
+ season_choice_key,
46
+ col_to_load,
47
+ config
48
+ )
49
+ except Exception as e:
50
+ raise RuntimeError(f"Erreur lors du chargement des données modélisées : {e}")
51
+
52
+ try:
53
+ observed_load = loader(
54
+ 'observed', scale_choice,
55
+ min_year_choice,
56
+ max_year_choice,
57
+ season_choice_key,
58
+ col_to_load + ["nan_ratio"],
59
+ config
60
+ )
61
+ except Exception as e:
62
+ raise RuntimeError(f"Erreur lors du chargement des données observées : {e}")
63
+
64
+ # Selection des données observées
65
+ len_series = 0.75*(max_year_choice-min_year_choice+1)
66
+ df_observed_cleaning = cleaning_data_observed(observed_load, len_series, missing_rate)
67
+
68
+ # Calcul des statistiques
69
+ modelised = compute_statistic_per_point(modelised_load, stat_choice_key)
70
+ observed = compute_statistic_per_point(df_observed_cleaning, stat_choice_key)
71
+
72
+ # Ajout de l'altitude et des lat lon
73
+ modelised = add_metadata(modelised, scale_choice_key, type='modelised')
74
+ observed = add_metadata(observed, scale_choice_key, type='observed')
75
+
76
+ # Obtention de la colonne étudiée
77
+ column = get_stat_column_name(stat_choice_key, scale_choice_key)
78
+
79
+ # Retrait des extrêmes pour l'affichage uniquement
80
+ modelised_show, observed_show = dont_show_extreme(modelised, observed, column, quantile_choice, stat_choice_key)
81
+
82
+ return {
83
+ "modelised_load": modelised_load,
84
+ "observed_load": observed_load,
85
+ "observed_cleaning": df_observed_cleaning,
86
+ "modelised_show": modelised_show,
87
+ "observed_show": observed_show,
88
+ "modelised": modelised,
89
+ "observed": observed,
90
+ "column": column
91
+ }
92
+
93
+ def pipeline_data_gev(params):
94
+
95
+ column = params["param_choice"]
96
+
97
+ BOOTSTRAP = False
98
+ if "_bootstrap" in params['model_name']: # dans le cas des modèles avec bootstrap
99
+ BOOTSTRAP = True
100
+ # On repasse sur les fichiers non boostrapés
101
+ params['model_name'] = params['model_name'].replace('_bootstrap', '')
102
+
103
+ df_modelised_load = pl.read_parquet(params["mod_dir"] / f"gev_param_{params['model_name']}.parquet")
104
+ df_observed_load = pl.read_parquet(params["obs_dir"] / f"gev_param_{params['model_name']}.parquet")
105
+
106
+ df_modelised = filter_nan(df_modelised_load, "xi") # xi est toujours valable
107
+ df_observed = filter_nan(df_observed_load, "xi") # xi est toujours valable
108
+
109
+ df_modelised = add_metadata(df_modelised, "mm_h" if params["echelle"] == "horaire" else "mm_j", type="modelised")
110
+ df_observed = add_metadata(df_observed, "mm_h" if params["echelle"] == "horaire" else "mm_j", type="observed")
111
+
112
+ # Étape 1 : créer une colonne avec les paramètres nettoyés
113
+ df_modelised = safe_compute_return_df(df_modelised)
114
+ df_observed = safe_compute_return_df(df_observed)
115
+
116
+ # Étape 2 : appliquer delta_qT_decennale (avec numpy)
117
+ T_choice = params["T_choice"] # ou récupéré dynamiquement via Streamlit
118
+
119
+ if "_break_year" in params['model_name']: # dans le cas des modèles avec point de rupture
120
+ year_range = params["max_year_choice"] - params["config"]["years"]["rupture"] # Δa+ = a_max - a_rupture
121
+ else:
122
+ year_range = params["max_year_choice"] - params["min_year_choice"] # Δa = a_max - a_min
123
+
124
+
125
+ if column == "Δqᵀ":
126
+ # Calcul du delta qT
127
+ df_modelised = df_modelised.with_columns([
128
+ pl.struct(["mu1", "sigma1", "xi"])
129
+ .map_elements(lambda row: compute_delta_qT(row, T_choice, year_range, params["par_X_annees"]), return_dtype=pl.Float64)
130
+ .alias("Δqᵀ")
131
+ ])
132
+
133
+ df_observed = df_observed.with_columns([
134
+ pl.struct(["mu1", "sigma1", "xi"])
135
+ .map_elements(lambda row: compute_delta_qT(row, T_choice, year_range, params["par_X_annees"]), return_dtype=pl.Float64)
136
+ .alias("Δqᵀ")
137
+ ])
138
+
139
+
140
+ elif column in ["ΔE", "ΔVar", "ΔCV"]:
141
+ t_start = params["min_year_choice"]
142
+ t_end = params["max_year_choice"]
143
+ t0 = params["config"]["years"]["rupture"]
144
+
145
+ df_modelised = df_modelised.with_columns([
146
+ pl.struct(["mu0", "mu1", "sigma0", "sigma1", "xi"])
147
+ .map_elements(lambda row: compute_delta_stat(row, column, t_start, t0 , t_end, params["par_X_annees"]), return_dtype=pl.Float64)
148
+ .alias(column)
149
+ ])
150
+
151
+ df_observed = df_observed.with_columns([
152
+ pl.struct(["mu0", "mu1", "sigma0", "sigma1", "xi"])
153
+ .map_elements(lambda row: compute_delta_stat(row, column, t_start, t0, t_end, params["par_X_annees"]), return_dtype=pl.Float64)
154
+ .alias(column)
155
+ ])
156
+
157
+
158
+
159
+ if BOOTSTRAP:
160
+ df_mod_bootstrap = pl.read_parquet(params["mod_dir"] / f"gev_param_{params['model_name']}_bootstrap.parquet")
161
+ df_obs_bootstrap = pl.read_parquet(params["obs_dir"] / f"gev_param_{params['model_name']}_bootstrap.parquet")
162
+
163
+ # Recalcule delta_qT pour chaque bootstrap
164
+ df_mod_bootstrap = df_mod_bootstrap.with_columns([
165
+ pl.struct(["mu1", "sigma1", "xi"]).map_elements(
166
+ lambda row: compute_delta_qT(
167
+ row,
168
+ params["T_choice"],
169
+ year_range,
170
+ params["par_X_annees"]
171
+ ),
172
+ return_dtype=pl.Float64
173
+ ).alias("Δqᵀ")
174
+ ])
175
+
176
+ df_obs_bootstrap = df_obs_bootstrap.with_columns([
177
+ pl.struct(["mu1", "sigma1", "xi"]).map_elements(
178
+ lambda row: compute_delta_qT(
179
+ row,
180
+ params["T_choice"],
181
+ year_range,
182
+ params["par_X_annees"]
183
+ ),
184
+ return_dtype=pl.Float64
185
+ ).alias("Δqᵀ")
186
+ ])
187
+
188
+ # Calcule les bornes de l'intervalle de confiance
189
+ df_ic_mod = (
190
+ df_mod_bootstrap
191
+ .group_by("NUM_POSTE")
192
+ .agg([
193
+ pl.col("Δqᵀ").quantile(0.05, "nearest").alias("Δqᵀ_q050"),
194
+ pl.col("Δqᵀ").quantile(0.95, "nearest").alias("Δqᵀ_q950"),
195
+ ])
196
+ )
197
+
198
+ df_ic_obs = (
199
+ df_obs_bootstrap
200
+ .group_by("NUM_POSTE")
201
+ .agg([
202
+ pl.col("Δqᵀ").quantile(0.05, "nearest").alias("Δqᵀ_q050"),
203
+ pl.col("Δqᵀ").quantile(0.95, "nearest").alias("Δqᵀ_q950"),
204
+ ])
205
+ )
206
+
207
+ # Forcer NUM_POSTE à être de même type (int) dans les deux DataFrames
208
+ df_ic_mod = df_ic_mod.with_columns([pl.col("NUM_POSTE").cast(pl.Int64)])
209
+ df_ic_obs = df_ic_obs.with_columns([pl.col("NUM_POSTE").cast(pl.Int64)])
210
+ df_modelised = df_modelised.with_columns([pl.col("NUM_POSTE").cast(pl.Int64)])
211
+ df_observed = df_observed.with_columns([pl.col("NUM_POSTE").cast(pl.Int64)])
212
+
213
+
214
+ # Join à df_observed
215
+ df_modelised = df_modelised.join(df_ic_mod, on="NUM_POSTE", how="left")
216
+ df_observed = df_observed.join(df_ic_obs, on="NUM_POSTE", how="left")
217
+
218
+ # Création d'une colonne est significatif ou non (ne recoupe pas l'intervalle)
219
+ df_modelised = df_modelised.with_columns([
220
+ (
221
+ ~((pl.col("Δqᵀ_q050") <= 0) & (pl.col("Δqᵀ_q950") >= 0))
222
+ ).alias("is_significant")
223
+ ])
224
+
225
+ df_observed = df_observed.with_columns([
226
+ (
227
+ ~((pl.col("Δqᵀ_q050") <= 0) & (pl.col("Δqᵀ_q950") >= 0))
228
+ ).alias("is_significant")
229
+ ])
230
+
231
+ # Retrait des percentiles
232
+ modelised_show = dont_show_extreme(df_modelised, column, params["quantile_choice"])
233
+ observed_show = dont_show_extreme(df_observed, column, params["quantile_choice"])
234
+
235
+ if column in ["Δqᵀ", "ΔE", "ΔVar", "ΔCV"]:
236
+
237
+ val_max = max(modelised_show[column].max(), observed_show[column].max())
238
+ val_min = min(modelised_show[column].min(), observed_show[column].min())
239
+ abs_max = max(abs(val_min), abs(val_max))
240
+
241
+ return {
242
+ "modelised_load": df_modelised_load,
243
+ "observed_load": df_observed_load,
244
+ "modelised": df_modelised,
245
+ "observed": df_observed,
246
+ "modelised_show": modelised_show,
247
+ "observed_show": observed_show,
248
+ "column": column,
249
+ "vmin": -abs_max,
250
+ "vmax": abs_max,
251
+ "echelle": "diverging_zero_white",
252
+ "continu": True
253
+ }
254
+
255
+ else:
256
+
257
+ return {
258
+ "modelised_load": df_modelised_load,
259
+ "observed_load": df_observed_load,
260
+ "modelised": df_modelised,
261
+ "observed": df_observed,
262
+ "modelised_show": modelised_show,
263
+ "observed_show": observed_show,
264
+ "column": column
265
+ }
app/pipelines/import_map.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.utils.config_utils import echelle_config
2
+ from app.utils.map_utils import create_layer, create_scatter_layer, create_tooltip
3
+ from app.utils.legends_utils import formalised_legend, display_vertical_color_legend
4
+
5
+ import pydeck as pdk
6
+
7
+ def safe_min(*args):
8
+ return min(x for x in args if x is not None) if any(x is not None for x in args) else None
9
+
10
+ def safe_max(*args):
11
+ return max(x for x in args if x is not None) if any(x is not None for x in args) else None
12
+
13
+
14
+ def pipeline_map(
15
+ params_load,
16
+ n_colors:int = 15,
17
+ param_view: dict = {"latitude": 46.9, "longitude": 1.7, "zoom": 5}
18
+ ):
19
+ # Déballage des paramètres
20
+ stat_choice_key, result, unit_label, height = params_load
21
+
22
+ # Echelle continue ou discrète
23
+ if "continu" in result:
24
+ continu = result["continu"]
25
+ elif stat_choice_key == "month":
26
+ continu = False
27
+ else:
28
+ continu = True
29
+
30
+ # Nombre de couleurs
31
+ if "categories" in result: # Discret
32
+ categories = result["categories"]
33
+ n_colors = len(categories)
34
+ else:
35
+ categories = None
36
+ n_colors = n_colors
37
+
38
+ # Echelle paramétrée par l'utilisateur
39
+ if "echelle" not in result: # Choix d'une échelle personnalisée
40
+ result["echelle"] = None
41
+
42
+ # On trouve alors la représéntation de la légende
43
+ colormap = echelle_config(continu, echelle=result["echelle"], n_colors=n_colors)
44
+
45
+ result_df_modelised_show = result["modelised_show"]
46
+ result_df_observed_show = result["observed_show"]
47
+
48
+ # Normalisation des valeurs modélisées
49
+ result_df_modelised_show, vmin_mod, vmax_mod = formalised_legend(
50
+ result["modelised_show"],
51
+ column_to_show=result["column"],
52
+ colormap=colormap,
53
+ is_categorical=not continu,
54
+ categories=categories
55
+ )
56
+
57
+ # Normalisation des observations avec les mêmes bornes
58
+ result_df_observed_show, vmin_obs, vmax_obs = formalised_legend(
59
+ result["observed_show"],
60
+ column_to_show=result["column"],
61
+ colormap=colormap,
62
+ is_categorical=not continu,
63
+ categories=categories
64
+ )
65
+
66
+ # Calcul des bornes communes
67
+ if "vmin" in result and "vmax" in result:
68
+ vmin_commun, vmax_commun = result["vmin"], result["vmax"]
69
+ else:
70
+ vmin_commun = safe_min(vmin_mod, vmin_obs)
71
+ vmax_commun = safe_max(vmax_mod, vmax_obs)
72
+
73
+ # Mise à jour de la normalisation pour les deux ensembles de données avec les bornes communes
74
+ result_df_modelised_show, _, _ = formalised_legend(
75
+ result["modelised_show"],
76
+ column_to_show=result["column"],
77
+ colormap=colormap,
78
+ vmin=vmin_commun,
79
+ vmax=vmax_commun,
80
+ is_categorical=not continu,
81
+ categories=categories
82
+ )
83
+
84
+ result_df_observed_show, _, _ = formalised_legend(
85
+ result["observed_show"],
86
+ column_to_show=result["column"],
87
+ colormap=colormap,
88
+ vmin=vmin_commun,
89
+ vmax=vmax_commun,
90
+ is_categorical=not continu,
91
+ categories=categories
92
+ )
93
+
94
+ # Création du layer modélisé et observé
95
+ layer = create_layer(result_df_modelised_show)
96
+ scatter_layer = create_scatter_layer(result_df_observed_show)
97
+
98
+ # Tooltip
99
+ tooltip = create_tooltip(unit_label)
100
+
101
+ # View par défaut
102
+ view_state = pdk.ViewState(
103
+ latitude=param_view["latitude"],
104
+ longitude=param_view["longitude"],
105
+ zoom=param_view["zoom"]
106
+ )
107
+
108
+ # Légende vertical
109
+ legend = display_vertical_color_legend(
110
+ height,
111
+ colormap,
112
+ vmin_commun,
113
+ vmax_commun,
114
+ n_ticks=n_colors,
115
+ label=unit_label,
116
+ model_labels=categories
117
+ )
118
+
119
+ return layer, scatter_layer, tooltip, view_state, legend
app/pipelines/import_scatter.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import polars as pl
3
+
4
+ from app.utils.hist_utils import plot_histogramme, plot_histogramme_comparatif
5
+ from app.utils.scatter_plot_utils import generate_scatter_plot_interactive
6
+ from app.utils.data_utils import match_and_compare
7
+ from app.utils.stats_utils import generate_metrics
8
+
9
+ def pipeline_scatter(params_load):
10
+ result, stat_choice_key, scale_choice_key, stat_choice, unit_label, height = params_load
11
+
12
+ df_modelised_load = result["modelised_load"]
13
+ df_observed_load = result["observed_load"]
14
+ n_tot_mod = df_modelised_load.select(pl.col("NUM_POSTE").n_unique()).item()
15
+ n_tot_obs = df_observed_load.select(pl.col("NUM_POSTE").n_unique()).item()
16
+
17
+ if stat_choice_key not in ["date", "month"]:
18
+ echelle = "horaire" if scale_choice_key == "mm_h" else "quotidien"
19
+ df_obs_vs_mod = pl.read_csv(f"data/metadonnees/obs_vs_mod/obs_vs_mod_{echelle}.csv")
20
+ obs_vs_mod = match_and_compare(result["observed"], result["modelised"], result["column"], df_obs_vs_mod)
21
+ if obs_vs_mod is not None and obs_vs_mod.height > 0:
22
+ fig = generate_scatter_plot_interactive(obs_vs_mod, stat_choice, unit_label, height)
23
+ me, mae, rmse, r2 = generate_metrics(obs_vs_mod)
24
+
25
+ return n_tot_mod, n_tot_obs, me, mae, rmse, r2, fig
26
+
27
+ else:
28
+ fig = plot_histogramme(result["modelised"], result["column"], stat_choice, stat_choice_key, unit_label, height)
29
+
30
+ return n_tot_mod, n_tot_obs, None, None, None, None, fig
31
+
32
+ else:
33
+ fig = plot_histogramme_comparatif(result["observed"], result["modelised"], result["column"], stat_choice, stat_choice_key, unit_label, height)
34
+
35
+ return n_tot_mod, n_tot_obs, None, None, None, None, fig
36
+
app/utils/__init__.py ADDED
File without changes
app/utils/config_utils.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import yaml
2
+ from matplotlib import colors as mcolors
3
+ from matplotlib.colors import ListedColormap
4
+
5
+ def menu_config_statisticals():
6
+ STATS = {
7
+ "Moyenne": "mean",
8
+ "Maximum": "max",
9
+ "Moyenne des maxima": "mean-max",
10
+ "Mois comptabilisant le plus de maximas": "month",
11
+ "Jour de pluie": "numday",
12
+ }
13
+
14
+ SEASON = {
15
+ "Année hydrologique": "hydro",
16
+ "Hiver": "djf",
17
+ "Printemps": "mam",
18
+ "Été": "jja",
19
+ "Automne": "son",
20
+ }
21
+
22
+ SCALE = {
23
+ "Horaire": "mm_h",
24
+ "Journalière": "mm_j"
25
+ }
26
+
27
+ return STATS, SEASON, SCALE
28
+
29
+ def menu_config_gev():
30
+ MODEL_PARAM = {
31
+ "s_gev": {"mu0": "μ₀", "sigma0": "σ₀", "xi": "ξ"},
32
+ "ns_gev_m1": {"mu0": "μ₀", "mu1": "μ₁", "sigma0": "σ₀", "xi": "ξ"},
33
+ "ns_gev_m2": {"mu0": "μ₀", "sigma0": "σ₀", "sigma1": "σ₁", "xi": "ξ"},
34
+ "ns_gev_m3": {"mu0": "μ₀", "mu1": "μ₁", "sigma0": "σ₀", "sigma1": "σ₁", "xi": "ξ"},
35
+ "ns_gev_m1_break_year": {"mu0": "μ₀", "mu1": "μ₁", "sigma0": "σ₀", "xi": "ξ"},
36
+ "ns_gev_m2_break_year": {"mu0": "μ₀", "sigma0": "σ₀", "sigma1": "σ₁", "xi": "ξ"},
37
+ "ns_gev_m3_break_year": {"mu0": "μ₀", "mu1": "μ₁", "sigma0": "σ₀", "sigma1": "σ₁", "xi": "ξ"},
38
+ "best_model": {"mu0": "μ₀", "mu1": "μ₁", "sigma0": "σ₀", "sigma1": "σ₁", "xi": "ξ"}
39
+ }
40
+
41
+ # Liste complète des modèles avec leurs équations explicites
42
+ MODEL_NAME = {
43
+ # Stationnaire
44
+ "M₀(μ₀, σ₀) : μ(t) = μ₀ ; σ(t) = σ₀ ; ξ(t) = ξ": "s_gev",
45
+
46
+ # Non stationnaires simples
47
+ "M₁(μ, σ₀) : μ(t) = μ₀ + μ₁·t ; σ(t) = σ₀ ; ξ(t) = ξ": "ns_gev_m1",
48
+ "M₂(μ₀, σ) : μ(t) = μ₀ ; σ(t) = σ₀ + σ₁·t ; ξ(t) = ξ": "ns_gev_m2",
49
+ "M₃(μ, σ) : μ(t) = μ₀ + μ₁·t ; σ(t) = σ₀ + σ₁·t ; ξ(t) = ξ": "ns_gev_m3",
50
+
51
+ # Non stationnaires avec rupture
52
+ "M₁⋆(μ, σ₀) : μ(t) = μ₀ + μ₁·t₊ ; σ(t) = σ₀ ; ξ(t) = ξ en notant t₊ = t · 𝟙_{t > t₀} avec t₀ = 1985": "ns_gev_m1_break_year",
53
+ "M₂⋆(μ₀, σ) : μ(t) = μ₀ ; σ(t) = σ₀ + σ₁·t₊ ; ξ(t) = ξ en notant t₊ = t · 𝟙_{t > t₀} avec t₀ = 1985": "ns_gev_m2_break_year",
54
+ "M₃⋆(μ, σ) : μ(t) = μ₀ + μ₁·t₊ ; σ(t) = σ₀ + σ₁·t₊ ; ξ(t) = ξ en notant t₊ = t · 𝟙_{t > t₀} avec t₀ = 1985": "ns_gev_m3_break_year",
55
+
56
+ "M₃⋆ᵇ(μ, σ) : μ(t) = μ₀ + μ₁·t₊ ; σ(t) = σ₀ + σ₁·t₊ ; ξ(t) = ξ en notant t₊ = t · 𝟙_{t > t₀} avec t₀ = 1985": "ns_gev_m3_break_year_bootstrap",
57
+
58
+ # Autres
59
+ "M(minimisant AIC)": "best_model",
60
+ "M(minimisant pval)": "best_model_lrt"
61
+ }
62
+
63
+ return MODEL_PARAM, MODEL_NAME
64
+
65
+ def reverse_param_label(param_label: str, model_name: str, model_param_map: dict) -> str:
66
+ """
67
+ Convertit un label unicode (e.g. 'μ₀') en nom de paramètre interne (e.g. 'mu0'),
68
+ en utilisant le mapping inverse de model_param_map.
69
+ """
70
+ if model_name not in model_param_map:
71
+ raise ValueError(f"Modèle {model_name} non trouvé dans le mapping.")
72
+
73
+ reverse_map = {v: k for k, v in model_param_map[model_name].items()}
74
+
75
+ if param_label not in reverse_map:
76
+ raise ValueError(f"Label {param_label} non trouvé pour le modèle {model_name}.")
77
+
78
+ return reverse_map[param_label]
79
+
80
+
81
+
82
+ def load_config(config_path: str) -> dict:
83
+ with open(config_path, "r") as f:
84
+ return yaml.safe_load(f)
85
+
86
+
87
+ def echelle_config(type_: bool, echelle: str = None, n_colors: int = 256):
88
+ if type_: # Continu
89
+
90
+ if echelle == "diverging_zero_white": # Choix personnalisé
91
+ # Dégradé négatif (bleu) → 0 (blanc) → positif (jaune à rouge)
92
+ custom_colorscale = [
93
+ (0.0, "#08306B"), # bleu foncé
94
+ (0.1, "#2171B5"),
95
+ (0.2, "#6BAED6"),
96
+ (0.3, "#C6DBEF"),
97
+ (0.49, "#ffffff"), # blanc à 0
98
+ (0.5, "#ffffff"),
99
+ (0.6, "#ffffb2"), # jaune clair
100
+ (0.7, "#fecc5c"),
101
+ (0.8, "#fd8d3c"),
102
+ (0.9, "#f03b20"),
103
+ (1.0, "#bd0026"), # rouge foncé
104
+ ]
105
+
106
+ cmap = mcolors.LinearSegmentedColormap.from_list("diverging_zero_white", custom_colorscale)
107
+
108
+ if n_colors is not None:
109
+ # Retourne une version discrète avec n couleurs
110
+ return ListedColormap([cmap(i / (n_colors - 1)) for i in range(n_colors)])
111
+ else:
112
+ return cmap
113
+
114
+ custom_colorscale = [
115
+ (0.0, "#FFFFE5"), # blanc
116
+ (0.1, "#DDEED6"),
117
+ (0.2, "#BCDDC8"),
118
+ (0.3, "#9BCCBA"),
119
+ (0.4, "#7ABBAC"),
120
+ (0.5, "#59AA9E"),
121
+ (0.6, "#389990"),
122
+ (0.7, "#29837A"),
123
+ (0.8, "#1C6D63"),
124
+ (0.9, "#0F564B"),
125
+ (1.0, "#003C30"),
126
+ ]
127
+
128
+ cmap = mcolors.LinearSegmentedColormap.from_list("custom", custom_colorscale)
129
+
130
+ if n_colors is not None:
131
+ # Retourne une version discrète avec n couleurs
132
+ return ListedColormap([cmap(i / (n_colors - 1)) for i in range(n_colors)])
133
+ else:
134
+ return cmap
135
+
136
+ else: # Discret
137
+
138
+ couleurs_par_mois = [
139
+ "#ffffff", # Janvier
140
+ "blue", # Février
141
+ "green", # Mars
142
+ "red", # Avril
143
+ "orange", # Mai
144
+ "#00CED1", # Juin
145
+ "yellow", # Juillet
146
+ "#f781bf", # Août
147
+ "purple", # Septembre
148
+ "#654321", # Octobre
149
+ "darkblue", # Novembre
150
+ "black", # Décembre
151
+ ]
152
+
153
+ return ListedColormap(couleurs_par_mois)
154
+
155
+
156
+ def get_readable_season(season_code: str) -> str:
157
+ """
158
+ Retourne le nom humainement lisible d'une saison à partir de son code ("hydro", "djf", etc.).
159
+ Résultat en minuscules.
160
+ """
161
+ _, SEASON, _ = menu_config_statisticals()
162
+ reverse_season = {v: k.lower() for k, v in SEASON.items()}
163
+ if season_code not in reverse_season:
164
+ raise ValueError(f"Code saison inconnu : {season_code}")
165
+ return reverse_season[season_code]
app/utils/data_utils.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import polars as pl
3
+ import streamlit as st
4
+ from scipy.spatial import cKDTree
5
+
6
+ from app.utils.config_utils import menu_config_statisticals
7
+
8
+ def get_column_load(stat: str, scale: str):
9
+ if stat == "mean":
10
+ col = "mean_mm_h"
11
+ elif stat == "max":
12
+ col = f"max_{scale}"
13
+ elif stat == "mean-max":
14
+ col = f"max_{scale}"
15
+ elif stat == "month":
16
+ col = f"max_date_{scale}"
17
+ elif stat == "numday":
18
+ col = "n_days_gt1mm"
19
+ else:
20
+ raise ValueError(f"Stat '{stat}' is not recognized")
21
+
22
+ return ["NUM_POSTE", col], col
23
+
24
+ def load_season(year: int, season_key: str, base_path: str, col_to_load: str) -> pl.DataFrame:
25
+ filename = f"{base_path}/{year:04d}/{season_key}.parquet"
26
+ return pl.read_parquet(filename, columns=col_to_load)
27
+
28
+ def load_data(type_data: str, echelle: str, min_year: int, max_year: int, season: str, col_to_load: str, config) -> pl.DataFrame:
29
+ _, SEASON, _ = menu_config_statisticals()
30
+ if season not in SEASON.values():
31
+ raise ValueError(f"Saison inconnue : {season}")
32
+
33
+ base_path = f'{config["statisticals"][type_data]}/{echelle}'
34
+
35
+ dataframes = []
36
+ errors = []
37
+
38
+ for year in range(min_year, max_year + 1):
39
+ try:
40
+ df = load_season(year, season, base_path, col_to_load)
41
+ # Conversion explicite des colonnes dates uniquement si elles existent
42
+ for col in ["max_date_mm_h", "max_date_mm_j"]:
43
+ if col in df.columns:
44
+ df = df.with_columns(
45
+ pl.col(col)
46
+ .cast(pl.Utf8) # s'assure qu'on peut parser avec str.strptime
47
+ .str.strptime(pl.Datetime, format="%Y-%m-%d", strict=False)
48
+ .cast(pl.Utf8) # retour sous forme de string (comme dans l'ancien code Pandas)
49
+ )
50
+
51
+ # Ajout de la colonne year
52
+ df = df.with_columns(pl.lit(year).alias("year"))
53
+
54
+ dataframes.append(df)
55
+
56
+ except Exception as e:
57
+ errors.append(f"{year} ({season}) : {e}")
58
+
59
+ if errors:
60
+ for err in errors:
61
+ st.warning(f"Erreur : {err}")
62
+
63
+ if not dataframes:
64
+ raise ValueError("Aucune donnée chargée.")
65
+
66
+ return pl.concat(dataframes, how="vertical")
67
+
68
+
69
+ def cleaning_data_observed(
70
+ df: pl.DataFrame,
71
+ len_serie: float = None,
72
+ nan_limit: float = 0.10
73
+ ) -> pl.DataFrame:
74
+ """
75
+ Filtre les maxima par deux critères :
76
+ 1) on annule les valeurs d’une année si nan_ratio > nan_limit
77
+ 2) on ne garde que les stations ayant au moins n années valides
78
+ """
79
+ # ——— règles dépendant de l’échelle ———
80
+ if len_serie is None:
81
+ raise ValueError('Paramètre len_serie à préciser')
82
+
83
+ # Selection des saisons avec nan_limit au maximum
84
+ df_filter = df.filter(pl.col("nan_ratio") <= nan_limit)
85
+
86
+ # Calcul du nombre d'années valides par station NUM_POSTE
87
+ station_counts = (
88
+ df_filter.group_by("NUM_POSTE")
89
+ .agg(pl.col("year").n_unique().alias("num_years"))
90
+ )
91
+
92
+ # Sélection des NUM_POSTE avec au moins len_serie d'années valides
93
+ valid_stations = station_counts.filter(pl.col("num_years") >= len_serie)
94
+
95
+ # Jointure pour ne garder que les stations valides
96
+ df_final = df_filter.filter(
97
+ pl.col("NUM_POSTE").is_in(valid_stations["NUM_POSTE"])
98
+ )
99
+
100
+ return df_final
101
+
102
+ def dont_show_extreme(
103
+ modelised: pl.DataFrame,
104
+ observed: pl.DataFrame,
105
+ column: str,
106
+ quantile_choice: float,
107
+ stat_choice_key: str = None
108
+ ) -> tuple[pl.DataFrame, pl.DataFrame]:
109
+
110
+ if stat_choice_key not in ("month", "date"):
111
+ # 1) Calcul des quantiles
112
+ q_mod = modelised.select(
113
+ pl.col(column).quantile(quantile_choice, interpolation="nearest")
114
+ ).item()
115
+
116
+ if observed is None or observed.height == 0:
117
+ seuil = q_mod
118
+ else:
119
+ q_obs = observed.select(
120
+ pl.col(column).quantile(quantile_choice, interpolation="nearest")
121
+ ).item()
122
+ seuil = max(q_mod, q_obs)
123
+
124
+ # 2) Saturation des couleurs
125
+ clamp_expr = (
126
+ pl.when(pl.col(column).abs() > seuil)
127
+ .then(pl.lit(seuil) * pl.col(column).sign())
128
+ .otherwise(pl.col(column))
129
+ .alias(column)
130
+ )
131
+
132
+ # 3) Renvoi des tableaux
133
+ modelised_show = modelised.with_columns(clamp_expr)
134
+ observed_show = observed.with_columns(clamp_expr)
135
+
136
+ else:
137
+ modelised_show, observed_show = modelised, observed
138
+
139
+ return modelised_show, observed_show
140
+
141
+
142
+ def add_metadata(df: pl.DataFrame, scale: str, type: str) -> pl.DataFrame:
143
+ echelle = 'horaire' if scale == 'mm_h' else 'quotidien'
144
+
145
+ # Charger les metadonnées avec Polars
146
+ df_meta = pl.read_csv(f"data/metadonnees/{type}/postes_{echelle}.csv")
147
+ # Harmoniser les types des colonnes lat/lon des deux c��tés
148
+ df_meta = df_meta.with_columns([
149
+ pl.col("NUM_POSTE").cast(pl.Int32),
150
+ pl.col("lat").cast(pl.Float32),
151
+ pl.col("lon").cast(pl.Float32),
152
+ pl.col("altitude").cast(pl.Int32) # altitude en entier
153
+ ])
154
+
155
+ df = df.with_columns([ # forcer ici aussi
156
+ pl.col("NUM_POSTE").cast(pl.Int32)
157
+ ])
158
+
159
+ # Join sur NUM_POSTE
160
+ return df.join(df_meta, on=["NUM_POSTE"], how="left")
161
+
162
+
163
+ def find_matching_point(df_model: pl.DataFrame, lat_obs: float, lon_obs: float):
164
+ df_model = df_model.with_columns([
165
+ ((pl.col("lat") - lat_obs) ** 2 + (pl.col("lon") - lon_obs) ** 2).sqrt().alias("dist")
166
+ ])
167
+ closest_row = df_model.filter(pl.col("dist") == pl.col("dist").min()).select(["lat", "lon"]).row(0)
168
+ return closest_row # (lat, lon)
169
+
170
+ def match_and_compare(
171
+ obs_df: pl.DataFrame,
172
+ mod_df: pl.DataFrame,
173
+ column_to_show: str,
174
+ obs_vs_mod: pl.DataFrame = None
175
+ ) -> pl.DataFrame:
176
+
177
+ if obs_vs_mod is None:
178
+ raise ValueError("obs_vs_mod must be provided with NUM_POSTE_obs and NUM_POSTE_mod columns")
179
+
180
+ obs_vs_mod = obs_vs_mod.with_columns(
181
+ pl.col("NUM_POSTE_obs").cast(pl.Int32)
182
+ ).filter(
183
+ pl.col("NUM_POSTE_obs").is_in(obs_df["NUM_POSTE"].cast(pl.Int32))
184
+ )
185
+
186
+ # Renommer temporairement pour le join
187
+ obs = obs_df.rename({"NUM_POSTE": "NUM_POSTE_obs"})
188
+ mod = mod_df.rename({"NUM_POSTE": "NUM_POSTE_mod"})
189
+
190
+ obs = obs_df.with_columns(
191
+ pl.col("NUM_POSTE").cast(pl.Int32)
192
+ ).rename({"NUM_POSTE": "NUM_POSTE_obs"})
193
+
194
+ mod = mod_df.with_columns(
195
+ pl.col("NUM_POSTE").cast(pl.Int32)
196
+ ).rename({"NUM_POSTE": "NUM_POSTE_mod"})
197
+
198
+ obs_vs_mod = obs_vs_mod.with_columns(
199
+ pl.col("NUM_POSTE_obs").cast(pl.Int32),
200
+ pl.col("NUM_POSTE_mod").cast(pl.Int32)
201
+ )
202
+
203
+ # Ajoute les valeurs observées et simulées en fonction des correspondances
204
+ matched = (
205
+ obs_vs_mod
206
+ .join(obs.select(["NUM_POSTE_obs", "lat", "lon", column_to_show]), on="NUM_POSTE_obs", how="left")
207
+ .join(mod.select(["NUM_POSTE_mod", column_to_show]), on="NUM_POSTE_mod", how="left", suffix="_mod")
208
+ .rename({column_to_show: "Station", f"{column_to_show}_mod": "AROME"})
209
+ )
210
+ matched = matched.select(["NUM_POSTE_obs", "lat", "lon", "NUM_POSTE_mod", "Station", "AROME"]).drop_nulls()
211
+
212
+ return matched
213
+
214
+
215
+ def standardize_year(year: float, min_year: int, max_year: int) -> float:
216
+ """
217
+ Normalise une année `year` entre 0 et 1 avec une transformation min-max.
218
+ """
219
+ return (year - min_year) / (max_year - min_year)
220
+
221
+
222
+ def filter_nan(df: pl.DataFrame, columns: list[str]):
223
+ return df.drop_nulls(subset=columns)
app/utils/gev_utils.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import polars as pl
3
+ from scipy.special import gamma
4
+
5
+ # --- Quantile GEV ---
6
+ # Soit :
7
+ # μ(t) = μ₀ + μ₁ × t # localisation dépendante du temps
8
+ # σ(t) = σ₀ + σ₁ × t # échelle dépendante du temps
9
+ # ξ = constante # forme
10
+ # T = période de retour (années)
11
+ # p = 1 − 1 / T # probabilité non-excédée associée
12
+ # Avec t : année (ou covariable normalisée dans l'intervalle [0; 1]
13
+ # t = (annee - min_year) / (max_year - min_year) = (annee - min_year) / delta_year
14
+ # Une unité de t (normalisée) = Δa années (max_year - min_year)
15
+
16
+ # En notant Δa = max_year - min_year et a = annee, on a :
17
+ # t = (a − aₘᵢₙ) / Δa ⇒ a = aₘᵢₙ + t ⋅ Δa
18
+
19
+ # La quantile notée qᵀ(t) (précipitation pour une période de retour T à l’année t) s’écrit :
20
+ # qᵀ(t) = μ(t) + [σ(t) / ξ] × [ (−log(1 − p))^(−ξ) − 1 ]
21
+ # qᵀ(t) = (μ₀ + μ₁ × t) + [(σ₀ + σ₁ × t) / ξ] × [ (−log(1 − (1/T)))^(−ξ) − 1 ]
22
+
23
+ # Soit : z_T = [ -log(1 - 1/T) ]^(−ξ) − 1 ← constante pour un T donné
24
+ # Donc : qᵀ(t) = μ₀ + μ₁·t + [(σ₀ + σ₁·t) / ξ] · z_T
25
+ # Ou : qᵀ(t) = μ(t) + [σ(t) / ξ] · z_T
26
+
27
+ # En dérivant qᵀ par rapport à t on a :
28
+ # dqᵀ/dt = μ₁ + σ₁ / ξ · z_T
29
+ # On rappelle : a = aₘᵢₙ + t ⋅ Δa
30
+ # Donc : dt/da = 1 / Δa
31
+
32
+ # Alors dqᵀ/da = dqᵀ/dt · dt/da = μ₁ + σ₁ / ξ · z_T · 1 / Δa
33
+
34
+ # LA VARIATION PAR AN de qᵀ :
35
+ # dqᵀ/da = 1 / Δa · (μ₁ + σ₁ / ξ · z_T)
36
+ # DONC PAR 10 ANS :
37
+ # Δqᵀ₁₀ₐₙₛ = (10 / Δa) ⋅ (μ₁ + (σ₁ / ξ) ⋅ zᵀ)
38
+
39
+
40
+
41
+ def safe_compute_return_df(df: pl.DataFrame) -> pl.DataFrame:
42
+ REQUIRED_GEV_COLS = ["mu0", "mu1", "sigma0", "sigma1", "xi"]
43
+ for col in REQUIRED_GEV_COLS:
44
+ if col not in df.columns:
45
+ df = df.with_columns(pl.lit(0.0).alias(col))
46
+ df = df.with_columns([
47
+ pl.col(col).fill_null(0.0).fill_nan(0.0) for col in REQUIRED_GEV_COLS
48
+ ])
49
+ return df
50
+
51
+
52
+ def compute_return_levels_ns(params: dict, T: np.ndarray, t_norm: float) -> np.ndarray:
53
+ """
54
+ Calcule les niveaux de retour selon le modèle NS-GEV fourni.
55
+ - params : dictionnaire des paramètres GEV d'un point
56
+ - T : périodes de retour (en années)
57
+ - t_norm : covariable temporelle normalisée (ex : 0 pour année moyenne)
58
+ """
59
+ mu = params.get("mu0", 0) + params["mu1"] * t_norm if "mu1" in params else params.get("mu0", 0) # μ(t)
60
+ sigma = params.get("sigma0", 0) + params["sigma1"] * t_norm if "sigma1" in params else params.get("sigma0", 0) # σ(t)
61
+ xi = params.get("xi", 0) # xi contant
62
+
63
+ if xi != 0:
64
+ qT = mu + (sigma / xi) * ((-np.log(1 - 1 / T))**(-xi) - 1)
65
+ else:
66
+ qT = mu - sigma * np.log(-np.log(1 - 1/T))
67
+
68
+ return qT
69
+
70
+
71
+ def delta_qT_X_years(mu1, sigma1, xi, T, year_range, par_X_annees):
72
+ """
73
+ Calcule la variation décennale du quantile de retour qᵀ(t)
74
+ dans un modèle GEV non stationnaire avec t ∈ [0, 1].
75
+
76
+ La variation est ramenée à l’échelle des années civiles en tenant compte de la
77
+ durée totale du modèle (year_range = a_max - a_min).
78
+ Si un point de rupture est introduit year_range = a_max - a_rupture,
79
+ avec une Δqᵀ = 0 avant la rupture.
80
+
81
+ Δqᵀ = (par_X_annees / year_range) × (μ₁ + (σ₁ / ξ) × z_T)
82
+ avec :
83
+ - z_T = [ -log(1 - 1/T) ]^(-ξ) - 1 si ξ ≠ 0
84
+ = log(-log(1 - 1/T)) si ξ = 0 (Gumbel)
85
+
86
+ par_X_annees représente 10, 20, 30 ans dans Δ_10ans qᵀ
87
+ """
88
+ try:
89
+ p = 1 - 1 / T
90
+ if xi == 0:
91
+ z_T = np.log(-np.log(p))
92
+ delta_q = (par_X_annees / year_range) * (mu1 + sigma1 * z_T)
93
+ else:
94
+ z_T = (-np.log(p))**(-xi) - 1
95
+ delta_q = (par_X_annees / year_range) * (mu1 + (sigma1 / xi) * z_T)
96
+ return float(delta_q)
97
+ except Exception:
98
+ return np.nan
99
+
100
+
101
+ def compute_delta_qT(row, T_choice, year_range, par_X_annees):
102
+ return delta_qT_X_years(
103
+ row["mu1"],
104
+ row["sigma1"],
105
+ row["xi"],
106
+ T=T_choice,
107
+ year_range=year_range,
108
+ par_X_annees=par_X_annees
109
+ )
110
+
111
+
112
+ # --- Espérence, variance, CV de GEV ---
113
+
114
+ def gev_moments(mu, sigma, xi):
115
+ if xi >= 0.5:
116
+ return np.nan, np.nan, np.nan # variance indéfinie
117
+ try:
118
+ mean = mu + sigma / xi * (gamma(1 - xi) - 1)
119
+ var = (sigma ** 2) / (xi ** 2) * (gamma(1 - 2 * xi) - gamma(1 - xi) ** 2)
120
+ cv = np.sqrt(var) / mean if mean != 0 else np.nan
121
+ return mean, var, cv
122
+ except Exception:
123
+ return np.nan, np.nan, np.nan
124
+
125
+
126
+ def eval_params_nsgev(mu0, mu1, sigma0, sigma1, xi, t, t0):
127
+ mu_t = mu0 + mu1 * (t - t0)
128
+ sigma_t = sigma0 + sigma1 * (t - t0)
129
+ return gev_moments(mu_t, sigma_t, xi)
130
+
131
+
132
+ def compute_delta_stat(row, stat: str, year_start: int, year_ref: int, year_end: int, par_X_annees: int) -> float:
133
+ """
134
+ Calcule la variation du moment statistique GEV (moyenne, variance, CV)
135
+ exprimée en changement moyen par 10 ans.
136
+
137
+ Parameters:
138
+ - row : dictionnaire contenant les paramètres GEV
139
+ - stat : "ΔE", "ΔVar" ou "ΔCV"
140
+ - year_start, year_end : années de début et de fin
141
+ - year_ref : année de référence (t0)
142
+
143
+ Returns:
144
+ - Variation du moment sélectionné rapportée à 10 ans
145
+ """
146
+ Δa = year_end - year_start
147
+ if Δa == 0:
148
+ return np.nan # évite division par zéro
149
+
150
+ # Moments aux deux dates
151
+ mean_start, var_start, cv_start = eval_params_nsgev(
152
+ mu0=row["mu0"], mu1=row.get("mu1", 0.0),
153
+ sigma0=row["sigma0"], sigma1=row.get("sigma1", 0.0),
154
+ xi=row["xi"], t=year_start, t0=year_ref
155
+ )
156
+
157
+ mean_end, var_end, cv_end = eval_params_nsgev(
158
+ mu0=row["mu0"], mu1=row.get("mu1", 0.0),
159
+ sigma0=row["sigma0"], sigma1=row.get("sigma1", 0.0),
160
+ xi=row["xi"], t=year_end, t0=year_ref
161
+ )
162
+
163
+ if stat == "ΔE":
164
+ return (mean_end - mean_start) * par_X_annees / Δa
165
+ elif stat == "ΔVar":
166
+ return (var_end - var_start) * par_X_annees / Δa
167
+ elif stat == "ΔCV":
168
+ return (cv_end - cv_start) * par_X_annees / Δa
169
+ else:
170
+ return np.nan
171
+
app/utils/hist_utils.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import plotly.express as px
2
+ import streamlit as st
3
+ import polars as pl
4
+ import pandas as pd
5
+
6
+ def plot_histogramme(df: pl.DataFrame, var, stat, stat_key, unit, height):
7
+ df = df.to_pandas()
8
+ # Définir l’ordre complet des mois
9
+ month_order = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
10
+ 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
11
+
12
+ if stat_key == 'month':
13
+ # Copie de sécurité
14
+ df = df.copy()
15
+
16
+ # Convertir le numéro du mois (1-12) en label texte
17
+ df[var] = df[var].astype(int)
18
+ df[var] = df[var].map({i+1: month_order[i] for i in range(12)})
19
+
20
+ # Calculer la répartition (pourcentage) par mois
21
+ counts = df[var].value_counts() # nb de lignes par mois présent
22
+ counts = counts.reindex(month_order, fill_value=0) # forcer l’existence de tous les mois, avec 0 pour les absents
23
+
24
+ # Convertir en pourcentage
25
+ total = counts.sum()
26
+ freq_percent = (counts / total * 100) if total > 0 else counts
27
+
28
+ # Construire un nouveau DF pour Plotly
29
+ hist_df = pd.DataFrame({var: freq_percent.index, 'Pourcentage': freq_percent.values})
30
+
31
+ # Plot en barres
32
+ fig = px.bar(
33
+ hist_df,
34
+ x=var,
35
+ y='Pourcentage'
36
+ )
37
+ fig.update_layout(
38
+ bargap=0.1, # Espacement entre barres
39
+ xaxis_title="", # Pas de titre horizontal
40
+ yaxis_title="Pourcentage de stations",
41
+ height=height,
42
+ xaxis=dict(
43
+ categoryorder='array',
44
+ categoryarray=month_order
45
+ )
46
+ )
47
+ else:
48
+ # Cas normal : on garde px.histogram
49
+ fig = px.histogram(
50
+ df,
51
+ x=var,
52
+ nbins=50,
53
+ histnorm='percent'
54
+ )
55
+ fig.update_layout(
56
+ bargap=0.1,
57
+ xaxis_title=f"{stat} ({unit})" if unit else f"{stat}",
58
+ yaxis_title="Pourcentage de stations",
59
+ height=height
60
+ )
61
+ return fig
62
+
63
+
64
+ def plot_histogramme_comparatif(df_observed: pl.DataFrame, df_modelised: pl.DataFrame, var, stat, stat_key, unit, height):
65
+ df_observed = df_observed.to_pandas()
66
+ df_modelised = df_modelised.to_pandas()
67
+ month_order = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
68
+ 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
69
+
70
+ if stat_key == 'month':
71
+ def prepare_df(df, label):
72
+ df = df.copy()
73
+ df[var] = df[var].astype(int)
74
+ df[var] = df[var].map({i + 1: month_order[i] for i in range(12)})
75
+ counts = df[var].value_counts()
76
+ counts = counts.reindex(month_order, fill_value=0)
77
+ total = counts.sum()
78
+ freq_percent = (counts / total * 100) if total > 0 else counts
79
+ return pd.DataFrame({
80
+ var: freq_percent.index,
81
+ 'Pourcentage': freq_percent.values,
82
+ 'Source': label
83
+ })
84
+
85
+ df_obs = prepare_df(df_observed, "Observé")
86
+ df_mod = prepare_df(df_modelised, "Modélisé")
87
+ hist_df = pd.concat([df_obs, df_mod], ignore_index=True)
88
+
89
+ fig = px.bar(
90
+ hist_df,
91
+ x=var,
92
+ y='Pourcentage',
93
+ color='Source',
94
+ barmode='group' # Affichage côte à côte
95
+ )
96
+ fig.update_layout(
97
+ bargap=0.15,
98
+ xaxis_title="",
99
+ yaxis_title="Pourcentage de stations",
100
+ height=height,
101
+ xaxis=dict(
102
+ categoryorder='array',
103
+ categoryarray=month_order
104
+ )
105
+ )
106
+ else:
107
+ # Affichage standard pour les autres stats
108
+ df_observed['Source'] = "Observé"
109
+ df_modelised['Source'] = "Modélisé"
110
+ df_all = pd.concat([df_observed, df_modelised], ignore_index=True)
111
+
112
+ fig = px.histogram(
113
+ df_all,
114
+ x=var,
115
+ color='Source',
116
+ nbins=50,
117
+ histnorm='percent',
118
+ barmode='overlay' # ou 'group' si tu veux les voir côte à côte
119
+ )
120
+ fig.update_layout(
121
+ bargap=0.1,
122
+ xaxis_title=f"{stat} ({unit})" if unit else f"{stat}",
123
+ yaxis_title="Pourcentage de stations",
124
+ height=height
125
+ )
126
+
127
+ return fig
app/utils/legends_utils.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from io import BytesIO
2
+ import base64
3
+ import polars as pl
4
+ import numpy as np
5
+ import datetime as dt
6
+ import matplotlib.pyplot as plt
7
+
8
+ def get_stat_column_name(stat_key: str, scale_key: str) -> str:
9
+ if stat_key == "mean":
10
+ return f"mean_all_{scale_key}"
11
+ elif stat_key == "max":
12
+ return f"max_all_{scale_key}"
13
+ elif stat_key == "mean-max":
14
+ return f"max_mean_{scale_key}"
15
+ elif stat_key == "date":
16
+ return "date_max_h" if scale_key == "mm_h" else "date_max_j"
17
+ elif stat_key == "month":
18
+ return "mois_pluvieux_h" if scale_key == "mm_h" else "mois_pluvieux_j"
19
+ elif stat_key == "numday":
20
+ return "jours_pluie_moyen"
21
+ else:
22
+ raise ValueError(f"Statistique inconnue : {stat_key}")
23
+
24
+ def get_stat_unit(stat_key: str, scale_key: str) -> str:
25
+ if stat_key in ["mean", "max", "mean-max"]:
26
+ return "mm/h" if scale_key == "mm_h" else "mm/j"
27
+ elif stat_key == "sum":
28
+ return "mm"
29
+ elif stat_key == "numday":
30
+ return "jours"
31
+ else:
32
+ return ""
33
+
34
+ def formalised_legend(df: pl.DataFrame, column_to_show: str, colormap, vmin=None, vmax=None, is_categorical=False, categories=None):
35
+ df = df.clone()
36
+
37
+ if is_categorical and categories is not None:
38
+ # Cas spécial catégoriel : ex : best_model
39
+ mapping = {cat: i for i, cat in enumerate(categories)} # s_gev=0, ns_gev_m1=1, etc.
40
+
41
+ df = df.with_columns([
42
+ pl.col(column_to_show).map_elements(lambda x: mapping.get(x, None), return_dtype=pl.Float64).alias("value_norm")
43
+ ])
44
+
45
+ vals = df["value_norm"].to_numpy()
46
+ colors = (255 * np.array(colormap(vals / (len(categories) - 1)))[:, :3]).astype(np.uint8)
47
+
48
+ alpha = np.full((colors.shape[0], 1), 255, dtype=np.uint8)
49
+ rgba = np.hstack([colors, alpha])
50
+
51
+ df = df.with_columns([
52
+ pl.Series("fill_color", rgba.tolist(), dtype=pl.List(pl.UInt8)),
53
+ pl.col(column_to_show).alias("val_fmt"), # on garde le nom du modèle comme texte
54
+ pl.col("lat").round(3).cast(pl.Utf8).alias("lat_fmt"),
55
+ pl.col("lon").round(3).cast(pl.Utf8).alias("lon_fmt"),
56
+ ])
57
+
58
+ return df, 0, len(categories) - 1
59
+
60
+ if column_to_show.startswith("date"):
61
+ # Conversion correcte en datetime (Polars)
62
+ df = df.with_columns(
63
+ pl.col(column_to_show).str.strptime(pl.Datetime, format="%Y-%m-%d %H:%M:%S%.6f", strict=False)
64
+ )
65
+
66
+ # Récupération min/max en datetime Python natif
67
+ min_dt = df[column_to_show].min()
68
+ max_dt = df[column_to_show].max()
69
+
70
+ if isinstance(min_dt, dt.date):
71
+ min_dt = dt.datetime.combine(min_dt, dt.time.min)
72
+ if isinstance(max_dt, dt.date):
73
+ max_dt = dt.datetime.combine(max_dt, dt.time.min)
74
+
75
+ vmin = min_dt if vmin is None else vmin
76
+ vmax = max_dt if vmax is None else vmax
77
+
78
+ # Gestion safe des timestamps sur Windows (pré-1970)
79
+ def safe_timestamp(d):
80
+ epoch = dt.datetime(1970, 1, 1)
81
+ return (d - epoch).total_seconds()
82
+
83
+ vmin_ts = safe_timestamp(vmin)
84
+ vmax_ts = safe_timestamp(vmax)
85
+
86
+ # Ajout de la colonne normalisée dans Polars
87
+ df = df.with_columns([
88
+ ((pl.col(column_to_show).cast(pl.Datetime).dt.timestamp() - vmin_ts) / (vmax_ts - vmin_ts))
89
+ .clip(0.0, 1.0)
90
+ .alias("value_norm")
91
+ ])
92
+
93
+ val_fmt_func = lambda x: x.strftime("%Y-%m-%d")
94
+
95
+
96
+ elif column_to_show.startswith("mois_pluvieux"):
97
+ df = df.with_columns(pl.col(column_to_show).cast(pl.Int32))
98
+ value_norm = ((df[column_to_show] - 1) / 11).clip(0.0, 1.0)
99
+ df = df.with_columns(value_norm.alias("value_norm"))
100
+
101
+ mois_labels = [
102
+ "Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
103
+ "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"
104
+ ]
105
+ val_fmt_func = lambda x: mois_labels[int(x) - 1] if 1 <= int(x) <= 12 else "Inconnu"
106
+
107
+ vmin, vmax = 1, 12
108
+
109
+ else: # ➔ Cas général (continu)
110
+ if vmax is None:
111
+ vmax = df[column_to_show].max()
112
+ if vmax is None: # Que des NaN
113
+ return df, None, None
114
+
115
+ if vmin is None:
116
+ vmin = df[column_to_show].min()
117
+ if vmin > 0:
118
+ vmin = 0
119
+
120
+ value_norm = ((df[column_to_show] - vmin) / (vmax - vmin)).clip(0.0, 1.0)
121
+ df = df.with_columns(value_norm.alias("value_norm"))
122
+
123
+ val_fmt_func = lambda x: f"{x:.2f}"
124
+
125
+ # Application de la colormap
126
+ # Étape 1 : extraire les valeurs (en NumPy)
127
+ vals = df["value_norm"].to_numpy()
128
+
129
+ # Étape 2 : appliquer le colormap sur tout le tableau (résultat : Nx4 array RGBA)
130
+ colors = (255 * np.array(colormap(vals))[:, :3]).astype(np.uint8)
131
+
132
+ # Étape 3 : ajouter l'alpha (255)
133
+ alpha = np.full((colors.shape[0], 1), 255, dtype=np.uint8)
134
+ rgba = np.hstack([colors, alpha])
135
+
136
+ # Étape 4 : réinjecter dans Polars
137
+ fill_color = pl.Series("fill_color", rgba.tolist(), dtype=pl.List(pl.UInt8))
138
+
139
+ df = df.with_columns([
140
+ pl.Series("fill_color", fill_color),
141
+ df[column_to_show].map_elements(val_fmt_func, return_dtype=pl.String).alias("val_fmt"), # val_fmt optimisé si float
142
+ pl.col("lat").round(3).cast(pl.Utf8).alias("lat_fmt"),
143
+ pl.col("lon").round(3).cast(pl.Utf8).alias("lon_fmt")
144
+ ])
145
+
146
+ return df, vmin, vmax
147
+
148
+ def display_vertical_color_legend(height, colormap, vmin, vmax, n_ticks=5, label="", model_labels=None):
149
+ if model_labels is not None:
150
+ # Si une liste de labels de modèles est fournie, on fait une légende discrète
151
+ color_boxes = ""
152
+ for idx, name in enumerate(model_labels):
153
+ rgba = colormap(idx / (len(model_labels) - 1)) # Normalisé entre 0-1
154
+ rgb = [int(255 * c) for c in rgba[:3]]
155
+ color = f"rgb({rgb[0]}, {rgb[1]}, {rgb[2]})"
156
+ color_boxes += (
157
+ f'<div style="display: flex; align-items: center; margin-bottom: 6px;">'
158
+ f' <div style="width: 18px; height: 18px; background-color: {color}; margin-right: 8px; border: 1px solid #ccc;"></div>'
159
+ f' <div style="font-size: 12px;">{name}</div>'
160
+ f'</div>'
161
+ )
162
+
163
+ html_legend = (
164
+ f'<div style="text-align: left; font-size: 13px; margin-bottom: 4px;">{label}</div>'
165
+ f'<div style="display: flex; flex-direction: column;">{color_boxes}</div>'
166
+ )
167
+ return html_legend
168
+
169
+ if isinstance(vmin, int) and isinstance(vmax, int) and (1 <= vmin <= 12) and (1 <= vmax <= 12):
170
+ mois_labels = [
171
+ "Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
172
+ "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"
173
+ ]
174
+ color_boxes = ""
175
+ for mois in range(vmin, vmax + 1):
176
+ rgba = colormap((mois - 1) / 11)
177
+ rgb = [int(255 * c) for c in rgba[:3]]
178
+ color = f"rgb({rgb[0]}, {rgb[1]}, {rgb[2]})"
179
+ label_mois = mois_labels[mois - 1]
180
+ color_boxes += (
181
+ f'<div style="display: flex; align-items: center; margin-bottom: 4px;">'
182
+ f' <div style="width: 14px; height: 14px; background-color: {color}; '
183
+ f'border: 1px solid #ccc; margin-right: 6px;"></div>'
184
+ f' <div style="font-size: 12px;">{label_mois}</div>'
185
+ f'</div>'
186
+ )
187
+ html_mois = (
188
+ f'<div style="text-align: left; font-size: 13px; margin-bottom: 4px;">{label}</div>'
189
+ f'<div style="display: flex; flex-direction: column;">{color_boxes}</div>'
190
+ )
191
+ return html_mois
192
+
193
+ gradient = np.linspace(1, 0, 64).reshape(64, 1)
194
+ fig, ax = plt.subplots(figsize=(1, 3), dpi=30)
195
+ ax.imshow(gradient, aspect='auto', cmap=colormap)
196
+ ax.axis('off')
197
+
198
+ buf = BytesIO()
199
+ plt.savefig(buf, format="png", bbox_inches='tight', pad_inches=0, transparent=True)
200
+ plt.close(fig)
201
+ base64_img = base64.b64encode(buf.getvalue()).decode()
202
+
203
+ if isinstance(vmin, dt.datetime) and isinstance(vmax, dt.datetime):
204
+ ticks_seconds = np.linspace(vmax.timestamp(), vmin.timestamp(), n_ticks)
205
+ ticks = [dt.datetime.fromtimestamp(t).strftime("%Y-%m-%d") for t in ticks_seconds]
206
+ else:
207
+ ticks_vals = np.linspace(vmax, vmin, n_ticks)
208
+ ticks = [f"{val:.2f}" for val in ticks_vals]
209
+
210
+ html_gradient = f"""
211
+ <div style="text-align: left; font-size: 13px;">{label}</div>
212
+ <div style="display: flex; flex-direction: row; align-items: center; height: {height-30}px;">
213
+ <img src="data:image/png;base64,{base64_img}"
214
+ style="height: 100%; width: 20px; border: 1px solid #ccc; border-radius: 5px;"/>
215
+ <div style="display: flex; flex-direction: column; justify-content: space-between;
216
+ margin-left: 8px; height: 100%; font-size: 12px;">
217
+ {''.join(f'<div>{tick}</div>' for tick in ticks)}
218
+ </div>
219
+ </div>
220
+ """
221
+ return html_gradient
app/utils/map_utils.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import pydeck as pdk
3
+ import streamlit as st
4
+ import polars as pl
5
+ import geopandas as gpd
6
+
7
+ def prepare_layer(df: pl.DataFrame) -> pl.DataFrame:
8
+ cols = ["lat", "lon", "lat_fmt", "lon_fmt", "altitude", "val_fmt", "fill_color"]
9
+
10
+ if "is_significant" in df.columns:
11
+ cols.append("is_significant")
12
+
13
+ return df.select(cols)
14
+
15
+
16
+ def fast_to_dicts(df: pl.DataFrame) -> list[dict]:
17
+ cols = df.columns
18
+ result = []
19
+
20
+ # Conversion explicite des colonnes en listes Python natives
21
+ arrays = {
22
+ col: (
23
+ df[col].to_list() # pour List ou String ou autre
24
+ if df[col].dtype == pl.List
25
+ else df[col].to_numpy().tolist()
26
+ )
27
+ for col in cols
28
+ }
29
+
30
+ n = len(df)
31
+ for i in range(n):
32
+ row = {col: arrays[col][i] for col in cols}
33
+ result.append(row)
34
+
35
+ return result
36
+
37
+ def create_layer(df: pl.DataFrame) -> pdk.Layer:
38
+ layers = []
39
+
40
+ df = prepare_layer(df)
41
+
42
+ if "is_significant" in df.columns:
43
+ df_sig = df.filter(pl.col("is_significant"))
44
+ df_non_sig = df.filter(~pl.col("is_significant"))
45
+ else:
46
+ df_sig = pl.DataFrame()
47
+ df_non_sig = df
48
+
49
+ # Points significatifs
50
+ if len(df_sig) > 0:
51
+ df_sig = df_sig.with_columns(pl.lit("*").alias("star_text"))
52
+
53
+
54
+ layers.append(
55
+ pdk.Layer(
56
+ "TextLayer",
57
+ data=fast_to_dicts(df_sig),
58
+ get_position=["lon", "lat"],
59
+ get_text="star_text",
60
+ get_size=5,
61
+ get_color=[0, 0, 0, 255], # noir
62
+ get_angle=0,
63
+ get_text_anchor="center",
64
+ get_alignment_baseline="bottom",
65
+ pickable=False
66
+ )
67
+ )
68
+
69
+ layers.append(
70
+ pdk.Layer(
71
+ "GridCellLayer",
72
+ data=fast_to_dicts(df_sig),
73
+ get_position=["lon", "lat"],
74
+ get_fill_color="fill_color",
75
+ cell_size=2500,
76
+ elevation=0,
77
+ elevation_scale=0,
78
+ lighting=None,
79
+ pickable=True,
80
+ opacity=0.2,
81
+ extruded=False
82
+ )
83
+ )
84
+
85
+ # Points non significatifs
86
+ if len(df_non_sig) > 0:
87
+ layers.append(
88
+ pdk.Layer(
89
+ "GridCellLayer",
90
+ data=fast_to_dicts(df_non_sig),
91
+ get_position=["lon", "lat"],
92
+ get_fill_color="fill_color",
93
+ cell_size=2500,
94
+ elevation=0,
95
+ elevation_scale=0,
96
+ lighting=None,
97
+ pickable=True,
98
+ opacity=0.2,
99
+ extruded=False
100
+ )
101
+ )
102
+
103
+ return layers
104
+
105
+
106
+ def create_scatter_layer(df: pl.DataFrame, radius=1500) -> list[pdk.Layer]:
107
+ layers = []
108
+
109
+ df = prepare_layer(df)
110
+
111
+ if "is_significant" in df.columns:
112
+ df_sig = df.filter(pl.col("is_significant"))
113
+ df_non_sig = df.filter(~pl.col("is_significant"))
114
+ else:
115
+ df_sig = pl.DataFrame()
116
+ df_non_sig = df
117
+
118
+ # Points significatifs avec IconLayer (Triangle non rempli)
119
+ if len(df_sig) > 0:
120
+ layers.append(
121
+ pdk.Layer(
122
+ "ScatterplotLayer",
123
+ data=fast_to_dicts(df_sig),
124
+ get_position=["lon", "lat"],
125
+ get_fill_color="fill_color",
126
+ get_line_color=[0, 0, 0],
127
+ line_width_min_pixels=0.2,
128
+ get_radius=radius,
129
+ radius_scale=3,
130
+ radius_min_pixels=2,
131
+ pickable=True,
132
+ stroked=False
133
+ )
134
+ )
135
+
136
+
137
+ # Points non significatifs en ScatterplotLayer classique
138
+ if len(df_non_sig) > 0:
139
+ layers.append(
140
+ pdk.Layer(
141
+ "ScatterplotLayer",
142
+ data=fast_to_dicts(df_non_sig),
143
+ get_position=["lon", "lat"],
144
+ get_fill_color="fill_color",
145
+ get_line_color=[0, 0, 0],
146
+ line_width_min_pixels=0.2,
147
+ get_radius=radius,
148
+ radius_scale=1,
149
+ radius_min_pixels=2,
150
+ pickable=True,
151
+ stroked=False
152
+ )
153
+ )
154
+
155
+ return layers
156
+
157
+
158
+
159
+ def create_tooltip(label: str) -> dict:
160
+ return {
161
+ "html": f"""
162
+ ({{lat_fmt}}, {{lon_fmt}})<br>
163
+ {{altitude}} m<br>
164
+ {{val_fmt}} {label}
165
+ """,
166
+ "style": {
167
+ "backgroundColor": "steelblue",
168
+ "color": "white"
169
+ },
170
+ "condition": "altitude !== 'undefined'"
171
+ }
172
+
173
+
174
+ def relief():
175
+ # Lire et reprojeter le shapefile
176
+ gdf = gpd.read_file(Path("data/external/niveaux/selection_courbes_niveau_france.shp").resolve()).to_crs(epsg=4326)
177
+
178
+ # Extraire les chemins
179
+ path_data = []
180
+ for _, row in gdf.iterrows():
181
+ geom = row.geometry
182
+ altitude = row["coordonnees"] # ou la colonne correcte (parfois 'ALTITUDE', à adapter)
183
+
184
+ if geom.geom_type == "LineString":
185
+ path_data.append({"path": list(geom.coords), "altitude": altitude})
186
+ elif geom.geom_type == "MultiLineString":
187
+ for line in geom.geoms:
188
+ path_data.append({"path": list(line.coords), "altitude": altitude})
189
+
190
+ # Couleur fixe blanc
191
+ return pdk.Layer(
192
+ "PathLayer",
193
+ data=path_data,
194
+ get_path="path",
195
+ get_color="[0, 0, 0, 100]",
196
+ width_scale=1,
197
+ width_min_pixels=0.5,
198
+ pickable=False
199
+ )
200
+
201
+ def plot_map(layers, view_state, tooltip, activate_relief: bool=False):
202
+ if not isinstance(layers, list):
203
+ layers = [layers]
204
+
205
+ # Supprime les couches nulles/indéfinies
206
+ layers = [layer for layer in layers if layer is not None]
207
+
208
+ if activate_relief:
209
+ relief_layer = relief()
210
+ if relief_layer is not None:
211
+ layers.append(relief_layer)
212
+
213
+ try:
214
+ return pdk.Deck(
215
+ layers=layers,
216
+ initial_view_state=view_state,
217
+ tooltip=tooltip,
218
+ map_style=None
219
+ )
220
+ except Exception as e:
221
+ st.error(f"Erreur lors de la création de la carte : {e}")
222
+ return None
223
+
app/utils/menus_utils.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ from pathlib import Path
3
+
4
+ from app.utils.config_utils import reverse_param_label
5
+
6
+ def menu_statisticals(min_years: int, max_years: int, STATS, SEASON):
7
+ if "selected_point" not in st.session_state:
8
+ st.session_state["selected_point"] = None
9
+
10
+ if "run_analysis" not in st.session_state:
11
+ st.session_state["run_analysis"] = False
12
+
13
+ # Crée les colonnes
14
+ col0, col1, col2, col3, col4, col5, col6, col7 = st.columns([0.3, 0.3, 0.2, 0.25, 0.25, 0.25, 0.2, 0.2])
15
+
16
+ with col0:
17
+ st.selectbox("Statistique étudiée", list(STATS.keys()), key="stat_choice")
18
+
19
+ with col1:
20
+ st.slider(
21
+ "Staturation couleurs",
22
+ min_value=0.950,
23
+ max_value=1.00,
24
+ value=0.995,
25
+ step=0.001,
26
+ format="%.3f",
27
+ key="quantile_choice"
28
+ )
29
+
30
+ with col2:
31
+ st.selectbox("Saison", list(SEASON.keys()), key="season_choice")
32
+
33
+ with col3:
34
+ season = st.session_state["season_choice"]
35
+ if season in ["Année hydrologique", "Hiver"]:
36
+ st.slider(
37
+ "Période",
38
+ min_value=min_years+1,
39
+ max_value=max_years,
40
+ value=(min_years+1, max_years),
41
+ key="year_range"
42
+ )
43
+ else:
44
+ st.slider(
45
+ "Période",
46
+ min_value=min_years,
47
+ max_value=max_years,
48
+ value=(min_years, max_years),
49
+ key="year_range"
50
+ )
51
+
52
+ with col4:
53
+ if st.session_state["stat_choice"] in ["Cumul", "Jour de pluie"]:
54
+ st.selectbox("Echelle temporelle", ["Journalière"], key="scale_choice")
55
+ else:
56
+ st.selectbox("Echelle temporelle", ["Journalière", "Horaire"], key="scale_choice")
57
+
58
+ with col5:
59
+ st.slider(
60
+ "Données manquantes",
61
+ min_value=0.0,
62
+ max_value=1.0,
63
+ value=0.1,
64
+ step=0.01,
65
+ key="missing_rate"
66
+ )
67
+
68
+ with col6:
69
+ st.checkbox("Courbes de niveaux", value=False, key="show_relief") # Case à cocher
70
+ st.checkbox("Afficher les stations", value=False, key="show_stations") # Case à cocher
71
+
72
+ with col7:
73
+ if st.button("Lancer l’analyse"):
74
+ st.session_state["run_analysis"] = True
75
+
76
+ if st.session_state["run_analysis"]:
77
+ return (
78
+ st.session_state["stat_choice"],
79
+ st.session_state["quantile_choice"],
80
+ st.session_state["year_range"][0],
81
+ st.session_state["year_range"][1],
82
+ st.session_state["season_choice"],
83
+ st.session_state["scale_choice"],
84
+ st.session_state["missing_rate"],
85
+ st.session_state["show_relief"],
86
+ st.session_state["show_stations"]
87
+ )
88
+ else:
89
+ return None
90
+
91
+
92
+
93
+ def menu_gev(config: dict, model_options: dict, ns_param_map: dict, SEASON, show_param: bool):
94
+ if "run_analysis" not in st.session_state:
95
+ st.session_state["run_analysis"] = False
96
+
97
+ col0, col1, col2, col3, col4, col5, col6, col7 = st.columns([0.6, 0.9, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5])
98
+
99
+ # Échelle
100
+ with col0:
101
+ Echelle = st.selectbox("Echelle temporelle", ["Journalière", "Horaire"], key="scale_choice")
102
+ st.session_state["echelle"] = "quotidien" if Echelle.lower() == "journalière" else "horaire"
103
+ st.session_state["unit"] = "mm/j" if st.session_state["echelle"] == "quotidien" else "mm/h"
104
+
105
+ # Modèle
106
+ with col1:
107
+ selected_model = st.selectbox(
108
+ "Modèle GEV",
109
+ [None] + list(model_options.keys()),
110
+ format_func=lambda x: "— Choisir un modèle —" if x is None else x,
111
+ key="model_type"
112
+ )
113
+
114
+ if st.session_state["model_type"] is not None:
115
+ model_name = model_options[st.session_state["model_type"]]
116
+ st.session_state["model_name"] = model_name
117
+
118
+ # Quantile
119
+ with col2:
120
+ st.selectbox("Choix de la saison", list(SEASON.keys()), key="season_choice")
121
+
122
+ with col3:
123
+ st.slider(
124
+ "Percentile de retrait",
125
+ min_value=0.950,
126
+ max_value=1.000,
127
+ value=1.000,
128
+ step=0.001,
129
+ format="%.3f",
130
+ key="quantile_choice"
131
+ )
132
+
133
+ # Paramètre GEV
134
+ with col4:
135
+ if show_param:
136
+ param_map = ns_param_map[model_name]
137
+ available_params = list(param_map.values()) # labels unicode
138
+ selected_label = st.selectbox(
139
+ "Paramètre GEV à afficher",
140
+ available_params,
141
+ index=0,
142
+ key="gev_param_choice"
143
+ )
144
+ # Conversion propre
145
+ st.session_state["param_choice"] = reverse_param_label(
146
+ selected_label, model_name, ns_param_map
147
+ )
148
+ else:
149
+ # st.session_state["param_choice"] = "Δqᵀ"
150
+ # selected_label = "Δqᵀ"
151
+ selected_label = st.selectbox(
152
+ "Quantité à afficher",
153
+ ["Δqᵀ", "ΔE", "ΔVar", "ΔCV"],
154
+ index=0,
155
+ key="delta_param_choice"
156
+ )
157
+ st.session_state["param_choice"] = selected_label
158
+
159
+ if selected_label in ["Δqᵀ"]:
160
+ with col5:
161
+ st.slider(
162
+ "Niveau de retour",
163
+ min_value=10,
164
+ max_value=100,
165
+ value=10,
166
+ step=10,
167
+ key="T_choice"
168
+ )
169
+ else:
170
+ st.session_state["T_choice"] = None
171
+
172
+ if selected_label in ["Δqᵀ", "ΔE", "ΔVar", "ΔCV"]:
173
+ with col6:
174
+ st.slider(
175
+ "Delta annees",
176
+ min_value=1,
177
+ max_value=60,
178
+ value=10,
179
+ step=1,
180
+ key="par_X_annees"
181
+ )
182
+ else:
183
+ st.session_state["par_X_annees"] = None
184
+
185
+
186
+ # Bouton d’analyse
187
+ with col7:
188
+ if st.button("Lancer l’analyse"):
189
+ st.session_state["run_analysis"] = True
190
+
191
+ if st.session_state["run_analysis"]:
192
+ # Valeurs par défaut
193
+ stat_choice_key = "max"
194
+ scale_choice_key = "mm_j" if st.session_state["echelle"] == "quotidien" else "mm_h"
195
+ season_choice_key = SEASON[st.session_state["season_choice"]]
196
+ min_year_choice = config["years"]["min"] + 1 if season_choice_key in ["hydro", "djf"] else config["years"]["min"]
197
+ max_year_choice = config["years"]["max"]
198
+ missing_rate = 0.15
199
+ # Répertoires
200
+ mod_dir = Path(config["gev"]["modelised"]) / st.session_state["echelle"] / season_choice_key
201
+ obs_dir = Path(config["gev"]["observed"]) / st.session_state["echelle"] / season_choice_key
202
+
203
+ return {
204
+ "echelle": st.session_state["echelle"],
205
+ "unit": st.session_state["unit"],
206
+ "model_name": st.session_state["model_name"],
207
+ "model_name_pres": selected_model,
208
+ "param_choice": st.session_state["param_choice"],
209
+ "param_choice_pres": selected_label,
210
+ "quantile_choice": st.session_state["quantile_choice"],
211
+ "stat_choice_key": stat_choice_key,
212
+ "scale_choice_key": scale_choice_key,
213
+ "season_choice_key": season_choice_key,
214
+ "season_choice": st.session_state["season_choice"],
215
+ "min_year_choice": min_year_choice,
216
+ "max_year_choice": max_year_choice,
217
+ "missing_rate": missing_rate,
218
+ "mod_dir": mod_dir,
219
+ "obs_dir": obs_dir,
220
+ "T_choice": st.session_state["T_choice"],
221
+ "par_X_annees": st.session_state["par_X_annees"]
222
+ }
223
+
224
+ return None
app/utils/scatter_plot_utils.py ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import polars as pl
2
+ import plotly.express as px
3
+ import plotly.graph_objects as go
4
+ import numpy as np
5
+ from scipy.stats import genextreme
6
+
7
+ def generate_scatter_plot_interactive(df: pl.DataFrame, stat_choice: str, unit_label: str, height: int,
8
+ x_label: str = "AROME", y_label: str = "Station"):
9
+ df_pd = df.select(["NUM_POSTE_obs", "NUM_POSTE_mod", "lat", "lon", x_label, y_label]).to_pandas()
10
+
11
+ fig = px.scatter(
12
+ df_pd,
13
+ x=x_label,
14
+ y=y_label,
15
+ title="",
16
+ opacity=0.5,
17
+ width=height,
18
+ height=height,
19
+ labels={
20
+ x_label: f"{stat_choice} du modèle AROME ({unit_label})",
21
+ y_label: f"{stat_choice} des stations ({unit_label})"
22
+ },
23
+ hover_data={"lat": True, "lon": True}
24
+ )
25
+
26
+ precision = ".1f" if unit_label == "mm/j" else ".2f"
27
+ fig.update_traces(
28
+ hovertemplate=
29
+ "Lat: %{customdata[2]:.4f}<br>Lon: %{customdata[3]:.4f}<br>"
30
+ f"{x_label} : %{{x:{precision}}}<br>{y_label} : %{{y:{precision}}}<extra></extra>",
31
+ customdata=df_pd[["NUM_POSTE_obs", "NUM_POSTE_mod", "lat", "lon"]].values
32
+ )
33
+
34
+ x_range = [df_pd[x_label].min(), df_pd[x_label].max()]
35
+ y_range = [df_pd[y_label].min(), df_pd[y_label].max()]
36
+ min_diag = min(x_range[0], y_range[0])
37
+ max_diag = min(x_range[1], y_range[1])
38
+
39
+ # Ajouter le trait y = x sans légende
40
+ fig.add_trace(
41
+ go.Scatter(
42
+ x=[min_diag, max_diag],
43
+ y=[min_diag, max_diag],
44
+ mode='lines',
45
+ line=dict(color='red', dash='dash'),
46
+ showlegend=False,
47
+ hoverinfo='skip'
48
+ )
49
+ )
50
+
51
+ # Ajouter une annotation "y = x" en bout de ligne
52
+ fig.add_annotation(
53
+ x=max_diag,
54
+ y=max_diag,
55
+ text="y = x",
56
+ showarrow=False,
57
+ font=dict(color='red'),
58
+ xanchor="left",
59
+ yanchor="bottom"
60
+ )
61
+
62
+ return fig
63
+
64
+
65
+
66
+
67
+ def generate_return_period_plot_interactive(
68
+ T, y_obs, y_mod,
69
+ label_obs="Stations", label_mod="AROME",
70
+ unit: str = "mm/j", height: int = 600,
71
+ points_obs: dict | None = None,
72
+ points_mod: dict | None = None
73
+ ):
74
+ fig = go.Figure()
75
+
76
+ # Courbe observations
77
+ fig.add_trace(go.Scatter(
78
+ x=T,
79
+ y=y_obs,
80
+ mode="lines",
81
+ name=label_obs,
82
+ line=dict(color="blue"),
83
+ hovertemplate="Période : %{x:.1f} ans<br>Précipitation : %{y:.1f} " + unit + "<extra></extra>"
84
+ ))
85
+
86
+ # Courbe modèle
87
+ fig.add_trace(go.Scatter(
88
+ x=T,
89
+ y=y_mod,
90
+ mode="lines",
91
+ name=label_mod,
92
+ line=dict(color="orange"),
93
+ hovertemplate="Période : %{x:.1f} ans<br>Précipitation : %{y:.1f} " + unit + "<extra></extra>"
94
+ ))
95
+
96
+ # Points maximas observés (facultatif)
97
+ if points_obs is not None:
98
+ fig.add_trace(go.Scatter(
99
+ x=points_obs["year"],
100
+ y=points_obs["value"],
101
+ mode="markers",
102
+ name="Maximas mesurés",
103
+ marker=dict(color="blue", size=4, symbol="x"),
104
+ hovertemplate="Période : %{x:.1f} ans<br>Max observé : %{y:.1f} " + unit + "<extra></extra>"
105
+ ))
106
+
107
+ # Maximas annuels bruts (facultatif)
108
+ if points_mod is not None:
109
+ fig.add_trace(go.Scatter(
110
+ x=points_mod["year"],
111
+ y=points_mod["value"],
112
+ mode="markers",
113
+ name="Maximas modélisés",
114
+ marker=dict(color="orange", size=4, symbol="x"),
115
+ hovertemplate="Année : %{x:.1f}<br>Max : %{y:.1f} " + unit + "<extra></extra>"
116
+ ))
117
+
118
+ fig.update_layout(
119
+ xaxis=dict(
120
+ title="Période de retour (ans)",
121
+ type="log",
122
+ showgrid=True,
123
+ minor=dict(ticklen=4, showgrid=True),
124
+ ),
125
+ yaxis=dict(
126
+ title=f"Précipitation ({unit})",
127
+ showgrid=True,
128
+ minor=dict(ticklen=4, showgrid=True),
129
+ ),
130
+ template="plotly_white",
131
+ height=height
132
+ )
133
+
134
+ return fig
135
+
136
+
137
+ def generate_gev_density_comparison_interactive(
138
+ maxima_obs: np.ndarray,
139
+ maxima_mod: np.ndarray,
140
+ params_obs: dict,
141
+ params_mod: dict,
142
+ unit: str = "mm/j",
143
+ height: int = 500,
144
+ t_norm: float = 0.0, # Covariable normalisée (ex: 0 pour année médiane)
145
+ ):
146
+ """
147
+ Trace deux courbes de densité GEV (observée et modélisée) superposées, sans histogramme.
148
+ """
149
+
150
+ # --- Récupération des paramètres observés ---
151
+ mu_obs = params_obs.get("mu0", 0) + params_obs.get("mu1", 0) * t_norm
152
+ sigma_obs = params_obs.get("sigma0", 0) + params_obs.get("sigma1", 0) * t_norm
153
+ xi_obs = params_obs.get("xi", 0)
154
+
155
+ # --- Récupération des paramètres modélisés ---
156
+ mu_mod = params_mod.get("mu0", 0) + params_mod.get("mu1", 0) * t_norm
157
+ sigma_mod = params_mod.get("sigma0", 0) + params_mod.get("sigma1", 0) * t_norm
158
+ xi_mod = params_mod.get("xi", 0)
159
+
160
+ # --- Domaine commun pour tracer ---
161
+ minima = min(maxima_obs.min(), maxima_mod.min()) * 0.9
162
+ maxima = max(maxima_obs.max(), maxima_mod.max()) * 1.1
163
+ x = np.linspace(minima, maxima, 500)
164
+
165
+ # --- Densités ---
166
+ density_obs = genextreme.pdf(x, c=-xi_obs, loc=mu_obs, scale=sigma_obs)
167
+ density_mod = genextreme.pdf(x, c=-xi_mod, loc=mu_mod, scale=sigma_mod)
168
+
169
+ # --- Création figure ---
170
+ fig = go.Figure()
171
+
172
+ fig.add_trace(go.Scatter(
173
+ x=x,
174
+ y=density_obs,
175
+ mode="lines",
176
+ name="GEV observée",
177
+ line=dict(color="blue"),
178
+ hovertemplate="Maxima : %{x:.1f} " + unit + "<br>Densité : %{y:.3f}<extra></extra>",
179
+ ))
180
+
181
+ fig.add_trace(go.Scatter(
182
+ x=x,
183
+ y=density_mod,
184
+ mode="lines",
185
+ name="GEV modélisée",
186
+ line=dict(color="orange"),
187
+ hovertemplate="Maxima : %{x:.1f} " + unit + "<br>Densité : %{y:.3f}<extra></extra>",
188
+ ))
189
+
190
+ fig.update_layout(
191
+ title="",
192
+ xaxis_title=f"Maximum journalier ({unit})",
193
+ yaxis_title="Densité",
194
+ template="plotly_white",
195
+ height=height,
196
+ )
197
+
198
+ return fig
199
+
200
+
201
+
202
+ import numpy as np
203
+ import plotly.graph_objects as go
204
+ from scipy.stats import genextreme
205
+ import matplotlib.cm as cm
206
+ import matplotlib.colors as mcolors
207
+
208
+ def generate_gev_density_comparison_interactive_3D(
209
+ maxima_obs: np.ndarray,
210
+ maxima_mod: np.ndarray,
211
+ params_obs: dict,
212
+ params_mod: dict,
213
+ unit: str = "mm/j",
214
+ height: int = 500,
215
+ min_year: int = 1960,
216
+ max_year: int = 2015,
217
+ ):
218
+ """
219
+ Trace deux ensembles de courbes de densité GEV (observée et modélisée) superposées,
220
+ en faisant varier la couleur de violet (min_year) à jaune (max_year).
221
+ """
222
+
223
+ # --- Génération des années ---
224
+ years = np.arange(min_year, max_year + 1)
225
+
226
+ # --- Couleurs violet -> jaune ---
227
+ cmap = cm.get_cmap('plasma')
228
+ norm = mcolors.Normalize(vmin=min_year, vmax=max_year)
229
+ colors = [mcolors.to_hex(cmap(norm(year))) for year in years]
230
+
231
+ # --- Domaine commun pour tracer ---
232
+ minima = min(maxima_obs.min(), maxima_mod.min()) * 0.9
233
+ maxima = max(maxima_obs.max(), maxima_mod.max()) * 1.1
234
+ x = np.linspace(minima, maxima, 500)
235
+
236
+ # --- Création de la figure ---
237
+ fig = go.Figure()
238
+
239
+ for i, year in enumerate(years):
240
+ t_norm = (year - (min_year + max_year) / 2) / (max_year - min_year)
241
+
242
+ # Densité observée
243
+ mu_obs = params_obs.get("mu0", 0) + params_obs.get("mu1", 0) * t_norm
244
+ sigma_obs = params_obs.get("sigma0", 0) + params_obs.get("sigma1", 0) * t_norm
245
+ xi_obs = params_obs.get("xi", 0)
246
+
247
+ density_obs = genextreme.pdf(x, c=-xi_obs, loc=mu_obs, scale=sigma_obs)
248
+
249
+ fig.add_trace(go.Scatter(
250
+ x=x,
251
+ y=density_obs,
252
+ mode="lines",
253
+ line=dict(color=colors[i]),
254
+ name=f"Obs {year}",
255
+ hovertemplate=f"Obs {year}<br>Maxima : %{{x:.1f}} {unit}<br>Densité : %{{y:.3f}}<extra></extra>",
256
+ showlegend=False,
257
+ ))
258
+
259
+ # Densité modélisée
260
+ mu_mod = params_mod.get("mu0", 0) + params_mod.get("mu1", 0) * t_norm
261
+ sigma_mod = params_mod.get("sigma0", 0) + params_mod.get("sigma1", 0) * t_norm
262
+ xi_mod = params_mod.get("xi", 0)
263
+
264
+ density_mod = genextreme.pdf(x, c=-xi_mod, loc=mu_mod, scale=sigma_mod)
265
+
266
+ fig.add_trace(go.Scatter(
267
+ x=x,
268
+ y=density_mod,
269
+ mode="lines",
270
+ line=dict(color=colors[i]),
271
+ name=f"Mod {year}",
272
+ hovertemplate=f"Mod {year}<br>Maxima : %{{x:.1f}} {unit}<br>Densité : %{{y:.3f}}<extra></extra>",
273
+ showlegend=False,
274
+ ))
275
+
276
+ # --- Layout final ---
277
+ fig.update_layout(
278
+ title="",
279
+ xaxis_title=f"Maximum journalier ({unit})",
280
+ yaxis_title="Densité",
281
+ template="plotly_white",
282
+ height=height,
283
+ )
284
+
285
+ return fig
286
+
287
+
288
+
289
+
290
+ def generate_time_series_maxima_interactive(
291
+ years_obs: np.ndarray,
292
+ max_obs: np.ndarray,
293
+ years_mod: np.ndarray,
294
+ max_mod: np.ndarray,
295
+ unit: str = "mm/j",
296
+ height: int = 500,
297
+ nr_year: int = 20,
298
+ return_levels_obs: float | None = None,
299
+ return_levels_mod: float | None = None
300
+ ):
301
+ fig_time_series = go.Figure()
302
+
303
+ # --- Observations (seulement en 'x' sans lignes)
304
+ fig_time_series.add_trace(go.Scatter(
305
+ x=years_obs,
306
+ y=max_obs,
307
+ mode='markers',
308
+ name='Maximas observés',
309
+ marker=dict(symbol='x', size=4, color="blue")
310
+ ))
311
+
312
+ # --- Modèle (seulement en 'x' sans lignes)
313
+ fig_time_series.add_trace(go.Scatter(
314
+ x=years_mod,
315
+ y=max_mod,
316
+ mode='markers',
317
+ name='Maximas modélisés',
318
+ marker=dict(symbol='x', size=4, color="orange")
319
+ ))
320
+
321
+ # --- Niveau de retour 20 ans observé
322
+ if return_levels_obs is not None:
323
+ fig_time_series.add_trace(go.Scatter(
324
+ x=years_obs, # ➔ Utilise toutes les années observées !
325
+ y=return_levels_obs,
326
+ mode='lines',
327
+ name=f'NR observé {nr_year} ans',
328
+ line=dict(color='blue', dash='solid')
329
+ ))
330
+
331
+ # --- Niveau de retour 20 ans modélisé
332
+ if return_levels_mod is not None:
333
+ fig_time_series.add_trace(go.Scatter(
334
+ x=years_mod, # ➔ Utilise toutes les années modélisées !
335
+ y=return_levels_mod,
336
+ mode='lines',
337
+ name=f'NR modélisé {nr_year} ans',
338
+ line=dict(color='orange', dash='solid')
339
+ ))
340
+
341
+ fig_time_series.update_layout(
342
+ title="",
343
+ xaxis_title="Année",
344
+ yaxis_title=f"Maxima annuel ({unit})",
345
+ height=height,
346
+ template="plotly_white"
347
+ )
348
+
349
+ return fig_time_series
350
+
351
+ import numpy as np
352
+ import plotly.graph_objects as go
353
+ from scipy.stats import genextreme
354
+
355
+ def generate_loglikelihood_profile_xi(
356
+ maxima: np.ndarray,
357
+ params: dict,
358
+ unit: str = "mm/j",
359
+ xi_range: float = 3,
360
+ height: int = 500,
361
+ t_norm: float = 0.0
362
+ ):
363
+ """
364
+ Trace le profil de log-vraisemblance autour de ξ ajusté.
365
+
366
+ - maxima : valeurs maximales (array)
367
+ - params : dictionnaire des paramètres GEV
368
+ - unit : unité des maxima
369
+ - xi_range : +/- intervalle autour de ξ pour tracer
370
+ - height : hauteur de la figure
371
+ - t_norm : covariable temporelle normalisée
372
+ """
373
+
374
+ # Récupération des paramètres (à t_norm donné)
375
+ mu = params.get("mu0", 0) + params.get("mu1", 0) * t_norm
376
+ sigma = params.get("sigma0", 0) + params.get("sigma1", 0) * t_norm
377
+ xi_fit = params.get("xi", 0)
378
+
379
+ def compute_nllh(x, mu, sigma, xi):
380
+ if sigma <= 0:
381
+ return np.inf
382
+ try:
383
+ return -np.sum(genextreme.logpdf(x, c=-xi, loc=mu, scale=sigma))
384
+ except Exception:
385
+ return np.inf
386
+
387
+ # Points autour du ξ ajusté
388
+ xis = np.linspace(xi_fit - xi_range, xi_fit + xi_range, 200)
389
+ logliks = [-compute_nllh(maxima, mu, sigma, xi) for xi in xis]
390
+
391
+ # --- Création figure Plotly ---
392
+ fig = go.Figure()
393
+
394
+ fig.add_trace(go.Scatter(
395
+ x=xis,
396
+ y=logliks,
397
+ mode="lines",
398
+ line=dict(color="blue"),
399
+ name="Log-vraisemblance",
400
+ hovertemplate="ξ : %{x:.3f}<br>Log-likelihood : %{y:.1f}<extra></extra>"
401
+ ))
402
+
403
+ # Conversion en array pour traitement
404
+ logliks = np.array(logliks)
405
+
406
+ # Filtrage des valeurs finies
407
+ finite_logliks = logliks[np.isfinite(logliks)]
408
+
409
+ if finite_logliks.size > 0:
410
+ ymin = finite_logliks.min() - 1 # Marge sous le min réel
411
+ ymax = finite_logliks.max()
412
+ else:
413
+ ymin, ymax = -10, 0 # Valeurs par défaut si tout est -inf
414
+
415
+ # Ajout de la ligne verticale
416
+ fig.add_trace(go.Scatter(
417
+ x=[xi_fit, xi_fit],
418
+ y=[ymin, ymax],
419
+ mode="lines",
420
+ line=dict(color="red", dash="dash"),
421
+ name=f"ξ ajusté ({xi_fit:.3f})"
422
+ ))
423
+
424
+ fig.update_layout(
425
+ title="",
426
+ xaxis_title="ξ",
427
+ yaxis_title="Log-vraisemblance",
428
+ template="plotly_white",
429
+ height=height
430
+ )
431
+
432
+ return fig
app/utils/show_info.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def show_info_data(col, label, n_points_valides, n_points_total):
2
+ both_defined = n_points_valides is not None and n_points_total is not None
3
+ if both_defined:
4
+ return col.markdown(f"""
5
+ **{label}**
6
+ {n_points_valides} / {n_points_total}
7
+ Tx couverture : {(n_points_valides / n_points_total * 100):.1f}%
8
+ """)
9
+ else:
10
+ return None
11
+
12
+ def show_info_metric(col, label, metric):
13
+ if metric is not None:
14
+ return col.markdown(f"""
15
+ **{label}**
16
+ {metric:.3f}
17
+ """)
18
+ else:
19
+ return None
app/utils/stats_utils.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import polars as pl
3
+ import numpy as np
4
+ from sklearn.metrics import mean_squared_error, mean_absolute_error
5
+
6
+ def compute_statistic_per_point(df: pl.DataFrame, stat_key: str) -> pl.DataFrame:
7
+ cols = df.columns
8
+
9
+ if stat_key == "mean":
10
+ has_h = "mean_mm_h" in cols
11
+ if has_h:
12
+ df = df.with_columns(
13
+ (pl.col("mean_mm_h") * 24).alias("mean_mm_j")
14
+ )
15
+ return df.group_by("NUM_POSTE").agg([
16
+ *( [pl.col("mean_mm_h").mean().alias("mean_all_mm_h")] if has_h else [] ),
17
+ *( [pl.col("mean_mm_j").mean().alias("mean_all_mm_j")] if has_h else [] ),
18
+ ])
19
+
20
+ elif stat_key == "max":
21
+ return df.group_by("NUM_POSTE").agg([
22
+ *( [pl.col("max_mm_h").max().alias("max_all_mm_h")] if "max_mm_h" in cols else [] ),
23
+ *( [pl.col("max_mm_j").max().alias("max_all_mm_j")] if "max_mm_j" in cols else [] ),
24
+ ])
25
+
26
+ elif stat_key == "mean-max":
27
+ return df.group_by("NUM_POSTE").agg([
28
+ *( [pl.col("max_mm_h").mean().alias("max_mean_mm_h")] if "max_mm_h" in cols else [] ),
29
+ *( [pl.col("max_mm_j").mean().alias("max_mean_mm_j")] if "max_mm_j" in cols else [] ),
30
+ ])
31
+
32
+ elif stat_key == "date":
33
+ res = []
34
+ if "max_mm_h" in cols and "max_date_mm_h" in cols:
35
+ df_h = (
36
+ df.sort("max_mm_h", descending=True)
37
+ .group_by("NUM_POSTE")
38
+ .agg(pl.col("max_date_mm_h").first().alias("date_max_h"))
39
+ )
40
+ res.append(df_h)
41
+ if "max_mm_j" in cols and "max_date_mm_j" in cols:
42
+ df_j = (
43
+ df.sort("max_mm_j", descending=True)
44
+ .group_by("NUM_POSTE")
45
+ .agg(pl.col("max_date_mm_j").first().alias("date_max_j"))
46
+ )
47
+ res.append(df_j)
48
+
49
+ if not res:
50
+ raise ValueError("Aucune date de maximum disponible.")
51
+ elif len(res) == 1:
52
+ return res[0]
53
+ else:
54
+ return res[0].join(res[1], on="NUM_POSTE", how="outer")
55
+
56
+ elif stat_key == "month":
57
+ exprs = []
58
+ if "max_date_mm_h" in cols:
59
+ exprs.append(
60
+ pl.col("max_date_mm_h")
61
+ .str.strptime(pl.Datetime, format="%Y-%m-%d %H:%M:%S%.f", strict=False)
62
+ .dt.month()
63
+ .alias("mois_max_h")
64
+ )
65
+ if "max_date_mm_j" in cols:
66
+ exprs.append(
67
+ pl.col("max_date_mm_j")
68
+ .str.strptime(pl.Datetime, format="%Y-%m-%d %H:%M:%S%.f", strict=False)
69
+ .dt.month()
70
+ .alias("mois_max_j")
71
+ )
72
+ if not exprs:
73
+ raise ValueError("Aucune date de maximum pour extraire les mois.")
74
+
75
+ df = df.with_columns(exprs)
76
+
77
+ mois_h = mois_j = None
78
+
79
+ if "mois_max_h" in df.columns:
80
+ mois_h = (
81
+ df.drop_nulls("mois_max_h")
82
+ .group_by(["NUM_POSTE", "mois_max_h"])
83
+ .len()
84
+ .sort(["NUM_POSTE", "len"], descending=[False, True])
85
+ .unique(subset=["NUM_POSTE"])
86
+ .select(["NUM_POSTE", "mois_max_h"])
87
+ .rename({"mois_max_h": "mois_pluvieux_h"})
88
+ )
89
+
90
+ if "mois_max_j" in df.columns:
91
+ mois_j = (
92
+ df.drop_nulls("mois_max_j")
93
+ .group_by(["NUM_POSTE", "mois_max_j"])
94
+ .len()
95
+ .sort(["NUM_POSTE", "len"], descending=[False, True])
96
+ .unique(subset=["NUM_POSTE"])
97
+ .select(["NUM_POSTE", "mois_max_j"])
98
+ .rename({"mois_max_j": "mois_pluvieux_j"})
99
+ )
100
+
101
+ if mois_h is None and mois_j is None:
102
+ return pl.DataFrame(schema={"NUM_POSTE": pl.Int64, "mois_pluvieux_h": pl.Int32, "mois_pluvieux_j": pl.Int32})
103
+ elif mois_h is None:
104
+ return mois_j.with_columns([pl.lit(None, dtype=pl.Int32).alias("mois_pluvieux_h")])
105
+ elif mois_j is None:
106
+ return mois_h.with_columns([pl.lit(None, dtype=pl.Int32).alias("mois_pluvieux_j")])
107
+ else:
108
+ return mois_h.join(mois_j, on="NUM_POSTE", how="outer")
109
+
110
+ elif stat_key == "numday":
111
+ if "n_days_gt1mm" not in df.columns:
112
+ raise ValueError("Colonne `n_days_gt1mm` manquante.")
113
+ return (
114
+ df.group_by("NUM_POSTE")
115
+ .agg(pl.col("n_days_gt1mm").mean().alias("jours_pluie_moyen"))
116
+ )
117
+
118
+ else:
119
+ raise ValueError(f"Statistique inconnue : {stat_key}")
120
+
121
+
122
+
123
+ def generate_metrics(df: pl.DataFrame, x_label: str = "AROME", y_label: str = "Station"):
124
+ x = df[x_label].to_numpy()
125
+ y = df[y_label].to_numpy()
126
+
127
+ if len(x) != len(y):
128
+ st.error("Longueur x et y différente")
129
+ return np.nan, np.nan, np.nan, np.nan
130
+
131
+ # Filtrage des NaNs sur les deux colonnes
132
+ mask = ~np.isnan(x) & ~np.isnan(y)
133
+ x_valid = x[mask]
134
+ y_valid = y[mask]
135
+
136
+ if len(x_valid) == 0:
137
+ st.warning("Aucune donnée valide après suppression des NaN.")
138
+ return np.nan, np.nan, np.nan, np.nan
139
+
140
+ rmse = np.sqrt(mean_squared_error(y_valid, x_valid))
141
+ mae = mean_absolute_error(y_valid, x_valid)
142
+ me = np.mean(x_valid - y_valid)
143
+
144
+ corr = np.corrcoef(x_valid, y_valid)[0, 1] if len(x_valid) > 1 else np.nan
145
+ r2_corr = corr**2 if not np.isnan(corr) else np.nan
146
+
147
+ return me, mae, rmse, r2_corr
download_data.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from huggingface_hub import snapshot_download
2
+ import os
3
+ import traceback
4
+
5
+ cache_path = os.path.expanduser("~/.cache/huggingface/hub")
6
+
7
+ try:
8
+ print("Téléchargement des métadonnées...")
9
+ snapshot_download(
10
+ repo_id="ncsdecoopman/ExtremePrecipit",
11
+ repo_type="dataset",
12
+ revision="main",
13
+ local_dir="data",
14
+ cache_dir=cache_path,
15
+ allow_patterns=["metadonnees/*"]
16
+ )
17
+
18
+ print("Téléchargement des reliefs...")
19
+ snapshot_download(
20
+ repo_id="ncsdecoopman/ExtremePrecipit",
21
+ repo_type="dataset",
22
+ revision="main",
23
+ local_dir="data",
24
+ cache_dir=cache_path,
25
+ allow_patterns=["external/*"]
26
+ )
27
+
28
+ for echelle in ["quotidien", "horaire"]:
29
+ print(f"Téléchargement des statistiques AROMES (mod)... - Echelle {echelle}")
30
+ snapshot_download(
31
+ repo_id="ncsdecoopman/ExtremePrecipit",
32
+ repo_type="dataset",
33
+ revision="main",
34
+ local_dir="data",
35
+ cache_dir=cache_path,
36
+ allow_patterns=["statisticals/modelised*"]
37
+ )
38
+
39
+ print(f"Téléchargement des statistiques STATIONS observées... - Echelle {echelle}")
40
+ snapshot_download(
41
+ repo_id="ncsdecoopman/ExtremePrecipit",
42
+ repo_type="dataset",
43
+ revision="main",
44
+ local_dir="data",
45
+ cache_dir=cache_path,
46
+ allow_patterns=["statisticals/observed*"]
47
+ )
48
+
49
+ except Exception as e:
50
+ print("Erreur pendant le téléchargement :")
51
+ traceback.print_exc()
52
+ raise SystemExit(1)
main.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+
3
+ from app.utils.map_utils import plot_map
4
+ from app.utils.legends_utils import get_stat_unit
5
+
6
+ from app.pipelines.import_data import pipeline_data
7
+ from app.pipelines.import_config import pipeline_config
8
+ from app.pipelines.import_map import pipeline_map
9
+ from app.pipelines.import_scatter import pipeline_scatter
10
+ from app.utils.show_info import show_info_data, show_info_metric
11
+
12
+ st.set_page_config(layout="wide", page_title="Analyse interactive des précipitations en France (1959–2022)", page_icon="🌧️")
13
+ st.markdown("""
14
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap" rel="stylesheet">
15
+ """, unsafe_allow_html=True)
16
+
17
+ st.markdown("""
18
+ <style>
19
+ * {
20
+ font-size: 10px !important;
21
+ }
22
+
23
+ /* Responsive layout des colonnes */
24
+ @media screen and (max-width: 1000px) {
25
+ .element-container:has(> .stColumn) {
26
+ display: flex;
27
+ flex-wrap: wrap;
28
+ }
29
+
30
+ .element-container:has(> .stColumn) .stColumn {
31
+ width: 48% !important;
32
+ min-width: 48% !important;
33
+ }
34
+ }
35
+
36
+ @media screen and (max-width: 600px) {
37
+ .element-container:has(> .stColumn) .stColumn {
38
+ width: 100% !important;
39
+ min-width: 100% !important;
40
+ }
41
+ }
42
+ </style>
43
+ """, unsafe_allow_html=True)
44
+
45
+ css = """
46
+ <style>
47
+ /* -------------------- VARIABLES GLOBALES -------------------- */
48
+ :root{
49
+ --primary:#5A7BFF;
50
+ --primary-light:#8FA0FF;
51
+ --accent:#FF7A59;
52
+ --bg:rgba(245,247,250,0.65);
53
+ --card:rgba(255,255,255,0.35);
54
+ --text:#1F2D3D;
55
+ --text-light:#6B7C93;
56
+ --radius:18px;
57
+ --shadow:0 12px 28px rgba(0,0,0,.12);
58
+ --blur:18px;
59
+ --font:"Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
60
+ }
61
+
62
+ /* -------------------- RESET & BODY -------------------- */
63
+ html, body, [class*="stAppViewContainer"]{
64
+ font-family: var(--font) !important;
65
+ color: var(--text);
66
+ }
67
+ body{
68
+ background: linear-gradient(135deg,#EEF2FF 0%,#FDFBFF 60%,#F0F4FF 100%) fixed !important;
69
+ }
70
+
71
+ /* Conteneur principal */
72
+ .block-container{
73
+ padding-top: 2.5rem !important;
74
+ padding-bottom: 3rem !important;
75
+ max-width: 98%;
76
+ }
77
+
78
+ /* -------------------- EN-TÊTES -------------------- */
79
+ h1,h2,h3,h4{
80
+ font-weight: 600 !important;
81
+ letter-spacing: -0.01em;
82
+ color: var(--text);
83
+ }
84
+ h1{
85
+ font-size: 2.1rem !important;
86
+ margin-bottom: 1.2rem;
87
+ }
88
+
89
+ /* -------------------- CARTES / WIDGETS -------------------- */
90
+ section.main > div{
91
+ backdrop-filter: blur(var(--blur));
92
+ background: var(--card);
93
+ border-radius: var(--radius);
94
+ box-shadow: var(--shadow);
95
+ padding: 1.5rem 1.8rem;
96
+ }
97
+
98
+ /* -------------------- LABELS DES WIDGETS -------------------- */
99
+ .css-10trblm, .stSlider label, .stSelectbox label, .stNumberInput label, .stMultiSelect label{
100
+ font-size: 0.88rem !important;
101
+ font-weight: 500 !important;
102
+ color: var(--text-light) !important;
103
+ margin-bottom: .4rem !important;
104
+ text-transform: uppercase;
105
+ letter-spacing: .04em;
106
+ }
107
+
108
+ /* -------------------- SELECTBOX -------------------- */
109
+ .stSelectbox > div div[data-baseweb="select"]{
110
+ background: rgba(255,255,255,0.55);
111
+ border-radius: var(--radius) !important;
112
+ border: 1px solid rgba(0,0,0,.05);
113
+ box-shadow: inset 0 2px 4px rgba(0,0,0,.04);
114
+ }
115
+ .stSelectbox > div div[data-baseweb="select"]:hover{
116
+ border-color: var(--primary-light);
117
+ }
118
+ .stSelectbox svg{
119
+ stroke: var(--primary) !important;
120
+ }
121
+
122
+ /* -------------------- SLIDER -------------------- */
123
+ [data-testid="stSlider"] > div{
124
+ padding-top: .6rem;
125
+ }
126
+ [data-testid="stSlider"] [data-testid="stThumbValue"]{
127
+ background: var(--primary);
128
+ color: #fff;
129
+ border-radius: 10px;
130
+ padding: 2px 8px;
131
+ font-size: .75rem;
132
+ box-shadow: var(--shadow);
133
+ }
134
+ [data-testid="stSlider"] [data-testid="stTickBar"]{
135
+ background: rgba(0,0,0,.08);
136
+ }
137
+ [data-testid="stSlider"] [data-testid="stTrack"]{
138
+ background: rgba(0,0,0,.12);
139
+ }
140
+ [data-testid="stSlider"] [data-testid="stTrack"] > div{
141
+ background: var(--primary);
142
+ }
143
+
144
+ /* -------------------- INPUT NUMBER / TEXT -------------------- */
145
+ .stNumberInput input, .stTextInput input{
146
+ background: rgba(255,255,255,0.6) !important;
147
+ border-radius: var(--radius) !important;
148
+ border: 1px solid rgba(0,0,0,.05) !important;
149
+ box-shadow: inset 0 2px 4px rgba(0,0,0,.05) !important;
150
+ }
151
+
152
+ /* -------------------- BOUTON -------------------- */
153
+ .stButton>button{
154
+ background: var(--primary) !important;
155
+ color: #fff !important;
156
+ border: none !important;
157
+ border-radius: var(--radius) !important;
158
+ padding: .65rem 1.4rem !important;
159
+ font-weight: 600 !important;
160
+ letter-spacing: .02em;
161
+ transition: all .22s ease;
162
+ box-shadow: 0 8px 18px rgba(90,123,255,.28);
163
+ }
164
+ .stButton>button:hover{
165
+ background: var(--primary-light) !important;
166
+ transform: translateY(-2px) !important;
167
+ box-shadow: 0 12px 24px rgba(90,123,255,.32);
168
+ }
169
+ .stButton>button:active{
170
+ transform: translateY(0) scale(.98) !important;
171
+ }
172
+
173
+ /* Petit bouton (col6) */
174
+ [class*="stColumn"]:nth-child(7) .stButton>button{
175
+ padding: .55rem .9rem !important;
176
+ font-size: .85rem !important;
177
+ }
178
+
179
+ /* -------------------- TOOLTIPS -------------------- */
180
+ [data-baseweb="tooltip"]{
181
+ backdrop-filter: blur(12px);
182
+ background: rgba(0,0,0,.75);
183
+ color: #fff;
184
+ border-radius: 8px;
185
+ font-size: .75rem;
186
+ padding: .4rem .65rem;
187
+ }
188
+
189
+ /* -------------------- SIDEBAR -------------------- */
190
+ .sidebar .block-container{
191
+ padding: 1rem 1rem 2rem 1rem !important;
192
+ }
193
+ [class*="stSidebar"]{
194
+ background: rgba(255,255,255,0.7) !important;
195
+ backdrop-filter: blur(18px);
196
+ box-shadow: var(--shadow);
197
+ }
198
+
199
+ /* -------------------- CHARTS -------------------- */
200
+ .js-plotly-plot .plotly .main-svg{
201
+ border-radius: var(--radius);
202
+ box-shadow: var(--shadow);
203
+ }
204
+
205
+ /* -------------------- SCROLLBAR -------------------- */
206
+ ::-webkit-scrollbar{
207
+ width: 8px;
208
+ height: 8px;
209
+ }
210
+ ::-webkit-scrollbar-thumb{
211
+ background: var(--primary-light);
212
+ border-radius: 10px;
213
+ }
214
+ ::-webkit-scrollbar-track{
215
+ background: transparent;
216
+ }
217
+
218
+ /* -------------------- SLIDER -------------------- */
219
+ /* Cacher totalement les chiffres min/max + graduations */
220
+ [data-testid="stSliderTickBarMin"], [data-testid="stSliderTickBarMax"]{
221
+ display:none !important;
222
+ }
223
+
224
+ stSliderTickBarMin
225
+
226
+ /* -------------------- MASQUER MENU & FOOTER -------------------- */
227
+ #MainMenu{visibility:hidden;}
228
+ footer{visibility:hidden;}
229
+ header{visibility:hidden;}
230
+
231
+ /* -------------------- GRADIENT TEXT -------------------- */
232
+ .gradient-premium {
233
+ font-size: 2.5rem !important; /* Titre XXL */
234
+ font-weight: 800 !important; /* Plus de présence */
235
+ letter-spacing: -0.025em !important; /* Ajustement espacement */
236
+
237
+ /* Dégradé en trois couleurs */
238
+ background: linear-gradient(
239
+ 360deg,
240
+ #5A7BFF 10%,
241
+ #5A7BFF 100%,
242
+ #F0F4FF 150%
243
+ ) !important;
244
+ color: transparent !important;
245
+ -webkit-text-fill-color: transparent !important;
246
+ -webkit-background-clip: text !important;
247
+ background-clip: text !important;
248
+
249
+ /* contour/glow léger */
250
+ text-shadow:
251
+ 0 0 2px rgba(255,255,255,0.8)
252
+
253
+ display: inline-block;
254
+ }
255
+ </style>
256
+ """
257
+
258
+
259
+ st.markdown(css, unsafe_allow_html=True)
260
+
261
+
262
+ def show(
263
+ config_path: dict,
264
+ height: int=600
265
+ ):
266
+
267
+ # Chargement des config
268
+ params_config = pipeline_config(config_path, type="stat")
269
+ config = params_config["config"]
270
+ stat_choice = params_config["stat_choice"]
271
+ season_choice = params_config["season_choice"]
272
+ stat_choice_key = params_config["stat_choice_key"]
273
+ scale_choice_key = params_config["scale_choice_key"]
274
+ min_year_choice = params_config["min_year_choice"]
275
+ max_year_choice = params_config["max_year_choice"]
276
+ season_choice_key = params_config["season_choice_key"]
277
+ missing_rate = params_config["missing_rate"]
278
+ quantile_choice = params_config["quantile_choice"]
279
+ scale_choice = params_config["scale_choice"]
280
+ show_relief = params_config["show_relief"]
281
+ show_stations = params_config["show_stations"]
282
+
283
+ # Préparation des paramètres pour pipeline_data
284
+ params_load = (
285
+ stat_choice_key,
286
+ scale_choice_key,
287
+ min_year_choice,
288
+ max_year_choice,
289
+ season_choice_key,
290
+ missing_rate,
291
+ quantile_choice,
292
+ scale_choice
293
+ )
294
+
295
+ # Obtention des données
296
+ result = pipeline_data(params_load, config, use_cache=True)
297
+
298
+ # Chargement des affichages graphiques
299
+ unit_label = get_stat_unit(stat_choice_key, scale_choice_key)
300
+ params_map = (
301
+ stat_choice_key,
302
+ result,
303
+ unit_label,
304
+ height
305
+ )
306
+ layer, scatter_layer, tooltip, view_state, html_legend = pipeline_map(params_map)
307
+
308
+ col1, col2, col3 = st.columns([1, 0.15, 1])
309
+
310
+ with col1:
311
+ scatter_layer = None if not show_stations else scatter_layer
312
+ deck = plot_map([layer, scatter_layer], view_state, tooltip, activate_relief=show_relief)
313
+ st.markdown(
314
+ f"""
315
+ <div style='text-align: left; margin-bottom: 10px;'>
316
+ <b>{stat_choice} des précipitations de {min_year_choice} à {max_year_choice} ({season_choice.lower()})</b>
317
+ </div>
318
+ """,
319
+ unsafe_allow_html=True
320
+ )
321
+ if deck:
322
+ st.pydeck_chart(deck, use_container_width=True, height=height)
323
+
324
+ with col2:
325
+ st.markdown(html_legend, unsafe_allow_html=True)
326
+
327
+ with col3:
328
+ params_scatter = (
329
+ result,
330
+ stat_choice_key,
331
+ scale_choice_key,
332
+ stat_choice,unit_label,
333
+ height
334
+ )
335
+ n_tot_mod, n_tot_obs, me, mae, rmse, r2, scatter = pipeline_scatter(params_scatter)
336
+
337
+ st.markdown(
338
+ """
339
+ <div style='text-align: left; font-size: 0.8em; color: grey; margin-top: 0px;'>
340
+ Données CP-RCM, 2.5 km, forçage ERA5, réanalyse ECMWF
341
+ </div>
342
+ """,
343
+ unsafe_allow_html=True
344
+ )
345
+ st.plotly_chart(scatter, use_container_width=True)
346
+
347
+ col0bis, col1bis, col2bis, col3bis, col4bis, col5bis, col6bis = st.columns(7)
348
+
349
+ show_info_data(col0bis, "CP-AROME map", result["modelised_show"].shape[0], n_tot_mod)
350
+ show_info_data(col1bis, "Stations", result["observed_show"].shape[0], n_tot_obs)
351
+ show_info_data(col2bis, "CP-AROME plot", result["modelised"].shape[0], n_tot_mod)
352
+ show_info_metric(col3bis, "ME", me)
353
+ show_info_metric(col4bis, "MAE", mae)
354
+ show_info_metric(col5bis, "RMSE", rmse)
355
+ show_info_metric(col6bis, "r²", r2)
356
+
357
+ if __name__ == "__main__":
358
+ config_path = "app/config/config.yaml"
359
+ st.markdown("""
360
+ <div style="text-align: center; margin-bottom: 2rem;">
361
+ <h1 style="
362
+ font-family: var(--font);
363
+ margin: 0;
364
+ ">
365
+ <span class="gradient-premium">
366
+ Analyse interactive des précipitations en France — 1959 – 2022
367
+ </span>
368
+ </h1>
369
+ </div>
370
+ """, unsafe_allow_html=True)
371
+
372
+ show(config_path)
requirements.txt ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core UI and plotting
2
+ streamlit==1.43.2
3
+ pydeck==0.9.1
4
+ matplotlib==3.10.0
5
+ plotly==5.24.1
6
+ streamlit_plotly_events==0.0.6
7
+ streamlit_folium==0.24.0
8
+ geopandas
9
+
10
+ # Data manipulation
11
+ pandas==2.2.3
12
+ polars==1.26.0
13
+ numpy==2.2.2
14
+ scipy==1.15.1
15
+
16
+ # File and compression utils
17
+ huggingface_hub==0.29.2
18
+ pyyaml==6.0.2
19
+
20
+ # Calculs
21
+ scikit-learn==1.6.1