Source code for pelagos_py.steps.processing.deep_correction

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

"""Generic deep-value (dark offset) correction for profile data.

Estimates a constant baseline (``dark_value``) from the deepest measurements of
the first few deep profiles and subtracts it from the whole record. Written for
chlorophyll fluorescence but works for any variable with a deep signal-free
region (the offset and thresholds are all parameters)."""

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

#### Custom imports ####
import xarray as xr
import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl
import pelagos_py.utils.palettes as palettes

# A depth_threshold shallower than this warns the user.
MIN_DEEP_THRESHOLD = 300

# Full-record diagnostic panel is downsampled to this many points.
MAX_DIAGNOSTIC_POINTS = 10_000


@register_step
[docs] class deep_correction(BaseStep, QCHandlingMixin): """ Subtract a deep dark offset from a profile variable. Estimates a roughly-constant baseline (the *dark value*) from the deepest measurements and subtracts it from the whole record, writing the result to an ``_ADJUSTED`` companion variable. Written for chlorophyll but works for any variable with a deep, signal-free region. The dark value is estimated as follows: 1. Find profiles whose ``depth_var`` reaches past ``depth_threshold`` (deeper = larger, e.g. ``PRES > 950``) that also carry enough data: at least ``min_profile_points`` finite points overall and ``min_valid_points`` below the threshold. Take the first ``n_profiles`` that qualify. 2. Smooth each profile with a ``smoothing_window``-point rolling median. 3. Keep the deep points (below the threshold) that are finite and below ``max_valid_value`` (guards against non-dark readings). 4. Take each profile's minimum deep value, provided it has at least ``min_valid_points`` valid deep points. 5. Use the median of those per-profile minima as the dark value. A ``dark_value`` supplied via config skips this estimation and is subtracted directly. Samples whose flags fall in ``calculation_flag_filter`` are left out of the estimate but still corrected — they just cannot bias it. Examples -------- A minimal config only needs the variable to correct; every other parameter has a sensible default:: - name: "Deep Correction" parameters: apply_to: "CHLA" diagnostics: true """ step_name = "Deep Correction" required_variables = ["PROFILE_NUMBER"] provided_variables = [] variable_parameters = ["apply_to", "depth_var"] uses_data_subset = True parameter_schema = { "apply_to": { "type": str, "default": "CHLA", "description": "Name of the variable to apply the correction to.", }, "dark_value": { "type": float, "default": None, "description": "Dark offset to subtract; if null it is computed from the data.", }, "depth_var": { "type": str, "default": "PRES", "description": "Vertical-coordinate variable used to find deep data (deeper = larger).", }, "depth_threshold": { "type": float, "default": 950, "description": "Only data where depth_var exceeds this is used to compute the dark value.", "unit": "dbar", }, "n_profiles": { "type": int, "default": 5, "description": "Number of deep profiles (in order of occurrence) to use.", }, "smoothing_window": { "type": int, "default": 5, "description": "Rolling-median window (points) applied along each profile before sampling.", }, "max_valid_value": { "type": float, "default": 5, "description": "Deep values at or above this are ignored (guards against non-dark readings).", }, "min_profile_points": { "type": int, "default": 20, "description": "A profile must have at least this many finite points overall to be selected.", }, "min_valid_points": { "type": int, "default": 3, "description": "A profile must have at least this many valid deep points to be selected and contribute.", }, } def run(self): self.filter_qc() # Resolve the input/output variable names (OG1 _ADJUSTED convention). self.apply_to, self.output_as = self.resolve_variables() self.compute_dark_value() self.log( f"Dark correction value for {self.apply_to} = {self.dark_value:.6f}" ) self.apply_dark_correction() self.reconstruct_data() self.update_qc() # Adding an _ADJUSTED variable needs its own QC. if self.apply_to != self.output_as: self.generate_qc({f"{self.output_as}_QC": [f"{self.apply_to}_QC"]}) if self.diagnostics: self.generate_diagnostics() self.context["data"].update(self.data) return self.context def resolve_variables(self): # OG1 convention: prefer an existing _ADJUSTED variable as input, else # produce one. apply_to = self.apply_to if apply_to not in self.data.data_vars: raise KeyError( f"[{self.step_name}] The variable {apply_to} does not exist in the data." ) if not apply_to.endswith("_ADJUSTED") and f"{apply_to}_ADJUSTED" in self.data.data_vars: self.log( f"User requested processing on {apply_to} but {apply_to}_ADJUSTED " f"already exists. Using {apply_to}_ADJUSTED...", console=False, ) apply_to = f"{apply_to}_ADJUSTED" output_as = apply_to if apply_to.endswith("_ADJUSTED") else f"{apply_to}_ADJUSTED" self.log(f"Processing {apply_to}...", console=False) return apply_to, output_as def compute_dark_value(self): # Per-profile diagnostics for plotting; empty when dark_value is given. self._profile_diagnostics = {} if self.dark_value is not None: self.log(f"Using dark value from config: {self.dark_value}", console=False) return var, depth = self.apply_to, self.depth_var self.log( f"Computing dark value from the first {self.n_profiles} profiles " f"reaching {depth} > {self.depth_threshold}.", console=False, ) if self.depth_threshold < MIN_DEEP_THRESHOLD: self.log_warn( f"depth_threshold ({self.depth_threshold}) is shallower than " f"{MIN_DEEP_THRESHOLD} {depth}; this may not be deep enough for a " "signal-free dark region and could bias the dark value." ) missing_vars = {"PROFILE_NUMBER", depth, var} - set(self.data.data_vars) if missing_vars: raise KeyError( f"[{self.step_name}] {missing_vars} could not be found in the data." ) df = self.data[["PROFILE_NUMBER", depth, var]].to_pandas() # Flagged samples must not inform the dark value, but are still corrected # by it, so drop them here rather than from self.data. df.loc[~self.calculation_mask(["PROFILE_NUMBER", depth, var]), var] = np.nan df = df.dropna(subset=["PROFILE_NUMBER", depth]) # Profiles reaching past the threshold, in order of occurrence; coverage # is checked below before a candidate spends an n_profiles slot. deep_reach = df.groupby("PROFILE_NUMBER")[depth].max() candidates = deep_reach[deep_reach > self.depth_threshold].index.to_numpy() if len(candidates) == 0: self.halt( "No profiles reach past the depth threshold. " "Try adjusting 'depth_threshold' or 'depth_var'." ) minima = [] skipped = [] for profile_number in candidates: if len(self._profile_diagnostics) >= self.n_profiles: break profile = df[df["PROFILE_NUMBER"] == profile_number].sort_values(depth) # Skip sparse/empty deep profiles. n_points = int(profile[var].notna().sum()) below = profile[profile[depth] > self.depth_threshold] n_deep_points = int(below[var].notna().sum()) if n_points < self.min_profile_points or n_deep_points < self.min_valid_points: skipped.append(int(profile_number)) continue # Smooth along the full profile, then sample the deep region. smoothed = ( profile[var] .rolling(self.smoothing_window, min_periods=1, center=True) .median() ) deep_mask = ( (profile[depth] > self.depth_threshold) & np.isfinite(smoothed) & (smoothed < self.max_valid_value) ) record = { "depth": profile[depth].to_numpy(), "raw": profile[var].to_numpy(), "smoothed": smoothed.to_numpy(), "deep_mask": deep_mask.to_numpy(), } self._profile_diagnostics[profile_number] = record deep_vals = smoothed[deep_mask] if int(deep_vals.notnull().sum()) >= self.min_valid_points: idxmin = deep_vals.idxmin() record["min_value"] = float(deep_vals.loc[idxmin]) record["min_depth"] = float(profile.loc[idxmin, depth]) minima.append(record["min_value"]) if skipped: self.log( f"Skipped {len(skipped)} deep profile(s) with too few points " f"(need >= {self.min_profile_points} total, " f">= {self.min_valid_points} below {self.depth_threshold} {depth}): " f"{skipped}", console=False, ) if len(minima) == 0: raise ValueError( f"[{self.step_name}] No profiles had at least {self.min_valid_points} " "valid deep points. Try adjusting the parameters." ) self.dark_value = float(np.nanmedian(minima)) self.log( f"Computed dark value: {self.dark_value:.6f} " f"(median of {len(minima)} profile minimums)", console=False, ) self.log( f"Min values range: {np.min(minima):.6f} to {np.max(minima):.6f}", console=False, ) def apply_dark_correction(self): self.data[self.output_as] = xr.DataArray( self.data[self.apply_to] - self.dark_value, dims=self.data[self.apply_to].dims, coords=self.data[self.apply_to].coords, ) if hasattr(self.data[self.apply_to], "attrs"): self.data[self.output_as].attrs = self.data[self.apply_to].attrs.copy() self.data[self.output_as].attrs[ "comment" ] = f"{self.apply_to} with dark value correction (dark_value={self.dark_value:.6f})" self.data[self.output_as].attrs["dark_value"] = self.dark_value def generate_diagnostics(self): # Two-panel figure: deep profiles used for the estimate (left) and the # whole record raw vs corrected (right). mpl.use("tkagg") if not self._profile_diagnostics: self.log( "No profile diagnostics to plot (dark value was supplied via config)." ) return fig, (ax_prof, ax_corr) = plt.subplots( 1, 2, figsize=(9, 5.5), dpi=150, sharey=True ) cmap = mpl.cm.viridis n = len(self._profile_diagnostics) colours = [cmap(i / max(n - 1, 1)) for i in range(n)] # --- Left: the deep profiles used, so biofouled/invalid ones stand out. for colour, (profile_number, rec) in zip( colours, self._profile_diagnostics.items() ): ax_prof.plot(rec["raw"], rec["depth"], c=colour, alpha=0.25, lw=0.8) ax_prof.plot( rec["smoothed"], rec["depth"], c=colour, lw=1.4, label=f"Prof. {int(profile_number)}", ) if "min_value" in rec: ax_prof.scatter( rec["min_value"], rec["min_depth"], c=[colour], edgecolors="k", zorder=5, s=30, ) ax_prof.axhline( self.depth_threshold, ls="--", c="grey", lw=1, label="Depth threshold" ) ax_prof.axvline(0, ls=":", c="0.4", lw=1, label="Zero") ax_prof.axvline( self.dark_value, ls="--", c="r", lw=1, label=f"Dark value ({self.dark_value:.4g})" ) ax_prof.invert_yaxis() # deeper (larger depth_var) at the bottom ax_prof.set_xlabel(self.apply_to, fontsize=8) ax_prof.set_ylabel(self.depth_var, fontsize=8) ax_prof.set_title("Deep profiles used for dark estimate", fontsize=9) ax_prof.tick_params(labelsize=7) ax_prof.legend(fontsize=6, loc="upper left", framealpha=0.9) self._add_deep_inset(ax_prof, colours) # --- Right: the whole record, raw (grey) vs corrected (coloured). self._plot_full_record(fig, ax_corr) fig.suptitle(f"Deep Correction — {self.apply_to}", fontsize=11) fig.tight_layout() plt.show(block=True) def _plot_full_record(self, fig, ax_corr): # Whole record vs depth: raw in grey, corrected coloured; downsampled. depth = np.asarray(self.data[self.depth_var].values).ravel() raw = np.asarray(self.data[self.apply_to].values).ravel() corr = np.asarray(self.data[self.output_as].values).ravel() finite = np.isfinite(depth) & np.isfinite(raw) & np.isfinite(corr) depth, raw, corr = depth[finite], raw[finite], corr[finite] if depth.size > MAX_DIAGNOSTIC_POINTS: rng = np.random.default_rng(0) keep = rng.choice(depth.size, MAX_DIAGNOSTIC_POINTS, replace=False) depth, raw, corr = depth[keep], raw[keep], corr[keep] n_label = f"{MAX_DIAGNOSTIC_POINTS:,} of {int(finite.sum()):,} pts" else: n_label = f"{depth.size:,} pts" ax_corr.scatter(raw, depth, c="0.7", s=4, alpha=0.4, label=f"Raw ({n_label})") cmap = palettes.cmap_for_variable(self.output_as, default="viridis") sc = ax_corr.scatter( corr, depth, c=corr, cmap=cmap, s=4, alpha=0.6, label="Corrected" ) ax_corr.axvline(0, ls="--", c="k", lw=1, label="Corrected baseline") cbar = fig.colorbar(sc, ax=ax_corr, pad=0.02) cbar.set_label(self.output_as, fontsize=7) cbar.ax.tick_params(labelsize=6) ax_corr.set_xlabel(self.output_as, fontsize=8) ax_corr.set_title("Full record: raw (grey) vs corrected", fontsize=9) ax_corr.tick_params(labelsize=7) ax_corr.legend(fontsize=6, loc="upper right", framealpha=0.9) def _add_deep_inset(self, ax_prof, colours): # Inset zoomed onto the deep (below-threshold) points driving the estimate. axins = ax_prof.inset_axes([0.56, 0.05, 0.4, 0.4]) deep_depths, deep_vals = [], [] for colour, rec in zip(colours, self._profile_diagnostics.values()): mask = rec["deep_mask"] depth = rec["depth"][mask] vals = rec["smoothed"][mask] axins.plot(vals, depth, c=colour, marker="o", ls="", ms=3) deep_depths.append(depth) deep_vals.append(vals) if "min_value" in rec: axins.scatter( rec["min_value"], rec["min_depth"], c=[colour], edgecolors="k", zorder=5, s=45, ) axins.axvline(self.dark_value, ls="--", c="r") # Frame on the deep points plus the dark value, with a 10% margin. depth_cat = np.concatenate(deep_depths) if deep_depths else np.array([]) vals_cat = np.concatenate(deep_vals) if deep_vals else np.array([]) if depth_cat.size and np.isfinite(vals_cat).any(): xlo = min(np.nanmin(vals_cat), self.dark_value) xhi = max(np.nanmax(vals_cat), self.dark_value) dlo, dhi = np.nanmin(depth_cat), np.nanmax(depth_cat) xlo, xhi = self._pad_range(xlo, xhi) dlo, dhi = self._pad_range(dlo, dhi) axins.set_xlim(xlo, xhi) axins.set_ylim(dhi, dlo) # inverted: deeper at the bottom axins.tick_params(labelsize=6) axins.set_title("Below threshold (zoom)", fontsize=7) @staticmethod def _pad_range(lo, hi, frac=0.1): # Widen a [lo, hi] range by a fraction of its span on each side. span = hi - lo pad = (span if span > 0 else abs(hi) or 1.0) * frac return lo - pad, hi + pad