diff --git a/Changelog.rst b/Changelog.rst index 3a0b2560c6..f4a10d80ae 100644 --- a/Changelog.rst +++ b/Changelog.rst @@ -3,6 +3,9 @@ version NEXT **2024-??-??** +* Allow a halo to be added by `cf.Field.indices` and + `cf.Field.subspace` + (https://github.com/NCAS-CMS/cf-python/issues/759) * Added spherical regridding to discrete sampling geometry destination grids (https://github.com/NCAS-CMS/cf-python/issues/716) * Added 3-d spherical regridding to `cf.Field.regrids`, and the option diff --git a/cf/docstring/docstring.py b/cf/docstring/docstring.py index 94d2c03935..10d9d5c87a 100644 --- a/cf/docstring/docstring.py +++ b/cf/docstring/docstring.py @@ -80,6 +80,13 @@ Whether `esmpy` logging is enabled or not is determined by `cf.regrid_logging`. If it is enabled then logging takes place after every call. By default logging is disabled.""", + # subspace halos + "{{subspace halos}}": """If a halo is defined via a positional argument, then each + subspaced axis will be extended to include that many extra + elements at each "side" of the axis. The number of extra + elements will be automatically reduced if including the full + amount defined by the halo would extend the subspace beyond + the axis limits.""", # ---------------------------------------------------------------- # Method description substitutions (3 levels of indentation) # ---------------------------------------------------------------- @@ -613,6 +620,37 @@ "{{to_size: `int`, optional}}": """to_size: `int`, optional Pad the axis after so that the new axis has the given size.""", + # subspace config options + "{{config: optional}}": """config: optional + Configure the subspace by specifying the mode of + operation (``mode``) and any halo to be added to the + subspaced axes (``halo``), with positional arguments + in the format ``mode``, or ``halo``, or ``mode, + halo``, or with no positional arguments at all. + + A mode of operation is given as a `str`, and a halo as + a non-negative `int` (or any object that can be + converted to one): + + ============== ====================================== + *mode* Description + ============== ====================================== + Not provided If no positional arguments are + provided then assume the + ``'compress'`` mode of operation with + no halo added to the subspaced axes. + + ``mode`` Define the mode of operation with no + halo added to the subspaced axes. + + ``mode, halo`` Define a mode of operation, as well as + a halo to be added to the subspaced + axes. + + ``halo`` Assume the ``'compress'`` mode of + operation and define a halo to be + added to the subspaced axes. + ============== ======================================""", # ---------------------------------------------------------------- # Method description substitutions (4 levels of indentation) # ---------------------------------------------------------------- @@ -663,4 +701,49 @@ The removed CFA-netCDF file name substitution. If the substitution was not defined then an empty dictionary is returned.""", + # subspace valid modes Field + "{{subspace valid modes Field}}": """Valid modes are: + + * ``'compress'`` This is the default. + Unselected locations are removed to create the + subspace. If the result is not hyperrectangular + then the minimum amount of unselected locations + required to make it so will also be specially + selected. Missing data is inserted at the + specially selected locations, unless a halo has + been defined (of any size, including 0). + + * ``'envelope'`` + The subspace is the smallest hyperrectangular + subspace that contains all of the selected + locations. Missing data is inserted at unselected + locations within the envelope, unless a halo has + been defined (of any size, including 0). + + * ``'full'`` + The subspace has the same domain as the original + construct. Missing data is inserted at unselected + locations, unless a halo has been defined (of any + size, including 0). + + .. note:: Setting a halo size of `0` differs from not + not defining a halo at all. The shape of the + returned field will always be the same, but + in the former case missing data will not be + inserted at unselected locations (if any) + within the output domain.""", + # subspace valid modes Domain + "{{subspace valid modes Domain}}": """Valid modes are: + + * ``'compress'`` This is the default. + Unselected locations are removed to create the + subspace. If the result is not hyperrectangular + then the minimum amount of unselected locations + required to make it so will also be specially + selected. + + * ``'envelope'`` + The subspace is the smallest hyperrectangular + subspace that contains all of the selected + locations.""", } diff --git a/cf/domain.py b/cf/domain.py index 262dd56b7f..1fbdb73e1c 100644 --- a/cf/domain.py +++ b/cf/domain.py @@ -739,11 +739,11 @@ def identities(self): return out - def indices(self, *mode, **kwargs): + def indices(self, *config, **kwargs): """Create indices that define a subspace of the domain construct. - The indices returned by this method be used to create the + The indices returned by this method may be used to create the subspace by passing them to the `subspace` method of the original domain construct. @@ -778,6 +778,10 @@ def indices(self, *mode, **kwargs): may still need to be inserted into the field construct's data. + **Halos** + + {{subspace halos}} + .. versionadded:: 3.11.0 .. seealso:: `subspace`, `where`, `__getitem__`, @@ -785,27 +789,11 @@ def indices(self, *mode, **kwargs): :Parameters: - mode: `str`, *optional* - There are two modes of operation, each of which provides - indices for a different type of subspace: - - ============== ====================================== - *mode* Description - ============== ====================================== - ``'compress'`` This is the default mode. Unselected - locations are removed to create the - returned subspace. Note that if a - multi-dimensional metadata construct - is being used to define the indices - then some missing data may still be - inserted at unselected locations. - - ``'envelope'`` The returned subspace is the smallest - that contains all of the selected - indices. - ============== ====================================== - - kwargs: *optional* + {{config: optional}} + + {{subspace valid modes Domain}} + + kwargs: optional A keyword name is an identity of a metadata construct, and the keyword value provides a condition for inferring indices that apply to the dimension (or @@ -857,22 +845,9 @@ def indices(self, *mode, **kwargs): : time(1) = [2019-01-01 00:00:00] """ - if len(mode) > 1: - raise ValueError( - "Can't provide more than one positional argument. " - f"Got: {', '.join(repr(x) for x in mode)}" - ) - - if not mode or "compress" in mode: - mode = "compress" - elif "envelope" in mode: - mode = "envelope" - else: - raise ValueError(f"Invalid value for 'mode' argument: {mode[0]!r}") - # Get the indices for every domain axis in the domain, without # any auxiliary masks. - domain_indices = self._indices(mode, None, False, kwargs) + domain_indices = self._indices(config, None, False, kwargs) return domain_indices["indices"] @@ -1119,20 +1094,20 @@ def roll(self, axis, shift, inplace=False): return d - def subspace(self, *mode, **kwargs): - """Create indices that define a subspace of the domain - construct. + def subspace(self, *config, **kwargs): + """Create a subspace of the field construct. - The indices returned by this method be used to create the subspace - by passing them to the `subspace` method of the original domain - construct. + Creation of a new domain construct which spans a subspace of + the domain of an existing domain construct is achieved by + identifying indices based on the metadata constructs + (subspacing by metadata). The new domain construct is created + with the same properties as the original domain construct. - The subspace is defined by identifying indices based on the - metadata constructs. + **Subspacing by metadata** - Metadata constructs are selected conditions are specified on their - data. Indices for subspacing are then automatically inferred from - where the conditions are met. + Subspacing by metadata selects metadata constructs and + specifies conditions on their data. Indices for subspacing are + then automatically inferred from where the conditions are met. Metadata constructs and the conditions on their data are defined by keyword parameters. @@ -1156,41 +1131,21 @@ def subspace(self, *mode, **kwargs): acting along orthogonal dimensions, some missing data may still need to be inserted into the field construct's data. - .. versionadded:: 3.11.0 - - .. seealso:: `indices` - - :Parameters: + **Halos** - mode: `str`, *optional* - There are two modes of operation, each of which provides - indices for a different type of subspace: + {{subspace halos}} - ============== ========================================== - *mode* Description - ============== ========================================== - ``'compress'`` Return indices that identify only the - requested locations. + .. versionadded:: 3.11.0 - This is the default mode. + .. seealso:: `indices`, `cf.Field.subspace` - Note that if a multi-dimensional metadata - construct is being used to define the - indices then some unrequested locations - may also be selected. + :Parameters: - ``'envelope'`` The returned subspace is the smallest that - contains all of the requested locations. + {{config: optional}} - ``'test'`` May be used on its own or in addition to - one of the other positional arguments. Do - not create a subspace, but return `True` - or `False` depending on whether or not it - is possible to create the specified - subspace. - ============== ========================================== + {{subspace valid modes Domain}} - kwargs: *optional* + kwargs: optional A keyword name is an identity of a metadata construct, and the keyword value provides a condition for inferring indices that apply to the dimension (or dimensions) @@ -1231,19 +1186,19 @@ def subspace(self, *mode, **kwargs): """ test = False - if "test" in mode: - mode = list(mode) - mode.remove("test") + if "test" in config: + config = list(config) + config.remove("test") test = True - if not mode and not kwargs: + if not config and not kwargs: if test: return True return self.copy() try: - indices = self.indices(*mode, **kwargs) + indices = self.indices(*config, **kwargs) except ValueError as error: if test: return False diff --git a/cf/field.py b/cf/field.py index 165d85d427..12c8c885f6 100644 --- a/cf/field.py +++ b/cf/field.py @@ -8809,7 +8809,7 @@ def insert_dimension( inplace=inplace, ) - def indices(self, *mode, **kwargs): + def indices(self, *config, **kwargs): """Create indices that define a subspace of the field construct. The subspace is defined by identifying indices based on the @@ -8860,39 +8860,30 @@ def indices(self, *mode, **kwargs): actually created, these masks are all automatically applied to the result. + **Halos** + + {{subspace halos}} + + For instance, ``f.indices(X=slice(10, 20))`` will give + identical results to each of ``f.indices(0, X=slice(10, + 20))``, ``f.indices(1, X=slice(11, 19))``, ``f.indices(2, + X=slice(12, 18))``, etc. + + If a halo has been defined (of any size, including 0), then no + ancillary masks will be created. + + .. versionadded:: 1.0 + .. seealso:: `subspace`, `where`, `__getitem__`, `__setitem__`, `cf.Domain.indices` :Parameters: - mode: `str`, *optional* - There are three modes of operation, each of which - provides indices for a different type of subspace: - - ============== ====================================== - *mode* Description - ============== ====================================== - ``'compress'`` This is the default mode. Unselected - locations are removed to create the - returned subspace. Note that if a - multi-dimensional metadata construct - is being used to define the indices - then some missing data may still be - inserted at unselected locations. - - ``'envelope'`` The returned subspace is the smallest - that contains all of the selected - indices. Missing data is inserted at - unselected locations within the - envelope. - - ``'full'`` The returned subspace has the same - domain as the original field - construct. Missing data is inserted at - unselected locations. - ============== ====================================== - - kwargs: *optional* + {{config: optional}} + + {{subspace valid modes Field}} + + kwargs: optional A keyword name is an identity of a metadata construct, and the keyword value provides a condition for inferring indices that apply to the dimension (or @@ -9015,7 +9006,7 @@ def indices(self, *mode, **kwargs): [-- -- -- -- -- -- 270.6 273.0 270.6]]] """ - if "exact" in mode: + if "exact" in config: _DEPRECATION_ERROR_ARG( self, "indices", @@ -9025,26 +9016,11 @@ def indices(self, *mode, **kwargs): removed_at="4.0.0", ) # pragma: no cover - if len(mode) > 1: - raise ValueError( - "Can't provide more than one positional argument. " - f"Got: {', '.join(repr(x) for x in mode)}" - ) - - if not mode or "compress" in mode: - mode = "compress" - elif "envelope" in mode: - mode = "envelope" - elif "full" in mode: - mode = "full" - else: - raise ValueError(f"Invalid value for 'mode' argument: {mode[0]!r}") - data_axes = self.get_data_axes() # Get the indices for every domain axis in the domain, # including any ancillary masks - domain_indices = self._indices(mode, data_axes, True, kwargs) + domain_indices = self._indices(config, data_axes, True, kwargs) # Initialise the output indices with any ancillary masks. # Ensure that each ancillary mask is broadcastable to the @@ -9076,7 +9052,9 @@ def indices(self, *mode, **kwargs): # spanned by the data if len(axis_indices) > len(data_axes): for axis, index in axis_indices.items(): - if axis in data_axes or index == slice(None): + if axis in data_axes or ( + isinstance(index, slice) and index == slice(None) + ): continue import dask.array as da @@ -13292,44 +13270,33 @@ def subspace(self): ``3:-2:-1``) is assumed to "wrap" around, rather then producing a null result. + **Halos** - .. seealso:: `indices`, `squeeze`, `where`, `__getitem__` + {{subspace halos}} + + For instance, ``f.subspace(X=slice(10, 20))`` will give + identical results to each of ``f.subspace(0, X=slice(10, + 20))``, ``f.subspace(1, X=slice(11, 19))``, ``f.subspace(2, + X=slice(12, 18))``, etc. + + .. versionadded:: 1.0 + + .. seealso:: `indices`, `where`, `__getitem__`, + `__setitem__`, `cf.Domain.subspace` :Parameters: - positional arguments: *optional* - There are three modes of operation, each of which provides - a different type of subspace: + {{config: optional}} - ============== ========================================== - *argument* Description - ============== ========================================== - ``'compress'`` This is the default mode. Unselected - locations are removed to create the - returned subspace. Note that if a - multi-dimensional metadata construct is - being used to define the indices then some - missing data may still be inserted at - unselected locations. - - ``'envelope'`` The returned subspace is the smallest that - contains all of the selected - indices. Missing data is inserted at - unselected locations within the envelope. - - ``'full'`` The returned subspace has the same domain - as the original field construct. Missing - data is inserted at unselected locations. - - ``'test'`` May be used on its own or in addition to - one of the other positional arguments. Do - not create a subspace, but return `True` - or `False` depending on whether or not it - is possible to create specified the - subspace. - ============== ========================================== + {{subspace valid modes Field}} + + In addition, an extra positional argument of ``'test'`` + is allowed. When provided, the subspace is not + returned, instead `True` or `False` is returned + depending on whether or not it is possible for the + requested subspace to be created. - keyword parameters: *optional* + keyword parameters: optional A keyword name is an identity of a metadata construct, and the keyword value provides a condition for inferring indices that apply to the dimension (or dimensions) diff --git a/cf/functions.py b/cf/functions.py index 70eb6a81df..a7f67bc3d3 100644 --- a/cf/functions.py +++ b/cf/functions.py @@ -1966,68 +1966,48 @@ def parse_indices(shape, indices, cyclic=False, keepdims=True): for i, (index, size) in enumerate(zip(parsed_indices, shape)): if cyclic and isinstance(index, slice): # Check for a cyclic slice - start = index.start - stop = index.stop - step = index.step - if start is None or stop is None: - step = 0 - elif step is None: - step = 1 - - if step > 0: - if 0 < start < size and 0 <= stop <= start: - # 6:0:1 => -4:0:1 - # 6:1:1 => -4:1:1 - # 6:3:1 => -4:3:1 - # 6:6:1 => -4:6:1 - start = size - start - elif -size <= start < 0 and -size <= stop <= start: - # -4:-10:1 => -4:1:1 - # -4:-9:1 => -4:1:1 - # -4:-7:1 => -4:3:1 - # -4:-4:1 => -4:6:1 - # -10:-10:1 => -10:0:1 - stop += size - elif step < 0: - if -size <= start < 0 and start <= stop < 0: - # -4:-1:-1 => 6:-1:-1 - # -4:-2:-1 => 6:-2:-1 - # -4:-4:-1 => 6:-4:-1 - # -10:-2:-1 => 0:-2:-1 - # -10:-10:-1 => 0:-10:-1 - start += size - elif 0 <= start < size and start < stop < size: - # 0:6:-1 => 0:-4:-1 - # 3:6:-1 => 3:-4:-1 - # 3:9:-1 => 3:-1:-1 - stop -= size - - if step > 0 and -size <= start < 0 and 0 <= stop <= size + start: - # x = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] - # x[ -1:0:1] => [9] - # x[ -1:1:1] => [9, 0] - # x[ -1:3:1] => [9, 0, 1, 2] - # x[ -1:9:1] => [9, 0, 1, 2, 3, 4, 5, 6, 7, 8] - # x[ -4:0:1] => [6, 7, 8, 9] - # x[ -4:1:1] => [6, 7, 8, 9, 0] - # x[ -4:3:1] => [6, 7, 8, 9, 0, 1, 2] - # x[ -4:6:1] => [6, 7, 8, 9, 0, 1, 2, 3, 4, 5] - # x[ -9:0:1] => [1, 2, 3, 4, 5, 6, 7, 8, 9] - # x[ -9:1:1] => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] - # x[-10:0:1] => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] - index = slice(0, stop - start, step) - roll[i] = -start - - elif step < 0 and 0 <= start < size and start - size <= stop < 0: - # x = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] - # x[0: -4:-1] => [0, 9, 8, 7] - # x[6: -1:-1] => [6, 5, 4, 3, 2, 1, 0] - # x[6: -2:-1] => [6, 5, 4, 3, 2, 1, 0, 9] - # x[6: -4:-1] => [6, 5, 4, 3, 2, 1, 0, 9, 8, 7] - # x[0: -2:-1] => [0, 9] - # x[0:-10:-1] => [0, 9, 8, 7, 6, 5, 4, 3, 2, 1] - index = slice(start - stop - 1, None, step) - roll[i] = -1 - stop + try: + index = normalize_slice(index, size, cyclic=True) + except IndexError: + # Non-cyclic slice + pass + else: + # Cyclic slice + start = index.start + stop = index.stop + step = index.step + if ( + step > 0 + and -size <= start < 0 + and 0 <= stop <= size + start + ): + # x = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + # x[ -1:0:1] => [9] + # x[ -1:1:1] => [9, 0] + # x[ -1:3:1] => [9, 0, 1, 2] + # x[ -1:9:1] => [9, 0, 1, 2, 3, 4, 5, 6, 7, 8] + # x[ -4:0:1] => [6, 7, 8, 9] + # x[ -4:1:1] => [6, 7, 8, 9, 0] + # x[ -4:3:1] => [6, 7, 8, 9, 0, 1, 2] + # x[ -4:6:1] => [6, 7, 8, 9, 0, 1, 2, 3, 4, 5] + # x[ -9:0:1] => [1, 2, 3, 4, 5, 6, 7, 8, 9] + # x[ -9:1:1] => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] + # x[-10:0:1] => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + index = slice(0, stop - start, step) + roll[i] = -start + + elif ( + step < 0 and 0 <= start < size and start - size <= stop < 0 + ): + # x = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + # x[0: -4:-1] => [0, 9, 8, 7] + # x[6: -1:-1] => [6, 5, 4, 3, 2, 1, 0] + # x[6: -2:-1] => [6, 5, 4, 3, 2, 1, 0, 9] + # x[6: -4:-1] => [6, 5, 4, 3, 2, 1, 0, 9, 8, 7] + # x[0: -2:-1] => [0, 9] + # x[0:-10:-1] => [0, 9, 8, 7, 6, 5, 4, 3, 2, 1] + index = slice(start - stop - 1, None, step) + roll[i] = -1 - stop elif keepdims and isinstance(index, Integral): # Convert an integral index to a slice @@ -2050,6 +2030,119 @@ def parse_indices(shape, indices, cyclic=False, keepdims=True): return parsed_indices, roll +def normalize_slice(index, size, cyclic=False): + """Normalise a slice. + + If *index* is not a slice, or *cyclic* is True and *index* is not + a cyclic slice, then an `IndexError` is raised. + + .. versionadded:: NEXTRELEASE + + :Parameters: + + index: + The index to be normalised. + + size: `int` + The size of the axis to which the *index* applies. + + cyclic: `bool` + If True then normalise a cyclic slice, and raise an + exception if the *index* is a not a cyclic slice. + + :Returns: + + `slice` + The normalised slice. + + **Examples** + + >>> cf.normalize_slice(slice(1, 4), 8) + slice(1, 4, 1) + >>> cf.normalize_slice(slice(None), 8) + slice(0, 8, 1) + >>> cf.normalize_slice(slice(6, None, -1), 8) + slice(6, None, -1) + >>> cf.normalize_slice(slice(-2, 4), 8) + slice(6, 4, 1) + + >>> cf.normalize_slice([1, 2], 8) + IndexError: [1, 2] is not a slice + + >>> cf.normalize_slice(slice(-2, 3), 8, cyclic=True) + slice(-2, 3, 1) + >>> cf.normalize_slice(slice(6, 3), 8, cyclic=True) + slice(-2, 3, 1) + + >>> cf.normalize_slice(slice(2, -3, -1), 8, cyclic=True) + slice(2, -3, -1) + >>> cf.normalize_slice(slice(2, 5, -1), 8, cyclic=True) + slice(2, -3, -1) + + >>> cf.normalize_slice(slice(1, 6), 8, cyclic=True) + IndexError: slice(1, 6, None) is not a cyclic slice + >>> cf.normalize_slice([1, 2, 3, 4], 8, cyclic=True) + IndexError: [1, 2, 3, 4] is not a cyclic slice + + """ + if not isinstance(index, slice): + step = 0 + elif cyclic: + start = index.start + stop = index.stop + step = index.step + if start is None or stop is None: + step = 0 + elif step is None: + step = 1 + + if step > 0: + if 0 < start < size and 0 <= stop <= start: + # 6:0:1 => -4:0:1 + # 6:1:1 => -4:1:1 + # 6:3:1 => -4:3:1 + # 6:6:1 => -4:6:1 + start -= size + elif -size <= start < 0 and -size <= stop <= start: + # -4:-10:1 => -4:1:1 + # -4:-9:1 => -4:1:1 + # -4:-7:1 => -4:3:1 + # -4:-4:1 => -4:6:1 + # -10:-10:1 => -10:0:1 + stop += size + elif step < 0: + if -size <= start < 0 and start <= stop < 0: + # -4:-1:-1 => 6:-1:-1 + # -4:-2:-1 => 6:-2:-1 + # -4:-4:-1 => 6:-4:-1 + # -10:-2:-1 => 0:-2:-1 + # -10:-10:-1 => 0:-10:-1 + start += size + elif 0 <= start < size and start < stop < size: + # 0:6:-1 => 0:-4:-1 + # 3:6:-1 => 3:-4:-1 + # 3:9:-1 => 3:-1:-1 + stop -= size + else: + start, stop, step = index.indices(size) + if step < 0 and stop < 0: + stop = None + + # Return the normalized non-cyclic slice + return slice(start, stop, step) + + if not ( + (step > 0 and start < 0 and stop > 0) + or (step < 0 and start > 0 and stop < 0) + ): + raise IndexError( + f"{index!r} is not a {'cyclic ' if cyclic else ''}slice" + ) + + # Return the normalized cyclic slice + return slice(start, stop, step) + + def get_subspace(array, indices): """Return a subspace defined by the given indices of an array. diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index 85cf503e55..9cd3a1a680 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -1,9 +1,10 @@ import logging from numbers import Integral -import dask.array as da import numpy as np from cfdm import is_log_level_debug, is_log_level_info +from dask.array.slicing import normalize_index +from dask.base import is_dask_collection from ..data import Data from ..decorators import ( @@ -12,7 +13,11 @@ _inplace_enabled_define_and_cleanup, _manage_log_level_via_verbosity, ) -from ..functions import _DEPRECATION_ERROR_KWARGS, bounds_combination_mode +from ..functions import ( + _DEPRECATION_ERROR_KWARGS, + bounds_combination_mode, + normalize_slice, +) from ..query import Query from ..units import Units @@ -198,7 +203,7 @@ def _equivalent_coordinate_references( return True - def _indices(self, mode, data_axes, ancillary_mask, kwargs): + def _indices(self, config, data_axes, ancillary_mask, kwargs): """Create indices that define a subspace of the field or domain construct. @@ -212,9 +217,9 @@ def _indices(self, mode, data_axes, ancillary_mask, kwargs): :Parameters: - mode: `str` - The mode of operation. See the *mode* parameter of - `indices` for details. + config: `tuple` + The mode of operation and the halo size. See the + *config* parameter of `indices` for details. data_axes: sequence of `str`, or `None` The domain axis identifiers of the data axes, or @@ -249,9 +254,46 @@ def _indices(self, mode, data_axes, ancillary_mask, kwargs): """ debug = is_log_level_debug(logger) - compress = mode == "compress" + # Parse mode and halo + n_config = len(config) + if not n_config: + mode = None + halo = None + elif n_config == 1: + try: + halo = int(config[0]) + except ValueError: + mode = config[0] + halo = None + else: + mode = None + elif n_config == 2: + mode, halo = config + else: + raise ValueError( + "Can't provide more than two positional arguments. " + f"Got: {', '.join(repr(x) for x in config)}" + ) + + compress = mode is None or mode == "compress" envelope = mode == "envelope" full = mode == "full" + if not (compress or envelope or full): + raise ValueError(f"Invalid mode of operation: {mode!r}") + + if halo is not None: + try: + halo = int(halo) + except ValueError: + ok = False + else: + ok = halo >= 0 + + if not ok: + raise ValueError( + "halo positional argument must be convertible to a " + f"non-negative integer. Got {halo!r}" + ) domain_axes = self.domain_axes(todict=True) @@ -373,18 +415,17 @@ def _indices(self, mode, data_axes, ancillary_mask, kwargs): # [7,4,2], slice(0,4,2), # numpy.array([2,4,7]), # [True,False,True] - index = value if debug: logger.debug(" 1-d CASE 1:") # pragma: no cover index = value if envelope or full: + # Set ind size = domain_axes[axis].get_size() - # TODODASK: consider using dask.arange here - d = np.arange(size) # self._Data(range(size)) - ind = (d[value],) # .array,) - index = slice(None) + ind = (np.arange(size)[value],) + # Placeholder which will be overwritten later + index = None elif ( item is not None @@ -447,10 +488,24 @@ def _indices(self, mode, data_axes, ancillary_mask, kwargs): index = slice(start, stop, 1) if full: - d = self._Data(da.arange(size)) - d.cyclic(0) - ind = (d[index].array,) - index = slice(None) + # Set ind + try: + index = normalize_slice(index, size, cyclic=True) + except IndexError: + # Index is not a cyclic slice + ind = (np.arange(size)[index],) + else: + # Index is a cyclic slice + ind = ( + np.arange(size)[ + np.arange( + index.start, index.stop, index.step + ) + ], + ) + + # Placeholder which will be overwritten later + index = None elif item is not None: # 1-d CASE 3: All other 1-d cases @@ -458,17 +513,30 @@ def _indices(self, mode, data_axes, ancillary_mask, kwargs): logger.debug(" 1-d CASE 3:") # pragma: no cover index = item == value - index = index.data.to_dask_array() + + # Performance: Convert the 1-d 'index' to a numpy + # array of bool. + # + # This is beacuse Dask can be *very* slow at + # instantiation time when the 'index' is a Dask + # array, in which case contents of 'index' are + # unknown. + index = np.asanyarray(index) if envelope or full: + # Set ind index = np.asanyarray(index) if np.ma.isMA(index): ind = np.ma.where(index) else: ind = np.where(index) - index = slice(None) - + # Placeholder which will be overwritten later + index = None + else: + # Convert bool to int, to save memory. + size = domain_axes[axis].get_size() + index = normalize_index(index, (size,))[0] else: raise ValueError( "Must specify a domain axis construct or a " @@ -523,17 +591,22 @@ def _indices(self, mode, data_axes, ancillary_mask, kwargs): ] # Find loctions that are True in all of the - # construct's matches + # constructs' matches item_match = item_matches.pop() for m in item_matches: item_match &= m - item_match = item_match.compute() - if np.ma.isMA: + # Set ind + item_match = np.asanyarray(item_match) + if np.ma.isMA(item_match): ind = np.ma.where(item_match) else: ind = np.where(item_match) + # Placeholders which will be overwritten later + for axis in canonical_axes: + indices[axis] = None + if debug: logger.debug( f" item_match = {item_match}\n" @@ -553,13 +626,13 @@ def _indices(self, mode, data_axes, ancillary_mask, kwargs): if item.has_bounds() ] - # If there are exactly two 2-d contructs constructs, - # both with cell bounds and both with 'cf.contains' - # values, then do an extra check to remove any cells - # already selected for which the given value is in - # fact outside of the cell. This could happen if the - # cells are not rectangular (e.g. for curvilinear - # latitudes and longitudes array). + # If there are exactly two 2-d constructs, both with + # cell bounds and both with 'cf.contains' values, then + # do an extra check to remove any cells already + # selected for which the given value is in fact + # outside of the cell. This could happen if the cells + # are not rectilinear (e.g. for curvilinear latitudes + # and longitudes arrays). if n_items == constructs[0].ndim == len(bounds) == 2: point2 = [] for v, construct in zip(points, transposed_constructs): @@ -626,7 +699,8 @@ def _point_not_in_cell(nodes_x, nodes_y, point): if ind is not None: mask_component_shape = [] masked_subspace_size = 1 - ind = np.array(ind) + # TODONUMPY2: https://numpy.org/devdocs/numpy_2_0_migration_guide.html#adapting-to-changes-in-the-copy-keyword + ind = np.array(ind, copy=False) for i, (axis, start, stop) in enumerate( zip(canonical_axes, ind.min(axis=1), ind.max(axis=1)) @@ -634,28 +708,35 @@ def _point_not_in_cell(nodes_x, nodes_y, point): if data_axes and axis not in data_axes: continue - if indices[axis] == slice(None): - if compress: - # Create a compressed index for this axis - size = stop - start + 1 - index = sorted(set(ind[i])) - elif envelope: - # Create an envelope index for this axis - stop += 1 - size = stop - start - index = slice(start, stop) - elif full: - # Create a full index for this axis - start = 0 - stop = domain_axes[axis].get_size() - size = stop - start - index = slice(None) - else: - raise ValueError( - "Must have mode full, envelope or compress" - ) # pragma: no cover + if compress: + # Create a compressed index for this axis + size = stop - start + 1 + index = sorted(set(ind[i])) + elif envelope: + # Create an envelope index for this axis + stop += 1 + size = stop - start + index = slice(start, stop) + elif full: + # Create a full index for this axis + start = 0 + stop = domain_axes[axis].get_size() + size = stop - start + index = slice(None) + else: + raise ValueError( + "Must have mode full, envelope, or compress" + ) # pragma: no cover - indices[axis] = index + # Overwrite the placeholder value of None + if indices[axis] is not None: + raise ValueError( + "This error means that there is a bug: The " + "'indices' dictionary should contain None for " + "each axis with an 'ind'." + ) + + indices[axis] = index mask_component_shape.append(size) masked_subspace_size *= size @@ -663,12 +744,208 @@ def _point_not_in_cell(nodes_x, nodes_y, point): create_mask = ( ancillary_mask + and halo is None and data_axes and ind.shape[1] < masked_subspace_size ) else: create_mask = False + # -------------------------------------------------------- + # Add a halo to the subspaced axes + # -------------------------------------------------------- + if halo: + # Note: We're about to make 'indices' inconsistent + # with 'ind', but that's OK because we're not + # going to use 'ind' again as 'create_mask' is + # False. + reduced_halo = False + for axis in item_axes: + index = indices[axis] + size = domain_axes[axis].get_size() + + try: + # Is index a cyclic slice? + index = normalize_slice(index, size, cyclic=True) + except IndexError: + try: + index = normalize_slice(index, size) + except IndexError: + # Index is not a slice + cyclic = False + else: + # Index is a non-cyclic slice, but if the + # axis is cyclic then it could become a + # cyclic slice once the halo is added. + cyclic = self.iscyclic(axis) + else: + # Index is a cyclic slice + cyclic = self.iscyclic(axis) + if not cyclic: + raise IndexError( + "Can't take a cyclic slice of a non-cyclic " + "axis" + ) + + if cyclic: + # Cyclic slice, or potentially cyclic slice. + # + # E.g. for halo=1 and size=5: + # slice(0, 2) -> slice(-1, 3) + # slice(-1, 2) -> slice(-2, 3) + # slice(1, None, -1) -> slice(2, -2, -1) + # slice(1, -2, -1) -> slice(2, -3, -1) + start = index.start + stop = index.stop + step = index.step + if step not in (1, -1): + # This restriction is due to the fact that + # the extended index is a slice (rather + # than a list of integers), and so we + # can't represent the uneven spacing that + # would be required if abs(step) != 1. + # Note that cyclic slices created by this + # method will always have a step of 1. + raise IndexError( + "A cyclic slice index can only have halos if " + f"it has step 1 or -1. Got {index!r}" + ) + + if step < 0 and stop is None: + stop = -1 + + if step > 0: + # Increasing cyclic slice + start = start - halo + if start < stop - size: + start = stop - size + reduced_halo = True + + stop = stop + halo + if stop > size + start: + stop = size + start + reduced_halo = True + + else: + # Decreasing cyclic slice + start = start + halo + if start > size + stop: + start = size + stop + reduced_halo = True + + stop = stop - halo + if stop < start - size: + stop = start - size + reduced_halo = True + + index = slice(start, stop, step) + else: + # A list/1-d array of int/bool, or a + # non-cyclic slice that can't become cyclic. + # + # E.g. for halo=1 and size=5: + # slice(1, 3) -> [0, 1, 2, 3] + # slice(1, 4, 2) -> [0, 1, 3, 4] + # slice(2, 0, -1) -> [3, 2, 1, 0] + # slice(2, 0, -1) -> [3, 2, 1, 0] + # [1, 2] -> [0, 1, 2, 3] + # [1, 3] -> [0, 1, 3, 4] + # [2, 1] -> [3, 2, 1, 0] + # [3, 1] -> [4, 3, 1, 0] + # [1, 3, 2] -> [0, 1, 3, 2, 1] + # [False, True, False, True, False] -> [0, 1, 3, 4] + if isinstance(index, slice): + index = np.arange(size)[index] + else: + if is_dask_collection(index): + index = np.asanyarray(index) + + index = normalize_index(index, (size,))[0] + + # Find the left-most and right-most elements + # ('iL' and iR') of the sequence of positive + # integers, and whether the sequence is + # increasing or decreasing at each end + # ('increasing_L' and 'increasing_R') + # + # For instance: + # + # ------------ -- -- ------------ ------------ + # index iL iR increasing_L increasing_R + # ------------ -- -- ------------ ------------ + # [1, 2, 3, 4] 1 4 True True + # [4, 3, 2, 1] 4 1 False False + # [2, 1, 3, 4] 2 4 False True + # [1, 2, 4, 3] 1 3 True False + # [2, 2, 3, 4] 2 4 True True + # [2, 2, 4, 3] 2 3 True False + # [3, 3, 4, 4] 3 4 True True + # [4, 4, 3, 3] 4 3 False False + # [10] 10 10 True True + # ------------ -- -- ------------ ------------ + n_index = index.size + if n_index == 1: + iL = index[0] + iR = iL + increasing_L = True + increasing_R = True + elif n_index > 1: + iL = index[0] + iR = index[-1] + increasing_L = iL <= index[np.argmax(index != iL)] + increasing_R = ( + iR >= index[-1 - np.argmax(index[::-1] != iR)] + ) + else: + raise IndexError( + "Can't add a halo to a zero-sized index: " + f"{index}" + ) + + # Extend the list at each end, but not + # exceeding the axis limits. + if increasing_L: + start = iL - halo + if start < 0: + start = 0 + reduced_halo = True + + left = range(start, iL) + else: + start = iL + halo + if start > size - 1: + start = size - 1 + reduced_halo = True + + left = range(start, iL, -1) + + if increasing_R: + stop = iR + 1 + halo + if stop > size: + stop = size + reduced_halo = True + + right = range(iR + 1, stop) + else: + stop = iR - 1 - halo + if stop < -1: + stop = -1 + reduced_halo = True + + right = range(iR - 1, stop, -1) + + index = index.tolist() + index[:0] = left + index.extend(right) + + # Reset the returned index + indices[axis] = index + + if reduced_halo: + logger.warning( + "Halo reduced to keep subspace within axis limits" + ) + # Create an ancillary mask for these axes if debug: logger.debug( diff --git a/cf/subspacefield.py b/cf/subspacefield.py index 6e6a554ba7..b9e1cf25d5 100644 --- a/cf/subspacefield.py +++ b/cf/subspacefield.py @@ -2,7 +2,7 @@ class SubspaceField(mixin.Subspace): - """Create a subspace of a field construct. + """Create a subspace of the field construct. Creation of a new field construct which spans a subspace of the domain of an existing field construct is achieved either by @@ -70,37 +70,94 @@ class SubspaceField(mixin.Subspace): ``3:-2:-1``) is assumed to "wrap" around, rather then producing a null result. - .. seealso:: `cf.Field.indices`, `cf.Field.squeeze`, - `cf.Field.where` + **Halos** + + If a halo is defined via a positional argument, then each + subspaced axis will be extended to include that many extra + elements at each "side" of the axis. The number of extra elements + will be automatically reduced if including the full amount defined + by the halo would extend the subspace beyond the axis limits. + + For instance, ``f.subspace(X=slice(10, 20))`` will give identical + results to each of ``f.subspace(0, X=slice(10, 20))``, + ``f.subspace(1, X=slice(11, 19))``, ``f.subspace(2, X=slice(12, + 18))``, etc. + + .. seealso:: `cf.Field.indices`, `cf.Field.where`, + `cf.Field.__getitem__`, `cf.Field.__setitem__`, + `cf.Domain.subspace` :Parameters: - positional arguments: *optional* - There are three modes of operation, each of which provides - a different type of subspace: - - ============== ========================================== - *argument* Description - ============== ========================================== - ``'compress'`` This is the default mode. Unselected - locations are removed to create the - returned subspace. Note that if a - multi-dimensional metadata construct is - being used to define the indices then some - missing data may still be inserted at - unselected locations. - - ``'envelope'`` The returned subspace is the smallest that - contains all of the selected - indices. Missing data is inserted at - unselected locations within the envelope. - - ``'full'`` The returned subspace has the same domain - as the original field construct. Missing - data is inserted at unselected locations. - ============== ========================================== - - keyword parameters: *optional* + config: optional + Configure the subspace by specifying the mode of operation + (``mode``) and any halo to be added to the subspaced axes + (``halo``), with positional arguments in the format + ``mode``, or ``halo``, or ``mode, halo``, or with no + positional arguments at all. + + A mode of operation is given as a `str`, and a halo as a + non-negative `int` (or any object that can be converted to + one): + + ============== ====================================== + *mode* Description + ============== ====================================== + Not provided If no positional arguments are + provided then assume the + ``'compress'`` mode of operation with + no halo added to the subspaced axes. + + ``mode`` Define the mode of operation with no + halo added to the subspaced axes. + + ``mode, halo`` Define a mode of operation, as well as + a halo to be added to the subspaced + axes. + + ``halo`` Assume the ``'compress'`` mode of + operation and define a halo to be + added to the subspaced axes. + ============== ====================================== + + Valid modes are: + + * ``'compress'`` This is the default. + Unselected locations are removed to create the + subspace. If the result is not hyperrectangular then + the minimum amount of unselected locations required + to make it so will also be specially + selected. Missing data is inserted at the specially + selected locations, unless a halo has been defined + (of any size, including 0). + + * ``'envelope'`` + The subspace is the smallest hyperrectangular + subspace that contains all of the selected + locations. Missing data is inserted at unselected + locations within the envelope, unless a halo has been + defined (of any size, including 0). + + * ``'full'`` + The subspace has the same domain as the original + construct. Missing data is inserted at unselected + locations, unless a halo has been defined (of any + size, including 0). + + .. note:: Setting a halo size of `0` differs from not not + defining a halo at all. The shape of the + returned field will always be the same, but in + the former case missing data will not be + inserted at unselected locations (if any) within + the output domain. + + In addition, an extra positional argument of ``'test'`` is + allowed. When provided, the subspace is not returned, + instead `True` or `False` is returned depending on whether + or not it is possible for the requested subspace to be + created. + + keyword parameters: optional A keyword name is an identity of a metadata construct, and the keyword value provides a condition for inferring indices that apply to the dimension (or dimensions) @@ -110,9 +167,12 @@ class SubspaceField(mixin.Subspace): :Returns: - `Field` + `Field` or `bool` An independent field construct containing the subspace of - the original field. + the original field. If the ``'test'`` positional argument + has been set then return `True` or `False` depending on + whether or not it is possible to create specified + subspace. **Examples** @@ -149,7 +209,7 @@ class SubspaceField(mixin.Subspace): __slots__ = [] - def __call__(self, *args, **kwargs): + def __call__(self, *config, **kwargs): """Create a subspace of a field construct. Creation of a new field construct which spans a subspace of the @@ -190,6 +250,10 @@ def __call__(self, *args, **kwargs): acting along orthogonal dimensions, some missing data may still need to be inserted into the field construct's data. + **Halos** + + {{subspace halos}} + .. seealso:: `cf.Field.indices`, `cf.Field.squeeze`, `cf.Field.where` @@ -268,19 +332,19 @@ def __call__(self, *args, **kwargs): field = self.variable test = False - if "test" in args: - args = list(args) - args.remove("test") + if "test" in config: + config = list(config) + config.remove("test") test = True - if not args and not kwargs: + if not config and not kwargs: if test: return True return field.copy() try: - indices = field.indices(*args, **kwargs) + indices = field.indices(*config, **kwargs) out = field[indices] except (ValueError, IndexError) as error: if test: diff --git a/cf/test/test_Field.py b/cf/test/test_Field.py index 13b36bf426..4dc2cab40d 100644 --- a/cf/test/test_Field.py +++ b/cf/test/test_Field.py @@ -1561,6 +1561,132 @@ def test_Field_indices(self): self.assertEqual(g.construct("aux_x").array, 160) self.assertEqual(g.construct("aux_y").array, 3) + # Halos: monotonic increasing sequence + index = [2, 3, 4, 5] + indices = f.indices(0, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 4)) + x = g.dimension_coordinate("X").array + self.assertTrue((x == [80, 120, 160, 200]).all()) + + indices = f.indices(1, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 6)) + x = g.dimension_coordinate("X").array + self.assertTrue((x == [40, 80, 120, 160, 200, 240]).all()) + + indices = f.indices(999, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 9)) + x = g.dimension_coordinate("X").array + self.assertTrue((x == f.dimension_coordinate("X").array).all()) + + # Halos: non-monotonic sequence + index = [2, 3, 4, 1] + indices = f.indices(0, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 4)) + x = g.dimension_coordinate("X").array + self.assertTrue((x == [80, 120, 160, 40]).all()) + + indices = f.indices(1, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 6)) + x = g.dimension_coordinate("X").array + self.assertTrue((x == [40, 80, 120, 160, 40, 0]).all()) + + for halo in (2, 999): + indices = f.indices(halo, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 7)) + x = g.dimension_coordinate("X").array + self.assertTrue((x == [0, 40, 80, 120, 160, 40, 0]).all()) + + # Halos: cyclic slice increasing + for index in (cf.wi(70, 200), slice(2, 6)): + indices = f.indices(0, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 4)) + x = g.dimension_coordinate("X").array + self.assertTrue((x == [80, 120, 160, 200]).all()) + + indices = f.indices(1, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 6)) + x = g.dimension_coordinate("X").array + self.assertTrue((x == [40, 80, 120, 160, 200, 240]).all()) + + indices = f.indices(999, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 9)) + x = g.dimension_coordinate("X").array + self.assertTrue( + (x == [-120, -80, -40, 0, 40, 80, 120, 160, 200]).all() + ) + + # Halos: cyclic slice increasing + index = cf.wi(-170, 40) + indices = f.indices(0, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 6)) + x = g.dimension_coordinate("X").array + self.assertTrue((x == [-160, -120, -80, -40, 0, 40]).all()) + + indices = f.indices(1, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 8)) + x = g.dimension_coordinate("X").array + self.assertTrue((x == [-200, -160, -120, -80, -40, 0, 40, 80]).all()) + + indices = f.indices(2, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 9)) + x = g.dimension_coordinate("X").array + self.assertTrue( + (x == [-240, -200, -160, -120, -80, -40, 0, 40, 80]).all() + ) + + # Halos: cyclic slice decreasing + index = slice(1, -5, -1) + indices = f.indices(0, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 6)) + x = g.dimension_coordinate("X").array + self.assertTrue((x == [40, 0, -40, -80, -120, -160]).all()) + + indices = f.indices(1, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 8)) + x = g.dimension_coordinate("X").array + self.assertTrue((x == [80, 40, 0, -40, -80, -120, -160, -200]).all()) + + indices = f.indices(2, grid_longitude=index) + g = f[indices] + self.assertEqual(g.shape, (1, 10, 9)) + x = g.dimension_coordinate("X").array + self.assertTrue( + (x == [120, 80, 40, 0, -40, -80, -120, -160, -200]).all() + ) + + # Halos: ancillary masking + index = cf.wi(90, 100) + indices = f.indices(longitude=index) + g = f[indices] + self.assertTrue(np.ma.is_masked(g.array)) + + for halo in (0, 1): + indices = f.indices(halo, longitude=index) + g = f[indices] + self.assertFalse(np.ma.is_masked(g.array)) + + # Test API with 0/1/2 arguments + kwargs = {"grid_latitude": [1]} + i = f.indices(**kwargs) + j = f.indices(0, **kwargs) + k = f.indices("compress", 0, **kwargs) + self.assertEqual(i, j) + self.assertEqual(i, k) + # Subspace has size 0 axis resulting from dask array index indices = f.indices(grid_latitude=cf.contains(-23.2)) with self.assertRaises(IndexError): @@ -2623,6 +2749,14 @@ def test_Field_subspace(self): h = f.subspace(grid_longitude=np.float64(20)) self.assertTrue(g.equals(h)) + # Test API with 0/1/2 arguments + kwargs = {"grid_latitude": [1]} + i = f.subspace(**kwargs) + j = f.subspace(0, **kwargs) + k = f.subspace("compress", 0, **kwargs) + self.assertEqual(i, j) + self.assertEqual(i, k) + def test_Field_auxiliary_to_dimension_to_auxiliary(self): f = cf.example_field(0) nd = len(f.dimension_coordinates()) diff --git a/cf/test/test_functions.py b/cf/test/test_functions.py index a5e583aaaa..daff8a485e 100644 --- a/cf/test/test_functions.py +++ b/cf/test/test_functions.py @@ -354,6 +354,52 @@ def test_size(self): def test_CFA(self): self.assertEqual(cf.CFA(), cf.__cfa_version__) + def test_normalize_slice(self): + self.assertEqual(cf.normalize_slice(slice(1, 4), 8), slice(1, 4, 1)) + self.assertEqual(cf.normalize_slice(slice(None), 8), slice(0, 8, 1)) + self.assertEqual( + cf.normalize_slice(slice(6, None, -1), 8), slice(6, None, -1) + ) + self.assertEqual(cf.normalize_slice(slice(-2, 4), 8), slice(6, 4, 1)) + + # Cyclic slices + self.assertEqual( + cf.normalize_slice(slice(-2, 3), 8, cyclic=True), slice(-2, 3, 1) + ) + self.assertEqual( + cf.normalize_slice(slice(6, 3), 8, cyclic=True), slice(-2, 3, 1) + ) + self.assertEqual( + cf.normalize_slice(slice(6, 3, 2), 8, cyclic=True), slice(-2, 3, 2) + ) + + self.assertEqual( + cf.normalize_slice(slice(2, -3, -1), 8, cyclic=True), + slice(2, -3, -1), + ) + self.assertEqual( + cf.normalize_slice(slice(2, 5, -1), 8, cyclic=True), + slice(2, -3, -1), + ) + self.assertEqual( + cf.normalize_slice(slice(2, 5, -2), 8, cyclic=True), + slice(2, -3, -2), + ) + + with self.assertRaises(IndexError): + cf.normalize_slice([1, 2], 8) + + for index in ( + slice(1, 6), + slice(6, 1, -1), + slice(None, 4, None), + slice(1, 6, 0), + [1, 2], + 5, + ): + with self.assertRaises(IndexError): + cf.normalize_slice(index, 8, cyclic=True) + if __name__ == "__main__": print("Run date:", datetime.datetime.now()) diff --git a/docs/attribute/cf.Domain.indices.html b/docs/attribute/cf.Domain.indices.html deleted file mode 100644 index b0f069b511..0000000000 --- a/docs/attribute/cf.Domain.indices.html +++ /dev/null @@ -1,165 +0,0 @@ - - - - - - - - cf.Domain.indices — Documentation - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
-
- - -
- -
-

cf.Domain.indices

-
-
-Domain.indices = <function Domain.indices>[source]
-
- -
- - -
- -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/attribute/cf.Domain.subspace.html b/docs/attribute/cf.Domain.subspace.html deleted file mode 100644 index 96308a8d0b..0000000000 --- a/docs/attribute/cf.Domain.subspace.html +++ /dev/null @@ -1,165 +0,0 @@ - - - - - - - - cf.Domain.subspace — Documentation - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
-
- - -
- -
-

cf.Domain.subspace

-
-
-Domain.subspace = <function Domain.subspace>[source]
-
- -
- - -
- -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/attribute/cf.Field.indices.html b/docs/attribute/cf.Field.indices.html deleted file mode 100644 index 95e2dce415..0000000000 --- a/docs/attribute/cf.Field.indices.html +++ /dev/null @@ -1,165 +0,0 @@ - - - - - - - - cf.Field.indices — Documentation - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
-
- - -
- -
-

cf.Field.indices

-
-
-Field.indices = <function Field.indices>[source]
-
- -
- - -
- -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/method/cf.Field.subspace.html b/docs/method/cf.Field.subspace.html deleted file mode 100644 index ce4b00f785..0000000000 --- a/docs/method/cf.Field.subspace.html +++ /dev/null @@ -1,323 +0,0 @@ - - - - - - - - cf.Field.subspace — Documentation - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
-
- - -
- -
-

cf.Field.subspace

-
-
-Field.subspace()
-

Create a subspace of the field construct.

-

Creation of a new field construct which spans a subspace of the -domain of an existing field construct is achieved either by -identifying indices based on the metadata constructs (subspacing -by metadata) or by indexing the field construct directly -(subspacing by index).

-

The subspacing operation, in either case, also subspaces any -metadata constructs of the field construct (e.g. coordinate -metadata constructs) which span any of the domain axis constructs -that are affected. The new field construct is created with the -same properties as the original field construct.

-

Subspacing by metadata

-

Subspacing by metadata, signified by the use of round brackets, -selects metadata constructs and specifies conditions on their -data. Indices for subspacing are then automatically inferred from -where the conditions are met.

-

Metadata constructs and the conditions on their data are defined -by keyword parameters.

-
    -
  • Any domain axes that have not been identified remain unchanged.

  • -
  • Multiple domain axes may be subspaced simultaneously, and it -doesn’t matter which order they are specified in.

  • -
  • Subspace criteria may be provided for size 1 domain axes that -are not spanned by the field construct’s data.

  • -
  • Explicit indices may also be assigned to a domain axis -identified by a metadata construct, with either a Python slice -object, or a sequence of integers or booleans.

  • -
  • For a dimension that is cyclic, a subspace defined by a slice or -by a Query instance is assumed to “wrap” around the edges of -the data.

  • -
  • Conditions may also be applied to multi-dimensional metadata -constructs. The “compress” mode is still the default mode (see -the positional arguments), but because the indices may not be -acting along orthogonal dimensions, some missing data may still -need to be inserted into the field construct’s data.

  • -
-

Subspacing by index

-

Subspacing by indexing, signified by the use of square brackets, -uses rules that are very similar to the numpy indexing rules, the -only differences being:

-
    -
  • An integer index i specified for a dimension reduces the size of -this dimension to unity, taking just the i-th element, but keeps -the dimension itself, so that the rank of the array is not -reduced.

  • -
  • When two or more dimensions’ indices are sequences of integers -then these indices work independently along each dimension -(similar to the way vector subscripts work in Fortran). This is -the same indexing behaviour as on a Variable object of the -netCDF4 package.

  • -
  • For a dimension that is cyclic, a range of indices specified by -a slice that spans the edges of the data (such as -2:3 or -3:-2:-1) is assumed to “wrap” around, rather then producing -a null result.

  • -
-
-

See also

-

indices, squeeze, where, __getitem__

-
-
-
Parameters
-
-
positional arguments: optional

There are three modes of operation, each of which provides -a different type of subspace:

- ---- - - - - - - - - - - - - - - - - - - - -

argument

Description

'compress'

This is the default mode. Unselected -locations are removed to create the -returned subspace. Note that if a -multi-dimensional metadata construct is -being used to define the indices then some -missing data may still be inserted at -unselected locations.

'envelope'

The returned subspace is the smallest that -contains all of the selected -indices. Missing data is inserted at -unselected locations within the envelope.

'full'

The returned subspace has the same domain -as the original field construct. Missing -data is inserted at unselected locations.

'test'

May be used on its own or in addition to -one of the other positional arguments. Do -not create a subspace, but return True -or False depending on whether or not it -is possible to create specified the -subspace.

-
-
keyword parameters: optional

A keyword name is an identity of a metadata construct, and -the keyword value provides a condition for inferring -indices that apply to the dimension (or dimensions) -spanned by the metadata construct’s data. Indices are -created that select every location for which the metadata -construct’s data satisfies the condition.

-
-
-
-
Returns
-
-
Field or bool

An independent field construct containing the subspace of -the original field. If the 'test' positional argument -has been set then return True or False depending on -whether or not it is possible to create specified -subspace.

-
-
-
-
-

Examples

-

There are further worked examples -in the tutorial.

-
>>> g = f.subspace(X=112.5)
->>> g = f.subspace(X=112.5, latitude=cf.gt(-60))
->>> g = f.subspace(latitude=cf.eq(-45) | cf.ge(20))
->>> g = f.subspace(X=[1, 2, 4], Y=slice(None, None, -1))
->>> g = f.subspace(X=cf.wi(-100, 200))
->>> g = f.subspace(X=slice(-2, 4))
->>> g = f.subspace(Y=[True, False, True, True, False])
->>> g = f.subspace(T=410.5)
->>> g = f.subspace(T=cf.dt('1960-04-16'))
->>> g = f.subspace(T=cf.wi(cf.dt('1962-11-01'),
-...                        cf.dt('1967-03-17 07:30')))
->>> g = f.subspace('compress', X=[1, 2, 4, 6])
->>> g = f.subspace('envelope', X=[1, 2, 4, 6])
->>> g = f.subspace('full', X=[1, 2, 4, 6])
->>> g = f.subspace(latitude=cf.wi(51, 53))
-
-
-
>>> g = f.subspace[::-1, 0]
->>> g = f.subspace[:, :, 1]
->>> g = f.subspace[:, 0]
->>> g = f.subspace[..., 6:3:-1, 3:6]
->>> g = f.subspace[0, [2, 3, 9], [4, 8]]
->>> g = t.subspace[0, :, -2]
->>> g = f.subspace[0, [2, 3, 9], [4, 8]]
->>> g = f.subspace[:, -2:3]
->>> g = f.subspace[:, 3:-2:-1]
->>> g = f.subspace[..., [True, False, True, True, False]]
-
-
-
- -
- - -
- -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/source/class/cf.Domain.rst b/docs/source/class/cf.Domain.rst index 526700e480..3117abd118 100644 --- a/docs/source/class/cf.Domain.rst +++ b/docs/source/class/cf.Domain.rst @@ -213,11 +213,11 @@ Subspacing .. autosummary:: :nosignatures: - :toctree: ../attribute/ - :template: attribute.rst + :toctree: ../method/ + :template: method.rst - ~cf.Domain.subspace ~cf.Domain.indices + ~cf.Domain.subspace NetCDF ------ diff --git a/docs/source/class/cf.Field.rst b/docs/source/class/cf.Field.rst index f56f6eff61..c0a0275daa 100644 --- a/docs/source/class/cf.Field.rst +++ b/docs/source/class/cf.Field.rst @@ -169,7 +169,8 @@ Data ~cf.Field.ndim ~cf.Field.shape ~cf.Field.size - ~cf.Field.varray + ~cf.Field.to_dask_array + ~cf.Field.varray .. rubric:: *Rearranging elements* @@ -524,15 +525,20 @@ Domain axes Subspacing ---------- +.. autosummary:: + :nosignatures: + :toctree: ../method/ + :template: method.rst + + ~cf.Field.__getitem__ + ~cf.Field.indices + .. autosummary:: :nosignatures: :toctree: ../attribute/ :template: attribute.rst - ~cf.Field.to_dask_array - ~cf.Field.__getitem__ ~cf.Field.subspace - ~cf.Field.indices Mathematical operations -----------------------