From 9d598f199806ad2f16cd826b4108cbba5f687952 Mon Sep 17 00:00:00 2001 From: lbdreyer Date: Mon, 31 Jan 2022 17:23:53 +0000 Subject: [PATCH 01/28] 3.2 version and whats new (#4559) --- docs/src/whatsnew/{dev.rst => 3.2.rst} | 9 +- docs/src/whatsnew/dev.rst.template | 112 ------------------------- docs/src/whatsnew/index.rst | 2 +- docs/src/whatsnew/latest.rst | 2 +- lib/iris/__init__.py | 2 +- 5 files changed, 7 insertions(+), 120 deletions(-) rename docs/src/whatsnew/{dev.rst => 3.2.rst} (98%) delete mode 100644 docs/src/whatsnew/dev.rst.template diff --git a/docs/src/whatsnew/dev.rst b/docs/src/whatsnew/3.2.rst similarity index 98% rename from docs/src/whatsnew/dev.rst rename to docs/src/whatsnew/3.2.rst index e2d4c2bc0b..c78e1283d6 100644 --- a/docs/src/whatsnew/dev.rst +++ b/docs/src/whatsnew/3.2.rst @@ -1,13 +1,13 @@ .. include:: ../common_links.inc -|iris_version| |build_date| [unreleased] -**************************************** +v3.2 (31 Jan 2022) [unreleased] +******************************* This document explains the changes made to Iris for this release (:doc:`View all changes `.) -.. dropdown:: :opticon:`report` |iris_version| Release Highlights +.. dropdown:: :opticon:`report` v3.2.0 Release Highlights :container: + shadow :title: text-primary text-center font-weight-bold :body: bg-light @@ -18,8 +18,7 @@ This document explains the changes made to Iris for this release * We've added experimental support for :ref:`Meshes `, which can now be loaded and - attached to a cube. Mesh support is based on the based on `CF-UGRID`_ - model. + attached to a cube. Mesh support is based on the `CF-UGRID`_ model. * We've also dropped support for ``Python 3.7``. And finally, get in touch with us on :issue:`GitHub` if you have diff --git a/docs/src/whatsnew/dev.rst.template b/docs/src/whatsnew/dev.rst.template deleted file mode 100644 index 79c578ca65..0000000000 --- a/docs/src/whatsnew/dev.rst.template +++ /dev/null @@ -1,112 +0,0 @@ -.. include:: ../common_links.inc - -|iris_version| |build_date| [unreleased] -**************************************** - -This document explains the changes made to Iris for this release -(:doc:`View all changes `.) - - -.. dropdown:: :opticon:`report` |iris_version| Release Highlights - :container: + shadow - :title: text-primary text-center font-weight-bold - :body: bg-light - :animate: fade-in - :open: - - The highlights for this major/minor release of Iris include: - - * N/A - - And finally, get in touch with us on :issue:`GitHub` if you have - any issues or feature requests for improving Iris. Enjoy! - - -NOTE: section below is a template for bugfix patches -==================================================== - (Please remove this section when creating an initial 'latest.rst') - -v3.X.X (DD MMM YYYY) -==================== - -.. dropdown:: :opticon:`alert` v3.X.X Patches - :container: + shadow - :title: text-primary text-center font-weight-bold - :body: bg-light - :animate: fade-in - - The patches in this release of Iris include: - - #. N/A - -NOTE: section above is a template for bugfix patches -==================================================== - (Please remove this section when creating an initial 'latest.rst') - - - -📢 Announcements -================ - -#. N/A - - -✨ Features -=========== - -#. N/A - - -🐛 Bugs Fixed -============= - -#. N/A - - -💣 Incompatible Changes -======================= - -#. N/A - - -🚀 Performance Enhancements -=========================== - -#. N/A - - -🔥 Deprecations -=============== - -#. N/A - - -🔗 Dependencies -=============== - -#. N/A - - -📚 Documentation -================ - -#. N/A - - -💼 Internal -=========== - -#. N/A - - -.. comment - Whatsnew author names (@github name) in alphabetical order. Note that, - core dev names are automatically included by the common_links.inc: - - - - -.. comment - Whatsnew resources in alphabetical order: - - diff --git a/docs/src/whatsnew/index.rst b/docs/src/whatsnew/index.rst index 51f03e8d8f..f425e649b9 100644 --- a/docs/src/whatsnew/index.rst +++ b/docs/src/whatsnew/index.rst @@ -10,7 +10,7 @@ Iris versions. .. toctree:: :maxdepth: 1 - dev.rst + 3.2.rst 3.1.rst 3.0.rst 2.4.rst diff --git a/docs/src/whatsnew/latest.rst b/docs/src/whatsnew/latest.rst index 56aebe92dd..2bdbea5d85 120000 --- a/docs/src/whatsnew/latest.rst +++ b/docs/src/whatsnew/latest.rst @@ -1 +1 @@ -dev.rst \ No newline at end of file +3.2.rst \ No newline at end of file diff --git a/lib/iris/__init__.py b/lib/iris/__init__.py index 26f03c0566..aca4e77e88 100644 --- a/lib/iris/__init__.py +++ b/lib/iris/__init__.py @@ -104,7 +104,7 @@ def callback(cube, field, filename): # Iris revision. -__version__ = "3.2.dev0" +__version__ = "3.2.0rc0" # Restrict the names imported when using "from iris import *" __all__ = [ From 8838e23f7461c575adc841fc5c3304c975805d6a Mon Sep 17 00:00:00 2001 From: Bill Little Date: Fri, 4 Feb 2022 10:24:11 +0000 Subject: [PATCH 02/28] update trove classifiers (#4564) --- setup.cfg | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 1d3fb8b7c9..c2d31a5ddb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,7 +11,6 @@ classifiers = Operating System :: Unix Programming Language :: Python Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: Implementation :: CPython Topic :: Scientific/Engineering From 611416730970d7603b6e1e4e48dc20c7fb55e2e6 Mon Sep 17 00:00:00 2001 From: Martin Yeo <40734014+trexfeathers@users.noreply.github.com> Date: Thu, 10 Feb 2022 11:44:56 +0000 Subject: [PATCH 03/28] New tool-agnostic ASV environment management (#4571) * New tool-agnostic ASV env management. * Benchmarking only build the latest Python. * Increase benchmark accuracy by increasing rounds. * Fix ASV rounds mistake. * ASV clearer use of _interpolate_commands. --- benchmarks/asv.conf.json | 23 ++- benchmarks/asv_delegated_conda.py | 208 +++++++++++++++++++++++++ benchmarks/nox_asv_plugin.py | 249 ------------------------------ noxfile.py | 9 +- 4 files changed, 231 insertions(+), 258 deletions(-) create mode 100644 benchmarks/asv_delegated_conda.py delete mode 100644 benchmarks/nox_asv_plugin.py diff --git a/benchmarks/asv.conf.json b/benchmarks/asv.conf.json index 9ea1cdb101..3468b2fca9 100644 --- a/benchmarks/asv.conf.json +++ b/benchmarks/asv.conf.json @@ -3,18 +3,25 @@ "project": "scitools-iris", "project_url": "https://github.com/SciTools/iris", "repo": "..", - "environment_type": "nox-conda", + "environment_type": "conda-delegated", "show_commit_url": "http://github.com/scitools/iris/commit/", "benchmark_dir": "./benchmarks", "env_dir": ".asv/env", "results_dir": ".asv/results", "html_dir": ".asv/html", - "plugins": [".nox_asv_plugin"], - // The commit to checkout to first run Nox to set up the environment. - "nox_setup_commit": "HEAD", - // The path of the noxfile's location relative to the project root. - "noxfile_rel_path": "noxfile.py", - // The ``--session`` arg to be used with ``--install-only`` to prep an environment. - "nox_session_name": "tests" + "plugins": [".asv_delegated_conda"], + + // The command(s) that create/update an environment correctly for the + // checked-out commit. + // Interpreted the same as build_command, with following exceptions: + // * No build-time environment variables. + // * Is run in the same environment as the ASV install itself. + "delegated_env_commands": [ + "sed -i 's/_PY_VERSIONS_ALL/_PY_VERSION_LATEST/g' noxfile.py", + "nox --envdir={conf_dir}/.asv/env/nox01 --session=tests --install-only --no-error-on-external-run --verbose" + ], + // The parent directory of the above environment. + // The most recently modified environment in the directory will be used. + "delegated_env_parent": "{conf_dir}/.asv/env/nox01" } diff --git a/benchmarks/asv_delegated_conda.py b/benchmarks/asv_delegated_conda.py new file mode 100644 index 0000000000..250a4e032d --- /dev/null +++ b/benchmarks/asv_delegated_conda.py @@ -0,0 +1,208 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +ASV plug-in providing an alternative :class:`asv.plugins.conda.Conda` +subclass that manages the Conda environment via custom user scripts. + +""" + +from os import environ +from os.path import getmtime +from pathlib import Path +from shutil import copy2, copytree, rmtree +from tempfile import TemporaryDirectory + +from asv import util as asv_util +from asv.config import Config +from asv.console import log +from asv.plugins.conda import Conda +from asv.repo import Repo + + +class CondaDelegated(Conda): + """ + Manage a Conda environment using custom user scripts, run at each commit. + + Ignores user input variations - ``matrix`` / ``pythons`` / + ``conda_environment_file``, since environment is being managed outside ASV. + + Original environment creation behaviour is inherited, but upon checking out + a commit the custom script(s) are run and the original environment is + replaced with a symlink to the custom environment. This arrangement is then + re-used in subsequent runs. + + """ + + tool_name = "conda-delegated" + + def __init__( + self, + conf: Config, + python: str, + requirements: dict, + tagged_env_vars: dict, + ) -> None: + """ + Parameters + ---------- + conf : Config instance + + python : str + Version of Python. Must be of the form "MAJOR.MINOR". + + requirements : dict + Dictionary mapping a PyPI package name to a version + identifier string. + + tagged_env_vars : dict + Environment variables, tagged for build vs. non-build + + """ + ignored = ["`python`"] + if requirements: + ignored.append("`requirements`") + if tagged_env_vars: + ignored.append("`tagged_env_vars`") + if conf.conda_environment_file: + ignored.append("`conda_environment_file`") + message = ( + f"Ignoring ASV setting(s): {', '.join(ignored)}. Benchmark " + "environment management is delegated to third party script(s)." + ) + log.warning(message) + requirements = {} + tagged_env_vars = {} + conf.conda_environment_file = None + + super().__init__(conf, python, requirements, tagged_env_vars) + self._update_info() + + self._env_commands = self._interpolate_commands( + conf.delegated_env_commands + ) + # Again using _interpolate_commands to get env parent path - allows use + # of the same ASV env variables. + env_parent_interpolated = self._interpolate_commands( + conf.delegated_env_parent + ) + # Returns list of tuples, we just want the first. + env_parent_first = env_parent_interpolated[0] + # The 'command' is the first item in the returned tuple. + env_parent_string = " ".join(env_parent_first[0]) + self._delegated_env_parent = Path(env_parent_string).resolve() + + @property + def name(self): + """Get a name to uniquely identify this environment.""" + return asv_util.sanitize_filename(self.tool_name) + + def _update_info(self) -> None: + """Make sure class properties reflect the actual environment being used.""" + # Follow symlink if it has been created. + actual_path = Path(self._path).resolve() + self._path = str(actual_path) + + # Get custom environment's Python version if it exists yet. + try: + get_version = ( + "from sys import version_info; " + "print(f'{version_info.major}.{version_info.minor}')" + ) + actual_python = self.run(["-c", get_version]) + self._python = actual_python + except OSError: + pass + + def _prep_env(self) -> None: + """Run the custom environment script(s) and switch to using that environment.""" + message = f"Running delegated environment management for: {self.name}" + log.info(message) + env_path = Path(self._path) + + def copy_asv_files(src_parent: Path, dst_parent: Path) -> None: + """For copying between self._path and a temporary cache.""" + asv_files = list(src_parent.glob("asv*")) + # build_root_path.name usually == "project" . + asv_files += [src_parent / Path(self._build_root).name] + for src_path in asv_files: + dst_path = dst_parent / src_path.name + if not dst_path.exists(): + # Only caching in case the environment has been rebuilt. + # If the dst_path already exists: rebuilding hasn't + # happened. Also a non-issue when copying in the reverse + # direction because the cache dir is temporary. + if src_path.is_dir(): + func = copytree + else: + func = copy2 + func(src_path, dst_path) + + with TemporaryDirectory(prefix="delegated_asv_cache_") as asv_cache: + asv_cache_path = Path(asv_cache) + # Cache all of ASV's files as delegated command may remove and + # re-build the environment. + copy_asv_files(env_path.resolve(), asv_cache_path) + + # Adapt the build_dir to the cache location. + build_root_path = Path(self._build_root) + build_dir_original = build_root_path / self._repo_subdir + build_dir_subpath = build_dir_original.relative_to( + build_root_path.parent + ) + build_dir = asv_cache_path / build_dir_subpath + + # Run the script(s) for delegated environment creation/updating. + # (An adaptation of self._interpolate_and_run_commands). + for command, env, return_codes, cwd in self._env_commands: + local_envs = dict(environ) + local_envs.update(env) + if cwd is None: + cwd = str(build_dir) + _ = asv_util.check_output( + command, + timeout=self._install_timeout, + cwd=cwd, + env=local_envs, + valid_return_codes=return_codes, + ) + + # Replace the env that ASV created with a symlink to the env + # created/updated by the custom script. + delegated_env_path = sorted( + self._delegated_env_parent.glob("*"), + key=getmtime, + reverse=True, + )[0] + if env_path.resolve() != delegated_env_path: + try: + env_path.unlink(missing_ok=True) + except IsADirectoryError: + rmtree(env_path) + env_path.symlink_to( + delegated_env_path, target_is_directory=True + ) + + # Check that environment exists. + try: + env_path.resolve(strict=True) + except FileNotFoundError: + message = f"Path does not resolve to environment: {env_path}" + log.error(message) + raise RuntimeError(message) + + # Restore ASV's files from the cache (if necessary). + copy_asv_files(asv_cache_path, env_path.resolve()) + + # Record new environment information in properties. + self._update_info() + + def checkout_project(self, repo: Repo, commit_hash: str) -> None: + """Check out the working tree of the project at given commit hash.""" + super().checkout_project(repo, commit_hash) + self._prep_env() + log.info( + f"Environment {self.name} updated to spec at {commit_hash[:8]}" + ) diff --git a/benchmarks/nox_asv_plugin.py b/benchmarks/nox_asv_plugin.py deleted file mode 100644 index 6c9ce14272..0000000000 --- a/benchmarks/nox_asv_plugin.py +++ /dev/null @@ -1,249 +0,0 @@ -# Copyright Iris contributors -# -# This file is part of Iris and is released under the LGPL license. -# See COPYING and COPYING.LESSER in the root of the repository for full -# licensing details. -""" -ASV plug-in providing an alternative ``Environment`` subclass, which uses Nox -for environment management. - -""" -from importlib.util import find_spec -from pathlib import Path -from shutil import copy2, copytree -from tempfile import TemporaryDirectory - -from asv import util as asv_util -from asv.config import Config -from asv.console import log -from asv.environment import get_env_name -from asv.plugins.conda import Conda, _find_conda -from asv.repo import Repo, get_repo - - -class NoxConda(Conda): - """ - Manage a Conda environment using Nox, updating environment at each commit. - - Defers environment management to the project's noxfile, which must be able - to create/update the benchmarking environment using ``nox --install-only``, - with the ``--session`` specified in ``asv.conf.json.nox_session_name``. - - Notes - ----- - If not all benchmarked commits support this use of Nox: the plugin will - need to be modified to prep the environment in other ways. - - """ - - tool_name = "nox-conda" - - @classmethod - def matches(cls, python: str) -> bool: - """Used by ASV to work out if this type of environment can be used.""" - result = find_spec("nox") is not None - if result: - result = super().matches(python) - - if result: - message = ( - f"NOTE: ASV env match check incomplete. Not possible to know " - f"if selected Nox session (asv.conf.json.nox_session_name) is " - f"compatible with ``--python={python}`` until project is " - f"checked out." - ) - log.warning(message) - - return result - - def __init__(self, conf: Config, python: str, requirements: dict) -> None: - """ - Parameters - ---------- - conf: Config instance - - python : str - Version of Python. Must be of the form "MAJOR.MINOR". - - requirements : dict - Dictionary mapping a PyPI package name to a version - identifier string. - - """ - from nox.sessions import _normalize_path - - # Need to checkout the project BEFORE the benchmark run - to access a noxfile. - self.project_temp_checkout = TemporaryDirectory( - prefix="nox_asv_checkout_" - ) - repo = get_repo(conf) - repo.checkout(self.project_temp_checkout.name, conf.nox_setup_commit) - self.noxfile_rel_path = conf.noxfile_rel_path - self.setup_noxfile = ( - Path(self.project_temp_checkout.name) / self.noxfile_rel_path - ) - self.nox_session_name = conf.nox_session_name - - # Some duplication of parent code - need these attributes BEFORE - # running inherited code. - self._python = python - self._requirements = requirements - self._env_dir = conf.env_dir - - # Prepare the actual environment path, to override self._path. - nox_envdir = str(Path(self._env_dir).absolute() / self.hashname) - nox_friendly_name = self._get_nox_session_name(python) - self._nox_path = Path(_normalize_path(nox_envdir, nox_friendly_name)) - - # For storing any extra conda requirements from asv.conf.json. - self._extra_reqs_path = self._nox_path / "asv-extra-reqs.yaml" - - super().__init__(conf, python, requirements) - - @property - def _path(self) -> str: - """ - Using a property to override getting and setting in parent classes - - unable to modify parent classes as this is a plugin. - - """ - return str(self._nox_path) - - @_path.setter - def _path(self, value) -> None: - """Enforce overriding of this variable by disabling modification.""" - pass - - @property - def name(self) -> str: - """Overridden to prevent inclusion of user input requirements.""" - return get_env_name(self.tool_name, self._python, {}) - - def _get_nox_session_name(self, python: str) -> str: - nox_cmd_substring = ( - f"--noxfile={self.setup_noxfile} " - f"--session={self.nox_session_name} " - f"--python={python}" - ) - - list_output = asv_util.check_output( - ["nox", "--list", *nox_cmd_substring.split(" ")], - display_error=False, - dots=False, - ) - list_output = list_output.split("\n") - list_matches = list(filter(lambda s: s.startswith("*"), list_output)) - matches_count = len(list_matches) - - if matches_count == 0: - message = f"No Nox sessions found for: {nox_cmd_substring} ." - log.error(message) - raise RuntimeError(message) - elif matches_count > 1: - message = ( - f"Ambiguous - >1 Nox session found for: {nox_cmd_substring} ." - ) - log.error(message) - raise RuntimeError(message) - else: - line = list_matches[0] - session_name = line.split(" ")[1] - assert isinstance(session_name, str) - return session_name - - def _nox_prep_env(self, setup: bool = False) -> None: - message = f"Running Nox environment update for: {self.name}" - log.info(message) - - build_root_path = Path(self._build_root) - env_path = Path(self._path) - - def copy_asv_files(src_parent: Path, dst_parent: Path) -> None: - """For copying between self._path and a temporary cache.""" - asv_files = list(src_parent.glob("asv*")) - # build_root_path.name usually == "project" . - asv_files += [src_parent / build_root_path.name] - for src_path in asv_files: - dst_path = dst_parent / src_path.name - if not dst_path.exists(): - # Only cache-ing in case Nox has rebuilt the env @ - # self._path. If the dst_path already exists: rebuilding - # hasn't happened. Also a non-issue when copying in the - # reverse direction because the cache dir is temporary. - if src_path.is_dir(): - func = copytree - else: - func = copy2 - func(src_path, dst_path) - - with TemporaryDirectory(prefix="nox_asv_cache_") as asv_cache: - asv_cache_path = Path(asv_cache) - if setup: - noxfile = self.setup_noxfile - else: - # Cache all of ASV's files as Nox may remove and re-build the environment. - copy_asv_files(env_path, asv_cache_path) - # Get location of noxfile in cache. - noxfile_original = ( - build_root_path / self._repo_subdir / self.noxfile_rel_path - ) - noxfile_subpath = noxfile_original.relative_to( - build_root_path.parent - ) - noxfile = asv_cache_path / noxfile_subpath - - nox_cmd = [ - "nox", - f"--noxfile={noxfile}", - # Place the env in the ASV env directory, instead of the default. - f"--envdir={env_path.parent}", - f"--session={self.nox_session_name}", - f"--python={self._python}", - "--install-only", - "--no-error-on-external-run", - "--verbose", - ] - - _ = asv_util.check_output(nox_cmd) - if not env_path.is_dir(): - message = f"Expected Nox environment not found: {env_path}" - log.error(message) - raise RuntimeError(message) - - if not setup: - # Restore ASV's files from the cache (if necessary). - copy_asv_files(asv_cache_path, env_path) - - def _setup(self) -> None: - """Used for initial environment creation - mimics parent method where possible.""" - try: - self.conda = _find_conda() - except IOError as e: - raise asv_util.UserError(str(e)) - if find_spec("nox") is None: - raise asv_util.UserError("Module not found: nox") - - message = f"Creating Nox-Conda environment for {self.name} ." - log.info(message) - - try: - self._nox_prep_env(setup=True) - finally: - # No longer need the setup checkout now that the environment has been built. - self.project_temp_checkout.cleanup() - - conda_args, pip_args = self._get_requirements(self.conda) - if conda_args or pip_args: - message = ( - "Ignoring user input package requirements. Benchmark " - "environment management is exclusively performed by Nox." - ) - log.warning(message) - - def checkout_project(self, repo: Repo, commit_hash: str) -> None: - """Check out the working tree of the project at given commit hash.""" - super().checkout_project(repo, commit_hash) - self._nox_prep_env() - log.info( - f"Environment {self.name} updated to spec at {commit_hash[:8]}" - ) diff --git a/noxfile.py b/noxfile.py index 8b23948677..6367b74aef 100755 --- a/noxfile.py +++ b/noxfile.py @@ -328,7 +328,14 @@ def asv_exec(*sub_args: str) -> None: # Else: compare to previous commit. previous_commit = os.environ.get("PR_BASE_SHA", "HEAD^1") try: - asv_exec("continuous", "--factor=1.2", previous_commit, "HEAD") + asv_exec( + "continuous", + "--factor=1.2", + previous_commit, + "HEAD", + "--attribute", + "rounds=4", + ) finally: asv_exec("compare", previous_commit, "HEAD") else: From ee9cadc989931dbd93d59495c92ddec88dbf4e68 Mon Sep 17 00:00:00 2001 From: lbdreyer Date: Fri, 11 Feb 2022 11:06:20 +0000 Subject: [PATCH 04/28] Fix load_http bug, extend testing, and note to docs (#4580) * Fix opendap bug, add docs and extra testing * Add whats new entry * Update docs/src/whatsnew/3.2.rst Co-authored-by: Bill Little * Add warning box Co-authored-by: Bill Little --- docs/src/whatsnew/3.2.rst | 3 +++ lib/iris/__init__.py | 8 ++++++++ lib/iris/fileformats/netcdf.py | 4 ++-- lib/iris/io/__init__.py | 8 ++++---- lib/iris/tests/test_load.py | 35 +++++++++++++++++++++++++++------- 5 files changed, 45 insertions(+), 13 deletions(-) diff --git a/docs/src/whatsnew/3.2.rst b/docs/src/whatsnew/3.2.rst index c78e1283d6..9aa6a78846 100644 --- a/docs/src/whatsnew/3.2.rst +++ b/docs/src/whatsnew/3.2.rst @@ -177,6 +177,9 @@ This document explains the changes made to Iris for this release to indicate cloud fraction greater than 7.9 oktas, rather than 7.5 (:issue:`3305`, :pull:`4535`) +#. `@lbdreyer`_ fixed a bug in :class:`iris.io.load_http` which was missing an import + (:pull:`4580`) + 💣 Incompatible Changes ======================= diff --git a/lib/iris/__init__.py b/lib/iris/__init__.py index aca4e77e88..95722c69cf 100644 --- a/lib/iris/__init__.py +++ b/lib/iris/__init__.py @@ -44,6 +44,10 @@ standard library function :func:`os.path.expanduser` and module :mod:`fnmatch` for more details. + .. warning:: + + If supplying a URL, only OPeNDAP Data Sources are supported. + * constraints: Either a single constraint, or an iterable of constraints. Each constraint can be either a string, an instance of @@ -287,6 +291,7 @@ def load(uris, constraints=None, callback=None): * uris: One or more filenames/URIs, as a string or :class:`pathlib.PurePath`. + If supplying a URL, only OPeNDAP Data Sources are supported. Kwargs: @@ -315,6 +320,7 @@ def load_cube(uris, constraint=None, callback=None): * uris: One or more filenames/URIs, as a string or :class:`pathlib.PurePath`. + If supplying a URL, only OPeNDAP Data Sources are supported. Kwargs: @@ -354,6 +360,7 @@ def load_cubes(uris, constraints=None, callback=None): * uris: One or more filenames/URIs, as a string or :class:`pathlib.PurePath`. + If supplying a URL, only OPeNDAP Data Sources are supported. Kwargs: @@ -399,6 +406,7 @@ def load_raw(uris, constraints=None, callback=None): * uris: One or more filenames/URIs, as a string or :class:`pathlib.PurePath`. + If supplying a URL, only OPeNDAP Data Sources are supported. Kwargs: diff --git a/lib/iris/fileformats/netcdf.py b/lib/iris/fileformats/netcdf.py index 100ab29daa..dd819fb63f 100644 --- a/lib/iris/fileformats/netcdf.py +++ b/lib/iris/fileformats/netcdf.py @@ -825,12 +825,12 @@ def inner(cf_datavar): def load_cubes(filenames, callback=None, constraints=None): """ - Loads cubes from a list of NetCDF filenames/URLs. + Loads cubes from a list of NetCDF filenames/OPeNDAP URLs. Args: * filenames (string/list): - One or more NetCDF filenames/DAP URLs to load from. + One or more NetCDF filenames/OPeNDAP URLs to load from. Kwargs: diff --git a/lib/iris/io/__init__.py b/lib/iris/io/__init__.py index 034fa4baab..8d5a2e05d2 100644 --- a/lib/iris/io/__init__.py +++ b/lib/iris/io/__init__.py @@ -216,7 +216,7 @@ def load_files(filenames, callback, constraints=None): def load_http(urls, callback): """ - Takes a list of urls and a callback function, and returns a generator + Takes a list of OPeNDAP URLs and a callback function, and returns a generator of Cubes from the given URLs. .. note:: @@ -226,11 +226,11 @@ def load_http(urls, callback): """ # Create default dict mapping iris format handler to its associated filenames + from iris.fileformats import FORMAT_AGENT + handler_map = collections.defaultdict(list) for url in urls: - handling_format_spec = iris.fileformats.FORMAT_AGENT.get_spec( - url, None - ) + handling_format_spec = FORMAT_AGENT.get_spec(url, None) handler_map[handling_format_spec].append(url) # Call each iris format handler with the appropriate filenames diff --git a/lib/iris/tests/test_load.py b/lib/iris/tests/test_load.py index 86ff2f1ece..d21b40ee26 100644 --- a/lib/iris/tests/test_load.py +++ b/lib/iris/tests/test_load.py @@ -12,6 +12,9 @@ import iris.tests as tests # isort:skip import pathlib +from unittest import mock + +import netCDF4 import iris import iris.io @@ -148,19 +151,20 @@ def test_path_object(self): self.assertEqual(len(cubes), 1) -class TestOpenDAP(tests.IrisTest): - def test_load(self): - # Check that calling iris.load_* with a http URI triggers a call to - # ``iris.io.load_http`` +class TestOPeNDAP(tests.IrisTest): + def setUp(self): + self.url = "http://geoport.whoi.edu:80/thredds/dodsC/bathy/gom15" - url = "http://geoport.whoi.edu:80/thredds/dodsC/bathy/gom15" + def test_load_http_called(self): + # Check that calling iris.load_* with an http URI triggers a call to + # ``iris.io.load_http`` class LoadHTTPCalled(Exception): pass def new_load_http(passed_urls, *args, **kwargs): self.assertEqual(len(passed_urls), 1) - self.assertEqual(url, passed_urls[0]) + self.assertEqual(self.url, passed_urls[0]) raise LoadHTTPCalled() try: @@ -174,11 +178,28 @@ def new_load_http(passed_urls, *args, **kwargs): iris.load_cubes, ]: with self.assertRaises(LoadHTTPCalled): - fn(url) + fn(self.url) finally: iris.io.load_http = orig + def test_netCDF_Dataset_call(self): + # Check that load_http calls netCDF4.Dataset and supplies the expected URL. + + # To avoid making a request to an OPeNDAP server in a test, instead + # mock the call to netCDF.Dataset so that it returns a dataset for a + # local file. + filename = tests.get_data_path( + ("NetCDF", "global", "xyt", "SMALL_total_column_co2.nc") + ) + fake_dataset = netCDF4.Dataset(filename) + + with mock.patch( + "netCDF4.Dataset", return_value=fake_dataset + ) as dataset_loader: + next(iris.io.load_http([self.url], callback=None)) + dataset_loader.assert_called_with(self.url, mode="r") + if __name__ == "__main__": tests.main() From 683ed1879b4d43219875cca67467c17fad82b3f4 Mon Sep 17 00:00:00 2001 From: lbdreyer Date: Tue, 15 Feb 2022 13:57:37 +0000 Subject: [PATCH 05/28] Finalise whatsnew and version string update (#4588) --- docs/src/whatsnew/3.2.rst | 6 +++--- lib/iris/__init__.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/src/whatsnew/3.2.rst b/docs/src/whatsnew/3.2.rst index 9aa6a78846..ef3764daa5 100644 --- a/docs/src/whatsnew/3.2.rst +++ b/docs/src/whatsnew/3.2.rst @@ -1,7 +1,7 @@ .. include:: ../common_links.inc -v3.2 (31 Jan 2022) [unreleased] -******************************* +v3.2 (15 Feb 2022) +****************** This document explains the changes made to Iris for this release (:doc:`View all changes `.) @@ -351,7 +351,7 @@ This document explains the changes made to Iris for this release #. `@lbdreyer`_ corrected the license PyPI classifier. (:pull:`4435`) -#. `@aaronspring `_ exchanged ``dask`` with +#. `@aaronspring`_ exchanged ``dask`` with ``dask-core`` in testing environments reducing the number of dependencies installed for testing. (:pull:`4434`) diff --git a/lib/iris/__init__.py b/lib/iris/__init__.py index 95722c69cf..009a83aed5 100644 --- a/lib/iris/__init__.py +++ b/lib/iris/__init__.py @@ -108,7 +108,7 @@ def callback(cube, field, filename): # Iris revision. -__version__ = "3.2.0rc0" +__version__ = "3.2.0" # Restrict the names imported when using "from iris import *" __all__ = [ From 0365407396f7606b40487c84afe4ff3cc0b9cc45 Mon Sep 17 00:00:00 2001 From: Bill Little Date: Tue, 15 Feb 2022 15:31:22 +0000 Subject: [PATCH 06/28] docs linkcheck skip (#4590) --- docs/src/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/src/conf.py b/docs/src/conf.py index 19f22e808f..db2cdc3633 100644 --- a/docs/src/conf.py +++ b/docs/src/conf.py @@ -316,6 +316,7 @@ def _dotv(version): "https://software.ac.uk/how-cite-software", "http://www.esrl.noaa.gov/psd/data/gridded/conventions/cdc_netcdf_standard.shtml", "http://www.nationalarchives.gov.uk/doc/open-government-licence", + "https://www.metoffice.gov.uk/", ] # list of sources to exclude from the build. From f66353f611a1bfc7ee708da32ea330a100bc577f Mon Sep 17 00:00:00 2001 From: lbdreyer Date: Wed, 16 Feb 2022 11:36:58 +0000 Subject: [PATCH 07/28] Add missing commit to v3.2.x and update version number (#4593) * fix trove classifier (#4324) * update version to 3.2. post release Co-authored-by: Bill Little --- lib/iris/__init__.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/iris/__init__.py b/lib/iris/__init__.py index 009a83aed5..a28a7cd479 100644 --- a/lib/iris/__init__.py +++ b/lib/iris/__init__.py @@ -108,7 +108,7 @@ def callback(cube, field, filename): # Iris revision. -__version__ = "3.2.0" +__version__ = "3.2.0.post0" # Restrict the names imported when using "from iris import *" __all__ = [ diff --git a/setup.cfg b/setup.cfg index c2d31a5ddb..1aabe33d83 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,7 @@ author = SciTools Developers author_email = scitools-iris-dev@googlegroups.com classifiers = - Development Status :: 5 Production/Stable + Development Status :: 5 - Production/Stable Intended Audience :: Science/Research License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+) Operating System :: MacOS From 2c29705d9e6286f75c802afa4a23f1c610189ca8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Feb 2022 13:50:30 +0000 Subject: [PATCH 08/28] Bump actions/script from 5.1.0 to 6 (#4586) Bumps [actions/script](https://github.com/actions/script) from 5.1.0 to 6. - [Release notes](https://github.com/actions/script/releases) - [Commits](https://github.com/actions/script/compare/v5.1.0...v6) --- updated-dependencies: - dependency-name: actions/script dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/refresh-lockfiles.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/refresh-lockfiles.yml b/.github/workflows/refresh-lockfiles.yml index b40c3ca446..28e01e4511 100644 --- a/.github/workflows/refresh-lockfiles.yml +++ b/.github/workflows/refresh-lockfiles.yml @@ -35,7 +35,7 @@ jobs: # the lockfile bot has made the head commit, abort the workflow. # This job can be manually overridden by running directly from the github actions panel # (known as a "workflow_dispatch") and setting the `clobber` input to "yes". - - uses: actions/script@v5.1.0 + - uses: actions/script@v6 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | From 36935a4775a4586cba65cddc43bc9c7fec69bfc4 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Wed, 16 Feb 2022 16:38:06 +0000 Subject: [PATCH 09/28] Yaml fixes + clarifications. (#4594) * Yaml fixes + clarifications. * Update .github/workflows/stale.yml Co-authored-by: Ruth Comer <10599679+rcomer@users.noreply.github.com> Co-authored-by: Bill Little Co-authored-by: Ruth Comer <10599679+rcomer@users.noreply.github.com> --- .github/workflows/refresh-lockfiles.yml | 4 +++- .github/workflows/stale.yml | 10 +++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/refresh-lockfiles.yml b/.github/workflows/refresh-lockfiles.yml index 28e01e4511..ff2f6c4d75 100644 --- a/.github/workflows/refresh-lockfiles.yml +++ b/.github/workflows/refresh-lockfiles.yml @@ -22,7 +22,9 @@ on: default: "no" schedule: # Run once a week on a Saturday night - - cron: 1 0 * * 6 + # N.B. "should" be quoted, according to + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onschedule + - cron: "1 0 * * 6" jobs: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index f9bb09ce46..a1bb0fca6c 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,9 +1,13 @@ # See https://github.com/actions/stale name: Stale issues and pull-requests + on: schedule: - - cron: 0 0 * * * + # Run once a day + # N.B. "should" be quoted, according to + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onschedule + - cron: "0 0 * * *" jobs: stale: @@ -59,11 +63,11 @@ jobs: stale-pr-label: Stale # Labels on issues exempted from stale. - exempt-issue-labels: | + exempt-issue-labels: "Status: Blocked,Status: Decision Required,Peloton 🚴‍♂️,Good First Issue" # Labels on prs exempted from stale. - exempt-pr-labels: | + exempt-pr-labels: "Status: Blocked,Status: Decision Required,Peloton 🚴‍♂️,Good First Issue" # Max number of operations per run. From 70583f96849789ad4af35caface1a4474466d983 Mon Sep 17 00:00:00 2001 From: Martin Yeo <40734014+trexfeathers@users.noreply.github.com> Date: Wed, 16 Feb 2022 23:14:27 +0000 Subject: [PATCH 10/28] Overnight benchmarks (#4583) --- .github/workflows/benchmark.yml | 59 +++++++++--- benchmarks/README.md | 80 ++++++++++++++++ noxfile.py | 165 +++++++++++++++++++++++++------- 3 files changed, 253 insertions(+), 51 deletions(-) create mode 100644 benchmarks/README.md diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index a8247a247b..d4c01af48a 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -1,10 +1,11 @@ -# This is a basic workflow to help you get started with Actions +# Use ASV to check for performance regressions in the last 24 hours' commits. name: benchmark-check on: - # Triggers the workflow on push or pull request events but only for the master branch - pull_request: + schedule: + # Runs every day at 23:00. + - cron: "0 23 * * *" jobs: benchmark: @@ -23,12 +24,8 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 - - - name: Fetch the PR base branch too - run: | - git fetch --depth=1 origin ${{ github.event.pull_request.base.ref }} - git branch _base FETCH_HEAD - echo PR_BASE_SHA=$(git rev-parse _base) >> $GITHUB_ENV + with: + fetch-depth: 0 - name: Install Nox run: | @@ -65,11 +62,46 @@ jobs: run: | echo "OVERRIDE_TEST_DATA_REPOSITORY=${GITHUB_WORKSPACE}/${IRIS_TEST_DATA_PATH}/test_data" >> $GITHUB_ENV - - name: Run CI benchmarks + - name: Run overnight benchmarks + run: | + first_commit=$(git log --after="$(date -d "1 day ago" +"%Y-%m-%d") 23:00:00" --pretty=format:"%h" | tail -n 1) + if [ "$first_commit" != "" ] + then + nox --session="benchmarks(overnight)" -- $first_commit + fi + + - name: Create issues for performance shifts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - mkdir --parents benchmarks/.asv - set -o pipefail - nox --session="benchmarks(ci compare)" | tee benchmarks/.asv/ci_compare.txt + if [ -d benchmarks/.asv/performance-shifts ] + then + cd benchmarks/.asv/performance-shifts + for commit_file in * + do + pr_number=$(git log "$commit_file"^! --oneline | grep -o "#[0-9]*" | tail -1 | cut -c 2-) + assignee=$(gh pr view $pr_number --json author -q '.["author"]["login"]' --repo $GITHUB_REPOSITORY) + title="Performance Shift(s): \`$commit_file\`" + body=" + Benchmark comparison has identified performance shifts at commit \ + $commit_file (#$pr_number). Please review the report below and \ + take corrective/congratulatory action as appropriate \ + :slightly_smiling_face: + +
+ Performance shift report + + \`\`\` + $(cat $commit_file) + \`\`\` + +
+ + Generated by GHA run [\`${{github.run_id}}\`](https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}) + " + gh issue create --title "$title" --body "$body" --assignee $assignee --label "Bot" --label "Type: Performance" --repo $GITHUB_REPOSITORY + done + fi - name: Archive asv results if: ${{ always() }} @@ -78,4 +110,3 @@ jobs: name: asv-report path: | benchmarks/.asv/results - benchmarks/.asv/ci_compare.txt diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000000..baa1afe700 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,80 @@ +# Iris Performance Benchmarking + +Iris uses an [Airspeed Velocity](https://github.com/airspeed-velocity/asv) +(ASV) setup to benchmark performance. This is primarily designed to check for +performance shifts between commits using statistical analysis, but can also +be easily repurposed for manual comparative and scalability analyses. + +The benchmarks are automatically run overnight +[by a GitHub Action](../.github/workflows/benchmark.yml), with any notable +shifts in performance being flagged in a new GitHub issue. + +## Running benchmarks + +`asv ...` commands must be run from this directory. You will need to have ASV +installed, as well as Nox (see +[Benchmark environments](#benchmark-environments)). + +[Iris' noxfile](../noxfile.py) includes a `benchmarks` session that provides +conveniences for setting up before benchmarking, and can also replicate the +automated overnight run locally. See the session docstring for detail. + +### Environment variables + +* ``DATA_GEN_PYTHON`` - required - path to a Python executable that can be +used to generate benchmark test objects/files; see +[Data generation](#data-generation). The Nox session sets this automatically, +but will defer to any value already set in the shell. +* ``BENCHMARK_DATA`` - optional - path to a directory for benchmark synthetic +test data, which the benchmark scripts will create if it doesn't already +exist. Defaults to ``/benchmarks/.data/`` if not set. + +## Writing benchmarks + +[See the ASV docs](https://asv.readthedocs.io/) for full detail. + +### Data generation +**Important:** be sure not to use the benchmarking environment to generate any +test objects/files, as this environment changes with each commit being +benchmarked, creating inconsistent benchmark 'conditions'. The +[generate_data](./benchmarks/generate_data/__init__.py) module offers a +solution; read more detail there. + +### ASV re-run behaviour + +Note that ASV re-runs a benchmark multiple times between its `setup()` routine. +This is a problem for benchmarking certain Iris operations such as data +realisation, since the data will no longer be lazy after the first run. +Consider writing extra steps to restore objects' original state _within_ the +benchmark itself. + +If adding steps to the benchmark will skew the result too much then re-running +can be disabled by setting an attribute on the benchmark: `number = 1`. To +maintain result accuracy this should be accompanied by increasing the number of +repeats _between_ `setup()` calls using the `repeat` attribute. +`warmup_time = 0` is also advisable since ASV performs independent re-runs to +estimate run-time, and these will still be subject to the original problem. + +### Scaling / non-Scaling Performance Differences + +When comparing performance between commits/file-type/whatever it can be helpful +to know if the differences exist in scaling or non-scaling parts of the Iris +functionality in question. This can be done using a size parameter, setting +one value to be as small as possible (e.g. a scalar `Cube`), and the other to +be significantly larger (e.g. a 1000x1000 `Cube`). Performance differences +might only be seen for the larger value, or the smaller, or both, getting you +closer to the root cause. + +## Benchmark environments + +We have disabled ASV's standard environment management, instead using an +environment built using the same Nox scripts as Iris' test environments. This +is done using ASV's plugin architecture - see +[asv_delegated_conda.py](asv_delegated_conda.py) and the extra config items in +[asv.conf.json](asv.conf.json). + +(ASV is written to control the environment(s) that benchmarks are run in - +minimising external factors and also allowing it to compare between a matrix +of dependencies (each in a separate environment). We have chosen to sacrifice +these features in favour of testing each commit with its intended dependencies, +controlled by Nox + lock-files). diff --git a/noxfile.py b/noxfile.py index 0600540c5b..e4d91c6bab 100755 --- a/noxfile.py +++ b/noxfile.py @@ -8,6 +8,8 @@ import hashlib import os from pathlib import Path +from tempfile import NamedTemporaryFile +from typing import Literal import nox from nox.logger import logger @@ -289,31 +291,60 @@ def linkcheck(session: nox.sessions.Session): ) -@nox.session(python=PY_VER, venv_backend="conda") +@nox.session @nox.parametrize( - ["ci_mode"], - [True, False], - ids=["ci compare", "full"], + "run_type", + ["overnight", "branch", "custom"], + ids=["overnight", "branch", "custom"], ) -def benchmarks(session: nox.sessions.Session, ci_mode: bool): +def benchmarks( + session: nox.sessions.Session, + run_type: Literal["overnight", "branch", "custom"], +): """ Perform Iris performance benchmarks (using Airspeed Velocity). + All run types require a single Nox positional argument (e.g. + ``nox --session="foo" -- my_pos_arg``) - detailed in the parameters + section - and can optionally accept a series of further arguments that will + be added to session's ASV command. + Parameters ---------- session: object A `nox.sessions.Session` object. - ci_mode: bool - Run a cut-down selection of benchmarks, comparing the current commit to - the last commit for performance regressions. - - Notes - ----- - ASV is set up to use ``nox --session=tests --install-only`` to prepare - the benchmarking environment. This session environment must use a Python - version that is also available for ``--session=tests``. + run_type: {"overnight", "branch", "custom"} + * ``overnight``: benchmarks all commits between the input **first + commit** to ``HEAD``, comparing each to its parent for performance + shifts. If a commit causes shifts, the output is saved to a file: + ``.asv/performance-shifts/``. Designed for checking the + previous 24 hours' commits, typically in a scheduled script. + * ``branch``: Performs the same operations as ``overnight``, but always + on two commits only - ``HEAD``, and ``HEAD``'s merge-base with the + input **base branch**. Output from this run is never saved to a file. + Designed for testing if the active branch's changes cause performance + shifts - anticipating what would be caught by ``overnight`` once + merged. + **For maximum accuracy, avoid using the machine that is running this + session. Run time could be >1 hour for the full benchmark suite.** + * ``custom``: run ASV with the input **ASV sub-command**, without any + preset arguments - must all be supplied by the user. So just like + running ASV manually, with the convenience of re-using the session's + scripted setup steps. + + Examples + -------- + * ``nox --session="benchmarks(overnight)" -- a1b23d4`` + * ``nox --session="benchmarks(branch)" -- upstream/main`` + * ``nox --session="benchmarks(branch)" -- upstream/mesh-data-model`` + * ``nox --session="benchmarks(branch)" -- upstream/main --bench=regridding`` + * ``nox --session="benchmarks(custom)" -- continuous a1b23d4 HEAD --quick`` """ + # The threshold beyond which shifts are 'notable'. See `asv compare`` docs + # for more. + COMPARE_FACTOR = 1.2 + session.install("asv", "nox") data_gen_var = "DATA_GEN_PYTHON" @@ -327,12 +358,12 @@ def benchmarks(session: nox.sessions.Session, ci_mode: bool): "nox", "--session=tests", "--install-only", - f"--python={session.python}", + f"--python={_PY_VERSION_LATEST}", ) # Find the environment built above, set it to be the data generation # environment. data_gen_python = next( - Path(".nox").rglob(f"tests*/bin/python{session.python}") + Path(".nox").rglob(f"tests*/bin/python{_PY_VERSION_LATEST}") ).resolve() session.env[data_gen_var] = data_gen_python @@ -360,25 +391,85 @@ def benchmarks(session: nox.sessions.Session, ci_mode: bool): # Skip over setup questions for a new machine. session.run("asv", "machine", "--yes") - def asv_exec(*sub_args: str) -> None: - run_args = ["asv", *sub_args] - session.run(*run_args) - - if ci_mode: - # If on a PR: compare to the base (target) branch. - # Else: compare to previous commit. - previous_commit = os.environ.get("PR_BASE_SHA", "HEAD^1") - try: - asv_exec( - "continuous", - "--factor=1.2", - previous_commit, - "HEAD", - "--attribute", - "rounds=4", - ) - finally: - asv_exec("compare", previous_commit, "HEAD") + # All run types require one Nox posarg. + run_type_arg = { + "overnight": "first commit", + "branch": "base branch", + "custom": "ASV sub-command", + } + if run_type not in run_type_arg.keys(): + message = f"Unsupported run-type: {run_type}" + raise NotImplementedError(message) + if not session.posargs: + message = ( + f"Missing mandatory first Nox session posarg: " + f"{run_type_arg[run_type]}" + ) + raise ValueError(message) + first_arg = session.posargs[0] + # Optional extra arguments to be passed down to ASV. + asv_args = session.posargs[1:] + + def asv_compare(*commits): + """Run through a list of commits comparing each one to the next.""" + commits = [commit[:8] for commit in commits] + shifts_dir = Path(".asv") / "performance-shifts" + for i in range(len(commits) - 1): + before = commits[i] + after = commits[i + 1] + asv_command_ = f"asv compare {before} {after} --factor={COMPARE_FACTOR} --split" + session.run(*asv_command_.split(" ")) + + if run_type == "overnight": + # Record performance shifts. + # Run the command again but limited to only showing performance + # shifts. + shifts = session.run( + *asv_command_.split(" "), "--only-changed", silent=True + ) + if shifts: + # Write the shifts report to a file. + # Dir is used by .github/workflows/benchmarks.yml, + # but not cached - intended to be discarded after run. + shifts_dir.mkdir(exist_ok=True, parents=True) + shifts_path = shifts_dir / after + with shifts_path.open("w") as shifts_file: + shifts_file.write(shifts) + + # Common ASV arguments used for both `overnight` and `bench` run_types. + asv_harness = "asv run {posargs} --attribute rounds=4 --interleave-rounds --strict --show-stderr" + + if run_type == "overnight": + first_commit = first_arg + commit_range = f"{first_commit}^^.." + asv_command = asv_harness.format(posargs=commit_range) + session.run(*asv_command.split(" "), *asv_args) + + # git rev-list --first-parent is the command ASV uses. + git_command = f"git rev-list --first-parent {commit_range}" + commit_string = session.run( + *git_command.split(" "), silent=True, external=True + ) + commit_list = commit_string.rstrip().split("\n") + asv_compare(*reversed(commit_list)) + + elif run_type == "branch": + base_branch = first_arg + git_command = f"git merge-base HEAD {base_branch}" + merge_base = session.run( + *git_command.split(" "), silent=True, external=True + )[:8] + + with NamedTemporaryFile("w") as hashfile: + hashfile.writelines([merge_base, "\n", "HEAD"]) + hashfile.flush() + commit_range = f"HASHFILE:{hashfile.name}" + asv_command = asv_harness.format(posargs=commit_range) + session.run(*asv_command.split(" "), *asv_args) + + asv_compare(merge_base, "HEAD") + else: - # f5ceb808 = first commit supporting nox --install-only . - asv_exec("run", "f5ceb808..HEAD") + asv_subcommand = first_arg + assert run_type == "custom" + session.run("asv", asv_subcommand, *asv_args) From 8f3e3b90dfb68945c1e32e8c3c4d3c6d11c6a7a0 Mon Sep 17 00:00:00 2001 From: Patrick Peglar Date: Thu, 17 Feb 2022 07:29:35 +0000 Subject: [PATCH 11/28] Utility class in netcdf loader should not be public. (#4592) * Utility class in netcdf loader should not be public. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Rename container for better clarity. Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../fileformats/_nc_load_rules/actions.py | 4 +-- lib/iris/fileformats/netcdf.py | 26 ++++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/iris/fileformats/_nc_load_rules/actions.py b/lib/iris/fileformats/_nc_load_rules/actions.py index d286abbf3d..4c5184deb1 100644 --- a/lib/iris/fileformats/_nc_load_rules/actions.py +++ b/lib/iris/fileformats/_nc_load_rules/actions.py @@ -18,7 +18,7 @@ 3) Iris-specific info is (still) stored in additional properties created on the engine object : - engine.cf_var, .cube, .cube_parts, .requires, .rule_triggered, .filename + engine.cf_var, .cube, .cube_parts, .requires, .rules_triggered, .filename Our "rules" are just action routines. The top-level 'run_actions' routine decides which actions to call, based on the @@ -78,7 +78,7 @@ def inner(engine, *args, **kwargs): # but also may vary depending on whether it successfully # triggered, and if so what it matched. rule_name = _default_rulenamesfunc(func.__name__) - engine.rule_triggered.add(rule_name) + engine.rules_triggered.add(rule_name) func._rulenames_func = _default_rulenamesfunc return inner diff --git a/lib/iris/fileformats/netcdf.py b/lib/iris/fileformats/netcdf.py index 8eb2b7d830..4526963972 100644 --- a/lib/iris/fileformats/netcdf.py +++ b/lib/iris/fileformats/netcdf.py @@ -498,7 +498,7 @@ def _actions_activation_stats(engine, cf_name): print("Rules Triggered:") - for rule in sorted(list(engine.rule_triggered)): + for rule in sorted(list(engine.rules_triggered)): print("\t%s" % rule) print("Case Specific Facts:") @@ -570,13 +570,21 @@ def _get_cf_var_data(cf_var, filename): return as_lazy_data(proxy, chunks=chunks) -class OrderedAddableList(list): - # Used purely in actions debugging, to accumulate a record of which actions - # were activated. - # It replaces a set, so as to record the ordering of operations, with - # possible repeats, and it also numbers the entries. - # Actions routines invoke the 'add' method, which thus effectively converts - # a set.add into a list.append. +class _OrderedAddableList(list): + """ + A custom container object for actions recording. + + Used purely in actions debugging, to accumulate a record of which actions + were activated. + + It replaces a set, so as to preserve the ordering of operations, with + possible repeats, and it also numbers the entries. + + The actions routines invoke an 'add' method, so this effectively replaces + a set.add with a list.append. + + """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._n_add = 0 @@ -602,7 +610,7 @@ def _load_cube(engine, cf, cf_var, filename): engine.cube = cube engine.cube_parts = {} engine.requires = {} - engine.rule_triggered = OrderedAddableList() + engine.rules_triggered = _OrderedAddableList() engine.filename = filename # Assert all the case-specific facts. From ebc2039c8d1990d5230e2b105b15526ca08383fa Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Thu, 24 Feb 2022 11:58:11 +0000 Subject: [PATCH 12/28] Stop using nc_time_axis.CalendarDateTime (#4584) * stop using CalendarDateTime * update dependencies * stronger tests for _fixup_dates * add whatsnew --- docs/src/whatsnew/dev.rst | 4 +++- lib/iris/plot.py | 8 +++----- lib/iris/tests/integration/plot/test_netcdftime.py | 9 ++------- lib/iris/tests/unit/plot/test__fixup_dates.py | 10 ++++------ requirements/ci/py38.yml | 2 +- setup.cfg | 2 +- 6 files changed, 14 insertions(+), 21 deletions(-) diff --git a/docs/src/whatsnew/dev.rst b/docs/src/whatsnew/dev.rst index 27ed876a20..857264f43f 100644 --- a/docs/src/whatsnew/dev.rst +++ b/docs/src/whatsnew/dev.rst @@ -61,7 +61,9 @@ This document explains the changes made to Iris for this release 🔗 Dependencies =============== -#. N/A +#. `@rcomer`_ introduced the ``nc-time-axis >=1.4`` minimum pin, reflecting that + we no longer use the deprecated :class:`nc_time_axis.CalendarDateTime` + when plotting against time coordinates. (:pull:`4584`) 📚 Documentation diff --git a/lib/iris/plot.py b/lib/iris/plot.py index 0e9645c783..3cd54ef08f 100644 --- a/lib/iris/plot.py +++ b/lib/iris/plot.py @@ -591,7 +591,7 @@ def _fixup_dates(coord, values): r = [datetime.datetime(*date) for date in dates] else: try: - import nc_time_axis + import nc_time_axis # noqa: F401 except ImportError: msg = ( "Cannot plot against time in a non-gregorian " @@ -603,12 +603,10 @@ def _fixup_dates(coord, values): raise IrisError(msg) r = [ - nc_time_axis.CalendarDateTime( - cftime.datetime(*date, calendar=coord.units.calendar), - coord.units.calendar, - ) + cftime.datetime(*date, calendar=coord.units.calendar) for date in dates ] + values = np.empty(len(r), dtype=object) values[:] = r return values diff --git a/lib/iris/tests/integration/plot/test_netcdftime.py b/lib/iris/tests/integration/plot/test_netcdftime.py index 340f37dda7..9f0baeda35 100644 --- a/lib/iris/tests/integration/plot/test_netcdftime.py +++ b/lib/iris/tests/integration/plot/test_netcdftime.py @@ -18,10 +18,6 @@ from iris.coords import AuxCoord -if tests.NC_TIME_AXIS_AVAILABLE: - from nc_time_axis import CalendarDateTime - - # Run tests in no graphics mode if matplotlib is not available. if tests.MPL_AVAILABLE: import iris.plot as iplt @@ -48,9 +44,8 @@ def test_360_day_calendar(self): ) for atime in times ] - expected_ydata = np.array( - [CalendarDateTime(time, calendar) for time in times] - ) + + expected_ydata = times (line1,) = iplt.plot(time_coord) result_ydata = line1.get_ydata() self.assertArrayEqual(expected_ydata, result_ydata) diff --git a/lib/iris/tests/unit/plot/test__fixup_dates.py b/lib/iris/tests/unit/plot/test__fixup_dates.py index 157780dcae..1ad5c87691 100644 --- a/lib/iris/tests/unit/plot/test__fixup_dates.py +++ b/lib/iris/tests/unit/plot/test__fixup_dates.py @@ -23,6 +23,7 @@ def test_gregorian_calendar(self): unit = Unit("hours since 2000-04-13 00:00:00", calendar="gregorian") coord = AuxCoord([1, 3, 6], "time", units=unit) result = _fixup_dates(coord, coord.points) + self.assertIsInstance(result[0], datetime.datetime) expected = [ datetime.datetime(2000, 4, 13, 1), datetime.datetime(2000, 4, 13, 3), @@ -34,6 +35,7 @@ def test_gregorian_calendar_sub_second(self): unit = Unit("seconds since 2000-04-13 00:00:00", calendar="gregorian") coord = AuxCoord([1, 1.25, 1.5], "time", units=unit) result = _fixup_dates(coord, coord.points) + self.assertIsInstance(result[0], datetime.datetime) expected = [ datetime.datetime(2000, 4, 13, 0, 0, 1), datetime.datetime(2000, 4, 13, 0, 0, 1), @@ -52,9 +54,7 @@ def test_360_day_calendar(self): cftime.datetime(2000, 2, 29, calendar=calendar), cftime.datetime(2000, 2, 30, calendar=calendar), ] - self.assertArrayEqual( - [cdt.datetime for cdt in result], expected_datetimes - ) + self.assertArrayEqual(result, expected_datetimes) @tests.skip_nc_time_axis def test_365_day_calendar(self): @@ -67,9 +67,7 @@ def test_365_day_calendar(self): cftime.datetime(2000, 2, 25, 1, 0, calendar=calendar), cftime.datetime(2000, 2, 25, 2, 30, calendar=calendar), ] - self.assertArrayEqual( - [cdt.datetime for cdt in result], expected_datetimes - ) + self.assertArrayEqual(result, expected_datetimes) @tests.skip_nc_time_axis def test_360_day_calendar_attribute(self): diff --git a/requirements/ci/py38.yml b/requirements/ci/py38.yml index d3d7f9d0c2..ef095815c9 100644 --- a/requirements/ci/py38.yml +++ b/requirements/ci/py38.yml @@ -25,7 +25,7 @@ dependencies: - graphviz - iris-sample-data >=2.4.0 - mo_pack - - nc-time-axis >=1.3 + - nc-time-axis >=1.4 - pandas - pip - python-stratify diff --git a/setup.cfg b/setup.cfg index 1aabe33d83..ecdcad85b2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -81,7 +81,7 @@ test = requests all = mo_pack - nc-time-axis>=1.3 + nc-time-axis>=1.4 pandas stratify %(docs)s From 2314e6b565c9fe147113fc50a0c44a1492bf95fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Feb 2022 10:55:00 +0000 Subject: [PATCH 13/28] Bump peter-evans/create-pull-request from 3.12.1 to 3.13.0 (#4607) Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 3.12.1 to 3.13.0. - [Release notes](https://github.com/peter-evans/create-pull-request/releases) - [Commits](https://github.com/peter-evans/create-pull-request/compare/f22a7da129c901513876a2380e2dae9f8e145330...89265e8d24a5dea438a2577fdc409a11e9f855ca) --- updated-dependencies: - dependency-name: peter-evans/create-pull-request dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/refresh-lockfiles.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/refresh-lockfiles.yml b/.github/workflows/refresh-lockfiles.yml index ff2f6c4d75..5a06c97189 100644 --- a/.github/workflows/refresh-lockfiles.yml +++ b/.github/workflows/refresh-lockfiles.yml @@ -111,7 +111,7 @@ jobs: - name: Create Pull Request id: cpr - uses: peter-evans/create-pull-request@f22a7da129c901513876a2380e2dae9f8e145330 + uses: peter-evans/create-pull-request@89265e8d24a5dea438a2577fdc409a11e9f855ca with: commit-message: Updated environment lockfiles committer: "Lockfile bot " From c2e75572facc50833eb69e0f6559aaeea1fad5c1 Mon Sep 17 00:00:00 2001 From: Will Benfold <69585101+wjbenfold@users.noreply.github.com> Date: Tue, 1 Mar 2022 09:41:33 +0000 Subject: [PATCH 14/28] Support false-easting and false-northing when loading Mercator-projected data (#4524) * Add extra pieces to get false easting and northing * tests * Add extra test file ref and cml * lib/iris/tests/results/netcdf/netcdf_merc_false.cml * Update cml for new test data * Bump test data version for cirrus * What's new --- .cirrus.yml | 2 +- docs/src/whatsnew/dev.rst | 3 +- lib/iris/coord_systems.py | 20 +++++- .../fileformats/_nc_load_rules/helpers.py | 38 +++------- lib/iris/fileformats/netcdf.py | 6 +- .../tests/results/coord_systems/Mercator.xml | 2 +- lib/iris/tests/results/netcdf/netcdf_merc.cml | 8 +-- .../results/netcdf/netcdf_merc_false.cml | 33 +++++++++ lib/iris/tests/test_netcdf.py | 10 +++ .../tests/unit/coord_systems/test_Mercator.py | 29 +++++++- .../test_has_supported_mercator_parameters.py | 71 +++++-------------- 11 files changed, 127 insertions(+), 95 deletions(-) create mode 100644 lib/iris/tests/results/netcdf/netcdf_merc_false.cml diff --git a/.cirrus.yml b/.cirrus.yml index 92b8d788e6..c9c1d71859 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -38,7 +38,7 @@ env: # Conda packages to be installed. CONDA_CACHE_PACKAGES: "nox pip" # Git commit hash for iris test data. - IRIS_TEST_DATA_VERSION: "2.5" + IRIS_TEST_DATA_VERSION: "2.7" # Base directory for the iris-test-data. IRIS_TEST_DATA_DIR: ${HOME}/iris-test-data diff --git a/docs/src/whatsnew/dev.rst b/docs/src/whatsnew/dev.rst index 857264f43f..5952dc45b0 100644 --- a/docs/src/whatsnew/dev.rst +++ b/docs/src/whatsnew/dev.rst @@ -31,7 +31,8 @@ This document explains the changes made to Iris for this release ✨ Features =========== -#. N/A +#. `@wjbenfold`_ added support for ``false_easting`` and ``false_northing`` to + :class:`~iris.coord_system.Mercator`. (:issue:`3107`, :pull:`4524`) 🐛 Bugs Fixed diff --git a/lib/iris/coord_systems.py b/lib/iris/coord_systems.py index 2f875bb159..311ed35f44 100644 --- a/lib/iris/coord_systems.py +++ b/lib/iris/coord_systems.py @@ -1083,6 +1083,8 @@ def __init__( longitude_of_projection_origin=None, ellipsoid=None, standard_parallel=None, + false_easting=None, + false_northing=None, ): """ Constructs a Mercator coord system. @@ -1098,6 +1100,12 @@ def __init__( * standard_parallel: The latitude where the scale is 1. Defaults to 0.0 . + * false_easting: + X offset from the planar origin in metres. Defaults to 0.0. + + * false_northing: + Y offset from the planar origin in metres. Defaults to 0.0. + """ #: True longitude of planar origin in degrees. self.longitude_of_projection_origin = _arg_default( @@ -1110,12 +1118,20 @@ def __init__( #: The latitude where the scale is 1. self.standard_parallel = _arg_default(standard_parallel, 0) + #: X offset from the planar origin in metres. + self.false_easting = _arg_default(false_easting, 0) + + #: Y offset from the planar origin in metres. + self.false_northing = _arg_default(false_northing, 0) + def __repr__(self): res = ( "Mercator(longitude_of_projection_origin=" "{self.longitude_of_projection_origin!r}, " "ellipsoid={self.ellipsoid!r}, " - "standard_parallel={self.standard_parallel!r})" + "standard_parallel={self.standard_parallel!r}, " + "false_easting={self.false_easting!r}, " + "false_northing={self.false_northing!r})" ) return res.format(self=self) @@ -1126,6 +1142,8 @@ def as_cartopy_crs(self): central_longitude=self.longitude_of_projection_origin, globe=globe, latitude_true_scale=self.standard_parallel, + false_easting=self.false_easting, + false_northing=self.false_northing, ) def as_cartopy_projection(self): diff --git a/lib/iris/fileformats/_nc_load_rules/helpers.py b/lib/iris/fileformats/_nc_load_rules/helpers.py index a5b507d583..198daeceea 100644 --- a/lib/iris/fileformats/_nc_load_rules/helpers.py +++ b/lib/iris/fileformats/_nc_load_rules/helpers.py @@ -440,10 +440,13 @@ def build_mercator_coordinate_system(engine, cf_grid_var): longitude_of_projection_origin = getattr( cf_grid_var, CF_ATTR_GRID_LON_OF_PROJ_ORIGIN, None ) + standard_parallel = getattr( + cf_grid_var, CF_ATTR_GRID_STANDARD_PARALLEL, None + ) + false_easting = getattr(cf_grid_var, CF_ATTR_GRID_FALSE_EASTING, None) + false_northing = getattr(cf_grid_var, CF_ATTR_GRID_FALSE_NORTHING, None) # Iris currently only supports Mercator projections with specific - # values for false_easting, false_northing, - # scale_factor_at_projection_origin and standard_parallel. These are - # checked elsewhere. + # scale_factor_at_projection_origin. This is checked elsewhere. ellipsoid = None if ( @@ -454,7 +457,11 @@ def build_mercator_coordinate_system(engine, cf_grid_var): ellipsoid = iris.coord_systems.GeogCS(major, minor, inverse_flattening) cs = iris.coord_systems.Mercator( - longitude_of_projection_origin, ellipsoid=ellipsoid + longitude_of_projection_origin, + ellipsoid=ellipsoid, + standard_parallel=standard_parallel, + false_easting=false_easting, + false_northing=false_northing, ) return cs @@ -1244,27 +1251,10 @@ def has_supported_mercator_parameters(engine, cf_name): is_valid = True cf_grid_var = engine.cf_var.cf_group[cf_name] - false_easting = getattr(cf_grid_var, CF_ATTR_GRID_FALSE_EASTING, None) - false_northing = getattr(cf_grid_var, CF_ATTR_GRID_FALSE_NORTHING, None) scale_factor_at_projection_origin = getattr( cf_grid_var, CF_ATTR_GRID_SCALE_FACTOR_AT_PROJ_ORIGIN, None ) - standard_parallel = getattr( - cf_grid_var, CF_ATTR_GRID_STANDARD_PARALLEL, None - ) - if false_easting is not None and false_easting != 0: - warnings.warn( - "False eastings other than 0.0 not yet supported " - "for Mercator projections" - ) - is_valid = False - if false_northing is not None and false_northing != 0: - warnings.warn( - "False northings other than 0.0 not yet supported " - "for Mercator projections" - ) - is_valid = False if ( scale_factor_at_projection_origin is not None and scale_factor_at_projection_origin != 1 @@ -1274,12 +1264,6 @@ def has_supported_mercator_parameters(engine, cf_name): "Mercator projections" ) is_valid = False - if standard_parallel is not None and standard_parallel != 0: - warnings.warn( - "Standard parallels other than 0.0 not yet " - "supported for Mercator projections" - ) - is_valid = False return is_valid diff --git a/lib/iris/fileformats/netcdf.py b/lib/iris/fileformats/netcdf.py index 4526963972..80f213dbc2 100644 --- a/lib/iris/fileformats/netcdf.py +++ b/lib/iris/fileformats/netcdf.py @@ -2561,10 +2561,8 @@ def add_ellipsoid(ellipsoid): cf_var_grid.longitude_of_projection_origin = ( cs.longitude_of_projection_origin ) - # The Mercator class has implicit defaults for certain - # parameters - cf_var_grid.false_easting = 0.0 - cf_var_grid.false_northing = 0.0 + cf_var_grid.false_easting = cs.false_easting + cf_var_grid.false_northing = cs.false_northing cf_var_grid.scale_factor_at_projection_origin = 1.0 # lcc diff --git a/lib/iris/tests/results/coord_systems/Mercator.xml b/lib/iris/tests/results/coord_systems/Mercator.xml index e8036ef824..db3ccffec7 100644 --- a/lib/iris/tests/results/coord_systems/Mercator.xml +++ b/lib/iris/tests/results/coord_systems/Mercator.xml @@ -1,2 +1,2 @@ - + diff --git a/lib/iris/tests/results/netcdf/netcdf_merc.cml b/lib/iris/tests/results/netcdf/netcdf_merc.cml index 02fc4e7c34..5e17400158 100644 --- a/lib/iris/tests/results/netcdf/netcdf_merc.cml +++ b/lib/iris/tests/results/netcdf/netcdf_merc.cml @@ -53,15 +53,15 @@ 45.5158, 45.9993]]" shape="(192, 192)" standard_name="longitude" units="Unit('degrees')" value_type="float32" var_name="lon"/> - - + - - + diff --git a/lib/iris/tests/results/netcdf/netcdf_merc_false.cml b/lib/iris/tests/results/netcdf/netcdf_merc_false.cml new file mode 100644 index 0000000000..d916f5f753 --- /dev/null +++ b/lib/iris/tests/results/netcdf/netcdf_merc_false.cml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/iris/tests/test_netcdf.py b/lib/iris/tests/test_netcdf.py index 2c22c6d088..8cdbe27257 100644 --- a/lib/iris/tests/test_netcdf.py +++ b/lib/iris/tests/test_netcdf.py @@ -218,6 +218,16 @@ def test_load_merc_grid(self): ) self.assertCML(cube, ("netcdf", "netcdf_merc.cml")) + def test_load_merc_false_en_grid(self): + # Test loading a single CF-netCDF file with a Mercator grid_mapping that + # includes false easting and northing + cube = iris.load_cube( + tests.get_data_path( + ("NetCDF", "mercator", "false_east_north_merc.nc") + ) + ) + self.assertCML(cube, ("netcdf", "netcdf_merc_false.cml")) + def test_load_stereographic_grid(self): # Test loading a single CF-netCDF file with a stereographic # grid_mapping. diff --git a/lib/iris/tests/unit/coord_systems/test_Mercator.py b/lib/iris/tests/unit/coord_systems/test_Mercator.py index 33efaef9da..8a37a8fcc5 100644 --- a/lib/iris/tests/unit/coord_systems/test_Mercator.py +++ b/lib/iris/tests/unit/coord_systems/test_Mercator.py @@ -29,7 +29,8 @@ def test_repr(self): "Mercator(longitude_of_projection_origin=90.0, " "ellipsoid=GeogCS(semi_major_axis=6377563.396, " "semi_minor_axis=6356256.909), " - "standard_parallel=0.0)" + "standard_parallel=0.0, " + "false_easting=0.0, false_northing=0.0)" ) self.assertEqual(expected, repr(self.tm)) @@ -38,16 +39,23 @@ class Test_init_defaults(tests.IrisTest): def test_set_optional_args(self): # Check that setting the optional (non-ellipse) args works. crs = Mercator( - longitude_of_projection_origin=27, standard_parallel=157.4 + longitude_of_projection_origin=27, + standard_parallel=157.4, + false_easting=13, + false_northing=12, ) self.assertEqualAndKind(crs.longitude_of_projection_origin, 27.0) self.assertEqualAndKind(crs.standard_parallel, 157.4) + self.assertEqualAndKind(crs.false_easting, 13.0) + self.assertEqualAndKind(crs.false_northing, 12.0) def _check_crs_defaults(self, crs): # Check for property defaults when no kwargs options were set. # NOTE: except ellipsoid, which is done elsewhere. self.assertEqualAndKind(crs.longitude_of_projection_origin, 0.0) self.assertEqualAndKind(crs.standard_parallel, 0.0) + self.assertEqualAndKind(crs.false_easting, 0.0) + self.assertEqualAndKind(crs.false_northing, 0.0) def test_no_optional_args(self): # Check expected defaults with no optional args. @@ -57,7 +65,10 @@ def test_no_optional_args(self): def test_optional_args_None(self): # Check expected defaults with optional args=None. crs = Mercator( - longitude_of_projection_origin=None, standard_parallel=None + longitude_of_projection_origin=None, + standard_parallel=None, + false_easting=None, + false_northing=None, ) self._check_crs_defaults(crs) @@ -77,6 +88,8 @@ def test_extra_kwargs(self): # converted to a cartopy CRS. longitude_of_projection_origin = 90.0 true_scale_lat = 14.0 + false_easting = 13 + false_northing = 12 ellipsoid = GeogCS( semi_major_axis=6377563.396, semi_minor_axis=6356256.909 ) @@ -85,6 +98,8 @@ def test_extra_kwargs(self): longitude_of_projection_origin, ellipsoid=ellipsoid, standard_parallel=true_scale_lat, + false_easting=false_easting, + false_northing=false_northing, ) expected = ccrs.Mercator( @@ -95,6 +110,8 @@ def test_extra_kwargs(self): ellipse=None, ), latitude_true_scale=true_scale_lat, + false_easting=false_easting, + false_northing=false_northing, ) res = merc_cs.as_cartopy_crs() @@ -113,6 +130,8 @@ def test_simple(self): def test_extra_kwargs(self): longitude_of_projection_origin = 90.0 true_scale_lat = 14.0 + false_easting = 13 + false_northing = 12 ellipsoid = GeogCS( semi_major_axis=6377563.396, semi_minor_axis=6356256.909 ) @@ -121,6 +140,8 @@ def test_extra_kwargs(self): longitude_of_projection_origin, ellipsoid=ellipsoid, standard_parallel=true_scale_lat, + false_easting=false_easting, + false_northing=false_northing, ) expected = ccrs.Mercator( @@ -131,6 +152,8 @@ def test_extra_kwargs(self): ellipse=None, ), latitude_true_scale=true_scale_lat, + false_easting=false_easting, + false_northing=false_northing, ) res = merc_cs.as_cartopy_projection() diff --git a/lib/iris/tests/unit/fileformats/nc_load_rules/helpers/test_has_supported_mercator_parameters.py b/lib/iris/tests/unit/fileformats/nc_load_rules/helpers/test_has_supported_mercator_parameters.py index dfe2895f29..1b9857c0be 100644 --- a/lib/iris/tests/unit/fileformats/nc_load_rules/helpers/test_has_supported_mercator_parameters.py +++ b/lib/iris/tests/unit/fileformats/nc_load_rules/helpers/test_has_supported_mercator_parameters.py @@ -28,7 +28,7 @@ def _engine(cf_grid_var, cf_name): class TestHasSupportedMercatorParameters(tests.IrisTest): - def test_valid(self): + def test_valid_base(self): cf_name = "mercator" cf_grid_var = mock.Mock( spec=[], @@ -45,85 +45,50 @@ def test_valid(self): self.assertTrue(is_valid) - def test_invalid_scale_factor(self): - # Iris does not yet support scale factors other than one for - # Mercator projections + def test_valid_false_easting_northing(self): cf_name = "mercator" cf_grid_var = mock.Mock( spec=[], - longitude_of_projection_origin=0, - false_easting=0, - false_northing=0, - scale_factor_at_projection_origin=0.9, + longitude_of_projection_origin=-90, + false_easting=15, + false_northing=10, + scale_factor_at_projection_origin=1, semi_major_axis=6377563.396, semi_minor_axis=6356256.909, ) engine = _engine(cf_grid_var, cf_name) - with warnings.catch_warnings(record=True) as warns: - warnings.simplefilter("always") - is_valid = has_supported_mercator_parameters(engine, cf_name) + is_valid = has_supported_mercator_parameters(engine, cf_name) - self.assertFalse(is_valid) - self.assertEqual(len(warns), 1) - self.assertRegex(str(warns[0]), "Scale factor") + self.assertTrue(is_valid) - def test_invalid_standard_parallel(self): - # Iris does not yet support standard parallels other than zero for - # Mercator projections + def test_valid_standard_parallel(self): cf_name = "mercator" cf_grid_var = mock.Mock( spec=[], - longitude_of_projection_origin=0, + longitude_of_projection_origin=-90, false_easting=0, false_northing=0, - standard_parallel=30, - semi_major_axis=6377563.396, - semi_minor_axis=6356256.909, - ) - engine = _engine(cf_grid_var, cf_name) - - with warnings.catch_warnings(record=True) as warns: - warnings.simplefilter("always") - is_valid = has_supported_mercator_parameters(engine, cf_name) - - self.assertFalse(is_valid) - self.assertEqual(len(warns), 1) - self.assertRegex(str(warns[0]), "Standard parallel") - - def test_invalid_false_easting(self): - # Iris does not yet support false eastings other than zero for - # Mercator projections - cf_name = "mercator" - cf_grid_var = mock.Mock( - spec=[], - longitude_of_projection_origin=0, - false_easting=100, - false_northing=0, - scale_factor_at_projection_origin=1, + standard_parallel=15, semi_major_axis=6377563.396, semi_minor_axis=6356256.909, ) engine = _engine(cf_grid_var, cf_name) - with warnings.catch_warnings(record=True) as warns: - warnings.simplefilter("always") - is_valid = has_supported_mercator_parameters(engine, cf_name) + is_valid = has_supported_mercator_parameters(engine, cf_name) - self.assertFalse(is_valid) - self.assertEqual(len(warns), 1) - self.assertRegex(str(warns[0]), "False easting") + self.assertTrue(is_valid) - def test_invalid_false_northing(self): - # Iris does not yet support false northings other than zero for + def test_invalid_scale_factor(self): + # Iris does not yet support scale factors other than one for # Mercator projections cf_name = "mercator" cf_grid_var = mock.Mock( spec=[], longitude_of_projection_origin=0, false_easting=0, - false_northing=100, - scale_factor_at_projection_origin=1, + false_northing=0, + scale_factor_at_projection_origin=0.9, semi_major_axis=6377563.396, semi_minor_axis=6356256.909, ) @@ -135,7 +100,7 @@ def test_invalid_false_northing(self): self.assertFalse(is_valid) self.assertEqual(len(warns), 1) - self.assertRegex(str(warns[0]), "False northing") + self.assertRegex(str(warns[0]), "Scale factor") if __name__ == "__main__": From de88403ee5aece18c2fe89f5068dc9ea30fb9099 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Mar 2022 09:47:18 +0000 Subject: [PATCH 15/28] Bump peter-evans/create-pull-request from 3.13.0 to 3.14.0 (#4608) Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 3.13.0 to 3.14.0. - [Release notes](https://github.com/peter-evans/create-pull-request/releases) - [Commits](https://github.com/peter-evans/create-pull-request/compare/89265e8d24a5dea438a2577fdc409a11e9f855ca...18f7dc018cc2cd597073088f7c7591b9d1c02672) --- updated-dependencies: - dependency-name: peter-evans/create-pull-request dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/refresh-lockfiles.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/refresh-lockfiles.yml b/.github/workflows/refresh-lockfiles.yml index 5a06c97189..96572fb815 100644 --- a/.github/workflows/refresh-lockfiles.yml +++ b/.github/workflows/refresh-lockfiles.yml @@ -111,7 +111,7 @@ jobs: - name: Create Pull Request id: cpr - uses: peter-evans/create-pull-request@89265e8d24a5dea438a2577fdc409a11e9f855ca + uses: peter-evans/create-pull-request@18f7dc018cc2cd597073088f7c7591b9d1c02672 with: commit-message: Updated environment lockfiles committer: "Lockfile bot " From 0adcbfa12931455eacf4a537edfd7f9ed39d27e1 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Tue, 1 Mar 2022 17:22:35 +0000 Subject: [PATCH 16/28] Revert plotting-vs-y (#4601) * revert plotting-vs-y * whatsnew --- docs/src/whatsnew/dev.rst | 4 +++- lib/iris/plot.py | 2 +- lib/iris/tests/results/imagerepo.json | 10 ++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/src/whatsnew/dev.rst b/docs/src/whatsnew/dev.rst index 5952dc45b0..b9d5989bfc 100644 --- a/docs/src/whatsnew/dev.rst +++ b/docs/src/whatsnew/dev.rst @@ -38,7 +38,9 @@ This document explains the changes made to Iris for this release 🐛 Bugs Fixed ============= -#. N/A +#. `@rcomer`_ reverted part of the change from :pull:`3906` so that + :func:`iris.plot.plot` no longer defaults to placing a "Y" coordinate (e.g. + latitude) on the y-axis of the plot. (:issue:`4493`, :pull:`4601`) 💣 Incompatible Changes diff --git a/lib/iris/plot.py b/lib/iris/plot.py index 3cd54ef08f..aefca889cf 100644 --- a/lib/iris/plot.py +++ b/lib/iris/plot.py @@ -673,7 +673,7 @@ def _get_plot_objects(args): if ( isinstance(v_object, iris.cube.Cube) and isinstance(u_object, iris.coords.Coord) - and iris.util.guess_coord_axis(u_object) in ["Y", "Z"] + and iris.util.guess_coord_axis(u_object) == "Z" ): u_object, v_object = v_object, u_object u, v = v, u diff --git a/lib/iris/tests/results/imagerepo.json b/lib/iris/tests/results/imagerepo.json index 79560a5365..6a997c38b4 100644 --- a/lib/iris/tests/results/imagerepo.json +++ b/lib/iris/tests/results/imagerepo.json @@ -684,7 +684,10 @@ "https://scitools.github.io/test-iris-imagehash/images/v4/8bfe956b7c01c2f26300929dfc1e3c6690736f91817e3b0c84be6be5d1603ed1.png" ], "iris.tests.test_plot.TestPlot.test_y.0": [ - "https://scitools.github.io/test-iris-imagehash/images/v4/8ff99c067e01e7166101c9c6b04396b5cd4e2f0993163de9c4fe7b79207e36a1.png" + "https://scitools.github.io/test-iris-imagehash/images/v4/8fe896266f068d873b83cb71e435725cd07c607ad07e70fcd0007a7881fe7ab8.png", + "https://scitools.github.io/test-iris-imagehash/images/v4/8fe896066f068d873b83cb71e435725cd07c607ad07c70fcd0007af881fe7bb8.png", + "https://scitools.github.io/test-iris-imagehash/images/v4/8fe896366f0f8d93398bcb71e435f24ed074646ed07670acf010726d81f2798c.png", + "https://scitools.github.io/test-iris-imagehash/images/v4/aff8946c7a14c99fb193d263e42432d8d00c2d27944a3f8dc5223ef703ff6b90.png" ], "iris.tests.test_plot.TestPlot.test_z.0": [ "https://scitools.github.io/test-iris-imagehash/images/v4/8fffc1dc7e019c70f001b70ee4386de1814e7938837b6a7f84d07c9f15b02f21.png" @@ -874,7 +877,10 @@ "https://scitools.github.io/test-iris-imagehash/images/v4/82ff950b7f81c0d6620199bcfc5e986695734da1816e1b2c85be2b65d96276d1.png" ], "iris.tests.test_plot.TestQuickplotPlot.test_y.0": [ - "https://scitools.github.io/test-iris-imagehash/images/v4/a3f9bc067e01c6166009c9c6b5439ee5cd4e0d2993361de9ccf65b79887636a9.png" + "https://scitools.github.io/test-iris-imagehash/images/v4/a7ffb6067f008d87339bc973e435d86ef034c87ad07c586cd001da69897e5838.png", + "https://scitools.github.io/test-iris-imagehash/images/v4/a7ffb6067f008d87339bc973e435d86ef034c87ad07cd86cd001da68897e58a8.png", + "https://scitools.github.io/test-iris-imagehash/images/v4/a7efb6367f008d97338fc973e435d86ef030c86ed070d86cd030d86d89f0d82c.png", + "https://scitools.github.io/test-iris-imagehash/images/v4/a2fbb46e7f10c99f2013d863e46498dcd06c0d2798421fa5dd221e7789ff6f10.png" ], "iris.tests.test_plot.TestQuickplotPlot.test_z.0": [ "https://scitools.github.io/test-iris-imagehash/images/v4/a3ffc1de7e009c7030019786f438cde3810fd93c9b734a778ce47c9799b02731.png" From 9a89050ccb1605b4a2f5b99be7facbd188f218a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Mar 2022 10:02:33 +0000 Subject: [PATCH 17/28] Bump actions/stale from 4.1.0 to 5 (#4612) Bumps [actions/stale](https://github.com/actions/stale) from 4.1.0 to 5. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/v4.1.0...v5) --- updated-dependencies: - dependency-name: actions/stale dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index a1bb0fca6c..008fe56deb 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -14,7 +14,7 @@ jobs: if: "github.repository == 'SciTools/iris'" runs-on: ubuntu-latest steps: - - uses: actions/stale@v4.1.0 + - uses: actions/stale@v5 with: repo-token: ${{ secrets.GITHUB_TOKEN }} From c83722b0b5241958309c2843d9d715f012faa48b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Mar 2022 10:03:27 +0000 Subject: [PATCH 18/28] Bump actions/checkout from 2 to 3 (#4611) Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v2...v3) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/benchmark.yml | 2 +- .github/workflows/refresh-lockfiles.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index d4c01af48a..086f6a0bb4 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -23,7 +23,7 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 diff --git a/.github/workflows/refresh-lockfiles.yml b/.github/workflows/refresh-lockfiles.yml index 96572fb815..2be35e9f18 100644 --- a/.github/workflows/refresh-lockfiles.yml +++ b/.github/workflows/refresh-lockfiles.yml @@ -76,7 +76,7 @@ jobs: python: ['38'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: install conda-lock run: | source $CONDA/bin/activate base @@ -98,7 +98,7 @@ jobs: needs: gen_lockfiles steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: get artifacts uses: actions/download-artifact@v2 with: From b42c65da21cbf487723ee585cadbe26a5155afb2 Mon Sep 17 00:00:00 2001 From: Martin Yeo <40734014+trexfeathers@users.noreply.github.com> Date: Thu, 3 Mar 2022 11:11:23 +0000 Subject: [PATCH 19/28] Final offline benchmark migration (#4562) --- benchmarks/benchmarks/__init__.py | 64 +++++ benchmarks/benchmarks/aux_factory.py | 3 +- benchmarks/benchmarks/coords.py | 20 +- benchmarks/benchmarks/cube.py | 44 ++- .../benchmarks/experimental/__init__.py | 9 + benchmarks/benchmarks/experimental/ugrid.py | 195 +++++++++++++ .../benchmarks/generate_data/__init__.py | 103 +++++++ benchmarks/benchmarks/generate_data/stock.py | 126 ++++++++ benchmarks/benchmarks/iterate.py | 3 +- benchmarks/benchmarks/mixin.py | 3 +- benchmarks/benchmarks/netcdf_save.py | 61 ++++ benchmarks/benchmarks/plot.py | 3 +- benchmarks/benchmarks/regions_combine.py | 268 ++++++++++++++++++ benchmarks/benchmarks/regridding.py | 21 +- benchmarks/benchmarks/ugrid_load.py | 128 +++++++++ 15 files changed, 1041 insertions(+), 10 deletions(-) create mode 100644 benchmarks/benchmarks/experimental/__init__.py create mode 100644 benchmarks/benchmarks/experimental/ugrid.py create mode 100644 benchmarks/benchmarks/generate_data/stock.py create mode 100644 benchmarks/benchmarks/netcdf_save.py create mode 100644 benchmarks/benchmarks/regions_combine.py create mode 100644 benchmarks/benchmarks/ugrid_load.py diff --git a/benchmarks/benchmarks/__init__.py b/benchmarks/benchmarks/__init__.py index 4a964a648d..38502c9306 100644 --- a/benchmarks/benchmarks/__init__.py +++ b/benchmarks/benchmarks/__init__.py @@ -4,5 +4,69 @@ # See COPYING and COPYING.LESSER in the root of the repository for full # licensing details. """Common code for benchmarks.""" +import resource + +from .generate_data import BENCHMARK_DATA, run_function_elsewhere ARTIFICIAL_DIM_SIZE = int(10e3) # For all artificial cubes, coords etc. + + +def disable_repeat_between_setup(benchmark_object): + """ + Decorator for benchmarks where object persistence would be inappropriate. + + E.g: + * Benchmarking data realisation + * Benchmarking Cube coord addition + + 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 + + +class TrackAddedMemoryAllocation: + """ + Context manager which measures by how much process resident memory grew, + during execution of its enclosed code block. + + Obviously limited as to what it actually measures : Relies on the current + process not having significant unused (de-allocated) memory when the + tested codeblock runs, and only reliable when the code allocates a + significant amount of new memory. + + Example: + with TrackAddedMemoryAllocation() as mb: + initial_call() + other_call() + result = mb.addedmem_mb() + + """ + + @staticmethod + def process_resident_memory_mb(): + return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024.0 + + def __enter__(self): + self.mb_before = self.process_resident_memory_mb() + return self + + def __exit__(self, *_): + self.mb_after = self.process_resident_memory_mb() + + def addedmem_mb(self): + """Return measured memory growth, in Mb.""" + return self.mb_after - self.mb_before diff --git a/benchmarks/benchmarks/aux_factory.py b/benchmarks/benchmarks/aux_factory.py index 270119da71..45bfa1b515 100644 --- a/benchmarks/benchmarks/aux_factory.py +++ b/benchmarks/benchmarks/aux_factory.py @@ -10,9 +10,10 @@ import numpy as np -from benchmarks import ARTIFICIAL_DIM_SIZE from iris import aux_factory, coords +from . import ARTIFICIAL_DIM_SIZE + class FactoryCommon: # TODO: once https://github.com/airspeed-velocity/asv/pull/828 is released: diff --git a/benchmarks/benchmarks/coords.py b/benchmarks/benchmarks/coords.py index fce7318d49..5cea1e1e2e 100644 --- a/benchmarks/benchmarks/coords.py +++ b/benchmarks/benchmarks/coords.py @@ -10,9 +10,10 @@ import numpy as np -from benchmarks import ARTIFICIAL_DIM_SIZE from iris import coords +from . import ARTIFICIAL_DIM_SIZE, disable_repeat_between_setup + def setup(): """General variables needed by multiple benchmark classes.""" @@ -92,6 +93,23 @@ def setup(self): def create(self): return coords.AuxCoord(**self.create_kwargs) + def time_points(self): + _ = self.component.points + + def time_bounds(self): + _ = self.component.bounds + + +@disable_repeat_between_setup +class AuxCoordLazy(AuxCoord): + """Lazy equivalent of :class:`AuxCoord`.""" + + def setup(self): + super().setup() + self.create_kwargs["points"] = self.component.lazy_points() + self.create_kwargs["bounds"] = self.component.lazy_bounds() + self.setup_common() + class CellMeasure(CoordCommon): def setup(self): diff --git a/benchmarks/benchmarks/cube.py b/benchmarks/benchmarks/cube.py index 3cfa6b248b..8a12391684 100644 --- a/benchmarks/benchmarks/cube.py +++ b/benchmarks/benchmarks/cube.py @@ -10,11 +10,13 @@ import numpy as np -from benchmarks import ARTIFICIAL_DIM_SIZE from iris import analysis, aux_factory, coords, cube +from . import ARTIFICIAL_DIM_SIZE, disable_repeat_between_setup +from .generate_data.stock import sample_meshcoord -def setup(): + +def setup(*params): """General variables needed by multiple benchmark classes.""" global data_1d global data_2d @@ -170,6 +172,44 @@ def setup(self): self.setup_common() +class MeshCoord: + params = [ + 6, # minimal cube-sphere + int(1e6), # realistic cube-sphere size + ARTIFICIAL_DIM_SIZE, # To match size in :class:`AuxCoord` + ] + param_names = ["number of faces"] + + def setup(self, n_faces): + mesh_kwargs = dict( + n_nodes=n_faces + 2, n_edges=n_faces * 2, n_faces=n_faces + ) + + self.mesh_coord = sample_meshcoord(sample_mesh_kwargs=mesh_kwargs) + self.data = np.zeros(n_faces) + self.cube_blank = cube.Cube(data=self.data) + self.cube = self.create() + + def create(self): + return cube.Cube( + data=self.data, aux_coords_and_dims=[(self.mesh_coord, 0)] + ) + + def time_create(self, n_faces): + _ = self.create() + + @disable_repeat_between_setup + def time_add(self, n_faces): + self.cube_blank.add_aux_coord(self.mesh_coord, 0) + + @disable_repeat_between_setup + def time_remove(self, n_faces): + self.cube.remove_coord(self.mesh_coord) + + def time_return(self, n_faces): + _ = self.cube + + class Merge: def setup(self): self.cube_list = cube.CubeList() diff --git a/benchmarks/benchmarks/experimental/__init__.py b/benchmarks/benchmarks/experimental/__init__.py new file mode 100644 index 0000000000..f16e400bce --- /dev/null +++ b/benchmarks/benchmarks/experimental/__init__.py @@ -0,0 +1,9 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Benchmark tests for the experimental module. + +""" diff --git a/benchmarks/benchmarks/experimental/ugrid.py b/benchmarks/benchmarks/experimental/ugrid.py new file mode 100644 index 0000000000..609abbe77c --- /dev/null +++ b/benchmarks/benchmarks/experimental/ugrid.py @@ -0,0 +1,195 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Benchmark tests for the experimental.ugrid module. + +""" + +from copy import deepcopy + +import numpy as np + +from iris.experimental import ugrid + +from .. import ARTIFICIAL_DIM_SIZE, disable_repeat_between_setup +from ..generate_data.stock import sample_mesh + + +class UGridCommon: + """ + A base class running a generalised suite of benchmarks for any ugrid object. + Object to be specified in a subclass. + + ASV will run the benchmarks within this class for any subclasses. + + ASV will not benchmark this class as setup() triggers a NotImplementedError. + (ASV has not yet released ABC/abstractmethod support - asv#838). + + """ + + params = [ + 6, # minimal cube-sphere + int(1e6), # realistic cube-sphere size + ] + param_names = ["number of faces"] + + def setup(self, *params): + self.object = self.create() + + def create(self): + raise NotImplementedError + + def time_create(self, *params): + """Create an instance of the benchmarked object. create() method is + specified in the subclass.""" + self.create() + + def time_return(self, *params): + """Return an instance of the benchmarked object.""" + _ = self.object + + +class Connectivity(UGridCommon): + def setup(self, n_faces): + self.array = np.zeros([n_faces, 3], dtype=np.int) + super().setup(n_faces) + + def create(self): + return ugrid.Connectivity( + indices=self.array, cf_role="face_node_connectivity" + ) + + def time_indices(self, n_faces): + _ = self.object.indices + + def time_location_lengths(self, n_faces): + # Proofed against the Connectivity name change (633ed17). + if getattr(self.object, "src_lengths", False): + meth = self.object.src_lengths + else: + meth = self.object.location_lengths + _ = meth() + + def time_validate_indices(self, n_faces): + self.object.validate_indices() + + +@disable_repeat_between_setup +class ConnectivityLazy(Connectivity): + """Lazy equivalent of :class:`Connectivity`.""" + + def setup(self, n_faces): + super().setup(n_faces) + self.array = self.object.lazy_indices() + self.object = self.create() + + +class Mesh(UGridCommon): + def setup(self, n_faces, lazy=False): + #### + # Steal everything from the sample mesh for benchmarking creation of a + # brand new mesh. + source_mesh = sample_mesh( + n_nodes=n_faces + 2, + n_edges=n_faces * 2, + n_faces=n_faces, + lazy_values=lazy, + ) + + def get_coords_and_axes(location): + search_kwargs = {f"include_{location}s": True} + return [ + (source_mesh.coord(axis=axis, **search_kwargs), axis) + for axis in ("x", "y") + ] + + self.mesh_kwargs = dict( + topology_dimension=source_mesh.topology_dimension, + node_coords_and_axes=get_coords_and_axes("node"), + connectivities=source_mesh.connectivities(), + edge_coords_and_axes=get_coords_and_axes("edge"), + face_coords_and_axes=get_coords_and_axes("face"), + ) + #### + + super().setup(n_faces) + + self.face_node = self.object.face_node_connectivity + self.node_x = self.object.node_coords.node_x + # Kwargs for reuse in search and remove methods. + self.connectivities_kwarg = dict(cf_role="edge_node_connectivity") + self.coords_kwarg = dict(include_faces=True) + + # TODO: an opportunity for speeding up runtime if needed, since + # eq_object is not needed for all benchmarks. Just don't generate it + # within a benchmark - the execution time is large enough that it + # could be a significant portion of the benchmark - makes regressions + # smaller and could even pick up regressions in copying instead! + self.eq_object = deepcopy(self.object) + + def create(self): + return ugrid.Mesh(**self.mesh_kwargs) + + def time_add_connectivities(self, n_faces): + self.object.add_connectivities(self.face_node) + + def time_add_coords(self, n_faces): + self.object.add_coords(node_x=self.node_x) + + def time_connectivities(self, n_faces): + _ = self.object.connectivities(**self.connectivities_kwarg) + + def time_coords(self, n_faces): + _ = self.object.coords(**self.coords_kwarg) + + def time_eq(self, n_faces): + _ = self.object == self.eq_object + + def time_remove_connectivities(self, n_faces): + self.object.remove_connectivities(**self.connectivities_kwarg) + + def time_remove_coords(self, n_faces): + self.object.remove_coords(**self.coords_kwarg) + + +@disable_repeat_between_setup +class MeshLazy(Mesh): + """Lazy equivalent of :class:`Mesh`.""" + + def setup(self, n_faces, lazy=True): + super().setup(n_faces, lazy=lazy) + + +class MeshCoord(UGridCommon): + # Add extra parameter value to match AuxCoord benchmarking. + params = UGridCommon.params + [ARTIFICIAL_DIM_SIZE] + + def setup(self, n_faces, lazy=False): + self.mesh = sample_mesh( + n_nodes=n_faces + 2, + n_edges=n_faces * 2, + n_faces=n_faces, + lazy_values=lazy, + ) + + super().setup(n_faces) + + def create(self): + return ugrid.MeshCoord(mesh=self.mesh, location="face", axis="x") + + def time_points(self, n_faces): + _ = self.object.points + + def time_bounds(self, n_faces): + _ = self.object.bounds + + +@disable_repeat_between_setup +class MeshCoordLazy(MeshCoord): + """Lazy equivalent of :class:`MeshCoord`.""" + + def setup(self, n_faces, lazy=True): + super().setup(n_faces, lazy=lazy) diff --git a/benchmarks/benchmarks/generate_data/__init__.py b/benchmarks/benchmarks/generate_data/__init__.py index a56f2e4623..125e2e1b53 100644 --- a/benchmarks/benchmarks/generate_data/__init__.py +++ b/benchmarks/benchmarks/generate_data/__init__.py @@ -16,11 +16,18 @@ benchmark sequence runs over two different Python versions. """ +from contextlib import contextmanager from inspect import getsource from os import environ from pathlib import Path from subprocess import CalledProcessError, check_output, run from textwrap import dedent +from typing import Iterable + +from iris import load_cube as iris_loadcube +from iris._lazy_data import as_concrete_data +from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD +from iris.fileformats import netcdf #: Python executable used by :func:`run_function_elsewhere`, set via env #: variable of same name. Must be path of Python within an environment that @@ -92,3 +99,99 @@ def run_function_elsewhere(func_to_run, *args, **kwargs): [DATA_GEN_PYTHON, "-c", python_string], capture_output=True, check=True ) return result.stdout + + +def generate_cube_like_2d_cubesphere( + n_cube: int, with_mesh: bool, output_path: str +): + """ + Construct and save to file an LFRIc cubesphere-like cube for a given + cubesphere size, *or* a simpler structured (UM-like) cube of equivalent + size. + + NOTE: this function is *NEVER* called from within this actual package. + Instead, it is to be called via benchmarks.remote_data_generation, + so that it can use up-to-date facilities, independent of the ASV controlled + environment which contains the "Iris commit under test". + This means: + * it must be completely self-contained : i.e. it includes all its + own imports, and saves results to an output file. + + """ + from iris import save + from iris.tests.stock.mesh import sample_mesh, sample_mesh_cube + + n_face_nodes = n_cube * n_cube + n_faces = 6 * n_face_nodes + + # Set n_nodes=n_faces and n_edges=2*n_faces + # : Not exact, but similar to a 'real' cubesphere. + n_nodes = n_faces + n_edges = 2 * n_faces + if with_mesh: + mesh = sample_mesh( + n_nodes=n_nodes, n_faces=n_faces, n_edges=n_edges, lazy_values=True + ) + cube = sample_mesh_cube(mesh=mesh, n_z=1) + else: + cube = sample_mesh_cube(nomesh_faces=n_faces, n_z=1) + + # Strip off the 'extra' aux-coord mapping the mesh, which sample-cube adds + # but which we don't want. + cube.remove_coord("mesh_face_aux") + + # Save the result to a named file. + save(cube, output_path) + + +def make_cube_like_2d_cubesphere(n_cube: int, with_mesh: bool): + """ + Generate an LFRIc cubesphere-like cube for a given cubesphere size, + *or* a simpler structured (UM-like) cube of equivalent size. + + All the cube data, coords and mesh content are LAZY, and produced without + allocating large real arrays (to allow peak-memory testing). + + NOTE: the actual cube generation is done in a stable Iris environment via + benchmarks.remote_data_generation, so it is all channeled via cached netcdf + files in our common testdata directory. + + """ + identifying_filename = ( + f"cube_like_2d_cubesphere_C{n_cube}_Mesh={with_mesh}.nc" + ) + filepath = BENCHMARK_DATA / identifying_filename + if not filepath.exists(): + # Create the required testfile, by running the generation code remotely + # in a 'fixed' python environment. + run_function_elsewhere( + generate_cube_like_2d_cubesphere, + n_cube, + with_mesh=with_mesh, + output_path=str(filepath), + ) + + # File now *should* definitely exist: content is simply the desired cube. + with PARSE_UGRID_ON_LOAD.context(): + with load_realised(): + cube = iris_loadcube(str(filepath)) + return cube + + +@contextmanager +def load_realised(): + """ + Force NetCDF loading with realised arrays. + + Since passing between data generation and benchmarking environments is via + file loading, but some benchmarks are only meaningful if starting with real + arrays. + """ + from iris.fileformats.netcdf import _get_cf_var_data as pre_patched + + def patched(cf_var, filename): + return as_concrete_data(pre_patched(cf_var, filename)) + + netcdf._get_cf_var_data = patched + yield netcdf + netcdf._get_cf_var_data = pre_patched diff --git a/benchmarks/benchmarks/generate_data/stock.py b/benchmarks/benchmarks/generate_data/stock.py new file mode 100644 index 0000000000..e352147fc8 --- /dev/null +++ b/benchmarks/benchmarks/generate_data/stock.py @@ -0,0 +1,126 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Wrappers for using :mod:`iris.tests.stock` methods for benchmarking. + +See :mod:`benchmarks.generate_data` for an explanation of this structure. +""" + +from pathlib import Path +import pickle + +from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD, load_mesh + +from . import BENCHMARK_DATA, REUSE_DATA, load_realised, run_function_elsewhere + + +def create_file__xios_2d_face_half_levels( + temp_file_dir, dataset_name, n_faces=866, n_times=1 +): + """ + Wrapper for :meth:`iris.tests.stock.netcdf.create_file__xios_2d_face_half_levels`. + + Have taken control of temp_file_dir + + todo: is create_file__xios_2d_face_half_levels still appropriate now we can + properly save Mesh Cubes? + """ + + def _external(*args, **kwargs): + from iris.tests.stock.netcdf import ( + create_file__xios_2d_face_half_levels, + ) + + print(create_file__xios_2d_face_half_levels(*args, **kwargs), end="") + + args_list = [dataset_name, n_faces, n_times] + args_hash = hash(str(args_list)) + save_path = ( + BENCHMARK_DATA / f"create_file__xios_2d_face_half_levels_{args_hash}" + ).with_suffix(".nc") + if not REUSE_DATA or not save_path.is_file(): + # create_file__xios_2d_face_half_levels takes control of save location + # so need to move to a more specific name that allows re-use. + actual_path = run_function_elsewhere( + _external, str(BENCHMARK_DATA), *args_list + ) + Path(actual_path.decode()).replace(save_path) + return save_path + + +def sample_mesh(n_nodes=None, n_faces=None, n_edges=None, lazy_values=False): + """Wrapper for :meth:iris.tests.stock.mesh.sample_mesh`.""" + + def _external(*args, **kwargs): + from iris.experimental.ugrid import save_mesh + from iris.tests.stock.mesh import sample_mesh + + save_path_ = kwargs.pop("save_path") + # Always saving, so laziness is irrelevant. Use lazy to save time. + kwargs["lazy_values"] = True + new_mesh = sample_mesh(*args, **kwargs) + save_mesh(new_mesh, save_path_) + + arg_list = [n_nodes, n_faces, n_edges] + args_hash = hash(str(arg_list)) + save_path = (BENCHMARK_DATA / f"sample_mesh_{args_hash}").with_suffix( + ".nc" + ) + if not REUSE_DATA or not save_path.is_file(): + _ = run_function_elsewhere( + _external, *arg_list, save_path=str(save_path) + ) + with PARSE_UGRID_ON_LOAD.context(): + if not lazy_values: + # Realise everything. + with load_realised(): + mesh = load_mesh(str(save_path)) + else: + mesh = load_mesh(str(save_path)) + return mesh + + +def sample_meshcoord(sample_mesh_kwargs=None, location="face", axis="x"): + """ + Wrapper for :meth:`iris.tests.stock.mesh.sample_meshcoord`. + + Parameters deviate from the original as cannot pass a + :class:`iris.experimental.ugrid.Mesh to the separate Python instance - must + instead generate the Mesh as well. + + MeshCoords cannot be saved to file, so the _external method saves the + MeshCoord's Mesh, then the original Python instance loads in that Mesh and + regenerates the MeshCoord from there. + """ + + def _external(sample_mesh_kwargs_, save_path_): + from iris.experimental.ugrid import save_mesh + from iris.tests.stock.mesh import sample_mesh, sample_meshcoord + + if sample_mesh_kwargs_: + input_mesh = sample_mesh(**sample_mesh_kwargs_) + else: + input_mesh = None + # Don't parse the location or axis arguments - only saving the Mesh at + # this stage. + new_meshcoord = sample_meshcoord(mesh=input_mesh) + save_mesh(new_meshcoord.mesh, save_path_) + + args_hash = hash(str(sample_mesh_kwargs)) + save_path = ( + BENCHMARK_DATA / f"sample_mesh_coord_{args_hash}" + ).with_suffix(".nc") + if not REUSE_DATA or not save_path.is_file(): + _ = run_function_elsewhere( + _external, + sample_mesh_kwargs_=sample_mesh_kwargs, + save_path_=str(save_path), + ) + with PARSE_UGRID_ON_LOAD.context(): + with load_realised(): + source_mesh = load_mesh(str(save_path)) + # Regenerate MeshCoord from its Mesh, which we saved. + return source_mesh.to_MeshCoord(location=location, axis=axis) diff --git a/benchmarks/benchmarks/iterate.py b/benchmarks/benchmarks/iterate.py index 20422750ef..0a5415ac2b 100644 --- a/benchmarks/benchmarks/iterate.py +++ b/benchmarks/benchmarks/iterate.py @@ -9,9 +9,10 @@ """ import numpy as np -from benchmarks import ARTIFICIAL_DIM_SIZE from iris import coords, cube, iterate +from . import ARTIFICIAL_DIM_SIZE + def setup(): """General variables needed by multiple benchmark classes.""" diff --git a/benchmarks/benchmarks/mixin.py b/benchmarks/benchmarks/mixin.py index e78b150438..bec5518eee 100644 --- a/benchmarks/benchmarks/mixin.py +++ b/benchmarks/benchmarks/mixin.py @@ -10,10 +10,11 @@ import numpy as np -from benchmarks import ARTIFICIAL_DIM_SIZE from iris import coords from iris.common.metadata import AncillaryVariableMetadata +from . import ARTIFICIAL_DIM_SIZE + LONG_NAME = "air temperature" STANDARD_NAME = "air_temperature" VAR_NAME = "air_temp" diff --git a/benchmarks/benchmarks/netcdf_save.py b/benchmarks/benchmarks/netcdf_save.py new file mode 100644 index 0000000000..c7580b3c63 --- /dev/null +++ b/benchmarks/benchmarks/netcdf_save.py @@ -0,0 +1,61 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Cubesphere-like netcdf saving benchmarks. + +Where possible benchmarks should be parameterised for two sizes of input data: + * minimal: enables detection of regressions in parts of the run-time that do + NOT scale with data size. + * large: large enough to exclusively detect regressions in parts of the + run-time that scale with data size. Aim for benchmark time ~20x + that of the minimal benchmark. + +""" +from iris import save +from iris.experimental.ugrid import save_mesh + +from . import TrackAddedMemoryAllocation +from .generate_data import make_cube_like_2d_cubesphere + + +class NetcdfSave: + params = [[1, 600], [False, True]] + param_names = ["cubesphere-N", "is_unstructured"] + + def setup(self, n_cubesphere, is_unstructured): + self.cube = make_cube_like_2d_cubesphere( + n_cube=n_cubesphere, with_mesh=is_unstructured + ) + + def _save_data(self, cube, do_copy=True): + if do_copy: + # Copy the cube, to avoid distorting the results by changing it + # Because we known that older Iris code realises lazy coords + cube = cube.copy() + save(cube, "tmp.nc") + + def _save_mesh(self, cube): + # In this case, we are happy that the mesh is *not* modified + save_mesh(cube.mesh, "mesh.nc") + + def time_netcdf_save_cube(self, n_cubesphere, is_unstructured): + self._save_data(self.cube) + + def time_netcdf_save_mesh(self, n_cubesphere, is_unstructured): + if is_unstructured: + self._save_mesh(self.cube) + + def track_addedmem_netcdf_save(self, n_cubesphere, is_unstructured): + cube = self.cube.copy() # Do this outside the testing block + with TrackAddedMemoryAllocation() as mb: + self._save_data(cube, do_copy=False) + return mb.addedmem_mb() + + +# Declare a 'Mb' unit for all 'track_addedmem_..' type benchmarks +for attr in dir(NetcdfSave): + if attr.startswith("track_addedmem_"): + getattr(NetcdfSave, attr).unit = "Mb" diff --git a/benchmarks/benchmarks/plot.py b/benchmarks/benchmarks/plot.py index 24899776dc..75195c86e9 100644 --- a/benchmarks/benchmarks/plot.py +++ b/benchmarks/benchmarks/plot.py @@ -10,9 +10,10 @@ import matplotlib import numpy as np -from benchmarks import ARTIFICIAL_DIM_SIZE from iris import coords, cube, plot +from . import ARTIFICIAL_DIM_SIZE + matplotlib.use("agg") diff --git a/benchmarks/benchmarks/regions_combine.py b/benchmarks/benchmarks/regions_combine.py new file mode 100644 index 0000000000..a99dc57263 --- /dev/null +++ b/benchmarks/benchmarks/regions_combine.py @@ -0,0 +1,268 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Benchmarks stages of operation of the function +:func:`iris.experimental.ugrid.utils.recombine_submeshes`. + +Where possible benchmarks should be parameterised for two sizes of input data: + * minimal: enables detection of regressions in parts of the run-time that do + NOT scale with data size. + * large: large enough to exclusively detect regressions in parts of the + run-time that scale with data size. Aim for benchmark time ~20x + that of the minimal benchmark. + +""" +import os + +import dask.array as da +import numpy as np + +from iris import load, load_cube, save +from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD +from iris.experimental.ugrid.utils import recombine_submeshes + +from . import TrackAddedMemoryAllocation +from .generate_data import make_cube_like_2d_cubesphere + + +class MixinCombineRegions: + # Characterise time taken + memory-allocated, for various stages of combine + # operations on cubesphere-like test data. + params = [4, 500] + param_names = ["cubesphere-N"] + + def _parametrised_cache_filename(self, n_cubesphere, content_name): + return f"cube_C{n_cubesphere}_{content_name}.nc" + + def _make_region_cubes(self, full_mesh_cube): + """Make a fixed number of region cubes from a full meshcube.""" + # Divide the cube into regions. + n_faces = full_mesh_cube.shape[-1] + # Start with a simple list of face indices + # first extend to multiple of 5 + n_faces_5s = 5 * ((n_faces + 1) // 5) + i_faces = np.arange(n_faces_5s, dtype=int) + # reshape (5N,) to (N, 5) + i_faces = i_faces.reshape((n_faces_5s // 5, 5)) + # reorder [2, 3, 4, 0, 1] within each block of 5 + i_faces = np.concatenate([i_faces[:, 2:], i_faces[:, :2]], axis=1) + # flatten to get [2 3 4 0 1 (-) 8 9 10 6 7 (-) 13 14 15 11 12 ...] + i_faces = i_faces.flatten() + # reduce back to orignal length, wrap any overflows into valid range + i_faces = i_faces[:n_faces] % n_faces + + # Divide into regions -- always slightly uneven, since 7 doesn't divide + n_regions = 7 + n_facesperregion = n_faces // n_regions + i_face_regions = (i_faces // n_facesperregion) % n_regions + region_inds = [ + np.where(i_face_regions == i_region)[0] + for i_region in range(n_regions) + ] + # NOTE: this produces 7 regions, with near-adjacent value ranges but + # with some points "moved" to an adjacent region. + # Also, region-0 is bigger (because of not dividing by 7). + + # Finally, make region cubes with these indices. + region_cubes = [full_mesh_cube[..., inds] for inds in region_inds] + return region_cubes + + def setup_cache(self): + """Cache all the necessary source data on disk.""" + + # Control dask, to minimise memory usage + allow largest data. + self.fix_dask_settings() + + for n_cubesphere in self.params: + # Do for each parameter, since "setup_cache" is NOT parametrised + mesh_cube = make_cube_like_2d_cubesphere( + n_cube=n_cubesphere, with_mesh=True + ) + # Save to files which include the parameter in the names. + save( + mesh_cube, + self._parametrised_cache_filename(n_cubesphere, "meshcube"), + ) + region_cubes = self._make_region_cubes(mesh_cube) + save( + region_cubes, + self._parametrised_cache_filename(n_cubesphere, "regioncubes"), + ) + + def setup( + self, n_cubesphere, imaginary_data=True, create_result_cube=True + ): + """ + The combine-tests "standard" setup operation. + + Load the source cubes (full-mesh + region) from disk. + These are specific to the cubesize parameter. + The data is cached on disk rather than calculated, to avoid any + pre-loading of the process memory allocation. + + If 'imaginary_data' is set (default), the region cubes data is replaced + with lazy data in the form of a da.zeros(). Otherwise, the region data + is lazy data from the files. + + If 'create_result_cube' is set, create "self.combined_cube" containing + the (still lazy) result. + + NOTE: various test classes override + extend this. + + """ + + # Load source cubes (full-mesh and regions) + with PARSE_UGRID_ON_LOAD.context(): + self.full_mesh_cube = load_cube( + self._parametrised_cache_filename(n_cubesphere, "meshcube") + ) + self.region_cubes = load( + self._parametrised_cache_filename(n_cubesphere, "regioncubes") + ) + + # Remove all var-names from loaded cubes, which can otherwise cause + # problems. Also implement 'imaginary' data. + for cube in self.region_cubes + [self.full_mesh_cube]: + cube.var_name = None + for coord in cube.coords(): + coord.var_name = None + if imaginary_data: + # Replace cube data (lazy file data) with 'imaginary' data. + # This has the same lazy-array attributes, but is allocated by + # creating chunks on demand instead of loading from file. + data = cube.lazy_data() + data = da.zeros( + data.shape, dtype=data.dtype, chunks=data.chunksize + ) + cube.data = data + + if create_result_cube: + self.recombined_cube = self.recombine() + + # Fix dask usage mode for all the subsequent performance tests. + self.fix_dask_settings() + + def fix_dask_settings(self): + """ + Fix "standard" dask behaviour for time+space testing. + + Currently this is single-threaded mode, with known chunksize, + which is optimised for space saving so we can test largest data. + + """ + + import dask.config as dcfg + + # Use single-threaded, to avoid process-switching costs and minimise memory usage. + # N.B. generally may be slower, but use less memory ? + dcfg.set(scheduler="single-threaded") + # Configure iris._lazy_data.as_lazy_data to aim for 100Mb chunks + dcfg.set({"array.chunk-size": "128Mib"}) + + def recombine(self): + # A handy general shorthand for the main "combine" operation. + result = recombine_submeshes( + self.full_mesh_cube, + self.region_cubes, + index_coord_name="i_mesh_face", + ) + return result + + +class CombineRegionsCreateCube(MixinCombineRegions): + """ + Time+memory costs of creating a combined-regions cube. + + The result is lazy, and we don't do the actual calculation. + + """ + + def setup(self, n_cubesphere): + # In this case only, do *not* create the result cube. + # That is the operation we want to test. + super().setup(n_cubesphere, create_result_cube=False) + + def time_create_combined_cube(self, n_cubesphere): + self.recombine() + + def track_addedmem_create_combined_cube(self, n_cubesphere): + with TrackAddedMemoryAllocation() as mb: + self.recombine() + return mb.addedmem_mb() + + +CombineRegionsCreateCube.track_addedmem_create_combined_cube.unit = "Mb" + + +class CombineRegionsComputeRealData(MixinCombineRegions): + """ + Time+memory costs of computing combined-regions data. + """ + + def time_compute_data(self, n_cubesphere): + self.recombined_cube.data + + def track_addedmem_compute_data(self, n_cubesphere): + with TrackAddedMemoryAllocation() as mb: + self.recombined_cube.data + + return mb.addedmem_mb() + + +CombineRegionsComputeRealData.track_addedmem_compute_data.unit = "Mb" + + +class CombineRegionsSaveData(MixinCombineRegions): + """ + Test saving *only*, having replaced the input cube data with 'imaginary' + array data, so that input data is not loaded from disk during the save + operation. + + """ + + def time_save(self, n_cubesphere): + # Save to disk, which must compute data + stream it to file. + save(self.recombined_cube, "tmp.nc") + + def track_addedmem_save(self, n_cubesphere): + with TrackAddedMemoryAllocation() as mb: + save(self.recombined_cube, "tmp.nc") + + return mb.addedmem_mb() + + def track_filesize_saved(self, n_cubesphere): + save(self.recombined_cube, "tmp.nc") + return os.path.getsize("tmp.nc") * 1.0e-6 + + +CombineRegionsSaveData.track_addedmem_save.unit = "Mb" +CombineRegionsSaveData.track_filesize_saved.unit = "Mb" + + +class CombineRegionsFileStreamedCalc(MixinCombineRegions): + """ + Test the whole cost of file-to-file streaming. + Uses the combined cube which is based on lazy data loading from the region + cubes on disk. + """ + + def setup(self, n_cubesphere): + # In this case only, do *not* replace the loaded regions data with + # 'imaginary' data, as we want to test file-to-file calculation+save. + super().setup(n_cubesphere, imaginary_data=False) + + def time_stream_file2file(self, n_cubesphere): + # Save to disk, which must compute data + stream it to file. + save(self.recombined_cube, "tmp.nc") + + def track_addedmem_stream_file2file(self, n_cubesphere): + with TrackAddedMemoryAllocation() as mb: + save(self.recombined_cube, "tmp.nc") + + return mb.addedmem_mb() + + +CombineRegionsFileStreamedCalc.track_addedmem_stream_file2file.unit = "Mb" diff --git a/benchmarks/benchmarks/regridding.py b/benchmarks/benchmarks/regridding.py index 6db33aa192..c315119c11 100644 --- a/benchmarks/benchmarks/regridding.py +++ b/benchmarks/benchmarks/regridding.py @@ -25,16 +25,31 @@ def setup(self) -> None: ) self.cube = iris.load_cube(cube_file_path) + # Prepare a tougher cube and chunk it + chunked_cube_file_path = tests.get_data_path( + ["NetCDF", "regrid", "regrid_xyt.nc"] + ) + self.chunked_cube = iris.load_cube(chunked_cube_file_path) + + # Chunked data makes the regridder run repeatedly + self.cube.data = self.cube.lazy_data().rechunk((1, -1, -1)) + template_file_path = tests.get_data_path( ["NetCDF", "regrid", "regrid_template_global_latlon.nc"] ) self.template_cube = iris.load_cube(template_file_path) - # Chunked data makes the regridder run repeatedly - self.cube.data = self.cube.lazy_data().rechunk((1, -1, -1)) + # Prepare a regridding scheme + self.scheme_area_w = AreaWeighted() def time_regrid_area_w(self) -> None: # Regrid the cube onto the template. - out = self.cube.regrid(self.template_cube, AreaWeighted()) + out = self.cube.regrid(self.template_cube, self.scheme_area_w) # Realise the data out.data + + def time_regrid_area_w_new_grid(self) -> None: + # Regrid the chunked cube + out = self.chunked_cube.regrid(self.template_cube, self.scheme_area_w) + # Realise data + out.data diff --git a/benchmarks/benchmarks/ugrid_load.py b/benchmarks/benchmarks/ugrid_load.py new file mode 100644 index 0000000000..352450dcec --- /dev/null +++ b/benchmarks/benchmarks/ugrid_load.py @@ -0,0 +1,128 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Mesh data loading benchmark tests. + +Where possible benchmarks should be parameterised for two sizes of input data: + * minimal: enables detection of regressions in parts of the run-time that do + NOT scale with data size. + * large: large enough to exclusively detect regressions in parts of the + run-time that scale with data size. Aim for benchmark time ~20x + that of the minimal benchmark. + +""" + +from iris import load_cube as iris_load_cube +from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD +from iris.experimental.ugrid import load_mesh as iris_load_mesh + +from .generate_data.stock import create_file__xios_2d_face_half_levels + + +def synthetic_data(**kwargs): + # Ensure all uses of the synthetic data function use the common directory. + # File location is controlled by :mod:`generate_data`, hence temp_file_dir=None. + return create_file__xios_2d_face_half_levels(temp_file_dir=None, **kwargs) + + +def load_cube(*args, **kwargs): + with PARSE_UGRID_ON_LOAD.context(): + return iris_load_cube(*args, **kwargs) + + +def load_mesh(*args, **kwargs): + with PARSE_UGRID_ON_LOAD.context(): + return iris_load_mesh(*args, **kwargs) + + +class BasicLoading: + params = [1, int(4.1e6)] + param_names = ["number of faces"] + + def setup_common(self, **kwargs): + self.data_path = synthetic_data(**kwargs) + + def setup(self, *args): + self.setup_common(dataset_name="Loading", n_faces=args[0]) + + def time_load_file(self, *args): + _ = load_cube(str(self.data_path)) + + def time_load_mesh(self, *args): + _ = load_mesh(str(self.data_path)) + + +class BasicLoadingTime(BasicLoading): + """Same as BasicLoading, but scaling over a time series - an unlimited dimension.""" + + param_names = ["number of time steps"] + + def setup(self, *args): + self.setup_common(dataset_name="Loading", n_faces=1, n_times=args[0]) + + +class DataRealisation: + # Prevent repeat runs between setup() runs - data won't be lazy after 1st. + number = 1 + # Compensate for reduced certainty by increasing number of repeats. + repeat = (10, 10, 10.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 + timeout = 300.0 + + params = [1, int(4e6)] + param_names = ["number of faces"] + + def setup_common(self, **kwargs): + data_path = synthetic_data(**kwargs) + self.cube = load_cube(str(data_path)) + + def setup(self, *args): + self.setup_common(dataset_name="Realisation", n_faces=args[0]) + + def time_realise_data(self, *args): + assert self.cube.has_lazy_data() + _ = self.cube.data[0] + + +class DataRealisationTime(DataRealisation): + """Same as DataRealisation, but scaling over a time series - an unlimited dimension.""" + + param_names = ["number of time steps"] + + def setup(self, *args): + self.setup_common( + dataset_name="Realisation", n_faces=1, n_times=args[0] + ) + + +class Callback: + params = [1, int(4.5e6)] + param_names = ["number of faces"] + + def setup_common(self, **kwargs): + def callback(cube, field, filename): + return cube[::2] + + self.data_path = synthetic_data(**kwargs) + self.callback = callback + + def setup(self, *args): + self.setup_common(dataset_name="Loading", n_faces=args[0]) + + def time_load_file_callback(self, *args): + _ = load_cube(str(self.data_path), callback=self.callback) + + +class CallbackTime(Callback): + """Same as Callback, but scaling over a time series - an unlimited dimension.""" + + param_names = ["number of time steps"] + + def setup(self, *args): + self.setup_common(dataset_name="Loading", n_faces=1, n_times=args[0]) From d0569c79e803951aa3461b5a2335c91008bf9c40 Mon Sep 17 00:00:00 2001 From: Martin Yeo <40734014+trexfeathers@users.noreply.github.com> Date: Fri, 4 Mar 2022 10:33:31 +0000 Subject: [PATCH 20/28] Overnight benchmarks remove ambiguity between file and commit names. (#4620) --- .github/workflows/benchmark.yml | 7 ++++--- noxfile.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 086f6a0bb4..6b155fc8bc 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -79,12 +79,13 @@ jobs: cd benchmarks/.asv/performance-shifts for commit_file in * do - pr_number=$(git log "$commit_file"^! --oneline | grep -o "#[0-9]*" | tail -1 | cut -c 2-) + commit="${commit_file%.*}" + pr_number=$(git log "$commit"^! --oneline | grep -o "#[0-9]*" | tail -1 | cut -c 2-) assignee=$(gh pr view $pr_number --json author -q '.["author"]["login"]' --repo $GITHUB_REPOSITORY) - title="Performance Shift(s): \`$commit_file\`" + title="Performance Shift(s): \`$commit\`" body=" Benchmark comparison has identified performance shifts at commit \ - $commit_file (#$pr_number). Please review the report below and \ + $commit (#$pr_number). Please review the report below and \ take corrective/congratulatory action as appropriate \ :slightly_smiling_face: diff --git a/noxfile.py b/noxfile.py index e4d91c6bab..c65319c35f 100755 --- a/noxfile.py +++ b/noxfile.py @@ -432,7 +432,7 @@ def asv_compare(*commits): # Dir is used by .github/workflows/benchmarks.yml, # but not cached - intended to be discarded after run. shifts_dir.mkdir(exist_ok=True, parents=True) - shifts_path = shifts_dir / after + shifts_path = (shifts_dir / after).with_suffix(".txt") with shifts_path.open("w") as shifts_file: shifts_file.write(shifts) From 35d97ed9bed0ccefa6d940d7d1d40a89b6ce0d24 Mon Sep 17 00:00:00 2001 From: Bill Little Date: Fri, 4 Mar 2022 14:58:48 +0000 Subject: [PATCH 21/28] purge deploy key (#4615) --- .github/deploy_key.scitools-docs.enc | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/deploy_key.scitools-docs.enc diff --git a/.github/deploy_key.scitools-docs.enc b/.github/deploy_key.scitools-docs.enc deleted file mode 100644 index 165a7c1970..0000000000 --- a/.github/deploy_key.scitools-docs.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABZSMeGIlHxHu4oCV_h8shbCRf1qJYoLO9Z0q9uKRDTlytoigzlvfxhN-9WMjc3Js1f1Zg55PfEpTOpL82p6QHF-gqW0k0qGjanO3lnQzM6EzIu3KyJPrVrL-O6edwoPMYKqwsNO3VQHNuEspsFKY0TbjnTPHc45SPU5LjEGX4c_SADSDcLDJm2rbrU2eVkT-gFHy_-ZzK0Di83WlDc79YzIkVe5BAn5PbWv3O9BROR4fJzecbjmWRT_rp1cqI_gaUpVcwTdRK3II9YnazBtW4h2WbCeTcySLD7N4o9K0P71SR6gG_XFbpML3Haf5IUdRi0qPBuvJ_4YVnnuJo6mhiIOJfUEcNj_bbLOYVzPmKyQMHvrPf_lK5JhdX6MUvqluhqHuc0i_z_j1O2y32lB7b1iiY6eE_BsNlXJHlOX1GiXkX0nZLI48p-D22jya44WshWSnVcoalcCDkdbvdFbpOscwXDR3nB-PCOmRUF_d1BlMbp1if-VP0yt3tJ_5yyCrqSRWwFusaibQTF6yoImetl7Am95hh2FjFDNkalHqtarnUv86w-26v1ukcTIjJ0iHzNbCK1m0VMkvE6uDeqRgIZnVKON5cesmM3YbulRrHpaOiSly_sMhLhfg5jTxAuOa319AQGoHEOcRLRUYdw2TQkDEiHGiUh_U4-nC7GTGDGcXyeBIa4ciuC2Qi0QXf9qyEGoIRcU8BP34LDNdtovJoZOBDzhr5Ajnu7yA3GB3TD_kiZrgm6agFuu7a51OMfjezhwGzUJ4X-empPctwm9woOJmPCTFqCvxB2VwVV0L6yngsTooyAHCi5st_AG-p5FIT3VZGx7EgCd68ze9XlRoACoe9XOdSFklbaSMGRbJlvKCPAA0zj4__PfIhlD8Cxwwjq_VXlSr_QxygIGZJlhkT46P9TroolgdipaBp1aQ3_PKHfgw5Y9ZqBKCZF5DOJejqUbfVKUp2JdqoX3yQBD0ByQFdfCuLvoiYcM2ofKdIMvel3Jwn0Nx4NYR2qg3h7FYti0jdrNlC89gnL4tKsf0DAGxZ1UYmqQMWJ3-GKCKrlKyeaHYB2djPRGP8VeoRZh_UorSNHU56KSztK_hTP6P0nFymRJRUSRBMKTaTfJf1aBlk9zJHSe9hOKwxyUNkwcTftGn5P0WNcnaTk3ecTVe-1QJKbPWwMBDzqQtTCsCizgN4UdQsmy4iMYq-LT2TC-JXXo0CPTNDybUj92wSa7KeKTvKnbN8DMZbGRdgy5BOSGw4hMIoIFSB-6tnBIvTntNfMT9ac9e9jKm47Q4qXpaeF3AsvBqxkMRQLaYVppPng6cA49VjJQDZ0gTdPKSSKZkApfeeQL0LLCGwzQ4C52TWK2NJSQ3pvRYI1F0taDQWopIiwFfox-OSYnOJECHkHjxaxhHQzVb3w47xKKZNXbLb-LV7QI-kGuKLfoqO1lq94cw1H-EVrXaGJcDDLjK2jRgdVfDyPsHMcW1oUDJqu8gQ6fCXYPbqJzdmFNFsc1hywHWCU7crV61D2QubwzbLRnP8053MvsMnbdhWtwocTlvvdG-qW6CiEA9Eanfpf0RW1W9oh6yQJ__0vS9UWswqq5ahkkpHY9LTE0US4L3xbFOrq7HgbA2jelTdPVfxo3BfUHuL8oKpFDTzgZi07gNmkhIZfpuXj2KFnm9XM31AsY6V2rXL0xSx-9rvi4FP0LK6V5vQ8OKI8aRPCDyzLUv2xnayMW4yaYg3GHD5yo7pIOswKc6GOEmetPnay3j0dVN3hfpkpfJWhss3vjZ2Zl0NmjJ7OuS25tjUGLy82A1yFSpL8mKRkHZJuMDZbd_Or6gaPVoVT_Otbkh-6pMZuDeOHOUfgey0Z374jCjRpyQ9k-Fpw8ykow8iIIQ088kC5CeQy6jRhD7mO3iR4-U1XKDJQNlNg1z_JYyDrwykp7FFN2sQn7RRYHIXx2iMrEDXdrdTrujMFN6omC13yDuXJukAgZb6zBBUTlonxRUBjUJWt2P-1sRRTsG8mr9EaE5K-xhR5Ust_37L3svNQ0vwLtPLIpWGZHhD8P_dYNR2RL4679xyzI8A7wLY82wFBHrcghAd4UtLJH9ul6IuS_CaVo-gbfowNRaQ0Zw7WHZGIXpZWEx1_zck6qDEaCY8TpQeciBWpH5uJDSYqdLdMwigdQEGzAJ1DHSWsyTrmOR7Lhwi9WqOzfWe4ahxAkAUH_Jdr_i-nGfl_x3OgQdHM7jWVMXDcXEmR0bkw-s0EKXCn20q2bxDkm5SUWkYtWAZ2aZRgo4wHOqGBcP99xZ25mq9uxtNOkLBF81lnVbn_4BAZBNnnKwwj4SafeIW4KR1ZOpnEI47sGUR6NhEk9VtJsv0zeZIv8VjRbNLh3QCxkNMue60SjJ48kjotZSX1RQJN0xwPftiABBf8MX9tyZe8emQvPeIcdQTSQPnYEUx22xZGeeJTNrZ9soQyP6mrkkRihp6o9tG7HT9QEVLGM19wAigwAAMMXGqdGzWwpar30JtJU94gAmIlwFUJqeO_fdJKFspnUyJ6gt5_oHsKNEV7Uz5EJwGpa94tlPJXjvZpu-wWQfu8U0trTU2mTCA0bmZIDID-Xk4vCW_SD4OVnsvWyga4QHSg3AqVTjnjlapAjsYcFjiOo2C_U3besloprpyuAwpTdn7zdfMHIJO0ckBFnXlk8XB3kT0YGrCpBvW6gYMXlnePVcr3wJehCvMg1Q9Dc5fVQUqt65zcjbgiudfzFGtTe9T4f1IttoAtrJgTN4W1mtbZzSK864I_ngaX5YWgZSinjkbocCCFEJDcbiXMnV7OWOZefqW6VZu4BZKEKlN9k2kH3UCECCK3uRAQIPn_48DgaVnAff2-fMADltiosSPJ_a3057acJP0cf-1QsJuV7r3zdzL3shgrMRjpSsSTCYdMhZ6disFGcJg7hJJvtH1FieZ76jps5FYi5lE8Ua9yBKlG4dCGuUBnikvpfy2FLMLFNn-iXLflu2oiBbcLvn_ReZUnFIR6KgGRN8xKEBaXATQVtb2E678GtQptK8PHP2DoAtbsIXUDn60YH04D9pEck8NnmWYAz7sWbiL6OKdaO7jQep4mt3CgkyFC0NCKP9zCbVNtmfHRVmHtckjgfHF-tK_v59KeAuwWPtm7ow2BjynAK42IGR9nWtQFRUZIboaND8UF76YGKFF7kOf_XTvoNrVTCRkD6b8KJy2IFfdoHP6WET9QLvwDSXgYLPlCX9z7aQ_lc57u5d_dGO-7NZ_Qbs69ByyIvQoztVBjw6fa7EzSwccqPfMQL_fiecNCng-r4gHaH6TlgSbfqQOISHxTtvmbym1no560ZsHfnQfuL6BCI8s6OoygxhOnQhaDqyOUVBut_x3VR_DKFMyUazXYNgLbRsdITaAvR-0gIx5TAX9n3A4HwHuiBZCtwRYaiJnW8FX9lk1Y_g5UHL2OC3rsNFui3aBLzAFhx58lALxnxhlUItuHHK9BgexnR2yCj2nOWLoWQzfFaf2_fpjEh_QBHTqUxdQZ8ighg_8lh6hmLbW4PcUxKX71RFmikLyS3-idlzsiEomNlPNaVllRF21vE6dR-nZ6xsxzTvNB4wumP2irQ9mFBTN1WpiLMyNoEEucA2I848YHUfkZrjTG_dcCQNp7H_2gKdIsZ135lUEG6lYfhLMHTmP5uYxxx3Pipjp6wF2GFCsZPIlIPsgrhbSxqkWg1EOViHtpw6ypFKn7wQHHfnrnHkFWnrKbMARVBjJUB-FhK4b6qLU_k_MTMipemneMUFXlj3EkEhKM18MIHGkIOkwG5QtPYcjUAf_2sZlxSMVnh6sQ8kVwF6lfk_l8jhoO93HUTntZUSv7GrE3s80yJgII4Qw37AdgcJiAkoPn1-17HfSsAy6uRh5-OvrCtkDqQxfuJSyn_4pRMh6hZT7N9pI5limMXXn2nHnxU93UT3qU-smA8q0ECfvK3JwoaYy_llSx0wSBvpmxjLQ302sFYM5FVZ9zRbHuLCCZShVopiyMDLHVJe_1g9Ou1KL-h6RVZgg3Ttyb5m2KDfoHEVLeZkW81YLCsyo7uNb6SVRM-615TIVGT6Eq7oJ6wO2LMDKjEpHKFiOFpY2fpR8noM81UqgLddYfl_lei7RVjaNO98otqE4iSNtpgJgyhAx4CdYm__yQRSXhckR4K7yAhM9Kh5BLbQQnf2_0WS1sWTmNMZZNMfOSqmTCRVwcYvg4TDGOA-vZARbZW1M7npVMldV_SbvgcEZD6InY9c40eheRqS0YD2W2HEZIiNeLRw0y5WBcYuJIpXhI3ViTXx-frJnv0Mo9uwmuLbJmWFcn6RdIVcU68_oPZZlZD4Vm7SjikbuZKF1BF3lXamTTDIBcWiDLwuNDv2lUkURDCWa5WJsfUCfTAJ6PTe8= \ No newline at end of file From 3820ae219b5d0d5cdca33fa3baa3655942714e68 Mon Sep 17 00:00:00 2001 From: lbdreyer Date: Tue, 8 Mar 2022 13:27:26 +0000 Subject: [PATCH 22/28] Remove no_clobber task from Refresh lockfiles Action (#4618) * Remove no_clobber task from Refresh lockfiles Action * review actions - add extra infor to pr message --- .github/workflows/refresh-lockfiles.yml | 43 ++----------------- .../contributing_ci_tests.rst | 13 +++--- 2 files changed, 8 insertions(+), 48 deletions(-) diff --git a/.github/workflows/refresh-lockfiles.yml b/.github/workflows/refresh-lockfiles.yml index 2be35e9f18..614bd7bb65 100644 --- a/.github/workflows/refresh-lockfiles.yml +++ b/.github/workflows/refresh-lockfiles.yml @@ -2,7 +2,7 @@ # available packages and dependencies. # # Environment specifications are given as conda environment.yml files found in -# `requirements/ci/py**.yml`. These state the pacakges required, the conda channels +# `requirements/ci/py**.yml`. These state the packages required, the conda channels # that the packages will be pulled from, and any versions of packages that need to be # pinned at specific versions. # @@ -14,12 +14,6 @@ name: Refresh Lockfiles on: workflow_dispatch: - inputs: - clobber: - description: | - Force the workflow to run, potentially clobbering any commits already made to the branch. - Enter "yes" or "true" to run. - default: "no" schedule: # Run once a week on a Saturday night # N.B. "should" be quoted, according to @@ -28,38 +22,6 @@ on: jobs: - - no_clobber: - if: "github.repository == 'SciTools/iris'" - runs-on: ubuntu-latest - steps: - # check if the auto-update-lockfiles branch exists. If it does, and someone other than - # the lockfile bot has made the head commit, abort the workflow. - # This job can be manually overridden by running directly from the github actions panel - # (known as a "workflow_dispatch") and setting the `clobber` input to "yes". - - uses: actions/script@v6 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - if (context.eventName == "workflow_dispatch") { - const clobber = context.payload.inputs.clobber || "no"; - if (["yes", "true", "y"].includes(clobber.trim().toLowerCase())) { - core.info("Manual override, continuing workflow, potentially overwriting previous commits to auto-update-lockfiles"); - return - } - } - github.repos.getBranch({...context.repo, branch: "auto-update-lockfiles"}).then(res => { - const committer = res.data.commit.commit.committer; - if (committer && committer.name === "Lockfile bot") { - core.info("Lockfile bot was the last to push to auto-update-lockfiles. Continue."); - } else { - core.setFailed("New commits to auto-update-lockfiles since bot last ran. Abort!"); - } - }).catch(err => { - if (err.status === 404) { - core.info("auto-update-lockfiles branch not found, continue"); - } - }) gen_lockfiles: # this is a matrix job: it splits to create new lockfiles for each @@ -69,7 +31,6 @@ jobs: # ref: https://tomasvotruba.com/blog/2020/11/16/how-to-make-dynamic-matrix-in-github-actions/ if: "github.repository == 'SciTools/iris'" runs-on: ubuntu-latest - needs: no_clobber strategy: matrix: @@ -121,6 +82,8 @@ jobs: title: "[iris.ci] environment lockfiles auto-update" body: | Lockfiles updated to the latest resolvable environment. + + If the CI test suite fails, create a new branch based of this pull request and add the required fixes to that branch. labels: | New: Pull Request Bot diff --git a/docs/src/developers_guide/contributing_ci_tests.rst b/docs/src/developers_guide/contributing_ci_tests.rst index 0257ff7cff..46848166b3 100644 --- a/docs/src/developers_guide/contributing_ci_tests.rst +++ b/docs/src/developers_guide/contributing_ci_tests.rst @@ -72,14 +72,11 @@ New lockfiles are generated automatically each week to ensure that Iris continue tested against the latest available version of its dependencies. Each week the yaml files in ``requirements/ci`` are resolved by a GitHub Action. If the resolved environment has changed, a pull request is created with the new lock files. -The CI test suite will run on this pull request and fixes for failed tests can be pushed to -the ``auto-update-lockfiles`` branch to be included in the PR. -Once a developer has pushed to this branch, the auto-update process will not run again until -the PR is merged, to prevent overwriting developer commits. -The auto-updater can still be invoked manually in this situation by going to the `GitHub Actions`_ -page for the workflow, and manually running using the "Run Workflow" button. -By default, this will also not override developer commits. To force an update, you must -confirm "yes" in the "Run Worflow" prompt. +The CI test suite will run on this pull request. If the tests fail, a developer +will need to create a new branch based off the ``auto-update-lockfiles`` branch +and add the required fixes to this new branch. If the fixes are made to the +``auto-update-lockfiles`` branch these will be overwritten the next time the +Github Action is run. .. _skipping Cirrus-CI tasks: From 77462d2caa51fca8ee6faff7c5b0a154cb641c21 Mon Sep 17 00:00:00 2001 From: Ruth Comer <10599679+rcomer@users.noreply.github.com> Date: Tue, 8 Mar 2022 15:15:10 +0000 Subject: [PATCH 23/28] Scalar Scatter Plot (#4616) * add failing test * pass test * initial review actions * test 2nd arg scalar * whatsnew --- docs/src/whatsnew/dev.rst | 3 ++ lib/iris/plot.py | 4 +- .../tests/unit/plot/test__get_plot_objects.py | 45 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 lib/iris/tests/unit/plot/test__get_plot_objects.py diff --git a/docs/src/whatsnew/dev.rst b/docs/src/whatsnew/dev.rst index b9d5989bfc..5cc0769c57 100644 --- a/docs/src/whatsnew/dev.rst +++ b/docs/src/whatsnew/dev.rst @@ -41,6 +41,9 @@ This document explains the changes made to Iris for this release #. `@rcomer`_ reverted part of the change from :pull:`3906` so that :func:`iris.plot.plot` no longer defaults to placing a "Y" coordinate (e.g. latitude) on the y-axis of the plot. (:issue:`4493`, :pull:`4601`) + +#. `@rcomer`_ enabled passing of scalar objects to :func:`~iris.plot.plot` and + :func:`~iris.plot.scatter`. (:pull:`4616`) 💣 Incompatible Changes diff --git a/lib/iris/plot.py b/lib/iris/plot.py index aefca889cf..d886ac1cf9 100644 --- a/lib/iris/plot.py +++ b/lib/iris/plot.py @@ -652,13 +652,13 @@ def _get_plot_objects(args): u_object, v_object = args[:2] u, v = _uv_from_u_object_v_object(u_object, v_object) args = args[2:] - if len(u) != len(v): + if u.size != v.size: msg = ( "The x and y-axis objects are not compatible. They should " "have equal sizes but got ({}: {}) and ({}: {})." ) raise ValueError( - msg.format(u_object.name(), len(u), v_object.name(), len(v)) + msg.format(u_object.name(), u.size, v_object.name(), v.size) ) else: # single argument diff --git a/lib/iris/tests/unit/plot/test__get_plot_objects.py b/lib/iris/tests/unit/plot/test__get_plot_objects.py new file mode 100644 index 0000000000..8586faa756 --- /dev/null +++ b/lib/iris/tests/unit/plot/test__get_plot_objects.py @@ -0,0 +1,45 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +"""Unit tests for the `iris.plot._get_plot_objects` function.""" + +# Import iris.tests first so that some things can be initialised before +# importing anything else. +import iris.tests as tests # isort:skip + +import iris.cube + +if tests.MPL_AVAILABLE: + from iris.plot import _get_plot_objects + + +@tests.skip_plot +class Test__get_plot_objects(tests.IrisTest): + def test_scalar(self): + cube1 = iris.cube.Cube(1) + cube2 = iris.cube.Cube(1) + expected = (cube1, cube2, 1, 1, ()) + result = _get_plot_objects((cube1, cube2)) + self.assertTupleEqual(expected, result) + + def test_mismatched_size_first_scalar(self): + cube1 = iris.cube.Cube(1) + cube2 = iris.cube.Cube([1, 42]) + with self.assertRaisesRegex( + ValueError, "x and y-axis objects are not compatible" + ): + _get_plot_objects((cube1, cube2)) + + def test_mismatched_size_second_scalar(self): + cube1 = iris.cube.Cube(1) + cube2 = iris.cube.Cube([1, 42]) + with self.assertRaisesRegex( + ValueError, "x and y-axis objects are not compatible" + ): + _get_plot_objects((cube2, cube1)) + + +if __name__ == "__main__": + tests.main() From ad17088e9081ee81d8d8f55da4554aadeb1249a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Mar 2022 17:56:07 +0000 Subject: [PATCH 24/28] Updated environment lockfiles (#4624) Co-authored-by: Lockfile bot --- requirements/ci/nox.lock/py38-linux-64.lock | 95 +++++++++++---------- 1 file changed, 48 insertions(+), 47 deletions(-) diff --git a/requirements/ci/nox.lock/py38-linux-64.lock b/requirements/ci/nox.lock/py38-linux-64.lock index caf6a739b3..a612138dfa 100644 --- a/requirements/ci/nox.lock/py38-linux-64.lock +++ b/requirements/ci/nox.lock/py38-linux-64.lock @@ -1,6 +1,6 @@ # Generated by conda-lock. # platform: linux-64 -# input_hash: 0b8e98b045b5545a96321ab961f5e97fe2da8aa929328cc8df2d4d5f33ed8159 +# input_hash: fc890d56b881193a2422ceb96d07b1b2bb857890e1d48fb24a765ec2f886d4d2 @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 @@ -9,20 +9,20 @@ https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed3 https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2#4d59c254e01d9cde7957100457e2d5fb https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-hab24e00_0.tar.bz2#19410c3df09dfb12d1206132a1d357c5 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_12.tar.bz2#f547bf125ab234cec9c89491b262fc2f -https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-11.2.0-he4da1e4_12.tar.bz2#7ff3b832ba5e6918c0d026976359d065 +https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-11.2.0-h5c6108e_13.tar.bz2#b62e87134ec17e1180cfcb3951624db4 +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-11.2.0-he4da1e4_13.tar.bz2#573a74710fad22a27da784cc238150b9 https://conda.anaconda.org/conda-forge/linux-64/mpi-1.0-mpich.tar.bz2#c1fcff3417b5a22bbc4cf6e8c23648cf https://conda.anaconda.org/conda-forge/linux-64/mysql-common-8.0.28-ha770c72_0.tar.bz2#56594fdd5a80774a80d546fbbccf2c03 https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-0.tar.bz2#f766549260d6815b0c52253f1fb1bb29 -https://conda.anaconda.org/conda-forge/linux-64/libgfortran-ng-11.2.0-h69a702a_12.tar.bz2#33c165be455015cc74e8d857182f3f58 -https://conda.anaconda.org/conda-forge/linux-64/libgomp-11.2.0-h1d223b6_12.tar.bz2#763c5ec8116d984b4a33342236d7da36 +https://conda.anaconda.org/conda-forge/linux-64/libgfortran-ng-11.2.0-h69a702a_13.tar.bz2#a3a07a89af69d1eada078695b42e4961 +https://conda.anaconda.org/conda-forge/linux-64/libgomp-11.2.0-h1d223b6_13.tar.bz2#8e91f1f21417c9ab1265240ee4f9db1e https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-1_gnu.tar.bz2#561e277319a41d4f24f5c05a9ef63c04 https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2#fee5683a3f04bd15cbd8318b096a27ab -https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-11.2.0-h1d223b6_12.tar.bz2#d34efbb8d7d6312c816b4bb647b818b1 +https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-11.2.0-h1d223b6_13.tar.bz2#63eaf0f146cc80abd84743d48d667da4 https://conda.anaconda.org/conda-forge/linux-64/alsa-lib-1.2.3-h516909a_0.tar.bz2#1378b88874f42ac31b2f8e4f6975cb7b 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.4-h9c3ff4c_0.tar.bz2#3cedab1fd76644efd516e1b271f2da95 +https://conda.anaconda.org/conda-forge/linux-64/expat-2.4.6-h27087fc_0.tar.bz2#90dec9e76bc164857cc200f81e981dab https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.10-h36c2ea0_0.tar.bz2#ac7bc6a654f8f41b352b38f4051135f8 https://conda.anaconda.org/conda-forge/linux-64/geos-3.10.2-h9c3ff4c_0.tar.bz2#fe9a66a351bfa7a84c3108304c7bcba5 https://conda.anaconda.org/conda-forge/linux-64/giflib-5.2.1-h36c2ea0_2.tar.bz2#626e68ae9cc5912d6adb79d318cf962d @@ -30,13 +30,13 @@ https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.13-h58526e2_1001.t https://conda.anaconda.org/conda-forge/linux-64/icu-69.1-h9c3ff4c_0.tar.bz2#e0773c9556d588b062a4e1424a6a02fa 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-9e-h7f98852_0.tar.bz2#5c214edc675a7fb7cbb34b1d854e5141 +https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.1-h166bdaf_0.tar.bz2#30186d27e2c9fa62b45fb1476b7200e3 https://conda.anaconda.org/conda-forge/linux-64/lerc-3.0-h9c3ff4c_0.tar.bz2#7fcefde484980d23f0ec24c11e314d2e https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.0.9-h7f98852_6.tar.bz2#b0f44f63f7d771d7670747a1dd5d5ac1 -https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.8-h7f98852_0.tar.bz2#91d22aefa665265e8e31988b15145c8a +https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.10-h7f98852_0.tar.bz2#ffa3a757a97e851293909b49f49f28fb 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/libiconv-1.16-h516909a_0.tar.bz2#5c0f338a513a2943c659ae619fca9211 -https://conda.anaconda.org/conda-forge/linux-64/libllvm13-13.0.0-hf817b99_0.tar.bz2#b10bb2ebebfffa8800fa80ad3285719e https://conda.anaconda.org/conda-forge/linux-64/libmo_unpack-3.1.2-hf484d3e_1001.tar.bz2#95f32a6a5a666d33886ca5627239f03d https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.0-h7f98852_0.tar.bz2#39b1328babf85c7c3a61636d9cd50206 https://conda.anaconda.org/conda-forge/linux-64/libogg-1.3.4-h7f98852_1.tar.bz2#6e8cc2173440d77708196c5b93771680 @@ -47,7 +47,7 @@ https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.32.1-h7f98852_1000.tar https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.2.2-h7f98852_1.tar.bz2#46cf26ecc8775a0aab300ea1821aaa3c 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.3-h846660c_100.tar.bz2#1bb747e2de717cb9a6501d72539d6556 +https://conda.anaconda.org/conda-forge/linux-64/mpich-4.0.1-h846660c_100.tar.bz2#4b85205b094808088bb0862e08251653 https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.3-h9c3ff4c_0.tar.bz2#fb31bcb7af058244479ca635d20f0f4a https://conda.anaconda.org/conda-forge/linux-64/nspr-4.32-h9c3ff4c_1.tar.bz2#29ded371806431b0499aaee146abfc3e https://conda.anaconda.org/conda-forge/linux-64/openssl-1.1.1l-h7f98852_0.tar.bz2#de7b38a1542dbe6f41653a8ae71adc53 @@ -68,30 +68,32 @@ https://conda.anaconda.org/conda-forge/linux-64/gettext-0.19.8.1-h73d1719_1008.t https://conda.anaconda.org/conda-forge/linux-64/libblas-3.9.0-13_linux64_openblas.tar.bz2#8a4038563ed92dfa622bd72c0d8f31d3 https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.0.9-h7f98852_6.tar.bz2#c7c03a2592cac92246a13a0732bd1573 https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.0.9-h7f98852_6.tar.bz2#28bfe0a70154e6881da7bae97517c948 -https://conda.anaconda.org/conda-forge/linux-64/libclang-13.0.0-default_hc23dcda_0.tar.bz2#7b140452b5bc91e46410b84807307249 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/libevent-2.1.10-h9b69904_4.tar.bz2#390026683aef81db27ff1b8570ca1336 +https://conda.anaconda.org/conda-forge/linux-64/libllvm13-13.0.1-hf817b99_2.tar.bz2#47da3ce0d8b2e65ccb226c186dd91eba https://conda.anaconda.org/conda-forge/linux-64/libvorbis-1.3.7-h9c3ff4c_0.tar.bz2#309dec04b70a3cc0f1e84a4013683bc0 https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.13-h7f98852_1004.tar.bz2#b3653fdc58d03face9724f602218a904 https://conda.anaconda.org/conda-forge/linux-64/readline-8.1-h46c0cb4_0.tar.bz2#5788de3c8d7a7d64ac56c784c4ef48e6 +https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.12-h27826a3_0.tar.bz2#5b8c42eb62e9fc961af70bdd6a26e168 https://conda.anaconda.org/conda-forge/linux-64/udunits2-2.2.28-hc3e0081_0.tar.bz2#d4c341e0379c31e9e781d4f204726867 https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.3-hd9c2040_1000.tar.bz2#9e856f78d5c80d5a78f61e72d1d473a3 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/zstd-1.5.2-ha95c52a_0.tar.bz2#5222b231b1ef49a7f60d40b363469b70 https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.0.9-h7f98852_6.tar.bz2#9e94bf16f14c78a36561d5019f490d22 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/krb5-1.19.2-h3790be6_4.tar.bz2#dbbd32092ee31aab0f2d213e8f9f1b40 https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.9.0-13_linux64_openblas.tar.bz2#b17676dbd6688396c3a3076259fb7907 -https://conda.anaconda.org/conda-forge/linux-64/libglib-2.70.2-h174f98d_1.tar.bz2#d03a54631298fd1ab732ff65f6ed3a07 +https://conda.anaconda.org/conda-forge/linux-64/libclang-13.0.1-default_hc23dcda_0.tar.bz2#8cebb0736cba83485b13dc10d242d96d +https://conda.anaconda.org/conda-forge/linux-64/libglib-2.70.2-h174f98d_4.tar.bz2#d44314ffae96b17657fbf3f8e47b04fc https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.9.0-13_linux64_openblas.tar.bz2#018b80e8f21d8560ae4961567e3e00c9 -https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.46.0-h812cca2_0.tar.bz2#507fa47e9075f889af8e8b72925379be +https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.47.0-h727a467_0.tar.bz2#a22567abfea169ff8048506b1ca9b230 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/libtiff-4.3.0-h6f004c6_2.tar.bz2#34fda41ca84e67232888c9a885903055 +https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.3.0-h542a066_3.tar.bz2#1a0efb4dfd880b0376da8e1ba39fa838 https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.9.12-h885dcf4_1.tar.bz2#d1355eaa48f465782f228275a0a69771 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/mysql-libs-8.0.28-hfa10184_0.tar.bz2#aac17542e50a474e2e632878dc696d50 https://conda.anaconda.org/conda-forge/linux-64/sqlite-3.37.0-h9cd32fc_0.tar.bz2#eb66fc098824d25518a79e83d12a81d6 -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/xorg-libx11-1.7.2-h7f98852_0.tar.bz2#12a61e640b8894504326aadafccbb790 https://conda.anaconda.org/conda-forge/linux-64/atk-1.0-2.36.0-h3371d22_4.tar.bz2#661e1ed5d92552785d9f8c781ce68685 https://conda.anaconda.org/conda-forge/linux-64/brotli-1.0.9-h7f98852_6.tar.bz2#612385c4a83edb0619fe911d9da317f4 @@ -100,7 +102,8 @@ https://conda.anaconda.org/conda-forge/linux-64/freetype-2.10.4-h0708190_1.tar.b https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.42.6-h04a7f16_0.tar.bz2#b24a1e18325a6e8f8b6b4a2ec5860ce2 https://conda.anaconda.org/conda-forge/linux-64/gstreamer-1.18.5-h9f60fe5_3.tar.bz2#511aa83cdfcc0132380db5daf2f15f27 https://conda.anaconda.org/conda-forge/linux-64/gts-0.7.6-h64030ff_2.tar.bz2#112eb9b5b93f0c02e59aea4fd1967363 -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/libcurl-7.81.0-h2574ce0_0.tar.bz2#1f8655741d0269ca6756f131522da1e8 +https://conda.anaconda.org/conda-forge/linux-64/libpq-14.2-hd57d9b9_0.tar.bz2#91b38e297e1cc79f88f7cbf7bdb248e0 https://conda.anaconda.org/conda-forge/linux-64/libwebp-1.2.2-h3452ae3_0.tar.bz2#c363665b4aabe56aae4f8981cff5b153 https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.0.3-he3ba5ed_0.tar.bz2#f9dbabc7e01c459ed7a1d1d64b206e9b https://conda.anaconda.org/conda-forge/linux-64/nss-3.74-hb5efdd6_0.tar.bz2#136876ca50177058594f6c2944e95c40 @@ -109,28 +112,29 @@ https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.4-h7f98852_1.ta https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.10-h7f98852_1003.tar.bz2#f59c1242cc1dd93e72c2ee2b360979eb https://conda.anaconda.org/conda-forge/noarch/alabaster-0.7.12-py_0.tar.bz2#2489a97287f90176ecdc3ca982b4b0a0 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.11-pyhd8ed1ab_0.tar.bz2#e51530e33440ea8044edb0076cb40a0f +https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-2.0.12-pyhd8ed1ab_0.tar.bz2#1f5b32dabae0f1893ae3283dac7f799e https://conda.anaconda.org/conda-forge/noarch/cloudpickle-2.0.0-pyhd8ed1ab_0.tar.bz2#3a8fc8b627d5fb6af827e126a10a86c6 https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.4-pyh9f0ad1d_0.tar.bz2#c08b4c1326b880ed44f3ffb04803332f +https://conda.anaconda.org/conda-forge/linux-64/curl-7.81.0-h2574ce0_0.tar.bz2#3a95d393b490f82aa406f1892fad84d9 https://conda.anaconda.org/conda-forge/noarch/cycler-0.11.0-pyhd8ed1ab_0.tar.bz2#a50559fad0affdbb33729a68669ca1cb https://conda.anaconda.org/conda-forge/noarch/distlib-0.3.4-pyhd8ed1ab_0.tar.bz2#7b50d840543d9cdae100e91582c33035 -https://conda.anaconda.org/conda-forge/noarch/filelock-3.4.2-pyhd8ed1ab_1.tar.bz2#d3f5797d3f9625c64860c93fc4359e64 -https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.13.94-ha180cfb_0.tar.bz2#c534c5248da4913002473919d76d0161 -https://conda.anaconda.org/conda-forge/noarch/fsspec-2022.1.0-pyhd8ed1ab_0.tar.bz2#188e095f4dc38887bb48b065734b9e8d +https://conda.anaconda.org/conda-forge/noarch/filelock-3.6.0-pyhd8ed1ab_0.tar.bz2#6e03ca6c7b47a4152a2b12c6eee3bd32 +https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.13.96-ha180cfb_0.tar.bz2#d190a1c55c84ba1c9a33484a38ece029 +https://conda.anaconda.org/conda-forge/noarch/fsspec-2022.2.0-pyhd8ed1ab_0.tar.bz2#f31e31092035d427b05233ab924c7613 https://conda.anaconda.org/conda-forge/linux-64/gst-plugins-base-1.18.5-hf529b03_3.tar.bz2#524a9f1718bac53a6cf4906bcc51d044 +https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.12.1-mpi_mpich_h08b82f9_4.tar.bz2#975d5635b158c1b3c5c795f9d0a430a1 https://conda.anaconda.org/conda-forge/noarch/idna-3.3-pyhd8ed1ab_0.tar.bz2#40b50b8b030f5f2f22085c062ed013dd https://conda.anaconda.org/conda-forge/noarch/imagesize-1.3.0-pyhd8ed1ab_0.tar.bz2#be807e7606fff9436e5e700f6bffb7c6 https://conda.anaconda.org/conda-forge/noarch/iris-sample-data-2.4.0-pyhd8ed1ab_0.tar.bz2#18ee9c07cf945a33f92caf1ee3d23ad9 -https://conda.anaconda.org/conda-forge/linux-64/libcurl-7.81.0-h2574ce0_0.tar.bz2#1f8655741d0269ca6756f131522da1e8 -https://conda.anaconda.org/conda-forge/linux-64/libpq-14.1-hd57d9b9_1.tar.bz2#a7024916bfdf33a014a0cc803580c9a1 https://conda.anaconda.org/conda-forge/noarch/locket-0.2.0-py_2.tar.bz2#709e8671651c7ec3d1ad07800339ff1d https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyh9f0ad1d_0.tar.bz2#2ba8498c1018c1e9c61eb99b973dfe19 https://conda.anaconda.org/conda-forge/noarch/nose-1.3.7-py_1006.tar.bz2#382019d5f8e9362ef6f60a8d4e7bce8f https://conda.anaconda.org/conda-forge/noarch/olefile-0.46-pyh9f0ad1d_1.tar.bz2#0b2e68acc8c78c8cc392b90983481f58 -https://conda.anaconda.org/conda-forge/noarch/platformdirs-2.3.0-pyhd8ed1ab_0.tar.bz2#7bc119135be2a43e1701432399d8c28a +https://conda.anaconda.org/conda-forge/noarch/platformdirs-2.5.1-pyhd8ed1ab_0.tar.bz2#d5df87964a39f67c46a5448f4e78d9b6 +https://conda.anaconda.org/conda-forge/linux-64/proj-8.2.1-h277dcde_0.tar.bz2#f2ceb1be6565c35e2db0ac948754751d https://conda.anaconda.org/conda-forge/noarch/pycparser-2.21-pyhd8ed1ab_0.tar.bz2#076becd9e05608f8dc72757d5f3a91ff https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.0.7-pyhd8ed1ab_0.tar.bz2#727e2216d9c47455d8ddc060eb2caad9 -https://conda.anaconda.org/conda-forge/noarch/pyshp-2.1.3-pyh44b312d_0.tar.bz2#2d1867b980785eb44b8122184d8b42a6 +https://conda.anaconda.org/conda-forge/noarch/pyshp-2.2.0-pyhd8ed1ab_0.tar.bz2#2aa546be05be34b8e1744afd327b623f https://conda.anaconda.org/conda-forge/linux-64/python_abi-3.8-2_cp38.tar.bz2#bfbb29d517281e78ac53e48d21e6e860 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 @@ -149,75 +153,72 @@ https://conda.anaconda.org/conda-forge/noarch/babel-2.9.1-pyh44b312d_0.tar.bz2#7 https://conda.anaconda.org/conda-forge/linux-64/cairo-1.16.0-ha00ac49_1009.tar.bz2#d1dff57b8731c245d3247b46d002e1c9 https://conda.anaconda.org/conda-forge/linux-64/certifi-2021.10.8-py38h578d9bd_1.tar.bz2#52a6cee65a5d10ed1c3f0af24fb48dd3 https://conda.anaconda.org/conda-forge/linux-64/cffi-1.15.0-py38h3931269_0.tar.bz2#9c491a90ae11d08ca97326a0ed876f3a -https://conda.anaconda.org/conda-forge/linux-64/curl-7.81.0-h2574ce0_0.tar.bz2#3a95d393b490f82aa406f1892fad84d9 https://conda.anaconda.org/conda-forge/linux-64/docutils-0.16-py38h578d9bd_3.tar.bz2#a7866449fb9e5e4008a02df276549d34 -https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.12.1-mpi_mpich_h9c45103_3.tar.bz2#4f1a733e563d27b98010b62888e149c9 -https://conda.anaconda.org/conda-forge/linux-64/importlib-metadata-4.10.1-py38h578d9bd_0.tar.bz2#26da12e39b1b93e82fb865e967d0cbe0 +https://conda.anaconda.org/conda-forge/linux-64/importlib-metadata-4.11.2-py38h578d9bd_0.tar.bz2#0c1ffd6807cbf6c15456c49ca9baa668 https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.3.2-py38h1fd1430_1.tar.bz2#085365abfe53d5d13bb68b1dda0b439e https://conda.anaconda.org/conda-forge/linux-64/libgd-2.3.3-h3cfcdeb_1.tar.bz2#37d7568c595f0cfcd0c493f5ca0344ab -https://conda.anaconda.org/conda-forge/linux-64/markupsafe-2.0.1-py38h497a2fe_1.tar.bz2#1ef7b5f4826ca48a15e2cd98a5c3436d +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/markupsafe-2.1.0-py38h0a891b7_1.tar.bz2#60eff55f2a845f35e58bd0be235fe4b7 https://conda.anaconda.org/conda-forge/linux-64/mpi4py-3.1.3-py38he865349_0.tar.bz2#b1b3d6847a68251a1465206ab466b475 -https://conda.anaconda.org/conda-forge/linux-64/numpy-1.22.2-py38h6ae9a64_0.tar.bz2#065a900932f904e0182acfcfadc467e3 +https://conda.anaconda.org/conda-forge/linux-64/numpy-1.22.3-py38h05e7239_0.tar.bz2#90b4ee61abb81fb3f3995ec9d4c734f0 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-6.2.1-py38hd70f55b_1.tar.bz2#80d719bee2b77a106b199150c0829107 https://conda.anaconda.org/conda-forge/noarch/pockets-0.9.1-py_0.tar.bz2#1b52f0c42e8077e5a33e00fe72269364 -https://conda.anaconda.org/conda-forge/linux-64/proj-8.2.1-h277dcde_0.tar.bz2#f2ceb1be6565c35e2db0ac948754751d https://conda.anaconda.org/conda-forge/linux-64/pyqt5-sip-4.19.18-py38h709712a_8.tar.bz2#11b72f5b1cc15427c89232321172a0bc https://conda.anaconda.org/conda-forge/linux-64/pysocks-1.7.1-py38h578d9bd_4.tar.bz2#9c4bbee6f682f2fc7d7803df3996e77e 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-py38h497a2fe_1.tar.bz2#977d03222271270ea8fe35388bf13752 +https://conda.anaconda.org/conda-forge/linux-64/python-xxhash-3.0.0-py38h0a891b7_0.tar.bz2#12eaa8cbfedfbf7879e5653467b03c94 https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0-py38h497a2fe_3.tar.bz2#131de7d638aa59fb8afbce59f1a8aa98 https://conda.anaconda.org/conda-forge/linux-64/qt-5.12.9-ha98a1a1_5.tar.bz2#9b27fa0b1044a2119fb1b290617fe06f -https://conda.anaconda.org/conda-forge/linux-64/setuptools-60.7.1-py38h578d9bd_0.tar.bz2#8bf9c51a7e371df1673de909c1f46e6c +https://conda.anaconda.org/conda-forge/linux-64/setuptools-60.9.3-py38h578d9bd_0.tar.bz2#864b832ea94d9c0b37ddfbbb8adb42f1 https://conda.anaconda.org/conda-forge/linux-64/tornado-6.1-py38h497a2fe_2.tar.bz2#63b3b55c98b4239134e0be080f448944 https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-14.0.0-py38h497a2fe_0.tar.bz2#8da7787169411910df2a62dc8ef533e0 -https://conda.anaconda.org/conda-forge/linux-64/virtualenv-20.13.0-py38h578d9bd_0.tar.bz2#561081f4a30990533541979c9ee84732 +https://conda.anaconda.org/conda-forge/linux-64/virtualenv-20.13.3-py38h578d9bd_0.tar.bz2#4f2dd671de7a8666acdc51a9dd6d4324 https://conda.anaconda.org/conda-forge/linux-64/brotlipy-0.7.0-py38h497a2fe_1003.tar.bz2#9189b42c42b9c87b2b2068cbe31901a8 -https://conda.anaconda.org/conda-forge/linux-64/cftime-1.5.2-py38h6c62de6_0.tar.bz2#73892e60ccea826c7f7a2215e48d22cf +https://conda.anaconda.org/conda-forge/linux-64/cftime-1.6.0-py38h3ec907f_0.tar.bz2#35411e5fc8dd523f9e68316847e6a25b https://conda.anaconda.org/conda-forge/linux-64/cryptography-36.0.1-py38h3e25421_0.tar.bz2#acc14d0d71dbf74f6a15f2456951b6cf -https://conda.anaconda.org/conda-forge/noarch/dask-core-2022.1.1-pyhd8ed1ab_0.tar.bz2#7968db84df10b74d9792d66d7da216df +https://conda.anaconda.org/conda-forge/noarch/dask-core-2022.2.1-pyhd8ed1ab_0.tar.bz2#0cb751f07e68fda1d631a02faa66f0de https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.29.1-py38h497a2fe_0.tar.bz2#121e02be214af4980911bb2cbd5b2742 -https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-3.3.1-hb4a5f5f_0.tar.bz2#abe529a4b140720078f0febe1b6014a4 +https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-3.4.0-hb4a5f5f_0.tar.bz2#42190c4597593e9742513d7b39b02c49 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/mo_pack-0.2.0-py38h6c62de6_1006.tar.bz2#829b1209dfadd431a11048d6eeaf5bef +https://conda.anaconda.org/conda-forge/linux-64/netcdf-fortran-4.5.4-mpi_mpich_h1364a43_0.tar.bz2#b6ba4f487ef9fd5d353ff277df06d133 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.4.0-py38h43a58ef_0.tar.bz2#23427f52c81076594a95c006ebf7552e -https://conda.anaconda.org/conda-forge/noarch/pip-22.0.3-pyhd8ed1ab_0.tar.bz2#45dedae69a0ea21cb8566d04b2ca5536 +https://conda.anaconda.org/conda-forge/linux-64/pandas-1.4.1-py38h43a58ef_0.tar.bz2#1083ebe2edc30e4fb9568d1f66e3588b +https://conda.anaconda.org/conda-forge/noarch/pip-22.0.4-pyhd8ed1ab_0.tar.bz2#b1239ce8ef2a1eec485c398a683c5bff https://conda.anaconda.org/conda-forge/noarch/pygments-2.11.2-pyhd8ed1ab_0.tar.bz2#caef60540e2239e27bf62569a5015e3b https://conda.anaconda.org/conda-forge/linux-64/pyproj-3.3.0-py38h5383654_1.tar.bz2#5b600e019fa7c33be73bdb626236936b https://conda.anaconda.org/conda-forge/linux-64/pyqt-impl-5.12.3-py38h0ffb2e6_8.tar.bz2#acfc7625a212c27f7decdca86fdb2aba https://conda.anaconda.org/conda-forge/linux-64/python-stratify-0.2.post0-py38h6c62de6_1.tar.bz2#a350e3f4ca899e95122f66806e048858 https://conda.anaconda.org/conda-forge/linux-64/pywavelets-1.2.0-py38h6c62de6_1.tar.bz2#2953d3fc0113fc6ffb955a5b72811fb0 -https://conda.anaconda.org/conda-forge/linux-64/scipy-1.7.3-py38h56a6a73_0.tar.bz2#2d318049369bb52d2687b0ac2be82751 +https://conda.anaconda.org/conda-forge/linux-64/scipy-1.8.0-py38h56a6a73_1.tar.bz2#86073932d9e675c5929376f6f8b79b97 https://conda.anaconda.org/conda-forge/linux-64/shapely-1.8.0-py38h596eeab_5.tar.bz2#ec3b783081e14a9dc0eb5ce609649728 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-napoleon-0.7-py_0.tar.bz2#0bc25ff6f2e34af63ded59692df5f749 https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.0.1-py38h1fd1430_1.tar.bz2#c494f75082f9c052944fda1b22c83336 https://conda.anaconda.org/conda-forge/linux-64/cf-units-3.0.1-py38h6c62de6_2.tar.bz2#350322b046c129e5802b79358a1343f7 -https://conda.anaconda.org/conda-forge/noarch/identify-2.4.8-pyhd8ed1ab_0.tar.bz2#d4d25c0b7c1a7a1b0442e061fdd49260 +https://conda.anaconda.org/conda-forge/linux-64/esmf-8.2.0-mpi_mpich_h4975321_100.tar.bz2#56f5c650937b1667ad0a557a0dff3bc4 +https://conda.anaconda.org/conda-forge/noarch/identify-2.4.11-pyhd8ed1ab_0.tar.bz2#979d7dfda4d04702391e80158c322039 https://conda.anaconda.org/conda-forge/noarch/imagehash-4.2.1-pyhd8ed1ab_0.tar.bz2#01cc8698b6e1a124dc4f585516c27643 https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.5.1-py38hf4fb855_0.tar.bz2#47cf0cab2ae368e1062e75cfbc4277af -https://conda.anaconda.org/conda-forge/linux-64/netcdf-fortran-4.5.4-mpi_mpich_h1364a43_0.tar.bz2#b6ba4f487ef9fd5d353ff277df06d133 https://conda.anaconda.org/conda-forge/linux-64/netcdf4-1.5.8-nompi_py38h2823cc8_101.tar.bz2#1dfe1cdee4532c72f893955259eb3de9 -https://conda.anaconda.org/conda-forge/linux-64/pango-1.50.3-h9967ed3_0.tar.bz2#37f1c68380bc5dfe0f5bb2655e207a73 +https://conda.anaconda.org/conda-forge/linux-64/pango-1.50.5-h4dcc4a0_0.tar.bz2#56ce3e3bec0d5c9e6db22083a3ef5e13 https://conda.anaconda.org/conda-forge/noarch/pyopenssl-22.0.0-pyhd8ed1ab_0.tar.bz2#1d7e241dfaf5475e893d4b824bb71b44 https://conda.anaconda.org/conda-forge/linux-64/pyqtchart-5.12-py38h7400c14_8.tar.bz2#78a2a6cb4ef31f997c1bee8223a9e579 https://conda.anaconda.org/conda-forge/linux-64/pyqtwebengine-5.12.1-py38h7400c14_8.tar.bz2#857894ea9c5e53c962c3a0932efa71ea https://conda.anaconda.org/conda-forge/linux-64/cartopy-0.20.2-py38ha217159_3.tar.bz2#d7461e191f7a0522e4709612786bdf4e -https://conda.anaconda.org/conda-forge/linux-64/esmf-8.2.0-mpi_mpich_h4975321_100.tar.bz2#56f5c650937b1667ad0a557a0dff3bc4 +https://conda.anaconda.org/conda-forge/linux-64/esmpy-8.2.0-mpi_mpich_py38h9147699_101.tar.bz2#5a9de1dec507b6614150a77d1aabf257 https://conda.anaconda.org/conda-forge/linux-64/gtk2-2.24.33-h90689f9_2.tar.bz2#957a0255ab58aaf394a91725d73ab422 https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.52.5-h0a9e6e8_2.tar.bz2#aa768fdaad03509a97df37f81163346b https://conda.anaconda.org/conda-forge/noarch/nc-time-axis-1.4.0-pyhd8ed1ab_0.tar.bz2#9113b4e4fa2fa4a7f129c71a6f319475 https://conda.anaconda.org/conda-forge/linux-64/pre-commit-2.17.0-py38h578d9bd_0.tar.bz2#839ac9dba9a6126c9532781a9ea4506b https://conda.anaconda.org/conda-forge/linux-64/pyqt-5.12.3-py38h578d9bd_8.tar.bz2#88368a5889f31dff922a2d57bbfc3f5b https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.8-pyhd8ed1ab_1.tar.bz2#53f1387c68c21cecb386e2cde51b3f7c -https://conda.anaconda.org/conda-forge/linux-64/esmpy-8.2.0-mpi_mpich_py38h9147699_101.tar.bz2#5a9de1dec507b6614150a77d1aabf257 -https://conda.anaconda.org/conda-forge/linux-64/graphviz-2.50.0-h8e749b2_2.tar.bz2#8c20fd968c8b6af73444b1199d5fb0cb +https://conda.anaconda.org/conda-forge/linux-64/graphviz-3.0.0-h5abf519_0.tar.bz2#e5521af56c6e927397ca9851eecb2f48 https://conda.anaconda.org/conda-forge/linux-64/matplotlib-3.5.1-py38h578d9bd_0.tar.bz2#0d78be9cf1c400ba8e3077cf060492f1 https://conda.anaconda.org/conda-forge/noarch/requests-2.27.1-pyhd8ed1ab_0.tar.bz2#7c1c427246b057b8fa97200ecdb2ed62 https://conda.anaconda.org/conda-forge/noarch/sphinx-4.4.0-pyh6c4a22f_1.tar.bz2#a9025d14c2a609e0d895ad3e75b5369c -https://conda.anaconda.org/conda-forge/noarch/sphinx-copybutton-0.4.0-pyhd8ed1ab_0.tar.bz2#80fd2cc25ad45911b4e42d5b91593e2f +https://conda.anaconda.org/conda-forge/noarch/sphinx-copybutton-0.5.0-pyhd8ed1ab_0.tar.bz2#4c969cdd5191306c269490f7ff236d9c https://conda.anaconda.org/conda-forge/noarch/sphinx-gallery-0.10.1-pyhd8ed1ab_0.tar.bz2#4918585fe5e5341740f7e63c61743efb https://conda.anaconda.org/conda-forge/noarch/sphinx-panels-0.6.0-pyhd8ed1ab_0.tar.bz2#6eec6480601f5d15babf9c3b3987f34a https://conda.anaconda.org/conda-forge/noarch/sphinx_rtd_theme-1.0.0-pyhd8ed1ab_0.tar.bz2#9f633f2f2869184e31acfeae95b24345 From 45f188bb45d96dfdd064020c6cbd949b0807198c Mon Sep 17 00:00:00 2001 From: Martin Yeo <40734014+trexfeathers@users.noreply.github.com> Date: Wed, 9 Mar 2022 16:01:35 +0000 Subject: [PATCH 25/28] Overnight benchmarks - find a valid issue assignee (#4627) * Overnight benchmarks find a valid issue assignee. * Overnight benchmarks safer check for a valid issue assignee. --- .github/workflows/benchmark.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 6b155fc8bc..04e26686ea 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -81,7 +81,16 @@ jobs: do commit="${commit_file%.*}" pr_number=$(git log "$commit"^! --oneline | grep -o "#[0-9]*" | tail -1 | cut -c 2-) - assignee=$(gh pr view $pr_number --json author -q '.["author"]["login"]' --repo $GITHUB_REPOSITORY) + author=$(gh pr view $pr_number --json author -q '.["author"]["login"]' --repo $GITHUB_REPOSITORY) + merger=$(gh pr view $pr_number --json mergedBy -q '.["mergedBy"]["login"]' --repo $GITHUB_REPOSITORY) + # Find a valid assignee from author/merger/nothing. + if curl -s https://api.github.com/users/$author | grep -q "login"; then + assignee=$author + elif curl -s https://api.github.com/users/$merger | grep -q "login"; then + assignee=$merger + else + assignee="" + fi title="Performance Shift(s): \`$commit\`" body=" Benchmark comparison has identified performance shifts at commit \ From 63775948fac6fa5153610fdaefa9c62f9aba12b6 Mon Sep 17 00:00:00 2001 From: tkknight <2108488+tkknight@users.noreply.github.com> Date: Thu, 10 Mar 2022 07:38:43 +0000 Subject: [PATCH 26/28] Votable Issues (#4617) * Create test2.yml * gha test * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * Update refresh-votable-issues.yml * initial * tidy * updated whatsnew * removed defunct ext links * Update docs/src/votable_issues.rst Co-authored-by: Ruth Comer <10599679+rcomer@users.noreply.github.com> * added suggestion to subscribe to voted issues. * Renamed votable to voted. Layout tweaks to the voted issues page. Co-authored-by: Ruth Comer <10599679+rcomer@users.noreply.github.com> --- docs/src/common_links.inc | 1 + docs/src/conf.py | 9 +++++++ docs/src/index.rst | 1 + docs/src/voted_issues.rst | 55 +++++++++++++++++++++++++++++++++++++++ docs/src/whatsnew/dev.rst | 3 ++- 5 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 docs/src/voted_issues.rst diff --git a/docs/src/common_links.inc b/docs/src/common_links.inc index 67fc493e3e..ce7f498d80 100644 --- a/docs/src/common_links.inc +++ b/docs/src/common_links.inc @@ -38,6 +38,7 @@ .. _using git: https://docs.github.com/en/github/using-git .. _requirements/ci/: https://github.com/SciTools/iris/tree/main/requirements/ci .. _CF-UGRID: https://ugrid-conventions.github.io/ugrid-conventions/ +.. _issues on GitHub: https://github.com/SciTools/iris/issues?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc .. comment diff --git a/docs/src/conf.py b/docs/src/conf.py index db2cdc3633..9c379ea730 100644 --- a/docs/src/conf.py +++ b/docs/src/conf.py @@ -302,6 +302,15 @@ def _dotv(version): html_static_path = ["_static"] html_style = "theme_override.css" +# this allows for using datatables: https://datatables.net/ +html_css_files = [ + "https://cdn.datatables.net/1.10.23/css/jquery.dataTables.min.css", +] + +html_js_files = [ + "https://cdn.datatables.net/1.10.23/js/jquery.dataTables.min.js", +] + # url link checker. Some links work but report as broken, lets ignore them. # See https://www.sphinx-doc.org/en/1.2/config.html#options-for-the-linkcheck-builder linkcheck_ignore = [ diff --git a/docs/src/index.rst b/docs/src/index.rst index e6a787a220..d247b93411 100644 --- a/docs/src/index.rst +++ b/docs/src/index.rst @@ -165,3 +165,4 @@ For **Iris 2.4** and earlier documentation please see the generated/api/iris techpapers/index copyright + voted_issues diff --git a/docs/src/voted_issues.rst b/docs/src/voted_issues.rst new file mode 100644 index 0000000000..edc1c860a2 --- /dev/null +++ b/docs/src/voted_issues.rst @@ -0,0 +1,55 @@ +.. include:: common_links.inc + +.. _voted_issues: + +Voted Issues +============ + +You can help us to prioritise development of new features by leaving a 👍 +reaction on the header (not subsequent comments) of any issue. + +.. tip:: We suggest you subscribe to the issue so you will be updated. + When viewing the issue there is a **Notifications** + section where you can select to subscribe. + +Below is a sorted table of all issues that have 1 or more 👍 from our github +project. Please note that there is more development activity than what is on +the below table. + +.. _voted-issues.json: https://github.com/scitools/voted_issues/blob/main/voted-issues.json + +.. raw:: html + + + + + + + + + + +
👍IssueAuthorTitle
+ + + + +

+ +.. note:: The data in this table is updated daily and is sourced from + `voted-issues.json`_. + For the latest data please see the `issues on GitHub`_. + Note that the list on Github does not show the number of votes 👍 + only the total number of comments for the whole issue. \ No newline at end of file diff --git a/docs/src/whatsnew/dev.rst b/docs/src/whatsnew/dev.rst index 5cc0769c57..a9e960c173 100644 --- a/docs/src/whatsnew/dev.rst +++ b/docs/src/whatsnew/dev.rst @@ -75,7 +75,8 @@ This document explains the changes made to Iris for this release 📚 Documentation ================ -#. N/A +#. `@tkknight`_ added a page to show the issues that have been voted for. See + :ref:`voted_issues`. (:issue:`3307`, :pull:`4617`) 💼 Internal From c27f524e257992c3a9e9d8bb1e25bacbe85afdaf Mon Sep 17 00:00:00 2001 From: Martin Yeo <40734014+trexfeathers@users.noreply.github.com> Date: Thu, 10 Mar 2022 16:55:08 +0000 Subject: [PATCH 27/28] Sperf & Cperf Benchmarks (#4621) --- benchmarks/README.md | 23 +- benchmarks/asv.conf.json | 1 + benchmarks/benchmarks/__init__.py | 61 ++++- benchmarks/benchmarks/cperf/__init__.py | 97 +++++++ benchmarks/benchmarks/cperf/equality.py | 58 ++++ benchmarks/benchmarks/cperf/load.py | 57 ++++ benchmarks/benchmarks/cperf/save.py | 47 ++++ .../{ugrid.py => ugrid/__init__.py} | 4 +- .../ugrid}/regions_combine.py | 41 +-- .../benchmarks/generate_data/__init__.py | 80 ------ benchmarks/benchmarks/generate_data/stock.py | 68 +++-- benchmarks/benchmarks/generate_data/ugrid.py | 195 ++++++++++++++ .../{loading.py => load/__init__.py} | 4 +- .../{ugrid_load.py => load/ugrid.py} | 2 +- .../benchmarks/{netcdf_save.py => save.py} | 21 +- benchmarks/benchmarks/sperf/__init__.py | 39 +++ .../benchmarks/sperf/combine_regions.py | 250 ++++++++++++++++++ benchmarks/benchmarks/sperf/equality.py | 36 +++ benchmarks/benchmarks/sperf/load.py | 32 +++ benchmarks/benchmarks/sperf/save.py | 56 ++++ .../asv_example_images/commits.png | Bin 0 -> 166632 bytes .../asv_example_images/comparison.png | Bin 0 -> 17405 bytes .../asv_example_images/scalability.png | Bin 0 -> 46256 bytes .../contributing_benchmarks.rst | 62 +++++ .../contributing_testing_index.rst | 1 + docs/src/whatsnew/dev.rst | 5 +- noxfile.py | 56 +++- 27 files changed, 1141 insertions(+), 155 deletions(-) create mode 100644 benchmarks/benchmarks/cperf/__init__.py create mode 100644 benchmarks/benchmarks/cperf/equality.py create mode 100644 benchmarks/benchmarks/cperf/load.py create mode 100644 benchmarks/benchmarks/cperf/save.py rename benchmarks/benchmarks/experimental/{ugrid.py => ugrid/__init__.py} (98%) rename benchmarks/benchmarks/{ => experimental/ugrid}/regions_combine.py (90%) create mode 100644 benchmarks/benchmarks/generate_data/ugrid.py rename benchmarks/benchmarks/{loading.py => load/__init__.py} (97%) rename benchmarks/benchmarks/{ugrid_load.py => load/ugrid.py} (98%) rename benchmarks/benchmarks/{netcdf_save.py => save.py} (78%) create mode 100644 benchmarks/benchmarks/sperf/__init__.py create mode 100644 benchmarks/benchmarks/sperf/combine_regions.py create mode 100644 benchmarks/benchmarks/sperf/equality.py create mode 100644 benchmarks/benchmarks/sperf/load.py create mode 100644 benchmarks/benchmarks/sperf/save.py create mode 100644 docs/src/developers_guide/asv_example_images/commits.png create mode 100644 docs/src/developers_guide/asv_example_images/comparison.png create mode 100644 docs/src/developers_guide/asv_example_images/scalability.png create mode 100644 docs/src/developers_guide/contributing_benchmarks.rst diff --git a/benchmarks/README.md b/benchmarks/README.md index baa1afe700..6ea53c3ae8 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -21,13 +21,20 @@ automated overnight run locally. See the session docstring for detail. ### Environment variables -* ``DATA_GEN_PYTHON`` - required - path to a Python executable that can be +* `OVERRIDE_TEST_DATA_REPOSITORY` - required - some benchmarks use +`iris-test-data` content, and your local `site.cfg` is not available for +benchmark scripts. +* `DATA_GEN_PYTHON` - required - path to a Python executable that can be used to generate benchmark test objects/files; see [Data generation](#data-generation). The Nox session sets this automatically, but will defer to any value already set in the shell. -* ``BENCHMARK_DATA`` - optional - path to a directory for benchmark synthetic +* `BENCHMARK_DATA` - optional - path to a directory for benchmark synthetic test data, which the benchmark scripts will create if it doesn't already -exist. Defaults to ``/benchmarks/.data/`` if not set. +exist. Defaults to `/benchmarks/.data/` if not set. +* `ON_DEMAND_BENCHMARKS` - optional - when set (to any value): benchmarks +decorated with `@on_demand_benchmark` are included in the ASV run. Usually +coupled with the ASV `--bench` argument to only run the benchmark(s) of +interest. Is set during the Nox `cperf` and `sperf` sessions. ## Writing benchmarks @@ -65,6 +72,16 @@ be significantly larger (e.g. a 1000x1000 `Cube`). Performance differences might only be seen for the larger value, or the smaller, or both, getting you closer to the root cause. +### On-demand benchmarks + +Some benchmarks provide useful insight but are inappropriate to be included in +a benchmark run by default, e.g. those with long run-times or requiring a local +file. These benchmarks should be decorated with `@on_demand_benchmark` +(see [benchmarks init](./benchmarks/__init__.py)), which +sets the benchmark to only be included in a run when the `ON_DEMAND_BENCHMARKS` +environment variable is set. Examples include the CPerf and SPerf benchmark +suites for the UK Met Office NG-VAT project. + ## Benchmark environments We have disabled ASV's standard environment management, instead using an diff --git a/benchmarks/asv.conf.json b/benchmarks/asv.conf.json index 3468b2fca9..7337eaa8c7 100644 --- a/benchmarks/asv.conf.json +++ b/benchmarks/asv.conf.json @@ -5,6 +5,7 @@ "repo": "..", "environment_type": "conda-delegated", "show_commit_url": "http://github.com/scitools/iris/commit/", + "branches": ["upstream/main"], "benchmark_dir": "./benchmarks", "env_dir": ".asv/env", diff --git a/benchmarks/benchmarks/__init__.py b/benchmarks/benchmarks/__init__.py index 38502c9306..765eb2195d 100644 --- a/benchmarks/benchmarks/__init__.py +++ b/benchmarks/benchmarks/__init__.py @@ -4,10 +4,10 @@ # See COPYING and COPYING.LESSER in the root of the repository for full # licensing details. """Common code for benchmarks.""" +from functools import wraps +from os import environ import resource -from .generate_data import BENCHMARK_DATA, run_function_elsewhere - ARTIFICIAL_DIM_SIZE = int(10e3) # For all artificial cubes, coords etc. @@ -70,3 +70,60 @@ def __exit__(self, *_): def addedmem_mb(self): """Return measured memory growth, in Mb.""" return self.mb_after - self.mb_before + + @staticmethod + def decorator(changed_params: list = None): + """ + Decorates this benchmark to track growth in resident memory during execution. + + Intended for use on ASV ``track_`` benchmarks. Applies the + :class:`TrackAddedMemoryAllocation` context manager to the benchmark + code, sets the benchmark ``unit`` attribute to ``Mb``. Optionally + replaces the benchmark ``params`` attribute with ``changed_params`` - + useful to avoid testing very small memory volumes, where the results + are vulnerable to noise. + + Parameters + ---------- + changed_params : list + Replace the benchmark's ``params`` attribute with this list. + + """ + if changed_params: + # Must make a copy for re-use safety! + _changed_params = list(changed_params) + else: + _changed_params = None + + def _inner_decorator(decorated_func): + @wraps(decorated_func) + def _inner_func(*args, **kwargs): + assert decorated_func.__name__[:6] == "track_" + # Run the decorated benchmark within the added memory context manager. + with TrackAddedMemoryAllocation() as mb: + decorated_func(*args, **kwargs) + return mb.addedmem_mb() + + if _changed_params: + # Replace the params if replacement provided. + _inner_func.params = _changed_params + _inner_func.unit = "Mb" + return _inner_func + + return _inner_decorator + + +def on_demand_benchmark(benchmark_object): + """ + Decorator. Disables these benchmark(s) unless ON_DEMAND_BENCHARKS env var is set. + + For benchmarks that, for whatever reason, should not be run by default. + E.g: + * Require a local file + * Used for scalability analysis instead of commit monitoring. + + Can be applied to benchmark classes/methods/functions. + + """ + if "ON_DEMAND_BENCHMARKS" in environ: + return benchmark_object diff --git a/benchmarks/benchmarks/cperf/__init__.py b/benchmarks/benchmarks/cperf/__init__.py new file mode 100644 index 0000000000..fb311c44dc --- /dev/null +++ b/benchmarks/benchmarks/cperf/__init__.py @@ -0,0 +1,97 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Benchmarks for the CPerf scheme of the UK Met Office's NG-VAT project. + +CPerf = comparing performance working with data in UM versus LFRic formats. + +Files available from the UK Met Office: + moo ls moose:/adhoc/projects/avd/asv/data_for_nightly_tests/ +""" +import numpy as np + +from iris import load_cube + +# TODO: remove uses of PARSE_UGRID_ON_LOAD once UGRID parsing is core behaviour. +from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD + +from ..generate_data import BENCHMARK_DATA +from ..generate_data.ugrid import make_cubesphere_testfile + +# The data of the core test UM files has dtype=np.float32 shape=(1920, 2560) +_UM_DIMS_YX = (1920, 2560) +# The closest cubesphere size in terms of datapoints is sqrt(1920*2560 / 6) +# This gives ~= 905, i.e. "C905" +_N_CUBESPHERE_UM_EQUIVALENT = int(np.sqrt(np.prod(_UM_DIMS_YX) / 6)) + + +class SingleDiagnosticMixin: + """For use in any benchmark classes that work on a single diagnostic file.""" + + params = [ + ["LFRic", "UM", "UM_lbpack0", "UM_netcdf"], + [False, True], + [False, True], + ] + param_names = ["file type", "height dim (len 71)", "time dim (len 3)"] + + def setup(self, file_type, three_d, three_times): + if file_type == "LFRic": + # Generate an appropriate synthetic LFRic file. + if three_times: + n_times = 3 + else: + n_times = 1 + + # Use a cubesphere size ~equivalent to our UM test data. + cells_per_panel_edge = _N_CUBESPHERE_UM_EQUIVALENT + create_kwargs = dict(c_size=cells_per_panel_edge, n_times=n_times) + + if three_d: + create_kwargs["n_levels"] = 71 + + # Will re-use a file if already present. + file_path = make_cubesphere_testfile(**create_kwargs) + + else: + # Locate the appropriate UM file. + if three_times: + # pa/pb003 files + numeric = "003" + else: + # pa/pb000 files + numeric = "000" + + if three_d: + # theta diagnostic, N1280 file w/ 71 levels (1920, 2560, 71) + file_name = f"umglaa_pb{numeric}-theta" + else: + # surface_temp diagnostic, N1280 file (1920, 2560) + file_name = f"umglaa_pa{numeric}-surfacetemp" + + file_suffices = { + "UM": "", # packed FF (WGDOS lbpack = 1) + "UM_lbpack0": ".uncompressed", # unpacked FF (lbpack = 0) + "UM_netcdf": ".nc", # UM file -> Iris -> NetCDF file + } + suffix = file_suffices[file_type] + + file_path = (BENCHMARK_DATA / file_name).with_suffix(suffix) + if not file_path.exists(): + message = "\n".join( + [ + f"Expected local file not found: {file_path}", + "Available from the UK Met Office.", + ] + ) + raise FileNotFoundError(message) + + self.file_path = file_path + self.file_type = file_type + + def load(self): + with PARSE_UGRID_ON_LOAD.context(): + return load_cube(str(self.file_path)) diff --git a/benchmarks/benchmarks/cperf/equality.py b/benchmarks/benchmarks/cperf/equality.py new file mode 100644 index 0000000000..47eb255513 --- /dev/null +++ b/benchmarks/benchmarks/cperf/equality.py @@ -0,0 +1,58 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Equality benchmarks for the CPerf scheme of the UK Met Office's NG-VAT project. +""" +from . import SingleDiagnosticMixin +from .. import on_demand_benchmark + + +class EqualityMixin(SingleDiagnosticMixin): + """ + Uses :class:`SingleDiagnosticMixin` as the realistic case will be comparing + :class:`~iris.cube.Cube`\\ s that have been loaded from file. + """ + + # Cut down the parent parameters. + params = [["LFRic", "UM"]] + + def setup(self, file_type, three_d=False, three_times=False): + super().setup(file_type, three_d, three_times) + self.cube = self.load() + self.other_cube = self.load() + + +@on_demand_benchmark +class CubeEquality(EqualityMixin): + """ + Benchmark time and memory costs of comparing LFRic and UM + :class:`~iris.cube.Cube`\\ s. + """ + + def _comparison(self): + _ = self.cube == self.other_cube + + def peakmem_eq(self, file_type): + self._comparison() + + def time_eq(self, file_type): + self._comparison() + + +@on_demand_benchmark +class MeshEquality(EqualityMixin): + """Provides extra context for :class:`CubeEquality`.""" + + params = [["LFRic"]] + + def _comparison(self): + _ = self.cube.mesh == self.other_cube.mesh + + def peakmem_eq(self, file_type): + self._comparison() + + def time_eq(self, file_type): + self._comparison() diff --git a/benchmarks/benchmarks/cperf/load.py b/benchmarks/benchmarks/cperf/load.py new file mode 100644 index 0000000000..04bb7e1a61 --- /dev/null +++ b/benchmarks/benchmarks/cperf/load.py @@ -0,0 +1,57 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +File loading benchmarks for the CPerf scheme of the UK Met Office's NG-VAT project. +""" +from . import SingleDiagnosticMixin +from .. import on_demand_benchmark + + +@on_demand_benchmark +class SingleDiagnosticLoad(SingleDiagnosticMixin): + def time_load(self, _, __, ___): + """ + The 'real world comparison' + * UM coords are always realised (DimCoords). + * LFRic coords are not realised by default (MeshCoords). + + """ + cube = self.load() + assert cube.has_lazy_data() + # UM files load lon/lat as DimCoords, which are always realised. + expecting_lazy_coords = self.file_type == "LFRic" + for coord_name in "longitude", "latitude": + coord = cube.coord(coord_name) + assert coord.has_lazy_points() == expecting_lazy_coords + assert coord.has_lazy_bounds() == expecting_lazy_coords + + def time_load_w_realised_coords(self, _, __, ___): + """A valuable extra comparison where both UM and LFRic coords are realised.""" + cube = self.load() + for coord_name in "longitude", "latitude": + coord = cube.coord(coord_name) + # Don't touch actual points/bounds objects - permanent + # realisation plays badly with ASV's re-run strategy. + if coord.has_lazy_points(): + coord.core_points().compute() + if coord.has_lazy_bounds(): + coord.core_bounds().compute() + + +@on_demand_benchmark +class SingleDiagnosticRealise(SingleDiagnosticMixin): + # The larger files take a long time to realise. + timeout = 600.0 + + def setup(self, file_type, three_d, three_times): + super().setup(file_type, three_d, three_times) + self.loaded_cube = self.load() + + def time_realise(self, _, __, ___): + # Don't touch loaded_cube.data - permanent realisation plays badly with + # ASV's re-run strategy. + assert self.loaded_cube.has_lazy_data() + self.loaded_cube.core_data().compute() diff --git a/benchmarks/benchmarks/cperf/save.py b/benchmarks/benchmarks/cperf/save.py new file mode 100644 index 0000000000..63eb5c25fb --- /dev/null +++ b/benchmarks/benchmarks/cperf/save.py @@ -0,0 +1,47 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +File saving benchmarks for the CPerf scheme of the UK Met Office's NG-VAT project. +""" + +from iris import save + +from . import _N_CUBESPHERE_UM_EQUIVALENT, _UM_DIMS_YX +from .. import TrackAddedMemoryAllocation, on_demand_benchmark +from ..generate_data.ugrid import ( + make_cube_like_2d_cubesphere, + make_cube_like_umfield, +) + + +@on_demand_benchmark +class NetcdfSave: + """ + Benchmark time and memory costs of saving ~large-ish data cubes to netcdf. + Parametrised by file type. + + """ + + params = ["LFRic", "UM"] + param_names = ["data type"] + + def setup(self, data_type): + if data_type == "LFRic": + self.cube = make_cube_like_2d_cubesphere( + n_cube=_N_CUBESPHERE_UM_EQUIVALENT, with_mesh=True + ) + else: + self.cube = make_cube_like_umfield(_UM_DIMS_YX) + + def _save_data(self, cube): + save(cube, "tmp.nc") + + def time_save_data_netcdf(self, data_type): + self._save_data(self.cube) + + @TrackAddedMemoryAllocation.decorator() + def track_addedmem_save_data_netcdf(self, data_type): + self._save_data(self.cube) diff --git a/benchmarks/benchmarks/experimental/ugrid.py b/benchmarks/benchmarks/experimental/ugrid/__init__.py similarity index 98% rename from benchmarks/benchmarks/experimental/ugrid.py rename to benchmarks/benchmarks/experimental/ugrid/__init__.py index 609abbe77c..3e5f1ae440 100644 --- a/benchmarks/benchmarks/experimental/ugrid.py +++ b/benchmarks/benchmarks/experimental/ugrid/__init__.py @@ -14,8 +14,8 @@ from iris.experimental import ugrid -from .. import ARTIFICIAL_DIM_SIZE, disable_repeat_between_setup -from ..generate_data.stock import sample_mesh +from ... import ARTIFICIAL_DIM_SIZE, disable_repeat_between_setup +from ...generate_data.stock import sample_mesh class UGridCommon: diff --git a/benchmarks/benchmarks/regions_combine.py b/benchmarks/benchmarks/experimental/ugrid/regions_combine.py similarity index 90% rename from benchmarks/benchmarks/regions_combine.py rename to benchmarks/benchmarks/experimental/ugrid/regions_combine.py index a99dc57263..0cac84d0a8 100644 --- a/benchmarks/benchmarks/regions_combine.py +++ b/benchmarks/benchmarks/experimental/ugrid/regions_combine.py @@ -24,8 +24,8 @@ from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD from iris.experimental.ugrid.utils import recombine_submeshes -from . import TrackAddedMemoryAllocation -from .generate_data import make_cube_like_2d_cubesphere +from ... import TrackAddedMemoryAllocation +from ...generate_data.ugrid import make_cube_like_2d_cubesphere class MixinCombineRegions: @@ -33,6 +33,8 @@ class MixinCombineRegions: # operations on cubesphere-like test data. params = [4, 500] param_names = ["cubesphere-N"] + # For use on 'track_addedmem_..' type benchmarks - result is too noisy. + no_small_params = params[1:] def _parametrised_cache_filename(self, n_cubesphere, content_name): return f"cube_C{n_cubesphere}_{content_name}.nc" @@ -188,13 +190,9 @@ def setup(self, n_cubesphere): def time_create_combined_cube(self, n_cubesphere): self.recombine() + @TrackAddedMemoryAllocation.decorator(MixinCombineRegions.no_small_params) def track_addedmem_create_combined_cube(self, n_cubesphere): - with TrackAddedMemoryAllocation() as mb: - self.recombine() - return mb.addedmem_mb() - - -CombineRegionsCreateCube.track_addedmem_create_combined_cube.unit = "Mb" + self.recombine() class CombineRegionsComputeRealData(MixinCombineRegions): @@ -203,16 +201,11 @@ class CombineRegionsComputeRealData(MixinCombineRegions): """ def time_compute_data(self, n_cubesphere): - self.recombined_cube.data + _ = self.recombined_cube.data + @TrackAddedMemoryAllocation.decorator(MixinCombineRegions.no_small_params) def track_addedmem_compute_data(self, n_cubesphere): - with TrackAddedMemoryAllocation() as mb: - self.recombined_cube.data - - return mb.addedmem_mb() - - -CombineRegionsComputeRealData.track_addedmem_compute_data.unit = "Mb" + _ = self.recombined_cube.data class CombineRegionsSaveData(MixinCombineRegions): @@ -227,18 +220,15 @@ def time_save(self, n_cubesphere): # Save to disk, which must compute data + stream it to file. save(self.recombined_cube, "tmp.nc") + @TrackAddedMemoryAllocation.decorator(MixinCombineRegions.no_small_params) def track_addedmem_save(self, n_cubesphere): - with TrackAddedMemoryAllocation() as mb: - save(self.recombined_cube, "tmp.nc") - - return mb.addedmem_mb() + save(self.recombined_cube, "tmp.nc") def track_filesize_saved(self, n_cubesphere): save(self.recombined_cube, "tmp.nc") return os.path.getsize("tmp.nc") * 1.0e-6 -CombineRegionsSaveData.track_addedmem_save.unit = "Mb" CombineRegionsSaveData.track_filesize_saved.unit = "Mb" @@ -258,11 +248,6 @@ def time_stream_file2file(self, n_cubesphere): # Save to disk, which must compute data + stream it to file. save(self.recombined_cube, "tmp.nc") + @TrackAddedMemoryAllocation.decorator(MixinCombineRegions.no_small_params) def track_addedmem_stream_file2file(self, n_cubesphere): - with TrackAddedMemoryAllocation() as mb: - save(self.recombined_cube, "tmp.nc") - - return mb.addedmem_mb() - - -CombineRegionsFileStreamedCalc.track_addedmem_stream_file2file.unit = "Mb" + save(self.recombined_cube, "tmp.nc") diff --git a/benchmarks/benchmarks/generate_data/__init__.py b/benchmarks/benchmarks/generate_data/__init__.py index 125e2e1b53..8874a2c214 100644 --- a/benchmarks/benchmarks/generate_data/__init__.py +++ b/benchmarks/benchmarks/generate_data/__init__.py @@ -22,11 +22,8 @@ from pathlib import Path from subprocess import CalledProcessError, check_output, run from textwrap import dedent -from typing import Iterable -from iris import load_cube as iris_loadcube from iris._lazy_data import as_concrete_data -from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD from iris.fileformats import netcdf #: Python executable used by :func:`run_function_elsewhere`, set via env @@ -101,83 +98,6 @@ def run_function_elsewhere(func_to_run, *args, **kwargs): return result.stdout -def generate_cube_like_2d_cubesphere( - n_cube: int, with_mesh: bool, output_path: str -): - """ - Construct and save to file an LFRIc cubesphere-like cube for a given - cubesphere size, *or* a simpler structured (UM-like) cube of equivalent - size. - - NOTE: this function is *NEVER* called from within this actual package. - Instead, it is to be called via benchmarks.remote_data_generation, - so that it can use up-to-date facilities, independent of the ASV controlled - environment which contains the "Iris commit under test". - This means: - * it must be completely self-contained : i.e. it includes all its - own imports, and saves results to an output file. - - """ - from iris import save - from iris.tests.stock.mesh import sample_mesh, sample_mesh_cube - - n_face_nodes = n_cube * n_cube - n_faces = 6 * n_face_nodes - - # Set n_nodes=n_faces and n_edges=2*n_faces - # : Not exact, but similar to a 'real' cubesphere. - n_nodes = n_faces - n_edges = 2 * n_faces - if with_mesh: - mesh = sample_mesh( - n_nodes=n_nodes, n_faces=n_faces, n_edges=n_edges, lazy_values=True - ) - cube = sample_mesh_cube(mesh=mesh, n_z=1) - else: - cube = sample_mesh_cube(nomesh_faces=n_faces, n_z=1) - - # Strip off the 'extra' aux-coord mapping the mesh, which sample-cube adds - # but which we don't want. - cube.remove_coord("mesh_face_aux") - - # Save the result to a named file. - save(cube, output_path) - - -def make_cube_like_2d_cubesphere(n_cube: int, with_mesh: bool): - """ - Generate an LFRIc cubesphere-like cube for a given cubesphere size, - *or* a simpler structured (UM-like) cube of equivalent size. - - All the cube data, coords and mesh content are LAZY, and produced without - allocating large real arrays (to allow peak-memory testing). - - NOTE: the actual cube generation is done in a stable Iris environment via - benchmarks.remote_data_generation, so it is all channeled via cached netcdf - files in our common testdata directory. - - """ - identifying_filename = ( - f"cube_like_2d_cubesphere_C{n_cube}_Mesh={with_mesh}.nc" - ) - filepath = BENCHMARK_DATA / identifying_filename - if not filepath.exists(): - # Create the required testfile, by running the generation code remotely - # in a 'fixed' python environment. - run_function_elsewhere( - generate_cube_like_2d_cubesphere, - n_cube, - with_mesh=with_mesh, - output_path=str(filepath), - ) - - # File now *should* definitely exist: content is simply the desired cube. - with PARSE_UGRID_ON_LOAD.context(): - with load_realised(): - cube = iris_loadcube(str(filepath)) - return cube - - @contextmanager def load_realised(): """ diff --git a/benchmarks/benchmarks/generate_data/stock.py b/benchmarks/benchmarks/generate_data/stock.py index e352147fc8..bbc7dc0a63 100644 --- a/benchmarks/benchmarks/generate_data/stock.py +++ b/benchmarks/benchmarks/generate_data/stock.py @@ -10,13 +10,36 @@ """ from pathlib import Path -import pickle from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD, load_mesh from . import BENCHMARK_DATA, REUSE_DATA, load_realised, run_function_elsewhere +def _create_file__xios_common(func_name, **kwargs): + def _external(func_name_, temp_file_dir, **kwargs_): + from iris.tests.stock import netcdf + + func = getattr(netcdf, func_name_) + print(func(temp_file_dir, **kwargs_), end="") + + args_hash = hash(str(kwargs)) + save_path = (BENCHMARK_DATA / f"{func_name}_{args_hash}").with_suffix( + ".nc" + ) + if not REUSE_DATA or not save_path.is_file(): + # The xios functions take control of save location so need to move to + # a more specific name that allows re-use. + actual_path = run_function_elsewhere( + _external, + func_name_=func_name, + temp_file_dir=str(BENCHMARK_DATA), + **kwargs, + ) + Path(actual_path.decode()).replace(save_path) + return save_path + + def create_file__xios_2d_face_half_levels( temp_file_dir, dataset_name, n_faces=866, n_times=1 ): @@ -29,26 +52,33 @@ def create_file__xios_2d_face_half_levels( properly save Mesh Cubes? """ - def _external(*args, **kwargs): - from iris.tests.stock.netcdf import ( - create_file__xios_2d_face_half_levels, - ) + return _create_file__xios_common( + func_name="create_file__xios_2d_face_half_levels", + dataset_name=dataset_name, + n_faces=n_faces, + n_times=n_times, + ) - print(create_file__xios_2d_face_half_levels(*args, **kwargs), end="") - args_list = [dataset_name, n_faces, n_times] - args_hash = hash(str(args_list)) - save_path = ( - BENCHMARK_DATA / f"create_file__xios_2d_face_half_levels_{args_hash}" - ).with_suffix(".nc") - if not REUSE_DATA or not save_path.is_file(): - # create_file__xios_2d_face_half_levels takes control of save location - # so need to move to a more specific name that allows re-use. - actual_path = run_function_elsewhere( - _external, str(BENCHMARK_DATA), *args_list - ) - Path(actual_path.decode()).replace(save_path) - return save_path +def create_file__xios_3d_face_half_levels( + temp_file_dir, dataset_name, n_faces=866, n_times=1, n_levels=38 +): + """ + Wrapper for :meth:`iris.tests.stock.netcdf.create_file__xios_3d_face_half_levels`. + + Have taken control of temp_file_dir + + todo: is create_file__xios_3d_face_half_levels still appropriate now we can + properly save Mesh Cubes? + """ + + return _create_file__xios_common( + func_name="create_file__xios_3d_face_half_levels", + dataset_name=dataset_name, + n_faces=n_faces, + n_times=n_times, + n_levels=n_levels, + ) def sample_mesh(n_nodes=None, n_faces=None, n_edges=None, lazy_values=False): diff --git a/benchmarks/benchmarks/generate_data/ugrid.py b/benchmarks/benchmarks/generate_data/ugrid.py new file mode 100644 index 0000000000..527b49a6bb --- /dev/null +++ b/benchmarks/benchmarks/generate_data/ugrid.py @@ -0,0 +1,195 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Scripts for generating supporting data for UGRID-related benchmarking. +""" +from iris import load_cube as iris_loadcube +from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD + +from . import BENCHMARK_DATA, REUSE_DATA, load_realised, run_function_elsewhere +from .stock import ( + create_file__xios_2d_face_half_levels, + create_file__xios_3d_face_half_levels, +) + + +def generate_cube_like_2d_cubesphere( + n_cube: int, with_mesh: bool, output_path: str +): + """ + Construct and save to file an LFRIc cubesphere-like cube for a given + cubesphere size, *or* a simpler structured (UM-like) cube of equivalent + size. + + NOTE: this function is *NEVER* called from within this actual package. + Instead, it is to be called via benchmarks.remote_data_generation, + so that it can use up-to-date facilities, independent of the ASV controlled + environment which contains the "Iris commit under test". + This means: + * it must be completely self-contained : i.e. it includes all its + own imports, and saves results to an output file. + + """ + from iris import save + from iris.tests.stock.mesh import sample_mesh, sample_mesh_cube + + n_face_nodes = n_cube * n_cube + n_faces = 6 * n_face_nodes + + # Set n_nodes=n_faces and n_edges=2*n_faces + # : Not exact, but similar to a 'real' cubesphere. + n_nodes = n_faces + n_edges = 2 * n_faces + if with_mesh: + mesh = sample_mesh( + n_nodes=n_nodes, n_faces=n_faces, n_edges=n_edges, lazy_values=True + ) + cube = sample_mesh_cube(mesh=mesh, n_z=1) + else: + cube = sample_mesh_cube(nomesh_faces=n_faces, n_z=1) + + # Strip off the 'extra' aux-coord mapping the mesh, which sample-cube adds + # but which we don't want. + cube.remove_coord("mesh_face_aux") + + # Save the result to a named file. + save(cube, output_path) + + +def make_cube_like_2d_cubesphere(n_cube: int, with_mesh: bool): + """ + Generate an LFRIc cubesphere-like cube for a given cubesphere size, + *or* a simpler structured (UM-like) cube of equivalent size. + + All the cube data, coords and mesh content are LAZY, and produced without + allocating large real arrays (to allow peak-memory testing). + + NOTE: the actual cube generation is done in a stable Iris environment via + benchmarks.remote_data_generation, so it is all channeled via cached netcdf + files in our common testdata directory. + + """ + identifying_filename = ( + f"cube_like_2d_cubesphere_C{n_cube}_Mesh={with_mesh}.nc" + ) + filepath = BENCHMARK_DATA / identifying_filename + if not filepath.exists(): + # Create the required testfile, by running the generation code remotely + # in a 'fixed' python environment. + run_function_elsewhere( + generate_cube_like_2d_cubesphere, + n_cube, + with_mesh=with_mesh, + output_path=str(filepath), + ) + + # File now *should* definitely exist: content is simply the desired cube. + with PARSE_UGRID_ON_LOAD.context(): + cube = iris_loadcube(str(filepath)) + + # Ensure correct laziness. + _ = cube.data + for coord in cube.coords(mesh_coords=False): + assert not coord.has_lazy_points() + assert not coord.has_lazy_bounds() + if cube.mesh: + for coord in cube.mesh.coords(): + assert coord.has_lazy_points() + for conn in cube.mesh.connectivities(): + assert conn.has_lazy_indices() + + return cube + + +def make_cube_like_umfield(xy_dims): + """ + Create a "UM-like" cube with lazy content, for save performance testing. + + Roughly equivalent to a single current UM cube, to be compared with + a "make_cube_like_2d_cubesphere(n_cube=_N_CUBESPHERE_UM_EQUIVALENT)" + (see below). + + Note: probably a bit over-simplified, as there is no time coord, but that + is probably equally true of our LFRic-style synthetic data. + + Args: + * xy_dims (2-tuple): + Set the horizontal dimensions = n-lats, n-lons. + + """ + + def _external(xy_dims_, save_path_): + from dask import array as da + import numpy as np + + from iris import save + from iris.coords import DimCoord + from iris.cube import Cube + + nz, ny, nx = (1,) + xy_dims_ + + # Base data : Note this is float32 not float64 like LFRic/XIOS outputs. + lazy_data = da.zeros((nz, ny, nx), dtype=np.float32) + cube = Cube(lazy_data, long_name="structured_phenom") + + # Add simple dim coords also. + z_dimco = DimCoord(np.arange(nz), long_name="level", units=1) + y_dimco = DimCoord( + np.linspace(-90.0, 90.0, ny), + standard_name="latitude", + units="degrees", + ) + x_dimco = DimCoord( + np.linspace(-180.0, 180.0, nx), + standard_name="longitude", + units="degrees", + ) + for idim, co in enumerate([z_dimco, y_dimco, x_dimco]): + cube.add_dim_coord(co, idim) + + save(cube, save_path_) + + save_path = ( + BENCHMARK_DATA / f"make_cube_like_umfield_{xy_dims}" + ).with_suffix(".nc") + if not REUSE_DATA or not save_path.is_file(): + _ = run_function_elsewhere(_external, xy_dims, str(save_path)) + with PARSE_UGRID_ON_LOAD.context(): + with load_realised(): + cube = iris_loadcube(str(save_path)) + + return cube + + +def make_cubesphere_testfile(c_size, n_levels=0, n_times=1): + """ + Build a C cubesphere testfile in a given directory, with a standard naming. + If n_levels > 0 specified: 3d file with the specified number of levels. + Return the file path. + + todo: is create_file__xios... still appropriate now we can properly save + Mesh Cubes? + + """ + n_faces = 6 * c_size * c_size + stem_name = f"mesh_cubesphere_C{c_size}_t{n_times}" + kwargs = dict( + temp_file_dir=None, + dataset_name=stem_name, # N.B. function adds the ".nc" extension + n_times=n_times, + n_faces=n_faces, + ) + + three_d = n_levels > 0 + if three_d: + kwargs["n_levels"] = n_levels + kwargs["dataset_name"] += f"_{n_levels}levels" + func = create_file__xios_3d_face_half_levels + else: + func = create_file__xios_2d_face_half_levels + + file_path = func(**kwargs) + return file_path diff --git a/benchmarks/benchmarks/loading.py b/benchmarks/benchmarks/load/__init__.py similarity index 97% rename from benchmarks/benchmarks/loading.py rename to benchmarks/benchmarks/load/__init__.py index 4558c3b5cb..74a751a46b 100644 --- a/benchmarks/benchmarks/loading.py +++ b/benchmarks/benchmarks/load/__init__.py @@ -19,8 +19,8 @@ from iris.cube import Cube from iris.fileformats.um import structured_um_loading -from .generate_data import BENCHMARK_DATA, REUSE_DATA, run_function_elsewhere -from .generate_data.um_files import create_um_files +from ..generate_data import BENCHMARK_DATA, REUSE_DATA, run_function_elsewhere +from ..generate_data.um_files import create_um_files class LoadAndRealise: diff --git a/benchmarks/benchmarks/ugrid_load.py b/benchmarks/benchmarks/load/ugrid.py similarity index 98% rename from benchmarks/benchmarks/ugrid_load.py rename to benchmarks/benchmarks/load/ugrid.py index 352450dcec..8227a4c5a0 100644 --- a/benchmarks/benchmarks/ugrid_load.py +++ b/benchmarks/benchmarks/load/ugrid.py @@ -19,7 +19,7 @@ from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD from iris.experimental.ugrid import load_mesh as iris_load_mesh -from .generate_data.stock import create_file__xios_2d_face_half_levels +from ..generate_data.stock import create_file__xios_2d_face_half_levels def synthetic_data(**kwargs): diff --git a/benchmarks/benchmarks/netcdf_save.py b/benchmarks/benchmarks/save.py similarity index 78% rename from benchmarks/benchmarks/netcdf_save.py rename to benchmarks/benchmarks/save.py index c7580b3c63..d4c36ef983 100644 --- a/benchmarks/benchmarks/netcdf_save.py +++ b/benchmarks/benchmarks/save.py @@ -4,7 +4,7 @@ # See COPYING and COPYING.LESSER in the root of the repository for full # licensing details. """ -Cubesphere-like netcdf saving benchmarks. +File saving benchmarks. Where possible benchmarks should be parameterised for two sizes of input data: * minimal: enables detection of regressions in parts of the run-time that do @@ -18,12 +18,15 @@ from iris.experimental.ugrid import save_mesh from . import TrackAddedMemoryAllocation -from .generate_data import make_cube_like_2d_cubesphere +from .generate_data.ugrid import make_cube_like_2d_cubesphere class NetcdfSave: params = [[1, 600], [False, True]] param_names = ["cubesphere-N", "is_unstructured"] + # For use on 'track_addedmem_..' type benchmarks - result is too noisy. + no_small_params = params + no_small_params[0] = params[0][1:] def setup(self, n_cubesphere, is_unstructured): self.cube = make_cube_like_2d_cubesphere( @@ -48,14 +51,8 @@ def time_netcdf_save_mesh(self, n_cubesphere, is_unstructured): if is_unstructured: self._save_mesh(self.cube) + @TrackAddedMemoryAllocation.decorator(no_small_params) def track_addedmem_netcdf_save(self, n_cubesphere, is_unstructured): - cube = self.cube.copy() # Do this outside the testing block - with TrackAddedMemoryAllocation() as mb: - self._save_data(cube, do_copy=False) - return mb.addedmem_mb() - - -# Declare a 'Mb' unit for all 'track_addedmem_..' type benchmarks -for attr in dir(NetcdfSave): - if attr.startswith("track_addedmem_"): - getattr(NetcdfSave, attr).unit = "Mb" + # Don't need to copy the cube here since track_ benchmarks don't + # do repeats between self.setup() calls. + self._save_data(self.cube, do_copy=False) diff --git a/benchmarks/benchmarks/sperf/__init__.py b/benchmarks/benchmarks/sperf/__init__.py new file mode 100644 index 0000000000..696c8ef4df --- /dev/null +++ b/benchmarks/benchmarks/sperf/__init__.py @@ -0,0 +1,39 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Benchmarks for the SPerf scheme of the UK Met Office's NG-VAT project. + +SPerf = assessing performance against a series of increasingly large LFRic +datasets. +""" +from iris import load_cube + +# TODO: remove uses of PARSE_UGRID_ON_LOAD once UGRID parsing is core behaviour. +from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD + +from ..generate_data.ugrid import make_cubesphere_testfile + + +class FileMixin: + """For use in any benchmark classes that work on a file.""" + + params = [ + [12, 384, 640, 960, 1280, 1668], + [1, 36, 72], + [1, 3, 36, 72], + ] + param_names = ["cubesphere_C", "N levels", "N time steps"] + # cubesphere_C: notation refers to faces per panel. + # e.g. C1 is 6 faces, 8 nodes + + def setup(self, c_size, n_levels, n_times): + self.file_path = make_cubesphere_testfile( + c_size=c_size, n_levels=n_levels, n_times=n_times + ) + + def load_cube(self): + with PARSE_UGRID_ON_LOAD.context(): + return load_cube(str(self.file_path)) diff --git a/benchmarks/benchmarks/sperf/combine_regions.py b/benchmarks/benchmarks/sperf/combine_regions.py new file mode 100644 index 0000000000..fd2c95c7fc --- /dev/null +++ b/benchmarks/benchmarks/sperf/combine_regions.py @@ -0,0 +1,250 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Region combine benchmarks for the SPerf scheme of the UK Met Office's NG-VAT project. +""" +import os.path + +from dask import array as da +import numpy as np + +from iris import load, load_cube, save +from iris.experimental.ugrid import PARSE_UGRID_ON_LOAD +from iris.experimental.ugrid.utils import recombine_submeshes + +from .. import TrackAddedMemoryAllocation, on_demand_benchmark +from ..generate_data.ugrid import make_cube_like_2d_cubesphere + + +class Mixin: + # Characterise time taken + memory-allocated, for various stages of combine + # operations on cubesphere-like test data. + timeout = 180.0 + params = [100, 200, 300, 500, 1000, 1668] + param_names = ["cubesphere_C"] + # Fix result units for the tracking benchmarks. + unit = "Mb" + + def _parametrised_cache_filename(self, n_cubesphere, content_name): + return f"cube_C{n_cubesphere}_{content_name}.nc" + + def _make_region_cubes(self, full_mesh_cube): + """Make a fixed number of region cubes from a full meshcube.""" + # Divide the cube into regions. + n_faces = full_mesh_cube.shape[-1] + # Start with a simple list of face indices + # first extend to multiple of 5 + n_faces_5s = 5 * ((n_faces + 1) // 5) + i_faces = np.arange(n_faces_5s, dtype=int) + # reshape (5N,) to (N, 5) + i_faces = i_faces.reshape((n_faces_5s // 5, 5)) + # reorder [2, 3, 4, 0, 1] within each block of 5 + i_faces = np.concatenate([i_faces[:, 2:], i_faces[:, :2]], axis=1) + # flatten to get [2 3 4 0 1 (-) 8 9 10 6 7 (-) 13 14 15 11 12 ...] + i_faces = i_faces.flatten() + # reduce back to orignal length, wrap any overflows into valid range + i_faces = i_faces[:n_faces] % n_faces + + # Divide into regions -- always slightly uneven, since 7 doesn't divide + n_regions = 7 + n_facesperregion = n_faces // n_regions + i_face_regions = (i_faces // n_facesperregion) % n_regions + region_inds = [ + np.where(i_face_regions == i_region)[0] + for i_region in range(n_regions) + ] + # NOTE: this produces 7 regions, with near-adjacent value ranges but + # with some points "moved" to an adjacent region. + # Also, region-0 is bigger (because of not dividing by 7). + + # Finally, make region cubes with these indices. + region_cubes = [full_mesh_cube[..., inds] for inds in region_inds] + return region_cubes + + def setup_cache(self): + """Cache all the necessary source data on disk.""" + + # Control dask, to minimise memory usage + allow largest data. + self.fix_dask_settings() + + for n_cubesphere in self.params: + # Do for each parameter, since "setup_cache" is NOT parametrised + mesh_cube = make_cube_like_2d_cubesphere( + n_cube=n_cubesphere, with_mesh=True + ) + # Save to files which include the parameter in the names. + save( + mesh_cube, + self._parametrised_cache_filename(n_cubesphere, "meshcube"), + ) + region_cubes = self._make_region_cubes(mesh_cube) + save( + region_cubes, + self._parametrised_cache_filename(n_cubesphere, "regioncubes"), + ) + + def setup( + self, n_cubesphere, imaginary_data=True, create_result_cube=True + ): + """ + The combine-tests "standard" setup operation. + + Load the source cubes (full-mesh + region) from disk. + These are specific to the cubesize parameter. + The data is cached on disk rather than calculated, to avoid any + pre-loading of the process memory allocation. + + If 'imaginary_data' is set (default), the region cubes data is replaced + with lazy data in the form of a da.zeros(). Otherwise, the region data + is lazy data from the files. + + If 'create_result_cube' is set, create "self.combined_cube" containing + the (still lazy) result. + + NOTE: various test classes override + extend this. + + """ + + # Load source cubes (full-mesh and regions) + with PARSE_UGRID_ON_LOAD.context(): + self.full_mesh_cube = load_cube( + self._parametrised_cache_filename(n_cubesphere, "meshcube") + ) + self.region_cubes = load( + self._parametrised_cache_filename(n_cubesphere, "regioncubes") + ) + + # Remove all var-names from loaded cubes, which can otherwise cause + # problems. Also implement 'imaginary' data. + for cube in self.region_cubes + [self.full_mesh_cube]: + cube.var_name = None + for coord in cube.coords(): + coord.var_name = None + if imaginary_data: + # Replace cube data (lazy file data) with 'imaginary' data. + # This has the same lazy-array attributes, but is allocated by + # creating chunks on demand instead of loading from file. + data = cube.lazy_data() + data = da.zeros( + data.shape, dtype=data.dtype, chunks=data.chunksize + ) + cube.data = data + + if create_result_cube: + self.recombined_cube = self.recombine() + + # Fix dask usage mode for all the subsequent performance tests. + self.fix_dask_settings() + + def fix_dask_settings(self): + """ + Fix "standard" dask behaviour for time+space testing. + + Currently this is single-threaded mode, with known chunksize, + which is optimised for space saving so we can test largest data. + + """ + + import dask.config as dcfg + + # Use single-threaded, to avoid process-switching costs and minimise memory usage. + # N.B. generally may be slower, but use less memory ? + dcfg.set(scheduler="single-threaded") + # Configure iris._lazy_data.as_lazy_data to aim for 100Mb chunks + dcfg.set({"array.chunk-size": "128Mib"}) + + def recombine(self): + # A handy general shorthand for the main "combine" operation. + result = recombine_submeshes( + self.full_mesh_cube, + self.region_cubes, + index_coord_name="i_mesh_face", + ) + return result + + +@on_demand_benchmark +class CreateCube(Mixin): + """ + Time+memory costs of creating a combined-regions cube. + + The result is lazy, and we don't do the actual calculation. + + """ + + def setup( + self, n_cubesphere, imaginary_data=True, create_result_cube=False + ): + # In this case only, do *not* create the result cube. + # That is the operation we want to test. + super().setup(n_cubesphere, imaginary_data, create_result_cube) + + def time_create_combined_cube(self, n_cubesphere): + self.recombine() + + @TrackAddedMemoryAllocation.decorator() + def track_addedmem_create_combined_cube(self, n_cubesphere): + self.recombine() + + +@on_demand_benchmark +class ComputeRealData(Mixin): + """ + Time+memory costs of computing combined-regions data. + """ + + def time_compute_data(self, n_cubesphere): + _ = self.recombined_cube.data + + @TrackAddedMemoryAllocation.decorator() + def track_addedmem_compute_data(self, n_cubesphere): + _ = self.recombined_cube.data + + +@on_demand_benchmark +class SaveData(Mixin): + """ + Test saving *only*, having replaced the input cube data with 'imaginary' + array data, so that input data is not loaded from disk during the save + operation. + + """ + + def time_save(self, n_cubesphere): + # Save to disk, which must compute data + stream it to file. + save(self.recombined_cube, "tmp.nc") + + @TrackAddedMemoryAllocation.decorator() + def track_addedmem_save(self, n_cubesphere): + save(self.recombined_cube, "tmp.nc") + + def track_filesize_saved(self, n_cubesphere): + save(self.recombined_cube, "tmp.nc") + return os.path.getsize("tmp.nc") * 1.0e-6 + + +@on_demand_benchmark +class FileStreamedCalc(Mixin): + """ + Test the whole cost of file-to-file streaming. + Uses the combined cube which is based on lazy data loading from the region + cubes on disk. + """ + + def setup( + self, n_cubesphere, imaginary_data=False, create_result_cube=True + ): + # In this case only, do *not* replace the loaded regions data with + # 'imaginary' data, as we want to test file-to-file calculation+save. + super().setup(n_cubesphere, imaginary_data, create_result_cube) + + def time_stream_file2file(self, n_cubesphere): + # Save to disk, which must compute data + stream it to file. + save(self.recombined_cube, "tmp.nc") + + @TrackAddedMemoryAllocation.decorator() + def track_addedmem_stream_file2file(self, n_cubesphere): + save(self.recombined_cube, "tmp.nc") diff --git a/benchmarks/benchmarks/sperf/equality.py b/benchmarks/benchmarks/sperf/equality.py new file mode 100644 index 0000000000..85c73ab92b --- /dev/null +++ b/benchmarks/benchmarks/sperf/equality.py @@ -0,0 +1,36 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Equality benchmarks for the SPerf scheme of the UK Met Office's NG-VAT project. +""" +from . import FileMixin +from .. import on_demand_benchmark + + +@on_demand_benchmark +class CubeEquality(FileMixin): + """ + Benchmark time and memory costs of comparing :class:`~iris.cube.Cube`\\ s + with attached :class:`~iris.experimental.ugrid.mesh.Mesh`\\ es. + + Uses :class:`FileMixin` as the realistic case will be comparing + :class:`~iris.cube.Cube`\\ s that have been loaded from file. + + """ + + # Cut down paremt parameters. + params = [FileMixin.params[0]] + + def setup(self, c_size, n_levels=1, n_times=1): + super().setup(c_size, n_levels, n_times) + self.cube = self.load_cube() + self.other_cube = self.load_cube() + + def peakmem_eq(self, n_cube): + _ = self.cube == self.other_cube + + def time_eq(self, n_cube): + _ = self.cube == self.other_cube diff --git a/benchmarks/benchmarks/sperf/load.py b/benchmarks/benchmarks/sperf/load.py new file mode 100644 index 0000000000..c1d1db43a9 --- /dev/null +++ b/benchmarks/benchmarks/sperf/load.py @@ -0,0 +1,32 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +File loading benchmarks for the SPerf scheme of the UK Met Office's NG-VAT project. +""" +from . import FileMixin +from .. import on_demand_benchmark + + +@on_demand_benchmark +class Load(FileMixin): + def time_load_cube(self, _, __, ___): + _ = self.load_cube() + + +@on_demand_benchmark +class Realise(FileMixin): + # The larger files take a long time to realise. + timeout = 600.0 + + def setup(self, c_size, n_levels, n_times): + super().setup(c_size, n_levels, n_times) + self.loaded_cube = self.load_cube() + + def time_realise_cube(self, _, __, ___): + # Don't touch loaded_cube.data - permanent realisation plays badly with + # ASV's re-run strategy. + assert self.loaded_cube.has_lazy_data() + self.loaded_cube.core_data().compute() diff --git a/benchmarks/benchmarks/sperf/save.py b/benchmarks/benchmarks/sperf/save.py new file mode 100644 index 0000000000..62c84a2619 --- /dev/null +++ b/benchmarks/benchmarks/sperf/save.py @@ -0,0 +1,56 @@ +# Copyright Iris contributors +# +# This file is part of Iris and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +File saving benchmarks for the SPerf scheme of the UK Met Office's NG-VAT project. +""" +import os.path + +from iris import save +from iris.experimental.ugrid import save_mesh + +from .. import TrackAddedMemoryAllocation, on_demand_benchmark +from ..generate_data.ugrid import make_cube_like_2d_cubesphere + + +@on_demand_benchmark +class NetcdfSave: + """ + Benchmark time and memory costs of saving ~large-ish data cubes to netcdf. + + """ + + params = [[1, 100, 200, 300, 500, 1000, 1668], [False, True]] + param_names = ["cubesphere_C", "is_unstructured"] + # Fix result units for the tracking benchmarks. + unit = "Mb" + + def setup(self, n_cubesphere, is_unstructured): + self.cube = make_cube_like_2d_cubesphere( + n_cube=n_cubesphere, with_mesh=is_unstructured + ) + + def _save_cube(self, cube): + save(cube, "tmp.nc") + + def _save_mesh(self, cube): + save_mesh(cube.mesh, "mesh.nc") + + def time_save_cube(self, n_cubesphere, is_unstructured): + self._save_cube(self.cube) + + @TrackAddedMemoryAllocation.decorator() + def track_addedmem_save_cube(self, n_cubesphere, is_unstructured): + self._save_cube(self.cube) + + def time_save_mesh(self, n_cubesphere, is_unstructured): + if is_unstructured: + self._save_mesh(self.cube) + + # The filesizes make a good reference point for the 'addedmem' memory + # usage results. + def track_filesize_save_cube(self, n_cubesphere, is_unstructured): + self._save_cube(self.cube) + return os.path.getsize("tmp.nc") * 1.0e-6 diff --git a/docs/src/developers_guide/asv_example_images/commits.png b/docs/src/developers_guide/asv_example_images/commits.png new file mode 100644 index 0000000000000000000000000000000000000000..4e0d69532277f78ecc36182f12c9bbe6191e151e GIT binary patch literal 166632 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�aS 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

l{#0eYSO(^XUF$ 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 literal 0 HcmV?d00001 diff --git a/docs/src/developers_guide/asv_example_images/comparison.png b/docs/src/developers_guide/asv_example_images/comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..e146d306965a2e8794070e78d0a85a4e5dacc11a GIT binary patch literal 17405 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 F{{z$YgKhu- literal 0 HcmV?d00001 diff --git a/docs/src/developers_guide/asv_example_images/scalability.png b/docs/src/developers_guide/asv_example_images/scalability.png new file mode 100644 index 0000000000000000000000000000000000000000..260c3ef5364f092fe5877339ae01a1a36a39b679 GIT binary patch literal 46256 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 L6Wwxc`_TUYOcv3v literal 0 HcmV?d00001 diff --git a/docs/src/developers_guide/contributing_benchmarks.rst b/docs/src/developers_guide/contributing_benchmarks.rst new file mode 100644 index 0000000000..65bc9635b6 --- /dev/null +++ b/docs/src/developers_guide/contributing_benchmarks.rst @@ -0,0 +1,62 @@ +.. include:: ../common_links.inc + +.. _contributing.benchmarks: + +Benchmarking +============ +Iris includes architecture for benchmarking performance and other metrics of +interest. This is done using the `Airspeed Velocity`_ (ASV) package. + +Full detail on the setup and how to run or write benchmarks is in +`benchmarks/README.md`_ in the Iris repository. + +Continuous Integration +---------------------- +The primary purpose of `Airspeed Velocity`_, and Iris' specific benchmarking +setup, is to monitor for performance changes using statistical comparison +between commits, and this forms part of Iris' continuous integration. + +Accurately assessing performance takes longer than functionality pass/fail +tests, so the benchmark suite is not automatically run against open pull +requests, instead it is **run overnight against each the commits of the +previous day** to check if any commit has introduced performance shifts. +Detected shifts are reported in a new Iris GitHub issue. + +If a pull request author/reviewer suspects their changes may cause performance +shifts, a convenience is available (currently via Nox) to replicate the +overnight benchmark run but comparing the current ``HEAD`` with a requested +branch (e.g. ``upstream/main``). Read more in `benchmarks/README.md`_. + +Other Uses +---------- +Even when not statistically comparing commits, ASV's accurate execution time +results - recorded using a sophisticated system of repeats - have other +applications. + +* Absolute numbers can be interpreted providing they are recorded on a + dedicated resource. +* Results for a series of commits can be visualised for an intuitive + understanding of when and why changes occurred. + + .. image:: asv_example_images/commits.png + :width: 300 + +* Parameterised benchmarks make it easy to visualise: + + * Comparisons + + .. image:: asv_example_images/comparison.png + :width: 300 + + * Scalability + + .. image:: asv_example_images/scalability.png + :width: 300 + +This also isn't limited to execution times. ASV can also measure memory demand, +and even arbitrary numbers (e.g. file size, regridding accuracy), although +without the repetition logic that execution timing has. + + +.. _Airspeed Velocity: https://github.com/airspeed-velocity/asv +.. _benchmarks/README.md: https://github.com/SciTools/iris/blob/main/benchmarks/README.md diff --git a/docs/src/developers_guide/contributing_testing_index.rst b/docs/src/developers_guide/contributing_testing_index.rst index c5cf1b997b..7c6eb1b3cc 100644 --- a/docs/src/developers_guide/contributing_testing_index.rst +++ b/docs/src/developers_guide/contributing_testing_index.rst @@ -11,3 +11,4 @@ Testing imagehash_index contributing_running_tests contributing_ci_tests + contributing_benchmarks diff --git a/docs/src/whatsnew/dev.rst b/docs/src/whatsnew/dev.rst index a9e960c173..bb37e39c45 100644 --- a/docs/src/whatsnew/dev.rst +++ b/docs/src/whatsnew/dev.rst @@ -82,7 +82,10 @@ This document explains the changes made to Iris for this release 💼 Internal =========== -#. N/A +#. `@trexfeathers`_ and `@pp-mo`_ finished implementing a mature benchmarking + infrastructure (see :ref:`contributing.benchmarks`), building on 2 hard + years of lessons learned 🎉. (:pull:`4477`, :pull:`4562`, :pull:`4571`, + :pull:`4583`, :pull:`4621`) .. comment diff --git a/noxfile.py b/noxfile.py index c65319c35f..00a866f814 100755 --- a/noxfile.py +++ b/noxfile.py @@ -5,6 +5,7 @@ """ +from datetime import datetime import hashlib import os from pathlib import Path @@ -294,12 +295,12 @@ def linkcheck(session: nox.sessions.Session): @nox.session @nox.parametrize( "run_type", - ["overnight", "branch", "custom"], - ids=["overnight", "branch", "custom"], + ["overnight", "branch", "cperf", "sperf", "custom"], + ids=["overnight", "branch", "cperf", "sperf", "custom"], ) def benchmarks( session: nox.sessions.Session, - run_type: Literal["overnight", "branch", "custom"], + run_type: Literal["overnight", "branch", "cperf", "sperf", "custom"], ): """ Perform Iris performance benchmarks (using Airspeed Velocity). @@ -327,6 +328,11 @@ def benchmarks( merged. **For maximum accuracy, avoid using the machine that is running this session. Run time could be >1 hour for the full benchmark suite.** + * ``cperf``: Run the on-demand CPerf suite of benchmarks (part of the + UK Met Office NG-VAT project) for the ``HEAD`` of ``upstream/main`` + only, and publish the results to the input **publish directory**, + within a unique subdirectory for this run. + * ``sperf``: As with CPerf, but for the SPerf suite. * ``custom``: run ASV with the input **ASV sub-command**, without any preset arguments - must all be supplied by the user. So just like running ASV manually, with the convenience of re-using the session's @@ -338,6 +344,7 @@ def benchmarks( * ``nox --session="benchmarks(branch)" -- upstream/main`` * ``nox --session="benchmarks(branch)" -- upstream/mesh-data-model`` * ``nox --session="benchmarks(branch)" -- upstream/main --bench=regridding`` + * ``nox --session="benchmarks(cperf)" -- my_publish_dir * ``nox --session="benchmarks(custom)" -- continuous a1b23d4 HEAD --quick`` """ @@ -395,6 +402,8 @@ def benchmarks( run_type_arg = { "overnight": "first commit", "branch": "base branch", + "cperf": "publish directory", + "sperf": "publish directory", "custom": "ASV sub-command", } if run_type not in run_type_arg.keys(): @@ -436,8 +445,11 @@ def asv_compare(*commits): with shifts_path.open("w") as shifts_file: shifts_file.write(shifts) - # Common ASV arguments used for both `overnight` and `bench` run_types. - asv_harness = "asv run {posargs} --attribute rounds=4 --interleave-rounds --strict --show-stderr" + # Common ASV arguments for all run_types except `custom`. + asv_harness = ( + "asv run {posargs} --attribute rounds=4 --interleave-rounds --strict " + "--show-stderr" + ) if run_type == "overnight": first_commit = first_arg @@ -469,6 +481,40 @@ def asv_compare(*commits): asv_compare(merge_base, "HEAD") + elif run_type in ("cperf", "sperf"): + publish_dir = Path(first_arg) + if not publish_dir.is_dir(): + message = ( + f"Input 'publish directory' is not a directory: {publish_dir}" + ) + raise NotADirectoryError(message) + publish_subdir = ( + publish_dir + / f"{run_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + ) + publish_subdir.mkdir() + + # Activate on demand benchmarks (C/SPerf are deactivated for 'standard' runs). + session.env["ON_DEMAND_BENCHMARKS"] = "True" + commit_range = "upstream/main^!" + + asv_command = ( + asv_harness.format(posargs=commit_range) + f" --bench={run_type}" + ) + session.run(*asv_command.split(" "), *asv_args) + + asv_command = f"asv publish {commit_range} --html-dir={publish_subdir}" + session.run(*asv_command.split(" ")) + + # Print completion message. + location = Path().cwd() / ".asv" + print( + f'New ASV results for "{run_type}".\n' + f'See "{publish_subdir}",' + f'\n html in "{location / "html"}".' + f'\n or JSON files under "{location / "results"}".' + ) + else: asv_subcommand = first_arg assert run_type == "custom" From 3ee7c56bcfea032fc5770f6c9b84b91511441d88 Mon Sep 17 00:00:00 2001 From: Martin Yeo <40734014+trexfeathers@users.noreply.github.com> Date: Fri, 11 Mar 2022 15:19:18 +0000 Subject: [PATCH 28/28] Improve Small Benchmark Accuracy (#4636) * Remove another memory benchmark that's smaller than the noise level. * Fixed import benchmarks. --- benchmarks/benchmarks/import_iris.py | 179 ++++++++++++++------------- benchmarks/benchmarks/save.py | 3 +- 2 files changed, 94 insertions(+), 88 deletions(-) diff --git a/benchmarks/benchmarks/import_iris.py b/benchmarks/benchmarks/import_iris.py index 3e83ea8cfe..ad54c23122 100644 --- a/benchmarks/benchmarks/import_iris.py +++ b/benchmarks/benchmarks/import_iris.py @@ -3,240 +3,247 @@ # This file is part of Iris and is released under the LGPL license. # See COPYING and COPYING.LESSER in the root of the repository for full # licensing details. -import sys +from importlib import import_module, reload class Iris: - warmup_time = 0 - number = 1 - repeat = 10 - - def setup(self): - self.before = set(sys.modules.keys()) - - def teardown(self): - after = set(sys.modules.keys()) - diff = after - self.before - for module in diff: - sys.modules.pop(module) + @staticmethod + def _import(module_name): + """ + Have experimented with adding sleep() commands into the imported + modules. The results reveal: + + ASV avoids invoking `import x` if nothing gets called in the + benchmark (some imports were timed, but only those where calls + happened during import). + + Using reload() is not identical to importing, but does produce + results that are very close to expected import times, so this is fine + for monitoring for regressions. + It is also ideal for accurate repetitions, without the need to mess + with the ASV `number` attribute etc, since cached imports are not used + and the repetitions are therefore no faster than the first run. + """ + mod = import_module(module_name) + reload(mod) def time_iris(self): - import iris + self._import("iris") def time__concatenate(self): - import iris._concatenate + self._import("iris._concatenate") def time__constraints(self): - import iris._constraints + self._import("iris._constraints") def time__data_manager(self): - import iris._data_manager + self._import("iris._data_manager") def time__deprecation(self): - import iris._deprecation + self._import("iris._deprecation") def time__lazy_data(self): - import iris._lazy_data + self._import("iris._lazy_data") def time__merge(self): - import iris._merge + self._import("iris._merge") def time__representation(self): - import iris._representation + self._import("iris._representation") def time_analysis(self): - import iris.analysis + self._import("iris.analysis") def time_analysis__area_weighted(self): - import iris.analysis._area_weighted + self._import("iris.analysis._area_weighted") def time_analysis__grid_angles(self): - import iris.analysis._grid_angles + self._import("iris.analysis._grid_angles") def time_analysis__interpolation(self): - import iris.analysis._interpolation + self._import("iris.analysis._interpolation") def time_analysis__regrid(self): - import iris.analysis._regrid + self._import("iris.analysis._regrid") def time_analysis__scipy_interpolate(self): - import iris.analysis._scipy_interpolate + self._import("iris.analysis._scipy_interpolate") def time_analysis_calculus(self): - import iris.analysis.calculus + self._import("iris.analysis.calculus") def time_analysis_cartography(self): - import iris.analysis.cartography + self._import("iris.analysis.cartography") def time_analysis_geomerty(self): - import iris.analysis.geometry + self._import("iris.analysis.geometry") def time_analysis_maths(self): - import iris.analysis.maths + self._import("iris.analysis.maths") def time_analysis_stats(self): - import iris.analysis.stats + self._import("iris.analysis.stats") def time_analysis_trajectory(self): - import iris.analysis.trajectory + self._import("iris.analysis.trajectory") def time_aux_factory(self): - import iris.aux_factory + self._import("iris.aux_factory") def time_common(self): - import iris.common + self._import("iris.common") def time_common_lenient(self): - import iris.common.lenient + self._import("iris.common.lenient") def time_common_metadata(self): - import iris.common.metadata + self._import("iris.common.metadata") def time_common_mixin(self): - import iris.common.mixin + self._import("iris.common.mixin") def time_common_resolve(self): - import iris.common.resolve + self._import("iris.common.resolve") def time_config(self): - import iris.config + self._import("iris.config") def time_coord_categorisation(self): - import iris.coord_categorisation + self._import("iris.coord_categorisation") def time_coord_systems(self): - import iris.coord_systems + self._import("iris.coord_systems") def time_coords(self): - import iris.coords + self._import("iris.coords") def time_cube(self): - import iris.cube + self._import("iris.cube") def time_exceptions(self): - import iris.exceptions + self._import("iris.exceptions") def time_experimental(self): - import iris.experimental + self._import("iris.experimental") def time_fileformats(self): - import iris.fileformats + self._import("iris.fileformats") def time_fileformats__ff(self): - import iris.fileformats._ff + self._import("iris.fileformats._ff") def time_fileformats__ff_cross_references(self): - import iris.fileformats._ff_cross_references + self._import("iris.fileformats._ff_cross_references") def time_fileformats__pp_lbproc_pairs(self): - import iris.fileformats._pp_lbproc_pairs + self._import("iris.fileformats._pp_lbproc_pairs") def time_fileformats_structured_array_identification(self): - import iris.fileformats._structured_array_identification + self._import("iris.fileformats._structured_array_identification") def time_fileformats_abf(self): - import iris.fileformats.abf + self._import("iris.fileformats.abf") def time_fileformats_cf(self): - import iris.fileformats.cf + self._import("iris.fileformats.cf") def time_fileformats_dot(self): - import iris.fileformats.dot + self._import("iris.fileformats.dot") def time_fileformats_name(self): - import iris.fileformats.name + self._import("iris.fileformats.name") def time_fileformats_name_loaders(self): - import iris.fileformats.name_loaders + self._import("iris.fileformats.name_loaders") def time_fileformats_netcdf(self): - import iris.fileformats.netcdf + self._import("iris.fileformats.netcdf") def time_fileformats_nimrod(self): - import iris.fileformats.nimrod + self._import("iris.fileformats.nimrod") def time_fileformats_nimrod_load_rules(self): - import iris.fileformats.nimrod_load_rules + self._import("iris.fileformats.nimrod_load_rules") def time_fileformats_pp(self): - import iris.fileformats.pp + self._import("iris.fileformats.pp") def time_fileformats_pp_load_rules(self): - import iris.fileformats.pp_load_rules + self._import("iris.fileformats.pp_load_rules") def time_fileformats_pp_save_rules(self): - import iris.fileformats.pp_save_rules + self._import("iris.fileformats.pp_save_rules") def time_fileformats_rules(self): - import iris.fileformats.rules + self._import("iris.fileformats.rules") def time_fileformats_um(self): - import iris.fileformats.um + self._import("iris.fileformats.um") def time_fileformats_um__fast_load(self): - import iris.fileformats.um._fast_load + self._import("iris.fileformats.um._fast_load") def time_fileformats_um__fast_load_structured_fields(self): - import iris.fileformats.um._fast_load_structured_fields + self._import("iris.fileformats.um._fast_load_structured_fields") def time_fileformats_um__ff_replacement(self): - import iris.fileformats.um._ff_replacement + self._import("iris.fileformats.um._ff_replacement") def time_fileformats_um__optimal_array_structuring(self): - import iris.fileformats.um._optimal_array_structuring + self._import("iris.fileformats.um._optimal_array_structuring") def time_fileformats_um_cf_map(self): - import iris.fileformats.um_cf_map + self._import("iris.fileformats.um_cf_map") def time_io(self): - import iris.io + self._import("iris.io") def time_io_format_picker(self): - import iris.io.format_picker + self._import("iris.io.format_picker") def time_iterate(self): - import iris.iterate + self._import("iris.iterate") def time_palette(self): - import iris.palette + self._import("iris.palette") def time_plot(self): - import iris.plot + self._import("iris.plot") def time_quickplot(self): - import iris.quickplot + self._import("iris.quickplot") def time_std_names(self): - import iris.std_names + self._import("iris.std_names") def time_symbols(self): - import iris.symbols + self._import("iris.symbols") def time_tests(self): - import iris.tests + self._import("iris.tests") def time_time(self): - import iris.time + self._import("iris.time") def time_util(self): - import iris.util + self._import("iris.util") # third-party imports def time_third_party_cartopy(self): - import cartopy + self._import("cartopy") def time_third_party_cf_units(self): - import cf_units + self._import("cf_units") def time_third_party_cftime(self): - import cftime + self._import("cftime") def time_third_party_matplotlib(self): - import matplotlib + self._import("matplotlib") def time_third_party_numpy(self): - import numpy + self._import("numpy") def time_third_party_scipy(self): - import scipy + self._import("scipy") diff --git a/benchmarks/benchmarks/save.py b/benchmarks/benchmarks/save.py index d4c36ef983..730b63294d 100644 --- a/benchmarks/benchmarks/save.py +++ b/benchmarks/benchmarks/save.py @@ -25,8 +25,7 @@ class NetcdfSave: params = [[1, 600], [False, True]] param_names = ["cubesphere-N", "is_unstructured"] # For use on 'track_addedmem_..' type benchmarks - result is too noisy. - no_small_params = params - no_small_params[0] = params[0][1:] + no_small_params = [[600], [True]] def setup(self, n_cubesphere, is_unstructured): self.cube = make_cube_like_2d_cubesphere(