Source code for pelagos_py.steps.processing.bbp

# 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 deriving particulate backscatter (BBP) from beta and isolating BBP spikes."""

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

#### Custom imports ####
import xarray as xr
import matplotlib.pyplot as plt
import matplotlib as mpl
import numpy as np
import glidertools as gt
from pelagos_py.utils import fig_spec


@register_step
[docs] class BBPFromBeta(BaseStep, QCHandlingMixin): step_name = "BBP from Beta" required_variables = ["TIME", "DEPTH", "TEMP", "PRAC_SALINITY"] provided_variables = [] # PROFILE_NUMBER is read in run() (data_subset) but not declared required above # (pre-existing; left as-is to avoid changing pipeline-validation behaviour). optional_variables = ["PROFILE_NUMBER"] variable_parameters = ["apply_to", "output_as"] uses_data_subset = True parameter_schema = { "apply_to": { "type": str, "default": "BBP700", "description": "Name of the variable to convert.", }, "output_as": { "type": str, "default": "BBP700", "description": "Name for the output variable added to the dataset.", }, "theta": { "type": float, "default": 124, "description": "Effective optical backscatter scattering angle (degrees).", }, "xfactor": { "type": float, "default": 1.076, "description": "Chi factor scaling particulate scattering to total backscatter.", }, }
[docs] def run(self): """ Example ------- :: - name: "BBP from Beta" parameters: apply_to: "BBP700" output_as: "BBP700" theta: 124 xfactor: 1.076 diagnostics: false Returns ------- """ self.filter_qc() # Get the required variables self.data_subset = self.data[ ["TIME", "PROFILE_NUMBER", "DEPTH", "TEMP", "PRAC_SALINITY", self.apply_to] ] # Gaps in TEMP/PRAC_SALINITY are left as NaN: BBP is not derived there and is # flagged missing (9) below. Add an Interpolate Data step first for gap-free BBP. # Apply the correction bbp_corrected = gt.flo_functions.flo_bback_total( self.data_subset[self.apply_to], self.data_subset["TEMP"], self.data_subset["PRAC_SALINITY"], self.theta, 700, self.xfactor, ) # Stitch back into the data self.data[self.output_as] = bbp_corrected self.data[self.output_as].attrs["units"] = "m-1" self.data[self.output_as].attrs["long_name"] = "Total particulate backscatter" self.data[self.output_as].attrs["standard_name"] = self.output_as self.reconstruct_data() self.update_qc() # Generate QC if a new variable is added. Otherwise warn the user that input is being overwritten. if self.apply_to != self.output_as: self.generate_qc({f"{self.output_as}_QC": [f"{self.apply_to}_QC"]}) else: self.log_warn( f"'apply_to' and 'output_as' are the same. This will cause {self.apply_to} to be overwritten." ) if self.diagnostics: self.generate_diagnostics() self.context["data"].update(self.data) return self.context
def generate_diagnostics(self): mpl.use("tkagg") # Clean both datasets beta_clean = remove_outliers(self.data_subset[self.apply_to]) bbp_clean = remove_outliers(self.data[self.output_as]) # Plot plt.figure(figsize=(10, 6)) plt.boxplot( [beta_clean, bbp_clean], vert=True, patch_artist=True, labels=["Beta", "BBP"], ) plt.title("Beta vs BBP") plt.ylabel("Value") plt.grid(True, linestyle="--", alpha=0.6) plt.show(block=True)
@register_step
[docs] class IsolateBBPSpikes(BaseStep, QCHandlingMixin): step_name = "Isolate BBP Spikes" required_variables = ["TIME"] provided_variables = [] variable_parameters = ["apply_to"] uses_data_subset = True parameter_schema = { "apply_to": { "type": str, "default": "BBP700", "description": "Name of the variable to filter.", }, "window_size": { "type": int, "default": 50, "description": "Median/minmax filter window size in samples.", }, "method": { "type": str, "default": "median", "description": "Filter method used to determine the baseline.", }, }
[docs] def run(self): """ Example ------- :: - name: "Isolate BBP Spikes" parameters: apply_to: "BBP700" window_size: 50 method: "median" diagnostics: false Returns ------- """ self.filter_qc() # Flagged samples are left out of the despike so they cannot drag their # neighbours' rolling baseline; they get no baseline and are flagged missing (9) # below. Add an Interpolate Data step first for a gap-free baseline. usable = self.data[self.apply_to].where( self.calculation_mask([self.apply_to]) ) self.baseline, self.spikes = gt.cleaning.despike( usable, self.window_size, spike_method=self.method ) self.data[f"{self.apply_to}_BASELINE"] = self.baseline self.data[f"{self.apply_to}_SPIKES"] = self.spikes self.reconstruct_data() self.update_qc() # Generate QC if a new variable is added. Otherwise warn the user that input is being overwritten. self.generate_qc( { f"{self.apply_to}_BASELINE_QC": [f"{self.apply_to}_QC"], f"{self.apply_to}_SPIKES_QC": [f"{self.apply_to}_QC"], } ) if self.diagnostics: self.generate_diagnostics() self.context["data"].update(self.data) return self.context
def generate_diagnostics(self): mpl.use("tkagg") raw = self.data[self.apply_to] time = self.data["TIME"] fig, axes = fig_spec.new_fig(nrows=2, sharex=True, height_ratios=(2, 1)) ax1, ax2 = axes[0][0], axes[1][0] # Panel 1: raw and baseline time series. ax1.plot(time[~np.isnan(raw)], raw[~np.isnan(raw)], ls="--", color=fig_spec.FLAGGED, label="Raw") ax1.plot(time[~np.isnan(self.baseline)], self.baseline[~np.isnan(self.baseline)], color=fig_spec.CATEGORY[1], alpha=fig_spec.ALPHA, label="Baseline") # Panel 2: isolated spike points. fig_spec.points(ax2, time[~np.isnan(self.spikes)], self.spikes[~np.isnan(self.spikes)], color=fig_spec.CATEGORY[2], label="Spikes") ylabel = fig_spec.axis_label(self.apply_to, self.data[self.apply_to].attrs.get("units")) for ax in (ax1, ax2): fig_spec.date_axis(ax, which="x") fig_spec.legend(ax) fig_spec.style_axes(ax1, ylabel=ylabel) fig_spec.style_axes(ax2, xlabel="Time", ylabel=ylabel) fig_spec.finish(fig, suptitle=f"{self.apply_to}: Baseline Timeseries & Spikes") plt.show(block=True)