Source code for pelagos_py.steps.base_step

# 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.
"""This module defines the base class for pipeline steps and configurations."""

from pelagos_py.utils.config_mirror import ConfigMirrorMixin
from pelagos_py.utils import parameter_spec
from pelagos_py.utils.log_levels import STOP
import warnings
import logging
import os
import time

[docs] REGISTERED_STEPS = {}
"""Registry of explicitly registered step classes."""
[docs] def register_step(cls): """Decorator to mark a step class for inclusion in the pipeline.""" step_name = getattr(cls, "step_name", None) if step_name is None: raise ValueError( f"Class {cls.__name__} is missing required 'step_name' attribute." ) REGISTERED_STEPS[step_name] = cls return cls
[docs] class BaseStep(ConfigMirrorMixin): """ Base class for pipeline steps with config-mirroring support. Every concrete subclass (registered via @register_step) inherits this. """ #: Declarative parameter schema. See :mod:`pelagos_py.utils.parameter_spec`. #: ``None`` means "not declared yet" and validation is skipped (e.g. the #: deferred oxygen steps). Any dict — including an empty ``{}`` for a step that #: genuinely takes no parameters — opts in to strict validation. parameter_schema = None #: Parameter keys consumed by framework mixins rather than the schema, and so #: permitted in a step's config even when absent from ``parameter_schema`` #: (e.g. ``qc_handling_settings`` handled by :class:`QCHandlingMixin`). framework_parameters = {"qc_handling_settings"} def __init__(self, name, parameters=None, diagnostics=False, context=None): # === Core behaviour (same as before) === self.name = name self.parameters = parameters or {} self.diagnostics = diagnostics self.context = context or {} # Resolve the declared parameter schema: fill in defaults for any omitted # optional parameters and raise on missing required ones. This is the single # source of parameter defaults — steps read e.g. ``self.velocity_threshold`` # directly, never ``getattr(self, ..., <inline default>)``. # A step with a declared schema opts in to strict validation (defaults + # required + reject-unknown). A step that has not declared one yet # (``parameter_schema is None``, e.g. the deferred oxygen steps) skips # validation, so its parameters pass through untouched until it is migrated. if self.parameter_schema is not None: resolved = parameter_spec.resolve( self.parameter_schema, self.parameters, label=self.name, allowed_extra=self.framework_parameters, ) for key, value in resolved.items(): self.parameters.setdefault(key, value) # Get child logger initialized in pipeline.py self.logger = logging.getLogger(f"pelagos_py.pipeline.step.{self.name}") # === Initialise config mirror system === self._init_config_mirror() # canonical parameters go in private store self._parameters = { "name": self.name, "parameters": self.parameters, "diagnostics": self.diagnostics, } # mirror parameters & diagnostics as attributes self._reset_parameter_bridge(mirror_keys=["parameters", "diagnostics"]) # expose param keys as attributes (for user convenience) for key, value in self.parameters.items(): setattr(self, key, value) # Stop the diagnostics timer as soon as diagnostics generation begins, so # the reported time/RAM cover the processing only (not a blocking plot). self._wrap_diagnostics_timing() # Continue method resolution order super().__init__() @classmethod
[docs] def describe_parameters(cls): """Return a JSON-serialisable description of this step's parameters. Introspection surface for external tools (e.g. a dashboard) that need to render a parameter form without instantiating the step. See :func:`pelagos_py.utils.parameter_spec.describe`. """ return parameter_spec.describe(cls.parameter_schema or {})
def run(self): """ Run the step and return the updated pipeline context. Subclasses override this with their processing logic; the base implementation only enforces that they do so. :meta private: """ raise NotImplementedError(f"Step '{self.name}' must implement a run() method.") return self.context def generate_diagnostics(self): """ Optional hook for emitting step diagnostics. Subclasses override this to log or plot information about their output; the base implementation does nothing. :meta private: """ pass def _wrap_diagnostics_timing(self): """ Wrap the step's diagnostics method so the performance timer stops the moment diagnostics generation begins. Steps call ``self.generate_diagnostics()`` (or ``plot_diagnostics()``) as the final action of :meth:`run`, usually a blocking plot. Wrapping it here means the pipeline-reported time/RAM cover the processing only, without every individual step needing to call :meth:`report_performance` itself. :meta private: """ import functools for attr in ("generate_diagnostics", "plot_diagnostics"): method = getattr(self, attr, None) if not callable(method): continue @functools.wraps(method) def wrapped(*args, _method=method, **kwargs): self.report_performance() return _method(*args, **kwargs) setattr(self, attr, wrapped) def report_performance(self): """ Log the step's processing time and memory use (diagnostics mode only). The pipeline records a start time before :meth:`run` and calls this once afterwards. :meth:`_wrap_diagnostics_timing` also triggers it the moment a step's diagnostics method is entered, so the reported time reflects the processing work only and not how long a blocking plot is left open. The call is idempotent: the first call reports, any later call (including the pipeline's fallback) is a no-op. :meta private: """ start = getattr(self, "_diagnostics_start", None) if start is None or getattr(self, "_diagnostics_reported", False): return self._diagnostics_reported = True self.log(f"Execution time: {time.time() - start:.2f} seconds.") try: import psutil mem_info = psutil.Process(os.getpid()).memory_info() self.log(f"Current memory usage: {mem_info.rss / 1024 ** 2:.2f} MB") except ImportError: pass
[docs] def log(self, message): """Log an info-level message with step name prefix.""" self.logger.info("[%s] %s", self.name, message)
[docs] def log_warn(self, message, warning_type=UserWarning): """Log a warning-level message with step name prefix.""" self.logger.warning("[%s] %s", self.name, message)
[docs] def check_data(self): """Check for data in context for transformer steps.""" if "data" not in self.context: self.logger.error( "[%s] No data found in context. Please load data first.", self.name ) self.logger.log( STOP, "Pipeline stopped at step '%s'. " "Add a data-loading step before it and re-run.", self.name, ) raise SystemExit(1)
# ----------- Config Handling -----------
[docs] def update_parameters(self, **kwargs): """ Update parameter values both in attributes and in private store. Example:: self.update_parameters(file_path='newfile.nc', add_meta=False) """ for k, v in kwargs.items(): self.parameters[k] = v setattr(self, k, v) self._parameters["parameters"] = self.parameters
[docs] def generate_config(self): """Return this step's config dict (suitable for saving to YAML).""" self._sync_attributes_to_parameters() return dict(self._parameters)
[docs] def save_config(self, path: str | None = None): """Save this step's config to YAML (for standalone debugging).""" import yaml, os cfg = self.generate_config() if path is None: safe_name = self.name.replace(" ", "_").lower() path = f"{safe_name}_step.yaml" os.makedirs(os.path.dirname(path) or ".", exist_ok=True) with open(path, "w") as f: yaml.safe_dump(cfg, f, sort_keys=False) print(f"[{self.name}] Step config saved → {path}") return cfg