Source code for pelagos_py.steps.processing.derive_ctd

# 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 deriving CTD variables (salinity, density, depth) using the GSW toolbox."""

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

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

# Diagnostic plot settings
PLOT_SIZE = (10, 8)  # Widened slightly to accommodate the external legend
PLOT_COLOURS = ["#00b894", "#0984e3", "#d63031", "#fdcb6e", "#6c5ce7", "#e84393", "#00cec9", "#e17055"]
FLAGGED_COLOUR = "#b2bec3"  # Grey for flagged/bad data
MARKER_SIZE = 1
PLOT_ALPHA = 0.6


@register_step
[docs] class DeriveCTDVariables(BaseStep, QCHandlingMixin): """ A processing step class for deriving oceanographic variables from CTD data. TEOS-10 implementation provided through Gibbs SeaWater (GSW) Oceanographic Toolbox functions. This step requires that "TIME", "LATITUDE", "LONGITUDE", "CNDC", "PRES" and "TEMP" are present in the dataset variables. Parameters ---------- to_derive : list list of variables to derive The following variables are supported: - "DEPTH" - "PRAC_SALINITY" (practical salinity) - "ABS_SALINITY" (absolute salinity) - "CONS_TEMP" (conservative temperature) - "DENSITY Examples -------- Example usage in a pipeline configuration: .. code-block:: yaml steps: - name: "Derive CTD" parameters: to_derive: [ DEPTH, PRAC_SALINITY, ABS_SALINITY, CONS_TEMP, DENSITY ] """ step_name = "Derive CTD" required_variables = ["TIME", "LATITUDE", "LONGITUDE", "CNDC", "PRES", "TEMP"] provided_variables = [ "DEPTH", "PRAC_SALINITY", "ABS_SALINITY", "CONS_TEMP", "DENSITY", ] parameter_schema = { "to_derive": { "type": list, "required": True, "options": [ "DEPTH", "PRAC_SALINITY", "ABS_SALINITY", "CONS_TEMP", "DENSITY", ], "description": "Subset of CTD variables to derive and add to the dataset.", }, } def run(self): self.log(f"Processing CTD...") self.filter_qc() # Convert xarray Dataset to Polars DataFrame for efficient numerical processing # Extract only the variables needed for GSW calculations df = pl.from_pandas( self.data[ ["TIME", "LATITUDE", "LONGITUDE", "CNDC", "PRES", "TEMP"] ].to_dataframe(), nan_to_null=False, ) # Define GSW (Gibbs SeaWater) function calls for deriving oceanographic variables # Each tuple contains: (output_variable_name, gsw_function, [required_input_variables]) gsw_function_calls = ( ("DEPTH", gsw.z_from_p, ["PRES", "LATITUDE"]), ("PRAC_SALINITY", gsw.SP_from_C, ["CNDC", "TEMP", "PRES"]), ( "ABS_SALINITY", gsw.SA_from_SP, ["PRAC_SALINITY", "PRES", "LONGITUDE", "LATITUDE"], ), ("CONS_TEMP", gsw.CT_from_t, ["ABS_SALINITY", "TEMP", "PRES"]), ("DENSITY", gsw.rho, ["ABS_SALINITY", "CONS_TEMP", "PRES"]), ) # Define metadata for each derived variable following CF conventions variable_metadata = { "DEPTH": { "long_name": "Depth from surface (negative down as defined by TEOS-10)", "units": "m", "standard_name": "DEPTH", "valid_min": -10925, # Mariana Trench depth "valid_max": 1, # Above sea level }, "PRAC_SALINITY": { "long_name": "Practical salinity", "units": "1", "standard_name": "PRAC_SALINITY", "valid_min": 2, # Extremely fresh water "valid_max": 42, # Hypersaline conditions }, "ABS_SALINITY": { "long_name": "Absolute salinity", "units": "g/kg", "standard_name": "ABS_SALINITY", "valid_min": 0, # Pure water "valid_max": 1000, # Pure salt (theoretical maximum) }, "CONS_TEMP": { "long_name": "Conservative temperature", "units": "degC", "standard_name": "CONS_TEMP", "valid_min": -2, # Freezing point of seawater "valid_max": 102, # Boiling point of seawater }, "DENSITY": { "long_name": "Density", "units": "kg/m3", "standard_name": "DENSITY", "valid_min": 900, # Warm, low salinity surface water "valid_max": 1100, # Cold, high salinity bottom water }, } # Process each GSW function call to derive new variables for var_name, func, args in gsw_function_calls: if var_name not in self.to_derive: continue self.log(f"Deriving {var_name}...") # Validate that all required inputs exist for this specific calculation # (e.g. an intermediate like PRAC_SALINITY may not have been derived) missing_args = [arg for arg in args if arg not in df.columns] if missing_args: self.log( f"Warning: Missing required variables {missing_args} for {var_name}. Skipping." ) continue # Apply the GSW function to pure numpy arrays input_arrays = [df[arg].to_numpy() for arg in args] derived_values = func(*input_arrays) df = df.with_columns(pl.Series(var_name, derived_values)) # Add the derived variable to the xarray Dataset with CF-compliant metadata self.data[var_name] = (("N_MEASUREMENTS",), derived_values) self.data[var_name].attrs = variable_metadata[var_name] # Safely generate QC by only passing source columns that actually exist source_qcs = [f"{arg}_QC" for arg in args if f"{arg}_QC" in self.data] if source_qcs: self.generate_qc({f"{var_name}_QC": source_qcs}) # Show diagnostic plots if diagnostics are enabled if self.diagnostics: self.plot_diagnostics() self.reconstruct_data() self.update_qc() # Update the context with the enhanced dataset self.context["data"] = self.data return self.context def plot_diagnostics(self): if "TIME" not in self.data: return # Combine physical inputs and derived outputs, filtering for what actually exists target_variables = ["PRES", "CNDC", "TEMP"] + self.provided_variables plot_vars = [var for var in target_variables if var in self.data] if not plot_vars: return matplotlib.use("tkagg") n_vars = len(plot_vars) fig, axes = plt.subplots(n_vars, 1, sharex=True, figsize=PLOT_SIZE, dpi=150) if n_vars == 1: axes = [axes] time_data = self.data["TIME"].values for i, var_name in enumerate(plot_vars): ax = axes[i] colour = PLOT_COLOURS[i % len(PLOT_COLOURS)] data_vals = self.data[var_name].values # Extract units and format cleanly (ignore "1" or missing units) units = str(self.data[var_name].attrs.get("units", "")).strip() if units in ["1", "unitless", "unknown", "None", ""]: unit_str = "" else: unit_str = f"\n[{units}]" # Determine QC status if the QC column exists qc_col = f"{var_name}_QC" if qc_col in self.data: qc_vals = self.data[qc_col].values # Treat 0 (No QC), 1, 2, 5, 8 as "Good" points good_mask = np.isin(qc_vals, [0, 1, 2, 5, 8]) bad_mask = ~good_mask & ~np.isnan(data_vals) good_plot_mask = good_mask & ~np.isnan(data_vals) # Plot bad data first so it sits beneath good data if np.any(bad_mask): ax.plot( time_data[bad_mask], data_vals[bad_mask], ls="", marker="o", markersize=MARKER_SIZE, alpha=PLOT_ALPHA, c=FLAGGED_COLOUR, zorder=1, ) # Plot good data on top if np.any(good_plot_mask): ax.plot( time_data[good_plot_mask], data_vals[good_plot_mask], ls="", marker="o", markersize=MARKER_SIZE, alpha=PLOT_ALPHA, c=colour, zorder=2, ) # We calculate stats only on the good data for a cleaner representation stats_data = data_vals[good_plot_mask] else: # Fallback if no QC column exists ax.plot( time_data, data_vals, ls="", marker="o", markersize=MARKER_SIZE, alpha=PLOT_ALPHA, c=colour, zorder=2, ) stats_data = data_vals[~np.isnan(data_vals)] # Calculate robust statistics if len(stats_data) > 0: v_min = np.nanmin(stats_data) v_max = np.nanmax(stats_data) v_mean = np.nanmean(stats_data) v_std = np.nanstd(stats_data) # Add formatted statistical legend outside the plot area stat_text = f"Min: {v_min:.3f}\nMax: {v_max:.3f}\nMean: {v_mean:.3f}\nStd: {v_std:.3f}" ax.plot([], [], ls="", label=stat_text) ax.legend( loc="center left", bbox_to_anchor=(1.01, 0.5), fontsize=6, framealpha=0.9, fancybox=True, ) ax.set_ylabel(f"{var_name}{unit_str}", fontsize=7) ax.grid(True, alpha=0.3) ax.tick_params(axis="both", which="major", labelsize=7) # Invert y-axis for pressure so the ocean surface is at the top of the plot if var_name == "PRES": ax.invert_yaxis() axes[-1].set_xlabel("Time", fontsize=8) fig.suptitle(f"{self.step_name} Diagnostics", fontsize=10, fontweight="bold") # Adjust layout to leave room on the right for the external legends fig.tight_layout(rect=[0, 0, 0.88, 1]) plt.show(block=True)