Repository: Ouranosinc/xclim

Issue:
Support `freq=None` in missing_* functions
### Description
<!--Describe what you were trying to get done.
Tell us what happened, what went wrong, and what you expected to happen.-->

This will simplify the Indicator class logic and facilitate switching the missing method.


Discussion:

Patch:
diff --git a/xclim/core/checks.py b/xclim/core/checks.py
index fb7eef91..d478910c 100644
--- a/xclim/core/checks.py
+++ b/xclim/core/checks.py
@@ -62,7 +62,7 @@ def assert_daily(var):
     if np.timedelta64(dt.timedelta(days=1)) != (t1 - t0).data:
         raise ValueError("time series is not daily.")
 
-    # Check that the series has the same time step throughout
+    # Check that the series does not go backward in time
     if not var.time.to_pandas().is_monotonic_increasing:
         raise ValueError("time index is not monotonically increasing.")
 
@@ -167,41 +167,75 @@ class MissingBase:
 
     @staticmethod
     def split_freq(freq):
+        if freq is None:
+            return "", None
+
         if "-" in freq:
-            pfreq, anchor = freq.split("-")
-        else:
-            pfreq, anchor = freq, None
+            return freq.split("-")
 
-        return pfreq, anchor
+        return freq, None
 
     @staticmethod
     def is_null(da, freq, **indexer):
-        # Compute the number of days in the time series during each period at the given frequency.
+        """Return a boolean array indicating which values are null."""
         selected = generic.select_time(da, **indexer)
         if selected.time.size == 0:
             raise ValueError("No data for selected period.")
 
-        return selected.isnull().resample(time=freq)
+        null = selected.isnull()
+        if freq:
+            return null.resample(time=freq)
+
+        return null
 
     def prepare(self, da, freq, **indexer):
-        pfreq, anchor = self.split_freq(freq)
+        """Prepare arrays to be fed to the `is_missing` function.
+
+        Parameters
+        ----------
+        da : xr.DataArray
+          Input data.
+        freq : str
+          Resampling frequency defining the periods defined in
+          http://pandas.pydata.org/pandas-docs/stable/timeseries.html#resampling.
+        **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.
+
+        Returns
+        -------
+        xr.DataArray, xr.DataArray
+          Boolean array indicating which values are null, array of expected number of valid values.
+
+        Notes
+        -----
+        If `freq=None` and an indexer is given, then missing values during period at the start or end of array won't be
+        flagged.
+        """
         null = self.is_null(da, freq, **indexer)
 
+        pfreq, anchor = self.split_freq(freq)
+
         c = null.sum(dim="time")
 
         # Otherwise simply use the start and end dates to find the expected number of days.
         if pfreq.endswith("S"):
             start_time = c.indexes["time"]
             end_time = start_time.shift(1, freq=freq)
-        else:
+        elif pfreq:
             end_time = c.indexes["time"]
             start_time = end_time.shift(-1, freq=freq)
+        else:
+            i = da.time.to_index()
+            start_time = i[:1]
+            end_time = i[-1:]
 
         if indexer:
             # Create a full synthetic time series and compare the number of days with the original series.
             t0 = str(start_time[0].date())
             t1 = str(end_time[-1].date())
-            if isinstance(c.indexes["time"], xr.CFTimeIndex):
+            if isinstance(da.indexes["time"], xr.CFTimeIndex):
                 cal = da.time.encoding.get("calendar")
                 t = xr.cftime_range(t0, t1, freq="D", calendar=cal)
             else:
@@ -209,11 +243,17 @@ class MissingBase:
 
             sda = xr.DataArray(data=np.ones(len(t)), coords={"time": t}, dims=("time",))
             st = generic.select_time(sda, **indexer)
-            count = st.notnull().resample(time=freq).sum(dim="time")
+            if freq:
+                count = st.notnull().resample(time=freq).sum(dim="time")
+            else:
+                count = st.notnull().sum(dim="time")
 
         else:
             n = (end_time - start_time).days
-            count = xr.DataArray(n.values, coords={"time": c.time}, dims="time")
+            if freq:
+                count = xr.DataArray(n.values, coords={"time": c.time}, dims="time")
+            else:
+                count = xr.DataArray(n.values[0] + 1)
 
         return null, count
 
diff --git a/xclim/core/indicator.py b/xclim/core/indicator.py
index 15ebb5de..97000280 100644
--- a/xclim/core/indicator.py
+++ b/xclim/core/indicator.py
@@ -308,12 +308,11 @@ class Indicator:
         from functools import reduce
 
         freq = kwds.get("freq")
-        if freq is not None:
-            # We flag any period with missing data
-            miss = (checks.missing_any(da, freq) for da in args)
-        else:
-            # There is no resampling, we flag where one of the input is missing
-            miss = (da.isnull() for da in args)
+        indexer = kwds.get("indexer") or {}
+
+        # We flag any period with missing data
+        miss = (checks.missing_any(da, freq, **indexer) for da in args)
+
         return reduce(np.logical_or, miss)
 
     def validate(self, da):
diff --git a/xclim/indicators/atmos/_conversion.py b/xclim/indicators/atmos/_conversion.py
index b61ef4fc..94b1ed85 100644
--- a/xclim/indicators/atmos/_conversion.py
+++ b/xclim/indicators/atmos/_conversion.py
@@ -2,7 +2,6 @@ from xclim import indices
 from xclim.core.indicator import Indicator
 from xclim.core.utils import wrapped_partial
 
-
 __all__ = [
     "tg",
     "wind_speed_from_vector",
@@ -19,6 +18,11 @@ class Converter(Indicator):
     def validate(self, da):
         """Input validation."""
 
+    def missing(self, *args, **kwds):
+        """Return whether an output is considered missing or not."""
+        # Converters should propagate null values themselves.
+        return False
+
 
 tg = Converter(
     identifier="tg",


Test patch:
diff --git a/tests/test_atmos.py b/tests/test_atmos.py
index 670e16ed..0cc0280d 100644
--- a/tests/test_atmos.py
+++ b/tests/test_atmos.py
@@ -10,16 +10,21 @@ K2C = 273.16
 
 
 def test_wind_speed_from_vectors():
-    uas = xr.DataArray(np.array([3, -3]), dims=["x"])
+    uas = xr.DataArray(np.array([3.0, -3.0]), dims=["x"])
     uas.attrs["units"] = "m s-1"
-    vas = xr.DataArray(np.array([4, -4]), dims=["x"])
+    vas = xr.DataArray(np.array([4.0, -4.0]), dims=["x"])
     vas.attrs["units"] = "m s-1"
-    exp_wind = xr.DataArray(np.array([5, 5]), dims=["x"])
+    exp_wind = xr.DataArray(np.array([5.0, 5.0]), dims=["x"])
     exp_wind.attrs["units"] = "m s-1"
 
     wind = atmos.wind_speed_from_vector(uas=uas, vas=vas)
     np.testing.assert_allclose(wind, exp_wind)
 
+    # missing values
+    uas[0] = np.nan
+    wind = atmos.wind_speed_from_vector(uas=uas, vas=vas)
+    np.testing.assert_array_equal(wind.isnull(), [True, False])
+
 
 def test_relative_humidity_dewpoint(tas_series, rh_series):
     np.testing.assert_allclose(
diff --git a/tests/test_checks.py b/tests/test_checks.py
index 3cae7161..4cad72ef 100644
--- a/tests/test_checks.py
+++ b/tests/test_checks.py
@@ -121,6 +121,25 @@ class TestMissingAnyFills:
         miss = checks.missing_any(ts, freq="YS", season="SON")
         np.testing.assert_equal(miss, [False])
 
+    def test_no_freq(self, tasmin_series):
+        ts = tasmin_series(np.zeros(360))
+
+        miss = checks.missing_any(ts, freq=None)
+        np.testing.assert_array_equal(miss, False)
+
+        t = list(range(31))
+        t.pop(5)
+        ts2 = ts.isel(time=t)
+        miss = checks.missing_any(ts2, freq=None)
+        np.testing.assert_array_equal(miss, True)
+
+        # With indexer
+        miss = checks.missing_any(ts, freq=None, month=[7])
+        np.testing.assert_array_equal(miss, False)
+
+        miss = checks.missing_any(ts2, freq=None, month=[7])
+        np.testing.assert_array_equal(miss, True)
+
     def test_hydro(self):
         fn = Path(TESTS_DATA, "Raven", "q_sim.nc")
         ds = xr.open_dataset(fn)
diff --git a/tests/test_indicators.py b/tests/test_indicators.py
index 9898ef5a..82a5bfcc 100644
--- a/tests/test_indicators.py
+++ b/tests/test_indicators.py
@@ -16,6 +16,7 @@ from xclim.core.formatting import update_history
 from xclim.core.indicator import Indicator
 from xclim.core.units import units
 from xclim.indices import tg_mean
+from xclim.indices.generic import select_time
 
 
 class UniIndTemp(Indicator):
@@ -45,6 +46,16 @@ class UniIndPr(Indicator):
         return da.resample(time=freq).mean(keep_attrs=True)
 
 
+class UniClim(Indicator):
+    identifier = "clim"
+    units = "K"
+
+    @staticmethod
+    def compute(da, **indexer):
+        select = select_time(da, **indexer)
+        return select.mean(dim="time", keep_attrs=True)
+
+
 def test_attrs(tas_series):
     import datetime as dt
 
@@ -69,6 +80,29 @@ def test_temp_unit_conversion(tas_series):
     np.testing.assert_array_almost_equal(txk, txc + 273.15)
 
 
+def test_missing(tas_series):
+    a = tas_series(np.ones(360, float))
+    ind = UniIndTemp()
+    clim = UniClim()
+
+    # Null value
+    a[5] = np.nan
+
+    out = ind(a, freq="MS")
+    assert out[0].isnull()
+
+    # With freq=None
+    out = clim(a)
+    assert out.isnull()
+
+    # With indexer
+    out = clim(a, month=[1])
+    assert not out.isnull()
+
+    out = clim(a, month=[7])
+    assert out.isnull()
+
+
 def test_json(pr_series):
     ind = UniIndPr()
     meta = ind.json()