Source code for pelagos_py.steps.quality_control.range_qc

# This file is part of pelagos_py.
#
# Copyright 2025-2026 National Oceanography Centre and The Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Unified range QC test.

Replaces the old ``gross range qc`` (flag values *outside* a good band) and
``impossible range qc`` (flag values *inside* an impossible band) with a single
test that does both. Each range carries an explicit ``inside``/``outside`` keyword
that chooses the behaviour: ``outside`` flags data outside the band (a good band),
``inside`` flags data within it (an impossible band). When no keyword is given the
bound *order* is used as a fallback (ascending ``[low, high]`` -> outside, descending
``[high, low]`` -> inside). A flag may list several ranges so the same flag can cover
more than one band. The test can also propagate a variable's flags onto companion
variables (e.g. flag PRES and TEMP bad whenever CNDC is bad), limit the checks to a
DEPTH window, and plot every flagged variable when diagnostics are enabled.
"""

#### Mandatory imports ####
import numpy as np
from pelagos_py.steps.base_qc import BaseQC, register_qc, flag_cols

#### Custom imports ####
import matplotlib
import matplotlib.pyplot as plt
import xarray as xr


# Argo flag-merge matrix used when propagating an "also_flag" flag onto a companion:
# the result of merging an existing flag (row) with a new one (column) is
# QC_COMBINATRIX[existing, new]. This is the same logic Apply QC uses to merge each
# test's flags into the dataset, copied here so a single test's own cross-flagging is
# consistent with it (e.g. a companion already flagged bad (4) is never downgraded to
# probably-bad (3) by propagation). See ApplyQC.organise_flags; this could later move
# to a shared utility.
QC_COMBINATRIX = np.array(
    [
        [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
        [1, 1, 2, 3, 4, 5, 1, 1, 8, 9],
        [2, 2, 2, 3, 4, 5, 2, 2, 8, 9],
        [3, 3, 3, 3, 4, 3, 3, 3, 3, 9],
        [4, 4, 4, 4, 4, 4, 4, 4, 4, 9],
        [5, 5, 5, 3, 4, 5, 5, 5, 8, 9],
        [6, 1, 2, 3, 4, 5, 6, 6, 8, 9],
        [7, 1, 2, 3, 4, 5, 6, 7, 8, 9],
        [8, 8, 8, 3, 4, 8, 8, 8, 8, 9],
        [9, 9, 9, 9, 9, 9, 9, 9, 9, 9],
    ]
)

FLAG_MEANINGS = {
    0: "no QC",
    1: "good",
    2: "prob good",
    3: "prob bad",
    4: "bad",
    5: "changed",
    8: "interp",
    9: "missing",
}


@register_qc
[docs] class range_qc(BaseQC): """ Flag measurements by value range. A single, configurable replacement for the old separate "gross range" and "impossible range" tests. Each ``{flag: bounds}`` entry describes one or more bands. A band is given as a ``[low, high]`` pair with an ``inside``/``outside`` keyword saying which side to flag: - **``[low, high, "outside"]``** — a *good* band; data ``< low`` or ``> high`` gets the flag. (The old "gross range" behaviour.) Accepts ``outside``, ``out`` or ``o`` (any capitalisation). - **``[low, high, "inside"]``** — an *impossible* band; data strictly between the two bounds gets the flag. (The old "impossible range" behaviour.) Accepts ``inside``, ``in`` or ``i`` (any capitalisation). - **A single scalar** ``value`` — flags exact matches (``data == value``). Handy for fill/filler values, e.g. ``4: 0.0`` to flag a pressure of exactly ``0`` as bad. The same flag can cover several bands by giving a *list of bands*, e.g. ``4: [[2, 3, "inside"], [0.1, 10, "outside"]]`` — a point is flagged if it falls in *any* of them. If the keyword is omitted the bound *order* is used as a fallback: an ascending ``[low, high]`` means ``outside`` (a good band) and a descending ``[high, low]`` means ``inside`` (an impossible band). An explicit keyword always wins over the order, so write ``inside``/``outside`` when in doubt. Within a variable, entries are applied most-severe-flag-first, so on overlap the worse flag wins. Anything checked but not flagged is marked good (1). Target Variable: Any Flag Number: Any (user-defined) Variables Flagged: Any (the tested variables, plus any ``also_flag`` companions) EXAMPLE ------- :: - name: "Apply QC" parameters: qc_settings: range qc: variable_ranges: PRES: 3: [-2.4, -5, inside] # impossible band: flag data INSIDE it 4: [-5, -.inf, inside] 9: 0.0 # single scalar -> flag the exact fill value 0.0 TEMP: 3: [0, 30, outside] # good band: flag data OUTSIDE it 4: [-2.5, 40, outside] CNDC: # one flag, two bands: flag bad both inside [2, 3] and outside [0.1, 10] 4: [[2, 3, inside], [0.1, 10, outside]] also_flag: CNDC: [PRES, TEMP] # CNDC's flags propagate onto PRES & TEMP (worst wins) test_depth_range: [-100, 0] # OPTIONAL: only check this DEPTH window diagnostics: true # plots every flagged variable, coloured by flag """ qc_name = "range qc" # Target variables are user-defined, so __init__ is redefined to resolve the # test's required/provided variables from the parameters. dynamic = True parameter_schema = { "variable_ranges": { "type": dict, "required": True, "description": "Per-variable {flag: band} ranges. A band is [low, high, 'inside'|'outside'] " "('outside' flags data outside it, 'inside' flags data within it); the keyword " "may be omitted, in which case an ascending pair means outside and a descending " "pair means inside. A flag may give a list of bands to cover several ranges.", }, "also_flag": { "type": dict, "default": {}, "description": "Propagate a variable's flags onto companion variables, e.g. " "{'CNDC': ['PRES', 'TEMP']}. Merged with the Argo matrix so the worst " "flag wins.", }, "test_depth_range": { "type": list, "default": None, "description": "Optional [min, max] DEPTH window; checks apply only to samples within it.", }, } def __init__(self, data, **kwargs): super().__init__(data, **kwargs) if self.also_flag is None: self.also_flag = {} self.tested_variables = list(self.variable_ranges.keys()) # Flags become QC values that Apply QC (and this class's also_flag # propagation) merges via the Argo 10x10 matrix, so they must be integer # indices 0-9. Validate up front to fail with a clear config error rather # than an IndexError deep in return_qc. for var, meta in self.variable_ranges.items(): for flag in meta: if isinstance(flag, bool) or not isinstance(flag, int) or not (0 <= flag <= 9): raise ValueError( f"[{self.qc_name}] invalid QC flag {flag!r} for variable " f"{var!r}; expected an Argo QC flag 0-9." ) self.required_variables = self.tested_variables.copy() if self.test_depth_range is not None: self.required_variables.append("DEPTH") # Outputs are the tested variables plus any companions they propagate onto. self.qc_outputs = list( {f"{var}_QC" for var in self.tested_variables} | {f"{var}_QC" for var in sum(self.also_flag.values(), [])} ) # Keyword aliases (any capitalisation) that force a band's behaviour. _OUTSIDE_KEYWORDS = {"outside", "out", "o"} _INSIDE_KEYWORDS = {"inside", "in", "i"} @classmethod def _iter_bands(cls, bounds): """Yield each individual band in a flag's configured ``bounds`` entry. A flag can carry a single band or a *list* of bands. A list of bands is one whose elements are themselves lists/tuples (e.g. ``[[2, 3], [0.1, 10]]``); anything else (a scalar, or a single ``[low, high(, kw)]`` band) is treated as one band and yielded as-is. """ if ( isinstance(bounds, (list, tuple)) and bounds and all(isinstance(b, (list, tuple)) for b in bounds) ): yield from bounds else: yield bounds @classmethod def _band_hit(cls, vals, band): """Return a boolean mask of the values a single configured ``band`` flags. - A single scalar flags exact matches (e.g. a fill value such as ``0``). - ``[low, high, 'outside']`` is a good band: values outside it are flagged. - ``[low, high, 'inside']`` is an impossible band: values strictly between the bounds are flagged. - When no keyword is given the order decides: an ascending ``[low, high]`` is a good band (flag outside), a descending ``[high, low]`` an impossible band (flag inside). An explicit keyword always wins over the bound order. NaNs compare ``False`` throughout, so missing values are never flagged here. """ if not isinstance(band, (list, tuple)): return vals == band # exact-match a single value mode = None # None -> fall back to bound order nums = list(band) if nums and isinstance(nums[-1], str): kw = nums[-1].strip().lower() if kw in cls._OUTSIDE_KEYWORDS: mode = "outside" elif kw in cls._INSIDE_KEYWORDS: mode = "inside" else: raise ValueError( f"Unknown range keyword {nums[-1]!r}; expected one of " f"{sorted(cls._OUTSIDE_KEYWORDS | cls._INSIDE_KEYWORDS)}." ) nums = nums[:-1] if len(nums) != 2: raise ValueError( f"Invalid range band {band!r}; expected a scalar, [low, high], " f"or [low, high, keyword]." ) a, b = nums if mode is None: mode = "outside" if a <= b else "inside" low, high = (a, b) if a <= b else (b, a) if mode == "outside": return (vals < low) | (vals > high) # good band -> flag outside return (vals > low) & (vals < high) # impossible band -> flag inside
[docs] def return_qc(self): n = len(self.data["N_MEASUREMENTS"]) # Restrict checks to a DEPTH window if requested; otherwise check everything. if self.test_depth_range is not None: depth = self.data["DEPTH"].values low, high = self.test_depth_range depth_mask = (depth >= low) & (depth <= high) else: depth_mask = np.ones(n, dtype=bool) qc_arrays = {} for var in self.tested_variables: vals = self.data[var].values qc = np.zeros(n, dtype=int) # Most-severe flag first so it wins where ranges overlap. for flag in sorted(self.variable_ranges[var], reverse=True): hit = np.zeros(n, dtype=bool) for band in self._iter_bands(self.variable_ranges[var][flag]): hit |= self._band_hit(vals, band) qc[hit & depth_mask & (qc == 0)] = flag # Anything checked but unflagged is good. qc[(qc == 0) & depth_mask] = 1 qc_arrays[var] = qc # Propagate flags onto companions using the Argo merge matrix, so a companion # keeps its own flag wherever merging the propagated one does not upgrade it # (e.g. an existing bad (4) is never downgraded by a propagated probably-bad (3)). # Companions that are not themselves tested start from "no QC" (0), so they # mirror the source's flags; Apply QC then merges the result with their existing # flags. for var, companions in self.also_flag.items(): src = qc_arrays.get(var) if src is None: continue for companion in companions: base = qc_arrays.get(companion, np.zeros(n, dtype=int)) qc_arrays[companion] = QC_COMBINATRIX[base, src] self.flags = xr.Dataset(coords={"N_MEASUREMENTS": self.data["N_MEASUREMENTS"]}) for var, qc in qc_arrays.items(): self.flags[f"{var}_QC"] = (("N_MEASUREMENTS",), qc) return self.flags
[docs] def plot_diagnostics(self): matplotlib.use("tkagg") # Auto-plot every variable this test flagged: the tested variables first # (they get range lines), then any companions it propagated onto. plot_order = list(self.tested_variables) for companion in sum(self.also_flag.values(), []): if companion not in plot_order: plot_order.append(companion) plot_vars = [ var for var in plot_order if var in self.data and f"{var}_QC" in self.flags ] if not plot_vars: return # Use TIME on the x-axis when available, otherwise the measurement index. if "TIME" in self.data: x = self.data["TIME"].values xlabel = "Time" else: x = self.data["N_MEASUREMENTS"].values xlabel = "Index" n_vars = len(plot_vars) fig, axes = plt.subplots( n_vars, 1, sharex=True, figsize=(10, 2.6 * n_vars + 1.5), dpi=150 ) if n_vars == 1: axes = [axes] for ax, var in zip(axes, plot_vars): vals = self.data[var].values qc = self.flags[f"{var}_QC"].values # Plot points coloured by flag (drawn low-to-high so worse flags sit on top). for flag in range(10): mask = qc == flag if not np.any(mask): continue ax.plot( x[mask], vals[mask], ls="", marker="o", markersize=2.5, alpha=0.8, color=flag_cols[flag], label=f"{flag} ({FLAG_MEANINGS.get(flag, 'n/a')})", zorder=flag, ) # Range boundaries for variables that define their own ranges (a single # scalar is drawn as one line). if var in self.variable_ranges: for flag, bounds in self.variable_ranges[var].items(): for band in self._iter_bands(bounds): band_list = band if isinstance(band, (list, tuple)) else [band] for bound in band_list: # Skip the inside/outside keyword; draw only numeric bounds. if isinstance(bound, str) or not np.isfinite(bound): continue ax.axhline( bound, ls="--", lw=1, alpha=0.6, color=flag_cols.get(flag, "k"), ) ax.set_ylabel(var, fontsize=8) ax.grid(True, alpha=0.3) ax.tick_params(axis="both", which="major", labelsize=8) if var == "PRES": ax.invert_yaxis() ax.legend( title="Flag", loc="center left", bbox_to_anchor=(1.01, 0.5), fontsize=7, framealpha=0.9, fancybox=True, ) axes[-1].set_xlabel(xlabel, fontsize=8) fig.suptitle("Range QC", fontsize=10, fontweight="bold") fig.tight_layout(rect=[0, 0, 0.83, 1]) plt.show(block=True)