Repository: Ouranosinc/xclim
Pull request #58 (2018-10-09 19:25:31)

Class to implement missing data handling, unit conversion, cf…
Okay, so it seems that we're stretching the limit of what can be done with a function and decorators. 
I've created a class to encapsulate checks, missing data handling, metadata attributes, etc. 

The idea would be to remove all decorators from indices.py, and replace the logic using subclasses (see example in temperature.py). I think the simpler design will be easier to understand. 

It's just a prototype, let me know what you think. 


Patch:
diff --git a/xclim/checks.py b/xclim/checks.py
index 7501fddf..713b5795 100644
--- a/xclim/checks.py
+++ b/xclim/checks.py
@@ -139,3 +139,14 @@ def check_is_dataarray(comp):
         return comp(data_array, *args, **kwds)
 
     return func
+
+
+def convert_temp(da, required_units='K'):
+    if da.units == required_units:
+        return da
+    elif da.units == 'C' and required_units == 'K':
+        return da + 273.15
+    elif da.units == 'K' and required_units == 'C':
+        return da - 273.15
+    else:
+        raise AttributeError(da.units)
diff --git a/xclim/indices.py b/xclim/indices.py
index 0863ea48..fa013465 100644
--- a/xclim/indices.py
+++ b/xclim/indices.py
@@ -17,7 +17,6 @@ from .utils import daily_downsampler as dds
 
 xr.set_options(enable_cftimeindex=True)  # Set xarray to use cftimeindex
 
-
 if six.PY2:
     from funcsigs import signature
 elif six.PY3:
@@ -28,6 +27,7 @@ elif six.PY3:
 K2C = 273.15
 ftomm = np.nan
 
+
 # TODO: Define a unit conversion system for temperature [K, C, F] and precipitation [mm h-1, Kg m-2 s-1] metrics
 
 # TODO: Move utility functions to another file.
@@ -827,7 +827,7 @@ def tropical_nights(tasmin, thresh=20, freq='YS'):
         .sum(dim='time')
 
 
-@valid_daily_max_temperature
+# @valid_daily_max_temperature
 def tx_max(tasmax, freq='YS'):
     r"""Highest max temperature
 
diff --git a/xclim/temperature.py b/xclim/temperature.py
new file mode 100644
index 00000000..f96df59d
--- /dev/null
+++ b/xclim/temperature.py
@@ -0,0 +1,40 @@
+from . import checks
+from . import indices
+from .utils import Indicator
+
+
+class TxMax(Indicator):
+    identifier = 'tx_max'
+    required_units = 'K'
+    long_name = 'Maximum temperature'
+    standard_name = 'tasmax'
+    description = 'Maximum daily maximum temperature over period.'
+    keywords = ''
+
+    def compute(self, tasmax, freq='YS'):
+        return indices.tx_max(tasmax, freq)
+
+    def convert_units(self, tas):
+        return (checks.convert_temp(tas, self.required_units),)
+
+    def cfprobe(self, tas):
+        checks.check_valid(tas, 'cell_methods', 'time: maximum within days')
+        checks.check_valid(tas, 'standard_name', 'air_temperature')
+
+    def validate(self, tas):
+        checks.assert_daily(tas)
+
+    def missing(self, tas, freq):
+        """An aggregated value is missing if any value in the group is missing."""
+        g = tas.notnull().resample(time=freq)
+        return g.sum(dim='time')
+
+
+class TxMin(TxMax):
+    identifier = 'tx_min'
+    long_name = 'Minimum maximum temperature'
+    standard_name = 'tx_min'
+    description = 'Minimum daily maximum temperature over the period'
+
+    def compute(self, tasmax, freq='YS'):
+        return indices.tx_min(tasmax, freq)
diff --git a/xclim/utils.py b/xclim/utils.py
index 622ae38d..ce7a2f96 100644
--- a/xclim/utils.py
+++ b/xclim/utils.py
@@ -67,3 +67,57 @@ def daily_downsampler(da, freq='YS'):
 
     # return groupby according to tags
     return buffer.groupby('tags')
+
+
+class Indicator(object):
+    identifier = ''
+    units = ''
+    required_units = ''
+    long_name = ''
+    standard_name = ''
+    description = ''
+    keywords = ''
+
+    def compute(self, *args, **kwds):
+        """Index computation method. To be subclassed"""
+        raise NotImplementedError
+
+    def convert_units(self, *args):
+        """Return DataArray with correct units, defined by `self.required_units`."""
+        raise NotImplementedError
+
+    def cfprobe(self, *args):
+        """Check input data compliance to expectations.
+        Warn of potential issues."""
+        raise NotImplementedError
+
+    def validate(self, *args):
+        """Validate input data requirements.
+        Raise error if conditions are not met."""
+        raise NotImplementedError
+
+    def decorate(self, da):
+        """Modify output's attributes in place."""
+        da.attrs.update(self.attrs)
+
+    def missing(self, *args, **kwds):
+        """Return boolean DataArray . To be subclassed"""
+        raise NotImplementedError
+
+    def __init__(self):
+        # Extract DataArray arguments from compute signature.
+        self.attrs = {'long_name': self.long_name,
+                      'units': self.units,
+                      'standard_name': self.standard_name
+                      }
+
+    def __call__(self, *args, **kwds):
+        self.validate(*args)
+        self.cfprobe(*args)
+
+        cargs = self.convert_units(*args)
+
+        out = self.compute(*cargs, **kwds)
+        self.decorate(out)
+
+        return out.where(self.missing(*cargs, freq=kwds['freq']))  # Won't work if keyword is passed as positional arg.


Test patch:
diff --git a/tests/test_temperature.py b/tests/test_temperature.py
new file mode 100644
index 00000000..2968627b
--- /dev/null
+++ b/tests/test_temperature.py
@@ -0,0 +1,33 @@
+import numpy as np
+import pandas as pd
+import pytest
+import xarray as xr
+
+from xclim.temperature import TxMax, TxMin
+
+
+@pytest.fixture
+def tas_series():
+    def _tas_series(values):
+        coords = pd.date_range('7/1/2000', periods=len(values), freq=pd.DateOffset(days=1))
+        return xr.DataArray(values, coords=[coords, ], dims='time',
+                            attrs={'standard_name': 'tasmax',
+                                   'cell_methods': 'time: maximum within days',
+                                   'units': 'K'})
+
+    return _tas_series
+
+
+class Test_TxMax():
+
+    def test_simple(self, tas_series):
+        ts = tas_series(np.arange(720))
+        tx_obj = TxMax()
+        tx_obj(ts, freq='Y')
+
+
+class Text_TxMin():
+    def test_simple(self, tas_series):
+        ts = tas_series(np.arange(720))
+        tx_obj = TxMin()
+        tx_obj(ts, freq='Y')