Composition¶
The composition primitives are carrier-agnostic and live in
pipekit; the xarray-aware
Operator base and the Dataset combinators are xrops:
xrops.Operator is a thin xarray-aware subclass of pipekit.Operator
that adds xarray.DataTree dispatch to __call__ — every operator in the
library inherits it, so all of them map leaf-wise over a DataTree for
free. The xarray-Dataset-specific combinators (Augment, ApplyToEach)
live in xrops.combinators.
Linear and DAG composition, same operators
from pipekit import Sequential, Graph, Input
from xrops.anomaly import RemoveMean
from xrops.ocn import Streamfunction, GeostrophicVelocities
# Linear pipeline
pipe = Sequential(RemoveMean(dim=("lat", "lon")), GeostrophicVelocities())
out = pipe(ds)
# The same operators wired as a DAG
x = Input("ssh")
psi = Streamfunction()(x)
uv = GeostrophicVelocities()(psi)
graph = Graph(inputs=x, outputs=uv)
Operator base class¶
Operator
¶
Bases: Operator
pipekit.Operator plus xarray DataTree dispatch.
Subclasses implement _apply(carrier, *extra) exactly as they
would against pipekit.Operator — the override only affects
__call__ dispatch, not _apply.
Example
One _apply implementation serves Dataset and
DataTree callers alike — the tree case maps over every leaf:
>>> import numpy as np
>>> import xarray as xr
>>> from xrops import Operator
>>> class Scale(Operator):
... def __init__(self, factor: float) -> None:
... self.factor = factor
... def _apply(self, ds: xr.Dataset) -> xr.Dataset:
... return ds * self.factor
>>> ds = xr.Dataset({"ssh": ("x", np.arange(3.0))})
>>> Scale(10.0)(ds)["ssh"].values
array([ 0., 10., 20.])
>>> tree = xr.DataTree.from_dict({"coarse": ds, "fine": ds * 2})
>>> out = Scale(10.0)(tree)
>>> sorted(out.children), float(out["fine"]["ssh"][2])
(['coarse', 'fine'], 40.0)
Sequential pipelines¶
Sequential
¶
Bases: Operator
Apply a list of operators in order, threading the output of each into the next.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operators
|
list[Operator]
|
A list of |
required |
Raises:
| Type | Description |
|---|---|
TypeError
|
if any element is not an |
Example
Basic::
pipe = Sequential([Scale(0.5), Scale(2.0)])
assert pipe(10.0) == 10.0
Slice into a sub-pipeline::
head = pipe[:1] # Sequential([Scale(0.5)])
Index a single step::
first = pipe[0] # Scale(0.5)
Source code in .venv/lib/python3.12/site-packages/pipekit/_base/sequential.py
compute_output_signature
¶
Thread the signature through every operator in order.
A registered carrier is read as its Signature first.
Source code in .venv/lib/python3.12/site-packages/pipekit/_base/sequential.py
describe
¶
Return an indented-tree pretty-print of the pipeline.
Recurses into nested Sequential / Graph operators. Useful
for notebook exploration; not parsed.
Source code in .venv/lib/python3.12/site-packages/pipekit/_base/sequential.py
summary
¶
Render a Keras-style summary table.
Threads input_signature through compute_output_signature;
a registered carrier is read as its Signature first. If any
operator returns None (does not track shape), the summary
still lists the operator but shows ? for its output
signature. With input_signature=None, only operator names
and types are shown.
The return type is str rather than printing directly so the
caller decides where it goes (notebook, log, file).
Source code in .venv/lib/python3.12/site-packages/pipekit/_base/sequential.py
Functional Graph API¶
Input
¶
Bases: Node
A named entry point into a Graph.
Operator.__call__ recognises Node (and any subclass, including
Input) as graph-construction mode. Graph._apply supplies the
value at run-time from the caller's kwargs / positional args.
Subclassing Node (master plan Report 2.A.3, "xr_toolz shape")
lets the topological sort treat all vertices uniformly with one
special case: "this node has no operator — source it from the
caller's inputs".
Source code in .venv/lib/python3.12/site-packages/pipekit/_base/graph.py
Node
¶
A vertex in a Graph.
Created automatically by Operator.__call__ when any positional
argument is a Node (or Input, which subclasses Node). Carries
the operator and its parent vertices. Compared by identity so the
id(...)-keyed evaluation cache during topological sort works.
Raises:
| Type | Description |
|---|---|
TypeError
|
if any element of |
Source code in .venv/lib/python3.12/site-packages/pipekit/_base/graph.py
Graph
¶
Bases: Operator
A symbolic operator graph with multiple inputs and outputs.
Construction is implicit — calling operators on Input / Node
instances builds the graph; Graph(inputs=..., outputs=...)
wraps the result. _apply(**inputs) evaluates in topological
order.
Inherits from Operator so a Graph satisfies the same interface
as any other operator. Operator.__call__ dispatches keyword
args straight through to Graph._apply; positional args are bound
to declared Inputs in declaration order.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
inputs
|
dict[str, Input]
|
Map of |
required |
outputs
|
dict[str, Node]
|
Map of |
required |
State
A graph that contains a stateful node (a StatefulOperator, or
a Sequential / Graph that contains one) threads a carry state
the way Sequential does: call it as
graph(a=..., b=..., state=s) and it returns
(outputs, state). The state passes through the stateful nodes
in topological order (the order describe lists); stateless
nodes ignore it. Inside a stateful Sequential the graph receives
(carrier, state) positionally, like any stateful step.
Raises:
| Type | Description |
|---|---|
ValueError
|
if the graph contains a cycle, if an |
Source code in .venv/lib/python3.12/site-packages/pipekit/_base/graph.py
get_config
¶
The whole topology: inputs, operators, nodes and outputs.
Every node is recorded, upstream ones included, so two graphs
that differ anywhere have different configs (and so different
state records and content hashes). The record::
{
"inputs": ["image", "reference"],
"operators": [<state>, ...],
"nodes": [{"operator": 0, "parents": [{"input": "image"}]}, ...],
"outputs": {"ndvi": {"node": 0}, "raw": {"input": "image"}},
}
operators lists each distinct operator once, by identity, in
first-use order, so an operator shared by several nodes is not
duplicated. nodes is in topological order; a parent or an
output refers either to a declared input by name or to an
earlier node by index.
Source code in .venv/lib/python3.12/site-packages/pipekit/_base/graph.py
from_config
classmethod
¶
Rebuild a graph from the topology get_config records.
Re-creates each Input, then calls each node's operator on its
parents in the recorded order, which wires the nodes exactly as
they were; an operator shared by several nodes stays one object.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
dict[str, Any]
|
A |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
if a reference names an undeclared input or a node that comes later in the order. |
Source code in .venv/lib/python3.12/site-packages/pipekit/_base/graph.py
compute_output_signature
¶
None: a graph returns a dict of outputs, not one carrier.
Use output_signatures for each output's signature, and
summary for the whole table.
Source code in .venv/lib/python3.12/site-packages/pipekit/_base/graph.py
output_signatures
¶
Each output's signature, propagated node by node without data.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
input_signatures
|
Any
|
|
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
|
dict[str, Any]
|
operator does not track shape, or where a multi-input node's |
dict[str, Any]
|
inputs disagree (see |
Source code in .venv/lib/python3.12/site-packages/pipekit/_base/graph.py
summary
¶
A Keras-style table of the nodes, their input and output signatures.
Nodes are listed in topological order. Signatures propagate
through each node's compute_output_signature; a node with
several inputs gets its operator's signature for them only when
they all agree (an elementwise join), and ? otherwise, since
compute_output_signature takes one input. The outputs follow.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
input_signatures
|
Any
|
As for |
None
|
Source code in .venv/lib/python3.12/site-packages/pipekit/_base/graph.py
describe
¶
Return a short topology description.
Lists inputs, internal nodes (in topological order), and outputs. Useful for notebook exploration.
Source code in .venv/lib/python3.12/site-packages/pipekit/_base/graph.py
Observation taps¶
Tap is a generic pass-through observer from pipekit — it runs a
side-effecting callback (logging, plotting, assertions) without altering
the data flowing through.
Tap
¶
Bases: Operator
Fire a callback on the carrier; return the carrier unchanged.
Distinct from Sink only by naming convention — Tap is for
observation (logging, metrics, breakpoint hooks); Sink is for
persistence. Both pass the carrier through; both carry
forbid_in_yaml = True.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fn
|
Callable[[Any], Any]
|
Callable invoked with the carrier. Its return value is ignored. |
required |
name
|
str | None
|
Optional name for |
None
|
Example::
log = Tap(lambda x: print(getattr(x, "shape", x)), name="log_shape")
pipe = Scale(2.0) | log | AddConst(1.0)
Source code in .venv/lib/python3.12/site-packages/pipekit/observe.py
xarray-Dataset combinators¶
Augment merges an operator's output back into its input Dataset (rather
than replacing it); ApplyToEach maps an operator over every data
variable independently.
Augment
¶
Bases: Operator
Run an inner Operator and merge its output back into the input.
The inner operator must accept a Dataset and return a Dataset. The
return value of :class:Augment is input.merge(inner(input)) —
the input's existing variables are preserved, and the inner op's
new variables are added alongside.
The merge is invoked with compat="no_conflicts" (xarray's
safe default), so if the inner op produces a variable name that
already exists in the input and the values differ, the merge
raises rather than silently overwriting. Diagnostics that share an
output name with one of their inputs (rare in practice) need to be
renamed before being wrapped.
The combinator is the canonical idiom for chaining diagnostics
inside a Sequential: each step augments the threaded Dataset
with new columns, so later diagnostics can depend on variables
added by earlier ones.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
inner
|
Operator
|
The inner :class: |
required |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
MergeError
|
If the inner op's output collides with an existing input variable whose values do not match. |
Example
Build a single Dataset carrying every kinematic diagnostic for a velocity field::
from xrops import Augment, Sequential
from xrops.ocn.operators import (
RelativeVorticity, KineticEnergy, OkuboWeiss,
)
diagnostics = Sequential([
Augment(RelativeVorticity()), # adds vort_r
Augment(KineticEnergy()), # adds ke
Augment(OkuboWeiss()), # adds ow
])
enriched = diagnostics(velocity_dataset)
# enriched has u, v, vort_r, ke, ow
Diagnostics that depend on previously-added columns work
transparently because Sequential threads the merged
Dataset forward::
Sequential([
Augment(RelativeVorticity()), # adds vort_r
Augment(Enstrophy(variable="vort_r")), # reads vort_r
])
See Also
:class:Sequential — chain :class:Augment calls together.
:class:ApplyToEach — augment with one diagnostic across many
variables.
Source code in packages/xrtoolz-ops/src/xrops/combinators.py
ApplyToEach
¶
Bases: Operator
Re-instantiate a prototype operator per value and merge results.
Given a prototype :class:Operator, a kwarg name, and a sequence
of values, :class:ApplyToEach builds and runs one new instance
of the prototype's class per value, varying the chosen kwarg.
Each resulting Dataset is merged into the input with the same
safe compat="no_conflicts" semantics as :class:Augment.
The behaviour is equivalent to::
Sequential([
Augment(prototype.__class__(**{**proto_config, kwarg: v}))
for v in values
])
Each rebuilt operator runs against the threaded result, not
the original input — i.e. the second pass sees the variables added
by the first pass. This is what makes the equivalence with
Sequential([Augment(...) for v in values]) exact, and lets
later sweeps consume variables produced by earlier ones.
The prototype's get_config() is read once and used as the
base kwargs dict for the rebuilt instances. The prototype's class
must therefore satisfy the leaf contract
cls(**proto.get_config()) — i.e. its get_config must
return a dict directly usable as constructor kwargs. All
single-operator Layer 1 classes in xrops satisfy this; the
composite/combinator classes (Sequential, Graph,
Augment, Tap, ApplyToEach itself) do not, because
their configs carry serialized child records rather than live
operator instances. ApplyToEach validates this contract at
construction time via a sentinel rebuild and raises
TypeError with a descriptive message if it fails.
Each rebuilt operator must produce uniquely-named output
variables across the value sweep, otherwise the eventual merge
raises. In practice most diagnostics name their output after the
input variable they consume (e.g. Frontogenesis(scalar="ssh")
→ ssh_frontogenesis), so this is not usually a concern.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prototype
|
Operator
|
A configured single-operator :class: |
required |
kwarg
|
str
|
Name of the constructor kwarg to vary across the
sweep. Must be a key of |
required |
values
|
Sequence[Any]
|
Sequence of values to substitute for |
required |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
Example
Compute frontogenesis for three scalars in one call::
from xrops import ApplyToEach
from xrops.ocn.operators import Frontogenesis
multi_fg = ApplyToEach(
Frontogenesis(scalar="ssh"),
kwarg="scalar",
values=["ssh", "thetao", "so"],
)
out = multi_fg(ds)
# out has ssh_frontogenesis, thetao_frontogenesis,
# so_frontogenesis merged in alongside the original
# variables.
Inside a Sequential that augments the dataset before and
after::
Sequential([
ValidateCoords(),
ApplyToEach(
Frontogenesis(scalar="ssh"),
kwarg="scalar",
values=["thetao", "so"],
),
Augment(KineticEnergy()),
])
See Also
:class:Augment — wrap a single inner op to merge its output.
Source code in packages/xrtoolz-ops/src/xrops/combinators.py
Shape signatures¶
Signature is pipekit's shape descriptor (ordered (name, size) dims and a dtype), used by compute_output_signature for static shape inference across a pipeline.
Signature
dataclass
¶
Immutable shape + dtype record.
Attributes:
| Name | Type | Description |
|---|---|---|
dims |
tuple[tuple[str, int | None], ...]
|
Tuple of |
dtype |
str | None
|
A dtype label (e.g. |
Example::
sig = Signature(dims=(("time", 12), ("y", 256), ("x", 256)), dtype="float32")
sig.format() # "(time=12, y=256, x=256) float32"
sig.drop_dims(("time",)) # drops the time axis
sig.replace_dims({"x": 128}) # change the x size
format
¶
Return a compact "(name=size, …) dtype" string.
Source code in .venv/lib/python3.12/site-packages/pipekit/signature.py
drop_dims
¶
drop_dims(names: tuple[str, ...]) -> Signature
Return a new Signature with the given dim names removed.
Raises:
| Type | Description |
|---|---|
KeyError
|
if a name in |
Source code in .venv/lib/python3.12/site-packages/pipekit/signature.py
replace_dims
¶
replace_dims(sizes: dict[str, int | None]) -> Signature
Return a new Signature with the listed dim sizes overridden.
Raises:
| Type | Description |
|---|---|
KeyError
|
if a name in |
Source code in .venv/lib/python3.12/site-packages/pipekit/signature.py
matches
¶
Whether these dims fit a dims pattern; sizes are ignored.
A pattern is whitespace-separated dim names with at most one
..., which matches zero or more dims at its position. Names
compare exactly and in order.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pattern
|
str
|
E.g. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
if the pattern has more than one |
Examples:
>>> sig = Signature(dims=(("time", 12), ("band", 2), ("y", 4), ("x", 4)))
>>> sig.matches("time band y x"), sig.matches("... y x")
(True, True)
>>> sig.matches("band y x"), sig.matches("time ... x")
(False, True)
Source code in .venv/lib/python3.12/site-packages/pipekit/signature.py
from_pattern
classmethod
¶
from_pattern(pattern: str, dtype: str | None = None) -> Signature
A sizeless Signature from a dims pattern, for annotations.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pattern
|
str
|
Whitespace-separated dim names, e.g. |
required |
dtype
|
str | None
|
An optional dtype label. |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if the pattern has a |
Example
Signature.from_pattern("band y x").dims (('band', None), ('y', None), ('x', None))