From cc421da1b7b783ac0af598ed00301c3b8bf241d7 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Tue, 23 Nov 2021 12:43:37 -0500 Subject: [PATCH 01/16] Resampling with indexing - first step - Added ResamplingIndicatorWithIndexing object - Refac Indicator to keep trace of injected parameter's metadata - Refac Indicator to remove explicit mentions of "indexer" --- xclim/core/indicator.py | 173 ++++++++++++++++++++++++++-------------- 1 file changed, 114 insertions(+), 59 deletions(-) diff --git a/xclim/core/indicator.py b/xclim/core/indicator.py index c2409f39f..f0c3ef81f 100644 --- a/xclim/core/indicator.py +++ b/xclim/core/indicator.py @@ -175,6 +175,7 @@ class Parameter: description: str = "" units: str = _empty choices: set = _empty + value: Any = _empty def update(self, other: dict): """Update a parameter's values from a dict.""" @@ -203,6 +204,10 @@ def __contains__(self, key): def asdict(self): return {k: v for k, v in asdict(self).items() if v is not _empty} + @property + def injected(self): + return self.value is not _empty + class IndicatorRegistrar: """Climate Indicator registering object.""" @@ -337,12 +342,12 @@ class Indicator(IndicatorRegistrar): references = "" notes = "" - _all_parameters: Mapping[str, Union[Parameter, Any]] = {} + _all_parameters: Mapping[str, Parameter] = {} """A dictionary mapping metadata about the input parameters to the indicator. - Keys are the arguments of the "compute" function. "Injected" parameters, - those absent from the indicator's call signature are listed here with the - injected values. Controlable parameters are instance of :py:class:`xclim.core.indicator.Parameter`. + Keys are the arguments of the "compute" function. All parameters are listed, even + those "injected", absent from the indicator's call signature. All are instances of + :py:class:`xclim.core.indicator.Parameter`. """ cf_attrs: Sequence[Mapping[str, Any]] = None @@ -385,6 +390,15 @@ def __new__(cls, **kwds): for name, value in docmeta.items(): # title, abstract, references, notes, long_name kwds.setdefault(name, value) + + # Inject parameters (subclasses can override or extend this through _injected_parameters) + for name, param in cls._injected_parameters(): + if name in parameters: + raise ValueError( + f"Class {cls.__name__} can't wrap indices that have a `{name}`" + " argument as it conflicts with arguments it injects." + ) + parameters[name] = param else: # inherit parameters from base class parameters = deepcopy(cls._all_parameters) @@ -486,16 +500,23 @@ def _parse_indice(compute, passed_parameters): ) meta["kind"] = infer_kind_from_parameter(param, has_units) - # Insert "ds" arg - params_dict["ds"] = { - "default": None, - "kind": InputKind.DATASET, - "description": "A dataset with the variables given by name.", - } - parameters = {name: Parameter(**param) for name, param in params_dict.items()} return parameters, docmeta + @classmethod + def _injected_parameters(cls): + """A list of tuples for arguments to inject, (name, Parameter).""" + return [ + ( + "ds", + Parameter( + kind=InputKind.DATASET, + default=None, + description="A dataset with the variables given by name.", + ), + ) + ] + @classmethod def _update_parameters(cls, parameters, passed): """Update parameters with the ones passed.""" @@ -505,7 +526,7 @@ def _update_parameters(cls, parameters, passed): # modified meta parameters[key].update(val) elif key in parameters: - parameters[key] = val + parameters[key].value = val else: raise KeyError(key) except KeyError as err: @@ -553,7 +574,7 @@ def _ensure_correct_parameters(cls, parameters): Sets the correct variable default to be sure. """ for name, meta in parameters.items(): - if isinstance(meta, Parameter): + if not meta.injected: if meta.kind <= InputKind.OPTIONAL_VARIABLE and meta.units is _empty: raise ValueError( f"Input variable {name} is missing expected units. Units are " @@ -567,7 +588,7 @@ def _ensure_correct_parameters(cls, parameters): # Sort parameters : Var, Opt Var, all params, ds, injected params. def sortkey(kv): - if isinstance(kv[1], Parameter): + if not kv[1].injected: if kv[1].kind in [0, 1, 50]: return kv[1].kind return 2 @@ -751,15 +772,24 @@ def __call__(self, *args, **kwds): # das : OrderedDict of variables (required + non-None optionals) # params : OrderedDict of parameters INCLUDING unpacked kwargs and injected EXCLUDING indexer # indexer: If present, the "indexer" kwargs <- this is needed by _update_attrs and _mask - das, params, indexer = self._parse_variables_from_call(args, kwds) + das, params = self._parse_variables_from_call(args, kwds) - das, params, indexer = self._preprocess_and_checks(das, params, indexer) + das, params = self._preprocess_and_checks(das, params) # Get correct variable names for the compute function. inv_var_map = dict(map(reversed, self._variable_mapping.items())) compute_das = {inv_var_map.get(nm, nm): das[nm] for nm in das} + # Compute the indicator values, ignoring NaNs and missing values. - outs = self.compute(**compute_das, **params, **indexer) + # Filter the passed parameters to only keep the ones needed by compute. + kwargs = {} + var_kwargs = {} + for nm, pa in signature(self.compute).parameters.items(): + if pa.kind == _Parameter.VAR_KEYWORD: + var_kwargs = params[nm] + elif nm not in compute_das and nm in params: + kwargs[nm] = params[nm] + outs = self.compute(**compute_das, **kwargs, **var_kwargs) if isinstance(outs, DataArray): outs = [outs] @@ -783,7 +813,6 @@ def __call__(self, *args, **kwds): attrs, names=self._cf_names, var_id=var_id, - indexer=indexer, ) ) @@ -799,7 +828,7 @@ def __call__(self, *args, **kwds): out.attrs.update(attrs) out.name = var_name - outs = self._postprocess(outs, das, params, indexer) + outs = self._postprocess(outs, das, params) # Return a single DataArray in case of single output, otherwise a tuple if self.n_outs == 1: @@ -818,28 +847,22 @@ def _parse_variables_from_call(self, args, kwds): # Extract variables + inject injected das = OrderedDict() params = ba.arguments.copy() - indexer = {} for name, param in self._all_parameters.items(): - if name == "indexer": - indexer = params.pop(name, {}) - elif isinstance(param, Parameter): + if not param.injected: # If a variable pop the arg if param.kind <= InputKind.OPTIONAL_VARIABLE: data = params.pop(name) # If a non-optional variable OR None, store the arg if param.kind == InputKind.VARIABLE or data is not None: das[name] = data - elif param.kind == InputKind.KWARGS: - kwargs = params.pop(name) - params.update(**kwargs) else: - params[name] = param + params[name] = param.value - return das, params, indexer + return das, params def _assign_named_args(self, ba): """Assign inputs passed as strings from ds.""" - ds = ba.arguments.pop("ds") + ds = ba.arguments.get("ds") for name in list(ba.arguments.keys()): if self.parameters[name].kind <= InputKind.OPTIONAL_VARIABLE and isinstance( ba.arguments[name], str @@ -858,15 +881,15 @@ def _assign_named_args(self, ba): f"dataset (got {name}='{ba.arguments[name]}')" ) - def _preprocess_and_checks(self, das, params, indexer): + def _preprocess_and_checks(self, das, params): """Actions to be done after parsing the arguments and before computing.""" # Pre-computation validation checks on DataArray arguments self._bind_call(self.datacheck, **das) self._bind_call(self.cfcheck, **das) - return das, params, indexer + return das, params - def _postprocess(self, outs, das, params, indexer): + def _postprocess(self, outs, das, params): """Actions to done after computing.""" return outs @@ -929,7 +952,7 @@ def _get_translated_metadata( ) @classmethod - def _update_attrs(cls, args, das, attrs, var_id=None, names=None, indexer=None): + def _update_attrs(cls, args, das, attrs, var_id=None, names=None): """Format attributes with the run-time values of `compute` call parameters. Cell methods and history attributes are updated, adding to existing values. @@ -951,8 +974,6 @@ def _update_attrs(cls, args, das, attrs, var_id=None, names=None, indexer=None): attributes. This is meant for multi-outputs indicators. names : Sequence[str] List of attribute names for which to get a translation. - indexer : Optiona[Mapping[str, str]] - The `indexer` argument as passed to the indicator. Returns ------- @@ -960,7 +981,7 @@ def _update_attrs(cls, args, das, attrs, var_id=None, names=None, indexer=None): Attributes with {} expressions replaced by call argument values. With updated `cell_methods` and `history`. `cell_methods` is not added if `names` is given and those not contain `cell_methods`. """ - out = cls._format(attrs, args, indexer) + out = cls._format(attrs, args) for locale in OPTIONS[METADATA_LOCALES]: out.update( cls._format( @@ -968,7 +989,6 @@ def _update_attrs(cls, args, das, attrs, var_id=None, names=None, indexer=None): locale, var_id=var_id, names=names or list(attrs.keys()) ), args=args, - indexer=indexer, formatter=get_local_formatter(locale), ) ) @@ -986,7 +1006,11 @@ def _update_attrs(cls, args, das, attrs, var_id=None, names=None, indexer=None): # In the history attr, call signature will be all keywords and might be in a # different order than the real function (but order doesn't really matter with keywords). kwargs = OrderedDict(**das) - kwargs.update(**args, **indexer) + for k, v in args.items(): + if cls._all_parameters[k].kind == InputKind.KWARGS: + kwargs.update(**v) + elif cls._all_parameters[k].kind != InputKind.DATASET: + kwargs[k] = v attrs["history"] = update_history( gen_call_string(cls._registry_id, **kwargs), new_name=out.get("var_name"), @@ -1082,11 +1106,11 @@ def json(self, args=None): # We need to deepcopy, otherwise empty defaults get overwritten! # All those tweaks are to ensure proper serialization of the returned dictionary. out["parameters"] = { - k: p.asdict() if isinstance(p, Parameter) else deepcopy(p) + k: p.asdict() if not p.injected else deepcopy(p.value) for k, p in self._all_parameters.items() } for name, param in list(out["parameters"].items()): - if isinstance(self._all_parameters[name], Parameter): + if not self._all_parameters[name].injected: param["kind"] = param["kind"].value # Get the int. if "choices" in param: # A set is stored, convert to list param["choices"] = list(param["choices"]) @@ -1102,7 +1126,6 @@ def _format( cls, attrs: dict, args: dict = None, - indexer: dict = None, formatter: AttrFormatter = default_formatter, ): """Format attributes including {} tags with arguments. @@ -1118,8 +1141,8 @@ def _format( # Use defaults if args is None: args = { - k: v.default if isinstance(v, Parameter) else v - for k, v in cls._all_parameters.items() + k: p.default if not p.injected else p.value + for k, p in cls._all_parameters.items() } # Prepare arguments @@ -1133,13 +1156,13 @@ def _format( # TODO: What about InputKind.NUMBER_SEQUENCE else: mba[k] = v - if indexer: - dk, dv = indexer.copy().popitem() - if dk == "month": - dv = "m{}".format(dv) - mba["indexer"] = dv - else: - mba["indexer"] = "annual" + # if indexer: + # dk, dv = indexer.copy().popitem() + # if dk == "month": + # dv = "m{}".format(dv) + # mba["indexer"] = dv + # else: + # mba["indexer"] = "annual" out = {} for key, val in attrs.items(): @@ -1216,7 +1239,7 @@ def parameters(self): return { name: param for name, param in self._all_parameters.items() - if isinstance(param, Parameter) + if not param.injected } @property @@ -1226,9 +1249,9 @@ def injected_parameters(self): Opposite of :py:meth:`Indicator.parameters`. """ return { - name: param + name: param.value for name, param in self._all_parameters.items() - if not isinstance(param, Parameter) + if param.injected } @@ -1278,9 +1301,9 @@ def __init__(self, **kwds): super().__init__(**kwds) - def _preprocess_and_checks(self, das, params, indexer): + def _preprocess_and_checks(self, das, params): """Perform parent's checks and also check if freq is allowed.""" - das, params, indexer = super()._preprocess_and_checks(das, params, indexer) + das, params = super()._preprocess_and_checks(das, params) # Check if the period is allowed: if ( @@ -1293,11 +1316,11 @@ def _preprocess_and_checks(self, das, params, indexer): f"of {self.allowed_periods})." ) - return das, params, indexer + return das, params - def _postprocess(self, outs, das, params, indexer): + def _postprocess(self, outs, das, params): """Masking of missing values.""" - outs = super()._postprocess(outs, das, params, indexer) + outs = super()._postprocess(outs, das, params) if self.missing != "skip": # Mask results that do not meet criteria defined by the `missing` method. @@ -1309,7 +1332,9 @@ def _postprocess(self, outs, das, params, indexer): # We flag periods according to the missing method. skip variables without a time coordinate. src_freq = self.src_freq if isinstance(self.src_freq, str) else None miss = ( - self._missing(da, params["freq"], src_freq, options, indexer) + self._missing( + da, params["freq"], src_freq, options, params.get("indexer", {}) + ) for da in das.values() if "time" in da.coords ) @@ -1319,6 +1344,36 @@ def _postprocess(self, outs, das, params, indexer): return outs +class ResamplingIndicatorWithIndexing(ResamplingIndicator): + """Resampling indicator that also injects "indexer" kwargs to subset the inputs before computation.""" + + @classmethod + def _injected_parameters(self): + return super()._injected_parameters + [ + ( + "indexer", + Parameter( + kind=InputKind.KWARGS, + description=( + "Indexing parameters to compute the indicator on a temporal " + "subset of the data, see ... for details on how to use this " + "parameter." + ), + ), + ) + ] + + def _preprocess_and_checks(self, das, params): + """Perform parent's checks and also check if freq is allowed.""" + das, params = super()._preprocess_and_checks(das, params) + + indexer = params.pop("indexer") + if indexer: + # do things + pass + return das, params + + class Daily(ResamplingIndicator): """Class for daily inputs and resampling computes.""" From 1b74f7e43b8b18794fad480de6adbf0306c8b2a8 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Tue, 23 Nov 2021 16:55:11 -0500 Subject: [PATCH 02/16] better select_time --- xclim/core/calendar.py | 3 + xclim/core/indicator.py | 5 +- xclim/indices/generic.py | 123 +++++++++++++++++++++---- xclim/testing/tests/test_generic.py | 138 ++++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 19 deletions(-) diff --git a/xclim/core/calendar.py b/xclim/core/calendar.py index 0e5354207..c0e2d8f52 100644 --- a/xclim/core/calendar.py +++ b/xclim/core/calendar.py @@ -46,6 +46,9 @@ "360_day": 360, } +# Names of calendars that have the same number of days for all years +uniform_calendars = ("noleap", "all_leap", "365_day", "366_day", "360_day") + def get_calendar(obj: Any, dim: str = "time") -> str: """Return the calendar of an object. diff --git a/xclim/core/indicator.py b/xclim/core/indicator.py index f0c3ef81f..1bf5a8551 100644 --- a/xclim/core/indicator.py +++ b/xclim/core/indicator.py @@ -770,8 +770,7 @@ def __call__(self, *args, **kwds): """Call function of Indicator class.""" # Put the variables in `das`, parse them according to the annotations # das : OrderedDict of variables (required + non-None optionals) - # params : OrderedDict of parameters INCLUDING unpacked kwargs and injected EXCLUDING indexer - # indexer: If present, the "indexer" kwargs <- this is needed by _update_attrs and _mask + # params : OrderedDict of parameters (var_kwargs as a single argument, if any) das, params = self._parse_variables_from_call(args, kwds) das, params = self._preprocess_and_checks(das, params) @@ -1367,7 +1366,7 @@ def _preprocess_and_checks(self, das, params): """Perform parent's checks and also check if freq is allowed.""" das, params = super()._preprocess_and_checks(das, params) - indexer = params.pop("indexer") + indexer = params.get("indexer") if indexer: # do things pass diff --git a/xclim/indices/generic.py b/xclim/indices/generic.py index e51449169..71661d77a 100644 --- a/xclim/indices/generic.py +++ b/xclim/indices/generic.py @@ -7,17 +7,19 @@ Helper functions for common generic actions done in the computation of indices. """ from collections.abc import Iterable -from typing import Optional, Union +from typing import Optional, Sequence, Tuple, Union import numpy as np import xarray import xarray as xr +from xarray.coding.cftime_offsets import to_cftime_datetime from xclim.core.calendar import ( convert_calendar, days_in_year, doy_to_days_since, get_calendar, + uniform_calendars, ) from xclim.core.units import ( convert_units_to, @@ -57,31 +59,120 @@ binary_ops = {">": "gt", "<": "lt", ">=": "ge", "<=": "le", "==": "eq", "!=": "ne"} -def select_time(da: xr.DataArray, **indexer): +def select_time( + da: Union[xr.DataArray, xr.Dataset], + drop: bool = True, + season: Union[str, Sequence[str]] = None, + month: Union[int, Sequence[int]] = None, + doy_bounds: Tuple[int, int] = None, + date_bounds: Tuple[str, str] = None, +): """Select entries according to a time period. + This conveniently improves xarray's :py:meth:`xarray.DataArray.where` and + :py:meth:`xarray.DataArray.sel` with fancier ways of indexing over time elements. + In addition to the data `da` and argument `drop`, only one of `season`, `month`, + `doy_bounds` or `date_bounds` may be passed. + Parameters ---------- - da : xr.DataArray + da : xr.DataArray or xr.Dataset Input data. - **indexer : {dim: indexer, }, optional - Time attribute and values over which to subset the array. For example, use season='DJF' to select winter values, - month=1 to select January, or month=[6,7,8] to select summer months. If not indexer is given, all values are - considered. + drop: boolean + Whether to drop elements outside the period of interest (default) or + to simply mask them. + season: string or sequence of strings + One or more of 'DJF', 'MAM', 'JJA' and 'SON'. + month: integer or sequence of integers + Sequence of month numbers (January = 1 ... December = 12) + doy_bounds: 2-tuple of integers + The bounds as (start, end) of the period of interest expressed in day-of-year, + integers going from 1 (January 1st) to 365 or 366 (December 31st). If calendar + awareness is needed, consider using ``date_bounds`` instead. + Bounds are included in the result. + date_bounds: 2-tuple of strings + The bounds as (start, end) of the period of interest expressed as dates in the + month-day (%m-%d) format. + Bounds are included in the result. Returns ------- - xr.DataArray - Selected input values. + xr.DataArray or xr.Dataset + Selected input values. If ``drop=False``, this has the same length as ``da`` + (along dimension 'time'), but with masked (NaN) values outside the period of + interest. + + Examples + -------- + Keep only the values of fall and spring. + + >>> ds = open_dataset("ERA5/daily_surface_cancities_1990-1993.nc") + >>> ds.time.size + 1461 + >>> out = select_time(ds, season=['MAM', 'SON']) + >>> out.time.size + 732 + + Or all values between two dates (included). + + >>> out = select_time(ds, date_bounds=('02-29', '03-02')) + >>> out.time.values + array(['1990-03-01T00:00:00.000000000', '1990-03-02T00:00:00.000000000', + '1991-03-01T00:00:00.000000000', '1991-03-02T00:00:00.000000000', + '1992-02-29T00:00:00.000000000', '1992-03-01T00:00:00.000000000', + '1992-03-02T00:00:00.000000000', '1993-03-01T00:00:00.000000000', + '1993-03-02T00:00:00.000000000'], dtype='datetime64[ns]') """ - if not indexer: - selected = da - else: - key, val = indexer.popitem() - time_att = getattr(da.time.dt, key) - selected = da.sel(time=time_att.isin(val)).dropna(dim="time") + N = sum(arg is not None for arg in [season, month, doy_bounds, date_bounds]) + if N > 1: + raise ValueError(f"Only one method of indexing may be given, got {N}.") + + if N == 0: + return da + + if season is not None: + if isinstance(season, str): + season = [season] + mask = da.time.dt.season.isin(season) + + elif month is not None: + if isinstance(month, int): + month = [month] + mask = da.time.dt.month.isin(month) + + elif doy_bounds is not None: + start, end = doy_bounds + if start <= end: + doys = np.arange(start, end + 1) + else: + doys = np.concatenate((np.arange(start, 367), np.arange(0, end + 1))) + mask = da.time.dt.dayofyear.isin(doys) + + elif date_bounds is not None: + # This one is a bit trickier. + start, end = date_bounds + time = da.time + calendar = get_calendar(time) + if calendar not in uniform_calendars: + # For non-uniform calendars, we can't simply convert dates to doys + # conversion to all_leap is safe for all non-uniform calendar as it doesn't remove any date. + time = convert_calendar(time, "all_leap") + # values of time are the _old_ calendar + # and the new calendar is in the coordinate + calendar = "all_leap" + + # Get doy of date, this is now safe because the calendar is uniform. + start = to_cftime_datetime("2000-" + start, calendar).dayofyr + end = to_cftime_datetime("2000-" + end, calendar).dayofyr + + if start <= end: + doys = np.arange(start, end + 1) + else: + doys = np.concatenate((np.arange(start, 367), np.arange(0, end + 1))) + mask = time.time.dt.dayofyear.isin(doys) + mask["time"] = da.time # If we converted, this puts back the correct coord. - return selected + return da.where(mask, drop=drop) def select_resample_op(da: xr.DataArray, op: str, freq: str = "YS", **indexer): diff --git a/xclim/testing/tests/test_generic.py b/xclim/testing/tests/test_generic.py index a24886cd1..b0a0ad3c1 100644 --- a/xclim/testing/tests/test_generic.py +++ b/xclim/testing/tests/test_generic.py @@ -306,3 +306,141 @@ def test_simple(self, tas_series): np.testing.assert_allclose(out, [0, 5, 10, 0, 0]) np.testing.assert_allclose(out, out_kelvin) + + +def series(start, end, calendar): + time = date_range(start, end, calendar=calendar) + return xr.DataArray([1] * time.size, dims=("time",), coords={"time": time}) + + +def test_select_time_month(): + da = series("1993-01-05", "1994-12-31", "default") + + out = generic.select_time(da, month=1) + exp = xr.concat( + ( + series("1993-01-05", "1993-01-31", "default"), + series("1994-01-01", "1994-01-31", "default"), + ), + "time", + ) + xr.testing.assert_equal(out, exp) + + out = generic.select_time(da, month=1, drop=False) + xr.testing.assert_equal(out.time, da.time) + assert out.sum() == 58 + + da = series("1993-01-05", "1994-12-30", "360_day") + out = generic.select_time(da, month=[3, 6]) + exp = xr.concat( + ( + series("1993-03-01", "1993-03-30", "360_day"), + series("1993-06-01", "1993-06-30", "360_day"), + series("1994-03-01", "1994-03-30", "360_day"), + series("1994-06-01", "1994-06-30", "360_day"), + ), + "time", + ) + xr.testing.assert_equal(out, exp) + + +def test_select_time_season(): + da = series("1993-01-05", "1994-12-31", "default") + + out = generic.select_time(da, season="DJF") + exp = xr.concat( + ( + series("1993-01-05", "1993-02-28", "default"), + series("1993-12-01", "1994-02-28", "default"), + series("1994-12-01", "1994-12-31", "default"), + ), + "time", + ) + xr.testing.assert_equal(out, exp) + + da = series("1993-01-05", "1994-12-31", "365_day") + out = generic.select_time(da, season=["MAM", "SON"]) + exp = xr.concat( + ( + series("1993-03-01", "1993-05-31", "365_day"), + series("1993-09-01", "1993-11-30", "365_day"), + series("1994-03-01", "1994-05-31", "365_day"), + series("1994-09-01", "1994-11-30", "365_day"), + ), + "time", + ) + xr.testing.assert_equal(out, exp) + + +def test_select_time_doys(): + da = series("2003-02-13", "2004-12-31", "default") + + out = generic.select_time(da, doy_bounds=(360, 75)) + exp = xr.concat( + ( + series("2003-02-13", "2003-03-16", "default"), + series("2003-12-26", "2004-03-15", "default"), + series("2004-12-25", "2004-12-31", "default"), + ), + "time", + ) + xr.testing.assert_equal(out, exp) + + da = series("2003-02-13", "2004-12-31", "proleptic_gregorian") + + out = generic.select_time(da, doy_bounds=(25, 80)) + exp = xr.concat( + ( + series("2003-02-13", "2003-03-21", "proleptic_gregorian"), + series("2004-01-25", "2004-03-20", "proleptic_gregorian"), + ), + "time", + ) + xr.testing.assert_equal(out, exp) + + +def test_select_time_dates(): + da = series("2003-02-13", "2004-11-01", "all_leap") + da = da.where(da.time.dt.dayofyear != 92, drop=True) # no 04-01 + + out = generic.select_time(da, date_bounds=("04-01", "12-04")) + exp = xr.concat( + ( + series("2003-04-02", "2003-12-04", "all_leap"), + series("2004-04-02", "2004-11-01", "all_leap"), + ), + "time", + ) + xr.testing.assert_equal(out, exp) + + da = series("2003-02-13", "2005-11-01", "standard") + + out = generic.select_time(da, date_bounds=("10-05", "02-29")) + exp = xr.concat( + ( + series("2003-02-13", "2003-02-28", "standard"), + series("2003-10-05", "2004-02-29", "standard"), + series("2004-10-05", "2005-02-28", "standard"), + series("2005-10-05", "2005-11-01", "standard"), + ), + "time", + ) + xr.testing.assert_equal(out, exp) + + +def test_select_time_errors(): + da = series("2003-01-01", "2004-01-01", "standard") + + xr.testing.assert_identical(da, generic.select_time(da)) + + with pytest.raises(ValueError, match="Only one method of indexing may be given"): + generic.select_time(da, season="DJF", month=[3, 4, 5]) + + with pytest.raises(ValueError, match="invalid day number provided in cftime."): + generic.select_time(da, date_bounds=("02-30", "03-03")) + + with pytest.raises(ValueError): + generic.select_time(da, date_bounds=("02-30",)) + + with pytest.raises(ValueError): + generic.select_time(da, doy_bounds=(300, 203, 202)) From 168afb0a697719a41596fe11f0c65da34ef13f97 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Tue, 23 Nov 2021 17:36:40 -0500 Subject: [PATCH 03/16] Add select time to ResamplingIndicatorWithIndexing - broken on some indicators --- xclim/core/indicator.py | 15 ++++++++------- xclim/indicators/land/_streamflow.py | 5 +++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/xclim/core/indicator.py b/xclim/core/indicator.py index 1bf5a8551..4117427c5 100644 --- a/xclim/core/indicator.py +++ b/xclim/core/indicator.py @@ -1348,7 +1348,7 @@ class ResamplingIndicatorWithIndexing(ResamplingIndicator): @classmethod def _injected_parameters(self): - return super()._injected_parameters + [ + return super()._injected_parameters() + [ ( "indexer", Parameter( @@ -1366,20 +1366,20 @@ def _preprocess_and_checks(self, das, params): """Perform parent's checks and also check if freq is allowed.""" das, params = super()._preprocess_and_checks(das, params) - indexer = params.get("indexer") - if indexer: - # do things - pass + indxr = params.get("indexer") + if indxr: + das = {k: indices.generic.select_time(da, **indxr) for k, da in das.items()} + print(das) return das, params -class Daily(ResamplingIndicator): +class Daily(ResamplingIndicatorWithIndexing): """Class for daily inputs and resampling computes.""" src_freq = "D" -class Hourly(ResamplingIndicator): +class Hourly(ResamplingIndicatorWithIndexing): """Class for hourly inputs and resampling computes.""" src_freq = "H" @@ -1387,6 +1387,7 @@ class Hourly(ResamplingIndicator): base_registry["Indicator"] = Indicator base_registry["ResamplingIndicator"] = ResamplingIndicator +base_registry["ResamplingIndicatorWithIndexing"] = ResamplingIndicatorWithIndexing base_registry["Hourly"] = Hourly base_registry["Daily"] = Daily diff --git a/xclim/indicators/land/_streamflow.py b/xclim/indicators/land/_streamflow.py index 9029b36d4..9e7ceaf54 100644 --- a/xclim/indicators/land/_streamflow.py +++ b/xclim/indicators/land/_streamflow.py @@ -1,7 +1,7 @@ """Streamflow indicator definitions.""" from xclim.core.cfchecks import check_valid -from xclim.core.indicator import Daily, Indicator +from xclim.core.indicator import Indicator, ResamplingIndicator from xclim.core.units import declare_units from xclim.indices import base_flow_index, generic, rb_flashiness_index from xclim.indices.stats import fit as _fit @@ -18,8 +18,9 @@ ] -class Streamflow(Daily): +class Streamflow(ResamplingIndicator): context = "hydro" + src_freq = "D" @staticmethod def cfcheck(q): From dd0ad8e01503922543a81ab76e0ec3ebcef25dc4 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Wed, 24 Nov 2021 15:35:36 -0500 Subject: [PATCH 04/16] Do not drop by default in select_time - write tests - adapt missing --- xclim/core/indicator.py | 5 +++-- xclim/core/missing.py | 4 ++-- xclim/indices/generic.py | 2 +- xclim/testing/tests/test_indicators.py | 30 +++++++++++++++++++++++--- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/xclim/core/indicator.py b/xclim/core/indicator.py index 4117427c5..8700abb85 100644 --- a/xclim/core/indicator.py +++ b/xclim/core/indicator.py @@ -1337,7 +1337,9 @@ def _postprocess(self, outs, das, params): for da in das.values() if "time" in da.coords ) - mask = reduce(np.logical_or, miss) + # Reduce by or and broadcast to ensure the same length in time + # When indexing is used and there are no valid points in the last period, mask will not include it + mask = reduce(np.logical_or, miss).reindex_like(outs[0], fill_value=True) outs = [out.where(~mask) for out in outs] return outs @@ -1369,7 +1371,6 @@ def _preprocess_and_checks(self, das, params): indxr = params.get("indexer") if indxr: das = {k: indices.generic.select_time(da, **indxr) for k, da in das.items()} - print(das) return das, params diff --git a/xclim/core/missing.py b/xclim/core/missing.py index 857ed235a..56175a268 100644 --- a/xclim/core/missing.py +++ b/xclim/core/missing.py @@ -88,7 +88,7 @@ def split_freq(freq): @staticmethod def is_null(da, freq, **indexer): """Return a boolean array indicating which values are null.""" - selected = generic.select_time(da, **indexer) + selected = generic.select_time(da, drop=True, **indexer) if selected.time.size == 0: raise ValueError("No data for selected period.") @@ -154,7 +154,7 @@ def prepare(self, da, freq, src_timestep, **indexer): ) sda = xr.DataArray(data=np.ones(len(t)), coords={"time": t}, dims=("time",)) - st = generic.select_time(sda, **indexer) + st = generic.select_time(sda, drop=True, **indexer) if freq: count = st.notnull().resample(time=freq).sum(dim="time") else: diff --git a/xclim/indices/generic.py b/xclim/indices/generic.py index 71661d77a..101d05fe8 100644 --- a/xclim/indices/generic.py +++ b/xclim/indices/generic.py @@ -61,7 +61,7 @@ def select_time( da: Union[xr.DataArray, xr.Dataset], - drop: bool = True, + drop: bool = False, season: Union[str, Sequence[str]] = None, month: Union[int, Sequence[int]] = None, doy_bounds: Tuple[int, int] = None, diff --git a/xclim/testing/tests/test_indicators.py b/xclim/testing/tests/test_indicators.py index 9f59ddb85..dac3b51d1 100644 --- a/xclim/testing/tests/test_indicators.py +++ b/xclim/testing/tests/test_indicators.py @@ -20,7 +20,7 @@ parse_doc, update_history, ) -from xclim.core.indicator import Daily, Indicator, registry +from xclim.core.indicator import Daily, Indicator, ResamplingIndicator, registry from xclim.core.units import convert_units_to, declare_units, units from xclim.core.utils import InputKind, MissingVariableError from xclim.indices import tg_mean @@ -77,7 +77,8 @@ def uniclim_compute(da: xr.DataArray, freq="YS", **indexer): return select.mean(dim="time", keep_attrs=True) -uniClim = Daily( +uniClim = ResamplingIndicator( + src_freq="D", realm="atmos", identifier="clim", cf_attrs=[dict(units="K")], @@ -417,7 +418,14 @@ def test_all_parameters_understood(official_indicators): def test_signature(): sig = signature(xclim.atmos.solid_precip_accumulation) - assert list(sig.parameters.keys()) == ["pr", "tas", "thresh", "freq", "ds"] + assert list(sig.parameters.keys()) == [ + "pr", + "tas", + "thresh", + "freq", + "ds", + "indexer", + ] assert sig.parameters["pr"].annotation == Union[xr.DataArray, str] assert sig.parameters["tas"].default == "tas" assert sig.parameters["tas"].kind == sig.parameters["tas"].POSITIONAL_OR_KEYWORD @@ -725,3 +733,19 @@ def test_resamplingIndicator_new_error(): module="test", compute=multioptvar_compute, ) + + +def test_resampling_indicator_with_indexing(pr_series): + pr = pr_series(np.ones(731), start="2003-01-01", units="mm/d") + + out = xclim.atmos.precip_accumulation(pr, freq="YS") + np.testing.assert_allclose(out, [365, 366]) + + out = xclim.atmos.precip_accumulation(pr, freq="YS", month=2) + np.testing.assert_allclose(out, [28, 29]) + + out = xclim.atmos.precip_accumulation(pr, freq="AS-JUL", doy_bounds=(1, 50)) + np.testing.assert_allclose(out, [50, 50, np.NaN]) + + out = xclim.atmos.precip_accumulation(pr, freq="YS", date_bounds=("02-29", "04-01")) + np.testing.assert_allclose(out, [32, 33]) From c8d39f59bd25a1dfb06c0c360a1c05e2ed4d0567 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Wed, 24 Nov 2021 17:29:26 -0500 Subject: [PATCH 05/16] upd hist - fix tests - reindex mask only if needed --- HISTORY.rst | 3 ++- xclim/core/indicator.py | 8 +++++--- xclim/testing/tests/test_cli.py | 2 +- xclim/testing/tests/test_formatting.py | 6 +++--- xclim/testing/tests/test_generic.py | 18 +++++++++--------- xclim/testing/tests/test_indicators.py | 2 +- 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 7903def43..acce1aed5 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -10,13 +10,14 @@ New features and enhancements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Added an optimized pathway for ``xclim.indices.run_length`` functions when ``window=1``. (:pull:`911`, :issue:`910`). * The data input frequency expected by ``Indicator``s is now in the ``src_freq`` attribute and is thus controlable by subclassing existing indicators. (:issue:`898`, :pull:`927`). +* New ``**indexer`` keyword args added to most indicators, it accepts the same arguments as ``xclim.indices.generic.select_time``, which has been improved. Unless otherwise specified, the time selection is done before than any computation. (:pull:`934`, :issue:`899`). Internal changes ~~~~~~~~~~~~~~~~ * Removed some logging configurations in ``dataflags`` that were polluting python's main logging configuration. (:pull:`909`). * Synchronized logging formatters in `xclim.ensembles` and `xclim.core.utils`. (:pull:`909`). * Added a helper function for generating the release notes with dynamically-generated ReStructuredText or Markdown-formatted hyperlinks (:pull:`922`, :issue:`907`). -* Split of resampling-related functionality of ``Indicator``s into a new ``ResamplingIndicator`` subclass. The use of new (private) methods makes it easier to inject functionality in indicator subclasses. (:issue:`867`, :pull:`927`). +* Split of resampling-related functionality of ``Indicator``s into a new ``ResamplingIndicator`` and ``ResamplingIndicatorWithIndexing`` subclasses. The use of new (private) methods makes it easier to inject functionality in indicator subclasses. (:issue:`867`, :pull:`927`, :pull:`934`). Bug fixes ~~~~~~~~~ diff --git a/xclim/core/indicator.py b/xclim/core/indicator.py index 8700abb85..c5f72ccdb 100644 --- a/xclim/core/indicator.py +++ b/xclim/core/indicator.py @@ -1339,7 +1339,9 @@ def _postprocess(self, outs, das, params): ) # Reduce by or and broadcast to ensure the same length in time # When indexing is used and there are no valid points in the last period, mask will not include it - mask = reduce(np.logical_or, miss).reindex_like(outs[0], fill_value=True) + mask = reduce(np.logical_or, miss) + if isinstance(mask, DataArray) and mask.time.size < outs[0].time.size: + mask = mask.reindex(time=outs[0].time, fill_value=True) outs = [out.where(~mask) for out in outs] return outs @@ -1357,8 +1359,8 @@ def _injected_parameters(self): kind=InputKind.KWARGS, description=( "Indexing parameters to compute the indicator on a temporal " - "subset of the data, see ... for details on how to use this " - "parameter." + "subset of the data. It accepts the same arguments as " + ":py:func:`xclim.indices.generic.select_time`." ), ), ) diff --git a/xclim/testing/tests/test_cli.py b/xclim/testing/tests/test_cli.py index ae120336f..795236d47 100644 --- a/xclim/testing/tests/test_cli.py +++ b/xclim/testing/tests/test_cli.py @@ -56,7 +56,7 @@ def test_indicator_help(indicator, indname): results = runner.invoke(cli, [indname, "--help"]) for name in indicator.parameters.keys(): - if name != "ds": + if name not in ["ds", "indexer"]: assert name in results.output diff --git a/xclim/testing/tests/test_formatting.py b/xclim/testing/tests/test_formatting.py index c35149e76..79f92e8e2 100644 --- a/xclim/testing/tests/test_formatting.py +++ b/xclim/testing/tests/test_formatting.py @@ -26,9 +26,9 @@ def test_indicator_docstring(): ) assert doc[6] == "Keywords : health,." assert doc[12] == " Default : `ds.tasmin`. [Required units : [temperature]]" - assert ( - doc[35] - == " Number of heat wave events (Tmin > {thresh_tasmin} and Tmax > {thresh_tasmax} for >= {window} days) (heat_wave_events)" + assert doc[38] == ( + " Number of heat wave events (Tmin > {thresh_tasmin} and Tmax > " + "{thresh_tasmax} for >= {window} days) (heat_wave_events)" ) doc = degree_days_exceedance_date.__doc__.split("\n") diff --git a/xclim/testing/tests/test_generic.py b/xclim/testing/tests/test_generic.py index b0a0ad3c1..9db9ef062 100644 --- a/xclim/testing/tests/test_generic.py +++ b/xclim/testing/tests/test_generic.py @@ -316,7 +316,7 @@ def series(start, end, calendar): def test_select_time_month(): da = series("1993-01-05", "1994-12-31", "default") - out = generic.select_time(da, month=1) + out = generic.select_time(da, drop=True, month=1) exp = xr.concat( ( series("1993-01-05", "1993-01-31", "default"), @@ -326,12 +326,12 @@ def test_select_time_month(): ) xr.testing.assert_equal(out, exp) - out = generic.select_time(da, month=1, drop=False) + out = generic.select_time(da, month=1) xr.testing.assert_equal(out.time, da.time) assert out.sum() == 58 da = series("1993-01-05", "1994-12-30", "360_day") - out = generic.select_time(da, month=[3, 6]) + out = generic.select_time(da, drop=True, month=[3, 6]) exp = xr.concat( ( series("1993-03-01", "1993-03-30", "360_day"), @@ -347,7 +347,7 @@ def test_select_time_month(): def test_select_time_season(): da = series("1993-01-05", "1994-12-31", "default") - out = generic.select_time(da, season="DJF") + out = generic.select_time(da, drop=True, season="DJF") exp = xr.concat( ( series("1993-01-05", "1993-02-28", "default"), @@ -359,7 +359,7 @@ def test_select_time_season(): xr.testing.assert_equal(out, exp) da = series("1993-01-05", "1994-12-31", "365_day") - out = generic.select_time(da, season=["MAM", "SON"]) + out = generic.select_time(da, drop=True, season=["MAM", "SON"]) exp = xr.concat( ( series("1993-03-01", "1993-05-31", "365_day"), @@ -375,7 +375,7 @@ def test_select_time_season(): def test_select_time_doys(): da = series("2003-02-13", "2004-12-31", "default") - out = generic.select_time(da, doy_bounds=(360, 75)) + out = generic.select_time(da, drop=True, doy_bounds=(360, 75)) exp = xr.concat( ( series("2003-02-13", "2003-03-16", "default"), @@ -388,7 +388,7 @@ def test_select_time_doys(): da = series("2003-02-13", "2004-12-31", "proleptic_gregorian") - out = generic.select_time(da, doy_bounds=(25, 80)) + out = generic.select_time(da, drop=True, doy_bounds=(25, 80)) exp = xr.concat( ( series("2003-02-13", "2003-03-21", "proleptic_gregorian"), @@ -403,7 +403,7 @@ def test_select_time_dates(): da = series("2003-02-13", "2004-11-01", "all_leap") da = da.where(da.time.dt.dayofyear != 92, drop=True) # no 04-01 - out = generic.select_time(da, date_bounds=("04-01", "12-04")) + out = generic.select_time(da, drop=True, date_bounds=("04-01", "12-04")) exp = xr.concat( ( series("2003-04-02", "2003-12-04", "all_leap"), @@ -415,7 +415,7 @@ def test_select_time_dates(): da = series("2003-02-13", "2005-11-01", "standard") - out = generic.select_time(da, date_bounds=("10-05", "02-29")) + out = generic.select_time(da, drop=True, date_bounds=("10-05", "02-29")) exp = xr.concat( ( series("2003-02-13", "2003-02-28", "standard"), diff --git a/xclim/testing/tests/test_indicators.py b/xclim/testing/tests/test_indicators.py index dac3b51d1..079803812 100644 --- a/xclim/testing/tests/test_indicators.py +++ b/xclim/testing/tests/test_indicators.py @@ -74,7 +74,7 @@ def uniindpr_compute(da: xr.DataArray, freq: str): @declare_units(da="[temperature]") def uniclim_compute(da: xr.DataArray, freq="YS", **indexer): select = select_time(da, **indexer) - return select.mean(dim="time", keep_attrs=True) + return select.mean(dim="time", keep_attrs=True).expand_dims("time") uniClim = ResamplingIndicator( From 83c3a2225e2473d2b6f0e45525e72972b31a7c55 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Wed, 24 Nov 2021 17:46:05 -0500 Subject: [PATCH 06/16] Fix doctest select_time --- xclim/indices/generic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xclim/indices/generic.py b/xclim/indices/generic.py index 101d05fe8..bbd8b5f24 100644 --- a/xclim/indices/generic.py +++ b/xclim/indices/generic.py @@ -109,13 +109,13 @@ def select_time( >>> ds = open_dataset("ERA5/daily_surface_cancities_1990-1993.nc") >>> ds.time.size 1461 - >>> out = select_time(ds, season=['MAM', 'SON']) + >>> out = select_time(ds, drop=True, season=['MAM', 'SON']) >>> out.time.size 732 Or all values between two dates (included). - >>> out = select_time(ds, date_bounds=('02-29', '03-02')) + >>> out = select_time(ds, drop=True, date_bounds=('02-29', '03-02')) >>> out.time.values array(['1990-03-01T00:00:00.000000000', '1990-03-02T00:00:00.000000000', '1991-03-01T00:00:00.000000000', '1991-03-02T00:00:00.000000000', From 24f1c752f74437118f0693eadf9a6bb325f10bf9 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Thu, 25 Nov 2021 15:58:44 -0500 Subject: [PATCH 07/16] Apply suggestions from code review Co-authored-by: David Huard --- HISTORY.rst | 2 +- xclim/indices/generic.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index acce1aed5..1a5ae5f38 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -10,7 +10,7 @@ New features and enhancements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Added an optimized pathway for ``xclim.indices.run_length`` functions when ``window=1``. (:pull:`911`, :issue:`910`). * The data input frequency expected by ``Indicator``s is now in the ``src_freq`` attribute and is thus controlable by subclassing existing indicators. (:issue:`898`, :pull:`927`). -* New ``**indexer`` keyword args added to most indicators, it accepts the same arguments as ``xclim.indices.generic.select_time``, which has been improved. Unless otherwise specified, the time selection is done before than any computation. (:pull:`934`, :issue:`899`). +* New ``**indexer`` keyword args added to most indicators, it accepts the same arguments as ``xclim.indices.generic.select_time``, which has been improved. Unless otherwise specified, the time selection is done before any computation. (:pull:`934`, :issue:`899`). Internal changes ~~~~~~~~~~~~~~~~ diff --git a/xclim/indices/generic.py b/xclim/indices/generic.py index bbd8b5f24..8461b61ef 100644 --- a/xclim/indices/generic.py +++ b/xclim/indices/generic.py @@ -89,11 +89,11 @@ def select_time( The bounds as (start, end) of the period of interest expressed in day-of-year, integers going from 1 (January 1st) to 365 or 366 (December 31st). If calendar awareness is needed, consider using ``date_bounds`` instead. - Bounds are included in the result. + Bounds are inclusive. date_bounds: 2-tuple of strings The bounds as (start, end) of the period of interest expressed as dates in the month-day (%m-%d) format. - Bounds are included in the result. + Bounds are inclusive. Returns ------- From a0481aea179f9373791eb01ee865272a10462747 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Thu, 25 Nov 2021 16:46:17 -0500 Subject: [PATCH 08/16] Add indexing only in cases where it makes clear sense --- xclim/core/indicator.py | 4 +- xclim/indicators/atmos/_temperature.py | 66 ++++++++++++++------------ xclim/indicators/atmos/_wind.py | 6 ++- 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/xclim/core/indicator.py b/xclim/core/indicator.py index c5f72ccdb..3c902f0ef 100644 --- a/xclim/core/indicator.py +++ b/xclim/core/indicator.py @@ -1376,13 +1376,13 @@ def _preprocess_and_checks(self, das, params): return das, params -class Daily(ResamplingIndicatorWithIndexing): +class Daily(ResamplingIndicator): """Class for daily inputs and resampling computes.""" src_freq = "D" -class Hourly(ResamplingIndicatorWithIndexing): +class Hourly(ResamplingIndicator): """Class for hourly inputs and resampling computes.""" src_freq = "H" diff --git a/xclim/indicators/atmos/_temperature.py b/xclim/indicators/atmos/_temperature.py index c09ea4b53..f2d1e9e8d 100644 --- a/xclim/indicators/atmos/_temperature.py +++ b/xclim/indicators/atmos/_temperature.py @@ -5,7 +5,7 @@ from xclim import indices from xclim.core import cfchecks -from xclim.core.indicator import Daily, Indicator +from xclim.core.indicator import Daily, Indicator, ResamplingIndicatorWithIndexing from xclim.core.utils import InputKind __all__ = [ @@ -85,7 +85,13 @@ class Temp(Daily): """Indicators involving daily temperature.""" -tn_days_above = Temp( +class TempWithIndexing(ResamplingIndicatorWithIndexing): + """Indicators involving daily temperature and adding an indexing possibility.""" + + src_freq = "D" + + +tn_days_above = TempWithIndexing( identifier="tn_days_above", units="days", standard_name="number_of_days_with_air_temperature_above_threshold", @@ -95,7 +101,7 @@ class Temp(Daily): compute=indices.tn_days_above, ) -tn_days_below = Temp( +tn_days_below = TempWithIndexing( identifier="tn_days_below", units="days", standard_name="number_of_days_with_air_temperature_below_threshold", @@ -105,7 +111,7 @@ class Temp(Daily): compute=indices.tn_days_below, ) -tg_days_above = Temp( +tg_days_above = TempWithIndexing( identifier="tg_days_above", units="days", standard_name="number_of_days_with_air_temperature_above_threshold", @@ -115,7 +121,7 @@ class Temp(Daily): compute=indices.tg_days_above, ) -tg_days_below = Temp( +tg_days_below = TempWithIndexing( identifier="tg_days_below", units="days", standard_name="number_of_days_with_air_temperature_below_threshold", @@ -125,7 +131,7 @@ class Temp(Daily): compute=indices.tg_days_below, ) -tx_days_above = Temp( +tx_days_above = TempWithIndexing( identifier="tx_days_above", units="days", standard_name="number_of_days_with_air_temperature_above_threshold", @@ -135,7 +141,7 @@ class Temp(Daily): compute=indices.tx_days_above, ) -tx_days_below = Temp( +tx_days_below = TempWithIndexing( identifier="tx_days_below", units="days", standard_name="number_of_days_with_air_temperature_below_threshold", @@ -145,7 +151,7 @@ class Temp(Daily): compute=indices.tx_days_below, ) -tx_tn_days_above = Temp( +tx_tn_days_above = TempWithIndexing( identifier="tx_tn_days_above", units="days", standard_name="number_of_days_with_air_temperature_above_threshold", @@ -244,7 +250,7 @@ class Temp(Daily): compute=indices.hot_spell_max_length, ) -tg_mean = Temp( +tg_mean = TempWithIndexing( identifier="tg_mean", units="K", standard_name="air_temperature", @@ -254,7 +260,7 @@ class Temp(Daily): compute=indices.tg_mean, ) -tg_max = Temp( +tg_max = TempWithIndexing( identifier="tg_max", units="K", standard_name="air_temperature", @@ -264,7 +270,7 @@ class Temp(Daily): compute=indices.tg_max, ) -tg_min = Temp( +tg_min = TempWithIndexing( identifier="tg_min", units="K", standard_name="air_temperature", @@ -274,7 +280,7 @@ class Temp(Daily): compute=indices.tg_min, ) -tx_mean = Temp( +tx_mean = TempWithIndexing( identifier="tx_mean", units="K", standard_name="air_temperature", @@ -284,7 +290,7 @@ class Temp(Daily): compute=indices.tx_mean, ) -tx_max = Temp( +tx_max = TempWithIndexing( identifier="tx_max", units="K", standard_name="air_temperature", @@ -294,7 +300,7 @@ class Temp(Daily): compute=indices.tx_max, ) -tx_min = Temp( +tx_min = TempWithIndexing( identifier="tx_min", units="K", standard_name="air_temperature", @@ -304,7 +310,7 @@ class Temp(Daily): compute=indices.tx_min, ) -tn_mean = Temp( +tn_mean = TempWithIndexing( identifier="tn_mean", units="K", standard_name="air_temperature", @@ -314,7 +320,7 @@ class Temp(Daily): compute=indices.tn_mean, ) -tn_max = Temp( +tn_max = TempWithIndexing( identifier="tn_max", units="K", standard_name="air_temperature", @@ -324,7 +330,7 @@ class Temp(Daily): compute=indices.tn_max, ) -tn_min = Temp( +tn_min = TempWithIndexing( identifier="tn_min", units="K", standard_name="air_temperature", @@ -334,7 +340,7 @@ class Temp(Daily): compute=indices.tn_min, ) -daily_temperature_range = Temp( +daily_temperature_range = TempWithIndexing( title="Mean of daily temperature range.", identifier="dtr", units="K", @@ -346,7 +352,7 @@ class Temp(Daily): parameters=dict(op="mean"), ) -max_daily_temperature_range = Temp( +max_daily_temperature_range = TempWithIndexing( title="Maximum of daily temperature range.", identifier="dtrmax", units="K", @@ -358,7 +364,7 @@ class Temp(Daily): parameters=dict(op="max"), ) -daily_temperature_range_variability = Temp( +daily_temperature_range_variability = TempWithIndexing( identifier="dtrvar", units="K", standard_name="air_temperature", @@ -372,7 +378,7 @@ class Temp(Daily): compute=indices.daily_temperature_range_variability, ) -extreme_temperature_range = Temp( +extreme_temperature_range = TempWithIndexing( identifier="etr", units="K", standard_name="air_temperature", @@ -432,7 +438,7 @@ class Temp(Daily): compute=indices.cool_night_index, ) -daily_freezethaw_cycles = Temp( +daily_freezethaw_cycles = TempWithIndexing( identifier="dlyfrzthw", units="days", long_name="daily freezethaw cycles", @@ -503,7 +509,7 @@ class Temp(Daily): ) -cooling_degree_days = Temp( +cooling_degree_days = TempWithIndexing( identifier="cooling_degree_days", units="K days", standard_name="integral_of_air_temperature_excess_wrt_time", @@ -514,7 +520,7 @@ class Temp(Daily): parameters={"thresh": {"default": "18.0 degC"}}, ) -heating_degree_days = Temp( +heating_degree_days = TempWithIndexing( identifier="heating_degree_days", units="K days", standard_name="integral_of_air_temperature_deficit_wrt_time", @@ -525,7 +531,7 @@ class Temp(Daily): parameters={"thresh": {"default": "17.0 degC"}}, ) -growing_degree_days = Temp( +growing_degree_days = TempWithIndexing( identifier="growing_degree_days", units="K days", standard_name="integral_of_air_temperature_excess_wrt_time", @@ -536,7 +542,7 @@ class Temp(Daily): parameters={"thresh": {"default": "4.0 degC"}}, ) -freezing_degree_days = Temp( +freezing_degree_days = TempWithIndexing( identifier="freezing_degree_days", units="K days", standard_name="integral_of_air_temperature_deficit_wrt_time", @@ -547,7 +553,7 @@ class Temp(Daily): parameters={"thresh": {"default": "0 degC"}}, ) -thawing_degree_days = Temp( +thawing_degree_days = TempWithIndexing( identifier="thawing_degree_days", units="K days", standard_name="integral_of_air_temperature_excess_wrt_time", @@ -568,7 +574,7 @@ class Temp(Daily): compute=indices.freshet_start, ) -frost_days = Temp( +frost_days = TempWithIndexing( identifier="frost_days", units="days", standard_name="days_with_air_temperature_below_threshold", @@ -621,7 +627,7 @@ class Temp(Daily): ) -ice_days = Temp( +ice_days = TempWithIndexing( identifier="ice_days", standard_name="days_with_air_temperature_below_threshold", units="days", @@ -730,7 +736,7 @@ class Temp(Daily): parameters={"thresh": {"default": "5.0 degC"}}, ) -tropical_nights = Temp( +tropical_nights = TempWithIndexing( identifier="tropical_nights", units="days", standard_name="number_of_days_with_air_temperature_above_threshold", diff --git a/xclim/indicators/atmos/_wind.py b/xclim/indicators/atmos/_wind.py index 57529c3cc..2f09a8f5a 100644 --- a/xclim/indicators/atmos/_wind.py +++ b/xclim/indicators/atmos/_wind.py @@ -1,12 +1,14 @@ from xclim import indices -from xclim.core.indicator import Daily +from xclim.core.indicator import ResamplingIndicatorWithIndexing __all__ = ["calm_days", "windy_days"] -class Wind(Daily): +class Wind(ResamplingIndicatorWithIndexing): """Indicator involving daily sfcWind series.""" + src_freq = "D" + calm_days = Wind( identifier="calm_days", From e78c8d2587d7537a413c861eb3f99410de9649eb Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Thu, 25 Nov 2021 17:00:48 -0500 Subject: [PATCH 09/16] remove duplicate code in select_time - edit history --- HISTORY.rst | 2 +- xclim/indices/generic.py | 21 +++++++++------------ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 1a5ae5f38..ac9de27ed 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -10,7 +10,7 @@ New features and enhancements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * Added an optimized pathway for ``xclim.indices.run_length`` functions when ``window=1``. (:pull:`911`, :issue:`910`). * The data input frequency expected by ``Indicator``s is now in the ``src_freq`` attribute and is thus controlable by subclassing existing indicators. (:issue:`898`, :pull:`927`). -* New ``**indexer`` keyword args added to most indicators, it accepts the same arguments as ``xclim.indices.generic.select_time``, which has been improved. Unless otherwise specified, the time selection is done before any computation. (:pull:`934`, :issue:`899`). +* New ``**indexer`` keyword args added to many indicators, it accepts the same arguments as ``xclim.indices.generic.select_time``, which has been improved. Unless otherwise specified, the time selection is done before any computation. (:pull:`934`, :issue:`899`). Internal changes ~~~~~~~~~~~~~~~~ diff --git a/xclim/indices/generic.py b/xclim/indices/generic.py index 8461b61ef..74477d9d4 100644 --- a/xclim/indices/generic.py +++ b/xclim/indices/generic.py @@ -140,14 +140,6 @@ def select_time( month = [month] mask = da.time.dt.month.isin(month) - elif doy_bounds is not None: - start, end = doy_bounds - if start <= end: - doys = np.arange(start, end + 1) - else: - doys = np.concatenate((np.arange(start, 367), np.arange(0, end + 1))) - mask = da.time.dt.dayofyear.isin(doys) - elif date_bounds is not None: # This one is a bit trickier. start, end = date_bounds @@ -162,16 +154,21 @@ def select_time( calendar = "all_leap" # Get doy of date, this is now safe because the calendar is uniform. - start = to_cftime_datetime("2000-" + start, calendar).dayofyr - end = to_cftime_datetime("2000-" + end, calendar).dayofyr + doy_bounds = ( + to_cftime_datetime("2000-" + start, calendar).dayofyr, + to_cftime_datetime("2000-" + end, calendar).dayofyr, + ) + if doy_bounds is not None: + start, end = doy_bounds if start <= end: doys = np.arange(start, end + 1) else: doys = np.concatenate((np.arange(start, 367), np.arange(0, end + 1))) - mask = time.time.dt.dayofyear.isin(doys) - mask["time"] = da.time # If we converted, this puts back the correct coord. + mask = da.time.dt.dayofyear.isin(doys) + # Needed if we converted calendar in date_bounds, this puts back the correct coord, useless and inoffensive otherwise + mask["time"] = da.time return da.where(mask, drop=drop) From 4b5f681a7482ee92bfe055af4390200c9e1bd017 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Thu, 25 Nov 2021 17:38:23 -0500 Subject: [PATCH 10/16] reform default_freq and reimplement indexer formatting --- xclim/core/indicator.py | 19 ++++++++++++------- xclim/indices/generic.py | 18 +++++++++++------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/xclim/core/indicator.py b/xclim/core/indicator.py index 3c902f0ef..2b1ca18e0 100644 --- a/xclim/core/indicator.py +++ b/xclim/core/indicator.py @@ -1153,15 +1153,20 @@ def _format( elif isinstance(v, (int, float)): mba[k] = "{:g}".format(v) # TODO: What about InputKind.NUMBER_SEQUENCE + elif k == "indexer": + if v: + dk, dv = v.copy().popitem() + if dk == "month": + dv = f"m{dv}" + elif dk in ("doy_bounds", "date_bounds"): + dv = f"{dv[0]} to {dv[1]}" + mba["indexer"] = dv + else: + mba["indexer"] = args.get("freq") or indices.generic.default_freq( + **v + ) else: mba[k] = v - # if indexer: - # dk, dv = indexer.copy().popitem() - # if dk == "month": - # dv = "m{}".format(dv) - # mba["indexer"] = dv - # else: - # mba["indexer"] = "annual" out = {} for key, val in attrs.items(): diff --git a/xclim/indices/generic.py b/xclim/indices/generic.py index 74477d9d4..7e308cce8 100644 --- a/xclim/indices/generic.py +++ b/xclim/indices/generic.py @@ -6,13 +6,13 @@ Helper functions for common generic actions done in the computation of indices. """ -from collections.abc import Iterable from typing import Optional, Sequence, Tuple, Union +import cftime import numpy as np import xarray import xarray as xr -from xarray.coding.cftime_offsets import to_cftime_datetime +from xarray.coding.cftime_offsets import _MONTH_ABBREVIATIONS, to_cftime_datetime from xclim.core.calendar import ( convert_calendar, @@ -223,11 +223,15 @@ def default_freq(**indexer) -> str: freq = "AS-JAN" if indexer: group, value = indexer.popitem() - if isinstance(value, str) and "DJF" in value: - freq = "AS-DEC" - if group == "month" and isinstance(value, Iterable) and sorted(value) != value: - raise NotImplementedError - + if group == "season": + month = 12 # The "season" scheme is based on AS-DEC + elif group == "month": + month = np.take(value, 0) + elif group == "doy_bounds": + month = cftime.num2date(value[0] - 1, "days since 2004-01-01").month + elif group == "date_bounds": + month = int(value[0][:2]) + freq = "AS-" + _MONTH_ABBREVIATIONS[month] return freq From 1f1af13a576781042b26fffc7958c36ce0add509 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Thu, 25 Nov 2021 17:42:20 -0500 Subject: [PATCH 11/16] fix hist --- HISTORY.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index ac9de27ed..d5a3c6902 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -7,20 +7,20 @@ History Contributors to this version: Pascal Bourgault (:user:`aulemahal`), Travis Logan (:user:`tlogan2000`), Trevor James Smith (:user:`Zeitsperre`) New features and enhancements -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * Added an optimized pathway for ``xclim.indices.run_length`` functions when ``window=1``. (:pull:`911`, :issue:`910`). * The data input frequency expected by ``Indicator``s is now in the ``src_freq`` attribute and is thus controlable by subclassing existing indicators. (:issue:`898`, :pull:`927`). * New ``**indexer`` keyword args added to many indicators, it accepts the same arguments as ``xclim.indices.generic.select_time``, which has been improved. Unless otherwise specified, the time selection is done before any computation. (:pull:`934`, :issue:`899`). Internal changes -~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^ * Removed some logging configurations in ``dataflags`` that were polluting python's main logging configuration. (:pull:`909`). * Synchronized logging formatters in `xclim.ensembles` and `xclim.core.utils`. (:pull:`909`). * Added a helper function for generating the release notes with dynamically-generated ReStructuredText or Markdown-formatted hyperlinks (:pull:`922`, :issue:`907`). * Split of resampling-related functionality of ``Indicator``s into a new ``ResamplingIndicator`` and ``ResamplingIndicatorWithIndexing`` subclasses. The use of new (private) methods makes it easier to inject functionality in indicator subclasses. (:issue:`867`, :pull:`927`, :pull:`934`). Bug fixes -~~~~~~~~~ +^^^^^^^^^ * Fix bugs in the `cf_attrs` and/or `abstract` of `continuous_snow_cover_end` and `continuous_snow_cover_start`. (:pull:`908`). 0.31.0 (2021-11-05) From b7264c63c2d01d01c0672c1c13a8b499b2c235bc Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Thu, 25 Nov 2021 17:43:13 -0500 Subject: [PATCH 12/16] fix hist bis --- HISTORY.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 7b27e160e..87ce4cdaa 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -13,7 +13,7 @@ New features and enhancements * New ``**indexer`` keyword args added to many indicators, it accepts the same arguments as ``xclim.indices.generic.select_time``, which has been improved. Unless otherwise specified, the time selection is done before any computation. (:pull:`934`, :issue:`899`). Breaking changes -~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^ * Following version 1.9 of the CF Conventions, published in September 2021, the calendar name "gregorian" is deprecated. ``core.calendar.get_calendar`` will return "standard", even if the underlying cftime objects still use "gregorian" (cftime <= 1.5.1). (:pull:`935`). Internal changes From 128bca6bfca928a03557a22fb9bec00f50762d7d Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Thu, 25 Nov 2021 18:24:57 -0500 Subject: [PATCH 13/16] fix most errors? its 18:23 anyway --- xclim/core/indicator.py | 2 +- xclim/indices/generic.py | 23 ++++++++++++----------- xclim/testing/tests/test_formatting.py | 2 +- xclim/testing/tests/test_generic.py | 2 +- xclim/testing/tests/test_indicators.py | 25 +++++++++++-------------- 5 files changed, 26 insertions(+), 28 deletions(-) diff --git a/xclim/core/indicator.py b/xclim/core/indicator.py index 2b1ca18e0..9dca279b8 100644 --- a/xclim/core/indicator.py +++ b/xclim/core/indicator.py @@ -1154,7 +1154,7 @@ def _format( mba[k] = "{:g}".format(v) # TODO: What about InputKind.NUMBER_SEQUENCE elif k == "indexer": - if v: + if v and v is not _empty: dk, dv = v.copy().popitem() if dk == "month": dv = f"m{dv}" diff --git a/xclim/indices/generic.py b/xclim/indices/generic.py index 7e308cce8..631989cba 100644 --- a/xclim/indices/generic.py +++ b/xclim/indices/generic.py @@ -130,6 +130,11 @@ def select_time( if N == 0: return da + def get_doys(start, end): + if start <= end: + return np.arange(start, end + 1) + return np.concatenate((np.arange(start, 367), np.arange(0, end + 1))) + if season is not None: if isinstance(season, str): season = [season] @@ -140,6 +145,9 @@ def select_time( month = [month] mask = da.time.dt.month.isin(month) + elif doy_bounds is not None: + mask = da.time.dt.dayofyear.isin(get_doys(*doy_bounds)) + elif date_bounds is not None: # This one is a bit trickier. start, end = date_bounds @@ -154,21 +162,14 @@ def select_time( calendar = "all_leap" # Get doy of date, this is now safe because the calendar is uniform. - doy_bounds = ( + doys = get_doys( to_cftime_datetime("2000-" + start, calendar).dayofyr, to_cftime_datetime("2000-" + end, calendar).dayofyr, ) + mask = time.time.dt.dayofyear.isin(doys) + # Needed if we converted calendar, this puts back the correct coord + mask["time"] = da.time - if doy_bounds is not None: - start, end = doy_bounds - if start <= end: - doys = np.arange(start, end + 1) - else: - doys = np.concatenate((np.arange(start, 367), np.arange(0, end + 1))) - mask = da.time.dt.dayofyear.isin(doys) - - # Needed if we converted calendar in date_bounds, this puts back the correct coord, useless and inoffensive otherwise - mask["time"] = da.time return da.where(mask, drop=drop) diff --git a/xclim/testing/tests/test_formatting.py b/xclim/testing/tests/test_formatting.py index 79f92e8e2..7f2fd53d2 100644 --- a/xclim/testing/tests/test_formatting.py +++ b/xclim/testing/tests/test_formatting.py @@ -26,7 +26,7 @@ def test_indicator_docstring(): ) assert doc[6] == "Keywords : health,." assert doc[12] == " Default : `ds.tasmin`. [Required units : [temperature]]" - assert doc[38] == ( + assert doc[35] == ( " Number of heat wave events (Tmin > {thresh_tasmin} and Tmax > " "{thresh_tasmax} for >= {window} days) (heat_wave_events)" ) diff --git a/xclim/testing/tests/test_generic.py b/xclim/testing/tests/test_generic.py index 9db9ef062..de2ab452f 100644 --- a/xclim/testing/tests/test_generic.py +++ b/xclim/testing/tests/test_generic.py @@ -442,5 +442,5 @@ def test_select_time_errors(): with pytest.raises(ValueError): generic.select_time(da, date_bounds=("02-30",)) - with pytest.raises(ValueError): + with pytest.raises(TypeError): generic.select_time(da, doy_bounds=(300, 203, 202)) diff --git a/xclim/testing/tests/test_indicators.py b/xclim/testing/tests/test_indicators.py index 079803812..e0267dc9d 100644 --- a/xclim/testing/tests/test_indicators.py +++ b/xclim/testing/tests/test_indicators.py @@ -418,14 +418,7 @@ def test_all_parameters_understood(official_indicators): def test_signature(): sig = signature(xclim.atmos.solid_precip_accumulation) - assert list(sig.parameters.keys()) == [ - "pr", - "tas", - "thresh", - "freq", - "ds", - "indexer", - ] + assert list(sig.parameters.keys()) == ["pr", "tas", "thresh", "freq", "ds"] assert sig.parameters["pr"].annotation == Union[xr.DataArray, str] assert sig.parameters["tas"].default == "tas" assert sig.parameters["tas"].kind == sig.parameters["tas"].POSITIONAL_OR_KEYWORD @@ -735,17 +728,21 @@ def test_resamplingIndicator_new_error(): ) -def test_resampling_indicator_with_indexing(pr_series): - pr = pr_series(np.ones(731), start="2003-01-01", units="mm/d") +def test_resampling_indicator_with_indexing(tas_series): + tas = tas_series(np.ones(731) + 273.15, start="2003-01-01") - out = xclim.atmos.precip_accumulation(pr, freq="YS") + out = xclim.atmos.tx_days_above(tas, thresh="0 degC", freq="YS") np.testing.assert_allclose(out, [365, 366]) - out = xclim.atmos.precip_accumulation(pr, freq="YS", month=2) + out = xclim.atmos.tx_days_above(tas, thresh="0 degC", freq="YS", month=2) np.testing.assert_allclose(out, [28, 29]) - out = xclim.atmos.precip_accumulation(pr, freq="AS-JUL", doy_bounds=(1, 50)) + out = xclim.atmos.tx_days_above( + tas, thresh="0 degC", freq="AS-JUL", doy_bounds=(1, 50) + ) np.testing.assert_allclose(out, [50, 50, np.NaN]) - out = xclim.atmos.precip_accumulation(pr, freq="YS", date_bounds=("02-29", "04-01")) + out = xclim.atmos.tx_days_above( + tas, thresh="0 degC", freq="YS", date_bounds=("02-29", "04-01") + ) np.testing.assert_allclose(out, [32, 33]) From 4e13d4c2db437befcb2eb9d882f7c1ff52356664 Mon Sep 17 00:00:00 2001 From: David Huard Date: Thu, 25 Nov 2021 21:11:23 -0500 Subject: [PATCH 14/16] fix remaining errors --- xclim/core/indicator.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/xclim/core/indicator.py b/xclim/core/indicator.py index 9dca279b8..0a3f74f6b 100644 --- a/xclim/core/indicator.py +++ b/xclim/core/indicator.py @@ -1154,7 +1154,7 @@ def _format( mba[k] = "{:g}".format(v) # TODO: What about InputKind.NUMBER_SEQUENCE elif k == "indexer": - if v and v is not _empty: + if v and v not in [_empty, _empty_default]: dk, dv = v.copy().popitem() if dk == "month": dv = f"m{dv}" @@ -1162,9 +1162,7 @@ def _format( dv = f"{dv[0]} to {dv[1]}" mba["indexer"] = dv else: - mba["indexer"] = args.get("freq") or indices.generic.default_freq( - **v - ) + mba["indexer"] = args.get("freq") or indices.generic.default_freq() else: mba[k] = v From 7aef4424fc7c6f122c7b5ae833fea8dd66a40f91 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Fri, 26 Nov 2021 10:22:21 -0500 Subject: [PATCH 15/16] Update xclim/core/indicator.py --- xclim/core/indicator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xclim/core/indicator.py b/xclim/core/indicator.py index 0a3f74f6b..513500409 100644 --- a/xclim/core/indicator.py +++ b/xclim/core/indicator.py @@ -1162,7 +1162,7 @@ def _format( dv = f"{dv[0]} to {dv[1]}" mba["indexer"] = dv else: - mba["indexer"] = args.get("freq") or indices.generic.default_freq() + mba["indexer"] = args.get("freq") or "YS" else: mba[k] = v From 29270ac2b10381ba1e61026e64e73c92b49de064 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Fri, 26 Nov 2021 10:31:25 -0500 Subject: [PATCH 16/16] =?UTF-8?q?Bump=20version:=200.31.3-beta=20=E2=86=92?= =?UTF-8?q?=200.31.4-beta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- setup.py | 2 +- xclim/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.cfg b/setup.cfg index 5615f6089..4c562d9c1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.31.3-beta +current_version = 0.31.4-beta commit = True tag = False parse = (?P\d+)\.(?P\d+).(?P\d+)(\-(?P[a-z]+))? diff --git a/setup.py b/setup.py index 374fe341d..788774bf7 100755 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ AUTHOR = "Travis Logan" AUTHOR_EMAIL = "logan.travis@ouranos.ca" REQUIRES_PYTHON = ">=3.7.0" -VERSION = "0.31.3-beta" +VERSION = "0.31.4-beta" LICENSE = "Apache Software License 2.0" with open("README.rst") as readme_file: diff --git a/xclim/__init__.py b/xclim/__init__.py index e2487a518..f3e6b7fdc 100644 --- a/xclim/__init__.py +++ b/xclim/__init__.py @@ -10,7 +10,7 @@ __author__ = """Travis Logan""" __email__ = "logan.travis@ouranos.ca" -__version__ = "0.31.3-beta" +__version__ = "0.31.4-beta" # Load official locales