Source code for pelagos_py.steps.processing.mixed_layer_depth

# 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 calculating the mixed layer depth (MLD) of each profile."""

#### Mandatory imports ####
from pelagos_py.steps.base_step import BaseStep, register_step
from pelagos_py.utils.qc_handling import QCHandlingMixin
import pelagos_py.utils.diagnostics as diag
import pelagos_py.utils.palettes as palettes

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

# Dataset variables each method reads; density is derived here as potential density.
METHOD_INPUTS = {"density": ["ABS_SALINITY", "CONS_TEMP"], "temp": ["TEMP"]}

# Name each method's values go by in logs and on plots.
METHOD_LABELS = {"density": "SIGMA0", "temp": "TEMP"}


@register_step
[docs] class MixedLayerDepthStep(BaseStep, QCHandlingMixin): """ Calculate the mixed layer depth (MLD) of each profile. The MLD is found per profile by a threshold method: from a near-surface reference point, the first depth at which the chosen variable departs from its reference value by more than a threshold marks the base of the mixed layer. ``PROFILE_NUMBER`` and ``DEPTH`` must already be derived. Two derived variables are written on the ``N_MEASUREMENTS`` dimension: ``MLD``, the profile's mixed layer depth (positive down, matching ``DEPTH``; ``NaN`` where undefined), and ``MLD_BOOL``, ``0`` above the MLD, ``1`` at/below it, ``NaN`` where undefined. Samples whose flags fall in ``calculation_flag_filter`` take no part in the search, so a bad value cannot place the MLD; both variables are still written for every measurement in the profile. The density method uses potential density (sigma0, referenced to 0 dbar) derived here from ``ABS_SALINITY`` and ``CONS_TEMP``, not the dataset's in-situ ``DENSITY`` whose pressure term alone crosses a typical threshold within a few metres of the surface. The derived sigma0 is not written to the dataset. Parameters ---------- method : str, optional ``"density"``, ``"temp"``, or ``"auto"`` (default; density if its inputs are present, else temp). An explicit choice whose inputs are missing halts the pipeline. reference_depth : float, optional Near-surface reference depth (positive down). Default ``10``. density_threshold : float, optional Density departure (kg/m3) marking the MLD. Default ``0.03``. temp_threshold : float, optional Temperature departure (degC) marking the MLD. Default ``0.2``. Examples -------- .. code-block:: yaml steps: - name: Mixed Layer Depth parameters: method: density reference_depth: 10 density_threshold: 0.03 temp_threshold: 0.2 diagnostics: true """ step_name = "Mixed Layer Depth" required_variables = ["PROFILE_NUMBER", "DEPTH"] provided_variables = ["MLD", "MLD_BOOL"] # ABS_SALINITY/CONS_TEMP/TEMP cover both "auto" method resolution and the # diagnostics panel, which computes MLD from every available method, not # just the configured one; TIME/PROFILE_DIRECTION are read only if present. optional_variables = [ "ABS_SALINITY", "CONS_TEMP", "TEMP", "TIME", "PROFILE_DIRECTION", ] uses_data_subset = True parameter_schema = { "method": { "type": str, "default": "auto", "options": ["auto", "density", "temp"], "description": "Variable the threshold keys off: 'density', 'temp', or 'auto' (density, else temp).", }, "reference_depth": { "type": [int, float], "default": 10, "description": "Near-surface reference depth (positive down).", }, "density_threshold": { "type": [int, float], "default": 0.03, "description": "Density departure (kg/m3) from the reference marking the MLD.", }, "temp_threshold": { "type": [int, float], "default": 0.2, "description": "Temperature departure (degC) from the reference marking the MLD.", }, } def run(self): self.filter_qc() self.resolved_method, self.threshold = self._resolve_method() self.log( f"Calculating MLD from {METHOD_LABELS[self.resolved_method]} " f"(threshold {self.threshold}, reference depth {self.reference_depth})..." ) depth = self.data["DEPTH"].values mld = self._compute_mld(self.resolved_method, self.threshold, progress=True) # Above MLD -> 0, at/below -> 1, NaN where depth or the profile's MLD is undefined. mld_bool = np.where(depth >= mld, 1.0, 0.0) mld_bool[np.isnan(depth) | np.isnan(mld)] = np.nan self.data["MLD"] = (("N_MEASUREMENTS",), mld) self.data["MLD"].attrs = { "long_name": "Mixed layer depth of the profile (positive down, matching DEPTH). NaN where undefined.", "units": "m", "standard_name": "MLD", } self.data["MLD_BOOL"] = (("N_MEASUREMENTS",), mld_bool) self.data["MLD_BOOL"].attrs = { "long_name": "Mixed layer flag: 0 above MLD, 1 at/below MLD, NaN where undefined.", "units": "None", "standard_name": "MLD_BOOL", "valid_min": 0, "valid_max": 1, "flag_values": "0, 1", "flag_meanings": "above_mld below_mld", } self.reconstruct_data() self.update_qc() qc_parents = [ f"{var}_QC" for var in ["PROFILE_NUMBER", "DEPTH"] + METHOD_INPUTS[self.resolved_method] ] self.generate_qc({"MLD_QC": qc_parents, "MLD_BOOL_QC": qc_parents}) if self.diagnostics: self.generate_diagnostics() self.context["data"].update(self.data) return self.context def _resolve_method(self): # Returns (method, threshold); "auto" prefers density, falls back to temp. method = str(self.method).lower() if method == "auto": for candidate in ("density", "temp"): if self._method_available(candidate): method = candidate break else: self.halt( "Method 'auto' needs either ABS_SALINITY and CONS_TEMP (density) or " "TEMP in the dataset, but neither set is present. Derive them " "beforehand (e.g. Derive CTD)." ) if method not in METHOD_INPUTS: self.halt( f"Unknown MLD method '{self.method}'. Choose 'auto', 'density', or 'temp'." ) if not self._method_available(method): missing = [ var for var in METHOD_INPUTS[method] if var not in self.data.data_vars ] self.halt( f"Method '{method}' requires {', '.join(METHOD_INPUTS[method])} in the " f"dataset, but {', '.join(missing)} is not present. Derive it beforehand " f"(e.g. Derive CTD)." ) threshold = ( self.density_threshold if method == "density" else self.temp_threshold ) return method, threshold def _method_available(self, method): return all(var in self.data.data_vars for var in METHOD_INPUTS[method]) def _method_values(self, method): # Density is potential density (sigma0) derived here, only to place the MLD. if method == "temp": return self.data["TEMP"].values return gsw.sigma0( self.data["ABS_SALINITY"].values, self.data["CONS_TEMP"].values ) def _compute_mld(self, method, threshold, progress=False): profile_number = self.data["PROFILE_NUMBER"].values depth = self.data["DEPTH"].values # Flagged samples must not influence the MLD, so the search runs on NaN-masked # copies; MLD/MLD_BOOL are still assigned to every sample using unmasked depth. usable = self.calculation_mask( ["PROFILE_NUMBER", "DEPTH"] + METHOD_INPUTS[method] ) search_depth = np.where(usable, depth, np.nan) search_values = np.where(usable, self._method_values(method), np.nan) mld = np.full(profile_number.shape, np.nan) profile_numbers = np.unique(profile_number[~np.isnan(profile_number)]) if progress: profile_numbers = self.log_progress(profile_numbers, desc="", unit="prof") for pn in profile_numbers: indices = np.where(profile_number == pn)[0] profile_mld = self._profile_mld( search_depth[indices], search_values[indices], threshold ) if np.isfinite(profile_mld): mld[indices] = profile_mld return mld def _profile_mld(self, depth, values, threshold): # Restrict to valid points at or below the reference depth. below_reference = depth >= self.reference_depth valid = below_reference & ~np.isnan(depth) & ~np.isnan(values) depth = depth[valid] values = values[valid] if depth.size == 0: return np.nan # Reference point: shallowest remaining measurement; if deeper than twice the # reference depth there is nothing near the surface to anchor to. reference_index = np.argmin(depth) if depth[reference_index] > 2 * self.reference_depth: return np.nan reference_value = values[reference_index] # Scan from the surface downward for the first threshold crossing. order = np.argsort(depth) depth = depth[order] values = values[order] exceeded = np.where(np.abs(values - reference_value) >= np.abs(threshold))[0] if exceeded.size == 0: return np.nan return float(depth[exceeded[0]]) # Panels and MLD lines are drawn for each of these that is present in the data. DIAGNOSTIC_METHODS = (("temp", "red"), ("density", "black")) DIAGNOSTIC_MAX_DEPTH = 200 def _profile_spans(self, profile_number, x): # (indices, [x_start, x_end]) per profile; span the core (PROFILE_DIRECTION # +-1) where available so MLD lines don't stretch across transect legs. direction = ( self.data["PROFILE_DIRECTION"].values if "PROFILE_DIRECTION" in self.data else None ) spans = [] for pn in np.unique(profile_number[~np.isnan(profile_number)]): indices = np.where(profile_number == pn)[0] span_indices = indices if direction is not None: core = indices[np.isin(direction[indices], (-1, 1))] if core.size: span_indices = core span_x = x[span_indices] if span_x.size == 0: continue spans.append((indices, [np.min(span_x), np.max(span_x)])) return spans def generate_diagnostics(self): matplotlib.use("tkagg") profile_number = self.data["PROFILE_NUMBER"].values depth = self.data["DEPTH"].values # TIME is not required by this step; fall back to measurement index. x = ( self.data["TIME"].values if "TIME" in self.data else np.arange(depth.size) ) profile_spans = self._profile_spans(profile_number, x) # Both MLDs are drawn on every panel for comparison; only the selected one is stored. methods = [ (method, colour) for method, colour in self.DIAGNOSTIC_METHODS if self._method_available(method) ] mlds = [] for method, colour in methods: threshold = ( self.density_threshold if method == "density" else self.temp_threshold ) mld = ( self.data["MLD"].values if method == self.resolved_method else self._compute_mld(method, threshold) ) mlds.append((f"MLD from {method}", colour, mld)) fig, axes = plt.subplots( len(methods), 1, figsize=(14, 4 * len(methods)), dpi=150, sharex=True, squeeze=False, ) for ax, (method, _) in zip(axes[:, 0], methods): panel_label = METHOD_LABELS[method] values = self._method_values(method) # Flagged samples and points below the plotted range are kept off the scatter. usable = self.calculation_mask(["DEPTH"] + METHOD_INPUTS[method]) valid = ( usable & ~np.isnan(depth) & ~np.isnan(values) & (depth <= self.DIAGNOSTIC_MAX_DEPTH) ) cmap = palettes.cmap_for_variable(panel_label, default="viridis") scatter = ax.scatter(x[valid], depth[valid], c=values[valid], s=2, cmap=cmap) colourbar = fig.colorbar(scatter, ax=ax) colourbar.set_label(panel_label) # Draw each MLD as a line spanning every profile's span. for label, colour, mld in mlds: for indices, span in profile_spans: finite_mld = mld[indices][np.isfinite(mld[indices])] if finite_mld.size == 0: continue ax.plot( span, [finite_mld[0], finite_mld[0]], c=colour, lw=1, label=label, ) label = None # only label the first line of each MLD ax.set_ylabel("DEPTH") # Positive-down depth: surface at the top, clipped to the shallow range. ax.set_ylim(self.DIAGNOSTIC_MAX_DEPTH, 0) ax.set_title(f"Mixed Layer Depth ({panel_label})") if ax.get_legend_handles_labels()[0]: ax.legend(loc="lower right") axes[-1, 0].set_xlabel("TIME" if "TIME" in self.data else "Measurement") fig.tight_layout() plt.show(block=True)