# 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 steps for processing dissolved-oxygen optode data (uncalibrated phase and optode temperature)."""
#### 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 matplotlib
import matplotlib.pyplot as plt
from mpl_toolkits.axes_grid1 import make_axes_locatable
from pelagos_py.utils import fig_spec
import numpy as np
import pandas as pd
import xarray as xr
from scipy.signal import butter, filtfilt
[docs]
def check_config(self, expected_params):
"""Runtime checks beyond the parameter schema.
Parameter *presence* and defaults are handled by ``parameter_schema``; this
additionally (a) catches method-dependent parameters left unset (``None``) and
(b) verifies that any ``*_name`` parameter points at a variable that actually
exists in the dataset.
"""
for param in expected_params:
if getattr(self, param, None) is None:
raise KeyError(f"[{self.step_name}] '{param}' is missing from config")
if "_name" in param:
if getattr(self, param) not in self.data.data_vars:
raise KeyError(
f"[{self.step_name}] {getattr(self, param)} could not be found in the data"
)
def _plot_section(data, var, pressure_var, step_name):
# Single panel section: TIME vs pressure_var (inverted), coloured by var's value.
# Continuous colour + colorbar isn't reproducible by the dashboard's WebGL viewer (PNG-only).
if var not in data or pressure_var not in data:
return
matplotlib.use("tkagg")
fig, axes = fig_spec.new_fig()
ax = axes[0][0]
time, pres, values = data["TIME"].values, data[pressure_var].values, data[var].values
finite = ~pd.isnull(time) & np.isfinite(pres) & np.isfinite(values)
sc = ax.scatter(
time[finite], pres[finite], c=values[finite], cmap="viridis",
s=fig_spec.MARKER, alpha=fig_spec.ALPHA, rasterized=finite.sum() > fig_spec.RASTER_ABOVE,
)
cbar = fig.colorbar(sc, ax=ax)
cbar.set_label(fig_spec.axis_label(var, data[var].attrs.get("units")), fontsize=fig_spec.FS_LABEL)
cbar.ax.tick_params(labelsize=fig_spec.FS_TICK)
fig_spec.date_axis(ax, which="x")
ylabel = fig_spec.axis_label(pressure_var, data[pressure_var].attrs.get("units"))
fig_spec.style_axes(ax, ylabel=ylabel)
ax.invert_yaxis()
fig_spec.finish(fig, suptitle=f"{step_name} Diagnostics")
plt.show(block=True)
def _qc_good_mask(data, var):
# True where var isn't flagged bad (4); no QC var means nothing to exclude.
qc_name = f"{var}_QC"
if qc_name not in data:
return np.ones(data[var].shape, dtype=bool)
return data[qc_name].values != 4
def _plot_diff(data, raw_var, corrected_var, pressure_var, step_name):
# Two panels: raw+corrected overlaid on TIME, and TIME vs pressure_var (inverted)
# coloured by (corrected - raw) - where in the water column the correction bites.
if raw_var not in data or corrected_var not in data:
return
matplotlib.use("tkagg")
fig, axes = fig_spec.new_fig(nrows=2, sharex=True)
ax0, ax1 = axes[0][0], axes[1][0]
# Reserve the same width on ax0 as the colorbar takes on ax1, so the shared TIME axis lines up.
divider0 = make_axes_locatable(ax0)
cax0 = divider0.append_axes("right", size="3%", pad=0.15)
cax0.axis("off")
good = _qc_good_mask(data, raw_var) & _qc_good_mask(data, corrected_var)
time = data["TIME"].values
fig_spec.points(ax0, time[good], data[raw_var].values[good], color=fig_spec.FLAGGED, label=raw_var)
fig_spec.points(ax0, time[good], data[corrected_var].values[good], color=fig_spec.CATEGORY[1], label=corrected_var)
fig_spec.style_axes(ax0, ylabel=fig_spec.axis_label(corrected_var, data[corrected_var].attrs.get("units")))
# An outside (bbox_to_anchor) legend would need more width than the colorbar spacer
# reserves above, re-breaking the TIME-axis alignment with ax1 - so keep it inside.
ax0.legend(fontsize=fig_spec.FS_LEGEND, loc="upper right", framealpha=0.9, markerscale=2)
diff = data[corrected_var].values - data[raw_var].values
if pressure_var in data:
pres = data[pressure_var].values
finite = np.isfinite(diff) & np.isfinite(pres) & good
divider1 = make_axes_locatable(ax1)
cax1 = divider1.append_axes("right", size="3%", pad=0.15)
sc = ax1.scatter(
time[finite], pres[finite], c=diff[finite], cmap="viridis",
s=fig_spec.MARKER, alpha=fig_spec.ALPHA, rasterized=True,
)
cbar = fig.colorbar(sc, cax=cax1)
cbar.set_label(f"{corrected_var} - {raw_var}", fontsize=fig_spec.FS_LABEL)
cbar.ax.tick_params(labelsize=fig_spec.FS_TICK)
fig_spec.date_axis(ax1, which="x")
fig_spec.style_axes(ax1, xlabel="TIME", ylabel=fig_spec.axis_label(pressure_var, data[pressure_var].attrs.get("units")))
ax1.invert_yaxis()
else:
fig_spec.points(ax1, time[good], diff[good], color=fig_spec.CATEGORY[0])
ax1.axhline(0, color="grey", alpha=0.7, zorder=0, linewidth=0.8)
fig_spec.date_axis(ax1, which="x")
fig_spec.style_axes(ax1, xlabel="TIME", ylabel=f"{corrected_var} - {raw_var}")
fig_spec.finish(fig, suptitle=f"{step_name} Diagnostics")
plt.show(block=True)
@register_step
[docs]
class DeriveUncalibratedPhase(BaseStep, QCHandlingMixin):
step_name = "Derive Uncalibrated Phase"
parameter_schema = {
"blue_phase_name": {
"type": str,
"required": True,
"description": "Name of the blue-phase variable in the dataset.",
},
"red_phase_name": {
"type": str,
"default": None,
"description": "Optional red-phase variable; subtracted from blue phase when given.",
},
}
[docs]
def run(self):
"""
Example
-------
::
- name: "Derive Uncalibrated Phase"
parameters:
# <MANDATORY>
blue_phase_name: "BPHASE_DOXY"
# <OPTIONAL>
red_phase_name: "RPHASE_DOXY"
diagnostics: false
Returns
-------
"""
self.filter_qc()
# Check blue_phase_name is present
check_config(self, ("blue_phase_name",))
# Check if the output already exists
if "UNCAL_PHASE_DOXY" in self.data.data_vars:
self.log_warn("UNCAL_PHASE_DOXY already exists in the data. Overwriting...")
# Calculate Uncalibrated phase and specify what QC will be derived from
qc_parents = [f"{self.blue_phase_name}_QC"]
if self.red_phase_name is not None:
check_config(self, ("red_phase_name",))
self.data["UNCAL_PHASE_DOXY"] = (
self.data[self.blue_phase_name] - self.data[self.red_phase_name]
)
qc_parents.append(f"{self.red_phase_name}_QC")
else:
self.data["UNCAL_PHASE_DOXY"] = self.data[self.blue_phase_name]
self.data["UNCAL_PHASE_DOXY"].attrs["units"] = self.data[self.blue_phase_name].attrs.get(
"units", "degree"
)
self.data["UNCAL_PHASE_DOXY"].attrs["long_name"] = "Uncalibrated oxygen optode phase"
self.data["UNCAL_PHASE_DOXY"].attrs["standard_name"] = "UNCAL_PHASE_DOXY"
self.reconstruct_data()
self.update_qc()
self.generate_qc({"UNCAL_PHASE_DOXY_QC": qc_parents})
if self.diagnostics:
self.generate_diagnostics()
self.context["data"] = self.data
return self.context
def generate_diagnostics(self):
_plot_section(self.data, "UNCAL_PHASE_DOXY", "PRES", self.step_name)
@register_step
[docs]
class DeriveOptodeTemperature(BaseStep, QCHandlingMixin):
step_name = "Derive Optode Temperature"
parameter_schema = {
"temp_voltage_name": {
"type": str,
"required": True,
"description": "Name of the optode temperature-voltage variable in the dataset.",
},
"calib_coefficients": {
"type": list,
"required": True,
"description": "Polynomial calibration coefficients (at least two).",
},
}
[docs]
def run(self):
"""
Example
-------
::
- name: "Derive Optode Temperature"
parameters:
temp_voltage_name: "TEMP_VOLTAGE_DOXY"
calib_coefficients: [0, 1, 0, 0, 0, 0]
diagnostics: false
Returns
-------
"""
self.filter_qc()
# Check the optode temperature voltage and calibration coefficients are present
check_config(self, ("temp_voltage_name", "calib_coefficients"))
# Check there are at least two coefficients for the polynomial. Fill in missing values.
if len(self.calib_coefficients) < 2:
raise ValueError(
f"[{self.step_name}] At least two calibration coefficients are required."
)
coeffs = [0] * 6
for i in range(len(self.calib_coefficients)):
coeffs[i] = self.calib_coefficients[i]
# Check if the output already exists
if "TEMP_DOXY" in self.data.data_vars:
self.log_warn("TEMP_DOXY already exists in the data. Overwriting...")
# Calculate temp_doxy
temp_doxy = 0
for i, coeff in enumerate(coeffs):
temp_doxy += coeff[i] * self.data[self.temp_voltage_name] ** i
self.data["TEMP_DOXY"] = temp_doxy
self.data["TEMP_DOXY"].attrs["units"] = "degree_Celsius"
self.data["TEMP_DOXY"].attrs["long_name"] = "Oxygen optode temperature"
self.data["TEMP_DOXY"].attrs["standard_name"] = "TEMP_DOXY"
self.reconstruct_data()
self.update_qc()
self.generate_qc({"TEMP_DOXY_QC": [f"{self.temp_voltage_name}_QC"]})
if self.diagnostics:
self.generate_diagnostics()
self.context["data"] = self.data
return self.context
def generate_diagnostics(self):
_plot_section(self.data, "TEMP_DOXY", "PRES", self.step_name)
@register_step
[docs]
class PhasePressureCorrection(BaseStep, QCHandlingMixin):
step_name = "Phase Pressure Correction"
parameter_schema = {
"optode_pressure_name": {
"type": str,
"required": True,
"description": "Name of the pressure variable used for the correction.",
},
"correction_coefficient": {
"type": float,
"required": True,
"description": "Pressure correction coefficient.",
},
}
[docs]
def run(self):
"""
Example
-------
::
- name: "Phase Pressure Correction"
parameters:
optode_pressure_name: "PRES"
correction_coefficient: 0.1
diagnostics: false
Returns
-------
"""
self.filter_qc()
# Check the optode pressure and correction coefficient are present and that UNCAL_PHASE_DOXY is in the data
check_config(self, ("optode_pressure_name", "correction_coefficient"))
if "UNCAL_PHASE_DOXY" not in self.data.data_vars:
raise KeyError(
f"[{self.step_name}] UNCAL_PHASE_DOXY required but is missing from the data"
)
# Apply the correction
self.data["UNCAL_PHASE_DOXY_PCORR"] = (
self.data["UNCAL_PHASE_DOXY"]
+ 0.001 * self.correction_coefficient * self.data[self.optode_pressure_name]
)
self.data["UNCAL_PHASE_DOXY_PCORR"].attrs["units"] = self.data["UNCAL_PHASE_DOXY"].attrs.get(
"units", "degree"
)
self.data["UNCAL_PHASE_DOXY_PCORR"].attrs["long_name"] = "Pressure-corrected uncalibrated oxygen optode phase"
self.data["UNCAL_PHASE_DOXY_PCORR"].attrs["standard_name"] = "UNCAL_PHASE_DOXY_PCORR"
self.reconstruct_data()
self.update_qc()
self.generate_qc(
{
"UNCAL_PHASE_DOXY_PCORR_QC": [
f"{self.optode_pressure_name}_QC",
"UNCAL_PHASE_DOXY_QC",
]
}
)
if self.diagnostics:
self.generate_diagnostics()
self.context["data"] = self.data
return self.context
def generate_diagnostics(self):
_plot_diff(
self.data,
"UNCAL_PHASE_DOXY",
"UNCAL_PHASE_DOXY_PCORR",
self.optode_pressure_name,
self.step_name,
)
def _plot_shift_diff(data, raw_var, shifted_var, pressure_var, step_name, lag_label):
# Single panel: TIME vs pressure_var (inverted), coloured by (shifted - raw); the
# raw/shifted overlay isn't useful here since a good shift looks almost identical to raw.
if raw_var not in data or shifted_var not in data or pressure_var not in data:
return
matplotlib.use("tkagg")
fig, axes = fig_spec.new_fig()
ax = axes[0][0]
good = _qc_good_mask(data, raw_var) & _qc_good_mask(data, shifted_var)
time, pres = data["TIME"].values, data[pressure_var].values
diff = data[shifted_var].values - data[raw_var].values
finite = np.isfinite(diff) & np.isfinite(pres) & good
sc = ax.scatter(
time[finite], pres[finite], c=diff[finite], cmap="viridis",
s=fig_spec.MARKER, alpha=fig_spec.ALPHA, rasterized=True,
)
cbar = fig.colorbar(sc, ax=ax)
cbar.set_label(f"{shifted_var} - {raw_var}", fontsize=fig_spec.FS_LABEL)
cbar.ax.tick_params(labelsize=fig_spec.FS_TICK)
ax.plot([], [], ls="", label=f"lag: {lag_label}")
ax.legend(fontsize=fig_spec.FS_LEGEND, loc="upper right", framealpha=0.9)
fig_spec.date_axis(ax, which="x")
ylabel = fig_spec.axis_label(pressure_var, data[pressure_var].attrs.get("units"))
fig_spec.style_axes(ax, ylabel=ylabel)
ax.invert_yaxis()
fig_spec.finish(fig, suptitle=f"{step_name} Diagnostics")
plt.show(block=True)
@register_step
[docs]
class ShiftOxygenToCTD(BaseStep, QCHandlingMixin):
step_name = "Shift Oxygen To CTD"
parameter_schema = {
"shift_vars": {
"type": list,
"required": True,
"description": "Names of optode variables (e.g. phase, optode temperature) to time-shift onto the CTD sampling grid.",
},
"lag_seconds": {
"type": float,
"default": None,
"description": "Constant geometric time lag (s), the water travel time between optode and CTD. If not set, the lag is instead derived per profile from pitch and dive rate.",
},
"pitch_name": {
"type": str,
"default": "GLIDER_PITCH",
"description": "Name of the glider pitch variable (radians), used to derive a per-profile lag when 'lag_seconds' is not set.",
},
"distance_cm": {
"type": float,
"default": 90.0,
"description": "Distance between the CTD and oxygen optode along the glider body, in centimetres. Used when deriving a per-profile lag.",
},
"cast_id_var": {
"type": str,
"default": "PROFILE_DIRECTION",
"options": ["PROFILE_DIRECTION", "SCI_PHASE"],
"description": "Variable distinguishing up- and downcasts, used when deriving a per-profile lag.",
},
}
def _derive_profile_lag(self):
# Per-profile geometric time lag (s) from mean pitch and dive rate, following Woo & Gourcuff (2023).
valid_casts = {"PROFILE_DIRECTION": (-1, 1), "SCI_PHASE": (1, 2)}[self.cast_id_var]
df = pd.DataFrame(
{
"PROFILE_NUMBER": self.data["PROFILE_NUMBER"].values,
"CAST": self.data[self.cast_id_var].values,
"PITCH": self.data[self.pitch_name].values,
"GRADIENT": self.data["PROFILE_GRADIENT"].values,
}
).dropna(subset=["PROFILE_NUMBER", "CAST"])
df = df.loc[df["CAST"].isin(valid_casts)]
per_cast = df.groupby(["CAST", "PROFILE_NUMBER"])[["PITCH", "GRADIENT"]].mean().reset_index()
velocity = per_cast["GRADIENT"] / np.sin(per_cast["PITCH"])
per_cast["LAG"] = (self.distance_cm / 100) / velocity
# Low-pass filter (per cast direction) needs a handful of profiles to be meaningful.
lag_lookup = {}
for _, group in per_cast.groupby("CAST"):
group = group.sort_values("PROFILE_NUMBER")
filled = group["LAG"].ffill().bfill()
if filled.notna().sum() >= 7:
b, a = butter(N=3, Wn=1 / 30, btype="low", fs=1)
filled = pd.Series(filtfilt(b, a, filled.to_numpy()), index=filled.index)
lag_lookup.update(dict(zip(group["PROFILE_NUMBER"], filled)))
return lag_lookup
def _shift_onto_grid(self, var, lag_lookup):
epoch_s = self.data["TIME"].values.astype("datetime64[ns]").astype("int64") / 1e9
values = self.data[var].values.astype(float)
valid = ~np.isnan(values) & ~np.isnan(epoch_s)
if valid.sum() < 2:
self.log_warn(f"'{var}' has fewer than two valid samples; cannot shift.")
return np.full(epoch_s.shape, np.nan)
src_time = epoch_s[valid]
src_values = values[valid]
order = np.argsort(src_time)
src_time, src_values = src_time[order], src_values[order]
if lag_lookup is None:
lag = self.lag_seconds
else:
lag = np.array([lag_lookup.get(p, np.nan) for p in self.data["PROFILE_NUMBER"].values])
# Value at CTD time t is the optode measurement of the same water parcel, taken 'lag' seconds later.
query_time = epoch_s + lag
shifted = np.interp(query_time, src_time, src_values, left=np.nan, right=np.nan)
shifted[np.isnan(query_time)] = np.nan
return shifted
[docs]
def run(self):
"""
Example
-------
::
- name: "Shift Oxygen To CTD"
parameters:
# <MANDATORY>
shift_vars: ["UNCAL_PHASE_DOXY", "TEMP_DOXY"]
# <OPTIONAL>
lag_seconds: null
pitch_name: "GLIDER_PITCH"
distance_cm: 90
cast_id_var: "PROFILE_DIRECTION"
diagnostics: false
Geometrically time-shifts optode measurements (e.g. phase, optode
temperature) onto the CTD's dense sampling grid, correcting for the
water travel time between the two sensors' positions on the glider
(Woo & Gourcuff, 2023). This is the reverse of the more common
approach of cloning CTD data onto the sparse optode grid: it produces
one shifted oxygen value per ``N_MEASUREMENTS`` row, named
``<variable>_SHIFTED``.
If ``lag_seconds`` is not set, the lag is instead derived per profile
from mean pitch and dive rate. If ``pitch_name`` cannot be found (or
is empty), a warning is logged and the step fails, since no lag is
then available; set ``lag_seconds`` for a constant lag instead.
Returns
-------
"""
self.filter_qc()
check_config(self, ("shift_vars", "distance_cm", "cast_id_var"))
for var in self.shift_vars:
if var not in self.data.data_vars:
raise KeyError(f"[{self.step_name}] '{var}' is missing from the data")
if "PROFILE_NUMBER" not in self.data.data_vars:
raise KeyError(f"[{self.step_name}] PROFILE_NUMBER required but is missing from the data")
lag_lookup = None
if self.lag_seconds is not None:
self.log(f"Using constant lag of {self.lag_seconds} s.")
self._lag_label = f"{self.lag_seconds:.3f}s (constant)"
else:
if self.pitch_name not in self.data.data_vars or bool(
self.data[self.pitch_name].isnull().all()
):
self.log_warn(
f"'{self.pitch_name}' not found or empty; cannot derive a per-profile lag. "
"Set 'lag_seconds' for a constant lag instead."
)
raise KeyError(
f"[{self.step_name}] No lag available: '{self.pitch_name}' missing and 'lag_seconds' not set."
)
if "PROFILE_GRADIENT" not in self.data.data_vars:
raise KeyError(
f"[{self.step_name}] PROFILE_GRADIENT required but is missing from the data"
)
if self.cast_id_var not in self.data.data_vars:
raise KeyError(f"[{self.step_name}] {self.cast_id_var} required but is missing from the data")
lag_lookup = self._derive_profile_lag()
mean_lag = np.nanmean(list(lag_lookup.values())) if lag_lookup else np.nan
self._lag_label = f"per-profile (mean {mean_lag:.3f}s)" if np.isfinite(mean_lag) else "per-profile"
for var in self.shift_vars:
out_name = f"{var}_SHIFTED"
if out_name in self.data.data_vars:
self.log_warn(f"{out_name} already exists in the data. Overwriting...")
self.data[out_name] = (("N_MEASUREMENTS",), self._shift_onto_grid(var, lag_lookup))
self.data[out_name].attrs["units"] = self.data[var].attrs.get("units")
parent_long_name = self.data[var].attrs.get("long_name", var)
self.data[out_name].attrs["long_name"] = f"{parent_long_name}, geometrically shifted onto the CTD grid"
self.data[out_name].attrs["standard_name"] = out_name
self.reconstruct_data()
self.update_qc()
self.generate_qc({f"{var}_SHIFTED_QC": [f"{var}_QC"] for var in self.shift_vars})
# The shift evaluates every row via interpolation, so a valid shifted value is
# never really its parent's original flag (e.g. still-missing 9) - it's "changed".
for var in self.shift_vars:
qc_name = f"{var}_SHIFTED_QC"
has_value = self.data[f"{var}_SHIFTED"].notnull()
self.data[qc_name] = xr.where(has_value, 5, self.data[qc_name])
if self.diagnostics:
self.generate_diagnostics()
self.context["data"] = self.data
return self.context
def generate_diagnostics(self):
for var in self.shift_vars:
_plot_shift_diff(self.data, var, f"{var}_SHIFTED", "PRES", self.step_name, self._lag_label)
@register_step
[docs]
class DeriveCalibratedPhase(BaseStep, QCHandlingMixin):
step_name = "Derive Calibrated Phase"
parameter_schema = {
"uncalibrated_phase_name": {
"type": str,
"required": True,
"description": "Name of the uncalibrated-phase variable in the dataset.",
},
"calib_coefficients": {
"type": list,
"required": True,
"description": "Polynomial calibration coefficients (at least two).",
},
}
[docs]
def run(self):
"""
Example
-------
::
- name: "Derive Calibrated Phase"
parameters:
uncalibrated_phase_name: "UNCAL_PHASE_DOXY"
calib_coefficients: [0, 1, 0, 0]
diagnostics: false
Returns
-------
"""
self.filter_qc()
# Check the config satisfies requirements
check_config(self, ("uncalibrated_phase_name", "calib_coefficients"))
# Check there are at least two coefficients for the polynomial. Fill in missing values.
if len(self.calib_coefficients) < 2:
raise ValueError(
f"[{self.step_name}] At least two calibration coefficients are required."
)
coeffs = [0] * 4
for i in range(len(self.calib_coefficients)):
coeffs[i] = self.calib_coefficients[i]
# Check if the output already exists
if "CAL_PHASE_DOXY" in self.data.data_vars:
self.log_warn("CAL_PHASE_DOXY already exists in the data. Overwriting...")
# Calculate cal_phase_doxy
cal_phase_doxy = 0
for i, coeff in enumerate(coeffs):
cal_phase_doxy += coeff * self.data[self.uncalibrated_phase_name] ** i
self.data["CAL_PHASE_DOXY"] = cal_phase_doxy
self.data["CAL_PHASE_DOXY"].attrs["units"] = self.data[self.uncalibrated_phase_name].attrs.get(
"units", "degree"
)
self.data["CAL_PHASE_DOXY"].attrs["long_name"] = "Calibrated oxygen optode phase"
self.data["CAL_PHASE_DOXY"].attrs["standard_name"] = "CAL_PHASE_DOXY"
self.reconstruct_data()
self.update_qc()
self.generate_qc({"CAL_PHASE_DOXY_QC": [f"{self.uncalibrated_phase_name}_QC"]})
if self.diagnostics:
self.generate_diagnostics()
self.context["data"] = self.data
return self.context
def generate_diagnostics(self):
_plot_section(self.data, "CAL_PHASE_DOXY", "PRES", self.step_name)
@register_step
[docs]
class DeriveOxygenConcentration(BaseStep, QCHandlingMixin):
step_name = "Derive Oxygen Concentration"
parameter_schema = {
"method": {
"type": str,
"required": True,
"options": ["poly", "SVU"],
"description": "Conversion method: 'poly' or 'SVU'.",
},
"temperature_name": {
"type": str,
"required": True,
"description": "Name of the temperature variable in the dataset.",
},
"calib_coefficient_matrix": {
"type": list,
"default": None,
"description": "Calibration coefficient matrix, shape (5, 4). Required by the 'poly' method.",
},
"svu_coefficients": {
"type": list,
"default": None,
"description": (
"Manufacturer SVUFoilCoef0-6, required by the 'SVU' method: "
"K_SV = c0 + c1*T + c2*T^2, P0 = c3 + c4*T, Pc = c5 + c6*CAL_PHASE_DOXY."
),
},
}
def func_poly(self):
# Check the calibration matrix has the right shape
if np.shape(self.calib_coefficient_matrix) != (5, 4):
raise ValueError(
f"[{self.step_name}] Calib coefficient matrix must be of shape (5, 4) for method 'poly'."
)
# Build the internal coefficient matrix
coeffs_matrix = np.full((5, 4), 0)
for i, row in enumerate(self.calib_coefficient_matrix):
coeffs_matrix[i, :] = row
# Apply the conversion
poly_temp = np.array(
[self.data[self.temperature_name].values ** i for i in range(4)]
)[np.newaxis, :, :]
molar_doxy = (
(poly_temp * coeffs_matrix[:, :, np.newaxis]).sum(axis=1)
* np.array([self.data["CAL_PHASE_DOXY"].values ** i for i in range(5)])
).sum(axis=0)
return molar_doxy
def func_SVU(self):
if len(self.svu_coefficients) != 7:
raise ValueError(
f"[{self.step_name}] 'svu_coefficients' must have 7 values (SVUFoilCoef0-6) for method 'SVU'."
)
c0, c1, c2, c3, c4, c5, c6 = self.svu_coefficients
T = self.data[self.temperature_name].values
phase = self.data["CAL_PHASE_DOXY"].values
# Stern-Volmer-Uchida equation (Aanderaa manufacturer form)
K_SV = c0 + c1 * T + c2 * T**2
P0 = c3 + c4 * T
Pc = c5 + c6 * phase
return (P0 / Pc - 1.0) / K_SV
[docs]
def run(self):
"""
Example
-------
::
- name: "Derive Oxygen Concentration"
parameters:
# <MANDATORY>
method: "SVU"
# <METHOD DEPENDENT>
# The following params are for "SVU" method
temperature_name: "TEMP_DOXY"
svu_coefficients: [c0, c1, c2, c3, c4, c5, c6] # manufacturer SVUFoilCoef0-6
diagnostics: false
``method: "poly"`` instead expects ``calib_coefficient_matrix`` (shape
(5, 4)); ``method: "SVU"`` expects ``svu_coefficients``, the 7
manufacturer SVUFoilCoef values (Stern-Volmer-Uchida equation).
Returns
-------
"""
self.filter_qc()
methods = {
"poly": (self.func_poly, ("temperature_name", "calib_coefficient_matrix")),
"SVU": (self.func_SVU, ("temperature_name", "svu_coefficients")),
}
# Check the specified method
check_config(self, ("method",))
if self.method not in methods.keys():
raise ValueError(f"[{self.step_name}] Unknown method '{self.method}'")
# Unpack the method args and functions
func, args = methods[self.method]
# Check the config satisfies requirements
check_config(self, args)
# Check if the output already exists
if "MOLAR_DOXY" in self.data.data_vars:
self.log_warn("MOLAR_DOXY already exists in the data. Overwriting...")
self.data["MOLAR_DOXY"] = (("N_MEASUREMENTS",), func())
self.data["MOLAR_DOXY"].attrs["units"] = "micromole/l"
self.data["MOLAR_DOXY"].attrs["long_name"] = "Molar dissolved oxygen concentration"
self.data["MOLAR_DOXY"].attrs["standard_name"] = "MOLAR_DOXY"
self.reconstruct_data()
self.update_qc()
self.generate_qc(
{"MOLAR_DOXY_QC": ["CAL_PHASE_DOXY_QC", f"{self.temperature_name}_QC"]}
)
if self.diagnostics:
self.generate_diagnostics()
self.context["data"] = self.data
return self.context
def generate_diagnostics(self):
_plot_section(self.data, "MOLAR_DOXY", "PRES", self.step_name)
@register_step
[docs]
class MolarDOXYSalinityCorrection(BaseStep, QCHandlingMixin):
step_name = "Molar DOXY Salinity Correction"
parameter_schema = {
"salinity_name": {
"type": str,
"required": True,
"description": "Name of the salinity variable in the dataset.",
},
"temperature_name": {
"type": str,
"required": True,
"description": "Name of the temperature variable in the dataset.",
},
"reference_salinity": {
"type": float,
"default": 0,
"description": "Reference salinity the correction is computed relative to.",
},
}
def oxy_solubility_salinity_correction(self):
# Get data
T = self.data[self.temperature_name]
S = self.data[self.salinity_name]
# Coefficients (Garcia & Gordon 1992 – Benson & Krause refit)
B0 = -6.24523e-3
B1 = -7.37614e-3
B2 = -1.03410e-2
B3 = -8.17083e-3
C0 = -4.88682e-7
# Scaled temperature term Ts
Ts = np.log((298.15 - T) / (273.15 + T))
# SCorr computation
salinity_correction_factor = np.exp(
(S - self.reference_salinity) * (B0 + B1 * Ts + B2 * (Ts**2) + B3 * (Ts**3))
+ C0 * ((S**2) - (self.reference_salinity**2))
)
return salinity_correction_factor
def water_vapour_partial_pressure(self, reference_salinity=None):
# Get data
T = self.data[self.temperature_name]
if reference_salinity is None:
S = self.data[self.salinity_name]
else:
S = reference_salinity
# Convert degrees C to Kelvin
T = T + 273.15
# Constants from polynomial equation 10 in Weiss&Price, 1980.
A = 24.4543
B = -67.4509
C = -4.8489
D = -0.000544
# Equation 10 in Weiss&Price, 1980
vapour_partial_pressure = 1013.25 * np.exp(
A + B * (100 / T) + C * np.log(T / 100) + D * S
)
return vapour_partial_pressure
[docs]
def run(self):
"""
Example
-------
::
- name: "Molar DOXY Salinity Correction"
parameters:
# <MANDATORY>
salinity_name: "PRAC_SALINITY"
temperature_name: "TEMP"
# <OPTIONAL>
reference_salinity: 0
diagnostics: false
Returns
-------
"""
self.filter_qc()
# Check the requred variable names are specified
check_config(self, ("salinity_name", "temperature_name"))
if "MOLAR_DOXY" not in self.data.data_vars:
raise KeyError(
f"[{self.step_name}] MOLAR_DOXY required but is missing from the data"
)
# Calculate factor with partial pressure of water vapour, following Weiss & PRice (1980)
A = 1013.25 - self.water_vapour_partial_pressure(
reference_salinity=self.reference_salinity
)
B = 1013.25 - self.water_vapour_partial_pressure()
S_Corr = self.oxy_solubility_salinity_correction()
MOLAR_DOXY_PSAL = (A / B) * S_Corr * self.data["MOLAR_DOXY"]
# Apply the correction
self.data["MOLAR_DOXY_PSAL"] = MOLAR_DOXY_PSAL
self.data["MOLAR_DOXY_PSAL"].attrs["units"] = self.data["MOLAR_DOXY"].attrs.get(
"units", "micromole/l"
)
self.data["MOLAR_DOXY_PSAL"].attrs["long_name"] = "Salinity-corrected molar dissolved oxygen concentration"
self.data["MOLAR_DOXY_PSAL"].attrs["standard_name"] = "MOLAR_DOXY_PSAL"
self.reconstruct_data()
self.update_qc()
self.generate_qc(
{
"MOLAR_DOXY_PSAL_QC": [
f"{self.salinity_name}_QC",
f"{self.temperature_name}_QC",
"MOLAR_DOXY_QC",
]
}
)
if self.diagnostics:
self.generate_diagnostics()
self.context["data"] = self.data
return self.context
def generate_diagnostics(self):
_plot_diff(self.data, "MOLAR_DOXY", "MOLAR_DOXY_PSAL", "PRES", self.step_name)
@register_step
[docs]
class MolarDOXYPressureCorrection(BaseStep, QCHandlingMixin):
step_name = "Molar DOXY Pressure Correction"
parameter_schema = {
"pressure_name": {
"type": str,
"required": True,
"description": "Name of the pressure variable in the dataset.",
},
"temperature_name": {
"type": str,
"required": True,
"description": "Name of the temperature variable in the dataset.",
},
"molar_doxy_name": {
"type": str,
"required": True,
"description": "Name of the molar oxygen variable to correct.",
},
"uncalibrated_phase_correction_applied": {
"type": bool,
"required": True,
"description": "Whether the uncalibrated-phase pressure correction was already applied (selects coefficients).",
},
}
[docs]
def run(self):
"""
Example
-------
::
- name: "Molar DOXY Pressure Correction"
parameters:
# <MANDATORY>
pressure_name: "PRES"
temperature_name: "TEMP"
molar_doxy_name: "MOLAR_DOXY_PSAL"
uncalibrated_phase_correction_applied: true
diagnostics: false
Returns
-------
"""
self.filter_qc()
# Check the required variable names are supplied
check_config(
self,
(
"pressure_name",
"temperature_name",
"molar_doxy_name",
"uncalibrated_phase_correction_applied",
),
)
# Set the correction coefficients
if self.uncalibrated_phase_correction_applied:
C1, C2 = 0.00022, 0.0419
else:
C1, C2 = 0.00025, 0.0328
MOLAR_DOXY_PSAL_PRES = self.data[self.molar_doxy_name] * (
1.0
+ (
(C1 * self.data[self.temperature_name] + C2)
* self.data[self.pressure_name]
)
/ 1000
)
# Apply the correction
self.data["MOLAR_DOXY_PSAL_PRES"] = MOLAR_DOXY_PSAL_PRES
self.data["MOLAR_DOXY_PSAL_PRES"].attrs["units"] = self.data[self.molar_doxy_name].attrs.get(
"units", "micromole/l"
)
self.data["MOLAR_DOXY_PSAL_PRES"].attrs["long_name"] = (
"Salinity- and pressure-corrected molar dissolved oxygen concentration"
)
self.data["MOLAR_DOXY_PSAL_PRES"].attrs["standard_name"] = "MOLAR_DOXY_PSAL_PRES"
self.reconstruct_data()
self.update_qc()
self.generate_qc(
{
"MOLAR_DOXY_PSAL_PRES_QC": [
f"{self.pressure_name}_QC",
f"{self.temperature_name}_QC",
f"{self.molar_doxy_name}_QC",
]
}
)
if self.diagnostics:
self.generate_diagnostics()
self.context["data"] = self.data
return self.context
def generate_diagnostics(self):
_plot_diff(
self.data,
self.molar_doxy_name,
"MOLAR_DOXY_PSAL_PRES",
self.pressure_name,
self.step_name,
)