Repository: Ouranosinc/xclim

Issue:
WMO check missing doesn't account for missing months
* xclim version: 0.29.0
* Python version: 3.9
* Operating System: BSD

### Description

The missing data checks using the `set_options(check_missing="wmo")` context are correctly dropping the months that don't have an adequate number of days or too many days missing in a series, but for annually-calculated indicators, if entire months are not within the series, the year is still valid (e.g.: If a time series final year ends in March, that year is considered valid!)

### What I Did
```
gdd = xclim.atmos.gdd(ds.tas, freq="YS", thresh="0 degC")  # station data ending in March
gdd.plot()
```
![image](https://user-images.githubusercontent.com/10819524/132063788-0d05810d-5273-43f8-a6b0-b96b3875b096.png)

```
with set_options(check_missing="wmo"):
    gdd = xclim.atmos.gdd(ds.tas, freq="YS", thresh="0 degC")  # station data ending in March
gdd.plot()
```
![image](https://user-images.githubusercontent.com/10819524/132063869-acb469a5-3e55-408c-9f3e-25407e1e2459.png)


Discussion:

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 0c8f0a75..4d485e63 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -17,6 +17,7 @@ Bug fixes
 * ``xc.core.units.rate2amount`` is now exact when the sampling frequency is monthly, seasonal or yearly. Earlier, monthly and yearly data were computed using constant month and year length. End-of-period frequencies are also correctly understood (ex: "M" vs "MS").
 * In the ``potential_evapotranspiration`` indice, add abbreviated ``method`` names to docstring.
 * Fixed an issue that prevented using the default ``group`` arg in adjustment objects.
+* Fix bug in ``missing_wmo``, where a period would be considered valid if all months met WMO criteria, but complete months in a year were missing. Now if any month does not meet criteria or is absent, the period will be considered missing.
 
 Internal Changes
 ~~~~~~~~~~~~~~~~
diff --git a/xclim/core/missing.py b/xclim/core/missing.py
index 67beff01..3fa0f9de 100644
--- a/xclim/core/missing.py
+++ b/xclim/core/missing.py
@@ -62,7 +62,7 @@ class MissingBase:
 
     def __init__(self, da, freq, src_timestep, **indexer):
         if src_timestep is None:
-            raise ValueError("`src_timestep` must be either 'D' or 'H'.")
+            raise ValueError("`src_timestep` must be either 'D', 'H' or 'M'.")
         self.null, self.count = self.prepare(da, freq, src_timestep, **indexer)
 
     @classmethod
@@ -140,7 +140,7 @@ class MissingBase:
             start_time = i[:1]
             end_time = i[-1:]
 
-        if indexer:
+        if indexer or "M" in src_timestep:
             # Create a full synthetic time series and compare the number of days with the original series.
             t = date_range(
                 start_time[0],
@@ -253,6 +253,11 @@ class MissingWMO(MissingAny):
     -------
     out : DataArray
       A boolean array set to True if period has missing values.
+
+    Notes
+    -----
+    If used at frequencies larger than a month, for example on an annual or seasonal basis, the function will return
+    True if any month within a period is missing.
     """
 
     def __init__(self, da, freq, src_timestep, **indexer):
@@ -274,8 +279,11 @@ class MissingWMO(MissingAny):
             raise ValueError(
                 "MissingWMO can only be used with Monthly or longer frequencies."
             )
-        obj = cls(da, "MS", src_timestep, **indexer)
-        return obj(**options).resample(time=freq).any()
+        obj = cls(da, "M", src_timestep, **indexer)
+        miss = obj(**options)
+        # Replace missing months by NaNs
+        mda = miss.where(miss == 0)
+        return MissingAny(mda, freq, "M", **indexer)()
 
     def is_missing(self, null, count, nm=11, nc=5):
         from xclim.indices import run_length as rl
@@ -421,8 +429,9 @@ def missing_any(da, freq, src_timestep=None, **indexer):  # noqa: D103
 
 def missing_wmo(da, freq, nm=11, nc=5, src_timestep=None, **indexer):  # noqa: D103
     src_timestep = src_timestep or xr.infer_freq(da.time)
-    missing = MissingWMO(da, "M", src_timestep, **indexer)(nm=nm, nc=nc)
-    return missing.resample(time=freq).any()
+    return MissingWMO.execute(
+        da, freq, src_timestep, options=dict(nm=nm, nc=nc), indexer=indexer
+    )
 
 
 def missing_pct(da, freq, tolerance, src_timestep=None, **indexer):  # noqa: D103


Test patch:
diff --git a/xclim/testing/tests/test_missing.py b/xclim/testing/tests/test_missing.py
index 1cfda02b..efd67ebf 100644
--- a/xclim/testing/tests/test_missing.py
+++ b/xclim/testing/tests/test_missing.py
@@ -12,6 +12,25 @@ from xclim.testing import open_dataset
 K2C = 273.15
 
 
+class TestMissingBase:
+    """The base class is well tested for daily input through the subclasses."""
+
+    def test_monthly_input(self):
+        """Creating array with 11 months."""
+        n = 11
+        time = xr.cftime_range(start="2002-01-01", periods=n, freq="M")
+        ts = xr.DataArray(np.random.rand(n), dims="time", coords={"time": time})
+        mb = missing.MissingBase(ts, freq="YS", src_timestep="M")
+        # Make sure count is 12, because we're requesting a YS freq.
+        assert mb.count == 12
+
+        n = 5
+        time = xr.cftime_range(start="2002-06-01", periods=n, freq="MS")
+        ts = xr.DataArray(np.random.rand(n), dims="time", coords={"time": time})
+        mb = missing.MissingBase(ts, freq="AS", src_timestep="M", season="JJA")
+        assert mb.count == 3
+
+
 class TestMissingAnyFills:
     def test_missing_days(self, tas_series):
         a = np.arange(360.0)
@@ -150,6 +169,13 @@ class TestMissingWMO:
         with pytest.raises(ValueError):
             missing.missing_wmo(pr, freq="MS")
 
+    def test_incomplete_year(self, tas_series):
+        # One complete month
+        a = np.arange(31)
+        ts = tas_series(a)
+        out = missing.missing_wmo(ts, freq="YS")
+        np.testing.assert_array_equal(out, [True])
+
 
 class TestMissingPct:
     def test_missing_days(self, tas_series):