Repository: Ouranosinc/xclim

Issue:
Split cfchecks, datachecks and missing functions into their own module. 
# Description
<!--Describe what you were trying to get done.
Tell us what happened, what went wrong, and what you expected to happen.-->

As part of the refactoring effort for a 1.0 release, I suggest we clearly split cfchecks, datachecks and missing functions into their own module

Discussion:

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 0bd3055e..eb7332a4 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -11,6 +11,7 @@ History
 * `ensembles.ensemble_percentiles` modified to compute along a `percentiles` dimension by default, instead of creating different variables.
 * Added indicator `first_day_below` and run length helper `first_run_after_date`.
 * Added ANUCLIM model climate indices mappings.
+* Split `checks.py` into `cfchecks.py`, `datachecks.py` and `missing.py`. This change will only affect users creating custom indices using utilities previously located in `checks.py`.
 
 0.17.0 (2020-05-15)
 -------------------
diff --git a/docs/checks.rst b/docs/checks.rst
index 4c5797ce..efc4ed5a 100644
--- a/docs/checks.rst
+++ b/docs/checks.rst
@@ -9,13 +9,13 @@ Missing values
 ==============
 The following functions can be used to mask missing values in :class:`Indicator` outputs.
 
-.. autofunction:: xclim.core.checks.missing_any
+.. autofunction:: xclim.core.missing.missing_any
    :noindex:
 
-.. autofunction:: xclim.core.checks.missing_pct
+.. autofunction:: xclim.core.missing.missing_pct
    :noindex:
-.. autofunction:: xclim.core.checks.missing_wmo
+.. autofunction:: xclim.core.missing.missing_wmo
    :noindex:
 
-.. autofunction:: xclim.core.checks.at_least_n_valid
+.. autofunction:: xclim.core.missing.at_least_n_valid
    :noindex:
diff --git a/docs/notebooks/options.ipynb b/docs/notebooks/options.ipynb
index 0cf164c1..0b82b54f 100644
--- a/docs/notebooks/options.ipynb
+++ b/docs/notebooks/options.ipynb
@@ -99,8 +99,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from xclim.core.checks import register_missing_method\n",
-    "from xclim.core.checks import MissingBase\n",
+    "from xclim.core.missing import register_missing_method\n",
+    "from xclim.core.missing import MissingBase\n",
     "from xclim.indices.run_length import longest_run\n",
     "\n",
     "@register_missing_method(\"consecutive\")\n",
diff --git a/xclim/core/cfchecks.py b/xclim/core/cfchecks.py
new file mode 100644
index 00000000..80645177
--- /dev/null
+++ b/xclim/core/cfchecks.py
@@ -0,0 +1,57 @@
+"""
+CF-Convention checks
+====================
+
+Utilities designed to verify the compliance of metadata with the CF-Convention.
+"""
+import fnmatch
+
+from .options import cfcheck
+from .utils import ValidationError
+
+# TODO: Implement pandas infer_freq in xarray with CFTimeIndex. >> PR pydata/xarray#4033
+
+
+@cfcheck
+def check_valid(var, key, expected):
+    r"""Check that a variable's attribute has the expected value. Warn user otherwise."""
+
+    att = getattr(var, key, None)
+    if att is None:
+        raise ValidationError(f"Variable does not have a `{key}` attribute.")
+    if not fnmatch.fnmatch(att, expected):
+        raise ValidationError(
+            f"Variable has a non-conforming {key}. Got `{att}`, expected `{expected}`",
+        )
+
+
+def check_valid_temperature(var, units):
+    r"""Check that variable is air temperature."""
+    check_valid(var, "standard_name", "air_temperature")
+    check_valid(var, "units", units)
+
+
+def check_valid_discharge(var):
+    r"""Check that the variable is a discharge."""
+    check_valid(var, "standard_name", "water_volume_transport_in_river_channel")
+    check_valid(var, "units", "m3 s-1")
+
+
+def check_valid_min_temperature(var, units="K"):
+    r"""Check that a variable is a valid daily minimum temperature."""
+
+    check_valid_temperature(var, units)
+    check_valid(var, "cell_methods", "time: minimum within days")
+
+
+def check_valid_mean_temperature(var, units="K"):
+    r"""Check that a variable is a valid daily mean temperature."""
+
+    check_valid_temperature(var, units)
+    check_valid(var, "cell_methods", "time: mean within days")
+
+
+def check_valid_max_temperature(var, units="K"):
+    r"""Check that a variable is a valid daily maximum temperature."""
+    check_valid_temperature(var, units)
+    check_valid(var, "cell_methods", "time: maximum within days")
diff --git a/xclim/core/checks.py b/xclim/core/checks.py
index a7da46db..3f8c4dcb 100644
--- a/xclim/core/checks.py
+++ b/xclim/core/checks.py
@@ -1,461 +1,4 @@
-# -*- coding: utf-8 -*-
-"""
-Health checks
-=============
-
-Functions performing basic health checks on xarray.DataArrays.
-"""
-import datetime as dt
-import fnmatch
-
-import numpy as np
-import pandas as pd
-import xarray as xr
-from boltons.funcutils import wraps
-
-from .options import cfcheck
-from .options import CHECK_MISSING
-from .options import datacheck
-from .options import MISSING_METHODS
-from .options import MISSING_OPTIONS
-from .options import OPTIONS
-from .options import register_missing_method
-from .utils import ValidationError
-
-# Dev notes
-# ---------
-#
-# I suggest we use `check` for weak checking, and `assert` for strong checking.
-# Weak checking would log problems in a log, while strong checking would raise an error.
-#
-# ANSWER to the above:
-#   The use of set_options with a switch for raising vs warning vs logging would render these terms useless.
-#
-# `functools.wraps` is used to copy the docstring and the function's original name from the source
-# function to the decorated function. This allows sphinx to correctly find and document functions.
-
-
-# TODO: Implement pandas infer_freq in xarray with CFTimeIndex. >> PR pydata/xarray#4033
-@cfcheck
-def check_valid(var, key, expected):
-    r"""Check that a variable's attribute has the expected value. Warn user otherwise."""
-
-    att = getattr(var, key, None)
-    if att is None:
-        raise ValidationError(f"Variable does not have a `{key}` attribute.")
-    if not fnmatch.fnmatch(att, expected):
-        raise ValidationError(
-            f"Variable has a non-conforming {key}. Got `{att}`, expected `{expected}`",
-        )
-
-
-@datacheck
-def check_daily(var):
-    r"""Assert that the series is daily and monotonic (no jumps in time index).
-
-    A ValueError is raised otherwise."""
-
-    t0, t1 = var.time[:2]
-
-    # This won't work for non-standard calendars. Needs to be implemented in xarray. Comment for now
-    if isinstance(t0.values, np.datetime64):
-        if pd.infer_freq(var.time.to_pandas()) != "D":
-            raise ValidationError("time series is not recognized as daily.")
-
-    # Check that the first time step is one day.
-    if np.timedelta64(dt.timedelta(days=1)) != (t1 - t0).data:
-        raise ValidationError("time series is not daily.")
-
-    # Check that the series does not go backward in time
-    if not var.time.to_pandas().is_monotonic_increasing:
-        raise ValidationError("time index is not monotonically increasing.")
-
-
-def check_valid_temperature(var, units):
-    r"""Check that variable is air temperature."""
-
-    check_valid(var, "standard_name", "air_temperature")
-    check_valid(var, "units", units)
-    check_daily(var)
-
-
-def check_valid_discharge(var):
-    r"""Check that the variable is a discharge."""
-    #
-    check_valid(var, "standard_name", "water_volume_transport_in_river_channel")
-    check_valid(var, "units", "m3 s-1")
-
-
-def valid_daily_min_temperature(comp, units="K"):
-    r"""Decorator to check that a computation runs on a valid temperature dataset."""
-
-    @wraps(comp)
-    def func(tasmin, *args, **kwds):
-        check_valid_temperature(tasmin, units)
-        check_valid(tasmin, "cell_methods", "time: minimum within days")
-        return comp(tasmin, **kwds)
-
-    return func
-
-
-def valid_daily_mean_temperature(comp, units="K"):
-    r"""Decorator to check that a computation runs on a valid temperature dataset."""
-
-    @wraps(comp)
-    def func(tas, *args, **kwds):
-        check_valid_temperature(tas, units)
-        check_valid(tas, "cell_methods", "time: mean within days")
-        return comp(tas, *args, **kwds)
-
-    return func
-
-
-def valid_daily_max_temperature(comp, units="K"):
-    r"""Decorator to check that a computation runs on a valid temperature dataset."""
-
-    @wraps(comp)
-    def func(tasmax, *args, **kwds):
-        check_valid_temperature(tasmax, units)
-        check_valid(tasmax, "cell_methods", "time: maximum within days")
-        return comp(tasmax, *args, **kwds)
-
-    return func
-
-
-def valid_daily_max_min_temperature(comp, units="K"):
-    r"""Decorator to check that a computation runs on valid min and max temperature datasets."""
-
-    @wraps(comp)
-    def func(tasmax, tasmin, **kwds):
-        valid_daily_max_temperature(tasmax, units)
-        valid_daily_min_temperature(tasmin, units)
-
-        return comp(tasmax, tasmin, **kwds)
-
-    return func
-
-
-def valid_daily_mean_discharge(comp):
-    r"""Decorator to check that a computation runs on valid discharge data."""
-
-    @wraps(comp)
-    def func(q, **kwds):
-        check_valid_discharge(q)
-        return comp(q, **kwds)
-
-    return func
-
-
-def valid_missing_data_threshold(comp, threshold=0):
-    r"""Check that the relative number of missing data points does not exceed a threshold."""
-    # TODO
-    raise NotImplementedError
-
-
-def check_is_dataarray(comp):
-    r"""Decorator to check that a computation has an instance of xarray.DataArray
-     as first argument."""
-
-    @wraps(comp)
-    def func(data_array, *args, **kwds):
-        assert isinstance(data_array, xr.DataArray)
-        return comp(data_array, *args, **kwds)
-
-    return func
-
-
-# This function can probably be made simpler once CFPeriodIndex is implemented.
-class MissingBase:
-    def __init__(self, da, freq, **indexer):
-        self.null, self.count = self.prepare(da, freq, **indexer)
-
-    @staticmethod
-    def split_freq(freq):
-        if freq is None:
-            return "", None
-
-        if "-" in freq:
-            return freq.split("-")
-
-        return freq, None
-
-    @staticmethod
-    def is_null(da, freq, **indexer):
-        """Return a boolean array indicating which values are null."""
-        from xclim.indices import generic
-
-        selected = generic.select_time(da, **indexer)
-        if selected.time.size == 0:
-            raise ValueError("No data for selected period.")
-
-        null = selected.isnull()
-        if freq:
-            return null.resample(time=freq)
-
-        return null
-
-    def prepare(self, da, freq, **indexer):
-        """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.
-        """
-        from xclim.indices import generic
-
-        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)
-        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(da.indexes["time"], xr.CFTimeIndex):
-                cal = da.time.encoding.get("calendar")
-                t = xr.cftime_range(t0, t1, freq="D", calendar=cal)
-            else:
-                t = pd.date_range(t0, t1, freq="D")
-
-            sda = xr.DataArray(data=np.ones(len(t)), coords={"time": t}, dims=("time",))
-            st = generic.select_time(sda, **indexer)
-            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
-            if freq:
-                count = xr.DataArray(n.values, coords={"time": c.time}, dims="time")
-            else:
-                count = xr.DataArray(n.values[0] + 1)
-
-        return null, count
-
-    def is_missing(self, null, count, **kwargs):
-        """Return whether or not the values within each period should be considered missing or not."""
-        raise NotImplementedError
-
-    @staticmethod
-    def validate(**kwargs):
-        """Return whether or not arguments are valid."""
-        return True
-
-    def __call__(self, **kwargs):
-        if not self.validate(**kwargs):
-            raise ValueError("Invalid arguments")
-        return self.is_missing(self.null, self.count, **kwargs)
-
-
-@register_missing_method("any")
-class MissingAny(MissingBase):
-    def is_missing(self, null, count, **kwargs):
-        cond0 = null.count(dim="time") != count  # Check total number of days
-        cond1 = null.sum(dim="time") > 0  # Check if any is missing
-        return cond0 | cond1
-
-
-@register_missing_method("wmo")
-class MissingWMO(MissingAny):
-    def __init__(self, da, freq, **indexer):
-        # Force computation on monthly frequency
-        if not freq.startswith("M"):
-            raise ValueError
-        super().__init__(da, freq, **indexer)
-
-    def is_missing(self, null, count, nm=11, nc=5):
-        import xclim.indices.run_length as rl
-
-        # Check total number of days
-        cond0 = null.count(dim="time") != count
-
-        # Check if more than threshold is missing
-        cond1 = null.sum(dim="time") >= nm
-
-        # Check for consecutive missing values
-        cond2 = null.map(rl.longest_run, dim="time") >= nc
-
-        return cond0 | cond1 | cond2
-
-    @staticmethod
-    def validate(nm, nc):
-        return nm < 31 and nc < 31
-
-
-@register_missing_method("pct")
-class MissingPct(MissingBase):
-    def is_missing(self, null, count, tolerance=0.1):
-        if tolerance < 0 or tolerance > 1:
-            raise ValueError("tolerance should be between 0 and 1.")
-
-        n = count - null.count(dim="time") + null.sum(dim="time")
-        return n / count >= tolerance
-
-    @staticmethod
-    def validate(tolerance):
-        return 0 <= tolerance <= 1
-
-
-@register_missing_method("at_least_n")
-class AtLeastNValid(MissingBase):
-    def is_missing(self, null, count, n=20):
-        """The result of a reduction operation is considered missing if less than `n` values are valid."""
-        nvalid = null.count(dim="time") - null.sum(dim="time")
-        return nvalid < n
-
-    @staticmethod
-    def validate(n):
-        return n > 0
-
-
-def missing_any(da, freq, **indexer):
-    r"""Return whether there are missing days in the array.
-
-    Parameters
-    ----------
-    da : DataArray
-      Input array at daily frequency.
-    freq : str
-      Resampling frequency.
-    **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
-    -------
-    out : DataArray
-      A boolean array set to True if period has missing values.
-    """
-    return MissingAny(da, freq, **indexer)()
-
-
-def missing_wmo(da, freq, nm=11, nc=5, **indexer):
-    r"""Return whether a series fails WMO criteria for missing days.
-
-    The World Meteorological Organisation recommends that where monthly means are computed from daily values,
-    it should considered missing if either of these two criteria are met:
-
-      – observations are missing for 11 or more days during the month;
-      – observations are missing for a period of 5 or more consecutive days during the month.
-
-    Stricter criteria are sometimes used in practice, with a tolerance of 5 missing values or 3 consecutive missing
-    values.
-
-    Parameters
-    ----------
-    da : DataArray
-      Input array at daily frequency.
-    freq : str
-      Resampling frequency.
-    nm : int
-      Number of missing values per month that should not be exceeded.
-    nc : int
-      Number of consecutive missing values per month that should not be exceeded.
-    **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
-    -------
-    out : DataArray
-      A boolean array set to True if period has missing values.
-    """
-    missing = MissingWMO(da, "M", **indexer)(nm=nm, nc=nc)
-    return missing.resample(time=freq).any()
-
-
-def missing_pct(da, freq, tolerance, **indexer):
-    r"""Return whether there are more missing days in the array than a given percentage.
-
-    Parameters
-    ----------
-    da : DataArray
-      Input array at daily frequency.
-    freq : str
-      Resampling frequency.
-    tolerance : float
-      Fraction of missing values that is tolerated.
-    **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
-    -------
-    out : DataArray
-      A boolean array set to True if period has missing values.
-    """
-    return MissingPct(da, freq, **indexer)(tolerance=tolerance)
-
-
-def at_least_n_valid(da, freq, n=1, **indexer):
-    r"""Return whether there are at least a given number of valid values.
-
-        Parameters
-        ----------
-        da : DataArray
-          Input array at daily frequency.
-        freq : str
-          Resampling frequency.
-        n : int
-          Minimum of valid values required.
-        **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
-        -------
-        out : DataArray
-          A boolean array set to True if period has missing values.
-        """
-    return AtLeastNValid(da, freq, **indexer)(n=n)
-
-
-def missing_from_context(da, freq, **indexer):
-    """Return whether each element of the resampled da should be considered missing according
-    to the currently set options in `xclim.set_options`.
-
-    See `xclim.set_options` and `xclim.core.options.register_missing_method`.
-    """
-    name = OPTIONS[CHECK_MISSING]
-    cls = MISSING_METHODS[name]
-    opts = OPTIONS[MISSING_OPTIONS][name]
-
-    return cls(da, freq, **indexer)(**opts)
+# This file is kept for backward compatibility. It will be removed in a later version.
+from .cfchecks import *
+from .datachecks import *
+from .missing import *
diff --git a/xclim/core/datachecks.py b/xclim/core/datachecks.py
new file mode 100644
index 00000000..0da55888
--- /dev/null
+++ b/xclim/core/datachecks.py
@@ -0,0 +1,35 @@
+"""
+Data checks
+===========
+
+Utilities designed to check the validity of data inputs.
+"""
+import datetime as dt
+
+import numpy as np
+import pandas as pd
+
+from .options import datacheck
+from .utils import ValidationError
+
+
+@datacheck
+def check_daily(var):
+    r"""Assert that the series is daily and monotonic (no jumps in time index).
+
+    A ValueError is raised otherwise."""
+
+    t0, t1 = var.time[:2]
+
+    # This won't work for non-standard calendars. Needs to be implemented in xarray. Comment for now
+    if isinstance(t0.values, np.datetime64):
+        if pd.infer_freq(var.time.to_pandas()) != "D":
+            raise ValidationError("time series is not recognized as daily.")
+
+    # Check that the first time step is one day.
+    if np.timedelta64(dt.timedelta(days=1)) != (t1 - t0).data:
+        raise ValidationError("time series is not daily.")
+
+    # Check that the series does not go backward in time
+    if not var.time.to_pandas().is_monotonic_increasing:
+        raise ValidationError("time index is not monotonically increasing.")
diff --git a/xclim/core/formatting.py b/xclim/core/formatting.py
index ccdcae9d..3f32130b 100644
--- a/xclim/core/formatting.py
+++ b/xclim/core/formatting.py
@@ -139,24 +139,23 @@ def merge_attributes(
     Parameters
     ----------
     attribute : str
-        The attribute to merge.
-    *inputs_list : Union[xr.DataArray, xr.Dataset]
-        The datasets or variables that were used to produce the new object.
-        Inputs given that way will be prefixed by their "name" attribute if available.
+      The attribute to merge.
+    inputs_list : Union[xr.DataArray, xr.Dataset]
+      The datasets or variables that were used to produce the new object. Inputs given that way will be prefixed by their `name` attribute if available.
     new_line : str
-        The character to put between each instance of the attributes. Usually, in CF-conventions,
-        the history attributes uses "\n" while cell_methods uses " ".
+      The character to put between each instance of the attributes. Usually, in CF-conventions,
+      the history attributes uses "\n" while cell_methods uses " ".
     missing_str : str
-        A string that is printed if an input doesn't have the attribute. Defaults to None, in which
-        case the input is simply skipped.
-    **inputs_kws : Union[xr.DataArray, xr.Dataset]
-        Mapping from names to the datasets or variables that were used to produce the new object.
-        Inputs given that way will be prefixes by the passed name.
+      A string that is printed if an input doesn't have the attribute. Defaults to None, in which
+      case the input is simply skipped.
+    inputs_kws : Union[xr.DataArray, xr.Dataset]
+      Mapping from names to the datasets or variables that were used to produce the new object.
+      Inputs given that way will be prefixes by the passed name.
 
     Returns
     -------
     str
-        The new attribute made from the combination of the ones from all the inputs.
+      The new attribute made from the combination of the ones from all the inputs.
     """
     inputs = []
     for in_ds in inputs_list:
@@ -191,20 +190,20 @@ def update_history(
     Parameters
     ----------
     hist_str : str
-        The string describing what has been done on the data.
+      The string describing what has been done on the data.
     new_name : Optional[str]
-        The name of the newly created variable or dataset to prefix hist_msg.
+      The name of the newly created variable or dataset to prefix hist_msg.
     *inputs_list : Union[xr.DataArray, xr.Dataset]
-        The datasets or variables that were used to produce the new object.
-        Inputs given that way will be prefixed by their "name" attribute if available.
+      The datasets or variables that were used to produce the new object.
+      Inputs given that way will be prefixed by their "name" attribute if available.
     **inputs_kws : Union[xr.DataArray, xr.Dataset]
-        Mapping from names to the datasets or variables that were used to produce the new object.
-        Inputs given that way will be prefixes by the passed name.
+      Mapping from names to the datasets or variables that were used to produce the new object.
+      Inputs given that way will be prefixes by the passed name.
 
     Returns
     -------
     str
-        The combine history of all inputs starting with `hist_str`.
+      The combine history of all inputs starting with `hist_str`.
 
     See also
     --------
diff --git a/xclim/core/indicator.py b/xclim/core/indicator.py
index 9c7ebc81..cde96441 100644
--- a/xclim/core/indicator.py
+++ b/xclim/core/indicator.py
@@ -15,8 +15,7 @@ from typing import Union
 import numpy as np
 from boltons.funcutils import wraps
 
-from .checks import check_daily
-from .checks import missing_from_context
+from .datachecks import check_daily
 from .formatting import AttrFormatter
 from .formatting import default_formatter
 from .formatting import merge_attributes
@@ -24,6 +23,7 @@ from .formatting import parse_doc
 from .formatting import update_history
 from .locales import get_local_attrs
 from .locales import get_local_formatter
+from .missing import missing_from_context
 from .options import OPTIONS
 from .units import convert_units_to
 from .units import units
diff --git a/xclim/core/missing.py b/xclim/core/missing.py
new file mode 100644
index 00000000..d758e168
--- /dev/null
+++ b/xclim/core/missing.py
@@ -0,0 +1,339 @@
+"""
+Missing values identification
+=============================
+
+Algorithms identifying missing values using different criteria:
+
+- missing_any: A result is missing if any input value is missing.
+- missing_wmo: A result is missing if 11 days are missing, or 5 consecutive values are missing in a month.
+- missing_pct: A result is missing if more than a given fraction of values are missing.
+- at_least_n_valid: A result is missing if less than a given number of valid values are present.
+
+"""
+import numpy as np
+import pandas as pd
+import xarray as xr
+from boltons.funcutils import wraps
+
+from .options import CHECK_MISSING
+from .options import MISSING_METHODS
+from .options import MISSING_OPTIONS
+from .options import OPTIONS
+from .options import register_missing_method
+
+__all__ = [
+    "missing_wmo",
+    "missing_any",
+    "missing_pct",
+    "missing_from_context",
+    "register_missing_method",
+]
+
+
+def check_is_dataarray(comp):
+    r"""Decorator to check that a computation has an instance of xarray.DataArray
+     as first argument."""
+
+    @wraps(comp)
+    def func(data_array, *args, **kwds):
+        assert isinstance(data_array, xr.DataArray)
+        return comp(data_array, *args, **kwds)
+
+    return func
+
+
+# This function can probably be made simpler once CFPeriodIndex is implemented.
+class MissingBase:
+    def __init__(self, da, freq, **indexer):
+        self.null, self.count = self.prepare(da, freq, **indexer)
+
+    @staticmethod
+    def split_freq(freq):
+        if freq is None:
+            return "", None
+
+        if "-" in freq:
+            return freq.split("-")
+
+        return freq, None
+
+    @staticmethod
+    def is_null(da, freq, **indexer):
+        """Return a boolean array indicating which values are null."""
+        from xclim.indices import generic
+
+        selected = generic.select_time(da, **indexer)
+        if selected.time.size == 0:
+            raise ValueError("No data for selected period.")
+
+        null = selected.isnull()
+        if freq:
+            return null.resample(time=freq)
+
+        return null
+
+    def prepare(self, da, freq, **indexer):
+        """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.
+        """
+        from xclim.indices import generic
+
+        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)
+        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(da.indexes["time"], xr.CFTimeIndex):
+                cal = da.time.encoding.get("calendar")
+                t = xr.cftime_range(t0, t1, freq="D", calendar=cal)
+            else:
+                t = pd.date_range(t0, t1, freq="D")
+
+            sda = xr.DataArray(data=np.ones(len(t)), coords={"time": t}, dims=("time",))
+            st = generic.select_time(sda, **indexer)
+            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
+            if freq:
+                count = xr.DataArray(n.values, coords={"time": c.time}, dims="time")
+            else:
+                count = xr.DataArray(n.values[0] + 1)
+
+        return null, count
+
+    def is_missing(self, null, count, **kwargs):
+        """Return whether or not the values within each period should be considered missing or not."""
+        raise NotImplementedError
+
+    @staticmethod
+    def validate(**kwargs):
+        """Return whether or not arguments are valid."""
+        return True
+
+    def __call__(self, **kwargs):
+        if not self.validate(**kwargs):
+            raise ValueError("Invalid arguments")
+        return self.is_missing(self.null, self.count, **kwargs)
+
+
+@register_missing_method("any")
+class MissingAny(MissingBase):
+    def is_missing(self, null, count, **kwargs):
+        cond0 = null.count(dim="time") != count  # Check total number of days
+        cond1 = null.sum(dim="time") > 0  # Check if any is missing
+        return cond0 | cond1
+
+
+@register_missing_method("wmo")
+class MissingWMO(MissingAny):
+    def __init__(self, da, freq, **indexer):
+        # Force computation on monthly frequency
+        if not freq.startswith("M"):
+            raise ValueError
+        super().__init__(da, freq, **indexer)
+
+    def is_missing(self, null, count, nm=11, nc=5):
+        import xclim.indices.run_length as rl
+
+        # Check total number of days
+        cond0 = null.count(dim="time") != count
+
+        # Check if more than threshold is missing
+        cond1 = null.sum(dim="time") >= nm
+
+        # Check for consecutive missing values
+        cond2 = null.map(rl.longest_run, dim="time") >= nc
+
+        return cond0 | cond1 | cond2
+
+    @staticmethod
+    def validate(nm, nc):
+        return nm < 31 and nc < 31
+
+
+@register_missing_method("pct")
+class MissingPct(MissingBase):
+    def is_missing(self, null, count, tolerance=0.1):
+        if tolerance < 0 or tolerance > 1:
+            raise ValueError("tolerance should be between 0 and 1.")
+
+        n = count - null.count(dim="time") + null.sum(dim="time")
+        return n / count >= tolerance
+
+    @staticmethod
+    def validate(tolerance):
+        return 0 <= tolerance <= 1
+
+
+@register_missing_method("at_least_n")
+class AtLeastNValid(MissingBase):
+    def is_missing(self, null, count, n=20):
+        """The result of a reduction operation is considered missing if less than `n` values are valid."""
+        nvalid = null.count(dim="time") - null.sum(dim="time")
+        return nvalid < n
+
+    @staticmethod
+    def validate(n):
+        return n > 0
+
+
+def missing_any(da, freq, **indexer):
+    r"""Return whether there are missing days in the array.
+
+    Parameters
+    ----------
+    da : DataArray
+      Input array at daily frequency.
+    freq : str
+      Resampling frequency.
+    **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
+    -------
+    out : DataArray
+      A boolean array set to True if period has missing values.
+    """
+    return MissingAny(da, freq, **indexer)()
+
+
+def missing_wmo(da, freq, nm=11, nc=5, **indexer):
+    r"""Return whether a series fails WMO criteria for missing days.
+
+    The World Meteorological Organisation recommends that where monthly means are computed from daily values,
+    it should considered missing if either of these two criteria are met:
+
+      – observations are missing for 11 or more days during the month;
+      – observations are missing for a period of 5 or more consecutive days during the month.
+
+    Stricter criteria are sometimes used in practice, with a tolerance of 5 missing values or 3 consecutive missing
+    values.
+
+    Parameters
+    ----------
+    da : DataArray
+      Input array at daily frequency.
+    freq : str
+      Resampling frequency.
+    nm : int
+      Number of missing values per month that should not be exceeded.
+    nc : int
+      Number of consecutive missing values per month that should not be exceeded.
+    **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
+    -------
+    out : DataArray
+      A boolean array set to True if period has missing values.
+    """
+    missing = MissingWMO(da, "M", **indexer)(nm=nm, nc=nc)
+    return missing.resample(time=freq).any()
+
+
+def missing_pct(da, freq, tolerance, **indexer):
+    r"""Return whether there are more missing days in the array than a given percentage.
+
+    Parameters
+    ----------
+    da : DataArray
+      Input array at daily frequency.
+    freq : str
+      Resampling frequency.
+    tolerance : float
+      Fraction of missing values that is tolerated.
+    **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
+    -------
+    out : DataArray
+      A boolean array set to True if period has missing values.
+    """
+    return MissingPct(da, freq, **indexer)(tolerance=tolerance)
+
+
+def at_least_n_valid(da, freq, n=1, **indexer):
+    r"""Return whether there are at least a given number of valid values.
+
+        Parameters
+        ----------
+        da : DataArray
+          Input array at daily frequency.
+        freq : str
+          Resampling frequency.
+        n : int
+          Minimum of valid values required.
+        **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
+        -------
+        out : DataArray
+          A boolean array set to True if period has missing values.
+        """
+    return AtLeastNValid(da, freq, **indexer)(n=n)
+
+
+def missing_from_context(da, freq, **indexer):
+    """Return whether each element of the resampled da should be considered missing according
+    to the currently set options in `xclim.set_options`.
+
+    See `xclim.set_options` and `xclim.core.options.register_missing_method`.
+    """
+    name = OPTIONS[CHECK_MISSING]
+    cls = MISSING_METHODS[name]
+    opts = OPTIONS[MISSING_OPTIONS][name]
+
+    return cls(da, freq, **indexer)(**opts)
diff --git a/xclim/indicators/atmos/_temperature.py b/xclim/indicators/atmos/_temperature.py
index aeefbf9f..a37e2b81 100644
--- a/xclim/indicators/atmos/_temperature.py
+++ b/xclim/indicators/atmos/_temperature.py
@@ -2,7 +2,7 @@
 import abc
 
 from xclim import indices
-from xclim.core import checks
+from xclim.core import cfchecks
 from xclim.core.indicator import Indicator
 from xclim.core.indicator import Indicator2D
 from xclim.core.units import check_units
@@ -60,8 +60,8 @@ class Tas(Indicator):
     """Class for univariate indices using mean daily temperature as the input."""
 
     def cfprobe(self, tas):
-        checks.check_valid(tas, "cell_methods", "**time: mean within days*")
-        checks.check_valid(tas, "standard_name", "air_temperature")
+        cfchecks.check_valid(tas, "cell_methods", "**time: mean within days*")
+        cfchecks.check_valid(tas, "standard_name", "air_temperature")
 
     @abc.abstractmethod
     def compute(*args, **kwds):
@@ -72,8 +72,8 @@ class Tasmin(Indicator):
     """Class for univariate indices using min daily temperature as the input."""
 
     def cfprobe(self, tasmin):
-        checks.check_valid(tasmin, "cell_methods", "*time: minimum within days*")
-        checks.check_valid(tasmin, "standard_name", "air_temperature")
+        cfchecks.check_valid(tasmin, "cell_methods", "*time: minimum within days*")
+        cfchecks.check_valid(tasmin, "standard_name", "air_temperature")
 
     @abc.abstractmethod
     def compute(*args, **kwds):
@@ -84,8 +84,8 @@ class Tasmax(Indicator):
     """Class for univariate indices using max daily temperature as the input."""
 
     def cfprobe(self, tasmax):
-        checks.check_valid(tasmax, "cell_methods", "*time: maximum within days*")
-        checks.check_valid(tasmax, "standard_name", "air_temperature")
+        cfchecks.check_valid(tasmax, "cell_methods", "*time: maximum within days*")
+        cfchecks.check_valid(tasmax, "standard_name", "air_temperature")
 
     @abc.abstractmethod
     def compute(*args, **kwds):
@@ -95,9 +95,9 @@ class Tasmax(Indicator):
 class TasminTasmax(Indicator2D):
     def cfprobe(self, tasmin, tasmax):
         for da in (tasmin, tasmax):
-            checks.check_valid(da, "standard_name", "air_temperature")
-        checks.check_valid(tasmin, "cell_methods", "*time: minimum within days*")
-        checks.check_valid(tasmax, "cell_methods", "*time: maximum within days*")
+            cfchecks.check_valid(da, "standard_name", "air_temperature")
+        cfchecks.check_valid(tasmin, "cell_methods", "*time: minimum within days*")
+        cfchecks.check_valid(tasmax, "cell_methods", "*time: maximum within days*")
         check_units(tasmax, tasmin.attrs["units"])
 
     @abc.abstractmethod
diff --git a/xclim/indicators/land/_streamflow.py b/xclim/indicators/land/_streamflow.py
index c3ee6454..1c11a7ed 100644
--- a/xclim/indicators/land/_streamflow.py
+++ b/xclim/indicators/land/_streamflow.py
@@ -1,14 +1,13 @@
 # -*- coding: utf-8 -*-
 import numpy as np
 
-from xclim.core import checks
+from xclim.core import cfchecks
+from xclim.core import missing
 from xclim.core.indicator import Indicator
 from xclim.core.utils import wrapped_partial
 from xclim.indices import base_flow_index
 from xclim.indices import generic
 
-# from boltons.funcutils import FunctionBuilder
-# import calendar
 
 __all__ = ["base_flow_index", "freq_analysis", "stats", "fit", "doy_qmax", "doy_qmin"]
 
@@ -23,7 +22,7 @@ class Streamflow(Indicator):
         pass
 
     def cfprobe(self, q):
-        checks.check_valid(q, "standard_name", "streamflow")
+        cfchecks.check_valid(q, "standard_name", "streamflow")
 
 
 class Stats(Streamflow):
@@ -34,7 +33,7 @@ class Stats(Streamflow):
         indexer = kwds["indexer"]
         freq = kwds["freq"] or generic.default_freq(**indexer)
 
-        miss = (checks.missing_any(da, freq, **indexer) for da in args)
+        miss = (missing.missing_any(da, freq, **indexer) for da in args)
         return reduce(np.logical_or, miss)
 
 
diff --git a/xclim/indicators/seaIce/_seaice.py b/xclim/indicators/seaIce/_seaice.py
index 3ae486d5..f4623be8 100644
--- a/xclim/indicators/seaIce/_seaice.py
+++ b/xclim/indicators/seaIce/_seaice.py
@@ -5,7 +5,7 @@ Sea ice indicators
 import abc
 
 from xclim import indices
-from xclim.core import checks
+from xclim.core import cfchecks
 from xclim.core.indicator import Indicator2D
 
 __all__ = ["sea_ice_area", "sea_ice_extent"]
@@ -15,7 +15,7 @@ class SicArea(Indicator2D):
     """Class for indicators having sea ice concentration and grid cell area inputs."""
 
     def cfprobe(self, sic, area):
-        checks.check_valid(sic, "standard_name", "sea_ice_area_fraction")
+        cfchecks.check_valid(sic, "standard_name", "sea_ice_area_fraction")
 
     @abc.abstractmethod
     def compute(*args, **kwds):


Test patch:
diff --git a/tests/test_checks.py b/tests/test_checks.py
index 2977dd15..1e21fa31 100644
--- a/tests/test_checks.py
+++ b/tests/test_checks.py
@@ -8,7 +8,8 @@ import pytest
 import xarray as xr
 
 from xclim import set_options
-from xclim.core import checks
+from xclim.core import cfchecks
+from xclim.core import datachecks
 from xclim.core.utils import ValidationError
 from xclim.indicators.atmos import tg_mean
 
@@ -25,7 +26,7 @@ TestObj = namedtuple("TestObj", ["test"])
 )
 def test_check_valid_ok(value, expected):
     d = TestObj(value)
-    checks.check_valid(d, "test", expected)
+    cfchecks.check_valid(d, "test", expected)
 
 
 @pytest.mark.parametrize(
@@ -34,7 +35,7 @@ def test_check_valid_ok(value, expected):
 def test_check_valid_raise(value, expected):
     d = TestObj(value)
     with pytest.raises(ValidationError):
-        checks.check_valid(d, "test", expected)
+        cfchecks.check_valid(d, "test", expected)
 
 
 class TestDateHandling:
@@ -83,7 +84,7 @@ def test_cf_compliance_options(tas_series, caplog):
 
     with caplog.at_level(logging.INFO):
         with set_options(cf_compliance="log"):
-            checks.check_valid_temperature(tas, "degK")
+            cfchecks.check_valid_temperature(tas, "degK")
 
             assert all([rec.levelname == "INFO" for rec in caplog.records])
             assert (
@@ -93,154 +94,10 @@ def test_cf_compliance_options(tas_series, caplog):
 
     with pytest.warns(UserWarning, match="Variable has a non-conforming"):
         with set_options(cf_compliance="warn"):
-            checks.check_valid_temperature(tas, "degK")
+            cfchecks.check_valid_temperature(tas, "degK")
 
     with pytest.raises(
         ValidationError, match="Variable has a non-conforming standard_name"
     ):
         with set_options(cf_compliance="raise"):
-            checks.check_valid_temperature(tas, "degK")
-
-
-class TestMissingAnyFills:
-    def test_missing_days(self, tas_series):
-        a = np.arange(360.0)
-        a[5:10] = np.nan
-        ts = tas_series(a)
-        out = checks.missing_any(ts, freq="MS")
-        assert out[0]
-        assert not out[1]
-
-    def test_missing_months(self):
-        n = 66
-        times = pd.date_range("2001-12-30", freq="1D", periods=n)
-        da = xr.DataArray(np.arange(n), [("time", times)])
-        miss = checks.missing_any(da, "MS")
-        np.testing.assert_array_equal(miss, [True, False, False, True])
-
-    def test_missing_years(self):
-        n = 378
-        times = pd.date_range("2001-12-31", freq="1D", periods=n)
-        da = xr.DataArray(np.arange(n), [("time", times)])
-        miss = checks.missing_any(da, "YS")
-        np.testing.assert_array_equal(miss, [True, False, True])
-
-    def test_missing_season(self):
-        n = 378
-        times = pd.date_range("2001-12-31", freq="1D", periods=n)
-        da = xr.DataArray(np.arange(n), [("time", times)])
-        miss = checks.missing_any(da, "Q-NOV")
-        np.testing.assert_array_equal(miss, [True, False, False, False, True])
-
-    def test_to_period_start(self, tasmin_series):
-        a = np.zeros(365) + K2C + 5.0
-        a[2] -= 20
-        ts = tasmin_series(a)
-        miss = checks.missing_any(ts, freq="AS-JUL")
-        np.testing.assert_equal(miss, [False])
-
-    def test_to_period_end(self, tasmin_series):
-        a = np.zeros(365) + K2C + 5.0
-        a[2] -= 20
-        ts = tasmin_series(a)
-        miss = checks.missing_any(ts, freq="A-JUN")
-        np.testing.assert_equal(miss, [False])
-
-    def test_month(self, tasmin_series):
-        ts = tasmin_series(np.zeros(36))
-        miss = checks.missing_any(ts, freq="YS", month=7)
-        np.testing.assert_equal(miss, [False])
-
-        miss = checks.missing_any(ts, freq="YS", month=8)
-        np.testing.assert_equal(miss, [True])
-
-        with pytest.raises(ValueError, match=r"No data for selected period."):
-            checks.missing_any(ts, freq="YS", month=1)
-
-        miss = checks.missing_any(ts, freq="YS", month=[7, 8])
-        np.testing.assert_equal(miss, [True])
-
-        ts = tasmin_series(np.zeros(76))
-        miss = checks.missing_any(ts, freq="YS", month=[7, 8])
-        np.testing.assert_equal(miss, [False])
-
-    def test_season(self, tasmin_series):
-        ts = tasmin_series(np.zeros(360))
-        miss = checks.missing_any(ts, freq="YS", season="MAM")
-        np.testing.assert_equal(miss, [False])
-
-        miss = checks.missing_any(ts, freq="YS", season="JJA")
-        np.testing.assert_array_equal(miss, [True, True])
-
-        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)
-        miss = checks.missing_any(ds.q_sim, freq="YS")
-        np.testing.assert_array_equal(miss[:-1], False)
-        np.testing.assert_array_equal(miss[-1], True)
-
-        miss = checks.missing_any(ds.q_sim, freq="YS", season="JJA")
-        np.testing.assert_array_equal(miss, False)
-
-
-class TestMissingWMO:
-    def test_missing_days(self, tas_series):
-        a = np.arange(360.0)
-        a[5:7] = np.nan  # Number of missing values under acceptable limit in a month
-        a[40:45] = np.nan  # Too many consecutive missing values
-        a[70:92:2] = np.nan  # Too many non-consecutive missing values
-        ts = tas_series(a)
-        out = checks.missing_wmo(ts, freq="MS")
-        assert not out[0]
-        assert out[1]
-        assert out[2]
-
-    def test_missing_days_in_quarter(self, tas_series):
-        a = np.arange(360.0)
-        a[5:16] = np.nan  # Number of missing values under acceptable limit in a month
-        ts = tas_series(a)
-        out = checks.missing_wmo(ts, freq="QS-JAN")
-        np.testing.assert_array_equal(out, [True, False, False, True])
-
-
-class TestMissingPct:
-    def test_missing_days(self, tas_series):
-        a = np.arange(360.0)
-        a[5:7] = np.nan  # Number of missing values under acceptable limit in a month
-        a[40:45] = np.nan  # Too many missing values
-        ts = tas_series(a)
-        out = checks.missing_pct(ts, freq="MS", tolerance=0.1)
-        assert not out[0]
-        assert out[1]
-
-
-class TestAtLeastNValid:
-    def test_at_least_n_valid(self, tas_series):
-        a = np.arange(360.0)
-        a[5:10] = np.nan  # Number of missing values under acceptable limit in a month
-        a[40:55] = np.nan  # Too many missing values
-        ts = tas_series(a)
-        out = checks.at_least_n_valid(ts, freq="MS", n=20)
-        np.testing.assert_array_equal(out[:2], [False, True])
+            cfchecks.check_valid_temperature(tas, "degK")
diff --git a/tests/test_missing.py b/tests/test_missing.py
new file mode 100644
index 00000000..836efd6d
--- /dev/null
+++ b/tests/test_missing.py
@@ -0,0 +1,157 @@
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import pytest
+import xarray as xr
+
+from xclim.core import missing
+
+K2C = 273.15
+
+TESTS_HOME = Path(__file__).absolute().parent
+TESTS_DATA = Path(TESTS_HOME, "testdata")
+
+
+class TestMissingAnyFills:
+    def test_missing_days(self, tas_series):
+        a = np.arange(360.0)
+        a[5:10] = np.nan
+        ts = tas_series(a)
+        out = missing.missing_any(ts, freq="MS")
+        assert out[0]
+        assert not out[1]
+
+    def test_missing_months(self):
+        n = 66
+        times = pd.date_range("2001-12-30", freq="1D", periods=n)
+        da = xr.DataArray(np.arange(n), [("time", times)])
+        miss = missing.missing_any(da, "MS")
+        np.testing.assert_array_equal(miss, [True, False, False, True])
+
+    def test_missing_years(self):
+        n = 378
+        times = pd.date_range("2001-12-31", freq="1D", periods=n)
+        da = xr.DataArray(np.arange(n), [("time", times)])
+        miss = missing.missing_any(da, "YS")
+        np.testing.assert_array_equal(miss, [True, False, True])
+
+    def test_missing_season(self):
+        n = 378
+        times = pd.date_range("2001-12-31", freq="1D", periods=n)
+        da = xr.DataArray(np.arange(n), [("time", times)])
+        miss = missing.missing_any(da, "Q-NOV")
+        np.testing.assert_array_equal(miss, [True, False, False, False, True])
+
+    def test_to_period_start(self, tasmin_series):
+        a = np.zeros(365) + K2C + 5.0
+        a[2] -= 20
+        ts = tasmin_series(a)
+        miss = missing.missing_any(ts, freq="AS-JUL")
+        np.testing.assert_equal(miss, [False])
+
+    def test_to_period_end(self, tasmin_series):
+        a = np.zeros(365) + K2C + 5.0
+        a[2] -= 20
+        ts = tasmin_series(a)
+        miss = missing.missing_any(ts, freq="A-JUN")
+        np.testing.assert_equal(miss, [False])
+
+    def test_month(self, tasmin_series):
+        ts = tasmin_series(np.zeros(36))
+        miss = missing.missing_any(ts, freq="YS", month=7)
+        np.testing.assert_equal(miss, [False])
+
+        miss = missing.missing_any(ts, freq="YS", month=8)
+        np.testing.assert_equal(miss, [True])
+
+        with pytest.raises(ValueError, match=r"No data for selected period."):
+            missing.missing_any(ts, freq="YS", month=1)
+
+        miss = missing.missing_any(ts, freq="YS", month=[7, 8])
+        np.testing.assert_equal(miss, [True])
+
+        ts = tasmin_series(np.zeros(76))
+        miss = missing.missing_any(ts, freq="YS", month=[7, 8])
+        np.testing.assert_equal(miss, [False])
+
+    def test_season(self, tasmin_series):
+        ts = tasmin_series(np.zeros(360))
+        miss = missing.missing_any(ts, freq="YS", season="MAM")
+        np.testing.assert_equal(miss, [False])
+
+        miss = missing.missing_any(ts, freq="YS", season="JJA")
+        np.testing.assert_array_equal(miss, [True, True])
+
+        miss = missing.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 = missing.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 = missing.missing_any(ts2, freq=None)
+        np.testing.assert_array_equal(miss, True)
+
+        # With indexer
+        miss = missing.missing_any(ts, freq=None, month=[7])
+        np.testing.assert_array_equal(miss, False)
+
+        miss = missing.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)
+        miss = missing.missing_any(ds.q_sim, freq="YS")
+        np.testing.assert_array_equal(miss[:-1], False)
+        np.testing.assert_array_equal(miss[-1], True)
+
+        miss = missing.missing_any(ds.q_sim, freq="YS", season="JJA")
+        np.testing.assert_array_equal(miss, False)
+
+
+class TestMissingWMO:
+    def test_missing_days(self, tas_series):
+        a = np.arange(360.0)
+        a[5:7] = np.nan  # Number of missing values under acceptable limit in a month
+        a[40:45] = np.nan  # Too many consecutive missing values
+        a[70:92:2] = np.nan  # Too many non-consecutive missing values
+        ts = tas_series(a)
+        out = missing.missing_wmo(ts, freq="MS")
+        assert not out[0]
+        assert out[1]
+        assert out[2]
+
+    def test_missing_days_in_quarter(self, tas_series):
+        a = np.arange(360.0)
+        a[5:16] = np.nan  # Number of missing values under acceptable limit in a month
+        ts = tas_series(a)
+        out = missing.missing_wmo(ts, freq="QS-JAN")
+        np.testing.assert_array_equal(out, [True, False, False, True])
+
+
+class TestMissingPct:
+    def test_missing_days(self, tas_series):
+        a = np.arange(360.0)
+        a[5:7] = np.nan  # Number of missing values under acceptable limit in a month
+        a[40:45] = np.nan  # Too many missing values
+        ts = tas_series(a)
+        out = missing.missing_pct(ts, freq="MS", tolerance=0.1)
+        assert not out[0]
+        assert out[1]
+
+
+class TestAtLeastNValid:
+    def test_at_least_n_valid(self, tas_series):
+        a = np.arange(360.0)
+        a[5:10] = np.nan  # Number of missing values under acceptable limit in a month
+        a[40:55] = np.nan  # Too many missing values
+        ts = tas_series(a)
+        out = missing.at_least_n_valid(ts, freq="MS", n=20)
+        np.testing.assert_array_equal(out[:2], [False, True])
diff --git a/tests/test_options.py b/tests/test_options.py
index 3634e4e6..5fc83254 100644
--- a/tests/test_options.py
+++ b/tests/test_options.py
@@ -4,7 +4,7 @@
 import pytest
 
 from xclim import set_options
-from xclim.core.checks import MissingBase
+from xclim.core.missing import MissingBase
 from xclim.core.options import OPTIONS
 from xclim.core.options import register_missing_method