Source code for ionworks.pipeline.base

"""Base classes for ionworks.pipeline."""

from typing import Any, NoReturn
import warnings

import pybamm
from pydantic import BaseModel, ConfigDict, Field, field_validator

from .serialize import serialize_parameter_value, serialize_parameters


def _get_element_type(comp: Any) -> str | None:
    """Determine the ``element_type`` discriminator for a pipeline component.

    Parameters
    ----------
    comp : Any
        A pipeline element instance (``DirectEntry``, ``DataFit``,
        ``Validation``, ``Calculation``, …).

    Returns
    -------
    str or None
        The canonical ``element_type`` string (``"entry"``, ``"data_fit"``,
        ``"validation"``, or ``"calculation"``), or ``None`` for raw configs
        (e.g. dicts) that are not recognised schema objects, so they pass
        through unchanged.
    """
    cls = type(comp)
    mro_names = {c.__name__ for c in cls.__mro__}
    if "DirectEntry" in mro_names:
        return "entry"
    if "DirectEntryFunctionSchema" in mro_names:
        return "entry"
    if cls.__name__ == "DataFit":
        return "data_fit"
    if cls.__name__ == "ArrayDataFit":
        return "array_data_fit"
    if cls.__name__ == "LinearConfidenceInterval":
        return "linear_confidence_interval"
    if cls.__name__ == "SobolSensitivity":
        return "sobol_sensitivity"
    if cls.__name__ == "Validation":
        return "validation"
    if "Calculation" in mro_names:
        return "calculation"
    return None


def _is_dataframe(value) -> bool:
    """Return True for a pandas or polars DataFrame, duck-typed.

    Avoids importing pandas/polars at module load so the schema package stays
    usable without them installed.
    """
    return hasattr(value, "to_dict") and hasattr(value, "columns")


def _serialize_value(value):
    """Recursively serialize a value, calling to_config on schemas.

    A value with no wire form raises rather than passing through: returned
    as-is it survives ``to_config`` and fails only once the server reads it.
    """
    if value is None:
        return None
    if isinstance(value, BaseSchema):
        return value.to_config()
    if isinstance(value, dict):
        return {k: _serialize_value(v) for k, v in value.items()}
    if isinstance(value, (list, tuple)):
        return [_serialize_value(v) for v in value]
    # A ParameterValues serialises via the shared helper (drops citations,
    # flattens Scalars), not its own ``to_config``/``to_json`` — so it must
    # precede the generic ``to_config`` branch below.
    if isinstance(value, pybamm.ParameterValues):
        return serialize_parameters(value)
    # A DataFit parameter may be a pybamm expression linking other parameters,
    # which the pipeline parser reads back as serialised symbol-JSON.
    if isinstance(value, pybamm.Symbol):
        return serialize_parameter_value(value=value)
    if hasattr(value, "to_config"):
        return value.to_config()
    if hasattr(value, "model_dump"):
        return value.model_dump(exclude_none=True)
    # pandas or polars DataFrame -> dict of lists for serialization
    if _is_dataframe(value):
        cls = type(value)
        mod = getattr(cls, "__module__", "")
        if "pandas" in mod:
            return value.to_dict(orient="list")
        if "polars" in mod:
            return value.to_dict(as_series=False)
        try:
            return value.to_dict(orient="list")
        except TypeError:
            return value.to_dict(as_series=False)
    if isinstance(value, (int, float, str, bool)):
        return value
    if hasattr(value, "tolist"):  # numpy arrays
        return value.tolist()
    raise ValueError(
        f"{type(value).__name__} has no wire form. Convert it to a number, "
        f"string, list, dict, or pybamm object before putting it in a schema. "
        f"Got {value!r:.200}."
    )


[docs] def reject_runtime_object(value: Any, field_name: str, expected: str) -> NoReturn: """Reject a value that is neither a config dict nor a schema instance. Centralizes the boundary guard shared by the distribution, sampler, prior, and objective resolvers. The schema contract holds only ``ionworks.pipeline`` objects — a config dict or the matching schema instance — so a live runtime object must be converted (via ``.to_config()`` or the matching ``ionworks.pipeline`` schema class) before it enters a schema, rather than embedded raw. Callers invoke this on the fall-through branch after they have already accepted the dict and schema-instance forms. Parameters ---------- value : Any The rejected value, echoed into the error message via ``repr``. field_name : str The schema field being resolved (e.g. ``"distribution"``, ``"sampler"``, ``"prior"``, ``"objective"``), used to phrase the message. expected : str User-facing description of the accepted non-dict form, phrased with its article so it reads in the message (e.g. ``"a Distribution instance"``, ``"an ionworks.pipeline objective instance"``). Use the public vocabulary, not an internal implementation class name. Raises ------ ValueError Always. The function never returns normally. """ raise ValueError( f"Invalid {field_name} {value!r}: expected a config dict or {expected}; " f"got {type(value).__name__}. Live runtime objects are " f"not accepted inside a schema." )
[docs] class BaseSchema(BaseModel): """Shared parent of every schema class in this package. Construct a concrete subclass (``iw.pipeline.Pipeline``, ``iw.pipeline.DataFit``, …) rather than this. It rejects unknown fields, so a typo fails at construction, and provides ``.to_config()``, the only supported serializer: ``.model_dump()`` omits the ``type`` discriminator and uses attribute names (``data_input``) rather than wire names (``data``). """ model_config = ConfigDict( extra="forbid", validate_assignment=True, arbitrary_types_allowed=True, populate_by_name=True, ) # Fields to exclude from serialization (comes from dict key in parser) _exclude_fields: set = set() # When False, to_config omits the "type" discriminator. Set on subclasses # whose serialised output must be a plain option dict (e.g. algorithm # options passed to pipeline algorithm classes that reject unknown keys). _emit_type: bool = True # When True, to_config emits only the fields the caller explicitly set, for # subclasses whose output is merged over a consumer's own defaults. _only_set_fields: bool = False # When False, a bare DataFrame in the "data" field skips the wrap below. # Set on payload wrappers (e.g. DataPayloadSpec, MetadataSpec) whose own # field happens to be named "data" too: they are already the wrapper, so # re-applying the bare-DataFrame convention would nest it a second time. _wrap_bare_dataframe: bool = True
[docs] def to_config(self) -> dict: """Build the dict you submit through ``ionworks.api``. Nested schemas convert recursively, ``None`` fields are dropped, and each component the server must re-identify gains a ``"type"`` key. Notes ----- The only supported serializer. ``.model_dump()`` (even with ``by_alias=True``) omits the ``"type"`` discriminator, so it is not a payload: ``iw.pipeline.stats.Uniform(lb=0.0, ub=1.0).to_config()`` gives ``{"distribution": "Uniform", "lb": 0.0, "ub": 1.0}``, whereas ``.model_dump(exclude_none=True)`` gives ``{"lb": 0.0, "ub": 1.0}``. """ config = {} # Iteration yields declared fields in declaration order, then any # extra="allow" keys in the order supplied, so output is stable. for key, value in self: if value is None: continue # Skip excluded fields if key in self._exclude_fields: continue if self._only_set_fields and key not in self.model_fields_set: continue # ``extra="allow"`` keys have no field, so they keep their own name. field = type(self).model_fields.get(key) output_key = ( (field.serialization_alias or field.alias or key) if field else key ) try: serialized = _serialize_value(value) except ValueError as e: raise ValueError(f"{output_key}: {e}") from None # A bare DataFrame in the `data` field serializes to a column dict; # the parser expects it wrapped as {"data": <columns>}. Mirror the # runtime DataInputController, which auto-wraps a bare DataFrame, so # callers don't have to write {"data": df} by hand. An already-wrapped # dict or a string path is not a DataFrame and is left untouched. if ( output_key == "data" and _is_dataframe(value) and self._wrap_bare_dataframe ): serialized = {"data": serialized} config[output_key] = serialized # Add type field based on class name (except for top-level containers) cls_name = self.__class__.__name__ if self._emit_type and cls_name not in ( "Pipeline", "DataFit", "ArrayDataFit", "LinearConfidenceInterval", "SobolSensitivity", "Parameter", "DirectEntry", "Validation", "Calculation", ): config["type"] = cls_name return config
[docs] def run(self, *args, **kwargs): raise NotImplementedError( "Execution not available. Submit to Ionworks API instead." )
[docs] class Pipeline(BaseSchema): """A sequence of steps that together produce a parameterised cell model. Each element pulls parameters from a source (``DirectEntry``), fits them to data (``DataFit``), derives quantities (``Calculation``) or checks them against held-out data (``Validation``), and passes its parameters to the next, so order matters. Submit ``.to_config()`` through ``ionworks.api`` to run it server-side. Parameters ---------- elements : dict, optional Mapping of element name to pipeline element (``DataFit``, ``DirectEntry``, ``Validation``, ``Calculation``, …). The name is used to identify the element in the pipeline report. ``None`` is treated as an empty pipeline by ``to_config``. output_file : str, optional Optional file path for persisting the final parameter values produced by the pipeline. If ``None``, parameters are not written to disk. name : str, optional Human-readable name for the pipeline, used in reports and logs. description : str, optional Free-text description of what the pipeline does. Examples -------- >>> # known parameters (e.g. ambient temperature) >>> known = iw.pipeline.direct_entries.DirectEntry( ... parameters={"Ambient temperature [K]": 298.15}, ... ) >>> # fit one parameter against an OCP measurement >>> obj = iw.pipeline.objectives.OCPHalfCell( ... electrode="positive", ... data_input="path/to/ocp.csv", ... ) >>> fit = iw.pipeline.DataFit( ... objectives={"ocp": obj}, ... parameters={"Positive electrode capacity [A.h]": iw.pipeline.Parameter( ... "Positive electrode capacity [A.h]", initial_value=3.0, bounds=(2.0, 4.0), ... )}, ... ) >>> # validate the fit against held-out cycling data >>> val_obj = iw.pipeline.objectives.CurrentDriven( ... data_input="path/to/cycle.csv", options={"model": "SPM"} ... ) >>> val = iw.pipeline.Validation(objectives={"cycle": val_obj}) >>> pipeline = iw.pipeline.Pipeline( ... {"known": known, "ocp fit": fit, "validate": val} ... ) >>> config = pipeline.to_config() >>> # then submit `config` via ionworks.api """ elements: dict | None = Field( default=None, description=( "Mapping of element name to pipeline element (``DataFit``, " "``DirectEntry``, ``Validation``, ``Calculation``, …). The name " "is used to identify the element in the pipeline report. " "``None`` is treated as an empty pipeline by ``to_config``." ), ) output_file: str | None = Field( default=None, description=( "Optional file path for persisting the final parameter values " "produced by the pipeline. If None, parameters are not written " "to disk." ), ) name: str | None = Field( default=None, description=("Human-readable name for the pipeline, used in reports and logs."), ) description: str | None = Field( default=None, description="Free-text description of what the pipeline does.", ) def __init__( self, elements: dict | None = None, output_file: str | None = None, name: str | None = None, description: str | None = None, **extra: Any, ): # Forward extra kwargs to BaseModel.__init__ so Pydantic enforces # ``extra='forbid'`` at the field-validation layer (rather than # raising a Python-level TypeError that bypasses Pydantic). super().__init__( elements=elements, output_file=output_file, name=name, description=description, **extra, ) @field_validator("elements", mode="before") @classmethod def _validate_elements(cls, elements): """Resolve and validate each element config against its schema class. A field validator (not a model validator) so it also runs on ``pipeline.elements = {...}`` reassignment, not just construction. Runs ahead of ``SimplePipeline``'s expensive-element field validator, which then classifies the resolved instances. Existing instances (e.g. ``ArrayDataFit``) pass through unchanged. """ if isinstance(elements, dict): from ._element_registry import validate_element return {name: validate_element(elem) for name, elem in elements.items()} return elements
[docs] def to_config(self) -> dict: """Build the pipeline payload you submit through ``ionworks.api``. Walks every element you added to ``elements``, serialises it (each element's own ``to_config``), and tags it with its kind (``"entry"``, ``"data_fit"``, ``"validation"``, …) so the server knows how to dispatch it. Pass the returned dict to the Ionworks API to run the full pipeline. """ config = {"elements": {}} for name, comp in (self.elements or {}).items(): # Serialize the component if hasattr(comp, "to_config"): elem_config = comp.to_config() elif hasattr(comp, "model_dump"): elem_config = comp.model_dump(exclude_none=True) elif isinstance(comp, dict): elem_config = dict(comp) else: elem_config = {} # Set element_type only for known schema types; raw configs (e.g. dicts) keep their existing element_type elem_type = _get_element_type(comp) if elem_type is not None: elem_config["element_type"] = elem_type config["elements"][name] = elem_config # Add other fields if self.output_file is not None: config["output_file"] = self.output_file if self.name is not None: config["name"] = self.name if self.description is not None: config["description"] = self.description return config
# Keep in sync with the backend's EXPENSIVE_ELEMENT_TYPES # (backend/src/pydantic_models/simple_pipelines.py). _EXPENSIVE_TYPES = frozenset({"data_fit", "array_data_fit", "validation"})
[docs] class SimplePipeline(Pipeline): """A pipeline that permits at most one expensive element. .. warning:: Deprecated, and removed in a future release. Use :class:`Pipeline`, which accepts the same elements. ``SimplePipeline`` is identical to :class:`Pipeline` except that it rejects configurations containing more than one expensive element (``DataFit``, ``ArrayDataFit``, or ``Validation``) at construction time, matching the constraint the SimplePipeline API endpoint enforces. Parameters ---------- elements : dict, optional Mapping of element name to pipeline element (``DataFit``, ``DirectEntry``, ``Validation``, ``Calculation``, …). At most one element may be a ``DataFit``, ``ArrayDataFit``, or ``Validation``. output_file : str, optional Optional file path for persisting the final parameter values produced by the pipeline. If ``None``, parameters are not written to disk. name : str, optional Human-readable name for the pipeline, used in reports and logs. description : str, optional Free-text description of what the pipeline does. Raises ------ ValueError If ``elements`` contains more than one ``DataFit``, ``ArrayDataFit``, or ``Validation`` element. Examples -------- >>> entry = iw.pipeline.direct_entries.DirectEntry( ... parameters={"Ambient temperature [K]": 298.15}, ... ) >>> obj = iw.pipeline.objectives.OCPHalfCell( ... electrode="positive", ... data_input="path/to/ocp.csv", ... ) >>> fit = iw.pipeline.DataFit( ... objectives={"ocp": obj}, ... parameters={"Positive electrode capacity [A.h]": iw.pipeline.Parameter( ... "Positive electrode capacity [A.h]", ... initial_value=3.0, ... bounds=(2.0, 4.0), ... )}, ... ) >>> pipeline = iw.pipeline.SimplePipeline( ... elements={"known": entry, "ocp fit": fit}, ... name="Single OCP fit", ... ) >>> config = pipeline.to_config() """ def __init__(self, *args: Any, **kwargs: Any): # Not an access-time warning, which would fire on every ``import *``. warnings.warn( "iw.pipeline.SimplePipeline is deprecated and will be removed in a future " "release. Use iw.pipeline.Pipeline, which accepts the same elements, and " "submit it through the regular pipeline API. Migration guide: " "https://docs.ionworks.com/build/parameterize/api#migrating-to-pipeline", DeprecationWarning, stacklevel=2, ) super().__init__(*args, **kwargs) @field_validator("elements") @classmethod def _validate_at_most_one_expensive_element( cls, elements: dict | None ) -> dict | None: # A field (not model) validator so the constraint also re-runs on # reassignment (``sp.elements = ...``), since BaseSchema enables # ``validate_assignment``. if elements is None: return elements # Raw dicts carry their own element_type; schema objects are classified # via _get_element_type (the same mapping to_config uses). expensive = [ name for name, elem in elements.items() if ( elem.get("element_type") if isinstance(elem, dict) else _get_element_type(elem) ) in _EXPENSIVE_TYPES ] if len(expensive) > 1: raise ValueError( f"SimplePipeline allows at most one DataFit, ArrayDataFit, or Validation " f"element, but got {len(expensive)}: {expensive}" ) return elements