From c32971f53e06b5aab5b77bbb21a83c9ef68eb78e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Jun 2026 08:19:12 +0000 Subject: [PATCH 01/11] feat(models): add EPT2_HELIOS and EPT2_EUROPA with meta; feat(variables): add 30min ssrd/fdir variables; docs: note 30min resolution for Helios in meta Co-authored-by: mroberto166 --- src/jua/weather/_model_meta.py | 19 +++++++++++++++++++ src/jua/weather/models.py | 2 ++ src/jua/weather/variables.py | 8 ++++++++ 3 files changed, 29 insertions(+) diff --git a/src/jua/weather/_model_meta.py b/src/jua/weather/_model_meta.py index 434497e..e2b6181 100644 --- a/src/jua/weather/_model_meta.py +++ b/src/jua/weather/_model_meta.py @@ -153,6 +153,25 @@ class ModelMetaInfo: full_forecasted_hours=480, temporal_resolution=TemporalResolution(base=6, special=((1, 0, 10 * 24),)), ) +_MODEL_META_INFO[Models.EPT2_HELIOS] = ModelMetaInfo( + has_grid_access=True, + forecast_name_mapping="ept-2-helios", + full_forecasted_hours=480, + has_forecast_file_access=True, + num_lats=2160, + num_lons=4320, + forecasts_per_day=48, # 30-minute update frequency + temporal_resolution=TemporalResolution(base=1), # 30-min steps handled by API +) +_MODEL_META_INFO[Models.EPT2_EUROPA] = ModelMetaInfo( + has_grid_access=True, + forecast_name_mapping="ept-2-europa", + full_forecasted_hours=480, + has_forecast_file_access=True, + num_lats=2160, + num_lons=4320, + temporal_resolution=TemporalResolution(base=6, special=((1, 0, 10 * 24),)), +) _MODEL_META_INFO[Models.AIFS] = ModelMetaInfo( has_grid_access=True, forecast_name_mapping="aifs", diff --git a/src/jua/weather/models.py b/src/jua/weather/models.py index 97cc15f..e6d3e5b 100644 --- a/src/jua/weather/models.py +++ b/src/jua/weather/models.py @@ -27,6 +27,8 @@ class Models(str, Enum): EPT2_HRRR = "ept2_hrrr" EPT2_RR = "ept2_rr" EPT2_REASONING = "ept2_reasoning" + EPT2_HELIOS = "ept2_helios" + EPT2_EUROPA = "ept2_europa" AIFS = "aifs" AURORA = "aurora" ECMWF_IFS_SINGLE = "ecmwf_ifs_single" diff --git a/src/jua/weather/variables.py b/src/jua/weather/variables.py index 94b419a..e2982cf 100644 --- a/src/jua/weather/variables.py +++ b/src/jua/weather/variables.py @@ -200,10 +200,18 @@ class Variables(Enum): "surface_downwelling_shortwave_flux_sum_1h", "J / m^2", "ssrd", None ) + SURFACE_DOWNWELLING_SHORTWAVE_FLUX_SUM_30MIN = Variable( + "surface_downwelling_shortwave_flux_sum_30min", "J / m^2", None, None + ) + SURFACE_DIRECT_DOWNWELLING_SHORTWAVE_FLUX_SUM_1H = Variable( "surface_direct_downwelling_shortwave_flux_sum_1h", "J / m^2", "fdir", None ) + SURFACE_DIRECT_DOWNWELLING_SHORTWAVE_FLUX_SUM_30MIN = Variable( + "surface_direct_downwelling_shortwave_flux_sum_30min", "J / m^2", None, None + ) + SURFACE_NET_DOWNWARD_SHORTWAVE_FLUX_SUM_1H = Variable( "surface_net_downward_shortwave_flux_sum_1h", "J / m^2", "ssr", None ) From 9f314fb72158e961e22775c3bbf05f48b1083d2d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Jun 2026 08:20:39 +0000 Subject: [PATCH 02/11] docs(changelog): note addition of EPT2 Helios/Europa and 30min ssrd/fdir Co-authored-by: mroberto166 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60c600c..81c3024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## Unreleased + +### Feat + +- models: add EPT2_HELIOS and EPT2_EUROPA +- variables: add 30min SSWR (ssrd) and FDIR variants + ## v0.26.0 (2026-05-28) ### Feat From 7dc4d2aa6164f9e1031b5d06e6e0e77dd38dcfeb Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:49:27 +0200 Subject: [PATCH 03/11] fix: fix models name --- src/jua/weather/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jua/weather/models.py b/src/jua/weather/models.py index e6d3e5b..c0086a9 100644 --- a/src/jua/weather/models.py +++ b/src/jua/weather/models.py @@ -27,8 +27,8 @@ class Models(str, Enum): EPT2_HRRR = "ept2_hrrr" EPT2_RR = "ept2_rr" EPT2_REASONING = "ept2_reasoning" - EPT2_HELIOS = "ept2_helios" - EPT2_EUROPA = "ept2_europa" + EPT2_HELIOS = "ept2_1_helios" + EPT2_EUROPA = "ept2_1_europa" AIFS = "aifs" AURORA = "aurora" ECMWF_IFS_SINGLE = "ecmwf_ifs_single" From db46c4f49e24e30aa3fbd539eb67e34e6d38084c Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:49:54 +0200 Subject: [PATCH 04/11] feat: add example for helios --- .../forecast_helios_valid_time_grid.py | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 examples/weather/forecast_helios_valid_time_grid.py diff --git a/examples/weather/forecast_helios_valid_time_grid.py b/examples/weather/forecast_helios_valid_time_grid.py new file mode 100644 index 0000000..9147539 --- /dev/null +++ b/examples/weather/forecast_helios_valid_time_grid.py @@ -0,0 +1,152 @@ +"""Helios SSRD 30min: init time vs valid time grid. + +Plots a 6x6 grid where: +- Rows: init times (30min apart) +- Columns: valid times (30min apart) +Each cell shows the forecast for that valid time issued at that init time. +Cells on the same column share the same valid time but have different lead times. +""" + +import logging +from datetime import datetime, timedelta + +import cartopy.crs as ccrs +import cartopy.feature as cfeature +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +from jua import JuaClient +from jua.weather import Models, Variables + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def main(): + client = JuaClient(request_credit_limit=10_000) + helios = client.weather.get_model(Models.EPT2_HELIOS) + + base_init = datetime(2026, 6, 1, 12) + n = 6 + step = timedelta(minutes=30) + + init_times = [base_init + i * step for i in range(n)] + valid_times = [base_init + (i + 1) * step for i in range(n)] + + variable = Variables.SURFACE_DOWNWELLING_SHORTWAVE_FLUX_SUM_30MIN + geo_kwargs = dict(latitude=slice(35, 60), longitude=slice(-15, 30)) + + # Fetch data for each init time + forecasts = {} + for init_time in init_times: + # Compute which valid times are reachable from this init + tds = [] + for vt in valid_times: + td_minutes = int((vt - init_time).total_seconds() / 60) + if td_minutes > 0: + tds.append(np.timedelta64(td_minutes, "m")) + + if not tds: + continue + + logger.info( + f"Fetching init={init_time.strftime('%H:%M')}, {len(tds)} lead times..." + ) + data = helios.get_forecasts( + init_time=init_time, + prediction_timedelta=tds, + variables=[variable], + stream=False, + **geo_kwargs, + ) + forecasts[init_time] = data[variable] + + # Determine shared color scale + all_vals = [float(f.min()) for f in forecasts.values()] + [ + float(f.max()) for f in forecasts.values() + ] + vmin, vmax = min(all_vals), max(all_vals) + + projection = ccrs.PlateCarree() + fig, axs = plt.subplots( + n, + n, + figsize=(4 * n, 3.5 * n), + subplot_kw={"projection": projection}, + ) + + for r_idx, init_time in enumerate(init_times): + for c_idx, vt in enumerate(valid_times): + ax = axs[r_idx, c_idx] + td_minutes = int((vt - init_time).total_seconds() / 60) + + if td_minutes <= 0 or init_time not in forecasts: + ax.set_visible(False) + continue + + td = pd.Timedelta(minutes=td_minutes) + field = forecasts[init_time] + + try: + plot_data = field.sel(prediction_timedelta=td, method="nearest") + plot_data.plot( + ax=ax, + transform=ccrs.PlateCarree(), + add_colorbar=False, + vmin=vmin, + vmax=vmax, + cmap="magma", + ) + except (KeyError, ValueError): + ax.set_visible(False) + continue + + ax.add_feature(cfeature.COASTLINE, linewidth=0.5, edgecolor="black") + ax.set_extent([-15, 30, 35, 60], crs=ccrs.PlateCarree()) + ax.set_title("") + + # Label: lead time in the corner + ax.text( + 0.05, + 0.95, + f"td={td_minutes}m", + transform=ax.transAxes, + fontsize=8, + va="top", + ha="left", + color="white", + bbox=dict(boxstyle="round,pad=0.2", facecolor="black", alpha=0.6), + ) + + # Row labels (init times) + for r_idx, init_time in enumerate(init_times): + axs[r_idx, 0].text( + -0.3, + 0.5, + f"init\n{init_time.strftime('%H:%M')}", + transform=axs[r_idx, 0].transAxes, + fontsize=11, + fontweight="bold", + va="center", + ha="center", + ) + + # Column labels (valid times) + for c_idx, vt in enumerate(valid_times): + axs[0, c_idx].set_title( + f"valid {vt.strftime('%H:%M')}", fontsize=11, fontweight="bold" + ) + + title = ( + "Helios SSRD 30min — init time vs valid time\n" + f"(base: {base_init.strftime('%Y-%m-%d %H:%M UTC')})" + ) + fig.suptitle(title, fontsize=16, fontweight="bold", y=0.98) + plt.tight_layout(rect=[0.05, 0, 1, 0.95]) + plt.savefig("ssrd_helios_valid_time_grid.png", dpi=150, bbox_inches="tight") + logger.info("Saved to ssrd_helios_valid_time_grid.png") + + +if __name__ == "__main__": + main() From 9c224569c0c38cc23151ea7d95a35462f17b3621 Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:01:07 +0200 Subject: [PATCH 05/11] fix(ci): handle force push in commit message check Fall back to merge-base with main when github.event.before SHA is unreachable after a force push. Co-authored-by: Cursor --- .github/workflows/checks-reusable.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/checks-reusable.yml b/.github/workflows/checks-reusable.yml index 8741352..aefef7d 100644 --- a/.github/workflows/checks-reusable.yml +++ b/.github/workflows/checks-reusable.yml @@ -27,15 +27,13 @@ jobs: - name: Check commit messages (Push) if: github.event_name == 'push' run: | - # For new branches, github.event.before is all zeros - if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then - # Find merge base with main and check from there + BEFORE="${{ github.event.before }}" + # Fall back to merge-base if before is zeros or unreachable (force push) + if [ "$BEFORE" = "0000000000000000000000000000000000000000" ] || ! git rev-parse --verify "$BEFORE^{commit}" >/dev/null 2>&1; then git fetch origin main - MERGE_BASE=$(git merge-base origin/main HEAD) - cz check --rev-range $MERGE_BASE..HEAD - else - cz check --rev-range ${{ github.event.before }}..${{ github.event.after }} + BEFORE=$(git merge-base origin/main HEAD) fi + cz check --rev-range "$BEFORE"..${{ github.event.after }} lint: name: Run Linting From b495861394032ba33bfafb4c9097cc2cece75bdb Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:01:52 +0200 Subject: [PATCH 06/11] Revert "fix(ci): handle force push in commit message check" This reverts commit 9c224569c0c38cc23151ea7d95a35462f17b3621. --- .github/workflows/checks-reusable.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/checks-reusable.yml b/.github/workflows/checks-reusable.yml index aefef7d..8741352 100644 --- a/.github/workflows/checks-reusable.yml +++ b/.github/workflows/checks-reusable.yml @@ -27,13 +27,15 @@ jobs: - name: Check commit messages (Push) if: github.event_name == 'push' run: | - BEFORE="${{ github.event.before }}" - # Fall back to merge-base if before is zeros or unreachable (force push) - if [ "$BEFORE" = "0000000000000000000000000000000000000000" ] || ! git rev-parse --verify "$BEFORE^{commit}" >/dev/null 2>&1; then + # For new branches, github.event.before is all zeros + if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then + # Find merge base with main and check from there git fetch origin main - BEFORE=$(git merge-base origin/main HEAD) + MERGE_BASE=$(git merge-base origin/main HEAD) + cz check --rev-range $MERGE_BASE..HEAD + else + cz check --rev-range ${{ github.event.before }}..${{ github.event.after }} fi - cz check --rev-range "$BEFORE"..${{ github.event.after }} lint: name: Run Linting From 8516f6171de165b925bb5957cda059c58af5bfeb Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:10:24 +0200 Subject: [PATCH 07/11] fix(tests): exclude solar-only Helios from generic forecast tests Co-authored-by: Cursor --- tests/functional/test_forecasts.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/functional/test_forecasts.py b/tests/functional/test_forecasts.py index 8c3031e..57cb0dd 100644 --- a/tests/functional/test_forecasts.py +++ b/tests/functional/test_forecasts.py @@ -38,8 +38,14 @@ Models.ICON_EU: datetime(2026, 2, 9, 0, 0, 0), } -ALL_MODELS = list(Models) -INTERNAL_MODELS = [m for m in Models if get_model_meta_info(m).has_grid_access] +SOLAR_ONLY_MODELS = {Models.EPT2_HELIOS} + +ALL_MODELS = [m for m in Models if m not in SOLAR_ONLY_MODELS] +INTERNAL_MODELS = [ + m + for m in Models + if get_model_meta_info(m).has_grid_access and m not in SOLAR_ONLY_MODELS +] def get_forecast_date(model: Models) -> datetime: From ed5471f76aa4475361bfa77aa96618cec83d3fb1 Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:19:56 +0200 Subject: [PATCH 08/11] fix(models): simplify Helios/Europa meta to match HRRR pattern Co-authored-by: Cursor --- src/jua/weather/_model_meta.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/jua/weather/_model_meta.py b/src/jua/weather/_model_meta.py index e2b6181..45d5913 100644 --- a/src/jua/weather/_model_meta.py +++ b/src/jua/weather/_model_meta.py @@ -155,21 +155,13 @@ class ModelMetaInfo: ) _MODEL_META_INFO[Models.EPT2_HELIOS] = ModelMetaInfo( has_grid_access=True, - forecast_name_mapping="ept-2-helios", full_forecasted_hours=480, - has_forecast_file_access=True, - num_lats=2160, - num_lons=4320, - forecasts_per_day=48, # 30-minute update frequency - temporal_resolution=TemporalResolution(base=1), # 30-min steps handled by API + forecasts_per_day=48, + temporal_resolution=TemporalResolution(base=1), ) _MODEL_META_INFO[Models.EPT2_EUROPA] = ModelMetaInfo( has_grid_access=True, - forecast_name_mapping="ept-2-europa", full_forecasted_hours=480, - has_forecast_file_access=True, - num_lats=2160, - num_lons=4320, temporal_resolution=TemporalResolution(base=6, special=((1, 0, 10 * 24),)), ) _MODEL_META_INFO[Models.AIFS] = ModelMetaInfo( From 3253d5a61ee59429594e05cb34cd4885eff49bec Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:21:47 +0200 Subject: [PATCH 09/11] fix(models): correct Helios/Europa forecast hours and update frequency Co-authored-by: Cursor --- src/jua/weather/_model_meta.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/jua/weather/_model_meta.py b/src/jua/weather/_model_meta.py index 45d5913..7f71d4a 100644 --- a/src/jua/weather/_model_meta.py +++ b/src/jua/weather/_model_meta.py @@ -155,14 +155,15 @@ class ModelMetaInfo: ) _MODEL_META_INFO[Models.EPT2_HELIOS] = ModelMetaInfo( has_grid_access=True, - full_forecasted_hours=480, + full_forecasted_hours=48, forecasts_per_day=48, temporal_resolution=TemporalResolution(base=1), ) _MODEL_META_INFO[Models.EPT2_EUROPA] = ModelMetaInfo( has_grid_access=True, - full_forecasted_hours=480, - temporal_resolution=TemporalResolution(base=6, special=((1, 0, 10 * 24),)), + full_forecasted_hours=48, + forecasts_per_day=24, + temporal_resolution=TemporalResolution(base=1), ) _MODEL_META_INFO[Models.AIFS] = ModelMetaInfo( has_grid_access=True, From dc967e23dff85f1ea08ca57c9e7d81bce6ddc987 Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:25:39 +0200 Subject: [PATCH 10/11] feat(models): support sub-hourly temporal resolution for Helios Allow fractional (hour) resolutions in TemporalResolution by iterating internally in minutes, and set Helios to a 30min cadence (base=0.5). Co-authored-by: Cursor --- src/jua/weather/_model_meta.py | 23 +++++++++++++++-------- tests/weather/test_temporal_resolution.py | 6 ++++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/jua/weather/_model_meta.py b/src/jua/weather/_model_meta.py index 7f71d4a..353df12 100644 --- a/src/jua/weather/_model_meta.py +++ b/src/jua/weather/_model_meta.py @@ -9,17 +9,19 @@ class TemporalResolution: """Internal class to store model temporal resolution Used for models with variable temporal resolution, such as EPT2. + Resolutions are expressed in hours and may be fractional (e.g. ``0.5`` + for a 30-minute cadence such as EPT2 Helios). Attributes: - default: The default temporal resolution for the model. - segments: The resolution of the model for prediction_timedelta ranges. + base: The default temporal resolution for the model, in hours. + special: The resolution of the model for prediction_timedelta ranges. Defined as `(resolution, from_hour, to_hour)`, where the model has a prediction every `resolution` hours when the prediction_timedelta is in the interval [`from_hour`, `to_hour`]. """ - base: int - special: tuple[tuple[int, int, int], ...] = tuple() + base: float + special: tuple[tuple[float, int, int], ...] = tuple() def __post_init__(self) -> None: """Checks that the special cases make sense""" @@ -45,6 +47,9 @@ def __post_init__(self) -> None: def num_prediction_timedeltas(self, from_hour: int, to_hour: int) -> int: """Determines the number of `prediction_timedeltas` in an interval. + Iterates internally in minutes so that sub-hourly resolutions + (e.g. a 30-minute cadence) are counted correctly. + Attributes: from_hour: The start hour for the interval to_hour: The end hour for the interval @@ -56,13 +61,15 @@ def num_prediction_timedeltas(self, from_hour: int, to_hour: int) -> int: ) num_timedeltas = 0 - for h in range(from_hour, to_hour + 1): + for minute in range(from_hour * 60, to_hour * 60 + 1): + hour = minute / 60 resolution = self.base for s_res, s_start, s_end in self.special: - if s_start <= h <= s_end: + if s_start <= hour <= s_end: resolution = s_res break - if h % resolution == 0: + resolution_minutes = round(resolution * 60) + if minute % resolution_minutes == 0: num_timedeltas += 1 return num_timedeltas @@ -157,7 +164,7 @@ class ModelMetaInfo: has_grid_access=True, full_forecasted_hours=48, forecasts_per_day=48, - temporal_resolution=TemporalResolution(base=1), + temporal_resolution=TemporalResolution(base=0.5), ) _MODEL_META_INFO[Models.EPT2_EUROPA] = ModelMetaInfo( has_grid_access=True, diff --git a/tests/weather/test_temporal_resolution.py b/tests/weather/test_temporal_resolution.py index f473c06..38e54f6 100644 --- a/tests/weather/test_temporal_resolution.py +++ b/tests/weather/test_temporal_resolution.py @@ -13,11 +13,13 @@ ((1, 0, 24), (3, 24, 48)), [((0, 48), 33), ((0, 72), 37)], ), + # Sub-hourly (30min) resolution: 2 steps per hour + (0.5, tuple(), [((0, 1), 3), ((0, 2), 5), ((0, 48), 97)]), ], ) def test_temporal_resolution( - base: int, - special: tuple[tuple[int, int, int]], + base: float, + special: tuple[tuple[float, int, int]], test_cases: list[tuple[int, int], int], ) -> None: tr = TemporalResolution(base=base, special=special) From 2e73b864d6bcbcd110473b96de649d3aa13ae1a3 Mon Sep 17 00:00:00 2001 From: mroberto166 <50059706+mroberto166@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:53:54 +0200 Subject: [PATCH 11/11] refactor(examples): replace Helios grid example with point comparison The grid example queried the full European domain across multiple init times and needed request_credit_limit=10_000, which could burn a large amount of user credits. It also hardcoded an init time that would fail CI once that run ages out. Replace it with a cheap single-point (Zurich) example that uses the live latest runs, compares Helios and ICON-EU SSRD 1h colored by model with a gradient over init time, and overlays a Helios T+1h ground-truth line. Co-authored-by: Cursor --- .../forecast_helios_valid_time_grid.py | 158 ----------------- .../weather/forecast_solar_runs_comparison.py | 165 ++++++++++++++++++ 2 files changed, 165 insertions(+), 158 deletions(-) delete mode 100644 examples/weather/forecast_helios_valid_time_grid.py create mode 100644 examples/weather/forecast_solar_runs_comparison.py diff --git a/examples/weather/forecast_helios_valid_time_grid.py b/examples/weather/forecast_helios_valid_time_grid.py deleted file mode 100644 index db2e9c5..0000000 --- a/examples/weather/forecast_helios_valid_time_grid.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Helios SSRD 30min: init time vs valid time grid. - -Plots a 6x6 grid where: -- Rows: init times (30min apart) -- Columns: valid times (30min apart) -Each cell shows the forecast for that valid time issued at that init time. -Cells on the same column share the same valid time but have different lead times. -""" - -import logging -import sys -from datetime import datetime, timedelta - -try: - import cartopy.crs as ccrs - import cartopy.feature as cfeature -except ImportError: - print("This example requires cartopy: pip install cartopy") - sys.exit(0) - -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd - -from jua import JuaClient -from jua.weather import Models, Variables - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def main(): - client = JuaClient(request_credit_limit=10_000) - helios = client.weather.get_model(Models.EPT2_HELIOS) - - base_init = datetime(2026, 6, 1, 12) - n = 6 - step = timedelta(minutes=30) - - init_times = [base_init + i * step for i in range(n)] - valid_times = [base_init + (i + 1) * step for i in range(n)] - - variable = Variables.SURFACE_DOWNWELLING_SHORTWAVE_FLUX_SUM_30MIN - geo_kwargs = dict(latitude=slice(35, 60), longitude=slice(-15, 30)) - - # Fetch data for each init time - forecasts = {} - for init_time in init_times: - # Compute which valid times are reachable from this init - tds = [] - for vt in valid_times: - td_minutes = int((vt - init_time).total_seconds() / 60) - if td_minutes > 0: - tds.append(np.timedelta64(td_minutes, "m")) - - if not tds: - continue - - logger.info( - f"Fetching init={init_time.strftime('%H:%M')}, {len(tds)} lead times..." - ) - data = helios.get_forecasts( - init_time=init_time, - prediction_timedelta=tds, - variables=[variable], - stream=False, - **geo_kwargs, - ) - forecasts[init_time] = data[variable] - - # Determine shared color scale - all_vals = [float(f.min()) for f in forecasts.values()] + [ - float(f.max()) for f in forecasts.values() - ] - vmin, vmax = min(all_vals), max(all_vals) - - projection = ccrs.PlateCarree() - fig, axs = plt.subplots( - n, - n, - figsize=(4 * n, 3.5 * n), - subplot_kw={"projection": projection}, - ) - - for r_idx, init_time in enumerate(init_times): - for c_idx, vt in enumerate(valid_times): - ax = axs[r_idx, c_idx] - td_minutes = int((vt - init_time).total_seconds() / 60) - - if td_minutes <= 0 or init_time not in forecasts: - ax.set_visible(False) - continue - - td = pd.Timedelta(minutes=td_minutes) - field = forecasts[init_time] - - try: - plot_data = field.sel(prediction_timedelta=td, method="nearest") - plot_data.plot( - ax=ax, - transform=ccrs.PlateCarree(), - add_colorbar=False, - vmin=vmin, - vmax=vmax, - cmap="magma", - ) - except (KeyError, ValueError): - ax.set_visible(False) - continue - - ax.add_feature(cfeature.COASTLINE, linewidth=0.5, edgecolor="black") - ax.set_extent([-15, 30, 35, 60], crs=ccrs.PlateCarree()) - ax.set_title("") - - # Label: lead time in the corner - ax.text( - 0.05, - 0.95, - f"td={td_minutes}m", - transform=ax.transAxes, - fontsize=8, - va="top", - ha="left", - color="white", - bbox=dict(boxstyle="round,pad=0.2", facecolor="black", alpha=0.6), - ) - - # Row labels (init times) - for r_idx, init_time in enumerate(init_times): - axs[r_idx, 0].text( - -0.3, - 0.5, - f"init\n{init_time.strftime('%H:%M')}", - transform=axs[r_idx, 0].transAxes, - fontsize=11, - fontweight="bold", - va="center", - ha="center", - ) - - # Column labels (valid times) - for c_idx, vt in enumerate(valid_times): - axs[0, c_idx].set_title( - f"valid {vt.strftime('%H:%M')}", fontsize=11, fontweight="bold" - ) - - title = ( - "Helios SSRD 30min — init time vs valid time\n" - f"(base: {base_init.strftime('%Y-%m-%d %H:%M UTC')})" - ) - fig.suptitle(title, fontsize=16, fontweight="bold", y=0.98) - plt.tight_layout(rect=[0.05, 0, 1, 0.95]) - plt.savefig("ssrd_helios_valid_time_grid.png", dpi=150, bbox_inches="tight") - logger.info("Saved to ssrd_helios_valid_time_grid.png") - - -if __name__ == "__main__": - main() diff --git a/examples/weather/forecast_solar_runs_comparison.py b/examples/weather/forecast_solar_runs_comparison.py new file mode 100644 index 0000000..b9ac0a5 --- /dev/null +++ b/examples/weather/forecast_solar_runs_comparison.py @@ -0,0 +1,165 @@ +"""Compare recent solar-radiation forecast runs at a point, valid-time aligned. + +Plots 1h surface downwelling shortwave flux (SSRD) at Zurich for the most recent +runs of Helios and ICON-EU. Every run is drawn against valid time, colored by +model with a light->dark gradient over init time (older runs lighter). + +A black "ground truth" line is built from each Helios run's T+1h value (valid at +init + 1h), stitched across runs to approximate the analysis. + +This uses cheap single-point queries, so it runs against the live latest +forecasts without needing a raised credit limit. +""" + +import logging +from datetime import timedelta + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from matplotlib.lines import Line2D + +from jua import JuaClient +from jua.types.geo import LatLon +from jua.weather import Models, Variables + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +ZURICH = LatLon(lat=47.3769, lon=8.5417, label="Zurich") +VARIABLE = Variables.SURFACE_DOWNWELLING_SHORTWAVE_FLUX_SUM_1H + +# Only keep runs initialized within this many hours of the latest available run. +WINDOW_HOURS = 6 + +# (label, matplotlib colormap) per model. +MODEL_CONFIG = { + Models.EPT2_HELIOS: ("Helios", "Oranges"), + Models.ICON_EU: ("ICON-EU", "Purples"), +} + + +def _naive_utc(ts) -> pd.Timestamp: + """Normalize a timestamp to tz-naive UTC.""" + t = pd.Timestamp(ts) + if t.tzinfo is not None: + t = t.tz_convert("UTC").tz_localize(None) + return t + + +def available_init_times(model_obj) -> list[pd.Timestamp]: + """Sorted (ascending) tz-naive init times available for a model.""" + available = model_obj.get_available_forecasts(limit=50) + return sorted(_naive_utc(f.init_time) for f in available.forecasts) + + +def ground_truth(model_obj, init_times, day_start, day_end): + """Stitch each run's T+1h value into a pseudo ground-truth series.""" + inits = [t for t in init_times if day_start <= t + timedelta(hours=1) <= day_end] + if not inits: + return None, None + + forecast = model_obj.get_forecasts( + init_time=[t.to_pydatetime() for t in inits], + points=ZURICH, + variables=[VARIABLE], + prediction_timedelta=[np.timedelta64(60, "m")], + stream=False, + ) + da = forecast[VARIABLE].squeeze() + valid_times = pd.to_datetime(da["init_time"].values) + pd.Timedelta(minutes=60) + values = np.asarray(da.values).ravel() + order = np.argsort(valid_times) + return valid_times[order], values[order] + + +def main(): + client = JuaClient() + + helios = client.weather.get_model(Models.EPT2_HELIOS) + helios_inits = available_init_times(helios) + if not helios_inits: + logger.warning("No Helios runs available; nothing to plot.") + return + + # Frame the chart on the day of the latest Helios run. + latest_init = helios_inits[-1] + day_start = latest_init.normalize() + day_end = day_start + timedelta(days=1) + + fig, ax = plt.subplots(figsize=(14, 7)) + legend_handles = [] + + for model_enum, (label, cmap_name) in MODEL_CONFIG.items(): + model_obj = client.weather.get_model(model_enum) + init_times = available_init_times(model_obj) + runs = [ + t for t in init_times if t >= init_times[-1] - timedelta(hours=WINDOW_HOURS) + ] + if not runs: + logger.warning(f"No runs found for {label}") + continue + + cmap = plt.get_cmap(cmap_name) + n = len(runs) + logger.info(f"{label}: {n} runs from {runs[0]} to {runs[-1]}") + + for i, init_time in enumerate(runs): + max_lead = int((day_end - init_time).total_seconds() / 3600) + 1 + if max_lead <= 0: + continue + + forecast = model_obj.get_forecasts( + init_time=init_time.to_pydatetime(), + points=ZURICH, + variables=[VARIABLE], + max_lead_time=min(48, max_lead), + stream=False, + ) + da = forecast[VARIABLE].to_absolute_time().squeeze() + times = pd.to_datetime(da["time"].values) + values = np.asarray(da.values).ravel() + + mask = times <= day_end + times, values = times[mask], values[mask] + if len(times) == 0: + continue + + # Older runs lighter, newest darkest. + shade = 0.35 + 0.6 * (i / max(n - 1, 1)) + ax.plot(times, values, color=cmap(shade), linewidth=1.6, alpha=0.9) + + legend_handles.append( + Line2D([0], [0], color=cmap(0.85), linewidth=2.5, label=label) + ) + + # Pseudo ground truth: Helios T+1h stitched across runs. + gt_times, gt_values = ground_truth(helios, helios_inits, day_start, day_end) + if gt_times is not None: + ax.plot(gt_times, gt_values, color="black", linewidth=2.8, zorder=10) + legend_handles.append( + Line2D( + [0], + [0], + color="black", + linewidth=2.8, + label="Ground truth (Helios T+1h)", + ) + ) + + ax.set_xlim(day_start, day_end) + ax.set_xlabel("Valid time (UTC)") + ax.set_ylabel(VARIABLE.display_name_with_unit) + ax.set_title( + f"SSRD 1h at {ZURICH.label} — recent runs (last {WINDOW_HOURS}h), " + "gradient = init time (light=older)" + ) + ax.legend(handles=legend_handles) + ax.grid(True, alpha=0.3) + fig.autofmt_xdate() + plt.tight_layout() + plt.show() + + +if __name__ == "__main__": + main()