Source code for pycsamt.pipeline._base

"""Base class for pyCSAMT pipeline objects.

:class:`PipelineBase` is the common ancestor for :class:`~.Pipeline` (and any
future pipeline variant).  It provides three groups of functionality that are
domain-specific to the pipeline subsystem and would not fit naturally into
:class:`~pycsamt.api.property.PyCSAMTObject` (which targets lightweight
config/style objects).

Why not inherit PyCSAMTObject?
-------------------------------
``PyCSAMTObject`` gives generic ``__repr__``, ``to_dict``, and ``summary``
built for dataclass-style config holders.  ``Pipeline`` needs a specialized
scikit-learn-style repr and its own serialization contract (``to_yaml``,
``to_json``, ``to_py``).  Inheriting from two incompatible repr contracts
creates confusion; a clean separation is better.

Groups provided
---------------
1. **Registry access** (static / class methods)
   Explore what steps are available without constructing a pipeline.

2. **Export** (instance methods)
   ``to_py(path)`` — new format that produces an editable Python script
   (YAML and JSON are on :class:`~.Pipeline` itself).

3. **Scaffold** (class method)
   ``scaffold(path, fmt, preset, name)`` — generate a ready-to-edit
   starter config in YAML, JSON, or Python.
"""

from __future__ import annotations

import json
import textwrap
from pathlib import Path
from typing import Any

from ._registry import (
    STEP_REGISTRY,
    categories,
    list_steps,
    lookup_step,
)

# ── formatting helpers ────────────────────────────────────────────────────────

_SEP = "─" * 68
_SEP2 = "═" * 68


def _defaults_str(defaults: dict) -> str:
    if not defaults:
        return ""
    return "  " + "  ".join(f"{k}={v!r}" for k, v in defaults.items())


def _params_repr(params: dict) -> str:
    """Python repr for a params dict, suitable for embedding in source."""
    if not params:
        return ""
    inner = ", ".join(f"{k}={v!r}" for k, v in params.items())
    return f"dict({inner})"


# ── base class ────────────────────────────────────────────────────────────────


[docs] class PipelineBase: """Base class for pyCSAMT pipeline objects. Inherit from this to gain registry introspection, ``to_py`` export, and ``scaffold`` template generation. :class:`~.Pipeline` inherits this. """ # ========================================================================= # 1. Registry access — static / class methods # =========================================================================
[docs] @staticmethod def available_steps(category: str | None = None) -> list: """Return all registered :class:`~._registry.StepSpec` objects. Parameters ---------- category: When supplied, restrict to steps in that category (e.g. ``"noise_removal"``, ``"frequency"``). Examples -------- >>> Pipeline.available_steps() >>> Pipeline.available_steps("static_shift") """ return list_steps(category)
[docs] @staticmethod def available_categories() -> list[str]: """Return a sorted list of step category names. Examples -------- >>> Pipeline.available_categories() ['dimensionality', 'frequency', 'noise_removal', ...] """ return categories()
[docs] @staticmethod def step_info(code_or_name: str) -> str: """Return a formatted info block for a single step. Parameters ---------- code_or_name: Registry code (``"NR001"``) or snake-case name (``"notch_powerline"``). Examples -------- >>> print(Pipeline.step_info("NR001")) >>> print(Pipeline.step_info("correct_ss_ama")) """ spec = lookup_step(code_or_name) qc_names = ( [fn_name for _, fn_name in spec.qc_defs] if spec.qc_defs else [] ) defs_str = ( " ".join(f"{k}={v!r}" for k, v in spec.defaults.items()) if spec.defaults else "—" ) qc_str = ", ".join(qc_names) if qc_names else "—" fn_path = ( f"{spec.mod}.{spec.fn_name}" if spec.mod and spec.fn_name else "(built-in wrapper)" ) lines = [ _SEP, f" {spec.code} {spec.label} [{spec.category}]", _SEP, f" name : {spec.name}", f" function : {fn_path}", f" defaults : {defs_str}", f" qc plots : {qc_str}", f" returns : {'Sites (transform)' if spec.returns_sites else 'Sites unchanged (diagnostic)'}", "", " Usage:", f' Step("{spec.code}") # defaults', ] if spec.defaults: first_k, first_v = next(iter(spec.defaults.items())) lines.append( f' Step("{spec.code}", {first_k}={first_v!r}) # override' ) lines.append(f' Step("{spec.name}") # by name') lines.append(_SEP) return "\n".join(lines)
[docs] @classmethod def catalogue(cls, category: str | None = None) -> str: """Return a full formatted catalogue of all available steps. Parameters ---------- category: Restrict output to one category, or ``None`` for all. Examples -------- >>> print(Pipeline.catalogue()) >>> print(Pipeline.catalogue("tensor")) """ cats = [category] if category else categories() lines = [] total = len(STEP_REGISTRY) n_cats = len(categories()) header = ( f" {total} steps across {n_cats} categories" if category is None else f" Category: {category}" ) lines += [_SEP2, " Available Pipeline Steps", header, _SEP2, ""] col_code = 8 col_name = 22 col_label = 34 for cat in cats: specs = list_steps(cat) if not specs: continue lines.append( f" {cat.upper()}{len(specs)} step{'s' if len(specs) != 1 else ''}" ) lines.append(" " + "─" * 64) for sp in specs: dstr = _defaults_str(sp.defaults) diag = " ★ diagnostic" if not sp.returns_sites else "" lines.append( f" {sp.code:<{col_code}} " f"{sp.name:<{col_name}} " f"{sp.label:<{col_label}}" f"{dstr}{diag}" ) lines.append("") return "\n".join(lines)
# ========================================================================= # 2. Export — to_py (instance method; to_yaml / to_json stay on Pipeline) # =========================================================================
[docs] def to_py(self, path: str | Path | None = None) -> str: """Serialize this pipeline to a Python config script. Produces a ``pipeline_config`` dict in a clean, readable ``.py`` file that can be edited and reloaded with :meth:`~.Pipeline.from_py`. Steps are grouped by category and commented with their human-readable labels. Parameters ---------- path: If given, write the output to this file path. Returns ------- str The generated Python source as a string (always returned, whether or not *path* is provided). Examples -------- >>> src = pipe.to_py() >>> print(src) >>> pipe.to_py("config/my_workflow.py") """ name = getattr(self, "name", "pipeline") outdir = getattr(self, "_output_dir", None) or "pipe_results" steps = list(getattr(self, "_steps", [])) # Group steps by category for comments lines = [ f"# pyCSAMT Pipeline: {name}", "# Generated by pycsamt v2 — Pipeline.to_py()", "# Reload : pipe = Pipeline.from_py(__file__)", "# Run : result = pipe.run(sites)", "", "pipeline_config = dict(", f' name = "{name}",', f' output_dir = "{outdir}",', " steps = [", ] current_cat: str | None = None for label, step in steps: cat = step.spec.category if cat != current_cat: lines.append( f" # ── {cat} {'─' * max(0, 56 - len(cat))}" ) current_cat = cat params_part = ( f", params={_params_repr(step.params)}" if step.params else "" ) lines.append( f' dict(name={label!r:<22}, code="{step.spec.code}"' f"{params_part})," ) lines += [ " ],", ")", ] src = "\n".join(lines) + "\n" if path is not None: p = Path(path) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(src, encoding="utf-8") return src
# ========================================================================= # 3. Scaffold — generate an editable starter config # =========================================================================
[docs] @classmethod def scaffold( cls, path: str | Path | None = None, *, fmt: str = "yaml", preset: str | None = None, name: str = "my_workflow", outdir: str = "pipe_results", ) -> str: """Generate a ready-to-edit starter pipeline config. The scaffold contains: - Active steps from *preset* (or a sensible default set) - All other available steps commented out with descriptions Parameters ---------- path: Write the output to this file. Extension is inferred from *fmt* if the path has none. fmt: Output format: ``"yaml"`` (default), ``"json"``, or ``"py"``. preset: Name of a built-in preset to use as the active step set (e.g. ``"basic_qc"``, ``"full_processing"``). When ``None`` a minimal ``basic_qc``-style set is used. name: Pipeline name written into the config. outdir: Default output directory written into the config. Returns ------- str The generated config content as a string. Examples -------- >>> print(Pipeline.scaffold()) >>> Pipeline.scaffold("config/starter.yaml", preset="full_processing") >>> Pipeline.scaffold("config/starter.py", fmt="py") """ fmt = fmt.lower() if fmt not in {"yaml", "json", "py"}: raise ValueError( f"fmt must be 'yaml', 'json', or 'py'. Got {fmt!r}" ) # Resolve active steps from preset or default active_steps = _resolve_scaffold_steps(preset) if fmt == "yaml": content = _scaffold_yaml(name, outdir, active_steps) elif fmt == "json": content = _scaffold_json(name, outdir, active_steps) else: content = _scaffold_py(name, outdir, active_steps, preset) if path is not None: p = Path(path) if not p.suffix: p = p.with_suffix(f".{fmt}") p.parent.mkdir(parents=True, exist_ok=True) p.write_text(content, encoding="utf-8") return content
# ── internal scaffold helpers ───────────────────────────────────────────────── def _resolve_scaffold_steps(preset_name: str | None) -> list: """Return (label, StepSpec, params) triples for the scaffold active steps.""" if preset_name is not None: from ._presets import get_preset preset = get_preset(preset_name) return [(lbl, step.spec, step.params) for lbl, step in preset.steps] # minimal default: FREQ002 → FREQ001 → FREQ004 → NR001 → SS001 → TZ001 → QC001 defaults = [ ("drop_dup", "FREQ002", {}), ("select_band", "FREQ001", {"band_hz": (1e-3, 1e4)}), ("align", "FREQ004", {}), ("notch", "NR001", {"mains_hz": 50}), ("correct_ss", "SS001", {}), ("rotate", "TZ001", {"method": "swift"}), ("qc", "QC001", {}), ] return [ (lbl, lookup_step(code), params) for lbl, code, params in defaults ] def _step_to_dict(label: str, spec: Any, params: dict) -> dict: d: dict = {"name": label, "code": spec.code} if params: # convert tuples → lists for JSON/YAML safety safe_params = { k: list(v) if isinstance(v, tuple) else v for k, v in params.items() } d["params"] = safe_params return d def _scaffold_yaml(name: str, outdir: str, active: list) -> str: """Generate the YAML scaffold.""" try: import yaml as _yaml except ImportError: _yaml = None # type: ignore[assignment] active_codes = {spec.code for _, spec, _ in active} # Build header header = textwrap.dedent(f"""\ # pyCSAMT Pipeline Configuration # Generated by: Pipeline.scaffold("{name}.yaml") # ───────────────────────────────────────────────────────────── # Usage : # pipe = Pipeline.from_yaml("this_file.yaml") # result = pipe.run(sites) # Help : Pipeline.catalogue() | Pipeline.step_info("NR001") # ───────────────────────────────────────────────────────────── name: {name} output_dir: {outdir} steps: """) step_lines: list[str] = [] for cat in categories(): specs = list_steps(cat) step_lines.append(f" # ── {cat} ({'─' * max(0, 56 - len(cat))})") for sp in specs: _step_to_dict("", sp, sp.defaults) entry = f" - {{name: {sp.name}, code: {sp.code}" if sp.defaults: defs_yaml = ", ".join( f"{k}: {list(v) if isinstance(v, tuple) else v!r}" for k, v in sp.defaults.items() ) entry += f", params: {{{defs_yaml}}}" entry += f"}} # {sp.label}" if sp.code in active_codes: step_lines.append(entry) else: step_lines.append(f" # {entry.lstrip()}") step_lines.append("") return header + "\n".join(step_lines) def _scaffold_json(name: str, outdir: str, active: list) -> str: """Generate the JSON scaffold.""" data = { "_comment": ( f"pyCSAMT Pipeline — {name}. Load: Pipeline.from_json(path)" ), "name": name, "output_dir": outdir, "steps": [ _step_to_dict(lbl, spec, params) for lbl, spec, params in active ], "_available_steps_hint": ( "Run Pipeline.catalogue() to see all 33 available steps." ), } return json.dumps(data, indent=2) def _scaffold_py( name: str, outdir: str, active: list, preset_name: str | None, ) -> str: """Generate the Python scaffold.""" subtitle = ( f"Preset: {preset_name}" if preset_name else f"Pipeline: {name}" ) header = textwrap.dedent(f"""\ # pyCSAMT Pipeline Configuration # {subtitle} # ───────────────────────────────────────────────────────────── # Reload : pipe = Pipeline.from_py(__file__) # Run : result = pipe.run(sites) # Help : Pipeline.catalogue() | Pipeline.step_info("NR001") # ───────────────────────────────────────────────────────────── """) active_codes = {spec.code for _, spec, _ in active} body_lines: list[str] = [ "pipeline_config = dict(", f' name = "{name}",', f' output_dir = "{outdir}",', " steps = [", ] current_cat: str | None = None for cat in categories(): specs = list_steps(cat) for sp in specs: if cat != current_cat: body_lines.append( f" # ── {cat} {'─' * max(0, 52 - len(cat))}" ) current_cat = cat use_params = sp.defaults params_part = ( f", params={_params_repr(use_params)}" if use_params else "" ) entry = ( f' dict(name={sp.name!r:<24}, code="{sp.code}"{params_part}),' f" # {sp.label}" ) if sp.code in active_codes: body_lines.append(entry) else: body_lines.append(f" # {entry.strip()}") body_lines += [ " ],", ")", ] return header + "\n".join(body_lines) + "\n"