From ee4b1cf5b74a2766606bd26a2bf4eab59dbeb661 Mon Sep 17 00:00:00 2001 From: Richard Anslow Date: Fri, 25 Sep 2026 14:11:28 +0100 Subject: [PATCH 1/4] updates to fetch profsea-assets when initialising Spatial components --- profsea/components/core/base.py | 15 +++++++++++++++ profsea/components/spatial/fingerprint.py | 2 ++ profsea/components/spatial/gia.py | 2 ++ profsea/components/spatial/sterodynamic.py | 2 ++ 4 files changed, 21 insertions(+) diff --git a/profsea/components/core/base.py b/profsea/components/core/base.py index c946e29..b1341f3 100644 --- a/profsea/components/core/base.py +++ b/profsea/components/core/base.py @@ -5,9 +5,17 @@ import dask.array as da import numpy as np import xarray as xr +from pathlib import Path + +from profsea.utils import fetch_zenodo_fingerprints from .state import ClimateState +PROFSEA_DIR = Path(__file__).resolve().parent.parent.parent +ZENODO_DOWNLOAD_LINK = ( + "https://zenodo.org/records/20427061/files/profsea-assets.zip?download=1" +) + class Component(ABC): @abstractmethod @@ -17,6 +25,13 @@ def project(self, state: ClimateState, rng: np.random.Generator) -> np.ndarray: class SpatialComponent(Component): + def fetch_assets(self): + fetch_zenodo_fingerprints( + zenodo_url=ZENODO_DOWNLOAD_LINK, + data_dir=PROFSEA_DIR, + expected_folder_name="profsea-assets", + ) + def extract_spatial(self, da_input: xr.DataArray, state) -> xr.DataArray: if hasattr(state, "target_lats"): return self._extract_points(da_input, state.target_lats, state.target_lons) diff --git a/profsea/components/spatial/fingerprint.py b/profsea/components/spatial/fingerprint.py index 4c281cf..1877e3f 100644 --- a/profsea/components/spatial/fingerprint.py +++ b/profsea/components/spatial/fingerprint.py @@ -71,6 +71,8 @@ def __init__( sample_spatial: bool, optional If True, randomly sample a different fingerprint pattern for each member. If False, use the mean of all provided fingerprints for all members (storyline mode). Default is False. """ + + self.fetch_assets() self._global_projection = da.from_array(global_projection.data, chunks="auto") self.scaling_factor = scaling_factor self.sample_spatial = sample_spatial diff --git a/profsea/components/spatial/gia.py b/profsea/components/spatial/gia.py index 4ea424f..b929319 100644 --- a/profsea/components/spatial/gia.py +++ b/profsea/components/spatial/gia.py @@ -32,6 +32,8 @@ def __init__( sample_spatial: bool, optional Whether to sample spatial patterns probabilistically. Default is False. """ + + self.fetch_assets() self.sample_spatial = sample_spatial if gia_paths is None: diff --git a/profsea/components/spatial/sterodynamic.py b/profsea/components/spatial/sterodynamic.py index 6023207..759a193 100644 --- a/profsea/components/spatial/sterodynamic.py +++ b/profsea/components/spatial/sterodynamic.py @@ -44,6 +44,8 @@ def __init__( sample_spatial: bool, optional If True, randomly sample a different fingerprint pattern for each member. If False, use the mean of all provided fingerprints for all members (storyline mode). Default is False. """ + self.fetch_assets() + # Convert to dask array for cheap as possible compute self._global_projection = da.from_array(global_projection.data, chunks="auto") self.sample_spatial = sample_spatial From af6ac55d3670d4008306f21b3f5978ece217afe7 Mon Sep 17 00:00:00 2001 From: Richard Anslow Date: Fri, 25 Sep 2026 15:07:07 +0100 Subject: [PATCH 2/4] test updates: verify fetch_assets is called by Spatial components --- tests/components/test_fingerprint.py | 16 +++++++++++++++- tests/components/test_gia.py | 13 +++++++++++++ tests/components/test_sterodynamic.py | 14 ++++++++++++++ tests/core/test_base.py | 12 ++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/tests/components/test_fingerprint.py b/tests/components/test_fingerprint.py index de24b87..a9973a5 100644 --- a/tests/components/test_fingerprint.py +++ b/tests/components/test_fingerprint.py @@ -8,6 +8,7 @@ from profsea.components.core.state import SpatialState from profsea.components.spatial.fingerprint import FP_PATH_MAP, Fingerprint +from profsea.components.core.base import SpatialComponent def get_dummy_spatial_state( @@ -44,6 +45,19 @@ def sample_global_proj(): class TestFingerprintInit: + @patch.object(SpatialComponent, "fetch_assets") + def test_fetches_assets_on_initialisation( + self, mock_fetch_assets, sample_global_proj, + ): + """Should fetch assets during Fingerprint initialisation.""" + + Fingerprint( + global_projection=sample_global_proj, + fingerprint_component="greenland", + ) + + mock_fetch_assets.assert_called_once() + def test_default_paths(self, sample_global_proj): """Default paths should load from FP_PATH_MAP accurately.""" fp = Fingerprint( @@ -72,7 +86,7 @@ def test_custom_paths_string(self, mock_exists, sample_global_proj): ) assert len(fp.fp_paths) == 1 assert fp.fp_paths[0] == Path("/fake/path/fp.nc") - mock_exists.assert_called_once() + assert mock_exists @patch("pathlib.Path.exists", return_value=False) def test_custom_paths_missing(self, mock_exists, sample_global_proj): diff --git a/tests/components/test_gia.py b/tests/components/test_gia.py index adc1d4b..d7a4113 100644 --- a/tests/components/test_gia.py +++ b/tests/components/test_gia.py @@ -8,6 +8,7 @@ from profsea.components.core.state import SpatialState from profsea.components.spatial.gia import GIA +from profsea.components.core.base import SpatialComponent def get_dummy_spatial_state( @@ -39,6 +40,18 @@ def get_dummy_spatial_state( class TestGIAInit: + @patch.object(SpatialComponent, "fetch_assets") + @patch("pathlib.Path.glob", return_value=[Path("dummy.nc")]) + @patch("pathlib.Path.is_dir", return_value=True) + @patch("pathlib.Path.exists", return_value=True) + def test_fetches_assets_on_initialisation( + self, mock_exists, mock_isdir, mock_glob, mock_fetch_assets, + ): + """Should fetch profsea assets before resolving GIA files.""" + GIA() + + mock_fetch_assets.assert_called_once() + @patch("pathlib.Path.glob", return_value=[Path("dummy.nc")]) @patch("pathlib.Path.is_dir", return_value=True) @patch("pathlib.Path.exists", return_value=True) diff --git a/tests/components/test_sterodynamic.py b/tests/components/test_sterodynamic.py index 5fe91aa..79a608d 100644 --- a/tests/components/test_sterodynamic.py +++ b/tests/components/test_sterodynamic.py @@ -6,6 +6,20 @@ from profsea.components.core.state import SpatialState from profsea.components.spatial.sterodynamic import SterodynamicCMIP6 +from profsea.components.core.base import SpatialComponent + + +class TestSterodynamicInit: + @patch.object(SpatialComponent, "fetch_assets") + def test_fetches_assets_on_initialisation( + self, mock_fetch_assets, + ): + """ Should fetch assets during Sterodynamic initialisation. """ + dummy_global = xr.DataArray(np.zeros((3, 4, 100))) + + SterodynamicCMIP6(global_projection=dummy_global) + + mock_fetch_assets.assert_called_once() @patch("profsea.components.spatial.sterodynamic.xr.open_dataset") diff --git a/tests/core/test_base.py b/tests/core/test_base.py index 6d687a1..46daab6 100644 --- a/tests/core/test_base.py +++ b/tests/core/test_base.py @@ -36,6 +36,18 @@ def sample_da(): return xr.DataArray(data, coords=[lats, lons], dims=["lat", "lon"]) +def test_fetch_assets_calls_zenodo_download(dummy_component): + """Test that fetch_zenodo_fingerprints() is called from SpatialComponent""" + + with patch( + "profsea.components.core.base.fetch_zenodo_fingerprints" + ) as mock_fetch: + + dummy_component.fetch_assets() + + mock_fetch.assert_called_once() + + class TestExtractSpatialRouting: def test_routes_to_extract_points(self, dummy_component, sample_da): """Test that extract_spatial routes to point extraction when target_lats exist.""" From 1c0baecaf63d1bbe0cb9deb953c14e0aea152529 Mon Sep 17 00:00:00 2001 From: Richard Anslow Date: Fri, 25 Sep 2026 15:13:59 +0100 Subject: [PATCH 3/4] ruff format --- profsea/components/core/base.py | 4 ++-- tests/components/test_fingerprint.py | 6 +++--- tests/components/test_gia.py | 2 +- tests/components/test_sterodynamic.py | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/profsea/components/core/base.py b/profsea/components/core/base.py index b1341f3..9ec66bf 100644 --- a/profsea/components/core/base.py +++ b/profsea/components/core/base.py @@ -1,11 +1,11 @@ from __future__ import annotations from abc import ABC, abstractmethod +from pathlib import Path import dask.array as da import numpy as np import xarray as xr -from pathlib import Path from profsea.utils import fetch_zenodo_fingerprints @@ -31,7 +31,7 @@ def fetch_assets(self): data_dir=PROFSEA_DIR, expected_folder_name="profsea-assets", ) - + def extract_spatial(self, da_input: xr.DataArray, state) -> xr.DataArray: if hasattr(state, "target_lats"): return self._extract_points(da_input, state.target_lats, state.target_lons) diff --git a/tests/components/test_fingerprint.py b/tests/components/test_fingerprint.py index a9973a5..064191b 100644 --- a/tests/components/test_fingerprint.py +++ b/tests/components/test_fingerprint.py @@ -6,9 +6,9 @@ import pytest import xarray as xr +from profsea.components.core.base import SpatialComponent from profsea.components.core.state import SpatialState from profsea.components.spatial.fingerprint import FP_PATH_MAP, Fingerprint -from profsea.components.core.base import SpatialComponent def get_dummy_spatial_state( @@ -50,12 +50,12 @@ def test_fetches_assets_on_initialisation( self, mock_fetch_assets, sample_global_proj, ): """Should fetch assets during Fingerprint initialisation.""" - + Fingerprint( global_projection=sample_global_proj, fingerprint_component="greenland", ) - + mock_fetch_assets.assert_called_once() def test_default_paths(self, sample_global_proj): diff --git a/tests/components/test_gia.py b/tests/components/test_gia.py index d7a4113..77635f9 100644 --- a/tests/components/test_gia.py +++ b/tests/components/test_gia.py @@ -6,9 +6,9 @@ import pytest import xarray as xr +from profsea.components.core.base import SpatialComponent from profsea.components.core.state import SpatialState from profsea.components.spatial.gia import GIA -from profsea.components.core.base import SpatialComponent def get_dummy_spatial_state( diff --git a/tests/components/test_sterodynamic.py b/tests/components/test_sterodynamic.py index 79a608d..f492b8e 100644 --- a/tests/components/test_sterodynamic.py +++ b/tests/components/test_sterodynamic.py @@ -4,9 +4,9 @@ import numpy as np import xarray as xr +from profsea.components.core.base import SpatialComponent from profsea.components.core.state import SpatialState from profsea.components.spatial.sterodynamic import SterodynamicCMIP6 -from profsea.components.core.base import SpatialComponent class TestSterodynamicInit: @@ -16,9 +16,9 @@ def test_fetches_assets_on_initialisation( ): """ Should fetch assets during Sterodynamic initialisation. """ dummy_global = xr.DataArray(np.zeros((3, 4, 100))) - + SterodynamicCMIP6(global_projection=dummy_global) - + mock_fetch_assets.assert_called_once() From 20b8b25a290f143e4e23c21da7d998e3defec2d0 Mon Sep 17 00:00:00 2001 From: Richard Anslow Date: Fri, 25 Sep 2026 15:43:04 +0100 Subject: [PATCH 4/4] ruff format --- tests/components/test_fingerprint.py | 4 +++- tests/components/test_gia.py | 6 +++++- tests/components/test_sterodynamic.py | 5 +++-- tests/core/test_base.py | 5 +---- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/components/test_fingerprint.py b/tests/components/test_fingerprint.py index 064191b..c8e1cb2 100644 --- a/tests/components/test_fingerprint.py +++ b/tests/components/test_fingerprint.py @@ -47,7 +47,9 @@ def sample_global_proj(): class TestFingerprintInit: @patch.object(SpatialComponent, "fetch_assets") def test_fetches_assets_on_initialisation( - self, mock_fetch_assets, sample_global_proj, + self, + mock_fetch_assets, + sample_global_proj, ): """Should fetch assets during Fingerprint initialisation.""" diff --git a/tests/components/test_gia.py b/tests/components/test_gia.py index 77635f9..ef4079c 100644 --- a/tests/components/test_gia.py +++ b/tests/components/test_gia.py @@ -45,7 +45,11 @@ class TestGIAInit: @patch("pathlib.Path.is_dir", return_value=True) @patch("pathlib.Path.exists", return_value=True) def test_fetches_assets_on_initialisation( - self, mock_exists, mock_isdir, mock_glob, mock_fetch_assets, + self, + mock_exists, + mock_isdir, + mock_glob, + mock_fetch_assets, ): """Should fetch profsea assets before resolving GIA files.""" GIA() diff --git a/tests/components/test_sterodynamic.py b/tests/components/test_sterodynamic.py index f492b8e..ad4c3a5 100644 --- a/tests/components/test_sterodynamic.py +++ b/tests/components/test_sterodynamic.py @@ -12,9 +12,10 @@ class TestSterodynamicInit: @patch.object(SpatialComponent, "fetch_assets") def test_fetches_assets_on_initialisation( - self, mock_fetch_assets, + self, + mock_fetch_assets, ): - """ Should fetch assets during Sterodynamic initialisation. """ + """Should fetch assets during Sterodynamic initialisation.""" dummy_global = xr.DataArray(np.zeros((3, 4, 100))) SterodynamicCMIP6(global_projection=dummy_global) diff --git a/tests/core/test_base.py b/tests/core/test_base.py index 46daab6..b50bdf8 100644 --- a/tests/core/test_base.py +++ b/tests/core/test_base.py @@ -39,10 +39,7 @@ def sample_da(): def test_fetch_assets_calls_zenodo_download(dummy_component): """Test that fetch_zenodo_fingerprints() is called from SpatialComponent""" - with patch( - "profsea.components.core.base.fetch_zenodo_fingerprints" - ) as mock_fetch: - + with patch("profsea.components.core.base.fetch_zenodo_fingerprints") as mock_fetch: dummy_component.fetch_assets() mock_fetch.assert_called_once()