diff --git a/.cirrus.yml b/.cirrus.yml index 969c465d..704f9e74 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -22,15 +22,36 @@ env: # Maximum cache period (in weeks) before forcing a new cache upload. CACHE_PERIOD: "2" # Increment the build number to force new conda cache upload. - CONDA_CACHE_BUILD: "0" + CONDA_CACHE_BUILD: "1" # Increment the build number to force new nox cache upload. - NOX_CACHE_BUILD: "0" + NOX_CACHE_BUILD: "2" # Increment the build number to force new pip cache upload. PIP_CACHE_BUILD: "0" # Pip package to be installed. PIP_CACHE_PACKAGES: "pip setuptools wheel nox pyyaml" # Conda packages to be installed. CONDA_CACHE_PACKAGES: "nox pip pyyaml" + # Use specific custom iris source feature branch. + IRIS_SOURCE: "github:main" + # Git commit hash for iris test data. + IRIS_TEST_DATA_VERSION: "2.2" + # Base directory for the iris-test-data. + IRIS_TEST_DATA_DIR: ${HOME}/iris-test-data + OVERRIDE_TEST_DATA_REPOSITORY: ${IRIS_TEST_DATA_DIR}/test_data + + +# +# YAML alias for the iris-test-data cache. +# +iris_test_data_template: &IRIS_TEST_DATA_TEMPLATE + data_cache: + folder: ${IRIS_TEST_DATA_DIR} + fingerprint_script: + - echo "iris-test-data v${IRIS_TEST_DATA_VERSION}" + populate_script: + - wget --quiet https://github.com/SciTools/iris-test-data/archive/v${IRIS_TEST_DATA_VERSION}.zip -O iris-test-data.zip + - unzip -q iris-test-data.zip + - mv iris-test-data-${IRIS_TEST_DATA_VERSION} ${IRIS_TEST_DATA_DIR} # @@ -88,8 +109,6 @@ test_task: only_if: ${SKIP_TEST_TASK} == "" auto_cancellation: true matrix: - env: - PY_VER: "3.6" env: PY_VER: "3.7" env: @@ -104,6 +123,7 @@ test_task: - echo "${CIRRUS_TASK_NAME}" - echo "${NOX_CACHE_BUILD}" - if [ -n "${IRIS_SOURCE}" ]; then echo "${IRIS_SOURCE}"; fi + << : *IRIS_TEST_DATA_TEMPLATE test_script: - export CONDA_OVERRIDE_LINUX="$(uname -r | cut -d'+' -f1)" - nox --session tests -- --verbose diff --git a/benchmarks/benchmarks/__init__.py b/benchmarks/benchmarks/__init__.py index df5f0b43..c99c91de 100644 --- a/benchmarks/benchmarks/__init__.py +++ b/benchmarks/benchmarks/__init__.py @@ -1 +1,44 @@ """Benchmark tests for iris-esmf-regrid""" + + +def disable_repeat_between_setup(benchmark_object): + """ + Decorator for benchmarks where object persistence would be inappropriate. + + E.g: + * Data is realised during testing. + + Can be applied to benchmark classes/methods/functions. + + https://asv.readthedocs.io/en/stable/benchmarks.html#timing-benchmarks + + """ + # Prevent repeat runs between setup() runs - object(s) will persist after 1st. + benchmark_object.number = 1 + # Compensate for reduced certainty by increasing number of repeats. + # (setup() is run between each repeat). + # Minimum 5 repeats, run up to 30 repeats / 20 secs whichever comes first. + benchmark_object.repeat = (5, 30, 20.0) + # ASV uses warmup to estimate benchmark time before planning the real run. + # Prevent this, since object(s) will persist after first warmup run, + # which would give ASV misleading info (warmups ignore ``number``). + benchmark_object.warmup_time = 0.0 + + return benchmark_object + + +def skip_benchmark(benchmark_object): + """ + Decorator for benchmarks skipping benchmarks. + """ + + def setup_cache(self): + pass + + def setup(*args): + raise NotImplementedError + + benchmark_object.setup_cache = setup_cache + benchmark_object.setup = setup + + return benchmark_object diff --git a/benchmarks/benchmarks/ci/esmf_regridder.py b/benchmarks/benchmarks/ci/esmf_regridder.py index 9db9c767..3a0efc01 100644 --- a/benchmarks/benchmarks/ci/esmf_regridder.py +++ b/benchmarks/benchmarks/ci/esmf_regridder.py @@ -1,16 +1,23 @@ """Quick running benchmarks for :mod:`esmf_regrid.esmf_regridder`.""" +import os from pathlib import Path import numpy as np import dask.array as da import iris from iris.cube import Cube +from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD +from benchmarks import disable_repeat_between_setup from esmf_regrid.esmf_regridder import GridInfo +from esmf_regrid.experimental.unstructured_scheme import ( + GridToMeshESMFRegridder, + MeshToGridESMFRegridder, +) from esmf_regrid.schemes import ESMFAreaWeightedRegridder -from ..generate_data import _grid_cube +from ..generate_data import _grid_cube, _gridlike_mesh_cube def _make_small_grid_args(): @@ -47,11 +54,11 @@ def time_make_grid(self): time_make_grid.version = 1 -class TimeRegridding: - params = ["similar", "large source", "large target", "mixed"] +class MultiGridCompare: + params = ["similar", "large_source", "large_target", "mixed"] param_names = ["source/target difference"] - def setup(self, type): + def get_args(self, tp): lon_bounds = (-180, 180) lat_bounds = (-90, 90) n_lons_src = 20 @@ -59,18 +66,44 @@ def setup(self, type): n_lons_tgt = 20 n_lats_tgt = 40 h = 100 - if type == "large source": + if tp == "large_source": n_lons_src = 100 n_lats_src = 200 - if type == "large target": + if tp == "large_target": n_lons_tgt = 100 n_lats_tgt = 200 + alt_coord_system = tp == "mixed" + args = ( + lon_bounds, + lat_bounds, + n_lons_src, + n_lats_src, + n_lons_tgt, + n_lats_tgt, + h, + alt_coord_system, + ) + return args + + +class TimeRegridding(MultiGridCompare): + def setup(self, tp): + ( + lon_bounds, + lat_bounds, + n_lons_src, + n_lats_src, + n_lons_tgt, + n_lats_tgt, + h, + alt_coord_system, + ) = self.get_args(tp) grid = _grid_cube( n_lons_src, n_lats_src, lon_bounds, lat_bounds, - alt_coord_system=(type == "mixed"), + alt_coord_system=alt_coord_system, ) tgt = _grid_cube(n_lons_tgt, n_lats_tgt, lon_bounds, lat_bounds) src_data = np.arange(n_lats_src * n_lons_src * h).reshape( @@ -79,25 +112,20 @@ def setup(self, type): src = Cube(src_data) src.add_dim_coord(grid.coord("latitude"), 0) src.add_dim_coord(grid.coord("longitude"), 1) - self.regridder = ESMFAreaWeightedRegridder(src, tgt) + self.regrid_class = ESMFAreaWeightedRegridder + self.regridder = self.regrid_class(src, tgt) self.src = src + self.tgt = tgt - def time_perform_regridding(self, type): + def time_prepare_regridding(self, tp): + _ = self.regrid_class(self.src, self.tgt) + + def time_perform_regridding(self, tp): _ = self.regridder(self.src) +@disable_repeat_between_setup class TimeLazyRegridding: - # Prevent repeat runs between setup() runs - data won't be lazy after 1st. - number = 1 - # Compensate for reduced certainty by increasing number of repeats. - # (setup() is run between each repeat). - # Minimum 5 repeats, run up to 30 repeats / 20 secs whichever comes first. - repeat = (5, 30, 20.0) - # Prevent ASV running its warmup, which ignores `number` and would - # therefore get a false idea of typical run time since the data would stop - # being lazy. - warmup_time = 0.0 - def setup_cache(self): SYNTH_DATA_DIR = Path().cwd() / "tmp_data" SYNTH_DATA_DIR.mkdir(exist_ok=True) @@ -146,3 +174,240 @@ def time_lazy_regridding(self, cache): def time_regridding_realisation(self, cache): assert self.result.has_lazy_data() _ = self.result.data + + +class TimeMeshToGridRegridding(TimeRegridding): + def setup(self, tp): + ( + lon_bounds, + lat_bounds, + n_lons_src, + n_lats_src, + n_lons_tgt, + n_lats_tgt, + h, + alt_coord_system_src, + ) = self.get_args(tp) + src_mesh = _gridlike_mesh_cube(n_lons_src, n_lats_src).mesh + tgt = _grid_cube( + n_lons_tgt, + n_lats_tgt, + lon_bounds, + lat_bounds, + alt_coord_system=alt_coord_system_src, + ) + src_data = np.arange(n_lats_src * n_lons_src * h).reshape([-1, h]) + src = Cube(src_data) + mesh_coord_x, mesh_coord_y = src_mesh.to_MeshCoords("face") + src.add_aux_coord(mesh_coord_x, 0) + src.add_aux_coord(mesh_coord_y, 0) + + self.regrid_class = MeshToGridESMFRegridder + self.regridder = self.regrid_class(src, tgt) + self.src = src + self.tgt = tgt + + +@disable_repeat_between_setup +class TimeLazyMeshToGridRegridding: + def setup_cache(self): + SYNTH_DATA_DIR = Path().cwd() / "tmp_data" + SYNTH_DATA_DIR.mkdir(exist_ok=True) + file = str(SYNTH_DATA_DIR.joinpath("chunked_cube.nc")) + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + n_lons_src = 100 + n_lats_src = 200 + n_lons_tgt = 20 + n_lats_tgt = 40 + h = 2000 + src_mesh = _gridlike_mesh_cube(n_lons_src, n_lats_src).mesh + tgt = _grid_cube(n_lons_tgt, n_lats_tgt, lon_bounds, lat_bounds) + + chunk_size = [n_lats_src * n_lons_src, 10] + src_data = da.ones([n_lats_src * n_lons_src, h], chunks=chunk_size) + src = Cube(src_data) + mesh_coord_x, mesh_coord_y = src_mesh.to_MeshCoords("face") + src.add_aux_coord(mesh_coord_x, 0) + src.add_aux_coord(mesh_coord_y, 0) + + iris.save(src, file, chunksizes=chunk_size) + # Construct regridder with a loaded version of the grid for consistency. + with PARSE_UGRID_ON_LOAD.context(): + loaded_src = iris.load_cube(file) + regridder = MeshToGridESMFRegridder(loaded_src, tgt) + + return regridder, file + + def setup(self, cache): + regridder, file = cache + with PARSE_UGRID_ON_LOAD.context(): + self.src = iris.load_cube(file) + cube = iris.load_cube(file) + self.result = regridder(cube) + + def time_lazy_regridding(self, cache): + assert self.src.has_lazy_data() + regridder, _ = cache + _ = regridder(self.src) + + def time_regridding_realisation(self, cache): + assert self.result.has_lazy_data() + _ = self.result.data + + +class TimeGridToMeshRegridding(TimeRegridding): + def setup(self, tp): + ( + lon_bounds, + lat_bounds, + n_lons_src, + n_lats_src, + n_lons_tgt, + n_lats_tgt, + h, + alt_coord_system, + ) = self.get_args(tp) + grid = _grid_cube( + n_lons_src, + n_lats_src, + lon_bounds, + lat_bounds, + alt_coord_system=alt_coord_system, + ) + src_data = np.arange(n_lats_src * n_lons_src * h).reshape( + [n_lats_src, n_lons_src, h] + ) + src = Cube(src_data) + src.add_dim_coord(grid.coord("latitude"), 0) + src.add_dim_coord(grid.coord("longitude"), 1) + tgt = _gridlike_mesh_cube(n_lons_tgt, n_lats_tgt) + self.regrid_class = GridToMeshESMFRegridder + self.regridder = self.regrid_class(src, tgt) + self.src = src + self.tgt = tgt + + +@disable_repeat_between_setup +class TimeLazyGridToMeshRegridding: + def setup_cache(self): + SYNTH_DATA_DIR = Path().cwd() / "tmp_data" + SYNTH_DATA_DIR.mkdir(exist_ok=True) + file = str(SYNTH_DATA_DIR.joinpath("chunked_cube.nc")) + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + n_lons_src = 100 + n_lats_src = 200 + n_lons_tgt = 20 + n_lats_tgt = 40 + h = 2000 + grid = _grid_cube(n_lons_src, n_lats_src, lon_bounds, lat_bounds) + + chunk_size = [n_lats_src, n_lons_src, 10] + src_data = da.ones([n_lats_src, n_lons_src, h], chunks=chunk_size) + src = Cube(src_data) + src.add_dim_coord(grid.coord("latitude"), 0) + src.add_dim_coord(grid.coord("longitude"), 1) + tgt = _gridlike_mesh_cube(n_lons_tgt, n_lats_tgt) + iris.save(src, file, chunksizes=chunk_size) + # Construct regridder with a loaded version of the grid for consistency. + loaded_src = iris.load_cube(file) + regridder = GridToMeshESMFRegridder(loaded_src, tgt) + + return regridder, file + + def setup(self, cache): + regridder, file = cache + self.src = iris.load_cube(file) + cube = iris.load_cube(file) + self.result = regridder(cube) + + def time_lazy_regridding(self, cache): + assert self.src.has_lazy_data() + regridder, _ = cache + _ = regridder(self.src) + + def time_regridding_realisation(self, cache): + assert self.result.has_lazy_data() + _ = self.result.data + + +class TimeRegridderIO(MultiGridCompare): + params = [MultiGridCompare.params, ["mesh_to_grid", "grid_to_mesh"]] + param_names = MultiGridCompare.param_names + ["regridder type"] + + def setup_cache(self): + from esmf_regrid.experimental.io import save_regridder + + SYNTH_DATA_DIR = Path().cwd() / "tmp_data" + SYNTH_DATA_DIR.mkdir(exist_ok=True) + + destination_file = str(SYNTH_DATA_DIR.joinpath("destination.nc")) + file_dict = {"destination": destination_file} + + for tp in self.params[0]: + ( + lon_bounds, + lat_bounds, + n_lons_src, + n_lats_src, + n_lons_tgt, + n_lats_tgt, + _, + alt_coord_system, + ) = self.get_args(tp) + src_grid = _grid_cube( + n_lons_src, + n_lats_src, + lon_bounds, + lat_bounds, + alt_coord_system=alt_coord_system, + ) + tgt_grid = _grid_cube( + n_lons_tgt, + n_lats_tgt, + lon_bounds, + lat_bounds, + alt_coord_system=alt_coord_system, + ) + src_mesh_cube = _gridlike_mesh_cube( + n_lons_src, + n_lats_src, + ) + tgt_mesh_cube = _gridlike_mesh_cube( + n_lons_tgt, + n_lats_tgt, + ) + + rg_dict = {} + rg_dict["mesh_to_grid"] = MeshToGridESMFRegridder(src_mesh_cube, tgt_grid) + rg_dict["grid_to_mesh"] = GridToMeshESMFRegridder(src_grid, tgt_mesh_cube) + + for rgt in self.params[1]: + regridder = rg_dict[rgt] + source_file = str(SYNTH_DATA_DIR.joinpath(f"source_{tp}_{rgt}.nc")) + + save_regridder(regridder, source_file) + + file_dict[(tp, rgt)] = source_file + return file_dict + + def setup(self, file_dict, tp, rgt): + from esmf_regrid.experimental.io import load_regridder, save_regridder + + self.load_regridder = load_regridder + self.save_regridder = save_regridder + + self.source_file = file_dict[(tp, rgt)] + self.destination_file = file_dict["destination"] + self.regridder = load_regridder(self.source_file) + + def teardown(self, _, tp, rgt): + if os.path.exists(self.destination_file): + os.remove(self.destination_file) + + def time_save(self, _, tp, rgt): + self.save_regridder(self.regridder, self.destination_file) + + def time_load(self, _, tp, rgt): + _ = self.load_regridder(self.source_file) diff --git a/benchmarks/benchmarks/generate_data.py b/benchmarks/benchmarks/generate_data.py index b0700a3a..ac1029ea 100644 --- a/benchmarks/benchmarks/generate_data.py +++ b/benchmarks/benchmarks/generate_data.py @@ -15,9 +15,11 @@ from subprocess import CalledProcessError, check_output, run from os import environ from pathlib import Path +import re from textwrap import dedent from iris import load_cube +from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD #: Python executable used by :func:`run_function_elsewhere`, set via env #: variable of same name. Must be path of Python within an environment that @@ -33,6 +35,17 @@ error = "Env variable DATA_GEN_PYTHON not a runnable python executable path." raise ValueError(error) +default_data_dir = (Path(__file__).parent.parent / ".data").resolve() +BENCHMARK_DATA = Path(environ.get("BENCHMARK_DATA", default_data_dir)) +if BENCHMARK_DATA == default_data_dir: + BENCHMARK_DATA.mkdir(exist_ok=True) +elif not BENCHMARK_DATA.is_dir(): + message = f"Not a directory: {BENCHMARK_DATA} ." + raise ValueError(message) + +# Flag to allow the rebuilding of synthetic data. +REUSE_DATA = True + def run_function_elsewhere(func_to_run, *args, **kwargs): """ @@ -108,20 +121,67 @@ def external(*args, **kwargs): cube = original(*args, **kwargs) save(cube, save_path) - save_dir = (Path(__file__).parent.parent / ".data").resolve() - save_dir.mkdir(exist_ok=True) - # TODO: caching? Currently written assuming overwrite every time. - save_path = save_dir / "_grid_cube.nc" - - _ = run_function_elsewhere( - external, + file_name_sections = [ + "_grid_cube", n_lons, n_lats, lon_outer_bounds, lat_outer_bounds, circular, - alt_coord_system=alt_coord_system, - save_path=str(save_path), - ) + alt_coord_system, + ] + file_name = "_".join(str(section) for section in file_name_sections) + # Remove 'unsafe' characters. + file_name = re.sub(r"\W+", "", file_name) + save_path = (BENCHMARK_DATA / file_name).with_suffix(".nc") + + if not REUSE_DATA or not save_path.is_file(): + _ = run_function_elsewhere( + external, + n_lons, + n_lats, + lon_outer_bounds, + lat_outer_bounds, + circular, + alt_coord_system=alt_coord_system, + save_path=str(save_path), + ) + return_cube = load_cube(str(save_path)) return return_cube + + +def _gridlike_mesh_cube(n_lons, n_lats): + """Wrapper for calling _gridlike_mesh via :func:`run_function_elsewhere`.""" + + def external(*args, **kwargs): + """ + Prep and call _gridlike_mesh, saving to a NetCDF file. + + Saving to a file allows the original python executable to pick back up. + + """ + from iris import save + + from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__mesh_to_MeshInfo import ( + _gridlike_mesh_cube as original, + ) + + save_path = kwargs.pop("save_path") + + cube = original(*args, **kwargs) + save(cube, save_path) + + save_path = BENCHMARK_DATA / f"_mesh_cube_{n_lons}_{n_lats}.nc" + + if not REUSE_DATA or not save_path.is_file(): + _ = run_function_elsewhere( + external, + n_lons, + n_lats, + save_path=str(save_path), + ) + + with PARSE_UGRID_ON_LOAD.context(): + return_cube = load_cube(str(save_path)) + return return_cube diff --git a/benchmarks/benchmarks/long/esmf_regridder.py b/benchmarks/benchmarks/long/esmf_regridder.py new file mode 100644 index 00000000..1a655587 --- /dev/null +++ b/benchmarks/benchmarks/long/esmf_regridder.py @@ -0,0 +1,275 @@ +"""Slower benchmarks for :mod:`esmf_regrid.esmf_regridder`.""" + +import os +from pathlib import Path + +import numpy as np +import dask.array as da +import iris +from iris.cube import Cube + +from benchmarks import disable_repeat_between_setup, skip_benchmark +from esmf_regrid.experimental.io import load_regridder, save_regridder +from esmf_regrid.experimental.unstructured_scheme import ( + GridToMeshESMFRegridder, + MeshToGridESMFRegridder, +) +from esmf_regrid.schemes import ESMFAreaWeightedRegridder +from ..generate_data import _grid_cube, _gridlike_mesh_cube + + +class PrepareScalabilityGridToGrid: + timeout = 180 + params = [50, 100, 200, 400, 600, 800] + param_names = ["grid width"] + height = 100 + regridder = ESMFAreaWeightedRegridder + + def src_cube(self, n): + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + src = _grid_cube(n, n, lon_bounds, lat_bounds) + return src + + def tgt_cube(self, n): + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + grid = _grid_cube(n + 1, n + 1, lon_bounds, lat_bounds) + return grid + + def setup(self, n): + self.src = self.src_cube(n) + self.tgt = self.tgt_cube(n) + + def time_prepare(self, n): + _ = self.regridder(self.src, self.tgt) + + +class PrepareScalabilityMeshToGrid(PrepareScalabilityGridToGrid): + regridder = MeshToGridESMFRegridder + + def src_cube(self, n): + src = _gridlike_mesh_cube(n, n) + return src + + def setup_cache(self): + SYNTH_DATA_DIR = Path().cwd() / "tmp_data" + SYNTH_DATA_DIR.mkdir(exist_ok=True) + destination_file = str(SYNTH_DATA_DIR.joinpath("dest_rg.nc")) + file_dict = {"destination": destination_file} + for n in self.params: + super().setup(n) + rg = self.regridder(self.src, self.tgt) + source_file = str(SYNTH_DATA_DIR.joinpath(f"source_rg_{n}.nc")) + save_regridder(rg, source_file) + file_dict[n] = source_file + return file_dict + + def setup(self, file_dict, n): + super().setup(n) + self.source_file = file_dict[n] + self.destination_file = file_dict["destination"] + self.rg = load_regridder(self.source_file) + + def teardown(self, _, n): + if os.path.exists(self.destination_file): + os.remove(self.destination_file) + + def time_load(self, _, n): + load_regridder(self.source_file) + + def time_save(self, _, n): + save_regridder(self.rg, self.destination_file) + + def time_prepare(self, _, n): + super().time_prepare(n) + + +class PrepareScalabilityGridToMesh(PrepareScalabilityGridToGrid): + regridder = GridToMeshESMFRegridder + + def tgt_cube(self, n): + tgt = _gridlike_mesh_cube(n + 1, n + 1) + return tgt + + def setup_cache(self): + SYNTH_DATA_DIR = Path().cwd() / "tmp_data" + SYNTH_DATA_DIR.mkdir(exist_ok=True) + destination_file = str(SYNTH_DATA_DIR.joinpath("dest_rg.nc")) + file_dict = {"destination": destination_file} + for n in self.params: + super().setup(n) + rg = self.regridder(self.src, self.tgt) + source_file = str(SYNTH_DATA_DIR.joinpath(f"source_rg_{n}.nc")) + save_regridder(rg, source_file) + file_dict[n] = source_file + return file_dict + + def setup(self, file_dict, n): + super().setup(n) + self.source_file = file_dict[n] + self.destination_file = file_dict["destination"] + self.rg = load_regridder(self.source_file) + + def teardown(self, _, n): + if os.path.exists(self.destination_file): + os.remove(self.destination_file) + + def time_load(self, _, n): + load_regridder(self.source_file) + + def time_save(self, _, n): + save_regridder(self.rg, self.destination_file) + + def time_prepare(self, _, n): + super().time_prepare(n) + + +@disable_repeat_between_setup +class PerformScalabilityGridToGrid: + params = [100, 200, 400, 600, 800, 1000] + param_names = ["height"] + grid_size = 400 + # Define the target grid to be smaller so that time spent realising a large array + # does not dominate the time spent on regridding calculation. A number which is + # not a factor of the grid size is chosen so that the two grids will be slightly + # misaligned. + target_grid_size = 41 + chunk_size = [grid_size, grid_size, 10] + regridder = ESMFAreaWeightedRegridder + file_name = "chunked_cube.nc" + + def src_cube(self, height): + data = da.ones([self.grid_size, self.grid_size, height], chunks=self.chunk_size) + src = Cube(data) + return src + + def add_src_metadata(self, cube): + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + grid = _grid_cube(self.grid_size, self.grid_size, lon_bounds, lat_bounds) + cube.add_dim_coord(grid.coord("latitude"), 0) + cube.add_dim_coord(grid.coord("longitude"), 1) + return cube + + def tgt_cube(self): + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + grid = _grid_cube( + self.target_grid_size, self.target_grid_size, lon_bounds, lat_bounds + ) + return grid + + def setup_cache(self): + SYNTH_DATA_DIR = Path().cwd() / "tmp_data" + SYNTH_DATA_DIR.mkdir(exist_ok=True) + file = str(SYNTH_DATA_DIR.joinpath(self.file_name)) + + src = self.src_cube(max(self.params)) + # While iris is not able to save meshes, we add these after loading. + # TODO: change this back after iris allows mesh saving. + iris.save(src, file, chunksizes=self.chunk_size) + loaded_src = iris.load_cube(file) + loaded_src = self.add_src_metadata(loaded_src) + tgt = self.tgt_cube() + rg = self.regridder(loaded_src, tgt) + return rg, file + + def setup(self, cache, height): + regridder, file = cache + src = iris.load_cube(file)[..., :height] + self.src = self.add_src_metadata(src) + # Realise data. + self.src.data + cube = iris.load_cube(file)[..., :height] + cube = self.add_src_metadata(cube) + self.result = regridder(cube) + + def time_perform(self, cache, height): + assert not self.src.has_lazy_data() + rg, _ = cache + _ = rg(self.src) + + def time_lazy_perform(self, cache, height): + assert self.result.has_lazy_data() + _ = self.result.data + + +class PerformScalabilityMeshToGrid(PerformScalabilityGridToGrid): + regridder = MeshToGridESMFRegridder + chunk_size = [PerformScalabilityGridToGrid.grid_size ^ 2, 10] + file_name = "chunked_cube_1d.nc" + + def setup_cache(self): + return super().setup_cache() + + def src_cube(self, height): + data = da.ones( + [self.grid_size * self.grid_size, height], chunks=self.chunk_size + ) + src = Cube(data) + return src + + def add_src_metadata(self, cube): + from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__mesh_to_MeshInfo import ( + _gridlike_mesh, + ) + + mesh = _gridlike_mesh(self.grid_size, self.grid_size) + mesh_coord_x, mesh_coord_y = mesh.to_MeshCoords("face") + cube.add_aux_coord(mesh_coord_x, 0) + cube.add_aux_coord(mesh_coord_y, 0) + return cube + + +class PerformScalabilityGridToMesh(PerformScalabilityGridToGrid): + regridder = GridToMeshESMFRegridder + + def setup_cache(self): + return super().setup_cache() + + def tgt_cube(self): + from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__mesh_to_MeshInfo import ( + _gridlike_mesh, + ) + + tgt = Cube(np.ones([self.target_grid_size * self.target_grid_size])) + mesh = _gridlike_mesh(self.target_grid_size, self.target_grid_size) + mesh_coord_x, mesh_coord_y = mesh.to_MeshCoords("face") + tgt.add_aux_coord(mesh_coord_x, 0) + tgt.add_aux_coord(mesh_coord_y, 0) + return tgt + + +# These benchmarks unusually long and are resource intensive so are skipped. +# They can be run by manually removing the skip. +@skip_benchmark +class PerformScalability1kGridToGrid(PerformScalabilityGridToGrid): + timeout = 600 + grid_size = 1100 + chunk_size = [grid_size, grid_size, 10] + # Define the target grid to be smaller so that time spent realising a large array + # does not dominate the time spent on regridding calculation. A number which is + # not a factor of the grid size is chosen so that the two grids will be slightly + # misaligned. + target_grid_size = 111 + + def setup_cache(self): + return super().setup_cache() + + +# These benchmarks unusually long and are resource intensive so are skipped. +# They can be run by manually removing the skip. +@skip_benchmark +class PerformScalability2kGridToGrid(PerformScalabilityGridToGrid): + timeout = 600 + grid_size = 2200 + chunk_size = [grid_size, grid_size, 10] + # Define the target grid to be smaller so that time spent realising a large array + # does not dominate the time spent on regridding calculation. A number which is + # not a factor of the grid size is chosen so that the two grids will be slightly + # misaligned. + target_grid_size = 221 + + def setup_cache(self): + return super().setup_cache() diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..5df17423 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,7 @@ +coverage: + status: + project: + default: + target: auto + threshold: 0.5% # coverage can drop by up to % while still posting success + patch: off \ No newline at end of file diff --git a/esmf_regrid/esmf_regridder.py b/esmf_regrid/esmf_regridder.py index 6178b46d..4ee6f8a3 100644 --- a/esmf_regrid/esmf_regridder.py +++ b/esmf_regrid/esmf_regridder.py @@ -5,6 +5,7 @@ from numpy import ma import scipy.sparse +import esmf_regrid from ._esmf_sdo import GridInfo __all__ = [ @@ -77,7 +78,9 @@ def __init__(self, src, tgt, precomputed_weights=None): self.src = src self.tgt = tgt + self.esmf_regrid_version = esmf_regrid.__version__ if precomputed_weights is None: + self.esmf_version = ESMF.__version__ weights_dict = _get_regrid_weights_dict( src.make_esmf_field(), tgt.make_esmf_field() ) @@ -99,6 +102,7 @@ def __init__(self, src, tgt, precomputed_weights=None): precomputed_weights.shape, ) ) + self.esmf_version = None self.weight_matrix = precomputed_weights def regrid(self, src_array, norm_type="fracarea", mdtol=1): diff --git a/esmf_regrid/experimental/io.py b/esmf_regrid/experimental/io.py new file mode 100644 index 00000000..0e764d76 --- /dev/null +++ b/esmf_regrid/experimental/io.py @@ -0,0 +1,179 @@ +"""Provides load/save functions for regridders.""" + +import iris +from iris.coords import AuxCoord +from iris.cube import Cube, CubeList +from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD +import numpy as np +import scipy.sparse + +import esmf_regrid +from esmf_regrid.experimental.unstructured_scheme import ( + GridToMeshESMFRegridder, + MeshToGridESMFRegridder, +) + + +SUPPORTED_REGRIDDERS = [ + GridToMeshESMFRegridder, + MeshToGridESMFRegridder, +] +REGRIDDER_NAME_MAP = {rg_class.__name__: rg_class for rg_class in SUPPORTED_REGRIDDERS} +SOURCE_NAME = "regridder_source_field" +TARGET_NAME = "regridder_target_field" +WEIGHTS_NAME = "regridder_weights" +WEIGHTS_SHAPE_NAME = "weights_shape" +WEIGHTS_ROW_NAME = "weight_matrix_rows" +WEIGHTS_COL_NAME = "weight_matrix_columns" +REGRIDDER_TYPE = "regridder_type" +VERSION_ESMF = "ESMF_version" +VERSION_INITIAL = "esmf_regrid_version_on_initialise" +MDTOL = "mdtol" + + +def save_regridder(rg, filename): + """ + Save a regridder scheme instance. + + Saves either a `GridToMeshESMFRegridder` or a `MeshToGridESMFRegridder`. + + Parameters + ---------- + rg : GridToMeshESMFRegridder, MeshToGridESMFRegridder + The regridder instance to save. + filename : str + The file name to save to. + """ + regridder_type = rg.__class__.__name__ + if regridder_type == "GridToMeshESMFRegridder": + src_grid = (rg.grid_y, rg.grid_x) + src_shape = [len(coord.points) for coord in src_grid] + src_data = np.zeros(src_shape) + src_cube = Cube(src_data, var_name=SOURCE_NAME, long_name=SOURCE_NAME) + src_cube.add_dim_coord(src_grid[0], 0) + src_cube.add_dim_coord(src_grid[1], 1) + + tgt_mesh = rg.mesh + tgt_data = np.zeros(tgt_mesh.face_node_connectivity.indices.shape[0]) + tgt_cube = Cube(tgt_data, var_name=TARGET_NAME, long_name=TARGET_NAME) + for coord in tgt_mesh.to_MeshCoords("face"): + tgt_cube.add_aux_coord(coord, 0) + elif regridder_type == "MeshToGridESMFRegridder": + src_mesh = rg.mesh + src_data = np.zeros(src_mesh.face_node_connectivity.indices.shape[0]) + src_cube = Cube(src_data, var_name=SOURCE_NAME, long_name=SOURCE_NAME) + for coord in src_mesh.to_MeshCoords("face"): + src_cube.add_aux_coord(coord, 0) + + tgt_grid = (rg.grid_y, rg.grid_x) + tgt_shape = [len(coord.points) for coord in tgt_grid] + tgt_data = np.zeros(tgt_shape) + tgt_cube = Cube(tgt_data, var_name=TARGET_NAME, long_name=TARGET_NAME) + tgt_cube.add_dim_coord(tgt_grid[0], 0) + tgt_cube.add_dim_coord(tgt_grid[1], 1) + else: + msg = ( + f"Expected a regridder of type `GridToMeshESMFRegridder` or " + f"`MeshToGridESMFRegridder`, got type {regridder_type}." + ) + raise TypeError(msg) + + weight_matrix = rg.regridder.weight_matrix + reformatted_weight_matrix = weight_matrix.tocoo() + weight_data = reformatted_weight_matrix.data + weight_rows = reformatted_weight_matrix.row + weight_cols = reformatted_weight_matrix.col + weight_shape = reformatted_weight_matrix.shape + + esmf_version = rg.regridder.esmf_version + esmf_regrid_version = rg.regridder.esmf_regrid_version + save_version = esmf_regrid.__version__ + + # Currently, all schemes use the fracarea normalization. + normalization = "fracarea" + + mdtol = rg.mdtol + attributes = { + "title": "iris-esmf-regrid regridding scheme", + REGRIDDER_TYPE: regridder_type, + VERSION_ESMF: esmf_version, + VERSION_INITIAL: esmf_regrid_version, + "esmf_regrid_version_on_save": save_version, + "normalization": normalization, + MDTOL: mdtol, + } + + weights_cube = Cube(weight_data, var_name=WEIGHTS_NAME, long_name=WEIGHTS_NAME) + row_coord = AuxCoord( + weight_rows, var_name=WEIGHTS_ROW_NAME, long_name=WEIGHTS_ROW_NAME + ) + col_coord = AuxCoord( + weight_cols, var_name=WEIGHTS_COL_NAME, long_name=WEIGHTS_COL_NAME + ) + weights_cube.add_aux_coord(row_coord, 0) + weights_cube.add_aux_coord(col_coord, 0) + + weight_shape_cube = Cube( + weight_shape, + var_name=WEIGHTS_SHAPE_NAME, + long_name=WEIGHTS_SHAPE_NAME, + ) + + # Avoid saving bug by placing the mesh cube second. + # TODO: simplify this when this bug is fixed in iris. + if regridder_type == "GridToMeshESMFRegridder": + cube_list = CubeList([src_cube, tgt_cube, weights_cube, weight_shape_cube]) + elif regridder_type == "MeshToGridESMFRegridder": + cube_list = CubeList([tgt_cube, src_cube, weights_cube, weight_shape_cube]) + + for cube in cube_list: + cube.attributes = attributes + + iris.fileformats.netcdf.save(cube_list, filename) + + +def load_regridder(filename): + """ + Load a regridder scheme instance. + + Loads either a `GridToMeshESMFRegridder` or a `MeshToGridESMFRegridder`. + + Parameters + ---------- + filename : str + The file name to load from. + """ + with PARSE_UGRID_ON_LOAD.context(): + cubes = iris.load(filename) + + # Extract the source, target and metadata information. + src_cube = cubes.extract_cube(SOURCE_NAME) + tgt_cube = cubes.extract_cube(TARGET_NAME) + weights_cube = cubes.extract_cube(WEIGHTS_NAME) + weight_shape_cube = cubes.extract_cube(WEIGHTS_SHAPE_NAME) + + # Determine the regridder type. + regridder_type = weights_cube.attributes[REGRIDDER_TYPE] + assert regridder_type in REGRIDDER_NAME_MAP.keys() + scheme = REGRIDDER_NAME_MAP[regridder_type] + + # Reconstruct the weight matrix. + weight_data = weights_cube.data + weight_rows = weights_cube.coord(WEIGHTS_ROW_NAME).points + weight_cols = weights_cube.coord(WEIGHTS_COL_NAME).points + weight_shape = weight_shape_cube.data + weight_matrix = scipy.sparse.csr_matrix( + (weight_data, (weight_rows, weight_cols)), shape=weight_shape + ) + + mdtol = weights_cube.attributes[MDTOL] + + regridder = scheme( + src_cube, tgt_cube, mdtol=mdtol, precomputed_weights=weight_matrix + ) + + esmf_version = weights_cube.attributes[VERSION_ESMF] + regridder.regridder.esmf_version = esmf_version + esmf_regrid_version = weights_cube.attributes[VERSION_INITIAL] + regridder.regridder.esmf_regrid_version = esmf_regrid_version + return regridder diff --git a/esmf_regrid/experimental/unstructured_scheme.py b/esmf_regrid/experimental/unstructured_scheme.py new file mode 100644 index 00000000..cd67e299 --- /dev/null +++ b/esmf_regrid/experimental/unstructured_scheme.py @@ -0,0 +1,736 @@ +"""Provides an iris interface for unstructured regridding.""" + +import copy +import functools + +import iris +from iris.analysis._interpolation import get_xy_dim_coords +import numpy as np + +from esmf_regrid.esmf_regridder import GridInfo, Regridder +from esmf_regrid.experimental.unstructured_regrid import MeshInfo + + +def _map_complete_blocks(src, func, dims, out_sizes): + """ + Apply a function to complete blocks. + + Based on :func:`iris._lazy_data.map_complete_blocks`. + By "complete blocks" we mean that certain dimensions are enforced to be + spanned by single chunks. + Unlike the iris version of this function, this function also handles + cases where the input and output have a different number of dimensions. + The particular cases this function is designed for involves collapsing + a 2D grid to a 1D mesh and expanding a 1D mesh to a 2D grid. Cases + involving the mapping between the same number of dimensions should still + behave the same as before. + + Parameters + ---------- + src : cube + Source :class:`~iris.cube.Cube` that function is applied to. + func : function + Function to apply. + dims : tuple of int + Dimensions that cannot be chunked. + out_sizes : tuple of int + Output size of dimensions that cannot be chunked. + + Returns + ------- + array + Either a :class:`dask.array.array`, or :class:`numpy.ndarray` + depending on the laziness of the data in src. + + """ + if not src.has_lazy_data(): + return func(src.data) + + data = src.lazy_data() + + # Ensure dims are not chunked + in_chunks = list(data.chunks) + for dim in dims: + in_chunks[dim] = src.shape[dim] + data = data.rechunk(in_chunks) + + # Determine output chunks + sorted_dims = sorted(dims) + out_chunks = list(data.chunks) + for dim, size in zip(sorted_dims, out_sizes): + out_chunks[dim] = size + + num_dims = len(dims) + num_out = len(out_sizes) + dropped_dims = [] + new_axis = None + if num_out > num_dims: + # While this code should be robust for cases where num_out > num_dims > 1, + # there is some ambiguity as to what their behaviour ought to be. + # Since these cases are out of our own scope, we explicitly ignore them + # for the time being. + assert num_dims == 1 + # While this code should be robust for cases where num_out > 2, + # we expect to handle at most 2D grids. + # Since these cases are out of our own scope, we explicitly ignore them + # for the time being. + assert num_out == 2 + slice_index = sorted_dims[-1] + # Insert the remaining contents of out_sizes in the position immediately + # after the last dimension. + out_chunks[slice_index:slice_index] = out_sizes[num_dims:] + new_axis = range(slice_index, slice_index + num_out - num_dims) + elif num_dims > num_out: + # While this code should be robust for cases where num_dims > num_out > 1, + # there is some ambiguity as to what their behaviour ought to be. + # Since these cases are out of our own scope, we explicitly ignore them + # for the time being. + assert num_out == 1 + # While this code should be robust for cases where num_dims > 2, + # we expect to handle at most 2D grids. + # Since these cases are out of our own scope, we explicitly ignore them + # for the time being. + assert num_dims == 2 + dropped_dims = sorted_dims[num_out:] + # Remove the remaining dimensions from the expected output shape. + for dim in dropped_dims[::-1]: + out_chunks.pop(dim) + else: + pass + + return data.map_blocks( + func, + chunks=out_chunks, + drop_axis=dropped_dims, + new_axis=new_axis, + dtype=src.dtype, + ) + + +# Taken from PR #26 +def _bounds_cf_to_simple_1d(cf_bounds): + assert (cf_bounds[1:, 0] == cf_bounds[:-1, 1]).all() + simple_bounds = np.empty((cf_bounds.shape[0] + 1,), dtype=np.float64) + simple_bounds[:-1] = cf_bounds[:, 0] + simple_bounds[-1] = cf_bounds[-1, 1] + return simple_bounds + + +def _mesh_to_MeshInfo(mesh): + # Returns a MeshInfo object describing the mesh of the cube. + assert mesh.topology_dimension == 2 + meshinfo = MeshInfo( + np.stack([coord.points for coord in mesh.node_coords], axis=-1), + mesh.face_node_connectivity.indices, + mesh.face_node_connectivity.start_index, + ) + return meshinfo + + +def _cube_to_GridInfo(cube): + # This is a simplified version of an equivalent function/method in PR #26. + # It is anticipated that this function will be replaced by the one in PR #26. + # + # Returns a GridInfo object describing the horizontal grid of the cube. + # This may be inherited from code written for the rectilinear regridding scheme. + lon = cube.coord("longitude") + lat = cube.coord("latitude") + # Ensure coords come from a proper grid. + assert isinstance(lon, iris.coords.DimCoord) + assert isinstance(lat, iris.coords.DimCoord) + # TODO: accommodate other x/y coords. + # TODO: perform checks on lat/lon. + # Checks may cover units, coord systems (e.g. rotated pole), contiguous bounds. + return GridInfo( + lon.points, + lat.points, + _bounds_cf_to_simple_1d(lon.bounds), + _bounds_cf_to_simple_1d(lat.bounds), + circular=lon.circular, + ) + + +def _regrid_along_mesh_dim(regridder, data, mesh_dim, mdtol): + # Before regridding, data is transposed to a standard form. + # In the future, this may be done within the regridder by specifying args. + + # Move the mesh axis to be the last dimension. + data = np.moveaxis(data, mesh_dim, -1) + + result = regridder.regrid(data, mdtol=mdtol) + + # Move grid axes back into the original position of the mesh. + result = np.moveaxis(result, [-2, -1], [mesh_dim, mesh_dim + 1]) + + return result + + +def _create_cube(data, src_cube, mesh_dim, grid_x, grid_y): + """ + Return a new cube for the result of regridding. + + Returned cube represents the result of regridding the source cube + onto the new grid. + All the metadata and coordinates of the result cube are copied from + the source cube, with two exceptions: + - Grid dimension coordinates are copied from the grid cube. + - Auxiliary coordinates which span the grid dimensions are + ignored. + + Parameters + ---------- + data : array + The regridded data as an N-dimensional NumPy array. + src_cube : cube + The source Cube. + mesh_dim : int + The dimension of the mesh within the source Cube. + grid_x : DimCoord + The :class:`iris.coords.DimCoord` for the new grid's X + coordinate. + grid_y : DimCoord + The :class:`iris.coords.DimCoord` for the new grid's Y + coordinate. + + Returns + ------- + cube + A new iris.cube.Cube instance. + + """ + new_cube = iris.cube.Cube(data) + + # TODO: The following code is rigid with respect to which dimensions + # the x coord and y coord are assigned to. We should decide if it is + # appropriate to copy the dimension ordering from the target cube + # instead. + new_cube.add_dim_coord(grid_x, mesh_dim + 1) + new_cube.add_dim_coord(grid_y, mesh_dim) + + new_cube.metadata = copy.deepcopy(src_cube.metadata) + + # TODO: Handle derived coordinates. The following code is taken from + # iris, the parts dealing with derived coordinates have been + # commented out for the time being. + # coord_mapping = {} + + def copy_coords(src_coords, add_method): + for coord in src_coords: + dims = src_cube.coord_dims(coord) + if hasattr(coord, "mesh") or mesh_dim in dims: + continue + # Since the mesh will be replaced by a 2D grid, dims which are + # beyond the mesh_dim are increased by one. + dims = [dim if dim < mesh_dim else dim + 1 for dim in dims] + result_coord = coord.copy() + # Add result_coord to the owner of add_method. + add_method(result_coord, dims) + # coord_mapping[id(coord)] = result_coord + + copy_coords(src_cube.dim_coords, new_cube.add_dim_coord) + copy_coords(src_cube.aux_coords, new_cube.add_aux_coord) + + # for factory in src_cube.aux_factories: + # # TODO: Regrid dependant coordinates which span mesh_dim. + # try: + # result.add_aux_factory(factory.updated(coord_mapping)) + # except KeyError: + # msg = ( + # "Cannot update aux_factory {!r} because of dropped" + # " coordinates.".format(factory.name()) + # ) + # warnings.warn(msg) + + return new_cube + + +def _regrid_unstructured_to_rectilinear__prepare( + src_mesh_cube, target_grid_cube, precomputed_weights=None +): + """ + First (setup) part of 'regrid_unstructured_to_rectilinear'. + + Check inputs and calculate the sparse regrid matrix and related info. + The 'regrid info' returned can be re-used over many 2d slices. + + """ + # TODO: Perform checks on the arguments. (grid coords are contiguous, + # spherical and monotonic. Mesh is defined on faces) + + # TODO: Account for differences in units. + + # TODO: Account for differences in coord systems. + + # TODO: Record appropriate dimensions (i.e. which dimension the mesh belongs to) + + grid_x, grid_y = get_xy_dim_coords(target_grid_cube) + mesh = src_mesh_cube.mesh + # TODO: Improve the checking of mesh validity. Check the mesh location and + # raise appropriate error messages. + assert mesh is not None + # From src_mesh_cube, fetch the mesh, and the dimension on the cube which that + # mesh belongs to. + mesh_dim = src_mesh_cube.mesh_dim() + + meshinfo = _mesh_to_MeshInfo(mesh) + gridinfo = _cube_to_GridInfo(target_grid_cube) + + regridder = Regridder(meshinfo, gridinfo, precomputed_weights) + + regrid_info = (mesh_dim, grid_x, grid_y, regridder) + + return regrid_info + + +def _regrid_unstructured_to_rectilinear__perform(src_cube, regrid_info, mdtol): + """ + Second (regrid) part of 'regrid_unstructured_to_rectilinear'. + + Perform the prepared regrid calculation on a single cube. + + """ + mesh_dim, grid_x, grid_y, regridder = regrid_info + + # Set up a function which can accept just chunk of data as an argument. + regrid = functools.partial( + _regrid_along_mesh_dim, + regridder, + mesh_dim=mesh_dim, + mdtol=mdtol, + ) + + # Apply regrid to all the chunks of src_cube, ensuring first that all + # chunks cover the entire horizontal plane (otherwise they would break + # the regrid function). + new_data = _map_complete_blocks( + src_cube, + regrid, + (mesh_dim,), + (len(grid_x.points), len(grid_y.points)), + ) + + new_cube = _create_cube( + new_data, + src_cube, + mesh_dim, + grid_x, + grid_y, + ) + + # TODO: apply tweaks to created cube (slice out length 1 dimensions) + + return new_cube + + +def regrid_unstructured_to_rectilinear(src_cube, grid_cube, mdtol=0): + """ + Regrid unstructured cube onto rectilinear grid. + + Return a new cube with data values calculated using the area weighted + mean of data values from unstructured cube src_cube regridded onto the + horizontal grid of grid_cube. The dimension on the cube belonging to + the mesh will replaced by the two dimensions associated with the grid. + This function requires that the horizontal dimension of src_cube is + described by a 2D mesh with data located on the faces of that mesh. + This function requires that the horizontal grid of grid_cube is + rectilinear (i.e. expressed in terms of two orthogonal 1D coordinates). + This function also requires that the coordinates describing the + horizontal grid have bounds. + + Parameters + ---------- + src_cube : cube + An unstructured instance of iris.cube.Cube that supplies the data, + metadata and coordinates. + grid_cube : cube + A rectilinear instance of iris.cube.Cube that supplies the desired + horizontal grid definition. + mdtol : float, optional + Tolerance of missing data. The value returned in each element of the + returned cube's data array will be masked if the fraction of masked + data in the overlapping cells of the source cube exceeds mdtol. This + fraction is calculated based on the area of masked cells within each + target cell. mdtol=0 means no missing data is tolerated while mdtol=1 + will mean the resulting element will be masked if and only if all the + overlapping cells of the source cube are masked. Defaults to 0. + + Returns + ------- + cube + A new iris.cube.Cube instance. + + """ + regrid_info = _regrid_unstructured_to_rectilinear__prepare(src_cube, grid_cube) + result = _regrid_unstructured_to_rectilinear__perform(src_cube, regrid_info, mdtol) + return result + + +class MeshToGridESMFRegridder: + """Regridder class for unstructured to rectilinear cubes.""" + + def __init__( + self, src_mesh_cube, target_grid_cube, mdtol=1, precomputed_weights=None + ): + """ + Create regridder for conversions between source mesh and target grid. + + Parameters + ---------- + src_grid_cube : cube + The unstructured iris cube providing the source grid. + target_grid_cube : cube + The rectilinear iris cube providing the target grid. + mdtol : float, optional + Tolerance of missing data. The value returned in each element of + the returned array will be masked if the fraction of masked data + exceeds mdtol. mdtol=0 means no missing data is tolerated while + mdtol=1 will mean the resulting element will be masked if and only + if all the contributing elements of data are masked. + Defaults to 1. + + """ + # TODO: Record information about the identity of the mesh. This would + # typically be a copy of the mesh, though given the potential size of + # the mesh, it may make sense to either retain a reference to the actual + # mesh or else something like a hash of the mesh. + + # Missing data tolerance. + # Code directly copied from iris. + if not (0 <= mdtol <= 1): + msg = "Value for mdtol must be in range 0 - 1, got {}." + raise ValueError(msg.format(mdtol)) + self.mdtol = mdtol + + partial_regrid_info = _regrid_unstructured_to_rectilinear__prepare( + src_mesh_cube, target_grid_cube, precomputed_weights=precomputed_weights + ) + + # Record source mesh. + self.mesh = src_mesh_cube.mesh + + # Store regrid info. + _, self.grid_x, self.grid_y, self.regridder = partial_regrid_info + + def __call__(self, cube): + """ + Regrid this cube onto the target grid of this regridder instance. + + The given cube must be defined with the same mesh as the source + cube used to create this MeshToGridESMFRegridder instance. + + Parameters + ---------- + cube : cube + A iris.cube.Cube instance to be regridded. + + Returns + ------- + A cube defined with the horizontal dimensions of the target + and the other dimensions from this cube. The data values of + this cube will be converted to values on the new grid using + area-weighted regridding via ESMF generated weights. + + """ + mesh = cube.mesh + # TODO: replace temporary hack when iris issues are sorted. + assert mesh is not None + # Ignore differences in var_name that might be caused by saving. + # TODO: uncomment this when iris issue with masked array comparison is sorted. + # self_mesh = copy.deepcopy(self.mesh) + # self_mesh.var_name = mesh.var_name + # for self_coord, other_coord in zip(self_mesh.all_coords, mesh.all_coords): + # if self_coord is not None: + # self_coord.var_name = other_coord.var_name + # for self_con, other_con in zip( + # self_mesh.all_connectivities, mesh.all_connectivities + # ): + # if self_con is not None: + # self_con.var_name = other_con.var_name + # if self_mesh != mesh: + # raise ValueError( + # "The given cube is not defined on the same " + # "source mesh as this regridder." + # ) + + mesh_dim = cube.mesh_dim() + + regrid_info = (mesh_dim, self.grid_x, self.grid_y, self.regridder) + + return _regrid_unstructured_to_rectilinear__perform( + cube, regrid_info, self.mdtol + ) + + +def _regrid_along_grid_dims(regridder, data, grid_x_dim, grid_y_dim, mdtol): + # The mesh will be assigned to the first dimension associated with the + # grid, whether that is associated with the x or y coordinate. + tgt_mesh_dim = min(grid_x_dim, grid_y_dim) + data = np.moveaxis(data, [grid_x_dim, grid_y_dim], [-1, -2]) + result = regridder.regrid(data, mdtol=mdtol) + + result = np.moveaxis(result, -1, tgt_mesh_dim) + return result + + +def _create_mesh_cube(data, src_cube, grid_x_dim, grid_y_dim, mesh): + """ + Return a new cube for the result of regridding. + + Returned cube represents the result of regridding the source cube + onto the new mesh. + All the metadata and coordinates of the result cube are copied from + the source cube, with two exceptions: + - Grid dimension coordinates are copied from the grid cube. + - Auxiliary coordinates which span the mesh dimension are + ignored. + + Parameters + ---------- + data : array + The regridded data as an N-dimensional NumPy array. + src_cube : cube + The source Cube. + grid_x_dim : int + The dimension of the x dimension on the source Cube. + grid_y_dim : int + The dimension of the y dimension on the source Cube. + mesh : Mesh + The :class:`iris.experimental.ugrid.Mesh` for the new + Cube. + + Returns + ------- + cube + A new iris.cube.Cube instance. + + """ + new_cube = iris.cube.Cube(data) + + min_grid_dim = min(grid_x_dim, grid_y_dim) + max_grid_dim = max(grid_x_dim, grid_y_dim) + for coord in mesh.to_MeshCoords("face"): + new_cube.add_aux_coord(coord, min_grid_dim) + + new_cube.metadata = copy.deepcopy(src_cube.metadata) + + def copy_coords(src_coords, add_method): + for coord in src_coords: + dims = src_cube.coord_dims(coord) + if grid_x_dim in dims or grid_y_dim in dims: + continue + # Since the 2D grid will be replaced by a 1D mesh, dims which are + # beyond the max_grid_dim are decreased by one. + dims = [dim if dim < max_grid_dim else dim - 1 for dim in dims] + result_coord = coord.copy() + # Add result_coord to the owner of add_method. + add_method(result_coord, dims) + + copy_coords(src_cube.dim_coords, new_cube.add_dim_coord) + copy_coords(src_cube.aux_coords, new_cube.add_aux_coord) + + return new_cube + + +def _regrid_rectilinear_to_unstructured__prepare( + src_grid_cube, target_mesh_cube, precomputed_weights=None +): + """ + First (setup) part of 'regrid_rectilinear_to_unstructured'. + + Check inputs and calculate the sparse regrid matrix and related info. + The 'regrid info' returned can be re-used over many 2d slices. + + """ + grid_x, grid_y = get_xy_dim_coords(src_grid_cube) + mesh = target_mesh_cube.mesh + # TODO: Improve the checking of mesh validity. Check the mesh location and + # raise appropriate error messages. + assert mesh is not None + grid_x_dim = src_grid_cube.coord_dims(grid_x)[0] + grid_y_dim = src_grid_cube.coord_dims(grid_y)[0] + + meshinfo = _mesh_to_MeshInfo(mesh) + gridinfo = _cube_to_GridInfo(src_grid_cube) + + regridder = Regridder(gridinfo, meshinfo, precomputed_weights) + + regrid_info = (grid_x_dim, grid_y_dim, grid_x, grid_y, mesh, regridder) + + return regrid_info + + +def _regrid_rectilinear_to_unstructured__perform(src_cube, regrid_info, mdtol): + """ + Second (regrid) part of 'regrid_rectilinear_to_unstructured'. + + Perform the prepared regrid calculation on a single cube. + + """ + grid_x_dim, grid_y_dim, grid_x, grid_y, mesh, regridder = regrid_info + + # Set up a function which can accept just chunk of data as an argument. + regrid = functools.partial( + _regrid_along_grid_dims, + regridder, + grid_x_dim=grid_x_dim, + grid_y_dim=grid_y_dim, + mdtol=mdtol, + ) + + face_node = mesh.face_node_connectivity + # In face_node_connectivity: `src`= face, `tgt` = node, so you want to + # get the length of the `src` dimension. + n_faces = face_node.shape[face_node.src_dim] + + # Apply regrid to all the chunks of src_cube, ensuring first that all + # chunks cover the entire horizontal plane (otherwise they would break + # the regrid function). + new_data = _map_complete_blocks( + src_cube, + regrid, + (grid_x_dim, grid_y_dim), + (n_faces,), + ) + + new_cube = _create_mesh_cube( + new_data, + src_cube, + grid_x_dim, + grid_y_dim, + mesh, + ) + return new_cube + + +def regrid_rectilinear_to_unstructured(src_cube, mesh_cube, mdtol=0): + """ + Regrid rectilinear cube onto unstructured mesh. + + Return a new cube with data values calculated using the area weighted + mean of data values from rectilinear cube src_cube regridded onto the + horizontal mesh of mesh_cube. The dimensions on the cube associated + with the grid will replaced by a dimension associated with the mesh. + That dimension will be the the first of the grid dimensions, whether + it is associated with the x or y coordinate. Since two dimensions are + being replaced by one, coordinates associated with dimensions after + the grid will become associated with dimensions one lower. + This function requires that the horizontal dimension of mesh_cube is + described by a 2D mesh with data located on the faces of that mesh. + This function requires that the horizontal grid of src_cube is + rectilinear (i.e. expressed in terms of two orthogonal 1D coordinates). + This function also requires that the coordinates describing the + horizontal grid have bounds. + + Parameters + ---------- + src_cube : cube + A rectilinear instance of iris.cube.Cube that supplies the data, + metadata and coordinates. + mesh_cube : cube + An unstructured instance of iris.cube.Cube that supplies the desired + horizontal mesh definition. + mdtol : float, optional + Tolerance of missing data. The value returned in each element of the + returned cube's data array will be masked if the fraction of masked + data in the overlapping cells of the source cube exceeds mdtol. This + fraction is calculated based on the area of masked cells within each + target cell. mdtol=0 means no missing data is tolerated while mdtol=1 + will mean the resulting element will be masked if and only if all the + overlapping cells of the source cube are masked. Defaults to 0. + + Returns + ------- + cube + A new iris.cube.Cube instance. + + """ + regrid_info = _regrid_rectilinear_to_unstructured__prepare(src_cube, mesh_cube) + result = _regrid_rectilinear_to_unstructured__perform(src_cube, regrid_info, mdtol) + return result + + +class GridToMeshESMFRegridder: + """Regridder class for rectilinear to unstructured cubes.""" + + def __init__( + self, src_mesh_cube, target_grid_cube, mdtol=1, precomputed_weights=None + ): + """ + Create regridder for conversions between source grid and target mesh. + + Parameters + ---------- + src_grid_cube : cube + The unstructured iris cube providing the source grid. + target_grid_cube : cube + The rectilinear iris cube providing the target mesh. + mdtol : float, optional + Tolerance of missing data. The value returned in each element of + the returned array will be masked if the fraction of masked data + exceeds mdtol. mdtol=0 means no missing data is tolerated while + mdtol=1 will mean the resulting element will be masked if and only + if all the contributing elements of data are masked. + Defaults to 1. + + """ + # Missing data tolerance. + # Code directly copied from iris. + if not (0 <= mdtol <= 1): + msg = "Value for mdtol must be in range 0 - 1, got {}." + raise ValueError(msg.format(mdtol)) + self.mdtol = mdtol + + partial_regrid_info = _regrid_rectilinear_to_unstructured__prepare( + src_mesh_cube, target_grid_cube, precomputed_weights=precomputed_weights + ) + + # Store regrid info. + _, _, self.grid_x, self.grid_y, self.mesh, self.regridder = partial_regrid_info + + def __call__(self, cube): + """ + Regrid this cube onto the target mesh of this regridder instance. + + The given cube must be defined with the same grid as the source + cube used to create this MeshToGridESMFRegridder instance. + + Parameters + ---------- + cube : cube + A iris.cube.Cube instance to be regridded. + + Returns + ------- + A cube defined with the horizontal dimensions of the target + and the other dimensions from this cube. The data values of + this cube will be converted to values on the new grid using + area-weighted regridding via ESMF generated weights. + + """ + grid_x, grid_y = get_xy_dim_coords(cube) + # Ignore differences in var_name that might be caused by saving. + self_grid_x = copy.deepcopy(self.grid_x) + self_grid_x.var_name = grid_x.var_name + self_grid_y = copy.deepcopy(self.grid_y) + self_grid_y.var_name = grid_y.var_name + if (grid_x != self_grid_x) or (grid_y != self_grid_y): + raise ValueError( + "The given cube is not defined on the same " + "source grid as this regridder." + ) + + grid_x_dim = cube.coord_dims(grid_x)[0] + grid_y_dim = cube.coord_dims(grid_y)[0] + + regrid_info = ( + grid_x_dim, + grid_y_dim, + self.grid_x, + self.grid_y, + self.mesh, + self.regridder, + ) + + return _regrid_rectilinear_to_unstructured__perform( + cube, regrid_info, self.mdtol + ) diff --git a/esmf_regrid/tests/integration/experimental/__init__.py b/esmf_regrid/tests/integration/experimental/__init__.py new file mode 100644 index 00000000..00ac6516 --- /dev/null +++ b/esmf_regrid/tests/integration/experimental/__init__.py @@ -0,0 +1 @@ +"""Integration tests for :mod:`esmf_regrid.experimental`.""" diff --git a/esmf_regrid/tests/integration/experimental/unstructured_scheme/__init__.py b/esmf_regrid/tests/integration/experimental/unstructured_scheme/__init__.py new file mode 100644 index 00000000..8819d721 --- /dev/null +++ b/esmf_regrid/tests/integration/experimental/unstructured_scheme/__init__.py @@ -0,0 +1 @@ +"""Integration tests for :mod:`esmf_regrid.experimental.unstructured_scheme`.""" diff --git a/esmf_regrid/tests/integration/experimental/unstructured_scheme/test_regrid_unstructured_to_rectilinear.py b/esmf_regrid/tests/integration/experimental/unstructured_scheme/test_regrid_unstructured_to_rectilinear.py new file mode 100644 index 00000000..d7aafe31 --- /dev/null +++ b/esmf_regrid/tests/integration/experimental/unstructured_scheme/test_regrid_unstructured_to_rectilinear.py @@ -0,0 +1,50 @@ +"""Integration tests for :func:`esmf_regrid.experimental.unstructured_scheme.regrid_unstructured_to_rectilinear`.""" + + +import os + +import iris +from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD +import numpy as np + +from esmf_regrid.experimental.unstructured_scheme import ( + regrid_unstructured_to_rectilinear, +) + + +def test_real_data(): + """ + Test for :func:`esmf_regrid.experimental.unstructured_scheme.regrid_unstructured_to_rectilinear`. + + Tests with cubes derived from realistic data. + """ + # Load source cube. + test_data_dir = iris.config.TEST_DATA_DIR + src_fn = os.path.join( + test_data_dir, "NetCDF", "unstructured_grid", "lfric_surface_mean.nc" + ) + with PARSE_UGRID_ON_LOAD.context(): + src = iris.load_cube(src_fn, "rainfall_flux") + + # Load target grid cube. + tgt_fn = os.path.join( + test_data_dir, "NetCDF", "global", "xyt", "SMALL_hires_wind_u_for_ipcc4.nc" + ) + tgt = iris.load_cube(tgt_fn) + + # Perform regridding. + result = regrid_unstructured_to_rectilinear(src, tgt) + + # Check data. + assert result.shape == (1, 160, 320) + assert np.isclose(result.data.mean(), 2.93844e-5) + assert np.isclose(result.data.std(), 2.71724e-5) + + # Check metadata. + assert result.metadata == src.metadata + assert result.coord("time") == src.coord("time") + assert result.coord("latitude") == tgt.coord("latitude") + assert result.coord("longitude") == tgt.coord("longitude") + assert result.coord_dims("time") == (0,) + assert result.coord_dims("latitude") == (1,) + assert result.coord_dims("longitude") == (2,) diff --git a/esmf_regrid/tests/unit/experimental/__init__.py b/esmf_regrid/tests/unit/experimental/__init__.py new file mode 100644 index 00000000..b8653193 --- /dev/null +++ b/esmf_regrid/tests/unit/experimental/__init__.py @@ -0,0 +1 @@ +"""Unit tests for :mod:`esmf_regrid.experimental`.""" diff --git a/esmf_regrid/tests/unit/experimental/io/__init__.py b/esmf_regrid/tests/unit/experimental/io/__init__.py new file mode 100644 index 00000000..d7e457c9 --- /dev/null +++ b/esmf_regrid/tests/unit/experimental/io/__init__.py @@ -0,0 +1 @@ +"""Unit tests for :mod:`esmf_regrid.experimental.io`.""" diff --git a/esmf_regrid/tests/unit/experimental/io/test_round_tripping.py b/esmf_regrid/tests/unit/experimental/io/test_round_tripping.py new file mode 100644 index 00000000..d1bdaa8f --- /dev/null +++ b/esmf_regrid/tests/unit/experimental/io/test_round_tripping.py @@ -0,0 +1,135 @@ +"""Unit tests for round tripping (saving then loading) with :mod:`esmf_regrid.experimental.io`.""" + +from copy import deepcopy + +import numpy as np +from numpy import ma + +from esmf_regrid.experimental.io import load_regridder, save_regridder +from esmf_regrid.experimental.unstructured_scheme import ( + GridToMeshESMFRegridder, + MeshToGridESMFRegridder, +) +from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__cube_to_GridInfo import ( + _grid_cube, +) +from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__mesh_to_MeshInfo import ( + _gridlike_mesh_cube, +) + + +def _make_grid_to_mesh_regridder(): + src_lons = 3 + src_lats = 4 + tgt_lons = 5 + tgt_lats = 6 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + # TODO check that circularity is preserved. + src = _grid_cube(src_lons, src_lats, lon_bounds, lat_bounds, circular=True) + src.coord("longitude").var_name = "longitude" + src.coord("latitude").var_name = "latitude" + tgt = _gridlike_mesh_cube(tgt_lons, tgt_lats) + + rg = GridToMeshESMFRegridder(src, tgt, mdtol=0.5) + return rg, src + + +def _make_mesh_to_grid_regridder(): + src_lons = 3 + src_lats = 4 + tgt_lons = 5 + tgt_lats = 6 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + # TODO check that circularity is preserved. + tgt = _grid_cube(tgt_lons, tgt_lats, lon_bounds, lat_bounds, circular=True) + src = _gridlike_mesh_cube(src_lons, src_lats) + + rg = MeshToGridESMFRegridder(src, tgt, mdtol=0.5) + return rg, src + + +def test_GridToMeshESMFRegridder_round_trip(tmp_path): + """Test save/load round tripping for `GridToMeshESMFRegridder`.""" + original_rg, src = _make_grid_to_mesh_regridder() + filename = tmp_path / "regridder.nc" + save_regridder(original_rg, filename) + loaded_rg = load_regridder(str(filename)) + + assert original_rg.mdtol == loaded_rg.mdtol + assert original_rg.grid_x == loaded_rg.grid_x + assert original_rg.grid_y == loaded_rg.grid_y + # TODO: uncomment when iris mesh comparison becomes available. + # assert original_rg.mesh == loaded_rg.mesh + + # Compare the weight matrices. + original_matrix = original_rg.regridder.weight_matrix + loaded_matrix = loaded_rg.regridder.weight_matrix + # Ensure the original and loaded weight matrix have identical type. + assert type(original_matrix) is type(loaded_matrix) # noqa E721 + assert np.array_equal(original_matrix.todense(), loaded_matrix.todense()) + + # Demonstrate regridding still gives the same results. + src_data = np.arange(np.product(src.data.shape)).reshape(src.data.shape) + src_mask = np.zeros(src.data.shape) + src_mask[0, 0] = 1 + src.data = ma.array(src_data, mask=src_mask) + # TODO: make this a cube comparison when mesh comparison becomes available. + assert np.array_equal(original_rg(src).data, loaded_rg(src).data) + + # Ensure version data is equal. + assert original_rg.regridder.esmf_version == loaded_rg.regridder.esmf_version + assert ( + original_rg.regridder.esmf_regrid_version + == loaded_rg.regridder.esmf_regrid_version + ) + + +def test_MeshToGridESMFRegridder_round_trip(tmp_path): + """Test save/load round tripping for `MeshToGridESMFRegridder`.""" + original_rg, src = _make_mesh_to_grid_regridder() + filename = tmp_path / "regridder.nc" + save_regridder(original_rg, filename) + loaded_rg = load_regridder(str(filename)) + + assert original_rg.mdtol == loaded_rg.mdtol + loaded_grid_x = deepcopy(loaded_rg.grid_x) + loaded_grid_x.var_name = original_rg.grid_x.var_name + assert original_rg.grid_x == loaded_grid_x + loaded_grid_y = deepcopy(loaded_rg.grid_y) + loaded_grid_y.var_name = original_rg.grid_y.var_name + assert original_rg.grid_y == loaded_grid_y + # TODO: uncomment when iris mesh comparison becomes available. + # assert original_rg.mesh == loaded_rg.mesh + + # Compare the weight matrices. + original_matrix = original_rg.regridder.weight_matrix + loaded_matrix = loaded_rg.regridder.weight_matrix + # Ensure the original and loaded weight matrix have identical type. + assert type(original_matrix) is type(loaded_matrix) # noqa E721 + assert np.array_equal(original_matrix.todense(), loaded_matrix.todense()) + + # Demonstrate regridding still gives the same results. + src_data = np.arange(np.product(src.data.shape)).reshape(src.data.shape) + src_mask = np.zeros(src.data.shape) + src_mask[0] = 1 + src.data = ma.array(src_data, mask=src_mask) + # Compare results, ignoring var_name changes due to saving. + original_result = original_rg(src) + loaded_result = loaded_rg(src) + original_result.var_name = loaded_result.var_name + original_result.coord("latitude").var_name = loaded_result.coord( + "latitude" + ).var_name + original_result.coord("longitude").var_name = loaded_result.coord( + "longitude" + ).var_name + assert original_result == loaded_result + + # Ensure version data is equal. + assert original_rg.regridder.esmf_version == loaded_rg.regridder.esmf_version + assert ( + original_rg.regridder.esmf_regrid_version + == loaded_rg.regridder.esmf_regrid_version + ) diff --git a/esmf_regrid/tests/unit/experimental/io/test_save_regridder.py b/esmf_regrid/tests/unit/experimental/io/test_save_regridder.py new file mode 100644 index 00000000..234fb604 --- /dev/null +++ b/esmf_regrid/tests/unit/experimental/io/test_save_regridder.py @@ -0,0 +1,13 @@ +"""Unit tests for :mod:`esmf_regrid.experimental.io.save_regridder`.""" + +import pytest + +from esmf_regrid.experimental.io import save_regridder + + +def test_invalid_type(tmp_path): + """Test that `save_regridder` raises a TypeError where appropriate.""" + invalid_obj = None + filename = tmp_path / "regridder.nc" + with pytest.raises(TypeError): + save_regridder(invalid_obj, filename) diff --git a/esmf_regrid/tests/unit/experimental/unstructured_scheme/__init__.py b/esmf_regrid/tests/unit/experimental/unstructured_scheme/__init__.py new file mode 100644 index 00000000..0a6654c6 --- /dev/null +++ b/esmf_regrid/tests/unit/experimental/unstructured_scheme/__init__.py @@ -0,0 +1 @@ +"""Unit tests for :mod:`esmf_regrid.experimental.unstructured_scheme`.""" diff --git a/esmf_regrid/tests/unit/experimental/unstructured_scheme/test_GridToMeshESMFRegridder.py b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test_GridToMeshESMFRegridder.py new file mode 100644 index 00000000..6971516c --- /dev/null +++ b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test_GridToMeshESMFRegridder.py @@ -0,0 +1,236 @@ +"""Unit tests for :func:`esmf_regrid.experimental.unstructured_scheme.GridToMeshESMFRegridder`.""" + +import dask.array as da +from iris.coords import AuxCoord, DimCoord +from iris.cube import Cube +import numpy as np +from numpy import ma +import pytest + +from esmf_regrid.experimental.unstructured_scheme import ( + GridToMeshESMFRegridder, +) +from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__cube_to_GridInfo import ( + _grid_cube, +) +from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__mesh_to_MeshInfo import ( + _gridlike_mesh, +) +from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__regrid_unstructured_to_rectilinear__prepare import ( + _flat_mesh_cube, +) + + +def test_flat_cubes(): + """ + Basic test for :func:`esmf_regrid.experimental.unstructured_scheme.GridToMeshESMFRegridder`. + + Tests with flat cubes as input (a 2D grid cube and a 1D mesh cube). + """ + tgt = _flat_mesh_cube() + + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + src = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + # Ensure data in the target grid is different to the expected data. + # i.e. target grid data is all zero, expected data is all one + tgt.data[:] = 0 + + def _add_metadata(cube): + result = cube.copy() + result.units = "K" + result.attributes = {"a": 1} + result.standard_name = "air_temperature" + scalar_height = AuxCoord([5], units="m", standard_name="height") + scalar_time = DimCoord([10], units="s", standard_name="time") + result.add_aux_coord(scalar_height) + result.add_aux_coord(scalar_time) + return result + + src = _add_metadata(src) + src.data[:] = 1 # Ensure all data in the source is one. + regridder = GridToMeshESMFRegridder(src, tgt) + result = regridder(src) + src_T = src.copy() + src_T.transpose() + result_transposed = regridder(src_T) + + expected_data = np.ones([n_lats, n_lons]) + expected_cube = _add_metadata(tgt) + + # Lenient check for data. + assert np.allclose(expected_data, result.data) + assert np.allclose(expected_data, result_transposed.data) + + # Check metadata and scalar coords. + expected_cube.data = result.data + assert expected_cube == result + expected_cube.data = result_transposed.data + assert expected_cube == result_transposed + + +def test_multidim_cubes(): + """ + Test for :func:`esmf_regrid.experimental.unstructured_scheme.regrid_rectilinear_to_unstructured`. + + Tests with multidimensional cubes. The source cube contains + coordinates on the dimensions before and after the grid dimensions. + """ + tgt = _flat_mesh_cube() + mesh = tgt.mesh + mesh_length = mesh.connectivity(contains_face=True).shape[0] + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + grid = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + + h = 2 + t = 3 + height = DimCoord(np.arange(h), standard_name="height") + time = DimCoord(np.arange(t), standard_name="time") + + src_data = np.empty([t, n_lats, n_lons, h]) + src_data[:] = np.arange(t * h).reshape([t, h])[:, np.newaxis, np.newaxis, :] + cube = Cube(src_data) + cube.add_dim_coord(grid.coord("latitude"), 1) + cube.add_dim_coord(grid.coord("longitude"), 2) + cube.add_dim_coord(time, 0) + cube.add_dim_coord(height, 3) + + regridder = GridToMeshESMFRegridder(grid, tgt) + result = regridder(cube) + + # Lenient check for data. + expected_data = np.empty([t, mesh_length, h]) + expected_data[:] = np.arange(t * h).reshape(t, h)[:, np.newaxis, :] + assert np.allclose(expected_data, result.data) + + mesh_coord_x, mesh_coord_y = mesh.to_MeshCoords("face") + expected_cube = Cube(expected_data) + expected_cube.add_dim_coord(time, 0) + expected_cube.add_aux_coord(mesh_coord_x, 1) + expected_cube.add_aux_coord(mesh_coord_y, 1) + expected_cube.add_dim_coord(height, 2) + + # Check metadata and scalar coords. + result.data = expected_data + assert expected_cube == result + + +def test_invalid_mdtol(): + """ + Test initialisation of :func:`esmf_regrid.experimental.unstructured_scheme.GridToMeshESMFRegridder`. + + Checks that an error is raised when mdtol is out of range. + """ + tgt = _flat_mesh_cube() + + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + src = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + + with pytest.raises(ValueError): + _ = GridToMeshESMFRegridder(src, tgt, mdtol=2) + with pytest.raises(ValueError): + _ = GridToMeshESMFRegridder(src, tgt, mdtol=-1) + + +def test_mismatched_grids(): + """ + Test error handling in calling of :func:`esmf_regrid.experimental.unstructured_scheme.GridToMeshESMFRegridder`. + + Checks that an error is raised when the regridder is called with a + cube whose grid does not match with the one used when initialising + the regridder. + """ + tgt = _flat_mesh_cube() + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + src = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + + regridder = GridToMeshESMFRegridder(src, tgt) + + n_lons_other = 3 + n_lats_other = 10 + src_other = _grid_cube( + n_lons_other, n_lats_other, lon_bounds, lat_bounds, circular=True + ) + with pytest.raises(ValueError): + _ = regridder(src_other) + + +def test_mask_handling(): + """ + Test masked data handling for :func:`esmf_regrid.experimental.unstructured_scheme.GridToMeshESMFRegridder`. + + Tests masked data handling for multiple valid values for mdtol. + """ + tgt = _flat_mesh_cube() + + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + src = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + + data = np.ones([n_lats, n_lons]) + mask = np.zeros([n_lats, n_lons]) + mask[0, 0] = 1 + masked_data = ma.array(data, mask=mask) + src.data = masked_data + regridder_0 = GridToMeshESMFRegridder(src, tgt, mdtol=0) + regridder_05 = GridToMeshESMFRegridder(src, tgt, mdtol=0.05) + regridder_1 = GridToMeshESMFRegridder(src, tgt, mdtol=1) + result_0 = regridder_0(src) + result_05 = regridder_05(src) + result_1 = regridder_1(src) + + expected_data = np.ones(tgt.shape) + expected_0 = ma.array(expected_data) + expected_05 = ma.array(expected_data, mask=[0, 0, 1, 0, 0, 0]) + expected_1 = ma.array(expected_data, mask=[1, 0, 1, 0, 0, 0]) + + assert ma.allclose(expected_0, result_0.data) + assert ma.allclose(expected_05, result_05.data) + assert ma.allclose(expected_1, result_1.data) + + +def test_laziness(): + """Test that regridding is lazy when source data is lazy.""" + n_lons = 12 + n_lats = 10 + h = 4 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + + mesh = _gridlike_mesh(n_lons, n_lats) + + src_data = np.arange(n_lats * n_lons * h).reshape([n_lats, n_lons, h]) + src_data = da.from_array(src_data, chunks=[3, 5, 2]) + src = Cube(src_data) + grid = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + src.add_dim_coord(grid.coord("latitude"), 0) + src.add_dim_coord(grid.coord("longitude"), 1) + + mesh_coord_x, mesh_coord_y = mesh.to_MeshCoords("face") + tgt_data = np.zeros([n_lats * n_lons]) + tgt = Cube(tgt_data) + tgt.add_aux_coord(mesh_coord_x, 0) + tgt.add_aux_coord(mesh_coord_y, 0) + + rg = GridToMeshESMFRegridder(src, tgt) + + assert src.has_lazy_data() + result = rg(src) + assert result.has_lazy_data() + out_chunks = result.lazy_data().chunks + expected_chunks = ((120,), (2, 2)) + assert out_chunks == expected_chunks + assert np.allclose(result.data, src_data.reshape([-1, h])) diff --git a/esmf_regrid/tests/unit/experimental/unstructured_scheme/test_MeshToGridESMFRegridder.py b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test_MeshToGridESMFRegridder.py new file mode 100644 index 00000000..8b9869b1 --- /dev/null +++ b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test_MeshToGridESMFRegridder.py @@ -0,0 +1,197 @@ +"""Unit tests for :func:`esmf_regrid.experimental.unstructured_scheme.MeshToGridESMFRegridder`.""" + +import dask.array as da +from iris.coords import AuxCoord, DimCoord +from iris.cube import Cube +import numpy as np +import pytest + +from esmf_regrid.experimental.unstructured_scheme import ( + MeshToGridESMFRegridder, +) +from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__cube_to_GridInfo import ( + _grid_cube, +) +from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__mesh_to_MeshInfo import ( + _gridlike_mesh, + _gridlike_mesh_cube, +) +from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__regrid_unstructured_to_rectilinear__prepare import ( + _flat_mesh_cube, + _full_mesh, +) + + +def test_flat_cubes(): + """ + Basic test for :func:`esmf_regrid.experimental.unstructured_scheme.MeshToGridESMFRegridder`. + + Tests with flat cubes as input (a 1D mesh cube and a 2D grid cube). + """ + src = _flat_mesh_cube() + + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + tgt = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + # Ensure data in the target grid is different to the expected data. + # i.e. target grid data is all zero, expected data is all one + tgt.data[:] = 0 + + def _add_metadata(cube): + result = cube.copy() + result.units = "K" + result.attributes = {"a": 1} + result.standard_name = "air_temperature" + scalar_height = AuxCoord([5], units="m", standard_name="height") + scalar_time = DimCoord([10], units="s", standard_name="time") + result.add_aux_coord(scalar_height) + result.add_aux_coord(scalar_time) + return result + + src = _add_metadata(src) + src.data[:] = 1 # Ensure all data in the source is one. + regridder = MeshToGridESMFRegridder(src, tgt) + result = regridder(src) + + expected_data = np.ones([n_lats, n_lons]) + expected_cube = _add_metadata(tgt) + + # Lenient check for data. + assert np.allclose(expected_data, result.data) + + # Check metadata and scalar coords. + expected_cube.data = result.data + assert expected_cube == result + + +def test_multidim_cubes(): + """ + Test for :func:`esmf_regrid.experimental.unstructured_scheme.MeshToGridESMFRegridder`. + + Tests with multidimensional cubes. The source cube contains + coordinates on the dimensions before and after the mesh dimension. + """ + mesh = _full_mesh() + mesh_length = mesh.connectivity(contains_face=True).shape[0] + + h = 2 + t = 3 + height = DimCoord(np.arange(h), standard_name="height") + time = DimCoord(np.arange(t), standard_name="time") + + src_data = np.empty([t, mesh_length, h]) + src_data[:] = np.arange(t * h).reshape([t, h])[:, np.newaxis, :] + mesh_cube = Cube(src_data) + mesh_coord_x, mesh_coord_y = mesh.to_MeshCoords("face") + mesh_cube.add_aux_coord(mesh_coord_x, 1) + mesh_cube.add_aux_coord(mesh_coord_y, 1) + mesh_cube.add_dim_coord(time, 0) + mesh_cube.add_dim_coord(height, 2) + + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + tgt = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + + src_cube = mesh_cube.copy() + src_cube.transpose([1, 0, 2]) + regridder = MeshToGridESMFRegridder(src_cube, tgt) + result = regridder(mesh_cube) + + # Lenient check for data. + expected_data = np.empty([t, n_lats, n_lons, h]) + expected_data[:] = np.arange(t * h).reshape(t, h)[:, np.newaxis, np.newaxis, :] + assert np.allclose(expected_data, result.data) + + expected_cube = Cube(expected_data) + expected_cube.add_dim_coord(time, 0) + expected_cube.add_dim_coord(tgt.coord("latitude"), 1) + expected_cube.add_dim_coord(tgt.coord("longitude"), 2) + expected_cube.add_dim_coord(height, 3) + + # Check metadata and scalar coords. + result.data = expected_data + assert expected_cube == result + + +def test_invalid_mdtol(): + """ + Test initialisation of :func:`esmf_regrid.experimental.unstructured_scheme.MeshToGridESMFRegridder`. + + Checks that an error is raised when mdtol is out of range. + """ + src = _flat_mesh_cube() + + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + tgt = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + + with pytest.raises(ValueError): + _ = MeshToGridESMFRegridder(src, tgt, mdtol=2) + with pytest.raises(ValueError): + _ = MeshToGridESMFRegridder(src, tgt, mdtol=-1) + + +@pytest.mark.xfail +def test_mistmatched_mesh(): + """ + Test the calling of :func:`esmf_regrid.experimental.unstructured_scheme.MeshToGridESMFRegridder`. + + Checks that an error is raised when the regridder is called with a cube + whose mesh does not match the one used for initialisation. + """ + src = _flat_mesh_cube() + + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + tgt = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + + rg = MeshToGridESMFRegridder(src, tgt) + + other_src = _gridlike_mesh_cube(n_lons, n_lats) + + with pytest.raises(ValueError) as excinfo: + _ = rg(other_src) + expected_message = ( + "The given cube is not defined on the same " "source mesh as this regridder." + ) + assert expected_message in str(excinfo.value) + + +def test_laziness(): + """Test that regridding is lazy when source data is lazy.""" + n_lons = 12 + n_lats = 10 + h = 4 + i = 9 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + + mesh = _gridlike_mesh(n_lons, n_lats) + + # Add a chunked dimension both before and after the mesh dimension. + # The leading length 1 dimension matches the example in issue #135. + src_data = np.arange(i * n_lats * n_lons * h).reshape([1, i, -1, h]) + src_data = da.from_array(src_data, chunks=[1, 3, 15, 2]) + src = Cube(src_data) + mesh_coord_x, mesh_coord_y = mesh.to_MeshCoords("face") + src.add_aux_coord(mesh_coord_x, 2) + src.add_aux_coord(mesh_coord_y, 2) + tgt = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + + rg = MeshToGridESMFRegridder(src, tgt) + + assert src.has_lazy_data() + result = rg(src) + assert result.has_lazy_data() + out_chunks = result.lazy_data().chunks + expected_chunks = ((1,), (3, 3, 3), (10,), (12,), (2, 2)) + assert out_chunks == expected_chunks + assert np.allclose(result.data.reshape([1, i, -1, h]), src_data) diff --git a/esmf_regrid/tests/unit/experimental/unstructured_scheme/test__create_cube.py b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test__create_cube.py new file mode 100644 index 00000000..50536059 --- /dev/null +++ b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test__create_cube.py @@ -0,0 +1,81 @@ +"""Unit tests for miscellaneous helper functions in `esmf_regrid.experimental.unstructured_scheme`.""" + +import iris +from iris.coords import AuxCoord, DimCoord +from iris.cube import Cube +import numpy as np + +from esmf_regrid.experimental.unstructured_scheme import _create_cube + + +def test_create_cube_2D(): + """Test creation of 2D output grid.""" + data = np.ones([2, 3]) + + # Create a source cube with metadata and scalar coords + src_cube = Cube(np.zeros(5)) + src_cube.units = "K" + src_cube.attributes = {"a": 1} + src_cube.standard_name = "air_temperature" + scalar_height = AuxCoord([5], units="m", standard_name="height") + scalar_time = DimCoord([10], units="s", standard_name="time") + src_cube.add_aux_coord(scalar_height) + src_cube.add_aux_coord(scalar_time) + + mesh_dim = 0 + + grid_x = DimCoord(np.arange(3), standard_name="longitude") + grid_y = DimCoord(np.arange(2), standard_name="latitude") + + cube = _create_cube(data, src_cube, mesh_dim, grid_x, grid_y) + src_metadata = src_cube.metadata + + expected_cube = Cube(data) + expected_cube.metadata = src_metadata + expected_cube.add_dim_coord(grid_x, 1) + expected_cube.add_dim_coord(grid_y, 0) + expected_cube.add_aux_coord(scalar_height) + expected_cube.add_aux_coord(scalar_time) + assert expected_cube == cube + + +def test_create_cube_4D(): + """Test creation of 2D output grid.""" + data = np.ones([4, 2, 3, 5]) + + # Create a source cube with metadata and scalar coords + src_cube = Cube(np.zeros([4, 5, 5])) + src_cube.units = "K" + src_cube.attributes = {"a": 1} + src_cube.standard_name = "air_temperature" + scalar_height = AuxCoord([5], units="m", standard_name="height") + scalar_time = DimCoord([10], units="s", standard_name="time") + src_cube.add_aux_coord(scalar_height) + src_cube.add_aux_coord(scalar_time) + first_coord = DimCoord(np.arange(4), standard_name="air_pressure") + src_cube.add_dim_coord(first_coord, 0) + last_coord = AuxCoord(np.arange(5), long_name="last_coord") + src_cube.add_aux_coord(last_coord, 2) + multidim_coord = AuxCoord(np.ones([4, 5]), long_name="2d_coord") + src_cube.add_aux_coord(multidim_coord, (0, 2)) + ignored_coord = AuxCoord(np.arange(5), long_name="ignore") + src_cube.add_aux_coord(ignored_coord, 1) + + mesh_dim = 1 + + grid_x = iris.coords.DimCoord(np.arange(3), standard_name="longitude") + grid_y = iris.coords.DimCoord(np.arange(2), standard_name="latitude") + + cube = _create_cube(data, src_cube, mesh_dim, grid_x, grid_y) + src_metadata = src_cube.metadata + + expected_cube = iris.cube.Cube(data) + expected_cube.metadata = src_metadata + expected_cube.add_dim_coord(grid_x, 2) + expected_cube.add_dim_coord(grid_y, 1) + expected_cube.add_dim_coord(first_coord, 0) + expected_cube.add_aux_coord(last_coord, 3) + expected_cube.add_aux_coord(multidim_coord, (0, 3)) + expected_cube.add_aux_coord(scalar_height) + expected_cube.add_aux_coord(scalar_time) + assert expected_cube == cube diff --git a/esmf_regrid/tests/unit/experimental/unstructured_scheme/test__cube_to_GridInfo.py b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test__cube_to_GridInfo.py new file mode 100644 index 00000000..96cd54db --- /dev/null +++ b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test__cube_to_GridInfo.py @@ -0,0 +1,94 @@ +"""Unit tests for miscellaneous helper functions in `esmf_regrid.experimental.unstructured_scheme`.""" + +from iris.coords import DimCoord +from iris.cube import Cube +import numpy as np +import scipy.sparse + +from esmf_regrid.esmf_regridder import Regridder +from esmf_regrid.experimental.unstructured_scheme import _cube_to_GridInfo + + +def _generate_points_and_bounds(n, outer_bounds): + lower, upper = outer_bounds + full_span = np.linspace(lower, upper, n * 2 + 1) + points = full_span[1::2] + bound_span = full_span[::2] + bounds = np.stack([bound_span[:-1], bound_span[1:]], axis=-1) + return points, bounds + + +def _grid_cube(n_lons, n_lats, lon_outer_bounds, lat_outer_bounds, circular=False): + lon_points, lon_bounds = _generate_points_and_bounds(n_lons, lon_outer_bounds) + lon = DimCoord( + lon_points, "longitude", units="degrees", bounds=lon_bounds, circular=circular + ) + lat_points, lat_bounds = _generate_points_and_bounds(n_lats, lat_outer_bounds) + lat = DimCoord(lat_points, "latitude", units="degrees", bounds=lat_bounds) + + data = np.zeros([n_lats, n_lons]) + cube = Cube(data) + cube.add_dim_coord(lon, 1) + cube.add_dim_coord(lat, 0) + return cube + + +def test_global_grid(): + """Test conversion of a global grid.""" + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + + cube = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + gridinfo = _cube_to_GridInfo(cube) + # Ensure conversion to ESMF works without error + _ = gridinfo.make_esmf_field() + + # The following test ensures there are no overlapping cells. + # This catches geometric/topological abnormalities that would arise from, + # for example: switching lat/lon values, using euclidean coords vs spherical. + rg = Regridder(gridinfo, gridinfo) + expected_weights = scipy.sparse.identity(n_lats * n_lons) + assert np.array_equal(expected_weights.todense(), rg.weight_matrix.todense()) + + +def test_local_grid(): + """Test conversion of a local grid.""" + n_lons = 6 + n_lats = 5 + lon_bounds = (-20, 20) + lat_bounds = (20, 60) + + cube = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds) + gridinfo = _cube_to_GridInfo(cube) + # Ensure conversion to ESMF works without error + _ = gridinfo.make_esmf_field() + + # The following test ensures there are no overlapping cells. + # Note that this test fails when longitude is circular. + rg = Regridder(gridinfo, gridinfo) + expected_weights = scipy.sparse.identity(n_lats * n_lons) + assert np.array_equal(expected_weights.todense(), rg.weight_matrix.todense()) + + +def test_grid_with_scalars(): + """Test conversion of a grid with scalar coords.""" + n_lons = 1 + n_lats = 5 + lon_bounds = (-20, 20) + lat_bounds = (20, 60) + + cube = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds) + # Convert longitude to a scalar + cube = cube[:, 0] + assert len(cube.shape) == 1 + + gridinfo = _cube_to_GridInfo(cube) + # Ensure conversion to ESMF works without error + _ = gridinfo.make_esmf_field() + + # The following test ensures there are no overlapping cells. + rg = Regridder(gridinfo, gridinfo) + expected_weights = scipy.sparse.identity(n_lats * n_lons) + assert np.array_equal(expected_weights.todense(), rg.weight_matrix.todense()) diff --git a/esmf_regrid/tests/unit/experimental/unstructured_scheme/test__mesh_to_MeshInfo.py b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test__mesh_to_MeshInfo.py new file mode 100644 index 00000000..bb97eccf --- /dev/null +++ b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test__mesh_to_MeshInfo.py @@ -0,0 +1,221 @@ +"""Unit tests for :func:`esmf_regrid.experimental.unstructured_scheme._mesh_to_MeshInfo`.""" + +from iris.coords import AuxCoord +from iris.cube import Cube +from iris.experimental.ugrid import Connectivity, Mesh +import numpy as np +from numpy import ma +import scipy.sparse + +from esmf_regrid.esmf_regridder import Regridder +from esmf_regrid.experimental.unstructured_scheme import _mesh_to_MeshInfo + + +def _pyramid_topology_connectivity_array(): + """ + Generate the face_node_connectivity array for a topological pyramid. + + The mesh described is a topological pyramid in the sense that there + exists a polygonal base (described by the indices [0, 1, 2, 3, 4]) + and all other faces are triangles connected to a single node (the node + with index 5). + """ + fnc_array = [ + [0, 1, 2, 3, 4], + [1, 0, 5, -1, -1], + [2, 1, 5, -1, -1], + [3, 2, 5, -1, -1], + [4, 3, 5, -1, -1], + [0, 4, 5, -1, -1], + ] + fnc_mask = [ + [0, 0, 0, 0, 0], + [0, 0, 0, 1, 1], + [0, 0, 0, 1, 1], + [0, 0, 0, 1, 1], + [0, 0, 0, 1, 1], + [0, 0, 0, 1, 1], + ] + fnc_ma = ma.array(fnc_array, mask=fnc_mask) + return fnc_ma + + +def _example_mesh(): + """Generate a global mesh with a pentagonal pyramid topology.""" + # The base of the pyramid is the following pentagon. + # + # 60 0 3 + # | \ / | + # 10 | 4 | + # | | + # | | + # -60 1----------2 + # + # 120 180 -120 + # + # The point of the pyramid is at the coordinate (0, 0). + # The geometry is designed so that a valid ESMF object is only produced when + # the orientation is correct (the face nodes are visited in an anticlockwise + # order). This sensitivity is due to the base of the pyramid being convex. + + # Generate face_node_connectivity (fnc). + fnc_ma = _pyramid_topology_connectivity_array() + fnc = Connectivity( + fnc_ma, + cf_role="face_node_connectivity", + start_index=0, + ) + lon_values = [120, 120, -120, -120, 180, 0] + lat_values = [60, -60, -60, 60, 10, 0] + lons = AuxCoord(lon_values, standard_name="longitude") + lats = AuxCoord(lat_values, standard_name="latitude") + mesh = Mesh(2, ((lons, "x"), (lats, "y")), fnc) + return mesh + + +def _gridlike_mesh(n_lons, n_lats): + """ + Generate a global mesh with geometry similar to a rectilinear grid. + + The resulting mesh will have n_lons cells spanning its longitudes and + n_lats cells spanning its latitudes for a total of (n_lons * n_lats) cells. + Note that the cells neighbouring the poles will actually be triangular while + the rest of the cells will be rectangular. + """ + # Arrange the indices of the non-pole nodes in an array representative of their + # latitude/longitude. + fnc_template = np.arange((n_lats - 1) * n_lons).reshape(n_lats - 1, n_lons) + 1 + fnc_array = np.empty([n_lats, n_lons, 4]) + # Assign points in an anticlockwise orientation. From the 0 node to 1 + # longitude increases, then from 1 to 2 latitude increases, from 2 to 3 + # longitude decreases and from 3 to 0 latitude decreases. + fnc_array[1:, :, 0] = fnc_template + fnc_array[1:, :, 1] = np.roll(fnc_template, -1, 1) + fnc_array[:-1, :, 2] = np.roll(fnc_template, -1, 1) + fnc_array[:-1, :, 3] = fnc_template + # Define the poles as single points. Note that all the cells adjacent to these + # nodes will be topologically triangular with the pole node repeated. One of + # these repeated pole node references will eventually be masked. + fnc_array[0, :, :2] = 0 + num_nodes = fnc_template.max() + fnc_array[-1, :, 2:] = num_nodes + 1 + # By convention, node references to be masked should be last in the list. + # Since one of the pole node references will end up masked, this should be + # moved to the end of the list of nodes. + fnc_array[0, :, :] = np.roll(fnc_array[0, :, :], -1, -1) + + # One of the two references to the pole node are defined to be masked. + fnc_mask = np.zeros_like(fnc_array) + fnc_mask[0, :, -1] = 1 + fnc_mask[-1, :, -1] = 1 + fnc_ma = ma.array(fnc_array, mask=fnc_mask, dtype=int) + + # The face node connectivity is flattened to the correct dimensionality. + fnc_ma = fnc_ma.reshape([-1, 4]) + + # Latitude and longitude values are set. + lat_values = np.linspace(-90, 90, n_lats + 1) + lon_values = np.linspace(-180, 180, n_lons, endpoint=False) + # Latitude values are broadcast to arrays with the same shape as the face node + # connectivity node references in fnc_template. + lon_array, lat_array = np.meshgrid(lon_values, lat_values[1:-1]) + node_lats = np.empty(num_nodes + 2) + # Note that fnc_template is created by reshaping a list of indices. These + # indices refer to node_lats and node_lons which are generated by reshaping + # lat_array and lon_array. Because of the way reshaping preserves order, there + # is a correspondance between an index in a particular position fnc_template + # and the latitude and longitude described by lat_array and lon_array in the + # same position. + node_lats[1:-1] = lat_array.reshape([-1]) + # Define the latitude and longitude of the poles. + node_lats[0] = lat_values[0] + node_lats[-1] = lat_values[-1] + node_lons = np.empty(num_nodes + 2) + node_lons[1:-1] = lon_array.reshape([-1]) + node_lons[0] = 0 + node_lons[-1] = 0 + + # Translate the mesh information into iris objects. + fnc = Connectivity( + fnc_ma, + cf_role="face_node_connectivity", + start_index=0, + ) + lons = AuxCoord(node_lons, standard_name="longitude") + lats = AuxCoord(node_lats, standard_name="latitude") + mesh = Mesh(2, ((lons, "x"), (lats, "y")), fnc) + + # In order to add a mesh to a cube, face locations must be added. + # These are not used in calculations and are here given a value of zero. + mesh_length = mesh.face_node_connectivity.shape[0] + dummy_face_lon = AuxCoord(np.zeros(mesh_length), standard_name="longitude") + dummy_face_lat = AuxCoord(np.zeros(mesh_length), standard_name="latitude") + mesh.add_coords(face_x=dummy_face_lon, face_y=dummy_face_lat) + mesh.long_name = "example mesh" + return mesh + + +def _gridlike_mesh_cube(n_lons, n_lats): + mesh = _gridlike_mesh(n_lons, n_lats) + data = np.zeros([n_lons * n_lats]) + cube = Cube(data) + mesh_coord_x, mesh_coord_y = mesh.to_MeshCoords("face") + cube.add_aux_coord(mesh_coord_x, 0) + cube.add_aux_coord(mesh_coord_y, 0) + return cube + + +def test__mesh_to_MeshInfo(): + """Basic test for :func:`esmf_regrid.experimental.unstructured_scheme._mesh_to_MeshInfo`.""" + mesh = _example_mesh() + meshinfo = _mesh_to_MeshInfo(mesh) + + expected_nodes = np.array( + [ + [120, 60], + [120, -60], + [-120, -60], + [-120, 60], + [180, 10], + [0, 0], + ] + ) + assert np.array_equal(expected_nodes, meshinfo.node_coords) + + expected_connectivity = _pyramid_topology_connectivity_array() + assert np.array_equal(expected_connectivity, meshinfo.fnc) + + expected_start_index = 0 + assert expected_start_index == meshinfo.esi + + +def test_anticlockwise_validity(): + """Test validity of objects derived from Mesh objects with anticlockwise orientation.""" + mesh = _example_mesh() + meshinfo = _mesh_to_MeshInfo(mesh) + + # Ensure conversion to ESMF works without error. + _ = meshinfo.make_esmf_field() + + # The following test ensures there are no overlapping cells. + # This catches geometric/topological abnormalities that would arise from, + # for example: switching lat/lon values, using euclidean coords vs spherical. + rg = Regridder(meshinfo, meshinfo) + expected_weights = scipy.sparse.identity(meshinfo.size) + assert np.allclose(expected_weights.todense(), rg.weight_matrix.todense()) + + +def test_large_mesh_validity(): + """Test validity of objects derived from a large gridlike Mesh.""" + mesh = _gridlike_mesh(40, 20) + meshinfo = _mesh_to_MeshInfo(mesh) + + # Ensure conversion to ESMF works without error. + _ = meshinfo.make_esmf_field() + + # The following test ensures there are no overlapping cells. + # This catches geometric/topological abnormalities that would arise from, + # for example: switching lat/lon values, using euclidean coords vs spherical. + rg = Regridder(meshinfo, meshinfo) + expected_weights = scipy.sparse.identity(meshinfo.size) + assert np.allclose(expected_weights.todense(), rg.weight_matrix.todense()) diff --git a/esmf_regrid/tests/unit/experimental/unstructured_scheme/test__regrid_unstructured_to_rectilinear__prepare.py b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test__regrid_unstructured_to_rectilinear__prepare.py new file mode 100644 index 00000000..4fce173a --- /dev/null +++ b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test__regrid_unstructured_to_rectilinear__prepare.py @@ -0,0 +1,70 @@ +"""Unit tests for :func:`esmf_regrid.experimental.unstructured_scheme._regrid_unstructured_to_rectilinear__prepare`.""" + +from iris.coords import AuxCoord +from iris.cube import Cube +import numpy as np + +from esmf_regrid.esmf_regridder import GridInfo +from esmf_regrid.experimental.unstructured_regrid import MeshInfo +from esmf_regrid.experimental.unstructured_scheme import ( + _regrid_unstructured_to_rectilinear__prepare, +) +from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__cube_to_GridInfo import ( + _grid_cube, +) +from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__mesh_to_MeshInfo import ( + _example_mesh, +) + + +def _full_mesh(): + mesh = _example_mesh() + + # In order to add a mesh to a cube, face locations must be added. + # These are not used in calculations and are here given a value of zero. + mesh_length = mesh.connectivity(contains_face=True).shape[0] + dummy_face_lon = AuxCoord(np.zeros(mesh_length), standard_name="longitude") + dummy_face_lat = AuxCoord(np.zeros(mesh_length), standard_name="latitude") + mesh.add_coords(face_x=dummy_face_lon, face_y=dummy_face_lat) + mesh.long_name = "example mesh" + return mesh + + +def _flat_mesh_cube(): + """ + Return a 1D cube with a mesh attached. + + Returned cube has no metadata except for the mesh and two MeshCoords. + Returned cube has data consisting of an array of ones. + """ + mesh = _full_mesh() + mesh_length = mesh.connectivity(contains_face=True).shape[0] + + cube = Cube(np.ones([mesh_length])) + mesh_coord_x, mesh_coord_y = mesh.to_MeshCoords("face") + cube.add_aux_coord(mesh_coord_x, 0) + cube.add_aux_coord(mesh_coord_y, 0) + return cube + + +def test_flat_cubes(): + """ + Basic test for :func:`esmf_regrid.experimental.unstructured_scheme._regrid_unstructured_to_rectilinear__prepare`. + + Tests with flat cubes as input (a 1D mesh cube and a 2D grid cube). + """ + src = _flat_mesh_cube() + + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + tgt = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + regrid_info = _regrid_unstructured_to_rectilinear__prepare(src, tgt) + mesh_dim, grid_x, grid_y, regridder = regrid_info + + assert mesh_dim == 0 + assert grid_x == tgt.coord("longitude") + assert grid_y == tgt.coord("latitude") + assert type(regridder.tgt) == GridInfo + assert type(regridder.src) == MeshInfo diff --git a/esmf_regrid/tests/unit/experimental/unstructured_scheme/test_regrid_rectilinear_to_unstructured.py b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test_regrid_rectilinear_to_unstructured.py new file mode 100644 index 00000000..d0406e62 --- /dev/null +++ b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test_regrid_rectilinear_to_unstructured.py @@ -0,0 +1,164 @@ +"""Unit tests for :func:`esmf_regrid.experimental.unstructured_scheme.regrid_rectilinear_to_unstructured`.""" + +from iris.coords import AuxCoord, DimCoord +from iris.cube import Cube +import numpy as np +from numpy import ma + +from esmf_regrid.experimental.unstructured_scheme import ( + regrid_rectilinear_to_unstructured, +) +from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__cube_to_GridInfo import ( + _grid_cube, +) +from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__regrid_unstructured_to_rectilinear__prepare import ( + _flat_mesh_cube, +) + + +def test_flat_cubes(): + """ + Basic test for :func:`esmf_regrid.experimental.unstructured_scheme.regrid_rectilinear_to_unstructured`. + + Tests with flat cubes as input (a 2D grid cube and a 1D mesh cube). + """ + tgt = _flat_mesh_cube() + + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + src = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + # Ensure data in the target grid is different to the expected data. + # i.e. target grid data is all zero, expected data is all one + tgt.data[:] = 0 + + def _add_metadata(cube): + result = cube.copy() + result.units = "K" + result.attributes = {"a": 1} + result.standard_name = "air_temperature" + scalar_height = AuxCoord([5], units="m", standard_name="height") + scalar_time = DimCoord([10], units="s", standard_name="time") + result.add_aux_coord(scalar_height) + result.add_aux_coord(scalar_time) + return result + + src = _add_metadata(src) + src.data[:] = 1 # Ensure all data in the source is one. + result = regrid_rectilinear_to_unstructured(src, tgt) + src_T = src.copy() + src_T.transpose() + result_transposed = regrid_rectilinear_to_unstructured(src_T, tgt) + + expected_data = np.ones([n_lats, n_lons]) + expected_cube = _add_metadata(tgt) + + # Lenient check for data. + assert np.allclose(expected_data, result.data) + assert np.allclose(expected_data, result_transposed.data) + + # Check metadata and scalar coords. + expected_cube.data = result.data + assert expected_cube == result + expected_cube.data = result_transposed.data + assert expected_cube == result_transposed + + +def test_multidim_cubes(): + """ + Test for :func:`esmf_regrid.experimental.unstructured_scheme.regrid_rectilinear_to_unstructured`. + + Tests with multidimensional cubes. The source cube contains + coordinates on the dimensions before and after the grid dimensions. + """ + tgt = _flat_mesh_cube() + mesh = tgt.mesh + mesh_length = mesh.connectivity(contains_face=True).shape[0] + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + grid = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + + h = 2 + p = 4 + t = 3 + height = DimCoord(np.arange(h), standard_name="height") + pressure = DimCoord(np.arange(p), standard_name="air_pressure") + time = DimCoord(np.arange(t), standard_name="time") + spanning = AuxCoord(np.ones([t, p, h]), long_name="spanning dim") + ignore = AuxCoord(np.ones([n_lats, h]), long_name="ignore") + + src_data = np.empty([t, n_lats, p, n_lons, h]) + src_data[:] = np.arange(t * p * h).reshape([t, p, h])[ + :, np.newaxis, :, np.newaxis, : + ] + cube = Cube(src_data) + cube.add_dim_coord(grid.coord("latitude"), 1) + cube.add_dim_coord(grid.coord("longitude"), 3) + cube.add_dim_coord(time, 0) + cube.add_dim_coord(pressure, 2) + cube.add_dim_coord(height, 4) + cube.add_aux_coord(spanning, [0, 2, 4]) + cube.add_aux_coord(ignore, [1, 4]) + + result = regrid_rectilinear_to_unstructured(cube, tgt) + + cube_transposed = cube.copy() + cube_transposed.transpose([0, 3, 2, 1, 4]) + result_transposed = regrid_rectilinear_to_unstructured(cube_transposed, tgt) + + # Lenient check for data. + expected_data = np.empty([t, mesh_length, p, h]) + expected_data[:] = np.arange(t * p * h).reshape(t, p, h)[:, np.newaxis, :, :] + assert np.allclose(expected_data, result.data) + assert np.allclose(expected_data, result_transposed.data) + + mesh_coord_x, mesh_coord_y = mesh.to_MeshCoords("face") + expected_cube = Cube(expected_data) + expected_cube.add_dim_coord(time, 0) + expected_cube.add_aux_coord(mesh_coord_x, 1) + expected_cube.add_aux_coord(mesh_coord_y, 1) + expected_cube.add_dim_coord(pressure, 2) + expected_cube.add_dim_coord(height, 3) + expected_cube.add_aux_coord(spanning, [0, 2, 3]) + + # Check metadata and scalar coords. + result.data = expected_data + assert expected_cube == result + result_transposed.data = expected_data + assert expected_cube == result_transposed + + +def test_mask_handling(): + """ + Test masked data handling for :func:`esmf_regrid.experimental.unstructured_scheme.regrid_rectilinear_to_unstructured`. + + Tests masked data handling for multiple valid values for mdtol. + """ + tgt = _flat_mesh_cube() + + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + src = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + + data = np.ones([n_lats, n_lons]) + mask = np.zeros([n_lats, n_lons]) + mask[0, 0] = 1 + masked_data = ma.array(data, mask=mask) + src.data = masked_data + result_0 = regrid_rectilinear_to_unstructured(src, tgt, mdtol=0) + result_05 = regrid_rectilinear_to_unstructured(src, tgt, mdtol=0.05) + result_1 = regrid_rectilinear_to_unstructured(src, tgt, mdtol=1) + + expected_data = np.ones(tgt.shape) + expected_0 = ma.array(expected_data) + expected_05 = ma.array(expected_data, mask=[0, 0, 1, 0, 0, 0]) + expected_1 = ma.array(expected_data, mask=[1, 0, 1, 0, 0, 0]) + + assert ma.allclose(expected_0, result_0.data) + assert ma.allclose(expected_05, result_05.data) + assert ma.allclose(expected_1, result_1.data) diff --git a/esmf_regrid/tests/unit/experimental/unstructured_scheme/test_regrid_unstructured_to_rectilinear.py b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test_regrid_unstructured_to_rectilinear.py new file mode 100644 index 00000000..783c9c34 --- /dev/null +++ b/esmf_regrid/tests/unit/experimental/unstructured_scheme/test_regrid_unstructured_to_rectilinear.py @@ -0,0 +1,107 @@ +"""Unit tests for :func:`esmf_regrid.experimental.unstructured_scheme.regrid_unstructured_to_rectilinear`.""" + +from iris.coords import AuxCoord, DimCoord +from iris.cube import Cube +import numpy as np + +from esmf_regrid.experimental.unstructured_scheme import ( + regrid_unstructured_to_rectilinear, +) +from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__cube_to_GridInfo import ( + _grid_cube, +) +from esmf_regrid.tests.unit.experimental.unstructured_scheme.test__regrid_unstructured_to_rectilinear__prepare import ( + _flat_mesh_cube, + _full_mesh, +) + + +def test_flat_cubes(): + """ + Basic test for :func:`esmf_regrid.experimental.unstructured_scheme.regrid_unstructured_to_rectilinear`. + + Tests with flat cubes as input (a 1D mesh cube and a 2D grid cube). + """ + src = _flat_mesh_cube() + + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + tgt = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + # Ensure data in the target grid is different to the expected data. + # i.e. target grid data is all zero, expected data is all one + tgt.data[:] = 0 + + def _add_metadata(cube): + result = cube.copy() + result.units = "K" + result.attributes = {"a": 1} + result.standard_name = "air_temperature" + scalar_height = AuxCoord([5], units="m", standard_name="height") + scalar_time = DimCoord([10], units="s", standard_name="time") + result.add_aux_coord(scalar_height) + result.add_aux_coord(scalar_time) + return result + + src = _add_metadata(src) + src.data[:] = 1 # Ensure all data in the source is one. + result = regrid_unstructured_to_rectilinear(src, tgt) + + expected_data = np.ones([n_lats, n_lons]) + expected_cube = _add_metadata(tgt) + + # Lenient check for data. + assert np.allclose(expected_data, result.data) + + # Check metadata and scalar coords. + expected_cube.data = result.data + assert expected_cube == result + + +def test_multidim_cubes(): + """ + Test for :func:`esmf_regrid.experimental.unstructured_scheme.regrid_unstructured_to_rectilinear`. + + Tests with multidimensional cubes. The source cube contains + coordinates on the dimensions before and after the mesh dimension. + """ + mesh = _full_mesh() + mesh_length = mesh.connectivity(contains_face=True).shape[0] + + h = 2 + t = 3 + height = DimCoord(np.arange(h), standard_name="height") + time = DimCoord(np.arange(t), standard_name="time") + + src_data = np.empty([t, mesh_length, h]) + src_data[:] = np.arange(t * h).reshape([t, h])[:, np.newaxis, :] + cube = Cube(src_data) + mesh_coord_x, mesh_coord_y = mesh.to_MeshCoords("face") + cube.add_aux_coord(mesh_coord_x, 1) + cube.add_aux_coord(mesh_coord_y, 1) + cube.add_dim_coord(time, 0) + cube.add_dim_coord(height, 2) + + n_lons = 6 + n_lats = 5 + lon_bounds = (-180, 180) + lat_bounds = (-90, 90) + tgt = _grid_cube(n_lons, n_lats, lon_bounds, lat_bounds, circular=True) + + result = regrid_unstructured_to_rectilinear(cube, tgt) + + # Lenient check for data. + expected_data = np.empty([t, n_lats, n_lons, h]) + expected_data[:] = np.arange(t * h).reshape(t, h)[:, np.newaxis, np.newaxis, :] + assert np.allclose(expected_data, result.data) + + expected_cube = Cube(expected_data) + expected_cube.add_dim_coord(time, 0) + expected_cube.add_dim_coord(tgt.coord("latitude"), 1) + expected_cube.add_dim_coord(tgt.coord("longitude"), 2) + expected_cube.add_dim_coord(height, 3) + + # Check metadata and scalar coords. + result.data = expected_data + assert expected_cube == result diff --git a/esmf_regrid/tests/unit/experimental/unstuctured_regrid/__init__.py b/esmf_regrid/tests/unit/experimental/unstuctured_regrid/__init__.py new file mode 100644 index 00000000..18b91be7 --- /dev/null +++ b/esmf_regrid/tests/unit/experimental/unstuctured_regrid/__init__.py @@ -0,0 +1 @@ +"""Unit tests for :mod:`esmf_regrid.experimental.unstructured_regrid`.""" diff --git a/noxfile.py b/noxfile.py index 7a389790..6c0b9b73 100644 --- a/noxfile.py +++ b/noxfile.py @@ -22,7 +22,7 @@ PACKAGE = "esmf_regrid" #: Cirrus-CI environment variable hook. -PY_VER = os.environ.get("PY_VER", ["3.6", "3.7", "3.8"]) +PY_VER = os.environ.get("PY_VER", ["3.7", "3.8"]) #: Cirrus-CI environment variable hook. COVERAGE = os.environ.get("COVERAGE", False) @@ -336,11 +336,18 @@ def tests(session: nox.sessions.Session): @nox.session(python=PY_VER, venv_backend="conda") @nox.parametrize( - ["ci_mode", "gh_pages"], - [(True, False), (False, False), (False, True)], - ids=["ci compare", "full", "full then publish"], + ["ci_mode", "long_mode", "gh_pages"], + [ + (True, False, False), + (False, False, False), + (False, False, True), + (False, True, False), + ], + ids=["ci compare", "full", "full then publish", "long snapshot"], ) -def benchmarks(session: nox.sessions.Session, ci_mode: bool, gh_pages: bool): +def benchmarks( + session: nox.sessions.Session, ci_mode: bool, long_mode: bool, gh_pages: bool +): """ Perform esmf-regrid performance benchmarks (using Airspeed Velocity). @@ -351,6 +358,8 @@ def benchmarks(session: nox.sessions.Session, ci_mode: bool, gh_pages: bool): ci_mode: bool Run a cut-down selection of benchmarks, comparing the current commit to the last commit for performance regressions. + long_mode: bool + Run the long running benchmarks at the current head of the repo. gh_pages: bool Run ``asv gh-pages --rewrite`` once finished. @@ -361,6 +370,19 @@ def benchmarks(session: nox.sessions.Session, ci_mode: bool, gh_pages: bool): """ session.install("asv", "nox", "pyyaml") + if "DATA_GEN_PYTHON" in os.environ: + print("Using existing data generation environment.") + else: + print("Setting up the data generation environment...") + session.run( + "nox", "--session=tests", "--install-only", f"--python={session.python}" + ) + data_gen_python = next( + Path(".nox").rglob(f"tests*/bin/python{session.python}") + ).resolve() + session.env["DATA_GEN_PYTHON"] = data_gen_python + + print("Running ASV...") session.cd("benchmarks") # Skip over setup questions for a new machine. session.run("asv", "machine", "--yes") @@ -381,6 +403,8 @@ def asv_exec(*sub_args: str) -> None: asv_exec("continuous", previous_commit, "HEAD", "--bench=ci") finally: asv_exec("compare", previous_commit, "HEAD") + elif long_mode: + asv_exec("run", "HEAD^!", "--bench=long") else: # f32f23a5 = first supporting commit for nox_asv_plugin.py . asv_exec("run", "f32f23a5..HEAD") diff --git a/requirements/nox.lock/py36-linux-64.lock b/requirements/nox.lock/py36-linux-64.lock deleted file mode 100644 index 9405237e..00000000 --- a/requirements/nox.lock/py36-linux-64.lock +++ /dev/null @@ -1,168 +0,0 @@ -# Generated by conda-lock. -# platform: linux-64 -# input_hash: 554527443e19a32a542d91fb85538a329d69479c4c2cc0808374dd9350c9cac8 -@EXPLICIT -https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2#d7c89558ba9fa0495403155b64376d81 -https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2021.10.8-ha878542_0.tar.bz2#575611b8a84f45960e87722eeb51fa26 -https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.36.1-hea4e1c9_2.tar.bz2#bd4f2e711b39af170e7ff15163fe87ee -https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-11.2.0-h5c6108e_11.tar.bz2#2dcb18a9a0fa31f4f29e5a9b3eade394 -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-11.2.0-he4da1e4_11.tar.bz2#0bf83958e788f1e75ba26154cb702afe -https://conda.anaconda.org/conda-forge/linux-64/mpi-1.0-mpich.tar.bz2#c1fcff3417b5a22bbc4cf6e8c23648cf -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-ng-11.2.0-h69a702a_11.tar.bz2#4ea2f9f83b617a7682e8aa05dcb37c6a -https://conda.anaconda.org/conda-forge/linux-64/libgomp-11.2.0-h1d223b6_11.tar.bz2#1d16527c76842bf9c41e9399d39d8097 -https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-1_gnu.tar.bz2#561e277319a41d4f24f5c05a9ef63c04 -https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-11.2.0-h1d223b6_11.tar.bz2#e3495f4f93cfd6b68021cbe2b5844cd5 -https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-h7f98852_4.tar.bz2#a1fd65c7ccbf10880423d82bca54eb54 -https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.18.1-h7f98852_0.tar.bz2#f26ef8098fab1f719c91eb760d63381a -https://conda.anaconda.org/conda-forge/linux-64/expat-2.4.1-h9c3ff4c_0.tar.bz2#16054ef3cb3ec5d8d29d08772662f65d -https://conda.anaconda.org/conda-forge/linux-64/geos-3.9.1-h9c3ff4c_2.tar.bz2#b9a6d9422aed3ad84ec6ccee9bfcaa0f -https://conda.anaconda.org/conda-forge/linux-64/jbig-2.1-h7f98852_2003.tar.bz2#1aa0cee79792fa97b7ff4545110b60bf -https://conda.anaconda.org/conda-forge/linux-64/jpeg-9d-h36c2ea0_0.tar.bz2#ea02ce6037dbe81803ae6123e5ba1568 -https://conda.anaconda.org/conda-forge/linux-64/lerc-3.0-h9c3ff4c_0.tar.bz2#7fcefde484980d23f0ec24c11e314d2e -https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.8-h7f98852_0.tar.bz2#91d22aefa665265e8e31988b15145c8a -https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h516909a_1.tar.bz2#6f8720dff19e17ce5d48cfe7f3d2f0a3 -https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2#d645c6d2ac96843a2bfaccd2d62b3ac3 -https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.18-pthreads_h8fe5266_0.tar.bz2#41532e4448c0cce086d6570f95e4e12e -https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.2.1-h7f98852_0.tar.bz2#90607c4c0247f04ec98b48997de71c1a -https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.2.11-h36c2ea0_1013.tar.bz2#dcddf696ff5dfcab567100d691678e18 -https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.9.3-h9c3ff4c_1.tar.bz2#fbe97e8fa6f275d7c76a09e795adc3e6 -https://conda.anaconda.org/conda-forge/linux-64/mpich-3.4.2-h846660c_100.tar.bz2#0868d02349fc7e128d4bdc515b58dd7e -https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.2-h58526e2_4.tar.bz2#509f2a21c4a09214cd737a480dfd80c9 -https://conda.anaconda.org/conda-forge/linux-64/openssl-1.1.1l-h7f98852_0.tar.bz2#de7b38a1542dbe6f41653a8ae71adc53 -https://conda.anaconda.org/conda-forge/linux-64/xxhash-0.8.0-h7f98852_3.tar.bz2#52402c791f35e414e704b7a113f99605 -https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.5-h516909a_1.tar.bz2#33f601066901f3e1a85af3522a8113f9 -https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h516909a_0.tar.bz2#03a530e925414902547cf48da7756db8 -https://conda.anaconda.org/conda-forge/linux-64/libblas-3.9.0-12_linux64_openblas.tar.bz2#4f93ba28c628a2c27cf39c055e6b219c -https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20191231-he28a2e2_2.tar.bz2#4d331e44109e3f0e19b4cb8f9b82f3e1 -https://conda.anaconda.org/conda-forge/linux-64/readline-8.1-h46c0cb4_0.tar.bz2#5788de3c8d7a7d64ac56c784c4ef48e6 -https://conda.anaconda.org/conda-forge/linux-64/udunits2-2.2.27.27-hc3e0081_2.tar.bz2#91f5207ecab9bab80964f49dfd81dbcc -https://conda.anaconda.org/conda-forge/linux-64/zlib-1.2.11-h36c2ea0_1013.tar.bz2#cf7190238072a41e9579e4476a6a60b8 -https://conda.anaconda.org/conda-forge/linux-64/hdf4-4.2.15-h10796ff_3.tar.bz2#21a8d66dc17f065023b33145c42652fe -https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.9.0-12_linux64_openblas.tar.bz2#2e5082d4a9a18c21100e6ce5b6bcb4ec -https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.9.0-12_linux64_openblas.tar.bz2#9f401a6807a97e0c859d7522ae3d51ec -https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.43.0-h812cca2_1.tar.bz2#d0a7846b7b3b8fb0d8b36904a53b8155 -https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.37-h21135ba_2.tar.bz2#b6acf807307d033d4b7e758b4f44b036 -https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.10.0-ha56f1ee_2.tar.bz2#6ab4eaa11ff01801cffca0a27489dc04 -https://conda.anaconda.org/conda-forge/linux-64/libzip-1.8.0-h4de3113_1.tar.bz2#175a746a43d42c053b91aa765fbc197d -https://conda.anaconda.org/conda-forge/linux-64/sqlite-3.36.0-h9cd32fc_2.tar.bz2#3588c2c6cb9f9bcc65b544ab1c715d60 -https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.11-h27826a3_1.tar.bz2#84e76fb280e735fec1efd2d21fd9cb27 -https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.0-ha95c52a_0.tar.bz2#b56f94865e2de36abf054e7bfa499034 -https://conda.anaconda.org/conda-forge/linux-64/freetype-2.10.4-h0708190_1.tar.bz2#4a06f2ac2e5bfae7b6b245171c3f07aa -https://conda.anaconda.org/conda-forge/linux-64/krb5-1.19.2-hcc1bbae_3.tar.bz2#e29650992ae593bc05fc93722483e5c3 -https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.3.0-h6f004c6_2.tar.bz2#34fda41ca84e67232888c9a885903055 -https://conda.anaconda.org/conda-forge/linux-64/python-3.6.13-hb7a2778_2_cpython.tar.bz2#c6f337bc180a8de2243ca826b28cb220 -https://conda.anaconda.org/conda-forge/noarch/appdirs-1.4.4-pyh9f0ad1d_0.tar.bz2#5f095bc6454094e96f146491fd03633b -https://conda.anaconda.org/conda-forge/noarch/attrs-21.2.0-pyhd8ed1ab_0.tar.bz2#d2e1c7f388ac403df7079b411c37cc50 -https://conda.anaconda.org/conda-forge/noarch/cfgv-3.3.1-pyhd8ed1ab_0.tar.bz2#ebb5f5f7dc4f1a3780ef7ea7738db08c -https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-2.0.0-pyhd8ed1ab_0.tar.bz2#4a57e24d5b759893615c05926b7b5fb9 -https://conda.anaconda.org/conda-forge/noarch/click-7.1.2-pyh9f0ad1d_0.tar.bz2#bd50a970ce07e660c319fdc4d730d3f1 -https://conda.anaconda.org/conda-forge/noarch/cloudpickle-2.0.0-pyhd8ed1ab_0.tar.bz2#3a8fc8b627d5fb6af827e126a10a86c6 -https://conda.anaconda.org/conda-forge/noarch/cycler-0.11.0-pyhd8ed1ab_0.tar.bz2#a50559fad0affdbb33729a68669ca1cb -https://conda.anaconda.org/conda-forge/noarch/dataclasses-0.8-pyh787bdff_2.tar.bz2#ed959a4ba14e6ddfa898630a5474a601 -https://conda.anaconda.org/conda-forge/noarch/distlib-0.3.3-pyhd8ed1ab_0.tar.bz2#cc7dae067bb31c1598e23e151dfca986 -https://conda.anaconda.org/conda-forge/noarch/filelock-3.4.0-pyhd8ed1ab_0.tar.bz2#caff9785491992b3250ed4048fe51e2c -https://conda.anaconda.org/conda-forge/noarch/fsspec-2021.11.0-pyhd8ed1ab_0.tar.bz2#4dc640d7025327ae332e7ff3658ade6e -https://conda.anaconda.org/conda-forge/noarch/heapdict-1.0.1-py_0.tar.bz2#77242bfb1e74a627fb06319b5a2d3b95 -https://conda.anaconda.org/conda-forge/noarch/idna-3.1-pyhd3deb0d_0.tar.bz2#9c9aea4b8391264477df484f798562d0 -https://conda.anaconda.org/conda-forge/noarch/iniconfig-1.1.1-pyh9f0ad1d_0.tar.bz2#39161f81cc5e5ca45b8226fbb06c6905 -https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.12-hddcbb42_0.tar.bz2#797117394a4aa588de6d741b06fad80f -https://conda.anaconda.org/conda-forge/linux-64/libcurl-7.80.0-h2574ce0_0.tar.bz2#5d0784b790350f7939bb5d3f2c32e700 -https://conda.anaconda.org/conda-forge/noarch/locket-0.2.0-py_2.tar.bz2#709e8671651c7ec3d1ad07800339ff1d -https://conda.anaconda.org/conda-forge/noarch/mccabe-0.6.1-py_1.tar.bz2#a326cb400c1ccd91789f3e7d02124d61 -https://conda.anaconda.org/conda-forge/noarch/more-itertools-8.11.0-pyhd8ed1ab_0.tar.bz2#fdc59243d230eec3bc80aacbd90b253b -https://conda.anaconda.org/conda-forge/noarch/olefile-0.46-pyh9f0ad1d_1.tar.bz2#0b2e68acc8c78c8cc392b90983481f58 -https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.4.0-hb52868f_1.tar.bz2#b7ad78ad2e9ee155f59e6428406ee824 -https://conda.anaconda.org/conda-forge/noarch/pathspec-0.9.0-pyhd8ed1ab_0.tar.bz2#f93dc0ccbc0a8472624165f6e256c7d1 -https://conda.anaconda.org/conda-forge/noarch/py-1.11.0-pyh6c4a22f_0.tar.bz2#b4613d7e7a493916d867842a6a148054 -https://conda.anaconda.org/conda-forge/noarch/pycodestyle-2.8.0-pyhd8ed1ab_0.tar.bz2#f2532eee272d45b1283ea4869d71f044 -https://conda.anaconda.org/conda-forge/noarch/pycparser-2.21-pyhd8ed1ab_0.tar.bz2#076becd9e05608f8dc72757d5f3a91ff -https://conda.anaconda.org/conda-forge/noarch/pyflakes-2.4.0-pyhd8ed1ab_0.tar.bz2#1aa3ecd37d0694e2ea5fef48da75371e -https://conda.anaconda.org/conda-forge/noarch/pyke-1.1.1-pyhd8ed1ab_1004.tar.bz2#5f0236abfbb6d53826d1afed1e64f82e -https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.0.6-pyhd8ed1ab_0.tar.bz2#3087df8c636c5a00e694605c39ce4982 -https://conda.anaconda.org/conda-forge/noarch/pyshp-2.1.3-pyh44b312d_0.tar.bz2#2d1867b980785eb44b8122184d8b42a6 -https://conda.anaconda.org/conda-forge/linux-64/python_abi-3.6-2_cp36m.tar.bz2#6f5b92d833a339da29ad8578c2a648ad -https://conda.anaconda.org/conda-forge/noarch/pytz-2021.3-pyhd8ed1ab_0.tar.bz2#7e4f811bff46a5a6a7e0094921389395 -https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2#e5f25f8dbc060e9a8d912e432202afc2 -https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2#4d22a9315e78c6827f806065957d566e -https://conda.anaconda.org/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_0.tar.bz2#6d6552722448103793743dabfbda532d -https://conda.anaconda.org/conda-forge/noarch/tblib-1.7.0-pyhd8ed1ab_0.tar.bz2#3d4afc31302aa7be471feb6be048ed76 -https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_0.tar.bz2#f832c45a477c78bebd107098db465095 -https://conda.anaconda.org/conda-forge/noarch/tomli-1.2.2-pyhd8ed1ab_0.tar.bz2#1c8a2f9ea18c267414e244cee668cd00 -https://conda.anaconda.org/conda-forge/noarch/toolz-0.11.2-pyhd8ed1ab_0.tar.bz2#f348d1590550371edfac5ed3c1d44f7e -https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.0.0-pyha770c72_0.tar.bz2#867353d58b5967a994dd3cec85166ad8 -https://conda.anaconda.org/conda-forge/noarch/zipp-3.6.0-pyhd8ed1ab_0.tar.bz2#855e2c4622f5eb50a4f6f7167b9ba17a -https://conda.anaconda.org/conda-forge/linux-64/antlr-python-runtime-4.7.2-py36h5fab9bb_1002.tar.bz2#37df435690656fc56f8b031cd759ef77 -https://conda.anaconda.org/conda-forge/linux-64/certifi-2021.5.30-py36h5fab9bb_0.tar.bz2#500e3fb737f9d2023755f78f1f22ca69 -https://conda.anaconda.org/conda-forge/linux-64/cffi-1.14.6-py36hd8eec40_1.tar.bz2#2e025dd15559c9882880f005e6c02018 -https://conda.anaconda.org/conda-forge/linux-64/chardet-4.0.0-py36h5fab9bb_1.tar.bz2#b63c63a44b8d37acaff014df8a512d92 -https://conda.anaconda.org/conda-forge/linux-64/colorlog-4.8.0-py36h5fab9bb_0.tar.bz2#f598e6698105fe501bb5a9a4b4222d06 -https://conda.anaconda.org/conda-forge/linux-64/coverage-6.0-py36h8f6f2f9_1.tar.bz2#2da2354fa279fcb23a4327d963a292cd -https://conda.anaconda.org/conda-forge/linux-64/curl-7.80.0-h2574ce0_0.tar.bz2#4d8fd67e5ab7e00fde8ad085464f43b7 -https://conda.anaconda.org/conda-forge/linux-64/cytoolz-0.11.0-py36h8f6f2f9_3.tar.bz2#cfdf59a409935a32e9f51b37d25b66f7 -https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.12.1-mpi_mpich_h9c45103_1.tar.bz2#848971db056bf44543aa2ce530fcaa90 -https://conda.anaconda.org/conda-forge/linux-64/importlib-metadata-4.2.0-py36h5fab9bb_0.tar.bz2#3433e578b7ae54372be1c31bcacedb5c -https://conda.anaconda.org/conda-forge/noarch/importlib_resources-5.4.0-pyhd8ed1ab_0.tar.bz2#9fb134dbabe7851a9d71411064b2c30d -https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.3.1-py36h605e78d_1.tar.bz2#a92afbf92c5416585457e5de5c3d98c7 -https://conda.anaconda.org/conda-forge/linux-64/markupsafe-2.0.1-py36h8f6f2f9_0.tar.bz2#e450eb239eb68d0467b1c6d0fef28ae9 -https://conda.anaconda.org/conda-forge/linux-64/mpi4py-3.1.1-py36h7b8b12a_0.tar.bz2#0fe94f31d63b9b9d26279f2a3aaa6108 -https://conda.anaconda.org/conda-forge/linux-64/msgpack-python-1.0.2-py36h605e78d_1.tar.bz2#9460d0c8c77d3d5c410eb6743a48eca8 -https://conda.anaconda.org/conda-forge/linux-64/mypy_extensions-0.4.3-py36h5fab9bb_3.tar.bz2#abc16a8fe5dc31bde74702e28ed59164 -https://conda.anaconda.org/conda-forge/linux-64/numpy-1.19.5-py36hfc0c790_2.tar.bz2#68dd09a5147b9dfc38a75237b99dad8e -https://conda.anaconda.org/conda-forge/noarch/packaging-21.3-pyhd8ed1ab_0.tar.bz2#71f1ab2de48613876becddd496371c85 -https://conda.anaconda.org/conda-forge/noarch/partd-1.2.0-pyhd8ed1ab_0.tar.bz2#0c32f563d7f22e3a34c95cad8cc95651 -https://conda.anaconda.org/conda-forge/linux-64/pillow-8.3.2-py36h676a545_0.tar.bz2#38f5006cc5d5743ba92859bbee17fef1 -https://conda.anaconda.org/conda-forge/linux-64/proj-7.2.0-h277dcde_2.tar.bz2#db654ee11298d3463bad67445707654c -https://conda.anaconda.org/conda-forge/linux-64/psutil-5.8.0-py36h8f6f2f9_1.tar.bz2#ccecd9206d61f029549a81a980174ed8 -https://conda.anaconda.org/conda-forge/noarch/pydocstyle-6.1.1-pyhd8ed1ab_0.tar.bz2#e417954a987d382b3142886726ab3aad -https://conda.anaconda.org/conda-forge/linux-64/pysocks-1.7.1-py36h5fab9bb_3.tar.bz2#4dfb9be0b2975bc7933f32c6db7af205 -https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.8.2-pyhd8ed1ab_0.tar.bz2#dd999d1cc9f79e67dbb855c8924c7984 -https://conda.anaconda.org/conda-forge/linux-64/python-xxhash-2.0.2-py36h8f6f2f9_0.tar.bz2#7fd3e41e00f0ebcac23b775ad5a8ca90 -https://conda.anaconda.org/conda-forge/linux-64/pyyaml-5.4.1-py36h8f6f2f9_1.tar.bz2#e9ca9afbe0ffbde954118168bc971871 -https://conda.anaconda.org/conda-forge/linux-64/regex-2021.9.30-py36h8f6f2f9_0.tar.bz2#9a008df07c939d3359b4a85231355ce8 -https://conda.anaconda.org/conda-forge/linux-64/setuptools-58.0.4-py36h5fab9bb_2.tar.bz2#d584b24af029d1fc78b6a0d2cc294d40 -https://conda.anaconda.org/conda-forge/linux-64/tornado-6.1-py36h8f6f2f9_1.tar.bz2#3d19680e14cb7cf6f383ba1fd3a72f2c -https://conda.anaconda.org/conda-forge/linux-64/typed-ast-1.4.3-py36h8f6f2f9_0.tar.bz2#738cc2808eaff7e220a6f46bbddd4e5a -https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.0.0-hd8ed1ab_0.tar.bz2#673180987c7cedf380792f046425d37d -https://conda.anaconda.org/conda-forge/noarch/zict-2.0.0-py_0.tar.bz2#4750152be22f24d695b3004c5e1712d3 -https://conda.anaconda.org/conda-forge/linux-64/asv-0.4.2-py36hc4f0c31_2.tar.bz2#aba2d553655f81aa4ec683f0039104e1 -https://conda.anaconda.org/conda-forge/noarch/black-20.8b1-py_1.tar.bz2#e555d6b71ec916c3dc4e6e3793cc9796 -https://conda.anaconda.org/conda-forge/linux-64/brotlipy-0.7.0-py36h8f6f2f9_1001.tar.bz2#0f244e9624403e17430e9d959530b01c -https://conda.anaconda.org/conda-forge/linux-64/cftime-1.2.1-py36h68bb277_1.tar.bz2#86ea5650a50b9d59336edb2ec57959de -https://conda.anaconda.org/conda-forge/linux-64/cryptography-35.0.0-py36hb60f036_0.tar.bz2#d7533bed1783b4abef7e598cee93347d -https://conda.anaconda.org/conda-forge/noarch/dask-core-2021.3.0-pyhd8ed1ab_0.tar.bz2#e7a647c6320649dd7c80a1938f1a211c -https://conda.anaconda.org/conda-forge/linux-64/editdistance-s-1.0.0-py36h605e78d_1.tar.bz2#89c5489b410421aabe2888e73154b9d3 -https://conda.anaconda.org/conda-forge/noarch/flake8-4.0.1-pyhd8ed1ab_1.tar.bz2#ebcd260142f408f587dafc01170a32f2 -https://conda.anaconda.org/conda-forge/linux-64/immutables-0.16-py36h8f6f2f9_0.tar.bz2#e9abd4b9cee6d7dd062ca2b5f4e4c9f3 -https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-4.2.0-hd8ed1ab_0.tar.bz2#0213ccecb06d99c195f126d1ad595beb -https://conda.anaconda.org/conda-forge/noarch/jinja2-3.0.3-pyhd8ed1ab_0.tar.bz2#036d872c653780cb26e797e2e2f61b4c -https://conda.anaconda.org/conda-forge/linux-64/libnetcdf-4.8.1-mpi_mpich_h319fa22_1.tar.bz2#7583fbaea3648f692c0c019254bc196c -https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.3.4-py36hd391965_0.tar.bz2#ed3c55ad68aa87ba9c2b2d5f6ede7f14 -https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.6.0-pyhd8ed1ab_0.tar.bz2#0941325bf48969e2b3b19d0951740950 -https://conda.anaconda.org/conda-forge/linux-64/pandas-1.1.5-py36h284efc9_0.tar.bz2#e5e3d1a5401c1c932ada9d4f0b6c8448 -https://conda.anaconda.org/conda-forge/linux-64/scipy-1.5.3-py36h81d768a_1.tar.bz2#cf58f52d8e8db2f8aa333cd9c674f890 -https://conda.anaconda.org/conda-forge/linux-64/shapely-1.7.1-py36hff28ebb_5.tar.bz2#b4a2b90331fcf24164f0638e02987489 -https://conda.anaconda.org/conda-forge/linux-64/virtualenv-20.4.7-py36h5fab9bb_0.tar.bz2#329ecf0bd164adb3b3c6e3415c9567a0 -https://conda.anaconda.org/conda-forge/noarch/argcomplete-1.12.3-pyhd8ed1ab_2.tar.bz2#b8152341fc3fc9880c6e1b9d188974e5 -https://conda.anaconda.org/conda-forge/linux-64/bokeh-2.3.3-py36h5fab9bb_0.tar.bz2#245f99d03d1c2f347657819419b32089 -https://conda.anaconda.org/conda-forge/linux-64/cartopy-0.19.0.post1-py36hbcbf2fa_1.tar.bz2#b2de33eb27c5be8d96d2b3e55914fe93 -https://conda.anaconda.org/conda-forge/linux-64/cf-units-2.1.5-py36h4d9540e_0.tar.bz2#95e04b7b16b85009509a9c93577d9dc0 -https://conda.anaconda.org/conda-forge/noarch/contextvars-2.4-py_0.tar.bz2#295fe9300971a6bd1dc4b18ad6509be2 -https://conda.anaconda.org/conda-forge/noarch/flake8-docstrings-1.6.0-pyhd8ed1ab_0.tar.bz2#dea85c9bee98ef38c90fa7348f7fa333 -https://conda.anaconda.org/conda-forge/noarch/flake8-import-order-0.18.1-py_0.tar.bz2#b3fca0d4b267be46b6d38fa77bf36f9a -https://conda.anaconda.org/conda-forge/noarch/identify-2.3.7-pyhd8ed1ab_0.tar.bz2#ae1a5e834fbca62ee88ab55fb276be63 -https://conda.anaconda.org/conda-forge/linux-64/netcdf-fortran-4.5.3-mpi_mpich_h1364a43_6.tar.bz2#9caa0cf923af3d037897c6d7f8ea57c0 -https://conda.anaconda.org/conda-forge/linux-64/netcdf4-1.5.7-nompi_py36h775750b_103.tar.bz2#3674a70eb06844dd3d0d5ff82e2259bb -https://conda.anaconda.org/conda-forge/linux-64/pluggy-1.0.0-py36h5fab9bb_1.tar.bz2#8cb2c916d28b55bee23a253ebaeb8208 -https://conda.anaconda.org/conda-forge/noarch/pyopenssl-21.0.0-pyhd8ed1ab_0.tar.bz2#8c49efecb7dca466e18b06015e8c88ce -https://conda.anaconda.org/conda-forge/linux-64/distributed-2021.3.0-py36h5fab9bb_0.tar.bz2#d484e4c9daade19800341eedff55f1d2 -https://conda.anaconda.org/conda-forge/linux-64/esmf-8.1.1-mpi_mpich_h4975321_102.tar.bz2#1ee3e15fc9f1b452207d260c2be568ec -https://conda.anaconda.org/conda-forge/noarch/nox-2021.10.1-pyhd8ed1ab_0.tar.bz2#c29de03dc9c5595cb011a6b2204c14f5 -https://conda.anaconda.org/conda-forge/linux-64/pre-commit-2.15.0-py36h5fab9bb_0.tar.bz2#392557fa768955d7ac1d8941656d43a4 -https://conda.anaconda.org/conda-forge/linux-64/pytest-6.2.5-py36h5fab9bb_0.tar.bz2#fa341f0e8917db9c3a1793584cae415e -https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.7-pyhd8ed1ab_0.tar.bz2#be75bab4820a56f77ba1a3fc9139c36a -https://conda.anaconda.org/conda-forge/noarch/dask-2021.3.0-pyhd8ed1ab_0.tar.bz2#ad8913a398eedda25f6243d02c973f28 -https://conda.anaconda.org/conda-forge/linux-64/esmpy-8.1.1-mpi_mpich_py36h0aee31b_101.tar.bz2#97ac1a08c330210bb00addc8d6d7777d -https://conda.anaconda.org/conda-forge/noarch/pytest-cov-3.0.0-pyhd8ed1ab_0.tar.bz2#0f7cac11bb696b62d378bde725bfc3eb -https://conda.anaconda.org/conda-forge/noarch/requests-2.26.0-pyhd8ed1ab_0.tar.bz2#0ed2ccbde6db9dd5789068eb7194463f -https://conda.anaconda.org/conda-forge/noarch/codecov-2.1.11-pyhd3deb0d_0.tar.bz2#9c661c2c14b4667827218402e6624ad5 -https://conda.anaconda.org/conda-forge/linux-64/iris-3.0.3-py36h5fab9bb_0.tar.bz2#80b3bfe39a6c744daffbf101a145d42f diff --git a/requirements/py36.yml b/requirements/py36.yml deleted file mode 100644 index ab5f0065..00000000 --- a/requirements/py36.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: esmf-regrid-dev - -channels: - - conda-forge - -dependencies: - - python=3.6 - -# Setup dependencies. - - setuptools>=40.8.0 - -# Core dependencies. - - cartopy>=0.18 - - numpy>=1.14 - - scipy - - esmpy>=7.0 - - iris - - cf-units - -# Test dependencies. - - asv - - black=20.8b1 - - codecov - - flake8 - - flake8-docstrings - - flake8-import-order - - nox - - pre-commit - - pytest - - pytest-cov diff --git a/setup.cfg b/setup.cfg index 0ec968fd..ea9b40d4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,7 +30,7 @@ version = attr: esmf_regrid.__version__ [options] packages = find: python_requires = - >=3.6,<3.9 + >=3.7,<3.9 setup_requires = setuptools>=40.8.0 zip_safe = False