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 numpy as np
import gsw
import matplotlib
import matplotlib.pyplot as plt
from pelagos_py.utils import fig_spec
from pelagos_py.utils.processing_utils import cndc_scale_factor


@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", ] uses_data_subset = True 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() # gsw wants conductivity in mS/cm; scale from the units attribute (S/m assumed if unset) cndc_factor = cndc_scale_factor(self.data["CNDC"].attrs.get("units")) # Define GSW (Gibbs SeaWater) function calls for deriving oceanographic variables # Each tuple contains: (output_variable_name, gsw_function, [required_input_variables]) gsw_function_calls = ( # gsw.z_from_p returns TEOS-10 height (negative down); negate for OG1 positive-down depth ("DEPTH", lambda p, lat: -gsw.z_from_p(p, lat), ["PRES", "LATITUDE"]), ( "PRAC_SALINITY", lambda c, t, p: gsw.SP_from_C(c * cndc_factor, t, p), ["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 below surface of the water body by unknown instrument " "and correction to zero at sea level using unspecified algorithm." ), "units": "metres", "standard_name": "depth", "valid_min": 0.0, "valid_max": 10000.0, "positive": "down", "ancillary_variables": "DEPTH_QC", "depth_vocabulary": "https://vocab.nerc.ac.uk/collection/OG1/current/DEPTH/", }, "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 self.data] 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 = [self.data[arg].values for arg in args] derived_values = func(*input_arrays) # 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"].update(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") fig, axes = fig_spec.new_fig(nrows=len(plot_vars), sharex=True) time_data = self.data["TIME"].values for i, (ax, var_name) in enumerate(zip(axes[:, 0], plot_vars)): data_vals = self.data[var_name].values # Colour by QC flag where a QC column exists, else a single series. qc_col = f"{var_name}_QC" if qc_col in self.data: fig_spec.flag_points(ax, time_data, data_vals, self.data[qc_col].values) fig_spec.legend(ax, title="Flags") else: fig_spec.points(ax, time_data, data_vals, color=fig_spec.CATEGORY[0]) fig_spec.date_axis(ax, which="x") ylabel = fig_spec.axis_label(var_name, self.data[var_name].attrs.get("units")) xlabel = "Time" if i == len(plot_vars) - 1 else None fig_spec.style_axes(ax, xlabel=xlabel, ylabel=ylabel) # Invert y-axis for pressure/depth so the ocean surface is at the top of the plot if var_name in ("PRES", "DEPTH"): ax.invert_yaxis() fig_spec.finish(fig, suptitle=f"{self.step_name} Diagnostics") plt.show(block=True)