Source code for pelagos_py.steps.input_output.correct_values

# 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.

"""Pipeline step for applying a linear correction (slope/intercept) to a variable.

Generic enough for unit conversions (e.g. CNDC S/m -> mS/cm is a simple x10),
sensor alignment (slope + intercept), or any other affine rescaling. An optional
``expected_range`` makes the correction self-detecting: it is applied only when
the data looks like it still needs it, so the same config keeps working even after
upstream input files are fixed.
"""

#### Mandatory imports ####
from pelagos_py.steps.base_step import BaseStep, register_step

#### Custom imports ####
import numpy as np
import matplotlib
import matplotlib.pyplot as plt


@register_step
[docs] class CorrectValues(BaseStep): """ Apply an affine correction ``corrected = slope * value + intercept`` to a variable. The correction is conditional when ``expected_range`` is given: the median of the valid (non-NaN) data is compared against ``[min, max]``, and the correction is applied only when the median falls *outside* that range (i.e. the data still looks uncorrected). When ``expected_range`` is omitted the correction is always applied. This makes a config robust to upstream fixes: e.g. a CNDC unit conversion (``slope: 10``) targeting ``expected_range: [20, 45]`` will scale S/m data into mS/cm, but quietly skip files that already arrive in mS/cm. Parameters ---------- target_variable : str Name of the variable to correct (e.g. ``"CNDC"``). slope : float, optional Multiplicative factor (default ``1.0``). For a x10 unit conversion, set ``10``. intercept : float, optional Additive offset applied after scaling (default ``0.0``). Use for alignment. expected_range : list, optional ``[min, max]`` for the *corrected* variable. The correction is applied only when the data's median falls outside this range. If omitted, the correction is always applied. corrected_units : str, optional Units string written to the variable's attributes after a correction is applied (e.g. ``"mS/cm"``). Left untouched if omitted or if no correction runs. Examples -------- Example usage in a pipeline configuration: .. code-block:: yaml steps: - name: Correct Values parameters: target_variable: CNDC slope: 10.0 intercept: 0.0 expected_range: [20, 45] corrected_units: mS/cm diagnostics: false """ step_name = "Correct Values" required_variables = [] provided_variables = [] parameter_schema = { "target_variable": { "type": str, "required": True, "description": "Name of the variable to correct (e.g. 'CNDC').", }, "slope": { "type": float, "default": 1.0, "description": "Multiplicative factor (corrected = slope * value + intercept). " "For a simple x10 unit conversion, set 10.", }, "intercept": { "type": float, "default": 0.0, "description": "Additive offset applied after scaling (corrected = slope * value + intercept). " "Use for sensor alignment.", }, "expected_range": { "type": list, "default": None, "description": "Optional [min, max] for the corrected variable. The correction is applied " "only when the data's median falls OUTSIDE this range. If omitted, the " "correction is always applied.", }, "corrected_units": { "type": str, "default": None, "description": "Optional units string written to the variable's attributes after a " "correction is applied (e.g. 'mS/cm').", }, } def run(self): self.check_data() self.data = self.context["data"] var = self.target_variable if var not in self.data: raise ValueError( f"[{self.name}] target_variable '{var}' not found in dataset. " f"Available variables: {list(self.data.data_vars)}." ) vals = self.data[var].values.astype(float) self._raw_data = vals.copy() self.applied = False valid_mask = ~np.isnan(vals) if not np.any(valid_mask): self.log_warn(f"'{var}' has no valid (non-NaN) values; nothing to correct.") self.context["data"] = self.data return self.context # Decide whether the correction is needed. if self.expected_range is not None: lo, hi = float(self.expected_range[0]), float(self.expected_range[1]) median_val = float(np.nanmedian(vals[valid_mask])) if lo <= median_val <= hi: self.log( f"'{var}' median ({median_val:.4g}) is within expected range " f"[{lo}, {hi}]; skipping correction." ) self.context["data"] = self.data return self.context self.log( f"'{var}' median ({median_val:.4g}) is outside expected range " f"[{lo}, {hi}]; applying correction." ) # Apply the affine correction (NaNs propagate harmlessly through arithmetic). corrected = self.slope * vals + self.intercept self.data[var].values = corrected self.applied = True self.log( f"Applied correction to '{var}': corrected = {self.slope} * value + {self.intercept}." ) if self.corrected_units is not None: self.data[var].attrs["units"] = self.corrected_units self.log(f"Set '{var}' units to '{self.corrected_units}'.") if self.diagnostics: self.plot_diagnostics() self.context["data"] = self.data return self.context def plot_diagnostics(self): if not self.applied: return var = self.target_variable corrected = self.data[var].values # Plot against TIME if available, otherwise against sample index. if "TIME" in self.data: x = self.data["TIME"].values xlabel = "Time" else: x = np.arange(len(corrected)) xlabel = "Sample index" matplotlib.use("tkagg") fig, ax = plt.subplots(figsize=(10, 5), dpi=150) ax.plot( x, self._raw_data, marker="o", ls="", color="#b2bec3", markersize=1.5, alpha=0.7, label="Raw", ) units = str(self.data[var].attrs.get("units", "")).strip() corrected_label = f"Corrected ({units})" if units else "Corrected" ax.plot( x, corrected, marker="o", ls="", color="#0984e3", markersize=1.5, alpha=0.7, label=corrected_label, ) if self.expected_range is not None: lo, hi = float(self.expected_range[0]), float(self.expected_range[1]) ax.axhline(hi, color="black", linestyle="--", alpha=0.6, linewidth=1, label=f"Max ({hi})") ax.axhline(lo, color="black", linestyle="--", alpha=0.6, linewidth=1, label=f"Min ({lo})") ax.set_ylabel(var, fontsize=8) ax.set_xlabel(xlabel, fontsize=8) ax.grid(True, alpha=0.3) ax.tick_params(axis="both", which="major", labelsize=8) ax.legend(loc="center left", bbox_to_anchor=(1.01, 0.5), fontsize=8, framealpha=0.9, fancybox=True) fig.suptitle( f"Value Correction: {var}\n(corrected = {self.slope} * value + {self.intercept})", fontsize=10, fontweight="bold", ) fig.tight_layout(rect=[0, 0, 0.82, 1]) plt.show(block=True)