Source code for pelagos_py.steps.processing.salinity

# 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 adjusting and deriving salinity from conductivity, temperature and pressure."""

#### 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.pyplot as plt
import matplotlib.lines as mlines
import matplotlib as mpl
from scipy import interpolate
from tqdm import tqdm
import xarray as xr
import pandas as pd
import numpy as np
import gsw


def running_average_nan(arr: np.ndarray, window_size: int) -> np.ndarray:
    """
    Symmetric running-average mean that ignores NaNs. ``window_size`` must be odd.

    :meta private:
    """

    if window_size % 2 == 0:
        raise ValueError("Window size must be odd for symmetry.")

    pad_size = window_size // 2  # Symmetric padding
    padded = np.pad(arr, pad_size, mode="reflect")  # Edge handling

    kernel = np.ones(window_size)
    # Compute weighted sums while ignoring NaNs
    sum_vals = np.convolve(np.nan_to_num(padded), kernel, mode="valid")
    count_vals = np.convolve(~np.isnan(padded), kernel, mode="valid")

    # Compute the moving average, handling NaNs properly and preventing uninitialised memory warnings
    avg = np.divide(
        sum_vals,
        count_vals,
        out=np.full_like(sum_vals, np.nan, dtype=float),
        where=(count_vals != 0),
    )

    return avg


def compute_optimal_lag(
    profile_data, filter_window_size, time_col, return_cost_data=False
):
    """
    Find the optimal conductivity-temperature time lag (seconds) for one profile.

    Trials lags from -2 s to +2 s in 0.1 s steps and returns the lag that minimises
    the standard deviation of (salinity - running-average salinity), i.e. the lag
    that suppresses salinity spiking. When ``return_cost_data`` is True a second
    dict of intermediate arrays is also returned for diagnostics. See
    :meth:`AdjustSalinity.correct_ct_lag` for the full method and references.

    :meta private:
    """

    # remove any rows where conductivity is bad (nan)
    profile_data = profile_data[[time_col, "CNDC", "PRES", "TEMP"]].dropna(
        dim="N_MEASUREMENTS", subset=["CNDC"]
    )

    if len(profile_data[time_col]) == 0:
        if return_cost_data:
            return np.nan, None
        return np.nan

    # Find the elapsed time in seconds from the start of the profile
    t0 = profile_data[time_col].values[0]
    profile_data["ELAPSED_TIME[s]"] = (profile_data[time_col] - t0).dt.total_seconds()

    # Creates a callable function that predicts what CNDC would be at any given time
    conductivity_from_time = interpolate.interp1d(
        profile_data["ELAPSED_TIME[s]"].values,
        profile_data["CNDC"].values,
        bounds_error=False,
    )

    # Specify the range time lags that the optimum will be found from. Column indexes are: (lag value, lag score)
    time_lags = np.array([np.linspace(-2, 2, 41), np.full(41, np.nan)]).T

    saved_psal = {} if return_cost_data else None

    # For each lag find its score and add it to the time_lags array
    for i, lag in enumerate(time_lags[:, 0].copy()):
        # Apply the time shift
        time_shifted_conductivity = conductivity_from_time(
            profile_data["ELAPSED_TIME[s]"] + lag
        )

        # Scale if necessary (handles conductivity supplied in S/m rather than mS/cm)
        cndc_scaled = (
            time_shifted_conductivity * 10
            if np.nanmax(time_shifted_conductivity) < 10
            else time_shifted_conductivity
        )

        # Derive salinity with the time shifted CNDC (spiking will be minimized when CNDC and TEMP are aligned)
        PSAL = gsw.conversions.SP_from_C(
            cndc_scaled, profile_data["TEMP"], profile_data["PRES"]
        )

        # Smooth the salinity profile (to remove spiking)
        PSAL_Smooth = running_average_nan(PSAL, filter_window_size)

        # Subtracting the raw and smoothed salinity gives an indication of "spikiness".
        PSAL_Diff = PSAL - PSAL_Smooth

        # More spiky data will have higher standard deviation - which is used to score the effectiveness of the applied lag
        time_lags[i, 1] = np.nanstd(PSAL_Diff)

        if return_cost_data:
            saved_psal[lag] = (PSAL, PSAL_Smooth)

    # return the time lag which has the lowest score (standard deviation)
    best_score_index = np.argmin(time_lags[:, 1])
    best_lag = time_lags[best_score_index, 0]

    if return_cost_data:
        zero_idx = int(np.argmin(np.abs(time_lags[:, 0])))
        zero_lag = time_lags[zero_idx, 0]
        p_best, p_smooth_best = saved_psal[best_lag]
        p_zero, p_smooth_zero = saved_psal[zero_lag]

        cost_data = {
            "lags": time_lags[:, 0],
            "costs": time_lags[:, 1],
            "best_lag": best_lag,
            "zero_lag": zero_lag,
            "elapsed_time": profile_data["ELAPSED_TIME[s]"].values,
            "resid_zero": p_zero - p_smooth_zero,
            "resid_best": p_best - p_smooth_best,
        }
        return best_lag, cost_data

    return best_lag


@register_step
[docs] class AdjustSalinity(BaseStep, QCHandlingMixin): """ Corrects conductivity- and temperature-related sensor errors so that salinity can be derived cleanly from a glider CTD. Two corrections are applied in sequence to the dataset: - **Conductivity-temperature lag (C-T lag).** Conductivity and temperature are measured by separate sensors with different response times, so the two records are slightly misaligned and produce salinity spikes at sharp gradients. :meth:`correct_ct_lag` estimates the optimal time shift between ``CNDC`` and ``TEMP`` from a sample of profiles and applies the median shift to the whole dataset, following Woo (2019) [3]_. - **Thermal-mass (thermal lag) error.** The conductivity cell stores and releases heat, so the temperature of the water inside it lags the ambient temperature. :meth:`correct_thermal_lag` reconstructs the in-cell temperature with the recursive filter and fixed coefficients of Morison et al. (1994) [1]_. The thermal-mass coefficients (``alpha``/``tau``) are taken directly from Morison et al. (1994) and are not re-optimised in T/S space, as done by Morison et al. (1994) or Garau et al. (2011) [2]_. They are appropriate for a pumped Sea-Bird CT sail at the conductivity-cell flow rate reported by Woo (2019); unpumped CTDs would require the flow rate to be derived from the glider's velocity through the water (e.g. from pitch or a flight model). Parameters ---------- filter_window_size : int, optional Length, in samples, of the running-average filter used when searching for the optimal C-T lag. Must be odd. Default ``21``. Examples -------- Example usage in a pipeline configuration: .. code-block:: yaml steps: - name: "ADJ: Salinity" parameters: filter_window_size: 21 diagnostics: false References ---------- .. [1] Morison, J., Andersen, R., Larson, N., D'Asaro, E., & Boyd, T. (1994). The correction for thermal-lag effects in Sea-Bird CTD data. *Journal of Atmospheric and Oceanic Technology*, 11(4), 1151-1164. .. [2] Garau, B., Ruiz, S., Zhang, W. G., Pascual, A., Heslop, E., Kerfoot, J., & Tintoré, J. (2011). Thermal lag correction on Slocum CTD glider data. *Journal of Atmospheric and Oceanic Technology*, 28(9), 1065-1071. .. [3] Woo, L. M. (2019). Delayed Mode QA/QC Best Practice Manual Version 2.0. Integrated Marine Observing System. DOI: 10.26198/5c997b5fdc9bd (http://dx.doi.org/10.26198/5c997b5fdc9bd). """ step_name = "Salinity Adjustment" required_variables = ["TIME", "PROFILE_NUMBER", "CNDC", "TEMP", "PRES"] provided_variables = [] parameter_schema = { "filter_window_size": { "type": int, "default": 21, "description": "Running-average filter size used when computing optimal time lags.", }, } def run(self): self.log(f"Running adjustment...") # TODO: TIME_CTD checking # Required for plotting later self.data_copy = self.data.copy(deep=True) # Check if TIME_CTD exists self.time_col = "TIME_CTD" if self.time_col not in self.data: self.log("TIME_CTD cound not be found. Defaulting to TIME instead.") self.time_col = "TIME" # Filter user-specified flags self.filter_qc() # Correct conductivity-temperature response time misalignment (C-T Lag) self.correct_ct_lag() # Correct thermal mass error self.correct_thermal_lag() self.reconstruct_data() self.update_qc() if self.diagnostics: self.generate_diagnostics() self.context["data"] = self.data return self.context
[docs] def correct_ct_lag(self): """ Align conductivity to temperature to suppress salinity spikes. Conductivity and temperature are measured by separate sensors whose physical offset and differing response times leave the two records slightly out of phase, producing salinity spikes at sharp gradients. For a random sample of up to 100 qualifying profiles, the optimal time shift of ``CNDC`` relative to ``TEMP`` is found by minimising the standard deviation of (salinity - running-average salinity) — the approach used by RBR's pyRSKtools — rather than comparing downcast against upcast as originally described by Woo (2019). The median of the per-profile lags is then applied to ``CNDC`` across the whole dataset. Trial lags run from -2 s to +2 s in 0.1 s steps. Only profiles longer than one hour with more than ``3 * filter_window_size`` samples contribute to the median. Notes ----- Operates in place on ``self.data``. ``self.ct_lag_median`` is stored for the diagnostics dashboard. """ profile_numbers = np.unique( self.data["PROFILE_NUMBER"].dropna(dim="N_MEASUREMENTS").values ) # Making a place to store intermediate products. Column dimensions: (profile number, time lag) self.per_profile_optimal_lag = np.full((len(profile_numbers), 2), np.nan) self._ct_cost_data = None prof_arr = self.data["PROFILE_NUMBER"].values # Randomly permute to ensure uniform sampling across the dataset indices = np.random.permutation(len(profile_numbers)) processed_count = 0 max_profiles = 100 filter_size = self.filter_window_size # Only a random sample of up to ``max_profiles`` profiles is processed to estimate # the median lag. Cheaply pre-scan (no salinity/interpolation, just per-profile time # span and count) to find how many profiles qualify, so the bar total matches what # will actually be processed and reaches 100%. time_arr = self.data[self.time_col].values finite = ~pd.isnull(time_arr) & ~pd.isnull(prof_arr) grouped_times = pd.Series(time_arr[finite]).groupby(prof_arr[finite]) durations = grouped_times.max() - grouped_times.min() counts = grouped_times.count() qualifying = (durations >= pd.Timedelta(hours=1)) & (counts > 3 * filter_size) n_to_process = min(max_profiles, int(qualifying.sum())) pbar = tqdm( total=n_to_process, colour="green", desc="\033[97mCT Lag Progress\033[0m", unit="prof", ) # Loop through all good profiles and store the optimal C-T lag for each. for i in indices: if processed_count >= max_profiles: break profile_number = profile_numbers[i] prof_indices = np.where(prof_arr == profile_number)[0] if len(prof_indices) == 0: continue profile = self.data.isel(N_MEASUREMENTS=prof_indices) valid_times = profile[self.time_col].dropna(dim="N_MEASUREMENTS") if len(valid_times) > 0: duration = valid_times.values[-1] - valid_times.values[0] if duration >= np.timedelta64(1, "h") and len(valid_times) > 3 * filter_size: if getattr(self, "diagnostics", False) and self._ct_cost_data is None: optimal_lag, cost_data = compute_optimal_lag( profile, filter_size, self.time_col, return_cost_data=True ) self._ct_cost_data = cost_data else: optimal_lag = compute_optimal_lag( profile, filter_size, self.time_col ) self.per_profile_optimal_lag[i, :] = [profile_number, optimal_lag] processed_count += 1 pbar.update(1) pbar.close() # Apply shifts valid_data_mask = ( self.data["CNDC"].notnull() & self.data[self.time_col].notnull() ) if not np.any(valid_data_mask): self.log("No valid CNDC data found. Skipping CT lag correction.") return lags = self.per_profile_optimal_lag[ ~np.isnan(self.per_profile_optimal_lag[:, 1]), 1 ] self.ct_lag_median = np.median(lags) if len(lags) > 0 else 0.0 data_subset = self.data[[self.time_col, "CNDC"]].where(valid_data_mask, drop=True) # Find the elapsed time in seconds t0 = data_subset[self.time_col].values[0] data_subset["ELAPSED_TIME[s]"] = ( data_subset[self.time_col] - t0 ).dt.total_seconds() CNDC_from_TIME = interpolate.interp1d( data_subset["ELAPSED_TIME[s]"].values, data_subset["CNDC"].values, bounds_error=False, ) shifted_time = data_subset["ELAPSED_TIME[s]"].values + self.ct_lag_median data_subset["CNDC"].values = CNDC_from_TIME(shifted_time) # Reinsert the time-shifted data back into self.data self.data["CNDC"][valid_data_mask] = data_subset["CNDC"]
[docs] def correct_thermal_lag(self): """ Correct the thermal-mass error in temperature. The conductivity cell stores and releases heat, so the temperature of the water inside it lags the ambient temperature and biases the derived salinity. This reconstructs the in-cell temperature for each profile using the recursive filter of Morison et al. (1994) (their eq. 5), which does not require the sensitivity of temperature to conductivity (their eq. 2). The amplitude (``alpha``) and time-constant (``tau``) coefficients are the fixed values of Morison et al. (1994), scaled by the conductivity-cell flow rate reported by Woo (2019); they are not re-optimised in T/S space (cf. Garau et al., 2011). Temperature is resampled to 1 Hz for the filter and interpolated back onto the original sampling. Notes ----- Operates in place on ``self.data``. """ corrected_temp_array = np.full(len(self.data["TEMP"]), np.nan) profile_numbers = np.unique( self.data["PROFILE_NUMBER"].dropna(dim="N_MEASUREMENTS").values ) self.filter_params = {} self._thermal_scatter_data = None for prof in tqdm( profile_numbers, colour="blue", desc="\033[97mThermal Lag Progress\033[0m", unit="prof", ): mask = self.data["PROFILE_NUMBER"] == prof nan_mask = self.data["TEMP"].isnull() | ~mask data_subset = self.data[[self.time_col, "TEMP", "PRES"]].where( ~nan_mask, drop=True ) if len(data_subset[self.time_col]) < 5: continue # Find the elapsed time in seconds t0 = data_subset[self.time_col].values[0] data_subset["ELAPSED_TIME[s]"] = ( data_subset[self.time_col] - t0 ).dt.total_seconds() # Define a function that can estimate TEMP at any time point TEMP_from_TIME = interpolate.interp1d( data_subset["ELAPSED_TIME[s]"], data_subset["TEMP"], bounds_error=False, fill_value="extrapolate", ) # Resample the data onto a 1Hz sample rate timeseries TIME_1Hz_sampling = np.arange(0, data_subset["ELAPSED_TIME[s]"].values[-1], 1) if len(TIME_1Hz_sampling) < 2: continue TEMP_1Hz_sampling = TEMP_from_TIME(TIME_1Hz_sampling) n_resamples = len(TEMP_1Hz_sampling) # Set up the recursive filter defined in "CTD dynamic performance and corrections through gradients" # Tau and alpha are the fixed coefficients of Morison94 for unpumped cell. # alpha: initial amplitude of the temperature error for a unit step change in ambient temperature [without unit]. alpha_offset = 0.0135 alpha_slope = 0.0264 # tau = beta^-1: time constant of the error, the e-folding time of the temperature error [s]. tau_offset = 7.1499 tau_slope = 2.7858 # flow_rate: The flow rate in the conductivity cell from Woo (2019). flow_rate = 0.4867 tau = tau_offset + tau_slope / np.sqrt(flow_rate) alpha = alpha_offset + alpha_slope / flow_rate self.filter_params = {"alpha": alpha, "tau": tau} # Set the filter coefficients nyquist_frequency = ( 1 / 2 ) # Nyquist frequency for 1 Hz sampling (= sample frequency / 2) a = 4 * nyquist_frequency * alpha * tau / (1 + 4 * nyquist_frequency * tau) b = 1 - (2 * a / alpha) # Apply the filter TEMP_correction = np.full(n_resamples, 0.0) for i in range(1, n_resamples): TEMP_correction[i] = -b * TEMP_correction[i - 1] + a * ( TEMP_1Hz_sampling[i] - TEMP_1Hz_sampling[i - 1] ) corrected_TEMP_1Hz_sampling = TEMP_1Hz_sampling - TEMP_correction # Resample the TEMP back onto the original time sampling corrected_TEMP_from_TIME = interpolate.interp1d( TIME_1Hz_sampling, corrected_TEMP_1Hz_sampling, bounds_error=False, fill_value="extrapolate", ) data_subset["TEMP"][:] = corrected_TEMP_from_TIME( data_subset["ELAPSED_TIME[s]"] ) # Store adjusted data indices = np.where(~nan_mask)[0] corrected_temp_array[indices] = data_subset["TEMP"].values if ( getattr(self, "diagnostics", False) and self._thermal_scatter_data is None and TIME_1Hz_sampling[-1] >= 3600 and np.nanmax(TEMP_1Hz_sampling) - np.nanmin(TEMP_1Hz_sampling) >= 1.0 ): self._thermal_scatter_data = { "dT_dt": np.gradient(TEMP_1Hz_sampling, TIME_1Hz_sampling), "correction": TEMP_correction, } # Reinsert the corrected data back into self.data final_temp = np.where( np.isnan(corrected_temp_array), self.data["TEMP"].values, corrected_temp_array ) self.data["TEMP"][:] = final_temp
[docs] def generate_diagnostics(self): """ Displays a comprehensive diagnostics dashboard detailing applied adjustments to conductivity and temperature, along with overall impacts on the dataset. """ mpl.use("tkagg") # --- Friendly Configuration Variables --- FIG_SIZE = (12, 7) DPI = 120 # Colours COLOUR_CORR_T = "darkred" COLOUR_CORR_C = "darkblue" COLOUR_BEST = "darkorange" COLOUR_SMOOTH = "dimgrey" COLOUR_SCATTER = "tab:purple" COLOUR_COMBINED = "teal" # Text Styles TITLE_SIZE = 9 LABEL_SIZE = 8 # --- Data Preparation --- prof_arr = self.data["PROFILE_NUMBER"].values unique_profs = np.unique(prof_arr[~pd.isnull(prof_arr)]) plot_qc_mask = xr.ones_like(self.data_copy["PROFILE_NUMBER"], dtype=bool) for var in ["TEMP", "CNDC", "PRES", "DEPTH", self.time_col]: qc_col = f"{var}_QC" if qc_col in self.data_copy.data_vars: plot_qc_mask = plot_qc_mask & ~self.data_copy[qc_col].isin([3, 4, 9]) valid_lags = self.per_profile_optimal_lag[ ~np.isnan(self.per_profile_optimal_lag[:, 1]) ] processed_profs = valid_lags[:, 0] if len(processed_profs) > 0: sample_prof = processed_profs[len(processed_profs) // 2] else: sample_prof = unique_profs[0] if len(unique_profs) > 0 else np.nan # --- Main Figure Setup --- fig = plt.figure(figsize=FIG_SIZE, dpi=DPI, constrained_layout=True) gs = fig.add_gridspec(2, 3) ax_lag = fig.add_subplot(gs[0, 0:2]) ax_cost = fig.add_subplot(gs[0, 2]) ax_scatter = fig.add_subplot(gs[1, 0]) ax_sal = fig.add_subplot(gs[1, 1]) ax_diff = fig.add_subplot(gs[1, 2]) # (1) Row 1, Col 1-2: Applied Lag Distribution over Profile Index ax_lag.axhline(0, color="black", linestyle="-", lw=1.2, alpha=0.8, zorder=1) profs_subset = self.per_profile_optimal_lag[:, 0] lags_subset = self.per_profile_optimal_lag[:, 1] valid_indices = ~np.isnan(lags_subset) if np.any(valid_indices): profs_plot = profs_subset[valid_indices] lags_plot = lags_subset[valid_indices] label_text = f"Combined (median: {self.ct_lag_median:.2f}s)" ax_lag.scatter( profs_plot, lags_plot, c=COLOUR_COMBINED, s=12, alpha=0.6, label=label_text, zorder=2, ) ax_lag.axhline( self.ct_lag_median, color=COLOUR_COMBINED, linestyle="--", lw=1.5, zorder=3 ) ax_lag.set_title("Dataset Lag Distribution by Profile", fontsize=TITLE_SIZE) ax_lag.set_xlabel("Profile Number", fontsize=LABEL_SIZE) ax_lag.set_ylabel("Optimal Lag (s)", fontsize=LABEL_SIZE) ax_lag.tick_params(axis="both", labelsize=LABEL_SIZE) ax_lag.grid(True, alpha=0.2) ax_lag.legend(fontsize=7) # (2) Row 1, Col 3: CT Lag Cost Curve if self._ct_cost_data: c = self._ct_cost_data ax_cost.plot(c["lags"], c["costs"], "o-", color=COLOUR_SMOOTH, lw=1, ms=3) ax_cost.axvline( c["best_lag"], color=COLOUR_BEST, ls="--", label=f"Best: {c['best_lag']:.2f}s" ) ax_cost.set_xlabel("Trial Lag (s)", fontsize=LABEL_SIZE) ax_cost.set_ylabel("std(PSAL - smooth)", fontsize=LABEL_SIZE) ax_cost.set_title( f"Optimal CT Lag Search (Profile {sample_prof:.0f})", fontsize=TITLE_SIZE ) ax_cost.tick_params(axis="both", labelsize=LABEL_SIZE) ax_cost.legend(fontsize=7) ax_cost.grid(True, alpha=0.2) # (3) Row 2, Col 1: Thermal Scatter & Parameters Legend if self._thermal_scatter_data: ts = self._thermal_scatter_data finite = np.isfinite(ts["correction"]) & np.isfinite(ts["dT_dt"]) ax_scatter.scatter( ts["dT_dt"][finite], ts["correction"][finite], s=4, alpha=0.3, color=COLOUR_SCATTER, ) ax_scatter.set_xlabel("dT/dt (°C/s)", fontsize=LABEL_SIZE) ax_scatter.set_ylabel("Corr Amplitude (°C)", fontsize=LABEL_SIZE) ax_scatter.set_title( f"Thermal Mass Verification (Profile {sample_prof:.0f})", fontsize=TITLE_SIZE ) ax_scatter.tick_params(axis="both", labelsize=LABEL_SIZE) ax_scatter.grid(True, alpha=0.2) alpha_val = self.filter_params.get("alpha", np.nan) tau_val = self.filter_params.get("tau", np.nan) param_text = f"Flow Velocity: ~0.49 m/s\nAlpha (α): {alpha_val:.4f}\nTau (τ): {tau_val:.2f} s" ax_scatter.text( 0.05, 0.95, param_text, transform=ax_scatter.transAxes, fontsize=7, verticalalignment="top", bbox=dict(boxstyle="round", facecolor="white", alpha=0.8, edgecolor="#ccc"), ) # (4) Row 2, Col 2: Combined Salinity Profiles mask_range = self.data_copy["PROFILE_NUMBER"].isin(processed_profs) uncorr = self.data_copy.where(mask_range & plot_qc_mask, drop=True) corr = self.data.where(mask_range & plot_qc_mask, drop=True) if len(uncorr["DEPTH"].dropna(dim="N_MEASUREMENTS")) > 0: c_raw = uncorr["CNDC"].values c_new = corr["CNDC"].values c_raw = c_raw * 10 if np.nanmax(c_raw) < 10 else c_raw c_new = c_new * 10 if np.nanmax(c_new) < 10 else c_new p_raw = gsw.conversions.SP_from_C( c_raw, uncorr["TEMP"].values, uncorr["PRES"].values ) p_new = gsw.conversions.SP_from_C( c_new, corr["TEMP"].values, uncorr["PRES"].values ) ax_sal.plot( p_raw, uncorr["DEPTH"].values, c="grey", ls="", marker=".", ms=1, alpha=0.3, label="Raw", ) sal_legend = [ mlines.Line2D( [], [], color="grey", marker=".", ls="", markersize=4, label="Raw (All)" ) ] ax_sal.plot( p_new, uncorr["DEPTH"].values, c=COLOUR_COMBINED, ls="", marker=".", ms=1.5, alpha=0.7, ) sal_legend.append( mlines.Line2D( [], [], color=COLOUR_COMBINED, marker=".", ls="", markersize=4, label="Corr Combined", ) ) ax_sal.set_title("Combined Result", fontsize=TITLE_SIZE) ax_sal.set_xlabel("Practical Salinity", fontsize=LABEL_SIZE) ax_sal.set_ylabel("Depth (m)", fontsize=LABEL_SIZE) ax_sal.tick_params(axis="both", labelsize=LABEL_SIZE) y_min, y_max = ax_sal.get_ylim() if abs(y_max) < abs(y_min): ax_sal.set_ylim(y_min, y_max) else: ax_sal.set_ylim(y_max, y_min) ax_sal.grid(True, alpha=0.2) ax_sal.legend(handles=sal_legend, fontsize=7, loc="lower right") # (5) Row 2, Col 3: Dataset Adjustments Diff Plot t_all = self.data_copy[self.time_col].values valid_t = ( ~np.isnat(t_all) & ~np.isnan(self.data_copy["TEMP"].values) & ~np.isnan(self.data_copy["CNDC"].values) & plot_qc_mask.values ) sub_step = max(1, np.sum(valid_t) // 50000) t_valid = t_all[valid_t][::sub_step] if len(t_valid) > 0: elapsed_days = (t_valid - t_valid[0]) / np.timedelta64(1, "D") temp_raw_all = self.data_copy["TEMP"].values[valid_t][::sub_step] temp_corr_all = self.data["TEMP"].values[valid_t][::sub_step] cndc_raw_all = self.data_copy["CNDC"].values[valid_t][::sub_step] cndc_corr_all = self.data["CNDC"].values[valid_t][::sub_step] cndc_raw_all = cndc_raw_all * 10 if np.nanmax(cndc_raw_all) < 10 else cndc_raw_all cndc_corr_all = ( cndc_corr_all * 10 if np.nanmax(cndc_corr_all) < 10 else cndc_corr_all ) temp_diff = temp_corr_all - temp_raw_all cndc_diff = cndc_corr_all - cndc_raw_all ax_diff_c = ax_diff.twinx() ax_diff.plot( elapsed_days, temp_diff, color=COLOUR_CORR_T, marker=".", ls="", ms=1, alpha=0.4, label="Temp Diff", ) ax_diff_c.plot( elapsed_days, cndc_diff, color=COLOUR_CORR_C, marker=".", ls="", ms=1, alpha=0.4, label="CNDC Diff", ) ax_diff.set_xlabel("Elapsed Time (Days)", fontsize=LABEL_SIZE) ax_diff.set_ylabel("Temp Difference (°C)", fontsize=LABEL_SIZE) ax_diff_c.set_ylabel("CNDC Difference (mS/cm)", fontsize=LABEL_SIZE) ax_diff.set_title("Dataset-Wide Adjustments (Corr - Raw)", fontsize=TITLE_SIZE) ax_diff.tick_params(axis="both", labelsize=LABEL_SIZE) ax_diff_c.tick_params(axis="y", labelsize=LABEL_SIZE) # Scale both axes symmetrically about zero so the two 0-lines coincide t_absmax = np.nanmax(np.abs(temp_diff)) c_absmax = np.nanmax(np.abs(cndc_diff)) if np.isfinite(t_absmax) and t_absmax > 0: ax_diff.set_ylim(-t_absmax * 1.05, t_absmax * 1.05) if np.isfinite(c_absmax) and c_absmax > 0: ax_diff_c.set_ylim(-c_absmax * 1.05, c_absmax * 1.05) lines1, labels1 = ax_diff.get_legend_handles_labels() lines2, labels2 = ax_diff_c.get_legend_handles_labels() leg_handles = [] for line in lines1 + lines2: leg_handles.append( mlines.Line2D([], [], color=line.get_color(), marker=".", ls="", markersize=6) ) ax_diff.legend(leg_handles, labels1 + labels2, loc="best", fontsize=7) ax_diff.grid(True, alpha=0.2) # Final Render fig.suptitle("Salinity Adjustment Diagnostics Dashboard", fontsize=11, fontweight="bold") plt.show(block=True)