diff --git a/docs/03 - Premiers graphiques/figures/humidity_overview.png b/docs/03 - Premiers graphiques/figures/humidity_overview.png new file mode 100644 index 0000000..9153f73 Binary files /dev/null and b/docs/03 - Premiers graphiques/figures/humidity_overview.png differ diff --git a/docs/03 - Premiers graphiques/figures/illuminance_overview.png b/docs/03 - Premiers graphiques/figures/illuminance_overview.png new file mode 100644 index 0000000..cbf826c Binary files /dev/null and b/docs/03 - Premiers graphiques/figures/illuminance_overview.png differ diff --git a/docs/03 - Premiers graphiques/figures/pressure_overview.png b/docs/03 - Premiers graphiques/figures/pressure_overview.png new file mode 100644 index 0000000..6bc7875 Binary files /dev/null and b/docs/03 - Premiers graphiques/figures/pressure_overview.png differ diff --git a/docs/03 - Premiers graphiques/figures/rain_rate_overview.png b/docs/03 - Premiers graphiques/figures/rain_rate_overview.png new file mode 100644 index 0000000..ed7c251 Binary files /dev/null and b/docs/03 - Premiers graphiques/figures/rain_rate_overview.png differ diff --git a/docs/03 - Premiers graphiques/figures/sun_elevation_overview.png b/docs/03 - Premiers graphiques/figures/sun_elevation_overview.png new file mode 100644 index 0000000..79644b1 Binary files /dev/null and b/docs/03 - Premiers graphiques/figures/sun_elevation_overview.png differ diff --git a/docs/03 - Premiers graphiques/figures/temperature_overview.png b/docs/03 - Premiers graphiques/figures/temperature_overview.png new file mode 100644 index 0000000..6028da1 Binary files /dev/null and b/docs/03 - Premiers graphiques/figures/temperature_overview.png differ diff --git a/docs/03 - Premiers graphiques/figures/wind_direction_overview.png b/docs/03 - Premiers graphiques/figures/wind_direction_overview.png new file mode 100644 index 0000000..4ca6f7f Binary files /dev/null and b/docs/03 - Premiers graphiques/figures/wind_direction_overview.png differ diff --git a/docs/03 - Premiers graphiques/figures/wind_speed_overview.png b/docs/03 - Premiers graphiques/figures/wind_speed_overview.png new file mode 100644 index 0000000..66238a9 Binary files /dev/null and b/docs/03 - Premiers graphiques/figures/wind_speed_overview.png differ diff --git a/docs/03 - Premiers graphiques/index.md b/docs/03 - Premiers graphiques/index.md index b560ec9..32b44ab 100644 --- a/docs/03 - Premiers graphiques/index.md +++ b/docs/03 - Premiers graphiques/index.md @@ -4,27 +4,29 @@ On peut désormais tracer nos premiers graphiques simples et bruts. S'ils ne sont pas très instructifs par rapport à ce que nous fournissent Home Assistant et InfluxDB, ils nous permettent au moins de nous assurer que tout fonctionne, et que les données semblent cohérentes. Les fichiers CSV correspondant à chaque figure sont conservés dans `data/` dans ce dossier. -On se limite dans un premier temps aux 7 derniers jours. +Les graphiques couvrent maintenant toute la période disponible dans `data/weather_minutely.csv`. +Une agrégation automatique réduit le nombre de points pour rester lisible (plus de courbes "peignes"), et l'axe des dates utilise un format compact qui évite tout chevauchement de labels. +On peut au besoin restreindre la période avec `--days` ou imposer une fréquence d'agrégation avec `--resample`. ```shell python "docs/03 - Premiers graphiques/scripts/plot_basic_variables.py" ``` -![](figures/temperature_last_7_days.png) +![](figures/temperature_overview.png) -![](figures/pressure_last_7_days.png) +![](figures/pressure_overview.png) -![](figures/humidity_last_7_days.png) +![](figures/humidity_overview.png) -![](figures/rain_rate_last_7_days.png) +![](figures/rain_rate_overview.png) -![](figures/illuminance_last_7_days.png) +![](figures/wind_speed_overview.png) -![](figures/wind_speed_last_7_days.png) +![](figures/wind_direction_overview.png) -![](figures/wind_direction_last_7_days.png) +![](figures/illuminance_overview.png) -![](figures/sun_elevation_last_7_days.png) +![](figures/sun_elevation_overview.png) ## Vues calendrier diff --git a/docs/03 - Premiers graphiques/scripts/plot_basic_variables.py b/docs/03 - Premiers graphiques/scripts/plot_basic_variables.py index f08aa02..0d323e7 100644 --- a/docs/03 - Premiers graphiques/scripts/plot_basic_variables.py +++ b/docs/03 - Premiers graphiques/scripts/plot_basic_variables.py @@ -1,5 +1,5 @@ # scripts/plot_basic_variables.py -"""Génère des séries temporelles simples (7 jours) pour chaque variable météo.""" +"""Génère des séries temporelles simples pour chaque variable météo.""" from __future__ import annotations @@ -7,7 +7,6 @@ import argparse from pathlib import Path import sys -import matplotlib.pyplot as plt import pandas as pd @@ -16,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from meteo.dataset import load_raw_csv -from meteo.plots import export_plot_dataset +from meteo.plots import PlotChoice, PlotStyle, plot_basic_series, recommended_style, resample_series_for_plot from meteo.variables import Variable, VARIABLES @@ -25,47 +24,32 @@ DOC_DIR = Path(__file__).resolve().parent.parent DEFAULT_OUTPUT_DIR = DOC_DIR / "figures" -def _prepare_slice(df: pd.DataFrame, *, last_days: int) -> pd.DataFrame: - """Extrait la fenêtre temporelle souhaitée et applique une moyenne horaire pour lisser la courbe.""" +def _select_window(df: pd.DataFrame, *, last_days: int | None) -> pd.DataFrame: + """Extrait la fenêtre temporelle souhaitée (ou la totalité si None).""" + if last_days is None: + return df end = df.index.max() start = end - pd.Timedelta(days=last_days) - df_slice = df.loc[start:end] - numeric_slice = df_slice.select_dtypes(include="number") - if numeric_slice.empty: - raise RuntimeError("Aucune colonne numérique disponible pour les moyennes horaires.") - return numeric_slice.resample("1h").mean() + return df.loc[start:end] -def _plot_variable(df_hourly: pd.DataFrame, var: Variable, output_dir: Path) -> Path | None: - """Trace la série pour une variable et retourne le chemin de l'image générée.""" - - if var.column not in df_hourly.columns: - print(f"⚠ Colonne absente pour {var.key} ({var.column}).") - return None - - series = df_hourly[var.column].dropna() - if series.empty: - print(f"⚠ Aucun point valide pour {var.key} dans l'intervalle choisi.") - return None - - output_dir.mkdir(parents=True, exist_ok=True) - output_path = output_dir / f"{var.key}_last_7_days.png" - - export_plot_dataset(series.to_frame(name=var.column), output_path) - - plt.figure() - plt.plot(series.index, series) - plt.xlabel("Temps (UTC)") +def _format_ylabel(var: Variable) -> str: unit_text = f" ({var.unit})" if var.unit else "" - plt.ylabel(f"{var.label}{unit_text}") - plt.title(f"{var.label} - Moyenne horaire sur les 7 derniers jours") - plt.grid(True) - plt.tight_layout() - plt.savefig(output_path, dpi=150) - plt.close() - print(f"✔ Graphique généré : {output_path}") - return output_path + return f"{var.label}{unit_text}" + + +def _aggregation_label(choice: PlotChoice, freq: str) -> str: + """Texte court pour indiquer l'agrégation appliquée.""" + + base = "moyenne" + if callable(choice.agg) and getattr(choice.agg, "__name__", "") == "_circular_mean_deg": + base = "moyenne circulaire" + elif choice.agg == "sum": + base = "somme" + elif choice.agg == "median": + base = "médiane" + return f"{base} {freq}" def main(argv: list[str] | None = None) -> None: @@ -78,8 +62,23 @@ def main(argv: list[str] | None = None) -> None: parser.add_argument( "--days", type=int, - default=7, - help="Nombre de jours à afficher (par défaut : 7).", + default=None, + help="Nombre de jours à afficher (par défaut : toute la période disponible).", + ) + parser.add_argument( + "--style", + choices=[style.value for style in PlotStyle], + help="Style de représentation à utiliser pour toutes les variables (par défaut : recommandations par variable).", + ) + parser.add_argument( + "--resample", + help="Fréquence pandas à utiliser pour l'agrégation temporelle (par défaut : calcul automatique).", + ) + parser.add_argument( + "--max-points", + type=int, + default=420, + help="Nombre de points cible après agrégation automatique (par défaut : 420).", ) parser.add_argument( "--output-dir", @@ -93,7 +92,7 @@ def main(argv: list[str] | None = None) -> None: raise FileNotFoundError(f"Dataset introuvable : {CSV_PATH}") df = load_raw_csv(CSV_PATH) - df_hourly = _prepare_slice(df, last_days=args.days) + df_window = _select_window(df, last_days=args.days) selected: list[Variable] if args.only: @@ -105,8 +104,44 @@ def main(argv: list[str] | None = None) -> None: else: selected = list(VARIABLES) + output_dir: Path = args.output_dir + output_dir.mkdir(parents=True, exist_ok=True) + for variable in selected: - _plot_variable(df_hourly, variable, args.output_dir) + if variable.column not in df_window.columns: + print(f"⚠ Colonne absente pour {variable.key} ({variable.column}).") + continue + + series = df_window[variable.column].dropna() + if series.empty: + print(f"⚠ Aucun point valide pour {variable.key} sur la période choisie.") + continue + + style_choice = recommended_style(variable, args.style) + + aggregated, freq_used = resample_series_for_plot( + series, + variable=variable, + freq=args.resample, + target_points=args.max_points, + ) + if aggregated.empty: + print(f"⚠ Pas de points après agrégation pour {variable.key}.") + continue + + output_path = output_dir / f"{variable.key}_overview.png" + annotate_freq = _aggregation_label(style_choice, freq_used) + + plot_basic_series( + aggregated, + variable=variable, + output_path=output_path, + style=style_choice.style, + title=f"{variable.label} — évolution temporelle", + ylabel=_format_ylabel(variable), + annotate_freq=annotate_freq, + ) + print(f"✔ Graphique généré : {output_path}") if __name__ == "__main__": diff --git a/meteo/plots/__init__.py b/meteo/plots/__init__.py index 07e2f64..e48fc41 100644 --- a/meteo/plots/__init__.py +++ b/meteo/plots/__init__.py @@ -22,6 +22,13 @@ from .relationships import ( plot_pairwise_relationship_grid, plot_scatter_pair, ) +from .basic_series import ( + PlotChoice, + PlotStyle, + plot_basic_series, + recommended_style, + resample_series_for_plot, +) from .seasonal_profiles import ( plot_daylight_hours, plot_diurnal_cycle, @@ -54,6 +61,11 @@ __all__ = [ "plot_hexbin_with_third_variable", "plot_pairwise_relationship_grid", "plot_scatter_pair", + "PlotChoice", + "PlotStyle", + "plot_basic_series", + "recommended_style", + "resample_series_for_plot", "plot_daylight_hours", "plot_diurnal_cycle", "plot_seasonal_hourly_profiles", diff --git a/meteo/plots/basic_series.py b/meteo/plots/basic_series.py new file mode 100644 index 0000000..c039dae --- /dev/null +++ b/meteo/plots/basic_series.py @@ -0,0 +1,232 @@ +"""Tracés simples et réutilisables pour les séries temporelles de base.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Callable + +import matplotlib.dates as mdates +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +from meteo.dataset import _circular_mean_deg +from meteo.variables import Variable + +from .base import export_plot_dataset + +__all__ = [ + "PlotStyle", + "PlotChoice", + "recommended_style", + "resample_series_for_plot", + "plot_basic_series", +] + + +class PlotStyle(str, Enum): + LINE = "line" + AREA = "area" + BAR = "bar" + SCATTER = "scatter" + + +@dataclass(frozen=True) +class PlotChoice: + """Configuration par variable : style et fonction d'agrégation.""" + + style: PlotStyle + agg: Callable[[pd.Series], float] | str = "mean" + + +DEFAULT_CHOICES: dict[str, PlotChoice] = { + # Variations continues : lignes ou aires. + "temperature": PlotChoice(PlotStyle.LINE, "mean"), + "pressure": PlotChoice(PlotStyle.LINE, "mean"), + "humidity": PlotChoice(PlotStyle.AREA, "mean"), + "illuminance": PlotChoice(PlotStyle.AREA, "mean"), + "sun_elevation": PlotChoice(PlotStyle.AREA, "mean"), + # Variables dont la perception bénéficie d'autres représentations. + "rain_rate": PlotChoice(PlotStyle.BAR, "mean"), + "wind_speed": PlotChoice(PlotStyle.LINE, "mean"), + "wind_direction": PlotChoice(PlotStyle.SCATTER, _circular_mean_deg), +} + +# Palette douce mais contrastée, associée aux variables. +PALETTE = { + "temperature": "#d1495b", + "pressure": "#5c677d", + "humidity": "#2c7bb6", + "rain_rate": "#1b9e77", + "illuminance": "#f4a259", + "wind_speed": "#118ab2", + "wind_direction": "#8e6c8a", + "sun_elevation": "#f08c42", +} +DEFAULT_COLOR = "#386cb0" + + +def recommended_style(variable: Variable, override: str | None = None) -> PlotChoice: + """Retourne le style/agrégation par défaut, ou une surcharge utilisateur.""" + + if override: + style = PlotStyle(override) + agg = DEFAULT_CHOICES.get(variable.key, PlotChoice(style)).agg + return PlotChoice(style, agg) + return DEFAULT_CHOICES.get(variable.key, PlotChoice(PlotStyle.LINE)) + + +def _nice_frequencies() -> list[tuple[str, pd.Timedelta]]: + return [ + ("5min", pd.Timedelta(minutes=5)), + ("10min", pd.Timedelta(minutes=10)), + ("15min", pd.Timedelta(minutes=15)), + ("30min", pd.Timedelta(minutes=30)), + ("1h", pd.Timedelta(hours=1)), + ("3h", pd.Timedelta(hours=3)), + ("6h", pd.Timedelta(hours=6)), + ("12h", pd.Timedelta(hours=12)), + ("1d", pd.Timedelta(days=1)), + ("3d", pd.Timedelta(days=3)), + ("7d", pd.Timedelta(days=7)), + ] + + +def _auto_resample_frequency(index: pd.DatetimeIndex, *, target_points: int = 420) -> str: + """Choisit une fréquence qui limite le nombre de points tout en conservant la forme générale.""" + + if index.empty or len(index) < 2: + return "1h" + + span = index.max() - index.min() + if span <= pd.Timedelta(0): + return "1h" + + for label, delta in _nice_frequencies(): + if span / delta <= target_points: + return label + + return _nice_frequencies()[-1][0] + + +def _format_time_axis(ax: plt.Axes) -> None: + locator = mdates.AutoDateLocator(minticks=4, maxticks=8) + formatter = mdates.ConciseDateFormatter(locator, formats=["%Y", "%b", "%d", "%d %H:%M", "%H:%M", "%S"]) + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(formatter) + + +def _infer_bar_width(index: pd.DatetimeIndex) -> float: + """ + Calcule une largeur de barre raisonnable (en jours) pour les histogrammes temporels. + """ + + if len(index) < 2: + return 0.3 # ~7 heures, pour rendre le point visible même isolé + + diffs = np.diff(index.asi8) # nanosecondes + median_ns = float(np.median(diffs)) + if not np.isfinite(median_ns) or median_ns <= 0: + return 0.1 + return pd.to_timedelta(median_ns, unit="ns") / pd.Timedelta(days=1) * 0.8 + + +def _ensure_datetime_index(series: pd.Series) -> pd.Series: + if not isinstance(series.index, pd.DatetimeIndex): + raise TypeError("Une série temporelle (DatetimeIndex) est attendue pour le tracé.") + return series + + +def _series_color(variable: Variable) -> str: + if variable.key in PALETTE: + return PALETTE[variable.key] + return PALETTE.get(variable.column, DEFAULT_COLOR) + + +def resample_series_for_plot( + series: pd.Series, + *, + variable: Variable, + freq: str | None = None, + target_points: int = 420, +) -> tuple[pd.Series, str]: + """ + Prépare une série pour l'affichage : resample et agrégation adaptés à la variable. + """ + + _ensure_datetime_index(series) + + if freq is None: + freq = _auto_resample_frequency(series.index, target_points=target_points) + + agg_func = DEFAULT_CHOICES.get(variable.key, PlotChoice(PlotStyle.LINE)).agg + resampled = series.resample(freq).agg(agg_func).dropna() + return resampled, freq + + +def plot_basic_series( + series: pd.Series, + *, + variable: Variable, + output_path: str | Path, + style: PlotStyle, + title: str, + ylabel: str, + annotate_freq: str | None = None, +) -> Path: + """ + Trace une série temporelle avec un style simple (ligne, aire, barres, nuage de points). + """ + + _ensure_datetime_index(series) + + if series.empty: + raise ValueError(f"Aucune donnée disponible pour {variable.key} après filtrage.") + + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + color = _series_color(variable) + x = mdates.date2num(series.index) + values = series.to_numpy(dtype=float) + + fig, ax = plt.subplots(figsize=(11, 4.2)) + if style is PlotStyle.LINE: + ax.plot_date(x, values, "-", linewidth=1.8, color=color, label=variable.label) + elif style is PlotStyle.AREA: + ax.fill_between(x, values, step="mid", color=color, alpha=0.2) + ax.plot_date(x, values, "-", linewidth=1.6, color=color) + elif style is PlotStyle.BAR: + width = _infer_bar_width(series.index) + ax.bar(x, values, width=width, color=color, edgecolor=color, linewidth=0.5, alpha=0.85) + elif style is PlotStyle.SCATTER: + ax.scatter(x, values, s=16, color=color, alpha=0.9) + else: + raise ValueError(f"Style inconnu : {style}") + + ax.set_title(title) + ax.set_ylabel(ylabel) + _format_time_axis(ax) + ax.grid(True, color="#e0e0e0", linewidth=0.8, alpha=0.7) + ax.margins(x=0.02, y=0.05) + + if annotate_freq: + ax.text( + 0.99, + 0.02, + f"Agrégation : {annotate_freq}", + transform=ax.transAxes, + ha="right", + va="bottom", + fontsize=9, + color="#555555", + ) + + fig.tight_layout() + fig.savefig(output_path, dpi=150) + plt.close(fig) + + export_plot_dataset(series.to_frame(name=variable.column), output_path) + return output_path.resolve()