""" ERA5 meteorological reanalysis data loader (SAFRAN alternative). Single Responsibility: Load and parse ERA5 data with spatial interpolation. """ import pandas as pd import xarray as xr import numpy as np import matplotlib.pyplot as plt import matplotlib.dates as mdates from pathlib import Path from typing import Optional, List from .base import BaseDataLoader class SAFRANLoader(BaseDataLoader): """ Loads ERA5 reanalysis data (used as SAFRAN alternative). Handles actual ERA5 NetCDF structure: - Gridded at 0.25° resolution - Variables: t2m, tp, pev, ssrd, u10, v10, sf, ro - Interpolates to station points """ # Variable mapping VAR_MAP = { 't2m': 'temp_2m_K', 'tp': 'precip_m', 'pev': 'evap_m', 'ssrd': 'solar_Jm2', 'u10': 'wind_u_ms', 'v10': 'wind_v_ms', 'sf': 'snow_m', 'ro': 'runoff_m' } def __init__( self, data_path: Path, station_coords: Optional[pd.DataFrame] = None, interp_method: str = "nearest" ): """ Initialize ERA5 loader. Args: data_path: Path to datasets/safran directory station_coords: DataFrame with [station_code, lat, lon] interp_method: "nearest" or "linear" """ super().__init__(data_path) self.station_coords = station_coords self.interp_method = interp_method def load(self) -> pd.DataFrame: """ Load ERA5 data and interpolate to stations. Returns: DataFrame with [date, station_code, temp_2m_K, precip_m, ...] """ nc_files = sorted(Path(self.data_path).glob("era5_*.nc")) if not nc_files: raise FileNotFoundError(f"No ERA5 files in {self.data_path}") all_dfs = [] for nc_file in nc_files: df = self._load_and_interpolate(nc_file) all_dfs.append(df) combined = pd.concat(all_dfs, ignore_index=True) return combined.sort_values(['date', 'station_code']).reset_index(drop=True) def _load_and_interpolate(self, nc_file: Path) -> pd.DataFrame: """ Load single NetCDF and interpolate to ALL stations in one vectorized xarray selection, not one .sel() + .to_dataframe() call per station. The original per-station loop does real work with meaningful per-call overhead (an index lookup, then a full DataFrame conversion) once per station PER FILE. At 27 stations across ~67 year-files (1960-2026) that's ~1,800 calls -- slow but tolerable. At the reach graph's ~2,900 nodes it's ~193,000 individual calls, which is what was actually hanging, not a proportionally-worse runtime. Fix: xarray's vectorized ("pointwise") indexing -- when the latitude/longitude indexers are DataArrays sharing a common dimension name ("station" here), a single .sel() call looks up every point at once instead of one point per call, producing a result with a "station" dimension instead of separate latitude/longitude dimensions. This turns ~193,000 calls into ~67 (one per file). CAVEAT -- I could not actually run this against xarray/netCDF4 in this environment (no network access to install them here), so this is reasoned from documented xarray API behavior, not verified execution like the rest of this project's fixes have been. Please test carefully, ideally against a small subset of the era5_*.nc files first, and confirm the output matches the old per-station-loop version's shape/values before trusting it for real training data. """ ds = xr.open_dataset(nc_file) time_coord = 'valid_time' if 'valid_time' in ds.coords else 'time' station_lats = xr.DataArray(self.station_coords['lat'].values, dims='station') station_lons = xr.DataArray(self.station_coords['lon'].values, dims='station') station_codes = self.station_coords['station_code'].values point_ds = ds.sel(latitude=station_lats, longitude=station_lons, method=self.interp_method) # point_ds now has a 'station' dimension (length n_stations) instead # of separate latitude/longitude dimensions -- attach real station # codes as a coordinate so they survive the to_dataframe() below, # rather than staying as anonymous integer positions. point_ds = point_ds.assign_coords(station=station_codes) df = point_ds.to_dataframe().reset_index() df = df.rename(columns={time_coord: 'date', 'station': 'station_code', **self.VAR_MAP}) keep_cols = ['date', 'station_code'] + [v for v in self.VAR_MAP.values() if v in df.columns] df = df[keep_cols].copy() df['date'] = pd.to_datetime(df['date']).dt.tz_localize(None) ds.close() return df @staticmethod def convert_units(df: pd.DataFrame) -> pd.DataFrame: """Convert ERA5 units to standard (°C, mm, W/m²).""" df = df.copy() # K → °C if 'temp_2m_K' in df.columns: df['temp_C'] = df['temp_2m_K'] - 273.15 # m → mm for var in ['precip_m', 'evap_m', 'snow_m', 'runoff_m']: if var in df.columns: df[var.replace('_m', '_mm')] = df[var] * 1000 # J/m² → W/m² if 'solar_Jm2' in df.columns: df['solar_Wm2'] = df['solar_Jm2'] / 86400 # Wind speed if 'wind_u_ms' in df.columns and 'wind_v_ms' in df.columns: df['wind_speed_ms'] = np.sqrt(df['wind_u_ms']**2 + df['wind_v_ms']**2) return df def get_metadata(self) -> dict: """Get ERA5 metadata.""" meta = super().get_metadata() meta.update({ "data_type": "era5_reanalysis", "source": "Copernicus CDS", "resolution": "0.25 degrees", "interp_method": self.interp_method }) return meta def plot_temperature( self, df: Optional[pd.DataFrame] = None, stations: Optional[List[str]] = None, figsize: tuple = (12, 5), save_path: Optional[Path] = None, ) -> plt.Axes: """ Plot 2m air temperature time series, one line per station. Args: df: Optional pre-loaded DataFrame (raw or already converted). If not provided, data is loaded and unit-converted from disk. stations: Optional list of station codes to plot. Defaults to all. figsize: Figure size in inches. save_path: If provided, saves the figure to this path. Returns: The matplotlib Axes object. """ if df is None: df = self.convert_units(self.load()) elif "temp_C" not in df.columns and "temp_2m_K" in df.columns: df = self.convert_units(df) plot_df = df.dropna(subset=["temp_C"]) if stations: plot_df = plot_df[plot_df["station_code"].isin(stations)] if plot_df.empty: raise ValueError("No temperature data available to plot") fig, ax = plt.subplots(figsize=figsize) for station_code, group in plot_df.groupby("station_code"): group = group.sort_values("date") ax.plot(group["date"], group["temp_C"], linewidth=1, label=station_code) ax.set_title("2m Air Temperature") ax.set_xlabel("Date") ax.set_ylabel("Temperature (°C)") ax.xaxis.set_major_locator(mdates.AutoDateLocator()) ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(ax.xaxis.get_major_locator())) ax.legend(title="Station", fontsize=8) ax.grid(True, alpha=0.3) plt.tight_layout() if save_path: plt.savefig(save_path, dpi=150, bbox_inches="tight") print(f"Plot saved to {save_path}") return ax def plot_precipitation( self, df: Optional[pd.DataFrame] = None, stations: Optional[List[str]] = None, cumulative: bool = True, figsize: tuple = (12, 5), save_path: Optional[Path] = None, ) -> plt.Axes: """ Plot precipitation, one line per station. Cumulative by default, which makes it easy to compare total rainfall received over the record and spot where stations diverge. Args: df: Optional pre-loaded DataFrame (raw or already converted). If not provided, data is loaded and unit-converted from disk. stations: Optional list of station codes to plot. Defaults to all. cumulative: If True, plot the running cumulative total (mm). If False, plot the raw daily/timestep values. figsize: Figure size in inches. save_path: If provided, saves the figure to this path. Returns: The matplotlib Axes object. """ if df is None: df = self.convert_units(self.load()) elif "precip_mm" not in df.columns and "precip_m" in df.columns: df = self.convert_units(df) plot_df = df.dropna(subset=["precip_mm"]) if stations: plot_df = plot_df[plot_df["station_code"].isin(stations)] if plot_df.empty: raise ValueError("No precipitation data available to plot") fig, ax = plt.subplots(figsize=figsize) for station_code, group in plot_df.groupby("station_code"): group = group.sort_values("date") values = group["precip_mm"].cumsum() if cumulative else group["precip_mm"] ax.plot(group["date"], values, linewidth=1, label=station_code) ax.set_title("Cumulative Precipitation" if cumulative else "Precipitation") ax.set_xlabel("Date") ax.set_ylabel("Cumulative precipitation (mm)" if cumulative else "Precipitation (mm)") ax.xaxis.set_major_locator(mdates.AutoDateLocator()) ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(ax.xaxis.get_major_locator())) ax.legend(title="Station", fontsize=8) ax.grid(True, alpha=0.3) plt.tight_layout() if save_path: plt.savefig(save_path, dpi=150, bbox_inches="tight") print(f"Plot saved to {save_path}") return ax