Source code for ionworks.api.custom_model

"""Custom model client for managing battery models and custom variables.

This module provides the :class:`ModelClient` for creating, reading,
updating, and deleting custom battery models within an organization, as well
as adding custom variables to those models.
"""

from __future__ import annotations

import io
import json
import os
import tempfile
from typing import IO, Any, Literal, NamedTuple

from ._model_config import normalize_model_config
from ._parameters import normalize_parameters
from ._project_id import resolve_project_id
from ._simulation_settings import normalize_simulation_settings
from .errors import IonworksError
from .models import (
    Model,
    PaginatedList,
    ParameterizedModel,
    _build_endpoint,
    _build_filter_params,
    _parse_list_response,
)

#: Allowed values for the ``chemistry`` field on uploaded custom models.
#: Mirrors the ``ModelChemistry`` Literal on the backend.
ModelChemistry = Literal["lithium_ion", "lithium_sulfur", "ecm", "generic"]


[docs] class ZooImport(NamedTuple): """The records :meth:`ModelClient.create_from_zoo` creates.""" model: Model parameterized_model: ParameterizedModel
[docs] class ModelClient: """Client for managing custom battery models. Provides methods to create, read, update, and delete custom models within an organization. Also supports adding custom variables to models. """ _BASE = "/models"
[docs] def __init__(self, client: Any) -> None: """Initialize the ModelClient. Parameters ---------- client : Any The HTTP client instance for making API requests. """ self.client = client
[docs] def get(self, model_id: str) -> Model: """Get a specific model by ID, including its config. Parameters ---------- model_id : str The ID of the model to retrieve. Returns ------- Model The requested model object (includes ``config`` field). """ endpoint = f"{self._BASE}/{model_id}" response_data = self.client.get(endpoint) return Model(**response_data)
[docs] def get_simulation_settings(self, model_id: str) -> dict[str, Any]: """Return a model's persisted simulation settings, ready to fold into a fit. Fits do not apply a model's saved mesh and solver automatically, as simulations do; merge these into the objective's ``options["simulation_kwargs"]``. For a parameterized model, read ``client.parameterized_model.get(pm_id).simulation_settings``, which takes precedence over the base model's. Parameters ---------- model_id : str The ID of the (base) model. Returns ------- dict[str, Any] The flat ``simulation_settings`` bag (``var_pts`` / ``submesh_types`` / ``spatial_methods`` / ``solver``), or an empty dict if the model has none persisted. Examples -------- >>> sim_kwargs = client.model.get_simulation_settings(model_id) # doctest: +SKIP >>> objective = iw.pipeline.objectives.CurrentDriven( ... data_input="…", ... options={"model": {"type": "SPMe"}, "simulation_kwargs": sim_kwargs}, ... ) # doctest: +SKIP """ return self.get(model_id).simulation_settings or {}
[docs] def list( self, limit: int | None = None, offset: int | None = None, *, project_id: str | None = None, name: str | None = None, name_exact: str | None = None, created_by_email: str | None = None, created_after: str | None = None, created_before: str | None = None, updated_after: str | None = None, updated_before: str | None = None, order_by: str | None = None, order: str | None = None, ) -> PaginatedList[Model]: """List a project's models with optional filtering. Parameters ---------- limit : int | None, optional Maximum number of models to return per page. offset : int | None, optional Number of models to skip for pagination. project_id : str | None, optional Project whose models to list. Defaults to the client's project (``IONWORKS_PROJECT_ID``). name : str | None, optional Case-insensitive substring match on model name. name_exact : str | None, optional Exact match on model name. created_by_email : str | None, optional Case-insensitive substring match on the creator's email. created_after : str | None, optional ISO datetime; return models created after this time. created_before : str | None, optional ISO datetime; return models created before this time. updated_after : str | None, optional ISO datetime; return models updated after this time. updated_before : str | None, optional ISO datetime; return models updated before this time. order_by : str | None, optional Column to sort by. order : str | None, optional Sort direction: ``"asc"`` or ``"desc"``. Returns ------- PaginatedList[Model] A list of model objects. Raises ------ ValueError If no ``project_id`` is given and the client has no default. """ project_id = resolve_project_id(self.client, project_id) filter_params = _build_filter_params( name=name, name_exact=name_exact, created_by_email=created_by_email, created_after=created_after, created_before=created_before, updated_after=updated_after, updated_before=updated_before, order_by=order_by, order=order, ) endpoint = _build_endpoint( self._BASE, { "project_id": project_id, "limit": limit, "offset": offset, **filter_params, }, ) response_data = self.client.get(endpoint) return _parse_list_response(response_data, Model)
[docs] def create(self, data: dict[str, Any] | None = None) -> Model: """Create a new model in a project. Parameters ---------- data : dict[str, Any] Dictionary containing the model data. Required fields: ``name``, ``config``. Optional fields: ``project_id`` (defaults to the client's project), ``description``, ``pybamm_version``, ``simulation_settings``. ``config`` may be a ``{"type": ...}`` dict or a built-in ``pybamm.BaseModel`` instance (e.g. ``pybamm.lithium_ion.SPMe()``), which is serialized for you the same way ``ionworks.pipeline`` accepts a pybamm model as a fit's model. Custom pybamm models go through :meth:`upload_custom` instead. ``simulation_settings`` is a persistent bag of pybamm simulation kwargs (``var_pts`` / ``submesh_types`` / ``spatial_methods`` / ``solver``) re-applied whenever the model is simulated. Build it from live pybamm objects with the schema wrapper:: import ionworks as iw import pybamm settings = iw.pipeline.models.SimulationSettings( var_pts={"r_n": 16, "r_p": 16}, submesh_types={ "negative particle": pybamm.MeshGenerator( pybamm.Exponential1DSubMesh, {"side": "right"} ), }, ) client.model.create({"name": "SPMe", "config": {"type": "SPMe"}, "simulation_settings": settings}) The ``SimulationSettings`` object is serialized for you; an already-serialized config ``dict`` (``settings.to_config()``) is equally accepted. Returns ------- Model The newly created model object. Raises ------ ValueError If ``data`` has no ``project_id`` and the client has no default. """ endpoint = self._BASE # Copied so filling in the default project never mutates the caller's dict. payload = {**normalize_model_config(normalize_simulation_settings(data))} payload["project_id"] = resolve_project_id( self.client, payload.get("project_id") ) response_data = self.client.post(endpoint, payload) return Model(**response_data)
[docs] def upload_custom( self, model: Any, *, name: str, chemistry: ModelChemistry = "lithium_ion", description: str | None = None, project_id: str | None = None, ) -> Model: """Upload a custom PyBaMM model into a project. Serialises a ``pybamm.BaseModel`` subclass instance (or accepts an already-serialised JSON file) and POSTs it to ``/models/upload-custom`` as multipart form data. Parameters ---------- model : pybamm.BaseModel | str | os.PathLike | IO[bytes] The model to upload. Either a ``pybamm.BaseModel`` instance (will be serialised via ``Serialise().save_custom_model``), a path to an existing serialised JSON file, or an open binary file object positioned at the start of the JSON content. name : str Display name for the uploaded model. chemistry : ModelChemistry, optional Chemistry tag controlling which simulation-pipeline path the model goes through. Defaults to ``"lithium_ion"``. Use ``"lithium_sulfur"`` for Li-S models, ``"ecm"`` for custom ECM models, or ``"generic"`` to opt out of all chemistry-specific enrichment. description : str | None, optional Optional human-readable description. project_id : str | None, optional Project to upload into. Defaults to the client's project (``IONWORKS_PROJECT_ID``). Returns ------- Model The created model record (``is_custom_model=True``). Raises ------ IonworksError On any HTTP error from the upload endpoint. ValueError If no ``project_id`` is given and the client has no default. Notes ----- ``Serialise().serialise_custom_model(model)`` returns a dict that contains ``EventType`` enums which aren't JSON-serialisable. When a ``pybamm.BaseModel`` is passed in, this method routes through ``save_custom_model(filename=...)`` (which handles the enum conversion) via a temp file that is unlinked after upload. """ data = { "name": name, "chemistry": chemistry, "project_id": resolve_project_id(self.client, project_id), } if description is not None: data["description"] = description cleanup_path: str | None = None file_handle: IO[bytes] upload_filename = "model.json" if hasattr(model, "rhs") and hasattr(model, "variables"): # Looks like a pybamm.BaseModel — serialise via temp file. from pybamm.expression_tree.operations.serialise import Serialise tmp = tempfile.NamedTemporaryFile(suffix=".json", delete=False) tmp.close() cleanup_path = tmp.name Serialise().save_custom_model(model, filename=cleanup_path) file_handle = open(cleanup_path, "rb") upload_filename = f"{getattr(model, 'name', 'model') or 'model'}.json" elif isinstance(model, str | os.PathLike): file_handle = open(model, "rb") upload_filename = os.path.basename(os.fspath(model)) elif isinstance(model, io.IOBase) or hasattr(model, "read"): file_handle = model # already-open file-like else: raise TypeError( f"Unsupported model type {type(model).__name__!r}: expected a " "pybamm.BaseModel, a filesystem path, or an open binary file." ) try: response = self.client.upload_multipart( f"{self._BASE}/upload-custom", data=data, files={"file": (upload_filename, file_handle, "application/json")}, ) finally: if cleanup_path is not None: try: file_handle.close() except Exception: pass try: os.unlink(cleanup_path) except OSError: pass if not isinstance(response, dict): raise IonworksError( "Unexpected non-JSON response from /models/upload-custom" ) return Model(**response)
[docs] def create_from_zoo( self, name: str, *, cell_spec_id: str | None = None, options: dict[str, Any] | None = None, model_kwargs: dict[str, Any] | None = None, parameter_values: Any = None, model_name: str | None = None, parameterized_model_name: str | None = None, description: str | None = None, chemistry: ModelChemistry = "lithium_ion", project_id: str | None = None, ) -> ZooImport: """Create a model and a parameterized model from the PyBaMM model zoo. Builds the zoo model locally, uploads it as a custom model (see :meth:`upload_custom`), then creates a parameterized model for it, under ``cell_spec_id`` or in the project. The zoo is not a dependency of this package; install it with ``pip install pybamm-model-zoo``, plus the model's ``zoo-<slug>`` extra if it has one. Under a cell specification, the backend overrides the parameterized model's nominal capacity, voltage cut-offs, and current with the specification's values. Parameters ---------- name : str Registered zoo model name, one of ``pybamm_model_zoo.list_models()`` (e.g. ``"LinearisedSPM"``). cell_spec_id : str | None, optional Cell specification the parameterized model belongs to, in the same project as the model. Defaults to None: the parameterized model has no cell specification and keeps the parameter set's capacity and cut-offs. options : dict[str, Any] | None, optional Model options passed to the zoo model's constructor. model_kwargs : dict[str, Any] | None, optional Other constructor arguments for zoo models that take them, e.g. ``{"num_physical_layers": 4}`` for ``MultiLayer3DThermalSPM``. See ``help(pybamm_model_zoo.load(name))`` for a model's arguments. parameter_values : pybamm.ParameterValues | str | dict | None, optional Parameters for the parameterized model: a ``pybamm.ParameterValues``, the name of a pybamm parameter set (e.g. ``"Chen2020"``), or an already-serialized parameters dict. Defaults to the zoo model's ``default_parameter_values``. A named set or ``ParameterValues`` is passed through the model's ``apply_stack_scaling``, for zoo models that have one, to add the model's own parameters; a dict is sent as is. model_name : str | None, optional Display name for the model. Defaults to the zoo model's name. parameterized_model_name : str | None, optional Display name for the parameterized model. Defaults to ``model_name``. description : str | None, optional Description for both records. Defaults to the zoo entry's summary. chemistry : ModelChemistry, optional Chemistry tag for the uploaded model, as for :meth:`upload_custom`. Defaults to ``"lithium_ion"``. project_id : str | None, optional Project to create the model in. Defaults to the client's project (``IONWORKS_PROJECT_ID``). Returns ------- ZooImport The created ``model`` and ``parameterized_model``. Raises ------ ImportError If ``pybamm-model-zoo`` is not installed. KeyError If ``name`` is not a registered zoo model. pybamm_model_zoo.ModelUnavailableError If the model's ``zoo-<slug>`` extra is not installed. pybamm_model_zoo.ManifestError If the zoo entry's manifest is malformed. ValueError If no ``project_id`` is given and the client has no default. IonworksError If either record cannot be created. When the server rejects the parameterized model (a 4xx), the uploaded model is deleted again, so a retry does not duplicate it. On any other failure the outcome is unknown, so the model is kept and the error notes its id. Examples -------- >>> zoo = client.model.create_from_zoo( ... "LinearisedSPM", cell_spec_id=spec.id, parameter_values="Chen2020" ... ) # doctest: +SKIP """ try: import pybamm_model_zoo except ImportError as e: raise ImportError( "create_from_zoo needs the PyBaMM model zoo: " "pip install pybamm-model-zoo" ) from e # Fail on a missing project before the slow model build, not after it. project_id = resolve_project_id(self.client, project_id) entry = pybamm_model_zoo.info(name) pybamm_model = entry.load()(options=options, **(model_kwargs or {})) if parameter_values is None: parameter_values = pybamm_model.default_parameter_values elif not isinstance(parameter_values, dict): import pybamm if isinstance(parameter_values, str): parameter_values = pybamm.ParameterValues(parameter_values) # A stock set lacks the parameters some zoo models add of their own. prepare = getattr(pybamm_model, "apply_stack_scaling", None) if prepare is not None: parameter_values = prepare(parameter_values.copy()) # Serialized before the upload, so a failure here leaves nothing behind. parameter_values = normalize_parameters({"parameters": parameter_values})[ "parameters" ] model_name = model_name or entry.name description = description if description is not None else entry.summary model = self.upload_custom( pybamm_model, name=model_name, chemistry=chemistry, description=description, project_id=project_id, ) try: parameterized_model = self.client.parameterized_model.create( cell_spec_id, { "name": parameterized_model_name or model_name, "model_id": model.id, "description": description, "parameters": parameter_values, "project_id": project_id, }, ) except IonworksError as e: # After a timeout or 5xx the PM may exist; deleting would cascade to it. if e.status_code is not None and 400 <= e.status_code < 500: self.delete(model.id) else: e.add_note( f"Model '{model_name}' was uploaded as {model.id}; the " "parameterized model may or may not have been created." ) raise return ZooImport(model=model, parameterized_model=parameterized_model)
[docs] def serialize( self, name: str, *, options: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build an ionworks model server-side and return its JSON. Asks the API to construct an ionworks model (e.g. ``"ECM"``, ``"LumpedSPMR"``, the MSMR models, or ``"GITTModel"``) and serialise it to a pybamm ``Serialise`` document. This is the raw counterpart to :meth:`download`: it returns the serialized dict without loading it, which is handy for saving to disk or re-uploading via :meth:`upload_custom`. The server builds the model — defined in the licensed ``ionworkspipeline`` package — so the caller does **not** need an ``ionworkspipeline`` license. Only ionworks models are served; standard pybamm models (``"SPM"``, ``"DFN"``, ...) are already usable directly with ``pybamm``. Parameters ---------- name : str Ionworks model class name, e.g. ``"ECM"``, ``"LumpedSPMR"``, ``"MSMRFullCellModel"``, ``"GITTModel"``. One of the names listed under ``"ionworks_models"`` by ``client.pybamm_models()``. options : dict[str, Any] | None, optional Options dict passed to the model constructor. Returns ------- dict[str, Any] The serialized pybamm model document. Raises ------ IonworksError If the model isn't an ionworks model, can't be built (e.g. invalid options), or the response isn't JSON. """ body = { "name": name, "options": options or {}, } response_data = self.client.post("/discovery/ionworks_models/serialize", body) if not isinstance(response_data, dict): raise IonworksError( "Unexpected non-JSON response from /discovery/ionworks_models/serialize" ) return response_data
[docs] def download( self, name: str, *, options: dict[str, Any] | None = None, path: str | os.PathLike[str] | None = None, ) -> Any: """Download an ionworks model as a ready-to-use pybamm model. Fetches the serialized model from the API and loads it locally with ``pybamm`` — so models defined in the licensed ``ionworkspipeline`` package (``ECM``, ``LumpedSPMR``, the MSMR models, ``GITTModel``, ...) can be used with only ``pybamm`` installed, no ``ionworkspipeline`` license required. Parameters ---------- name : str Ionworks model class name, e.g. ``"ECM"``, ``"LumpedSPMR"``, ``"MSMRFullCellModel"``, ``"GITTModel"``. One of the names listed under ``"ionworks_models"`` by ``client.pybamm_models()``. options : dict[str, Any] | None, optional Options dict passed to the model constructor. path : str | os.PathLike[str] | None, optional If given, also write the serialized model JSON to this path. The file can later be re-uploaded with :meth:`upload_custom`. Returns ------- pybamm.BaseModel The deserialized model, ready to pass to ``pybamm.Simulation``. Notes ----- Serialization captures the model's mathematical structure (rhs, algebraic, variables, events, initial conditions) but not Python helper methods such as ``set_initial_state`` or classmethods. When ``path`` is given, the written file may contain bare ``Infinity``/``NaN`` tokens (pybamm uses infinite bounds and event thresholds). Python's ``json`` and ``Serialise.load_custom_model`` read these fine, but they are not strictly valid JSON — strict parsers (JS ``JSON.parse``, ``jq``, ...) will reject the file. """ model_dict = self.serialize(name, options=options) from pybamm.expression_tree.operations.serialise import Serialise # Load first so a failed round-trip (e.g. a server/client pybamm # version mismatch) doesn't leave an unloadable file at ``path``. model = Serialise.load_custom_model(model_dict) if path is not None: with open(path, "w", encoding="utf-8") as fh: json.dump(model_dict, fh) return model
[docs] def update( self, model_id: str, data: dict[str, Any] | None = None, ) -> Model: """Update an existing model. Parameters ---------- model_id : str The ID of the model to update. data : dict[str, Any] Dictionary containing the fields to update. Supports ``name``, ``description``, ``pybamm_version``, and ``simulation_settings`` (a ``SimulationSettings`` object or its config dict; send ``null`` to clear the persisted settings). Returns ------- Model The updated model object. """ endpoint = f"{self._BASE}/{model_id}" payload = normalize_model_config(normalize_simulation_settings(data)) response_data = self.client.patch(endpoint, payload) return Model(**response_data)
[docs] def delete(self, model_id: str) -> None: """Delete a model by ID. Parameters ---------- model_id : str The ID of the model to delete. """ endpoint = f"{self._BASE}/{model_id}" self.client.delete(endpoint)
[docs] def add_custom_variable( self, model_id: str, data: dict[str, Any] | None = None, ) -> Model: """Add a custom variable to a model. Parameters ---------- model_id : str The ID of the model to add the custom variable to. data : dict[str, Any] Dictionary containing the custom variable data. Required fields: ``name``, ``expression``. Returns ------- Model The updated model object (includes config with the new variable). """ endpoint = f"{self._BASE}/{model_id}/custom-variables" response_data = self.client.post(endpoint, data) return Model(**response_data)