diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 0000000..682d9db --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,40 @@ +name: sonarcloud + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + branches: + - main + +jobs: + + sonarcloud: + name: SonarCloud + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.10' + - name: Python info + shell: bash -l {0} + run: | + which python3 + python3 --version + - name: Install dependencies + run: python3 -m pip install hatch --upgrade + - name: Run unit tests with coverage + run: hatch run coverage + - name: Correct coverage paths + run: sed -i "s+$PWD/++g" coverage.xml + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index 842e84f..08636b1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # zampy Tool for downloading Land Surface Model input data +[![github license badge](https://img.shields.io/github/license/EcoExtreML/zampy)](https://github.com/EcoExtreML/zampy) +[![build](https://github.com/EcoExtreML/zampy/actions/workflows/build.yml/badge.svg)](https://github.com/EcoExtreML/zampy/actions/workflows/build.yml) +[![workflow scc badge](https://sonarcloud.io/api/project_badges/measure?project=EcoExtreML_zampy&metric=coverage)](https://sonarcloud.io/dashboard?id=EcoExtreML_zampy) + ## Tool outline: diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..c5a0059 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,13 @@ +sonar.organization=ecoextreml +sonar.projectKey=EcoExtreML_zampy +sonar.host.url=https://sonarcloud.io +sonar.sources=src/zampy/ +sonar.tests=tests/ +sonar.links.homepage=https://github.com/EcoExtreML/zampy +sonar.links.scm=https://github.com/EcoExtreML/zampy +sonar.links.issue=https://github.com/EcoExtreML/zampy/issues +sonar.links.ci=https://github.com/EcoExtreML/zampy/actions +sonar.python.coverage.reportPaths=coverage.xml +sonar.python.xunit.reportPath=xunit-result.xml +sonar.python.pylint.reportPaths=pylint-report.txt +sonar.python.version=3.8, 3.9, 3.10 \ No newline at end of file diff --git a/src/zampy/datasets/converter.py b/src/zampy/datasets/converter.py index 399258d..106eb12 100644 --- a/src/zampy/datasets/converter.py +++ b/src/zampy/datasets/converter.py @@ -36,7 +36,16 @@ def check_convention(convention: Union[str, Path]) -> None: def convert( data: xr.Dataset, dataset: Dataset, convention: Union[str, Path] ) -> xr.Dataset: - """Convert a loaded dataset to the specified convention.""" + """Convert a loaded dataset to the specified convention. + + Args: + data: Input xarray data. + dataset: Zampy dataset instance. + convention: Input data exchange convention. + + Return: + Input xarray with converted variables following given convention. + """ unit_reg = variables.unit_registration() converted = False if isinstance(convention, str): @@ -53,11 +62,11 @@ def convert( var_name = convention_dict[var.lower()]["variable"] var_units = data[var].attrs["units"] if unit_reg(var_units) != unit_reg(convert_units): - converted = True # lazy dask array data = _convert_var(data, var, convert_units) data = data.rename({var: var_name}) print(f"{var} renamed to {var_name}.") + converted = True else: print(f"Variable '{var}' is not included in '{convention}' convention.") diff --git a/src/zampy/datasets/dataset_protocol.py b/src/zampy/datasets/dataset_protocol.py index 5b108c5..77b7042 100644 --- a/src/zampy/datasets/dataset_protocol.py +++ b/src/zampy/datasets/dataset_protocol.py @@ -38,12 +38,12 @@ def __post_init__(self) -> None: """Validate the initialized SpatialBounds class.""" if self.south > self.north: raise ValueError( - "Value of southern bound is greater than norther bound." + "Value of southern bound is greater than northern bound." "\nPlease check the spatial bounds input." ) if self.west > self.east: raise ValueError( - "Value of western bound is greater than east bound." + "Value of western bound is greater than eastern bound." "\nPlease check the spatial bounds input." ) diff --git a/src/zampy/datasets/utils.py b/src/zampy/datasets/utils.py index c9d3a8a..c6a6214 100644 --- a/src/zampy/datasets/utils.py +++ b/src/zampy/datasets/utils.py @@ -58,7 +58,7 @@ def download_url(url: str, fpath: Path, overwrite: bool) -> None: def get_url_size(url: str) -> int: """Return the size (bytes) of a given URL.""" - response = requests.head(url, timeout=30) + response = requests.head(url) return int(response.headers["Content-Length"]) diff --git a/src/zampy/datasets/validation.py b/src/zampy/datasets/validation.py index 7f70234..5da63c8 100644 --- a/src/zampy/datasets/validation.py +++ b/src/zampy/datasets/validation.py @@ -51,10 +51,10 @@ def compare_variables( variable_names: User requested variables. Raises: - ValueError: _description_ + InvalidVariableError: If the variables are not available in the dataset """ if not all(var in dataset.variable_names for var in variable_names): - raise ValueError( + raise InvalidVariableError( f"Input variable and/or units does not match the {dataset.name} dataset." ) diff --git a/tests/test_converter.py b/tests/test_converter.py new file mode 100644 index 0000000..7214dd4 --- /dev/null +++ b/tests/test_converter.py @@ -0,0 +1,103 @@ +"""Unit test for converter.""" + +from pathlib import Path +import numpy as np +import pytest +import xarray as xr +from test_datasets import data_folder +from zampy.datasets import EthCanopyHeight +from zampy.datasets import converter +from zampy.datasets.eth_canopy_height import parse_tiff_file + + +path_dummy_data = data_folder / "eth-canopy-height" + +# ruff: noqa: B018 + + +def test_check_convention_not_support(): + convention = "fake_convention" + with pytest.raises(ValueError, match="not supported"): + converter.check_convention(convention) + + +def test_check_convention_not_exist(): + convention = Path("fake_path") + with pytest.raises(FileNotFoundError, match="could not be found"): + converter.check_convention(convention) + + +def test_convert_var(): + """Test _convert_var function.""" + ds = parse_tiff_file( + path_dummy_data / "ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.tif", + ) + ds_convert = converter._convert_var(ds, "height_of_vegetation", "decimeter") + + assert np.allclose( + ds_convert["height_of_vegetation"].values, + ds["height_of_vegetation"].values * 10.0, + equal_nan=True, + ) + + +def test_convert_var_name(): + """Test convert function. + + In this test, no unit-conversion is performed. Only the variable name is updated. + """ + ds = parse_tiff_file( + path_dummy_data / "ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.tif", + ) + ds_convert = converter.convert( + data=ds, dataset=EthCanopyHeight(), convention="ALMA" + ) + + assert list(ds_convert.data_vars)[0] == "Hveg" + + +def test_convert_unit(): + """Test convert function. + + In this test, unit conversion is performed. + """ + ds = parse_tiff_file( + path_dummy_data / "ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.tif", + ) + ds["height_of_vegetation"].attrs["units"] = "decimeter" + ds_convert = converter.convert( + data=ds, dataset=EthCanopyHeight(), convention="ALMA" + ) + + assert np.allclose( + ds_convert["Hveg"].values, + ds["height_of_vegetation"].values / 10.0, + equal_nan=True, + ) + + +def test_convert_no_conversion(): + """Test convert function. + + In this test, no conversion is performed. The input will be returned without change. + """ + dummy_ds = xr.Dataset( + data_vars=dict( + temperature=( + ["latitude", "longitude"], + np.random.randn(1, 2), + {"units": "Celsius"}, + ), + ), + coords=dict( + lon=(["longitude"], [110, 111]), + lat=(["latitude"], [20]), + ), + attrs=dict(units="Weather dataset."), + ) + + ds_convert = converter.convert( + data=dummy_ds, dataset=EthCanopyHeight(), convention="ALMA" + ) + + assert list(ds_convert.data_vars)[0] == "temperature" diff --git a/tests/test_data/eth-canopy-height/ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.tif b/tests/test_data/eth-canopy-height/ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.tif new file mode 100644 index 0000000..c30681b Binary files /dev/null and b/tests/test_data/eth-canopy-height/ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.tif differ diff --git a/tests/test_data/eth-canopy-height/properties.json b/tests/test_data/eth-canopy-height/properties.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dataset_protocol.py b/tests/test_dataset_protocol.py new file mode 100644 index 0000000..66a29b8 --- /dev/null +++ b/tests/test_dataset_protocol.py @@ -0,0 +1,99 @@ +"""Unit test for dataset protocol.""" + +import json +from pathlib import Path +from tempfile import TemporaryDirectory +import numpy as np +import pytest +from zampy.datasets import dataset_protocol +from zampy.datasets.dataset_protocol import SpatialBounds +from zampy.datasets.dataset_protocol import TimeBounds + + +def dummy_property_file(dataset_folder): + """Write a dummy property file for testing.""" + times = TimeBounds(np.datetime64("2020-01-01"), np.datetime64("2020-12-31")) + bbox = SpatialBounds(54, 6, 51, 3) + variables = ["Hveg", "SWnet"] + + dataset_protocol.write_properties_file( + dataset_folder=dataset_folder, + spatial_bounds=bbox, + time_bounds=times, + variable_names=variables, + ) + + +def test_write_properties(): + """Test write properties function.""" + with TemporaryDirectory() as temp_dir: + dataset_folder = Path(temp_dir) + dummy_property_file(dataset_folder) + + json_file_path = dataset_folder / "properties.json" + with json_file_path.open(mode="r", encoding="utf-8") as file: + properties = json.load(file) + + # Verify the written data + assert properties["start_time"] == "2020-01-01" + assert properties["end_time"] == "2020-12-31" + assert properties["north"] == 54 + assert properties["east"] == 6 + assert properties["south"] == 51 + assert properties["west"] == 3 + assert properties["variable_names"] == ["Hveg", "SWnet"] + + +def test_read_properties(): + """Test read properties function.""" + with TemporaryDirectory() as temp_dir: + dataset_folder = Path(temp_dir) + dummy_property_file(dataset_folder) + + ( + spatial_bounds, + time_bounds, + variable_names, + ) = dataset_protocol.read_properties_file(dataset_folder) + + # Verify the returned values + assert spatial_bounds.north == 54 + assert spatial_bounds.east == 6 + assert spatial_bounds.south == 51 + assert spatial_bounds.west == 3 + assert time_bounds.start == "2020-01-01" + assert time_bounds.end == "2020-12-31" + assert variable_names == ["Hveg", "SWnet"] + + +def test_copy_properties_file(): + """Test copy properties file function.""" + # Create temporary directories + with TemporaryDirectory() as temp_dir1, TemporaryDirectory() as temp_dir2: + source_folder = Path(temp_dir1) + target_folder = Path(temp_dir2) + + # Create a properties.json file in the source folder + dummy_property_file(source_folder) + + # Call the function + dataset_protocol.copy_properties_file(source_folder, target_folder) + + # Verify that the file has been copied + target_file_path = target_folder / "properties.json" + assert target_file_path.exists() + + +def test_invalid_spatial_bounds_north_south(): + with pytest.raises(ValueError, match="greater than northern bound"): + SpatialBounds(51, 6, 54, 3) + + +def test_invalid_spatial_bounds_east_west(): + with pytest.raises(ValueError, match="greater than eastern bound"): + SpatialBounds(54, 6, 51, 20) + + +def test_invalid_time_bounds(): + with pytest.raises(ValueError): + TimeBounds(np.datetime64("2021-01-01"), np.datetime64("2020-12-31")) diff --git a/tests/test_datasets/test_eth_canopy_height.py b/tests/test_datasets/test_eth_canopy_height.py new file mode 100644 index 0000000..0beca68 --- /dev/null +++ b/tests/test_datasets/test_eth_canopy_height.py @@ -0,0 +1,175 @@ +"""Unit test for ETH canopy height dataset.""" + +import json +from pathlib import Path +from unittest.mock import patch +import numpy as np +import pytest +import xarray as xr +from zampy.datasets import eth_canopy_height +from zampy.datasets.dataset_protocol import SpatialBounds +from zampy.datasets.dataset_protocol import TimeBounds +from . import data_folder + + +@pytest.fixture(scope="function") +def dummy_dir(tmp_path_factory): + """Create a dummpy directory for testing.""" + return tmp_path_factory.mktemp("data") + + +class TestEthCanopyHeight: + """Test the EthCanopyHeight class.""" + + @patch("urllib.request.urlretrieve") + def test_download(self, mock_urlretrieve, dummy_dir): + """Test download functionality. + + Here we mock the downloading and save property file to a fake path. + """ + times = TimeBounds(np.datetime64("2020-01-01"), np.datetime64("2020-12-31")) + bbox = SpatialBounds(54, 6, 51, 3) + variable = ["height_of_vegetation"] + download_dir = Path(dummy_dir, "download") + + canopy_height_dataset = eth_canopy_height.EthCanopyHeight() + canopy_height_dataset.download( + download_dir=download_dir, + time_bounds=times, + spatial_bounds=bbox, + variable_names=variable, + ) + + # make sure that the download is called + assert mock_urlretrieve.called + + # check property file + with (download_dir / "eth-canopy-height" / "properties.json").open( + mode="r", encoding="utf-8" + ) as file: + json_dict = json.load(file) + # check property + assert json_dict["variable_names"] == variable + + def ingest_dummy_data(self, temp_dir): + """Ingest dummy tif data to nc for other tests.""" + canopy_height_dataset = eth_canopy_height.EthCanopyHeight() + canopy_height_dataset.ingest( + download_dir=data_folder, ingest_dir=Path(temp_dir) + ) + ds = xr.load_dataset( + Path( + temp_dir, + "eth-canopy-height", + "ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.nc", + ) + ) + + return ds, canopy_height_dataset + + def test_ingest(self, dummy_dir): + """Test ingest function.""" + ds, _ = self.ingest_dummy_data(dummy_dir) + + assert type(ds) == xr.Dataset + + def test_load(self, dummy_dir): + """Test load function.""" + _, canopy_height_dataset = self.ingest_dummy_data(dummy_dir) + + times = TimeBounds(np.datetime64("2020-01-01"), np.datetime64("2020-12-31")) + bbox = SpatialBounds(54, 6, 51, 3) + variable = ["height_of_vegetation"] + + ds = canopy_height_dataset.load( + ingest_dir=Path(dummy_dir), + time_bounds=times, + spatial_bounds=bbox, + variable_names=variable, + resolution=1.0, + regrid_method="flox", + ) + + # we assert the regridded coordinates + expected_lat = [51.0, 52.0, 53.0, 54.0] + expected_lon = [3.0, 4.0, 5.0, 6.0] + + np.testing.assert_allclose(ds.latitude.values, expected_lat) + np.testing.assert_allclose(ds.longitude.values, expected_lon) + + def test_convert(self, dummy_dir): + """Test convert function.""" + _, canopy_height_dataset = self.ingest_dummy_data(dummy_dir) + canopy_height_dataset.convert(ingest_dir=Path(dummy_dir), convention="ALMA") + # TODO: finish this test when the function is complete. + + +def test_get_filenames(): + """Test file names generator.""" + bbox = SpatialBounds(54, 8, 51, 3) + expected = [ + "ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.tif", + "ETH_GlobalCanopyHeight_10m_2020_N51E006_Map.tif", + ] + + file_names = eth_canopy_height.get_filenames(bbox) + assert file_names == expected + + +def test_get_filenames_sd(): + """Test file names of standard deviation.""" + bbox = SpatialBounds(54, 8, 51, 3) + expected = [ + "ETH_GlobalCanopyHeight_10m_2020_N51E003_Map_SD.tif", + "ETH_GlobalCanopyHeight_10m_2020_N51E006_Map_SD.tif", + ] + + file_names = eth_canopy_height.get_filenames(bbox, sd_file=True) + assert file_names == expected + + +def test_valid_filenames(): + """Test function to get valid filenames.""" + test_filenames = [ + "ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.tif", + "ETH_GlobalCanopyHeight_10m_2020_N51E004_Map.tif", # fake + "ETH_GlobalCanopyHeight_10m_2020_N51E006_Map.tif", + ] + + expected = [ + "ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.tif", + "ETH_GlobalCanopyHeight_10m_2020_N51E006_Map.tif", + ] + + file_names = eth_canopy_height.get_valid_filenames(test_filenames) + assert file_names == expected + + +def test_parse_tiff_file(): + """Test tiff file parser.""" + dummy_ds = eth_canopy_height.parse_tiff_file( + Path( + data_folder, + "eth-canopy-height", + "ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.tif", + ) + ) + assert type(dummy_ds) == xr.Dataset + + +def test_convert_tiff_to_netcdf(dummy_dir): + """Test tiff to netcdf conversion function.""" + dummy_data = Path( + data_folder, + "eth-canopy-height", + "ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.tif", + ) + eth_canopy_height.convert_tiff_to_netcdf( + ingest_folder=Path(dummy_dir), + file=dummy_data, + ) + + ds = xr.load_dataset( + Path(dummy_dir, "ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.nc") + ) + assert type(ds) == xr.Dataset diff --git a/tests/test_regrid.py b/tests/test_regrid.py new file mode 100644 index 0000000..0094347 --- /dev/null +++ b/tests/test_regrid.py @@ -0,0 +1,133 @@ +"""Unit test for regridding.""" + +import numpy as np +import pytest +from test_datasets import data_folder +from zampy.datasets.dataset_protocol import SpatialBounds +from zampy.datasets.eth_canopy_height import parse_tiff_file +from zampy.utils import regrid + + +path_dummy_data = data_folder / "eth-canopy-height" +XESMF_INSTALLED = True +# Since xesmf is only supported via conda, we need these checks to support +# tests cases running with and without conda environment in CD/CI +try: + import xesmf as _ # noqa: F401 (unused import) +except ImportError: + XESMF_INSTALLED = False + +# ruff: noqa: B018 + + +@pytest.mark.skipif(XESMF_INSTALLED, reason="xesmf is installed") +def assert_xesmf_available() -> None: + """Test assert_xesmf_available function with exception case.""" + with pytest.raises(ImportError, match="Could not import the `xesmf`"): + regrid.assert_xesmf_available() + + +@pytest.fixture +def dummy_dataset(): + ds = parse_tiff_file( + path_dummy_data / "ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.tif", + ) + return ds + + +def test_infer_resolution(dummy_dataset): + """Test resolution inferring function.""" + (resolution_lat, resolution_lon) = regrid.infer_resolution(dummy_dataset) + expected_lat = 0.01 + expected_lon = 0.01 + + assert resolution_lat == pytest.approx(expected_lat, 0.001) + assert resolution_lon == pytest.approx(expected_lon, 0.001) + + +def test_groupby_regrid(dummy_dataset): + """Test groupby regrid function.""" + bbox = SpatialBounds(54, 6, 51, 3) + ds = regrid._groupby_regrid(data=dummy_dataset, spatial_bounds=bbox, resolution=1.0) + expected_lat = [51.0, 52.0, 53.0, 54.0] + expected_lon = [3.0, 4.0, 5.0, 6.0] + + np.testing.assert_allclose(ds.latitude.values, expected_lat) + np.testing.assert_allclose(ds.longitude.values, expected_lon) + + +def test_interp_regrid(dummy_dataset): + """Test interp regrid function.""" + bbox = SpatialBounds(51.1, 3.1, 51, 3) + ds = regrid._interp_regrid(data=dummy_dataset, spatial_bounds=bbox, resolution=0.05) + expected_lat = [51.0, 51.05, 51.1] + expected_lon = [3.0, 3.05, 3.1] + + np.testing.assert_allclose(ds.latitude.values, expected_lat) + np.testing.assert_allclose(ds.longitude.values, expected_lon) + + +def test_flox_regrid_coarser(dummy_dataset): + """Test flox regridding at a coarser resolution + + Note that the native resolution is about lat/lon=0.01. + """ + bbox = SpatialBounds(54, 6, 51, 3) + ds = regrid.flox_regrid(data=dummy_dataset, spatial_bounds=bbox, resolution=1.0) + expected_lat = [51.0, 52.0, 53.0, 54.0] + expected_lon = [3.0, 4.0, 5.0, 6.0] + + np.testing.assert_allclose(ds.latitude.values, expected_lat) + np.testing.assert_allclose(ds.longitude.values, expected_lon) + + +def test_flox_regrid_finer(dummy_dataset): + """Test flox regridding at a finer resolution + + Note that the native resolution is about lat/lon=0.01. + """ + bbox = SpatialBounds(51.02, 3.02, 51, 3) + ds = regrid.flox_regrid(data=dummy_dataset, spatial_bounds=bbox, resolution=0.002) + # only compare first 5 index + expected_lat = [51.0, 51.002, 51.004, 51.006, 51.008] + expected_lon = [3.0, 3.002, 3.004, 3.006, 3.008] + + np.testing.assert_allclose(ds.latitude.values[:5], expected_lat) + np.testing.assert_allclose(ds.longitude.values[:5], expected_lon) + + +def test_flox_regrid_close(dummy_dataset): + """Test flox regridding at a resolution close to native. + + Note that the native resolution is about lat/lon=0.01. + """ + bbox = SpatialBounds(51.1, 3.1, 51, 3) + ds = regrid.flox_regrid(data=dummy_dataset, spatial_bounds=bbox, resolution=0.02) + expected_lat = [51.0, 51.02, 51.04, 51.06, 51.08, 51.1] + expected_lon = [3.0, 3.02, 3.04, 3.06, 3.08, 3.1] + + np.testing.assert_allclose(ds.latitude.values, expected_lat) + np.testing.assert_allclose(ds.longitude.values, expected_lon) + + +def test_regrid_data_flox(dummy_dataset): + bbox = SpatialBounds(54, 6, 51, 3) + ds = regrid.regrid_data( + data=dummy_dataset, spatial_bounds=bbox, resolution=1.0, method="flox" + ) + expected_lat = [51.0, 52.0, 53.0, 54.0] + expected_lon = [3.0, 4.0, 5.0, 6.0] + + np.testing.assert_allclose(ds.latitude.values, expected_lat) + np.testing.assert_allclose(ds.longitude.values, expected_lon) + + +def test_regrid_data_unknown_method(dummy_dataset): + bbox = SpatialBounds(54, 6, 51, 3) + with pytest.raises(ValueError): + regrid.regrid_data( + data=dummy_dataset, + spatial_bounds=bbox, + resolution=1.0, + method="fake_method", + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index ca13b0a..5239c2c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,6 @@ """Unit test for utils functions.""" +import tempfile from pathlib import Path from unittest.mock import patch import numpy as np @@ -9,6 +10,68 @@ from zampy.datasets.dataset_protocol import TimeBounds +def test_tqdm_update(): + """Test tqdm function.""" + # Create an instance of TqdmUpdate + progress_bar = utils.TqdmUpdate(total=100) + progress_bar.update_to(10, 10) + + # Assert that the progress bar's value has been updated correctly + assert progress_bar.n == 100 + + +@patch("requests.head") +def test_get_url_size(mock_head): + """Test url size function.""" + url = "https://example.com/test_file.txt" + + # Create a mock response object + mock_response = mock_head.return_value + mock_response.headers = {"Content-Length": "1024"} + + size = utils.get_url_size(url) + + # Assert that the mock head function was called with the correct URL + mock_head.assert_called_once_with(url) + + # Assert that the returned size is correct + assert size == 1024 + + +def test_get_file_size(): + """Create a temporary file with a size of 1024 bytes.""" + with tempfile.NamedTemporaryFile() as temp_file: + temp_path = Path(temp_file.name) + temp_file.write(b"0" * 1024) + temp_file.flush() + + # Call the get_file_size() function + size = utils.get_file_size(temp_path) + + # Assert that the returned size is correct + assert size == 1024 + + +def test_get_file_size_not_exist(): + """Test with a non-existing file.""" + non_existing_path = Path("non_existing_file.txt") + size = utils.get_file_size(non_existing_path) + assert size == 0 + + +@patch("urllib.request.urlretrieve") +def test_download_url(mock_urlretrieve): + """Test download function.""" + # fake test data + url = "https://example.com/test_file.txt" + fpath = Path("test_file.txt") + overwrite = True + + utils.download_url(url, fpath, overwrite) + # assrt that the urlretrieve function is called. + assert mock_urlretrieve.called + + @pytest.fixture(scope="function") def valid_path_cds(tmp_path_factory): """Create a dummy .cdsapirc file.""" diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..9e35ba2 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,42 @@ +"""Unit test for validation.""" + +import numpy as np +import pytest +from zampy.datasets import EthCanopyHeight +from zampy.datasets import validation +from zampy.datasets.dataset_protocol import SpatialBounds +from zampy.datasets.dataset_protocol import TimeBounds + + +def test_compare_variables_not_match(): + dummy_dataset = EthCanopyHeight() + variables = ["fake_var"] + with pytest.raises(validation.InvalidVariableError): + validation.compare_variables(dummy_dataset, variables) + + +def test_compare_time_bounds_not_cover_start(): + dummy_dataset = EthCanopyHeight() + times = TimeBounds(np.datetime64("1900-01-01"), np.datetime64("2020-12-31")) + with pytest.raises(validation.InvalidTimeBoundsError, match="not cover the start"): + validation.compare_time_bounds(dummy_dataset, times) + + +def test_compare_time_bounds_not_cover_end(): + dummy_dataset = EthCanopyHeight() + times = TimeBounds(np.datetime64("2020-01-01"), np.datetime64("2100-12-31")) + with pytest.raises(validation.InvalidTimeBoundsError, match="not cover the end"): + validation.compare_time_bounds(dummy_dataset, times) + + +def test_validate_download_request(): + """Check function validate download request. + + Note that all the cases with errors are tested separately in other functions. + Here we make sure that the function can be called without error. + """ + dummy_dataset = EthCanopyHeight() + times = TimeBounds(np.datetime64("2020-01-01"), np.datetime64("2020-12-31")) + bbox = SpatialBounds(54, 6, 51, 3) + variables = ["height_of_vegetation"] + validation.validate_download_request(dummy_dataset, "./", times, bbox, variables) diff --git a/tests/test_xesmf_regrid.py b/tests/test_xesmf_regrid.py new file mode 100644 index 0000000..569340e --- /dev/null +++ b/tests/test_xesmf_regrid.py @@ -0,0 +1,48 @@ +"""Unit test for regridding functions with xesmf.""" + +from pathlib import Path +import numpy as np +import pytest +from zampy.datasets.dataset_protocol import SpatialBounds +from zampy.datasets.eth_canopy_height import parse_tiff_file + + +XESMF_INSTALLED = True +try: + import xesmf as _ # noqa: F401 (unused import) + from zampy.utils import xesmf_regrid +except ImportError: + XESMF_INSTALLED = False + + +@pytest.mark.skipif(not XESMF_INSTALLED, reason="xesmf is not installed") +def test_create_new_grid(): + bbox = SpatialBounds(54, 6, 51, 3) + ds = xesmf_regrid.create_new_grid(spatial_bounds=bbox, resolution=1.0) + expected_lat = [51.0, 52.0, 53.0, 54.0] + expected_lon = [3.0, 4.0, 5.0, 6.0] + + np.testing.assert_allclose(ds.latitude.values, expected_lat) + np.testing.assert_allclose(ds.longitude.values, expected_lon) + + +@pytest.mark.skipif(not XESMF_INSTALLED, reason="xesmf is not installed") +def test_xesfm_regrid(): + # load dummy dataset + path_dummy_data = ( + Path(__file__).resolve().parent / "test_data" / "eth-canopy-height" + ) + dummy_dataset = parse_tiff_file( + path_dummy_data / "ETH_GlobalCanopyHeight_10m_2020_N51E003_Map.tif", + ) + + bbox = SpatialBounds(54, 6, 51, 3) + ds = xesmf_regrid.xesfm_regrid( + data=dummy_dataset, spatial_bounds=bbox, resolution=1.0 + ) + + expected_lat = [51.0, 52.0, 53.0, 54.0] + expected_lon = [3.0, 4.0, 5.0, 6.0] + + np.testing.assert_allclose(ds.latitude.values, expected_lat) + np.testing.assert_allclose(ds.longitude.values, expected_lon)