"""YAML-as-Pipeline: load a v3 processing template and run it.
The schema is a flat list of *steps*. Each step has:
- ``id`` (optional): name by which downstream steps reference this
step's output. Defaults to the step index as a string.
- ``kind``: the op kind (``sub``, ``moving_average``, ``statistics``,
...). Matches the registry in :data:`OP_REGISTRY`.
- ``source``: id of the data source the op consumes. May be an
``inputs:`` key on the first reference; thereafter it is the id of a
previous step.
- ``rhs`` (binary ops only): id of the right-hand-side source.
- op-specific fields (``field``, ``out``, ``factor``, ``window``, ...)
passed straight into the params model.
The runner is a small interpreter: it walks ``inputs`` -> loads via
the supplied :class:`Storage` -> walks ``pipeline`` -> dispatches each
step to the registered op -> records the output under the step id ->
walks ``outputs`` -> writes each named result via the same storage.
:func:`load_template` validates the whole template up front:
unknown op kinds, dangling ``source`` / ``rhs`` references, duplicate
step ids, ``rhs`` on a unary op, and per-step params (missing required
fields, typo'd fields) are all rejected before any input is read.
Example YAML::
name: cp_default
inputs:
body:
kind: surface
path: body.h5
field: pressure
p_ref:
kind: points
path: probe.h5
field: pressure
pipeline:
- id: cp_raw
kind: sub
source: body
rhs: p_ref
field: pressure
out: cp
- id: cp
kind: scale
source: cp_raw
field: cp
factor: 800.0
- id: cp_stats
kind: statistics
source: cp
field: cp
kinds: [mean, rms, min, max]
outputs:
cp_timeseries:
source: cp
path: cp.time_series.h5
cp_stats:
source: cp_stats
path: cp.stats.h5
"""
from __future__ import annotations
__all__ = [
"InputSpec",
"OutputSpec",
"PipelineTemplate",
"OP_REGISTRY",
"register_op",
"OpSpec",
"BinaryOpSpec",
"run_template",
"load_template",
"validate_template",
"OpInfo",
"list_ops",
"op_info",
"DigestStrategy",
"FreshnessConfig",
]
import inspect
import pathlib
from typing import TYPE_CHECKING, Callable, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic.json_schema import GenerateJsonSchema
from cfdmod.core.data_source import DataSource
from cfdmod.core.errors import (
CfdmodError,
OpError,
TemplateError,
TemplateReferenceError,
)
from cfdmod.core.protocols import Storage
from cfdmod.utils import read_yaml
if TYPE_CHECKING:
from cfdmod.core.memory import ChunkPlan
from cfdmod.core.progress import RunEvent
# ---------------------------------------------------------------------------
# Op registry
# ---------------------------------------------------------------------------
# An op entry is one of:
# - unary: fn(ds, params) -> ds
# - binary: fn(ds, rhs, params) -> ds
#
# We tag each entry with its arity so the runner knows whether to read
# a ``rhs`` source. Params are constructed by Pydantic from the
# remaining step fields (everything except id/kind/source/rhs).
OpEntry = tuple[Literal["unary", "binary"], Callable[..., DataSource], type[BaseModel]]
OP_REGISTRY: dict[str, OpEntry] = {}
[docs]
def register_op(
kind: str,
fn: Callable[..., DataSource],
params_cls: type[BaseModel],
*,
arity: Literal["unary", "binary"] = "unary",
) -> None:
"""Register an op under ``kind`` -- the public extension point.
A consumer adds a custom op by writing a function
``fn(ds, params) -> DataSource`` (or ``fn(ds, rhs, params)`` for a
binary op) and a ``params_cls``, then calling this. The op is then a
first-class citizen: it is usable in YAML/dict templates under its
``kind``, validated by :func:`validate_template`, and listed by
:func:`list_ops`.
For the op's data-source contract (``consumes`` / ``produces`` /
``requires_element_meta`` / ...) to be picked up by the catalog and
the template linter, ``params_cls`` should subclass
:class:`cfdmod.core.ops.OpParams` and set those class attributes; a
plain ``BaseModel`` still registers but is treated as unconstrained.
Idempotent: re-registering the same kind replaces the entry, so a
consumer can also override a built-in.
"""
OP_REGISTRY[kind] = (arity, fn, params_cls)
def _populate_default_registry() -> None:
"""Wire every built-in op into the registry.
Kept as a function so the registry is populated lazily on first
use; this avoids import cycles with the recipe layer.
"""
if OP_REGISTRY:
return
from cfdmod.core.ops.data_source_create import (
ExtremeValueParams,
FaceCutParams,
FieldSeriesForGroupsParams,
FilterByGroupingParams,
ProbeExtractionParams,
ProfileInterpolationParams,
StatisticsParams,
compute_statistics,
extreme_value,
face_cut,
field_series_for_groups,
filter_by_grouping,
probe_extraction,
profile_interpolation,
)
from cfdmod.core.ops.data_source_create.modal_projection import (
ModalProjectionParams,
modal_projection,
)
from cfdmod.core.ops.data_source_create.modal_recomposition import (
ModalRecompositionParams,
modal_recomposition,
)
from cfdmod.core.ops.field import (
AddParams,
DerivativeParams,
DivParams,
ForceContributionParams,
FrequencyFilterParams,
MomentContributionParams,
MovingAverageParams,
MulParams,
ScaleParams,
SubParams,
add,
derivative,
div,
force_contribution,
frequency_filter,
moment_contribution,
moving_average,
mul,
scale,
sub,
)
from cfdmod.core.ops.geometric import (
AttachGroupingParams,
BodyGroupingParams,
ConnectivityGroupingParams,
MeshAttachParams,
RegroupTopologyParams,
ZoningGroupingParams,
attach_grouping,
body_grouping,
connectivity_grouping,
mesh_attach,
regroup_topology,
zoning_grouping,
)
from cfdmod.core.ops.time import (
RescaleTimeParams,
TranslateParams,
WindowSelectionParams,
rescale,
translate,
window_selection,
)
# Unary ops.
for kind, fn, cls in [
("time_window", window_selection, WindowSelectionParams),
("time_translate", translate, TranslateParams),
("time_rescale", rescale, RescaleTimeParams),
("moving_average", moving_average, MovingAverageParams),
("derivative", derivative, DerivativeParams),
("frequency_filter", frequency_filter, FrequencyFilterParams),
("scale", scale, ScaleParams),
("attach_grouping", attach_grouping, AttachGroupingParams),
("mesh_attach", mesh_attach, MeshAttachParams),
("body_grouping", body_grouping, BodyGroupingParams),
("zoning_grouping", zoning_grouping, ZoningGroupingParams),
("connectivity_grouping", connectivity_grouping, ConnectivityGroupingParams),
("regroup_topology", regroup_topology, RegroupTopologyParams),
("force_contribution", force_contribution, ForceContributionParams),
("moment_contribution", moment_contribution, MomentContributionParams),
("filter_by_grouping", filter_by_grouping, FilterByGroupingParams),
("face_cut", face_cut, FaceCutParams),
("field_series_for_groups", field_series_for_groups, FieldSeriesForGroupsParams),
("statistics", compute_statistics, StatisticsParams),
("extreme_value", extreme_value, ExtremeValueParams),
("modal_projection", modal_projection, ModalProjectionParams),
("modal_recomposition", modal_recomposition, ModalRecompositionParams),
("probe_extraction", probe_extraction, ProbeExtractionParams),
("profile_interpolation", profile_interpolation, ProfileInterpolationParams),
]:
register_op(kind, fn, cls, arity="unary")
# Binary ops. The runner reads ``rhs`` from the step and passes the
# resolved DataSource as the second positional argument.
for kind, fn, cls in [
("add", add, AddParams),
("sub", sub, SubParams),
("mul", mul, MulParams),
("div", div, DivParams),
]:
register_op(kind, fn, cls, arity="binary")
# ---------------------------------------------------------------------------
# Public op catalog (issue #147)
# ---------------------------------------------------------------------------
# The op registry is populated eagerly at import (bottom of this module), so a
# consumer can enumerate the op set without first running a template. The
# catalog below turns the registry into a stable, dependency-light description
# a node-based pipeline editor can consume: op kinds, arities, data-source
# contracts, and per-op parameter JSON Schemas.
class _LenientJsonSchema(GenerateJsonSchema):
"""JSON-schema generator that degrades gracefully on opaque types.
Some op params carry numpy arrays or whole value objects (e.g.
:class:`~cfdmod.core.grouping.Grouping`) that have no JSON-schema
representation. Rather than fail the whole catalog, emit an empty
(``{}`` = "any") schema for those fields; every scalar / string /
enum field still renders normally for a form-building consumer.
"""
def handle_invalid_for_json_schema(self, schema: object, error_info: str) -> dict:
return {}
def _op_family(params_cls: type[BaseModel]) -> str:
"""Resolve the op family for a params class.
An explicit ``op_family`` class attribute wins (custom ops set it);
otherwise the family is inferred from the subpackage the op lives in,
so built-in ops need no per-op bookkeeping. Families mirror
:data:`cfdmod.core.ops.OpKind`.
"""
declared = getattr(params_cls, "op_family", None)
if declared:
return declared
mod = params_cls.__module__
if ".ops.time." in mod:
return "time"
if ".ops.geometric." in mod:
return "geometric"
if ".ops.data_source_create." in mod:
return "source_create"
return "field"
class OpInfo(BaseModel):
"""Machine-readable description of one registered op.
This is the unit returned by :func:`list_ops` / :func:`op_info`. It
carries everything a consumer needs to render an op and validate a
graph statically: the op ``kind`` (the string written under a step's
``kind:`` in a template), its ``arity``, its data-source contract, and
the JSON Schema of its parameters.
"""
kind: str
family: str
arity: Literal["unary", "binary"]
consumes: list[str] | None
produces: str
requires_element_meta: list[str]
produces_element_meta: list[str]
replaces_fields: bool
params_schema: dict
def _op_info(kind: str, entry: OpEntry) -> OpInfo:
arity, _, params_cls = entry
consumes = getattr(params_cls, "consumes", None)
return OpInfo(
kind=kind,
family=_op_family(params_cls),
arity=arity,
consumes=None if consumes is None else sorted(consumes),
produces=getattr(params_cls, "produces", "same"),
requires_element_meta=sorted(getattr(params_cls, "requires_element_meta", frozenset())),
produces_element_meta=sorted(getattr(params_cls, "produces_element_meta", frozenset())),
replaces_fields=bool(getattr(params_cls, "replaces_fields", False)),
params_schema=params_cls.model_json_schema(schema_generator=_LenientJsonSchema),
)
def list_ops() -> list[OpInfo]:
"""Return the full op catalog, sorted by kind.
Enumerates every registered op (built-ins plus any registered via
:func:`register_op`) with its contract and parameter schema. Populates
the registry on first call if it has not been already.
"""
_populate_default_registry()
return [_op_info(kind, OP_REGISTRY[kind]) for kind in sorted(OP_REGISTRY)]
def op_info(kind: str) -> OpInfo:
"""Return the :class:`OpInfo` for a single op kind.
Raises ``KeyError`` if the kind is not registered.
"""
_populate_default_registry()
if kind not in OP_REGISTRY:
raise KeyError(f"unknown op kind {kind!r}; registered kinds: {sorted(OP_REGISTRY)}")
return _op_info(kind, OP_REGISTRY[kind])
# ---------------------------------------------------------------------------
# Schema models
# ---------------------------------------------------------------------------
InputKind = Literal["surface", "volume", "points", "groups", "modes"]
DigestStrategy = Literal["size_mtime", "content", "backend"]
"""How an input's change-detection token is derived. See :meth:`Storage.digest`."""
class FreshnessConfig(BaseModel):
"""Output-staleness settings for a template.
Attributes:
digest: Default strategy used to digest input dependencies when
computing an output's signature. ``size_mtime`` (the default)
reads no bytes.
per_input: Optional per-input override, mapping an ``inputs:`` name
to a strategy that wins over ``digest`` for that input only.
"""
model_config = ConfigDict(extra="forbid")
digest: DigestStrategy = "size_mtime"
per_input: dict[str, DigestStrategy] = Field(default_factory=dict)
class InputSpec(BaseModel):
"""One entry under ``inputs:``.
Attributes:
kind: The :class:`~cfdmod.core.data_source.DataSource` kind this
input is expected to be. ``run_template`` reads the source
and asserts the loaded kind matches, so a mismatch (e.g. a
probe file not named ``points.*``, which the H5 adapter would
otherwise read as a surface) is caught rather than silently
wrong.
path: Absolute or repo-relative path to the input. Resolved
against the template's ``root`` (see :func:`load_template`).
field: For inputs that bundle a single field (probe / inflow),
the field name on disk. Optional for multi-field inputs.
extras: Free-form fields forwarded to the storage adapter
(e.g. ``group`` selector for h5 timeseries).
"""
model_config = ConfigDict(extra="allow")
kind: InputKind
path: str
field: str | None = None
class OutputSpec(BaseModel):
"""One entry under ``outputs:``.
Attributes:
source: id of the step (or input) whose output is written.
path: Destination path, resolved against the template root.
format: Storage format tag. Only ``xdmf_h5`` is currently
supported (the sole built-in :class:`Storage`).
persist: Whether to write this output through the storage.
``False`` computes it and hands it back without touching disk --
what a service wants when it will serialise the result itself.
hold: Whether to keep this output in the dict ``run_template``
returns. ``False`` lets its arrays be released as soon as it has
been written, which is what a batch job wants.
extras: Free-form fields forwarded to the storage adapter
(e.g. ``group`` name for the H5 timeseries layout).
Setting both to ``False`` would compute an output and throw it away, so it
is rejected at validation.
"""
model_config = ConfigDict(extra="allow")
source: str
path: str
format: Literal["xdmf_h5"] = "xdmf_h5"
persist: bool = True
hold: bool = True
class OpSpec(BaseModel):
"""One pipeline step. Accepts arbitrary op-specific fields."""
model_config = ConfigDict(extra="allow")
id: str | None = None
kind: str
source: str
rhs: str | None = None
[docs]
class PipelineTemplate(BaseModel):
"""A complete YAML template."""
model_config = ConfigDict(extra="forbid")
name: str = "pipeline"
root: str | None = None
inputs: dict[str, InputSpec] = Field(default_factory=dict)
pipeline: list[OpSpec] = Field(default_factory=list)
outputs: dict[str, OutputSpec] = Field(default_factory=dict)
freshness: FreshnessConfig = Field(default_factory=FreshnessConfig)
# Backwards-compat alias for symmetry with OpSpec.
BinaryOpSpec = OpSpec
# ---------------------------------------------------------------------------
# Loader / runner
# ---------------------------------------------------------------------------
[docs]
def load_template(path: pathlib.Path | str) -> PipelineTemplate:
"""Load a YAML template from disk.
``root`` defaults to the directory containing the YAML file so
relative ``path:`` entries inside ``inputs:`` / ``outputs:`` are
resolved against the template's own location, not the caller's
cwd.
"""
p = pathlib.Path(path).resolve()
data = read_yaml(p)
if "root" not in data:
data["root"] = str(p.parent)
template = PipelineTemplate.model_validate(data)
validate_template(template)
return template
# Points sources carry coordinates intrinsically, so ``position`` element
# metadata is treated as available on any points binding even before an op
# populates ElementMeta.position explicitly.
_INTRINSIC_META = {"points": frozenset({"position"})}
class _BindingState:
"""Symbolic description of a binding tracked during static validation.
Carries the data-source ``kind``, the set of available field names
(``None`` = "unknown", i.e. not declared -> field checks are skipped
to avoid false positives), and the set of available element-metadata
keys.
"""
__slots__ = ("kind", "fields", "meta")
def __init__(self, kind: str, fields: frozenset[str] | None, meta: frozenset[str]) -> None:
self.kind = kind
self.fields = fields
self.meta = meta
def _seed_meta(kind: str) -> frozenset[str]:
return _INTRINSIC_META.get(kind, frozenset())
def _input_state(spec: "InputSpec") -> _BindingState:
fields = frozenset({spec.field}) if spec.field else None
return _BindingState(spec.kind, fields, _seed_meta(spec.kind))
def _consumed_fields(params: BaseModel) -> frozenset[str]:
fn = getattr(params, "consumed_fields", None)
return frozenset(fn()) if callable(fn) else frozenset()
def _produced_fields(params: BaseModel) -> frozenset[str]:
fn = getattr(params, "produced_fields", None)
return frozenset(fn()) if callable(fn) else frozenset()
def _next_state(
params_cls: type[BaseModel], params: BaseModel, src: _BindingState
) -> _BindingState:
"""Compute the output binding state of an op applied to ``src``."""
produces = getattr(params_cls, "produces", "same")
produces_meta = frozenset(getattr(params_cls, "produces_element_meta", frozenset()))
replaces = bool(getattr(params_cls, "replaces_fields", False))
kind = src.kind if produces == "same" else produces
if produces == "same":
meta = src.meta | produces_meta
else:
# Fresh source: only the metadata the op sets, plus the new kind's
# intrinsic metadata.
meta = produces_meta | _seed_meta(kind)
if replaces:
fields: frozenset[str] | None = _produced_fields(params)
elif src.fields is None:
fields = None
else:
fields = src.fields | _produced_fields(params)
return _BindingState(kind, fields, meta)
def _check_contract(
step_id: str,
step_kind: str,
params_cls: type[BaseModel],
params: BaseModel,
src: _BindingState,
) -> None:
"""Validate one op against its source binding's kind / meta / fields.
Strict on kind and element metadata (both deterministic); permissive
on fields when the source's field set is unknown (undeclared input),
so a valid template is never rejected for a field the linter merely
could not see.
"""
consumes = getattr(params_cls, "consumes", None)
if consumes is not None and src.kind not in consumes:
raise TemplateError(
f"step {step_id!r} ({step_kind!r}) consumes a {sorted(consumes)} data source "
f"but its source is kind {src.kind!r}"
)
missing_meta = frozenset(getattr(params_cls, "requires_element_meta", frozenset())) - src.meta
if missing_meta:
raise TemplateError(
f"step {step_id!r} ({step_kind!r}) requires element metadata {sorted(missing_meta)} "
f"not present on its source; attach it upstream (e.g. mesh_attach)"
)
if src.fields is not None:
missing_fields = _consumed_fields(params) - src.fields
if missing_fields:
raise TemplateError(
f"step {step_id!r} ({step_kind!r}) reads field(s) {sorted(missing_fields)} "
f"not present on its source; available: {sorted(src.fields)}"
)
def validate_template(template: PipelineTemplate) -> None:
"""Statically validate a template before any I/O.
Walks the step DAG and raises on the errors a user is most likely to
hit: unknown op kinds, dangling ``source`` / ``rhs`` references,
duplicate step ids (or an id colliding with an input name), a ``rhs``
on a unary op, and per-step params errors (missing required fields,
typo'd fields caught by ``extra="forbid"``).
It also runs a symbolic contract pass over the op catalog (issue
#147): each step's declared ``consumes`` kind and ``requires_element_meta``
are checked against the source binding, and field reads are checked when
the field set is known. This catches graph-wiring mistakes -- e.g. a
``force_contribution`` before ``mesh_attach``, or a surface-only op on a
points binding -- that a visual pipeline editor produces. The pass is
strict on kind / metadata (deterministic) and permissive on fields when
the source's fields were not declared. Called by :func:`load_template`;
also usable standalone on a programmatically built template.
"""
_populate_default_registry()
known: set[str] = set(template.inputs)
states: dict[str, _BindingState] = {
name: _input_state(spec) for name, spec in template.inputs.items()
}
for i, step in enumerate(template.pipeline):
step_id = step.id or f"step_{i}"
if step.kind not in OP_REGISTRY:
raise TemplateReferenceError(
f"unknown op kind {step.kind!r} at step {step_id!r}; "
f"registered kinds: {sorted(OP_REGISTRY)}"
)
arity, _, params_cls = OP_REGISTRY[step.kind]
if step.source not in known:
raise TemplateReferenceError(
f"step {step_id!r} references unknown source {step.source!r}; "
f"known so far: {sorted(known)}"
)
if arity == "binary":
if step.rhs is None:
raise TemplateError(f"step {step_id!r} is binary ({step.kind!r}) but has no rhs")
if step.rhs not in known:
raise TemplateReferenceError(
f"step {step_id!r} references unknown rhs {step.rhs!r}"
)
elif step.rhs is not None:
raise TemplateError(
f"step {step_id!r} is unary ({step.kind!r}) but has a rhs {step.rhs!r}; "
"rhs is only valid on binary ops (add/sub/mul/div)"
)
# Build the params model so missing/typo'd fields fail here, not
# after every input has already been read from disk.
params = _step_params(step, params_cls, template.root)
# Symbolic contract check + state propagation.
src_state = states[step.source]
_check_contract(step_id, step.kind, params_cls, params, src_state)
# Register the id last so a step cannot reference itself, and so a
# duplicate id (or a clash with an input name) is caught.
if step_id in known:
raise TemplateError(
f"duplicate step id {step_id!r}; ids must be unique and must "
"not collide with an input name"
)
known.add(step_id)
states[step_id] = _next_state(params_cls, params, src_state)
for out_name, out in template.outputs.items():
if out.source not in known:
raise TemplateReferenceError(
f"output {out_name!r} references unknown source {out.source!r}; "
f"known: {sorted(known)}"
)
if not out.persist and not out.hold:
raise TemplateError(
f"output {out_name!r} sets both persist and hold to false, so it would "
"be computed and discarded; drop the output instead"
)
def _resolve_key(template_root: str | None, path: str) -> str:
"""Resolve a template ``path:`` to the storage key.
Storage adapters are keyed by stem (no extension): the H5 adapter
resolves ``foo`` to ``<root>/foo.h5``. YAML templates may write
``path: foo``, ``path: foo.h5``, or an absolute path; we strip the
``.h5`` / ``.xdmf`` suffix uniformly so the storage sees a stem.
The resolved key is anchored on the template's ``root:`` when the
YAML path is relative; absolute paths and ``MemoryStorage`` keys
(any string) pass through unchanged.
"""
pp = pathlib.Path(path)
if pp.suffix in {".h5", ".xdmf"}:
pp = pp.with_suffix("")
if pp.is_absolute() or template_root is None:
return str(pp)
return str(pathlib.Path(template_root) / pp)
# Step-level fields whose values are paths the user wrote relative to
# the template's root. The runner resolves them to absolute paths before
# building the op's params model so ops never need to know about the
# YAML's location.
_PATHLIKE_FIELDS = frozenset({"mesh", "mesh_path", "lnas", "csv"})
def _resolve_pathlike(value: object, template_root: str | None) -> object:
if not isinstance(value, str) or template_root is None:
return value
pp = pathlib.Path(value)
if pp.is_absolute():
return value
return str(pathlib.Path(template_root) / pp)
def _step_params(
step: OpSpec,
params_cls: type[BaseModel],
template_root: str | None,
) -> BaseModel:
"""Build the params model from the step's extras.
String fields whose name is in :data:`_PATHLIKE_FIELDS` are
resolved against ``template_root`` so users can write relative
paths in YAML.
"""
raw = step.model_dump()
for key in ("id", "kind", "source", "rhs"):
raw.pop(key, None)
for key, value in list(raw.items()):
if key in _PATHLIKE_FIELDS:
raw[key] = _resolve_pathlike(value, template_root)
return params_cls.model_validate(raw)
def _accepts_kind(storage: Storage) -> bool:
"""Whether ``storage.read_data_source`` takes the ``kind`` keyword.
``Storage`` is a structural protocol, so a consumer's adapter written
against the older two-argument signature is still a valid ``Storage``.
Probing the signature once keeps those working, and -- unlike catching
``TypeError`` around the call -- cannot mistake a genuine ``TypeError``
raised *inside* the adapter for an old signature.
"""
try:
params = inspect.signature(storage.read_data_source).parameters
except (TypeError, ValueError): # builtins / C extensions have no signature
return False
if "kind" in params:
return True
return any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
def _live_time_arrays(template: PipelineTemplate) -> int:
"""How many time-resolved arrays the pipeline holds at its widest point.
Used to price a time window. Counting exactly is not possible -- numpy
temporaries inside an op are invisible from here -- so this is a floor:
every declared input that carries a time axis, plus every step that
produces one. That is conservative in the right direction (it over-counts
live arrays, so it under-sizes the window) as long as ops do not allocate
more than one extra array of their own, which the field ops do not.
Never returns less than 1, so the caller can divide by it.
"""
_populate_default_registry()
# Inputs are assumed time-resolved: an aggregated one costs a single
# column regardless of window size, so counting it is the safe error.
live = len(template.inputs) + len(template.pipeline)
return max(1, live)
def _chunkable_step_params(template: PipelineTemplate) -> list[BaseModel]:
"""Bound params for every step, for :func:`assert_time_chunkable`."""
out: list[BaseModel] = []
for step in template.pipeline:
entry = OP_REGISTRY.get(step.kind)
if entry is None:
continue
out.append(_step_params(step, entry[2], template.root))
return out
def _plan_for(
template: PipelineTemplate,
bindings: dict[str, DataSource],
chunk_size: int | None,
memory_budget: int | None,
n_live_arrays: int | None,
) -> "ChunkPlan":
"""Size the time window for this run from the loaded inputs.
The shape comes from the widest time-resolved input: that is what a window
of the pipeline actually costs. With no time-resolved input, or a single
timestep, the plan is a single pass -- there is nothing to split.
"""
from cfdmod.core.memory import plan_chunking
resolved_live = n_live_arrays if n_live_arrays is not None else _live_time_arrays(template)
timed = [ds for ds in bindings.values() if not ds.time.is_time_aggregated]
n_timesteps = max((ds.time.n_timesteps for ds in timed), default=0)
n_elements = max((ds.n_elements for ds in timed), default=0)
if n_timesteps <= 1:
return plan_chunking(n_elements, n_timesteps, n_live_arrays=resolved_live)
return plan_chunking(
n_elements,
n_timesteps,
budget_bytes=memory_budget,
chunk_size=chunk_size,
n_live_arrays=resolved_live,
)
def _last_use(template: PipelineTemplate) -> dict[str, int]:
"""Step index after which each binding is no longer read.
A binding is live until the last step that names it as ``source`` or
``rhs``; anything an output depends on is live to the end (represented by
an index past the last step). Used to drop the runner's reference to an
intermediate as soon as nothing downstream can ask for it -- the arrays
then go when Python's refcount hits zero, which is the only safe way to
release them: bindings share field arrays by reference, so the runner must
never reach in and mutate a store.
"""
end = len(template.pipeline)
last: dict[str, int] = {}
for i, step in enumerate(template.pipeline):
for ref in (step.source, step.rhs):
if ref:
last[ref] = i
last[step.id or f"step_{i}"] = last.get(step.id or f"step_{i}", i)
for out in template.outputs.values():
last[out.source] = end
return last
def _retained_bindings(template: PipelineTemplate) -> set[str] | None:
"""Step ids whose per-window results must be kept, or ``None`` for all.
Only what the ``outputs:`` block asks for survives a chunked run. That is
not a convenience -- it is what makes chunking reduce anything. Keeping
every intermediate for every window holds the full-size arrays *plus* the
windowed copies, which costs strictly more than not chunking at all
(measured: 25.8 MB against 11.4 MB unchunked, on a template whose only
real output was a 4-group reduction of 4000 triangles).
With no declared outputs there is nothing to select on, so everything is
kept and chunking bounds transient allocations only.
"""
sources = {out.source for out in template.outputs.values()}
return sources or None
class _Reporter:
"""Bundles the ``on_progress`` / ``cancel`` seams so the runner takes one
argument instead of threading two optionals through four functions.
Both are optional and the no-op case costs an attribute check, so the
unobserved path is unchanged.
"""
__slots__ = ("_on_progress", "_cancel")
def __init__(self, on_progress, cancel) -> None:
self._on_progress = on_progress
self._cancel = cancel
def emit(self, phase, name, index, total, **extra) -> None:
if self._on_progress is None:
return
from cfdmod.core.progress import RunEvent
self._on_progress(RunEvent(phase=phase, name=name, index=index, total=total, **extra))
def check(self, phase, name) -> None:
"""Raise :class:`RunCancelled` if the caller asked to stop."""
if self._cancel is not None and self._cancel():
from cfdmod.core.progress import RunCancelled
raise RunCancelled(phase, name)
_NULL_REPORTER = _Reporter(None, None)
def _walk_chunked(
template: PipelineTemplate,
bindings: dict[str, DataSource],
needed_steps: set[str] | None,
plan: "ChunkPlan",
reporter: "_Reporter" = _NULL_REPORTER,
last_use: dict[str, int] | None = None,
) -> dict[str, DataSource]:
"""Run the step walk once per time window and concatenate the results.
This is :func:`cfdmod.core.chunked.chunk_map_time` generalised to a
multi-input template: every time-resolved binding is sliced to the same
window, the whole walk runs on the slice, and the per-window results are
concatenated along time. Time-aggregated bindings (a static reference
pressure, a mesh) pass through untouched.
Only the bindings :func:`_retained_bindings` selects are accumulated
across windows; the rest go out of scope with their window, which is the
entire source of the memory saving. The returned dict therefore carries
the inputs (unsliced, as loaded) plus the retained results -- an
intermediate that no output depends on is not reconstructed.
Safe only for a time-length-preserving pipeline -- the caller must have
run :func:`~cfdmod.core.chunked.assert_time_chunkable` first. An op that
reduces the time axis (``statistics``) does not declare time
chunkability precisely because windowed statistics are not the statistics
of the whole series.
"""
from cfdmod.core.chunked import concat_time, slice_time, time_windows
retain = _retained_bindings(template)
windows = list(time_windows(plan.n_timesteps, plan.chunk_size))
accumulated: dict[str, list[DataSource]] = {}
for w, sl in enumerate(windows):
# Poll per window: that is the unit of work that actually takes time,
# so it is the granularity at which cancelling is useful.
reporter.check("step", f"window {w + 1}")
window = {
name: ds if ds.time.is_time_aggregated else slice_time(ds, sl)
for name, ds in bindings.items()
}
produced = _walk_steps(
template,
window,
needed_steps,
reporter,
window_index=w,
n_windows=len(windows),
last_use=last_use,
)
for name, ds in produced.items():
if retain is not None and name not in retain:
continue
accumulated.setdefault(name, []).append(ds)
# Drop the window's own bindings before allocating the next one.
del produced, window
merged: dict[str, DataSource] = dict(bindings)
for name, parts in accumulated.items():
if len(parts) == 1 or parts[0].time.is_time_aggregated:
merged[name] = parts[0]
else:
merged[name] = concat_time(parts)
return merged
[docs]
def run_template(
template: PipelineTemplate,
*,
storage: Storage,
skip_fresh: bool = False,
chunk_size: int | None = None,
memory_budget: int | None = None,
n_live_arrays: int | None = None,
on_plan: Callable[["ChunkPlan"], None] | None = None,
on_progress: Callable[["RunEvent"], None] | None = None,
cancel: Callable[[], bool] | None = None,
return_all: bool = False,
) -> dict[str, DataSource]:
"""Run a parsed template against a :class:`Storage`.
Returns the dict of all named values (inputs + step outputs) so
callers can inspect intermediates. The ``outputs:`` block is
written through ``storage.write_data_source`` as a side effect.
Each written output is stamped with a freshness signature (via
``storage.write_signature``) when the backend supports it, so a later
:func:`~cfdmod.core.freshness.output_status` / ``skip_fresh`` run can
tell fresh outputs from stale ones.
With ``skip_fresh=True`` the runner first asks which outputs are stale
(``freshness.output_status``), then runs only the steps and loads only
the inputs those stale outputs depend on -- fresh outputs are neither
recomputed nor rewritten. If every declared output is already fresh the
run is a no-op and returns an empty binding dict.
Time chunking
-------------
With ``chunk_size`` or ``memory_budget`` the pipeline runs over contiguous
windows of the time axis and the per-window results are concatenated, so
peak memory is ``O(n_elements * chunk)`` rather than
``O(n_elements * n_timesteps)``. The numbers are unchanged; only the peak
is.
Two things are worth being clear about:
- **It is not free on every template.** The win is real when the pipeline
collapses the element axis before the concatenation (a per-triangle force
summed to a per-floor coefficient): the big intermediates then live for
one window at a time while the concatenated result stays small. A
pipeline that keeps the full element axis still bounds its transient
allocations, but its final output is the same size as the unchunked one.
- **Not every pipeline may be chunked.** Every op must declare ``"time"``
in ``chunkable_along``. ``statistics`` deliberately does not -- the
statistics of a window are not the statistics of the series -- so a
template containing it raises before any I/O, naming the offending ops,
rather than producing plausible wrong numbers.
Args:
chunk_size: Timesteps per window. Mutually exclusive with
``memory_budget``.
memory_budget: Bytes the run may spend on time-resolved arrays; the
window size is derived from it (see :mod:`cfdmod.core.memory`).
Mutually exclusive with ``chunk_size``.
n_live_arrays: Override for how many time-resolved arrays the budget
arithmetic assumes are live at once. Derived from the template
when omitted.
on_plan: Called with the :class:`~cfdmod.core.memory.ChunkPlan` before
execution starts, whether or not chunking is on. Use it to log or
surface what the run decided; ``ChunkPlan.describe()`` renders a
line.
on_progress: Called with a :class:`~cfdmod.core.progress.RunEvent` as
each input is loaded, each step runs, and each output is written.
cancel: Polled at those same boundaries; returning True raises
:class:`~cfdmod.core.progress.RunCancelled`. cfdmod cannot
interrupt a numpy call in flight, so a run stops at the next
boundary -- but the check precedes every write, so a cancelled run
never leaves a partially written output set.
return_all: Keep every intermediate binding alive and return it. Off by
default: the runner otherwise drops each binding once no remaining
step or output reads it, so the peak of a run is its widest live
set rather than the sum of everything it ever computed. Turn it on
for notebook work where inspecting intermediates is the point.
Returns:
The inputs, plus the outputs declared with ``hold: true`` (the
default), plus -- with ``return_all`` -- every intermediate.
"""
_populate_default_registry()
# Static validation first: fail on typos/dangling refs before any I/O.
validate_template(template)
strategy = template.freshness.digest
supports_freshness = hasattr(storage, "write_signature") and hasattr(storage, "digest")
stale_outputs: set[str] | None = None
needed_steps: set[str] | None = None
needed_inputs: set[str] | None = None
if skip_fresh:
if not supports_freshness:
raise TemplateError(
"skip_fresh=True requires a storage backend with digest/read_signature/"
"write_signature; this backend has none"
)
from cfdmod.core.freshness import closure_for_outputs, output_status
statuses = output_status(template, storage, strategy)
stale_outputs = {n for n, s in statuses.items() if not s.is_fresh}
if template.outputs and not stale_outputs:
# Everything is up to date -- nothing to load, run, or write.
return {}
needed_steps, needed_inputs = closure_for_outputs(template, stale_outputs)
# 1. Load inputs (all, or -- under skip_fresh -- only those the stale
# outputs depend on).
reporter = _Reporter(on_progress, cancel)
# With no declared outputs there is nothing to select on, so keep
# everything -- that template is being run for its intermediates.
last_use = None if (return_all or not template.outputs) else _last_use(template)
accepts_kind = _accepts_kind(storage)
bindings: dict[str, DataSource] = {}
n_inputs = len(template.inputs)
for i, (name, spec) in enumerate(template.inputs.items()):
if needed_inputs is not None and name not in needed_inputs:
continue
reporter.check("load", name)
reporter.emit("load", name, i, n_inputs)
# Storage keys are logical names. We treat the resolved path as
# the storage key so the adapter can map it to its on-disk
# layout.
key = _resolve_key(template.root, spec.path)
# Pass the declared kind down rather than letting the adapter guess.
# The h5 layout does not record it, so without this the backend falls
# back to the filename stem and a probe file not named ``points.*``
# loads as a surface.
try:
if accepts_kind:
ds = storage.read_data_source(key, kind=spec.kind)
else:
ds = storage.read_data_source(key)
except ValueError as exc:
raise TemplateError(
f"input {name!r} declares kind {spec.kind!r}, which the storage "
f"backend cannot read from {spec.path!r}: {exc}"
) from exc
# Invariant check. With the kind passed explicitly this should be
# unreachable; it stays so a backend that ignores the keyword cannot
# feed the pipeline the wrong kind silently.
if ds.kind != spec.kind:
raise TemplateError(
f"input {name!r} declares kind {spec.kind!r} but the source at "
f"{spec.path!r} loaded as kind {ds.kind!r}"
)
bindings[name] = ds
# 2. Decide whether and how to chunk the time axis, and say so.
plan = _plan_for(template, bindings, chunk_size, memory_budget, n_live_arrays)
if on_plan is not None:
on_plan(plan)
if plan.is_chunked:
# Fail before any work if an op in the chain cannot be windowed.
from cfdmod.core.chunked import assert_time_chunkable
try:
assert_time_chunkable(_chunkable_step_params(template))
except ValueError as exc:
raise TemplateError(f"cannot run this template chunked over time: {exc}") from exc
# 3. Walk pipeline, over the whole time axis or one window at a time.
if plan.is_chunked:
bindings = _walk_chunked(template, bindings, needed_steps, plan, reporter, last_use)
else:
bindings = _walk_steps(template, bindings, needed_steps, reporter, last_use=last_use)
return _write_outputs(
template,
bindings,
storage,
stale_outputs,
supports_freshness,
strategy,
reporter,
return_all,
)
def _walk_steps(
template: PipelineTemplate,
bindings: dict[str, DataSource],
needed_steps: set[str] | None,
reporter: "_Reporter" = _NULL_REPORTER,
*,
window_index: int | None = None,
n_windows: int | None = None,
last_use: dict[str, int] | None = None,
) -> dict[str, DataSource]:
"""Execute the template's steps against ``bindings``, returning them extended.
Split out of :func:`run_template` so the chunked runner can call it once per
time window with windowed inputs. ``bindings`` is not mutated.
"""
bindings = dict(bindings)
total = len(template.pipeline)
for i, step in enumerate(template.pipeline):
step_id = step.id or f"step_{i}"
if needed_steps is not None and step_id not in needed_steps:
continue
reporter.check("step", step_id)
reporter.emit(
"step",
step_id,
i,
total,
op_kind=step.kind,
window=window_index,
n_windows=n_windows,
)
if step.kind not in OP_REGISTRY:
raise TemplateReferenceError(
f"unknown op kind {step.kind!r} at step {step_id!r}; "
f"registered kinds: {sorted(OP_REGISTRY)}"
)
arity, fn, params_cls = OP_REGISTRY[step.kind]
if step.source not in bindings:
raise TemplateReferenceError(
f"step {step_id!r} references unknown source {step.source!r}"
)
ds = bindings[step.source]
params = _step_params(step, params_cls, template.root)
if arity == "binary":
if step.rhs is None:
raise TemplateError(f"step {step_id!r} is binary but has no rhs")
if step.rhs not in bindings:
raise TemplateReferenceError(
f"step {step_id!r} references unknown rhs {step.rhs!r}"
)
# Execute the op. A failure inside the op is wrapped as OpError with
# the failing step id / kind, so a consumer can map it precisely
# (rather than string-matching a bare exception) -- but cfdmod's own
# TemplateError / TemplateReferenceError pass through untouched.
try:
if arity == "binary":
result = fn(ds, bindings[step.rhs], params)
else:
result = fn(ds, params)
except CfdmodError:
raise
except Exception as exc:
raise OpError(
f"step {step_id!r} ({step.kind!r}) raised while executing: {exc}",
step_id=step_id,
op_kind=step.kind,
) from exc
bindings[step_id] = result
# Drop our reference to anything no downstream step or output reads.
# Refcounting frees the arrays; we never mutate a store, because
# bindings share field arrays and another binding may still hold one.
if last_use is not None:
for name in [n for n, last in last_use.items() if last <= i and n in bindings]:
del bindings[name]
return bindings
def _write_outputs(
template: PipelineTemplate,
bindings: dict[str, DataSource],
storage: Storage,
stale_outputs: set[str] | None,
supports_freshness: bool,
strategy: str,
reporter: "_Reporter" = _NULL_REPORTER,
return_all: bool = False,
) -> dict[str, DataSource]:
"""Write the ``outputs:`` block, stamping freshness where supported.
Honours each output's ``persist`` / ``hold``: ``persist: false`` computes it
without touching storage, ``hold: false`` drops it from the returned dict
once written.
Cancellation is polled before each write, so a cancelled run never leaves a
partially written output set.
"""
sign = None
if supports_freshness and any(o.persist for o in template.outputs.values()):
from cfdmod.core.freshness import signature as sign
total = len(template.outputs)
dropped: set[str] = set()
for i, (out_name, out) in enumerate(template.outputs.items()):
if stale_outputs is not None and out_name not in stale_outputs:
continue
if out.source not in bindings:
raise TemplateReferenceError(f"output references unknown source {out.source!r}")
if out.persist:
reporter.check("write", out_name)
reporter.emit("write", out_name, i, total)
key = _resolve_key(template.root, out.path)
storage.write_data_source(key, bindings[out.source])
if sign is not None:
storage.write_signature(key, sign(template, out_name, storage, strategy))
if not out.hold:
dropped.add(out.source)
# An output source is only released if *no* output that holds shares it.
if not return_all:
kept = {o.source for o in template.outputs.values() if o.hold}
for name in dropped - kept:
bindings.pop(name, None)
return bindings
# Populate the op registry at import so consumers can enumerate ops (via
# list_ops / op_info / OP_REGISTRY) without first running a template. Safe:
# no op module imports this module, so there is no cycle.
_populate_default_registry()